@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.
- package/README.md +44 -22
- package/dist/agent/config.js +2 -2
- package/dist/agent/events.js +32 -3
- package/dist/agent/listing.js +14 -10
- package/dist/agent/packages.js +6 -10
- package/dist/agent/pi.js +113 -70
- package/dist/agent/roles.js +104 -0
- package/dist/agent/types.js +78 -0
- package/dist/boards/boards.js +42 -32
- package/dist/channels/chains.js +0 -4
- package/dist/channels/commands.js +7 -12
- package/dist/channels/config.js +27 -1
- package/dist/channels/control.js +5 -16
- package/dist/channels/conversations.js +4 -9
- package/dist/channels/lark-api.js +20 -26
- package/dist/channels/lark-outbound.js +21 -29
- package/dist/channels/lark-panel.js +1 -1
- package/dist/channels/lark.js +81 -39
- package/dist/channels/lines.js +33 -1
- package/dist/channels/panel.js +17 -107
- package/dist/channels/routes.js +11 -21
- package/dist/channels/runtime.js +79 -25
- package/dist/channels/slack-outbound.js +12 -14
- package/dist/channels/slack-panel.js +1 -1
- package/dist/channels/slack-render.js +1 -1
- package/dist/channels/slack-thread.js +65 -0
- package/dist/channels/slack.js +85 -124
- package/dist/channels/types.js +1 -1
- package/dist/cli.js +7 -2
- package/dist/config-sync.js +42 -37
- package/dist/core/chain.js +221 -0
- package/dist/core/identity.js +36 -13
- package/dist/core/inbound-file.js +11 -4
- package/dist/core/reply.js +111 -81
- package/dist/core/router.js +111 -118
- package/dist/core/search.js +65 -0
- package/dist/core/types.js +38 -77
- package/dist/db.js +41 -2
- package/dist/drain.js +1 -1
- package/dist/main.js +65 -40
- package/dist/settings.js +26 -19
- package/dist/socket.js +1 -0
- package/dist/tasks/agent.js +11 -5
- package/dist/tasks/callbacks.js +55 -5
- package/dist/tasks/cli.js +22 -11
- package/dist/tasks/definitions.js +30 -17
- package/dist/tasks/execution.js +11 -5
- package/dist/tasks/groups.js +5 -2
- package/dist/tasks/messages.js +7 -7
- package/dist/tasks/open-items.js +88 -0
- package/dist/tasks/operations.js +76 -39
- package/dist/tasks/outbox.js +19 -6
- package/dist/tasks/routes.js +19 -249
- package/dist/tasks/service.js +166 -34
- package/dist/tasks/store.js +123 -57
- package/dist/tasks/types.js +3 -0
- package/dist/tools.js +12 -5
- package/dist/web/auth.js +2 -2
- package/dist/web/config-sync.js +4 -5
- package/dist/web/config.js +9 -2
- package/dist/web/explorer.js +2 -2
- package/dist/web/fs.js +16 -1
- package/dist/web/instance.js +8 -5
- package/dist/web/packages.js +1 -1
- package/dist/web/passkeys.js +3 -3
- package/dist/web/providers.js +3 -3
- package/dist/web/public/assets/code-CWT27KH1.js +2 -0
- package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
- package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js +5 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js.br +0 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js.gz +0 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
- package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
- package/dist/web/public/assets/index-D0ZYdweB.js +14 -0
- package/dist/web/public/assets/index-D0ZYdweB.js.br +0 -0
- package/dist/web/public/assets/index-D0ZYdweB.js.gz +0 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js +9 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js.br +0 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js.gz +0 -0
- package/dist/web/public/index.html +39 -109
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +0 -2
- package/dist/web/push.js +3 -3
- package/dist/web/server.js +89 -116
- package/dist/web/session-state.js +4 -40
- package/docs/deploy.md +5 -3
- package/package.json +2 -1
- package/skills/pier-boards/SKILL.md +14 -4
- package/skills/pier-help/SKILL.md +62 -23
- package/skills/pier-search/SKILL.md +27 -0
- package/skills/pier-tasks/SKILL.md +68 -18
- package/dist/channels/handoff.js +0 -94
- package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
- package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
- package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
- package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
- package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js +0 -85
- package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
- package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
- package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
- package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
- package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
- package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
package/dist/boards/boards.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// Boards: static pages an agent writes
|
|
2
|
-
//
|
|
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
|
|
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 = (
|
|
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 = (
|
|
61
|
+
const mintView = (board, key) => {
|
|
62
62
|
const expires = Date.now() + VIEW_TTL_MS;
|
|
63
|
-
return `${expires.toString(36)}-${sign(
|
|
63
|
+
return `${expires.toString(36)}-${sign(board, expires, key)}`;
|
|
64
64
|
};
|
|
65
|
-
|
|
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(
|
|
90
|
+
return sameToken(sign(board, expires), view.slice(cut + 1));
|
|
78
91
|
}
|
|
79
|
-
/** Malformed boards are reported once, not on every
|
|
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
|
-
|
|
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,
|
|
153
|
-
return { slug, title, description,
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
289
|
-
// hand
|
|
290
|
-
// revoked one.
|
|
291
|
-
const
|
|
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,
|
|
308
|
-
// back through the boundary, which
|
|
309
|
-
//
|
|
310
|
-
|
|
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)))
|
package/dist/channels/chains.js
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
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
|
|
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 =
|
|
15
|
-
|
|
16
|
-
|
|
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
|
}
|
package/dist/channels/config.js
CHANGED
|
@@ -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. */
|
package/dist/channels/control.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
93
|
-
const
|
|
94
|
-
|
|
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,
|
|
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,
|
|
25
|
-
`).run(key.channelId, key.conversationId, sessionId,
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
2
|
-
// buttons on the last, and what an empty turn still
|
|
3
|
-
|
|
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(
|
|
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.
|
|
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(
|
|
53
|
+
const lost = await sendAttachments(paths, (file) => this.api.uploadFile(to, file), this.log);
|
|
50
54
|
if (lost)
|
|
51
|
-
await this.
|
|
55
|
+
await this.post(to, card([markdown(lost)]));
|
|
52
56
|
}
|
|
53
|
-
|
|
54
|
-
|
|
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(
|
|
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(
|
|
98
|
-
messageId = (await this.
|
|
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: []
|
|
10
|
+
const fresh = (root, messageId, draft) => ({ root, messageId, draft, dirs: [] });
|
|
11
11
|
export class LarkPanel extends ChatPanel {
|
|
12
12
|
deps;
|
|
13
13
|
fence = ["`", "`"];
|