Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Loopable

Prompt it when you're here; loop it when you're not. Loopable turns the work that keeps coming back into agent workflows, set up in the browser at loop.goodone.si. They run on the user's own machine with Codex or Claude Code, and answer where they were asked.
Prompt it when you're there; loop it when you're not. Loopable turns the work that keeps coming back into agent workflows, set up in the browser at loop.goodone.si. They run on the user's own machine with Codex or Claude Code, and answer where they were asked.

Work comes in from GitHub, Slack, Lark, email, or the clock. A loop says what to do. Codex, Claude Code, or Cursor Agent does it on a runner on the user's machine, and the answer is written back where it was asked. Every run is recorded. The story is in `docs/narrative.md`.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Loopable

Prompt it when you're here. Loop it when you're not.
Prompt it when you're there. Loop it when you're not.

Loopable turns the work that keeps coming back into agent workflows, in minutes. Set them up at [loop.goodone.si](https://loop.goodone.si); they run on your machine with Codex or Claude Code, and answer where they were asked.

Expand Down
2 changes: 1 addition & 1 deletion docs/narrative.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Narrative

Prompt it when you're here. Loop it when you're not.
Prompt it when you're there. Loop it when you're not.

Loopable turns the work that keeps coming back into agent workflows, in minutes. You set them up in the browser at loop.goodone.si; they run on your machine with Codex or Claude Code, and answer where they were asked.

Expand Down
Binary file modified public/og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 5 additions & 6 deletions src/connectors/feishu/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,18 +181,18 @@ function mentionsOf(raw: unknown): string[] {
export async function history(
credential: FeishuCredential,
chatId: string,
oldestMs?: string,
oldestMs: string,
): Promise<FeishuMessage[]> {
const parsed: FeishuMessage[] = [];
let pageToken = "";
do {
const query: Record<string, string> = {
container_id_type: "chat",
container_id: chatId,
sort_type: oldestMs ? "ByCreateTimeAsc" : "ByCreateTimeDesc",
page_size: oldestMs ? "50" : "1",
sort_type: "ByCreateTimeAsc",
page_size: "50",
start_time: String(Math.floor(Number(oldestMs) / 1000)),
};
if (oldestMs) query.start_time = String(Math.floor(Number(oldestMs) / 1000));
if (pageToken) query.page_token = pageToken;
const body = await call(credential.host, "/im/v1/messages", {
token: credential.tenantAccessToken,
Expand Down Expand Up @@ -227,8 +227,7 @@ export async function history(
mentionIds: mentionsOf(message.mentions),
});
}
pageToken =
oldestMs && data.has_more === true && typeof data.page_token === "string" ? data.page_token : "";
pageToken = data.has_more === true && typeof data.page_token === "string" ? data.page_token : "";
} while (pageToken);
return parsed.sort((a, b) => Number(a.createTime) - Number(b.createTime));
}
Expand Down
26 changes: 20 additions & 6 deletions src/connectors/feishu/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,29 +110,42 @@ describe("feishu poll", () => {
settings: {},
credential: account,
cursor: cursor as never,
since: 1690000000000,
});

it("records where each group is and does not replay old messages on the first look", async () => {
it("reads a group it has not seen from when the loop last looked", async () => {
givenFeishu({
chats: [{ chat_id: "oc_eng", name: "eng" }],
chats: [
{ chat_id: "oc_eng", name: "eng" },
{ chat_id: "oc_quiet", name: "quiet" },
],
history: {
oc_eng: [
{
message_id: "om_old",
message_id: "om_before",
create_time: "1690000000000",
chat_id: "oc_eng",
msg_type: "text",
sender: { id: "ou_maya", sender_type: "user" },
body: { content: "{\"text\":\"@_user_1 old\"}" },
body: { content: "{\"text\":\"@_user_1 before the loop\"}" },
mentions: [{ id: "ou_bot" }],
},
{
message_id: "om_ask",
create_time: "1690000030000",
chat_id: "oc_eng",
msg_type: "text",
sender: { id: "ou_maya", sender_type: "user" },
body: { content: "{\"text\":\"@_user_1 sent before the first look\"}" },
mentions: [{ id: "ou_bot" }],
},
],
},
});

const { signals, cursor } = await poll(null);
expect(signals).toEqual([]);
expect(cursor).toEqual({ oc_eng: "1690000000000" });
expect(signals.map((signal) => [signal.key, signal.at])).toEqual([["oc_eng:om_ask", 1690000030000]]);
expect(cursor).toEqual({ oc_eng: "1690000030000", oc_quiet: "1690000000000" });
});

it("keeps a group mention of the bot and skips chat and its own replies", async () => {
Expand Down Expand Up @@ -183,6 +196,7 @@ describe("feishu poll", () => {
user: "ou_maya",
text: "@_user_1 checkout 5xx",
},
at: 1700000002000,
},
]);
expect(cursor).toEqual({ oc_eng: "1700000003000" });
Expand Down
12 changes: 7 additions & 5 deletions src/connectors/feishu/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export const feishuRuntime: ConnectorRuntime = {
return { ready: true };
},

async poll({ workflowId, credential, cursor }) {
async poll({ workflowId, credential, cursor, since }) {
if (workflowId !== "feishu.ask") {
throw new Error(`Feishu cannot watch for ${workflowId}.`);
}
Expand All @@ -116,16 +116,17 @@ export const feishuRuntime: ConnectorRuntime = {
const signals: Signal[] = [];

for (const chat of chats) {
const oldest = previous[chat.id];
// A group seen for the first time is read from when the loop last
// looked, so a mention sent before this look is not taken as the start.
const oldest = previous[chat.id] ?? String(since);
const messages = await history(account, chat.id, oldest);
if (messages.length === 0) {
if (!next[chat.id]) next[chat.id] = String(Date.now());
next[chat.id] = oldest;
continue;
}
next[chat.id] = messages[messages.length - 1]!.createTime;
if (!oldest) continue;
for (const message of messages) {
if (oldest && Number(message.createTime) <= Number(oldest)) continue;
if (Number(message.createTime) <= Number(oldest)) continue;
if (
!isAsk({
senderId: message.senderId,
Expand All @@ -149,6 +150,7 @@ export const feishuRuntime: ConnectorRuntime = {
title: summarise(message.text),
url: permalink(account.host, message.chatId),
payload: asked as unknown as JsonValue,
at: Number(message.createTime),
});
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/connectors/schedule/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function clockAt(text: string): void {
}

function look(settings: Record<string, unknown>, cursor: string | null) {
return scheduleRuntime.poll!({ workflowId: "schedule.recurring", settings, credential: null, cursor });
return scheduleRuntime.poll!({ workflowId: "schedule.recurring", settings, credential: null, cursor, since: 0 });
}

afterEach(() => {
Expand Down
4 changes: 2 additions & 2 deletions src/connectors/slack/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,14 @@ export async function listConversations(credential: SlackCredential): Promise<Sl
export async function history(
credential: SlackCredential,
channelId: string,
oldest?: string,
oldest: string,
): Promise<SlackMessage[]> {
const query: Record<string, string> = {
channel: channelId,
limit: "100",
inclusive: "false",
oldest,
};
if (oldest) query.oldest = oldest;
const body = await slack("conversations.history", credential.botToken, { query });
if (!body.ok) {
if (body.error === "not_in_channel" || body.error === "channel_not_found") return [];
Expand Down
18 changes: 12 additions & 6 deletions src/connectors/slack/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,23 +84,27 @@ describe("slack poll", () => {
settings: {},
credential: account,
cursor: cursor as never,
since: 95_000,
});

it("records where each channel is and does not replay old messages on the first look", async () => {
givenSlack({
it("reads a channel it has not seen from when the loop last looked", async () => {
const sent = givenSlack({
channels: [
{ id: "Ceng", name: "eng" },
{ id: "Dmaya", is_im: true },
],
history: {
Ceng: [{ ts: "90.0", user: "Umaya", text: "<@Ubot> old mention" }],
Dmaya: [{ ts: "91.0", user: "Umaya", text: "old dm" }],
Ceng: [{ ts: "96.0", user: "Umaya", text: "<@Ubot> sent before the first look" }],
},
});

const { signals, cursor } = await poll(null);
expect(signals).toEqual([]);
expect(cursor).toEqual({ Ceng: "90.0", Dmaya: "91.0" });
expect(signals.map((signal) => [signal.key, signal.at])).toEqual([["Ceng:96.0", 96_000]]);
expect(cursor).toEqual({ Ceng: "96.0", Dmaya: "95" });
const asked = sent
.filter((call) => call.url.includes("conversations.history"))
.map((call) => new URL(call.url).searchParams.get("oldest"));
expect(asked).toEqual(["95", "95"]);
});

it("keeps a channel mention of the bot and a DM, and skips its own replies", async () => {
Expand Down Expand Up @@ -134,6 +138,7 @@ describe("slack poll", () => {
text: "<@Ubot> checkout 5xx",
threadTs: "101.0",
},
at: 101_000,
},
{
key: "Dmaya:103.0",
Expand All @@ -148,6 +153,7 @@ describe("slack poll", () => {
text: "what broke checkout",
threadTs: "103.0",
},
at: 103_000,
},
]);
expect(cursor).toEqual({ Ceng: "102.0", Dmaya: "103.0" });
Expand Down
10 changes: 6 additions & 4 deletions src/connectors/slack/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const slackRuntime: ConnectorRuntime = {
return { ready: true };
},

async poll({ workflowId, credential, cursor }) {
async poll({ workflowId, credential, cursor, since }) {
if (workflowId !== "slack.ask") {
throw new Error(`Slack cannot watch for ${workflowId}.`);
}
Expand All @@ -104,14 +104,15 @@ export const slackRuntime: ConnectorRuntime = {
const signals: Signal[] = [];

for (const channel of channels) {
const oldest = previous[channel.id];
// A channel seen for the first time is read from when the loop last
// looked, so a mention sent before this look is not taken as the start.
const oldest = previous[channel.id] ?? String(since / 1000);
const messages = await history(account, channel.id, oldest);
if (messages.length === 0) {
if (!next[channel.id]) next[channel.id] = String(Date.now() / 1000);
next[channel.id] = oldest;
continue;
}
next[channel.id] = messages[messages.length - 1]!.ts;
if (!oldest) continue;
for (const message of messages) {
if (
!isAsk({
Expand Down Expand Up @@ -139,6 +140,7 @@ export const slackRuntime: ConnectorRuntime = {
title: summarise(message.text),
url: permalink(account, channel.id, message.ts),
payload: asked as unknown as JsonValue,
at: Math.round(Number(message.ts) * 1000),
});
}
}
Expand Down
13 changes: 13 additions & 0 deletions src/connectors/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,12 @@ export type Signal = WorkItemRef & {
* kept here or it is lost between noticing it and acting on it.
*/
payload?: JsonValue;
/**
* When it happened, in milliseconds, for sources that know. A loop's first
* look sends what it finds to the backlog, but a message sent after the
* loop was made is not backlog: it was sent to the loop.
*/
at?: number;
};

export type ConnectorRuntime = {
Expand Down Expand Up @@ -458,6 +464,13 @@ export type ConnectorRuntime = {
* between them instead of each seeing all of them.
*/
cursor: JsonValue | null;
/**
* When this loop last looked, or when it was made if it never has, in
* milliseconds. A stream with nothing in the cursor for a place reads it
* from here: taking the newest message as the starting point would skip
* the very message that was sent to the loop before its first look.
*/
since: number;
}): Promise<{ signals: Signal[]; cursor?: JsonValue }>;
/** Carry out one of the manifest's actions. This is the part that writes. */
applyAction?(input: {
Expand Down
1 change: 1 addition & 0 deletions src/connectors/wechat/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const poll = (settings: Record<string, unknown> = {}, cursor: string | null = nu
settings,
credential: account,
cursor,
since: 0,
});

afterEach(() => vi.unstubAllGlobals());
Expand Down
4 changes: 2 additions & 2 deletions src/landing/landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
import { HeroFlow } from "./hero-flow";
import { LoopFooter } from "./loop-footer";

const TITLE = "Loopable — prompt it when you're here, loop it when you're not";
const TITLE = "Loopable — prompt it when you're there, loop it when you're not";
const DESCRIPTION =
"Loopable turns the work that keeps coming back into agent workflows, in minutes. They run on your machine with Codex or Claude Code, and answer where they were asked.";
const ORIGIN = "https://loop.goodone.si";
Expand Down Expand Up @@ -150,7 +150,7 @@ function Hero({ signedIn, next }: { signedIn: boolean; next?: string }) {
<section className="mx-auto grid min-h-[calc(100svh-3.5rem)] max-w-6xl items-center gap-12 px-6 py-16 md:grid-cols-[1.25fr_1fr]">
<div>
<h1 className="text-4xl font-semibold tracking-tight text-balance lg:text-[2.75rem] lg:leading-tight">
<span className="block">Prompt it when you&rsquo;re here.</span>
<span className="block">Prompt it when you&rsquo;re there.</span>
<span className="block">Loop it when you&rsquo;re not.</span>
</h1>
<p className="mt-5 max-w-xl text-lg text-muted-foreground text-pretty">{DESCRIPTION}</p>
Expand Down
13 changes: 13 additions & 0 deletions src/server/signals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,19 @@ describe("the first look", () => {
expect((await signalsFor("a")).map((row) => row.outcome)).toEqual(["backlog", "backlog"]);
});

it("runs what was sent after the loop was made, even before it first looked", async () => {
await givenLoop("a", 1);
const made = (await db().select().from(loops).where(eq(loops.id, "a")).get())!.createdAt.getTime();
answer = [
{ ...pull(1), at: made - 60_000 },
{ ...pull(2), at: made + 30_000 },
];

const [report] = await pollAllLoops();
expect(report).toMatchObject({ found: 2, queued: 1, backlog: 1 });
expect((await tasksFor("a")).map((task) => task.sourceRef)).toEqual(["acme/web#2"]);
});

it("acts on everything that turns up after it", async () => {
await givenLoop("a", 1);
answer = [pull(1)];
Expand Down
15 changes: 9 additions & 6 deletions src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,12 @@ function claimOf(loop: Loop, signal: Signal): string {
/**
* One look at what a loop is watching for.
*
* The first look never acts. Whatever is already waiting when a loop is
* created is that loop's backlog, and running an agent over a review queue
* that has been piling up for a month is not what someone turning on a loop
* is asking for. It is recorded rather than discarded, so it can still be run
* on purpose afterwards.
* The first look does not act on what was there before the loop. Whatever is
* already waiting when a loop is created is that loop's backlog, and running
* an agent over a review queue that has been piling up for a month is not
* what someone turning on a loop is asking for. It is recorded rather than
* discarded, so it can still be run on purpose afterwards. A message sent
* after the loop was made, before its first look, is acted on.
*/
export async function pollLoop(loop: Loop, claimed: Set<string>): Promise<PollReport> {
const report: PollReport = {
Expand Down Expand Up @@ -85,6 +86,7 @@ export async function pollLoop(loop: Loop, claimed: Set<string>): Promise<PollRe
settings: loop.settings,
credential,
cursor: loop.pollCursor ?? null,
since: (loop.polledAt ?? loop.createdAt).getTime(),
});
const found = answer.signals;
report.found = found.length;
Expand Down Expand Up @@ -131,11 +133,12 @@ export async function pollLoop(loop: Loop, claimed: Set<string>): Promise<PollRe
// first look: the same thing sitting in two loops' lists would be run
// twice by whoever works through them.
const hold = signal.hold ?? judged.get(signal.key);
const before = signal.at === undefined || signal.at < loop.createdAt.getTime();
const outcome = claimed.has(claim)
? "superseded"
: hold
? "held"
: first
: first && before
? "backlog"
: "queued";
const task = outcome === "queued" ? await enqueueSignal({ loop, signal }) : null;
Expand Down
Loading