@timqi/pier 0.1.14 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +44 -22
  2. package/dist/agent/config.js +2 -2
  3. package/dist/agent/events.js +32 -3
  4. package/dist/agent/listing.js +14 -10
  5. package/dist/agent/packages.js +6 -10
  6. package/dist/agent/pi.js +113 -70
  7. package/dist/agent/roles.js +104 -0
  8. package/dist/agent/types.js +78 -0
  9. package/dist/boards/boards.js +42 -32
  10. package/dist/channels/chains.js +0 -4
  11. package/dist/channels/commands.js +7 -12
  12. package/dist/channels/config.js +27 -1
  13. package/dist/channels/control.js +5 -16
  14. package/dist/channels/conversations.js +4 -9
  15. package/dist/channels/lark-api.js +20 -26
  16. package/dist/channels/lark-outbound.js +21 -29
  17. package/dist/channels/lark-panel.js +1 -1
  18. package/dist/channels/lark.js +81 -39
  19. package/dist/channels/lines.js +33 -1
  20. package/dist/channels/panel.js +17 -107
  21. package/dist/channels/routes.js +11 -21
  22. package/dist/channels/runtime.js +79 -25
  23. package/dist/channels/slack-outbound.js +12 -14
  24. package/dist/channels/slack-panel.js +1 -1
  25. package/dist/channels/slack-render.js +1 -1
  26. package/dist/channels/slack-thread.js +65 -0
  27. package/dist/channels/slack.js +85 -124
  28. package/dist/channels/types.js +1 -1
  29. package/dist/cli.js +7 -2
  30. package/dist/config-sync.js +42 -37
  31. package/dist/core/chain.js +221 -0
  32. package/dist/core/identity.js +36 -13
  33. package/dist/core/inbound-file.js +11 -4
  34. package/dist/core/reply.js +111 -81
  35. package/dist/core/router.js +111 -118
  36. package/dist/core/search.js +65 -0
  37. package/dist/core/types.js +38 -77
  38. package/dist/db.js +41 -2
  39. package/dist/drain.js +1 -1
  40. package/dist/main.js +65 -40
  41. package/dist/settings.js +26 -19
  42. package/dist/socket.js +1 -0
  43. package/dist/tasks/agent.js +11 -5
  44. package/dist/tasks/callbacks.js +55 -5
  45. package/dist/tasks/cli.js +22 -11
  46. package/dist/tasks/definitions.js +30 -17
  47. package/dist/tasks/execution.js +11 -5
  48. package/dist/tasks/groups.js +5 -2
  49. package/dist/tasks/messages.js +7 -7
  50. package/dist/tasks/open-items.js +88 -0
  51. package/dist/tasks/operations.js +76 -39
  52. package/dist/tasks/outbox.js +19 -6
  53. package/dist/tasks/routes.js +19 -249
  54. package/dist/tasks/service.js +166 -34
  55. package/dist/tasks/store.js +123 -57
  56. package/dist/tasks/types.js +3 -0
  57. package/dist/tools.js +12 -5
  58. package/dist/web/auth.js +2 -2
  59. package/dist/web/config-sync.js +4 -5
  60. package/dist/web/config.js +9 -2
  61. package/dist/web/explorer.js +2 -2
  62. package/dist/web/fs.js +16 -1
  63. package/dist/web/instance.js +8 -5
  64. package/dist/web/packages.js +1 -1
  65. package/dist/web/passkeys.js +3 -3
  66. package/dist/web/providers.js +3 -3
  67. package/dist/web/public/assets/code-CWT27KH1.js +2 -0
  68. package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
  69. package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
  70. package/dist/web/public/assets/explorer-C4DhXFCm.js +5 -0
  71. package/dist/web/public/assets/explorer-C4DhXFCm.js.br +0 -0
  72. package/dist/web/public/assets/explorer-C4DhXFCm.js.gz +0 -0
  73. package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
  74. package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
  75. package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
  76. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  77. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  78. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  79. package/dist/web/public/assets/index-D0ZYdweB.js +14 -0
  80. package/dist/web/public/assets/index-D0ZYdweB.js.br +0 -0
  81. package/dist/web/public/assets/index-D0ZYdweB.js.gz +0 -0
  82. package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
  83. package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
  84. package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
  85. package/dist/web/public/assets/settings-DSTeuIyc.js +9 -0
  86. package/dist/web/public/assets/settings-DSTeuIyc.js.br +0 -0
  87. package/dist/web/public/assets/settings-DSTeuIyc.js.gz +0 -0
  88. package/dist/web/public/index.html +39 -109
  89. package/dist/web/public/index.html.br +0 -0
  90. package/dist/web/public/index.html.gz +0 -0
  91. package/dist/web/public/manifest.webmanifest +0 -2
  92. package/dist/web/push.js +3 -3
  93. package/dist/web/server.js +89 -116
  94. package/dist/web/session-state.js +4 -40
  95. package/docs/deploy.md +5 -3
  96. package/package.json +2 -1
  97. package/skills/pier-boards/SKILL.md +14 -4
  98. package/skills/pier-help/SKILL.md +62 -23
  99. package/skills/pier-search/SKILL.md +27 -0
  100. package/skills/pier-tasks/SKILL.md +68 -18
  101. package/dist/channels/handoff.js +0 -94
  102. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  103. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  104. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  105. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  106. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  107. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  108. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  109. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  110. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  111. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  112. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  113. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  114. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  115. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  116. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  117. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  118. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  119. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  120. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  121. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  122. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  123. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  124. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  125. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  126. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  127. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  128. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  129. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  130. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
@@ -1,5 +1,5 @@
1
- // Boards: static pages an agent writes, derived by scanning $PIER_HOME/boards,
2
- // never registered (docs/design/05-boards.md). Only <board>/site is reachable
1
+ // Boards: static pages an agent writes under $PIER_HOME/boards, never
2
+ // registered (docs/design/05-boards.md). Only <board>/site is reachable
3
3
  // over HTTP, so a public board leaks nothing about how it was made. Bytes are
4
4
  // served on two password-free prefixes, `/p/*` (published) and `/b/*` (a
5
5
  // signed prefix the boundary mints), stylesheet included, and run sandboxed.
@@ -10,7 +10,7 @@ import { logger } from "../log.js";
10
10
  import { pierPath } from "../paths.js";
11
11
  export const defaultBoardsDir = () => pierPath("boards");
12
12
  /** Deleted boards keep their bytes under `<slug>.deleted-<ts>`, which this
13
- * pattern excludes from every scan — one rename is the whole delete path. */
13
+ * pattern refuses on every route — one rename is the whole delete path. */
14
14
  const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
15
15
  /** Without the 32 bits after the slug, `/p/` could be walked with a dictionary.
16
16
  * Minted the first time a manifest is seen public, by whichever path did it. */
@@ -55,14 +55,27 @@ let viewKey = randomBytes(32);
55
55
  export const rotateBoardViews = () => {
56
56
  viewKey = randomBytes(32);
57
57
  };
58
- const sign = (slug, expires) => createHmac("sha256", viewKey).update(`${slug}\0${String(expires)}`).digest("base64url").slice(0, 22);
58
+ const sign = (board, expires, key = viewKey) => createHmac("sha256", key).update(`${board}\0${String(expires)}`).digest("base64url").slice(0, 22);
59
59
  /** `<expiry in base36>-<signature>`: its own path segment, so the first hyphen
60
60
  * is the cut and a hyphenated slug stays unambiguous. */
61
- const mintView = (slug) => {
61
+ const mintView = (board, key) => {
62
62
  const expires = Date.now() + VIEW_TTL_MS;
63
- return `${expires.toString(36)}-${sign(slug, expires)}`;
63
+ return `${expires.toString(36)}-${sign(board, expires, key)}`;
64
64
  };
65
- function validView(slug, view) {
65
+ /** What a prefix is signed for: the slug and its directory, because a delete
66
+ * renames the board away and frees the slug, and a prefix must not open the
67
+ * successor. Null when there is no such directory. */
68
+ async function boardOf(dir, slug) {
69
+ try {
70
+ return `${slug}\0${String((await stat(join(dir, slug))).ino)}`;
71
+ }
72
+ catch (err) {
73
+ if (err.code !== "ENOENT")
74
+ logger("boards").warn(`cannot stat board ${slug}`, err);
75
+ return null;
76
+ }
77
+ }
78
+ function validView(board, view) {
66
79
  const cut = view.indexOf("-");
67
80
  if (cut < 1)
68
81
  return false;
@@ -74,9 +87,9 @@ function validView(slug, view) {
74
87
  return false;
75
88
  if (expires <= Date.now())
76
89
  return false;
77
- return sameToken(sign(slug, expires), view.slice(cut + 1));
90
+ return sameToken(sign(board, expires), view.slice(cut + 1));
78
91
  }
79
- /** Malformed boards are reported once, not on every scan. */
92
+ /** Malformed boards are reported once, not on every request. */
80
93
  const warned = new Set();
81
94
  /** The one place a slug becomes a path, so it is validated here (`../../etc`,
82
95
  * NUL). Extra keys survive a write. The one write-back is minting a token for
@@ -104,7 +117,6 @@ async function readManifest(dir, slug) {
104
117
  ...m,
105
118
  title: typeof m.title === "string" && m.title ? m.title : slug,
106
119
  description: typeof m.description === "string" ? m.description : "",
107
- sessions: Array.isArray(m.sessions) ? m.sessions.filter((s) => typeof s === "string") : [],
108
120
  public: m.public === true,
109
121
  token: typeof m.token === "string" && TOKEN.test(m.token) ? m.token : "",
110
122
  };
@@ -130,7 +142,7 @@ async function updatedAt(dir, slug) {
130
142
  (await stat(join(dir, slug)).catch(() => null));
131
143
  return (info?.mtime ?? new Date()).toISOString();
132
144
  }
133
- export async function listBoards(dir) {
145
+ async function listBoards(dir) {
134
146
  let entries;
135
147
  try {
136
148
  entries = (await readdir(dir, { withFileTypes: true }))
@@ -143,17 +155,14 @@ export async function listBoards(dir) {
143
155
  logger("boards").warn(`cannot scan ${dir}`, err);
144
156
  return [];
145
157
  }
146
- // One board's manifest says nothing about the next one's, so the scan waits
147
- // once for all of them rather than once per board.
148
158
  const boards = await Promise.all(entries.map(async (slug) => {
149
159
  const manifest = await readManifest(dir, slug);
150
160
  if (!manifest)
151
161
  return null;
152
- const { title, description, sessions, public: isPublic, token } = manifest;
153
- return { slug, title, description, sessions, public: isPublic, token, updatedAt: await updatedAt(dir, slug) };
162
+ const { title, description, public: isPublic, token } = manifest;
163
+ return { slug, title, description, public: isPublic, token, updatedAt: await updatedAt(dir, slug) };
154
164
  }));
155
- // Freshest first: the board someone just wrote is the one they came to see.
156
- // Slug breaks ties so equal mtimes still list in a stable order.
165
+ // Freshest first; slug breaks ties so equal mtimes still list in a stable order.
157
166
  return boards
158
167
  .filter((board) => board !== null)
159
168
  .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.slug.localeCompare(b.slug));
@@ -231,8 +240,7 @@ async function serveFile(c, dir, slug, rest) {
231
240
  }
232
241
  export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
233
242
  app.get("/api/boards", async (c) => c.json(await listBoards(dir)));
234
- // Publishing is the one decision a human owns; every other field belongs to
235
- // the agent that wrote the board.
243
+ // Publishing is the one manifest field a human owns; the rest is the agent's.
236
244
  app.patch("/api/boards/:slug", async (c) => {
237
245
  const slug = c.req.param("slug");
238
246
  const body = (await c.req.json().catch(() => null));
@@ -242,21 +250,19 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
242
250
  if (!manifest)
243
251
  return c.json({ error: "no such board" }, 404);
244
252
  manifest.public = body.public;
245
- // Publishing an unpublished board is the case readManifest cannot mint for:
246
- // it read the manifest while it was still private.
253
+ // readManifest mints only for a manifest it read as public.
247
254
  if (manifest.public && !manifest.token)
248
255
  manifest.token = mintToken();
249
256
  await writeManifest(dir, slug, manifest);
250
257
  return c.json({ public: manifest.public, token: manifest.token });
251
258
  });
259
+ // A rename, so the undo is on disk; signed prefixes are bound to the
260
+ // directory's inode (boardOf), so none opens a successor on the same slug.
252
261
  app.delete("/api/boards/:slug", async (c) => {
253
262
  const slug = c.req.param("slug");
254
263
  if (!(await readManifest(dir, slug)))
255
264
  return c.json({ error: "no such board" }, 404);
256
265
  await rename(join(dir, slug), join(dir, `${slug}.deleted-${Date.now()}`));
257
- // A capability names a slug, and a slug can be taken again: the prefixes
258
- // handed out for the board that just died must not open its successor.
259
- rotateBoardViews();
260
266
  return c.json({ deleted: slug });
261
267
  });
262
268
  // Declared before the wildcards below: `_assets` is not a slug.
@@ -285,10 +291,12 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
285
291
  // needs no cookie and can be sandboxed into an opaque origin.
286
292
  const mint = async (c) => {
287
293
  const slug = c.req.param("slug") ?? "";
288
- // Signed before the read: a sign-out landing during it would otherwise
289
- // hand this request a capability made with the key that replaced the
290
- // revoked one.
291
- const view = SLUG.test(slug) ? mintView(slug) : "";
294
+ // The key as this request found it: a sign-out landing during the reads
295
+ // would otherwise hand it a capability made with the key that replaced
296
+ // the revoked one.
297
+ const key = viewKey;
298
+ const board = SLUG.test(slug) ? await boardOf(dir, slug) : null;
299
+ const view = board ? mintView(board, key) : "";
292
300
  // A board that is gone says so here, rather than after a redirect.
293
301
  if (!view || !(await readManifest(dir, slug)))
294
302
  return c.notFound();
@@ -304,10 +312,12 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
304
312
  const rest = c.req.path.slice(`/b/${slug}/${view}/`.length);
305
313
  if (!SLUG.test(slug))
306
314
  return c.notFound();
307
- // Expired, forged, or signed with a key that has since rotated: send it
308
- // back through the boundary, which re-mints for a live session in one hop
309
- // and asks a stranger for the password. Existence stays unsaid either way.
310
- if (!validView(slug, view)) {
315
+ // Expired, forged, signed with a key that has since rotated, or for a
316
+ // board since renamed away: send it back through the boundary, which
317
+ // re-mints for a live session in one hop and asks a stranger for the
318
+ // password. Existence stays unsaid either way.
319
+ const board = await boardOf(dir, slug);
320
+ if (!board || !validView(board, view)) {
311
321
  return c.redirect(`/boards/${slug}/${rest}${new URL(c.req.url).search}`);
312
322
  }
313
323
  if (!(await readManifest(dir, slug)))
@@ -48,10 +48,6 @@ export class Chains {
48
48
  else
49
49
  this.held -= 1;
50
50
  }
51
- /** The backpressure primitive. */
52
- oldest() {
53
- return Promise.race(this.active.values()).catch(() => { });
54
- }
55
51
  /** Two adapters handling one message would prompt twice; a hung handler
56
52
  * holding up the Console's save is worse than either, hence the bound. */
57
53
  async drain(timeoutMs) {
@@ -1,19 +1,14 @@
1
1
  // Slash commands in IM text, parsed once for every platform: clients add
2
- // whitespace and capitalise.
3
- /** The configure-first trigger, one spelling on both platforms: `s <text>`
4
- * (Lark also `/s <text>`), the text being the panel's pending question. A bare
5
- * `s` carries no question and is prose. */
2
+ // whitespace and capitalise. `%` is `/` too: Slack's client eats an unregistered `/`.
3
+ /** The configure-first trigger: `/s <text>` or `%s <text>`, the text being the
4
+ * panel's pending question. `/s` alone carries no question and is prose. */
6
5
  export function settingsDraft(text) {
7
- return /^\/?s[ \t]+(\S[\s\S]*)$/i.exec(text.trim())?.[1];
6
+ return /^[/%]s[ \t]+(\S[\s\S]*)$/i.exec(text.trim())?.[1];
8
7
  }
9
8
  export function parseCommand(text) {
10
9
  const trimmed = text.trim();
11
- if (!trimmed.startsWith("/"))
12
- return null;
13
10
  // Args verbatim: a path or a sentence must keep its own spacing.
14
- const match = /^\/(\S+)[ \t]*([\s\S]*)$/.exec(trimmed);
15
- const name = match?.[1] ?? "";
16
- if (!name)
17
- return null; // a bare "/" is not a command
18
- return { name: name.toLowerCase(), args: match?.[2] ?? "" };
11
+ const match = /^[/%](\S+)[ \t]*([\s\S]*)$/.exec(trimmed);
12
+ // A bare "/" or "%" is not a command.
13
+ return match?.[1] ? { name: match[1].toLowerCase(), args: match[2] ?? "" } : null;
19
14
  }
@@ -1,7 +1,7 @@
1
1
  // Channel config persistence and the permission gate every adapter shares: one
2
2
  // JSON document per platform, so a surface configuring one reads and writes one row.
3
3
  import { randomInt } from "node:crypto";
4
- import { defaultChannelConfig, } from "./types.js";
4
+ import { defaultChannelConfig, PLATFORMS, } from "./types.js";
5
5
  const BIND_CODE_TTL_MS = 10 * 60_000;
6
6
  /** Six symbols out of 36 is 2 billion, but a caller who may retry forever only
7
7
  * needs the TTL; five wrong tries void the code instead. */
@@ -72,6 +72,32 @@ export class ChannelStore {
72
72
  chat(platform, chatId) {
73
73
  return this.get(platform).chats.find((c) => c.id === chatId);
74
74
  }
75
+ /** One home instance-wide: setting it clears every other row on both
76
+ * platforms. Refuses a chat that is unknown or not a DM; `null` leaves no home anywhere. */
77
+ setHome(platform, chatId) {
78
+ if (chatId !== null && this.cached(platform).chats.find((c) => c.id === chatId)?.kind !== "dm") {
79
+ throw new Error(`${platform}:${chatId} is not a known DM`);
80
+ }
81
+ for (const p of PLATFORMS) {
82
+ const config = this.get(p);
83
+ for (const chat of config.chats) {
84
+ if (p === platform && chat.id === chatId)
85
+ chat.home = true;
86
+ else
87
+ delete chat.home;
88
+ }
89
+ this.save(p, config);
90
+ }
91
+ }
92
+ // On the per-message path: no clone.
93
+ home() {
94
+ for (const platform of PLATFORMS) {
95
+ const chat = this.cached(platform).chats.find((c) => c.home);
96
+ if (chat)
97
+ return { platform, chatId: chat.id };
98
+ }
99
+ return undefined;
100
+ }
75
101
  /** No platform reliably lists every chat a bot is in, so discovery is passive
76
102
  * and happens on every message: the unchanged case must cost no clone. A new
77
103
  * chat copies the platform defaults and owns them from then on. */
@@ -50,7 +50,7 @@ export function createControl({ router, factory, conversations, store, modelMenu
50
50
  model: session.model,
51
51
  thinking: session.thinkingLevel,
52
52
  tokens: usage?.tokens ?? null,
53
- contextWindow: usage?.contextWindow ?? null,
53
+ compactAt: usage?.compactAt ?? null,
54
54
  };
55
55
  },
56
56
  pins: modelMenu,
@@ -89,21 +89,10 @@ export function createControl({ router, factory, conversations, store, modelMenu
89
89
  const seen = new Set([...(own ? [own] : []), ...projectCwds(await factory.list())]);
90
90
  return [...seen].slice(0, limit);
91
91
  },
92
- async recent(key, exchanges) {
93
- const session = await live(key);
94
- if (!session)
95
- return [];
96
- const pairs = [];
97
- for (const turn of await session.history()) {
98
- const last = pairs.at(-1);
99
- if (turn.role === "user")
100
- pairs.push({ user: turn.text });
101
- // The first reply after a user turn; later assistant turns of the same
102
- // exchange are continuations of the same answer.
103
- else if (turn.role === "assistant" && last && last.assistant === undefined)
104
- last.assistant = turn.text;
105
- }
106
- return pairs.slice(-exchanges);
92
+ isHome(key) {
93
+ const home = store.home();
94
+ return home?.platform === key.channelId && home.chatId === chatOf(key.conversationId);
107
95
  },
96
+ skills: async (key) => (await live(key))?.skills() ?? [],
108
97
  };
109
98
  }
@@ -18,11 +18,11 @@ export class ConversationStore {
18
18
  * one launched from the chat defaults, which a re-create reads again. */
19
19
  set(key, sessionId, launch) {
20
20
  this.db.prepare(`
21
- INSERT INTO conversations(channel_id, conversation_id, session_id, updated_at, launch)
22
- VALUES (?, ?, ?, ?, ?)
21
+ INSERT INTO conversations(channel_id, conversation_id, session_id, launch)
22
+ VALUES (?, ?, ?, ?)
23
23
  ON CONFLICT(channel_id, conversation_id) DO UPDATE SET
24
- session_id = excluded.session_id, updated_at = excluded.updated_at, launch = excluded.launch
25
- `).run(key.channelId, key.conversationId, sessionId, Date.now(), launch ? JSON.stringify(launch) : null);
24
+ session_id = excluded.session_id, launch = excluded.launch
25
+ `).run(key.channelId, key.conversationId, sessionId, launch ? JSON.stringify(launch) : null);
26
26
  }
27
27
  launchOf(key) {
28
28
  const row = this.db.prepare(`
@@ -47,11 +47,6 @@ export class ConversationStore {
47
47
  `).get(sessionId);
48
48
  return row && { channelId: row.channel_id, conversationId: row.conversation_id };
49
49
  }
50
- /** Every session some IM conversation answers for, in one query. */
51
- boundSessions() {
52
- const rows = this.db.prepare(`SELECT session_id FROM conversations`).all();
53
- return new Set(rows.map((r) => r.session_id));
54
- }
55
50
  /** Every thread of one chat: a chat that is gone would otherwise keep its
56
51
  * sessions bound to a conversation nothing can reach. */
57
52
  forgetChat(channelId, chatId) {
@@ -100,29 +100,30 @@ export class LarkApi {
100
100
  });
101
101
  }
102
102
  // --- messages ------------------------------------------------------------------
103
- async replyCard(messageId, card) {
104
- const res = await this.client.im.v1.message.reply({
105
- path: { message_id: messageId },
106
- data: {
107
- msg_type: "interactive",
108
- content: JSON.stringify(card),
109
- // Lark's equivalent of posting to a thread_ts.
110
- reply_in_thread: true,
111
- },
112
- });
113
- return { messageId: ok("message.reply", res).data?.message_id ?? "" };
103
+ replyCard(messageId, card) {
104
+ return this.post({ root: messageId }, "interactive", card);
114
105
  }
115
- async createCard(chatId, card) {
116
- const res = await this.client.im.v1.message.create({
117
- params: { receive_id_type: "chat_id" },
118
- data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
119
- });
120
- return { messageId: ok("message.create", res).data?.message_id ?? "" };
106
+ sendCard(chatId, card) {
107
+ return this.post({ chatId }, "interactive", card);
108
+ }
109
+ async post(to, type, content) {
110
+ const data = { msg_type: type, content: JSON.stringify(content) };
111
+ const res = "root" in to
112
+ // `reply_in_thread` is Lark's equivalent of posting to a thread_ts.
113
+ ? ok("message.reply", await this.client.im.v1.message.reply({
114
+ path: { message_id: to.root },
115
+ data: { ...data, reply_in_thread: true },
116
+ }))
117
+ : ok("message.create", await this.client.im.v1.message.create({
118
+ params: { receive_id_type: "chat_id" },
119
+ data: { ...data, receive_id: to.chatId },
120
+ }));
121
+ return { messageId: res.data?.message_id ?? "" };
121
122
  }
122
123
  /** Images take the image endpoint so they render inline; `stream` is Lark's
123
124
  * "type unknown". The SDK unwraps an upload response to its `data`, so a
124
125
  * failure arrives as a missing key, not a code. */
125
- async uploadFile(rootId, file) {
126
+ async uploadFile(to, file) {
126
127
  const bytes = Buffer.from(file.bytes);
127
128
  let content;
128
129
  if (file.image) {
@@ -141,14 +142,7 @@ export class LarkApi {
141
142
  throw new Error(`lark file.create: no file_key for ${file.name}`);
142
143
  content = { file_key: res.file_key };
143
144
  }
144
- ok("message.reply", await this.client.im.v1.message.reply({
145
- path: { message_id: rootId },
146
- data: {
147
- msg_type: file.image ? "image" : "file",
148
- content: JSON.stringify(content),
149
- reply_in_thread: true,
150
- },
151
- }));
145
+ await this.post(to, file.image ? "image" : "file", content);
152
146
  }
153
147
  async patchCard(messageId, card) {
154
148
  ok("message.patch", await this.client.im.v1.message.patch({
@@ -1,7 +1,9 @@
1
- // How a turn becomes cards in a Lark thread: one card per chunk, footer and
2
- // buttons on the last, and what an empty turn still has to say.
3
- import { formatTurnMeta, isSilentReply, originLabel, quietLabel } from "../core/reply.js";
1
+ // How a turn becomes cards in a Lark thread, or the home chat's main flow: one
2
+ // card per chunk, footer and buttons on the last, and what an empty turn still
3
+ // has to say.
4
+ import { formatTurnMeta, isSilentReply, quietLabel } from "../core/reply.js";
4
5
  import { sendAttachments, splitAttachments } from "./attach.js";
6
+ import { noteBody } from "./lines.js";
5
7
  import { button, buttonRow, card, chunk, footer, LARK_MAX, markdown, OFFER_PREFIX, withFooter, withoutButtons, } from "./lark-render.js";
6
8
  const SENT_CACHE = 200;
7
9
  export class LarkOutbound {
@@ -18,7 +20,7 @@ export class LarkOutbound {
18
20
  /** An empty turn still posts its footer and says which kind of nothing (§5).
19
21
  * The footer folds into the last chunk's element (a second element renders
20
22
  * a blank gap); only a bodiless turn gets the standalone one. */
21
- async reply(root, reply) {
23
+ async reply(to, reply) {
22
24
  // A local file link is dead in Lark: the bytes are uploaded instead.
23
25
  const { text: spoken, paths } = splitAttachments(reply.text);
24
26
  const text = spoken.trim();
@@ -27,6 +29,8 @@ export class LarkOutbound {
27
29
  const note = [quiet, meta].filter(Boolean).join(" · ");
28
30
  if (!(text || reply.suggestions.length || note || paths.length))
29
31
  return;
32
+ // An empty root is a main-flow button, which only the home chat has (lark.ts onAction).
33
+ const root = "root" in to ? to.root : "";
30
34
  const row = reply.suggestions.length
31
35
  ? buttonRow(reply.suggestions.map((label, index) => button(label, { key: `${OFFER_PREFIX}${index}`, root, label })))
32
36
  : undefined;
@@ -42,32 +46,16 @@ export class LarkOutbound {
42
46
  elements.push(row);
43
47
  if (!elements.length)
44
48
  continue;
45
- const { messageId } = await this.api.replyCard(root, card(elements));
49
+ const { messageId } = await this.post(to, card(elements));
46
50
  if (last && row && messageId)
47
51
  this.remember(messageId, card(elements));
48
52
  }
49
- const lost = await sendAttachments(paths, (file) => this.api.uploadFile(root, file), this.log);
53
+ const lost = await sendAttachments(paths, (file) => this.api.uploadFile(to, file), this.log);
50
54
  if (lost)
51
- await this.api.replyCard(root, card([markdown(lost)]));
55
+ await this.post(to, card([markdown(lost)]));
52
56
  }
53
- /** The root, then one card inside its topic: on the phone a topic has its
54
- * own composer only once it has a reply. Answers the root's id. The handoff
55
- * writes no row when this throws, so a root left behind would invite a reply
56
- * into a thread nobody answers: it is taken back down. */
57
- async open(chatId, note) {
58
- const link = note.url ? `[Open on the web](${note.url})` : "(no public URL set — Settings → Instance)";
59
- const { messageId: root } = await this.api.createCard(chatId, card([
60
- markdown(`**Continued from web: ${note.title}**\n${link}\nReply in this thread to continue.`),
61
- ]));
62
- try {
63
- await this.api.replyCard(root, card([markdown("Reply here to continue.")]));
64
- }
65
- catch (err) {
66
- await this.api.deleteMessage(root)
67
- .catch((e) => this.log(`orphan handoff root ${root} not deleted: ${String(e)}`));
68
- throw err;
69
- }
70
- return root;
57
+ post(to, sent) {
58
+ return "root" in to ? this.api.replyCard(to.root, sent) : this.api.sendCard(to.chatId, sent);
71
59
  }
72
60
  /** Best-effort: a card sent before a restart keeps its row, logged. */
73
61
  async retire(messageId) {
@@ -80,6 +68,11 @@ export class LarkOutbound {
80
68
  const kept = withoutButtons(known);
81
69
  await this.api.patchCard(messageId, kept.body.elements.length ? kept : card([footer("Option taken.")])).catch((err) => this.log(`retiring options failed: ${String(err)}`));
82
70
  }
71
+ /** A note's root rewritten in place (a child thread's state); one card, so
72
+ * the body is cut to the budget rather than split. */
73
+ async edit(messageId, note) {
74
+ await this.api.patchCard(messageId, card([markdown(chunk(noteBody(note, "*"), LARK_MAX)[0] ?? "")]));
75
+ }
83
76
  remember(messageId, sent) {
84
77
  this.sent.set(messageId, sent);
85
78
  while (this.sent.size > SENT_CACHE) {
@@ -91,11 +84,10 @@ export class LarkOutbound {
91
84
  }
92
85
  /** No footer: the turn this input triggers has not ended. Answers with the
93
86
  * id of the last card posted, where the caller puts the 👀. */
94
- async note(root, note) {
95
- const body = note.text.split("\n").map((line) => `> ${line}`).join("\n");
87
+ async note(to, note) {
96
88
  let messageId;
97
- for (const part of chunk(`*${originLabel(note.origin)}*\n${body}`, LARK_MAX)) {
98
- messageId = (await this.api.replyCard(root, card([markdown(part)]))).messageId;
89
+ for (const part of chunk(noteBody(note, "*"), LARK_MAX)) {
90
+ messageId = (await this.post(to, card([markdown(part)]))).messageId;
99
91
  }
100
92
  return messageId;
101
93
  }
@@ -7,7 +7,7 @@ import { ChatPanel, CWD_DRAFT_TAIL, CWD_PLACEHOLDER, holdQuestion, PANEL_PREFIX,
7
7
  /** A form-submit button name: `cwdgo:<thread root>`. */
8
8
  export const CWD_SUBMIT_PREFIX = "cwdgo:";
9
9
  const CWD_FIELD = "cwd";
10
- const fresh = (root, messageId, draft) => ({ root, messageId, draft, dirs: [], sessions: [] });
10
+ const fresh = (root, messageId, draft) => ({ root, messageId, draft, dirs: [] });
11
11
  export class LarkPanel extends ChatPanel {
12
12
  deps;
13
13
  fence = ["`", "`"];