diff --git a/AGENTS.md b/AGENTS.md index 16d978c..249109e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/README.md b/README.md index 1a5b67b..dc4da04 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/narrative.md b/docs/narrative.md index 9d1112a..3179aef 100644 --- a/docs/narrative.md +++ b/docs/narrative.md @@ -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. diff --git a/public/og.png b/public/og.png index a41d8b1..ae6baf0 100644 Binary files a/public/og.png and b/public/og.png differ diff --git a/src/connectors/feishu/api.ts b/src/connectors/feishu/api.ts index 5689b5b..aa7572c 100644 --- a/src/connectors/feishu/api.ts +++ b/src/connectors/feishu/api.ts @@ -181,7 +181,7 @@ function mentionsOf(raw: unknown): string[] { export async function history( credential: FeishuCredential, chatId: string, - oldestMs?: string, + oldestMs: string, ): Promise { const parsed: FeishuMessage[] = []; let pageToken = ""; @@ -189,10 +189,10 @@ export async function history( const query: Record = { 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, @@ -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)); } diff --git a/src/connectors/feishu/runtime.test.ts b/src/connectors/feishu/runtime.test.ts index 2d1af8a..0e15f4f 100644 --- a/src/connectors/feishu/runtime.test.ts +++ b/src/connectors/feishu/runtime.test.ts @@ -110,20 +110,33 @@ 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" }], }, ], @@ -131,8 +144,8 @@ describe("feishu poll", () => { }); 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 () => { @@ -183,6 +196,7 @@ describe("feishu poll", () => { user: "ou_maya", text: "@_user_1 checkout 5xx", }, + at: 1700000002000, }, ]); expect(cursor).toEqual({ oc_eng: "1700000003000" }); diff --git a/src/connectors/feishu/runtime.ts b/src/connectors/feishu/runtime.ts index 2023080..b48c5fd 100644 --- a/src/connectors/feishu/runtime.ts +++ b/src/connectors/feishu/runtime.ts @@ -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}.`); } @@ -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, @@ -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), }); } } diff --git a/src/connectors/schedule/runtime.test.ts b/src/connectors/schedule/runtime.test.ts index 8cbbe47..c5c53d6 100644 --- a/src/connectors/schedule/runtime.test.ts +++ b/src/connectors/schedule/runtime.test.ts @@ -17,7 +17,7 @@ function clockAt(text: string): void { } function look(settings: Record, 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(() => { diff --git a/src/connectors/slack/api.ts b/src/connectors/slack/api.ts index aa96ae4..e144e45 100644 --- a/src/connectors/slack/api.ts +++ b/src/connectors/slack/api.ts @@ -109,14 +109,14 @@ export async function listConversations(credential: SlackCredential): Promise { const query: Record = { 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 []; diff --git a/src/connectors/slack/runtime.test.ts b/src/connectors/slack/runtime.test.ts index ad45ec1..faa7211 100644 --- a/src/connectors/slack/runtime.test.ts +++ b/src/connectors/slack/runtime.test.ts @@ -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 () => { @@ -134,6 +138,7 @@ describe("slack poll", () => { text: "<@Ubot> checkout 5xx", threadTs: "101.0", }, + at: 101_000, }, { key: "Dmaya:103.0", @@ -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" }); diff --git a/src/connectors/slack/runtime.ts b/src/connectors/slack/runtime.ts index b26f929..fb3cfb8 100644 --- a/src/connectors/slack/runtime.ts +++ b/src/connectors/slack/runtime.ts @@ -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}.`); } @@ -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({ @@ -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), }); } } diff --git a/src/connectors/types.ts b/src/connectors/types.ts index a55d568..6ffd5f3 100644 --- a/src/connectors/types.ts +++ b/src/connectors/types.ts @@ -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 = { @@ -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: { diff --git a/src/connectors/wechat/runtime.test.ts b/src/connectors/wechat/runtime.test.ts index e77cb7e..98fec14 100644 --- a/src/connectors/wechat/runtime.test.ts +++ b/src/connectors/wechat/runtime.test.ts @@ -43,6 +43,7 @@ const poll = (settings: Record = {}, cursor: string | null = nu settings, credential: account, cursor, + since: 0, }); afterEach(() => vi.unstubAllGlobals()); diff --git a/src/landing/landing.tsx b/src/landing/landing.tsx index 1e69878..70797e9 100644 --- a/src/landing/landing.tsx +++ b/src/landing/landing.tsx @@ -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"; @@ -150,7 +150,7 @@ function Hero({ signedIn, next }: { signedIn: boolean; next?: string }) {

- Prompt it when you’re here. + Prompt it when you’re there. Loop it when you’re not.

{DESCRIPTION}

diff --git a/src/server/signals.test.ts b/src/server/signals.test.ts index 7d51c39..8c15fd7 100644 --- a/src/server/signals.test.ts +++ b/src/server/signals.test.ts @@ -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)]; diff --git a/src/server/signals.ts b/src/server/signals.ts index 8458eca..3ff7ba3 100644 --- a/src/server/signals.ts +++ b/src/server/signals.ts @@ -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): Promise { const report: PollReport = { @@ -85,6 +86,7 @@ export async function pollLoop(loop: Loop, claimed: Set): Promise): Promise