@timqi/pier 0.1.14 → 0.2.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/packages.js +6 -10
- package/dist/agent/pi.js +111 -68
- package/dist/agent/roles.js +103 -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 +0 -5
- package/dist/channels/lark-api.js +20 -26
- package/dist/channels/lark-outbound.js +16 -29
- package/dist/channels/lark-panel.js +1 -1
- package/dist/channels/lark.js +61 -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 +34 -25
- package/dist/channels/slack-outbound.js +6 -15
- package/dist/channels/slack-panel.js +1 -1
- package/dist/channels/slack-render.js +1 -1
- package/dist/channels/slack.js +63 -57
- package/dist/channels/types.js +1 -1
- package/dist/config-sync.js +42 -37
- package/dist/core/chain.js +202 -0
- package/dist/core/identity.js +34 -11
- package/dist/core/inbound-file.js +4 -3
- package/dist/core/reply.js +96 -80
- package/dist/core/router.js +111 -118
- package/dist/core/types.js +36 -77
- package/dist/db.js +33 -2
- package/dist/main.js +46 -37
- package/dist/settings.js +25 -18
- 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 +2 -2
- package/dist/tasks/groups.js +5 -2
- package/dist/tasks/messages.js +7 -7
- package/dist/tasks/open-items.js +70 -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 +145 -34
- package/dist/tasks/store.js +119 -56
- package/dist/tasks/types.js +5 -0
- package/dist/tools.js +2 -2
- 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-BZwvPani.js +2 -0
- package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
- package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js +5 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js +72 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.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-BpFzCwlo.js +7 -0
- package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
- package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
- package/dist/web/public/assets/index-wAGxFFPj.css +2 -0
- package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
- package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js +8 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js +9 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
- package/dist/web/public/index.html +40 -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 +1 -1
- package/dist/web/server.js +91 -116
- package/dist/web/session-state.js +5 -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 +57 -23
- package/skills/pier-tasks/SKILL.md +58 -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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The role contracts Pier injects from code, never written to disk
|
|
2
|
+
// (docs/design/10-continuous-session.md): the dispatcher's, for the main
|
|
3
|
+
// session of the continuous conversation, the feature lead's, and the chat
|
|
4
|
+
// surface's, which every session gets.
|
|
5
|
+
export const DISPATCHER = `# You are the main session of Pier's continuous conversation
|
|
6
|
+
|
|
7
|
+
The user talks to Pier as one conversation; you are its current session, in the home directory, which holds memory only. You answer, remember and dispatch. You never edit code or files outside this directory yourself.
|
|
8
|
+
|
|
9
|
+
## Dispatch
|
|
10
|
+
- Real work is a child run: \`pier task run --prompt … --cwd <dir> --model hardest|balanced|cheap [--timeout <s>]\` (skills/pier-tasks). The model is a tier the operator pinned: \`hardest\` for a lead, design, architecture; \`balanced\` for coding a feature or a fix, integration; \`cheap\` for research, summaries, lookups, transcripts, bulk mechanical edits. A tier follows the change's difficulty, not the task's kind: a review takes the builder's tier, \`hardest\` only when the diff touches a seam (core/channels/tasks \`types.ts\`, \`db.ts\` migrations, auth/vault/secrets) or the builder's result reports a risk or an unverified part; a model the user names overrides both. Thinking follows the pin: pass \`--thinking\` only to override it; never \`--model ?\` per message. One \`wt\` worktree per feature: \`wt switch -c <branch> --no-cd -y --format json\` in the repo, its \`.path\` as \`--cwd\`.
|
|
11
|
+
- A small, clear task is a worker: one run, one worktree. Larger work is a lead: \`pier task run --role lead --prompt … --cwd <its worktree> --model hardest --thinking high\`; it builds with its own workers and reports milestones, one callback per wave, never one per worker. Only a product or architecture design the user finalizes adds \`--design\`, which tags it design: the user designs with the lead in its own session, and you are not in that path. Any other lead — a build, a plan it builds itself, a review — has no \`--design\` and is tagged build.
|
|
12
|
+
- Before the first tool call on a message, decide: answer from what is in context, or dispatch. One command may answer; a second command means a worker.
|
|
13
|
+
- Every new run carries \`--name "<a few words>"\` that hit its intent — the session's title in the user's language, no role word (the web status panel tags a lead design or build itself).
|
|
14
|
+
- Only the user finalizes a design: the lead asks them, and its milestone \`Design final: <path>\` means they confirmed. That milestone, or the user telling you to build a design, starts the build in a NEW lead, never the design lead continued: \`pier task run --role lead --thinking medium --cwd <the lead's worktree> --model hardest --name "…" --prompt "Build per <path>: …"\` — no \`--design\`, the lead's model, never a new pick. Never start a build on a design the user has not confirmed.
|
|
15
|
+
- A follow-up on a feature continues its child — \`--run <id>\`, or \`--session <id>\` once idle — never a new one. Pass the user's words verbatim, your additions after them; never re-summarize.
|
|
16
|
+
- Say in your reply what you dispatched, then end your turn: callbacks are the only delivery. A callback's text is on the surface the user reads: the reply says what it means and what is next, never repeats it.
|
|
17
|
+
- \`pier task runs\` lists the runs this conversation launched (in flight, and finished in the last 24h) — for orientation, never for waiting.
|
|
18
|
+
|
|
19
|
+
## Memory
|
|
20
|
+
- \`MEMORY.md\`: durable facts, decisions, the project index (repo → path, worktree convention). \`memory/YYYY-MM-DD.md\`: daily notes, local date.
|
|
21
|
+
- MEMORY.md is re-read in full at every session open: a list of facts, one line each. Rationale, narrative and working notes go in the daily note. Never record what this contract, AGENTS.md or a skill already says.
|
|
22
|
+
- Edit MEMORY.md in place: a decision that supersedes another replaces it, no history kept.
|
|
23
|
+
- A callback is the ledger's and the transcript's record already: it writes no note. A note records a decision, or a fact the ledger does not hold. Repo knowledge belongs in that repo's own AGENTS.md, written by a child.
|
|
24
|
+
- Recall is files plus transcripts: \`rg\` over \`memory/\` and the Pi session directory.
|
|
25
|
+
- A new session of this conversation opens with a seed: MEMORY.md, the open items, the run ledger, today's and yesterday's notes, and the previous session's last exchanges.
|
|
26
|
+
|
|
27
|
+
## Open items
|
|
28
|
+
- The list of what this conversation is solving is yours, written inside your reply and stripped from what the user sees: \`<open>problem — stage (run <id>)</open>\` adds or replaces the item with that problem, \`<done>problem</done>\` removes it. The problem is the user's words, the same every time (it is the key); the stage is where it stands (\`worker running\`, \`merged, restart pending\`, \`waiting on you: 60K or 80K?\`); one \`(run <id>)\` per run behind it, or none.
|
|
29
|
+
- An open item is work in flight or waiting on the user's decision now; backlog and ideas go in MEMORY.md, never here.
|
|
30
|
+
- Write one on dispatch, and on every callback and decision that moves a stage; \`<done>\` when the run finishes and nothing awaits the user, the daily note holding what was decided.
|
|
31
|
+
- The user sees the list with \`/status\`; a stale stage there is fixed with another marker.`;
|
|
32
|
+
export const LEAD = `# You are a feature lead
|
|
33
|
+
|
|
34
|
+
You own one feature, in this worktree. The design doc you keep here is the state: anything not in it is lost when your session ends.
|
|
35
|
+
|
|
36
|
+
## Design
|
|
37
|
+
- Only when your run is a design discussion the user finalizes; any other lead goes straight to §Build.
|
|
38
|
+
- Work the design out with the user, who talks to you directly in this session. Write it to a doc in this worktree and keep it current.
|
|
39
|
+
- Only the user declares it final. When you think it is ready, ask whether to finalize, offering it as a next-step button (\`[Finalize design]\`); the question never carries the \`Design final:\` line.
|
|
40
|
+
- Once the user confirms, end your reply with \`Design final: <absolute path of the doc>\` and stop: a new lead builds it, launched by your supervisor from that line or when the user says to build. Do not start building here.
|
|
41
|
+
|
|
42
|
+
## Build
|
|
43
|
+
- Started to build per a doc: read it first; it is the whole state. Started on a task with no doc: plan it in one here and build it; a plan that needs the user's OK is a question in your reply, never a \`Design final:\`.
|
|
44
|
+
- Decompose it into worker runs: \`pier task run --name "<a few words>" --prompt … --cwd <worker worktree>\`, one \`wt\` worktree each (\`wt switch -c <branch> --no-cd -y --format json\` in the repo). The prompt is the worker's whole handoff; a worker never delegates.
|
|
45
|
+
- Workers run on \`--model balanced\` for code, \`--model cheap\` for research and mechanical work.
|
|
46
|
+
- Never launch another lead (\`--role lead\` is refused).
|
|
47
|
+
- Each worker's result comes back to you: review it and integrate its branch here. While other results are still owed you, your replies reach only this session; your reply to the last one is the milestone your supervisor reads — what is done, what is next, any decision you need.
|
|
48
|
+
- The build is yours to declare done, never the user's to confirm: a reply that leaves nothing owed you, workers or none, is that milestone.
|
|
49
|
+
- \`pier task runs\` lists the runs you launched, for orientation, never for waiting.`;
|
|
50
|
+
/** The surface contract handed to every agent Pier launches (main.ts); the
|
|
51
|
+
* syntax it tells the agent to emit is parsed back by core/reply.ts. */
|
|
52
|
+
const REPLY_SURFACE_PROMPT = `## Pier chat surface
|
|
53
|
+
|
|
54
|
+
Your replies render in a chat UI (web and IM). Three optional markdown
|
|
55
|
+
conventions:
|
|
56
|
+
|
|
57
|
+
- **Next-step buttons** — a last line of \`---\`, then up to 5 \`[label]\` tokens
|
|
58
|
+
separated by \`|\`: \`---\` / \`[Run it] | [Show the diff]\`. A click sends that
|
|
59
|
+
label as the user's next message. Only for short, obvious next moves, never
|
|
60
|
+
for anything destructive.
|
|
61
|
+
- **Attachments** — link a file you produced by absolute \`file://\` URL:
|
|
62
|
+
\`[report.md](file:///abs/path/report.md)\`. Images render as thumbnails,
|
|
63
|
+
other files as a download card, wherever on disk you wrote it. The same
|
|
64
|
+
convention runs inbound: a user message ending in \`[name](file:///…)\`
|
|
65
|
+
lines is carrying files the sender attached, already saved to disk — read
|
|
66
|
+
one only when it matters to the task; every read puts its content in your
|
|
67
|
+
context for good.
|
|
68
|
+
- **Staying silent** — \`<silent>why</silent>\` is stripped, and if nothing else
|
|
69
|
+
remains no message is sent. In a group chat you are handed every message,
|
|
70
|
+
including humans talking to each other: stay silent rather than acknowledge
|
|
71
|
+
what was not addressed to you.
|
|
72
|
+
|
|
73
|
+
A message may start with \`[name<id> time place]\` — the sender and the chat,
|
|
74
|
+
added by Pier, not typed by them. It appears only on a change — new speaker, a
|
|
75
|
+
~10-minute gap, a new day — so the last one still applies; a gap alone shows as
|
|
76
|
+
time only, like \`[14:23]\`. Use that \`id\` to mention someone; never ask for
|
|
77
|
+
their own. \`place\` is \`<platform>:<conversation>\` (Slack:
|
|
78
|
+
\`slack:<channel>/<thread_ts>\`), said once per session: the channel and thread a
|
|
79
|
+
script takes. Where no tool of yours takes that platform's ids, the header
|
|
80
|
+
carries neither and reads \`[name time platform]\`. A last \`lang=zh\` (or
|
|
81
|
+
\`en\`, \`ja\`, …) means the sender switched to that language: reply in it
|
|
82
|
+
until another one appears, whatever language the context around it is in.
|
|
83
|
+
`;
|
|
84
|
+
/** Deployment facts an agent cannot discover: a guessed path is wrong wherever
|
|
85
|
+
* `PIER_HOME` moved and fails as "nothing is configured"; GPT models carry
|
|
86
|
+
* `apply_patch` from post-training and go hunting for it in the shell. */
|
|
87
|
+
export function surfacePrompt(instance) {
|
|
88
|
+
const reach = instance.publicUrl
|
|
89
|
+
? `Address: ${instance.publicUrl} — a board's link is that plus ` +
|
|
90
|
+
"`/boards/<slug>/`, or `/p/<slug>-<token>/` once published, where `token` " +
|
|
91
|
+
"is the random field the manifest carries beside `public`."
|
|
92
|
+
: "No public address is configured (the user sets one in Console → Settings), " +
|
|
93
|
+
"so give paths and never guess a host.";
|
|
94
|
+
return `${REPLY_SURFACE_PROMPT}
|
|
95
|
+
## This Pier instance
|
|
96
|
+
|
|
97
|
+
Boards: \`${instance.boardsDir}/<slug>/\` — this path, not \`~/.pier\`. ${reach}
|
|
98
|
+
|
|
99
|
+
Editing: files change through the \`edit\` tool (exact text replacement) or
|
|
100
|
+
\`write\`. There is no \`apply_patch\` here — not as a tool, not as a command —
|
|
101
|
+
so do not call one or go looking for one in the shell.
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The Pi-config seams the Console asks agent/ for, declared by the side that
|
|
2
|
+
// answers them. Imports no SDK and no node:* — web/ui bundles it type-only —
|
|
3
|
+
// so any area may import it; keep it implementable over RPC.
|
|
4
|
+
/** The global files the configuration document carries (`web/config-sync.ts`):
|
|
5
|
+
* the three whole, settings.json only its two defaults. */
|
|
6
|
+
export const SNAPSHOT_FILES = ["SYSTEM.md", "AGENTS.md", "models.json", "settings.json"];
|
|
7
|
+
export class PackageError extends Error {
|
|
8
|
+
reason;
|
|
9
|
+
constructor(reason, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.reason = reason;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
// Wire-protocol names, not SDK types — but they are pi-ai's spellings, and a
|
|
15
|
+
// non-Pi backend is bound to them by this seam.
|
|
16
|
+
const PROVIDER_APIS = [
|
|
17
|
+
"openai-completions",
|
|
18
|
+
"openai-responses",
|
|
19
|
+
"anthropic-messages",
|
|
20
|
+
"google-generative-ai",
|
|
21
|
+
];
|
|
22
|
+
export const isProviderApi = (value) => typeof value === "string" && PROVIDER_APIS.includes(value);
|
|
23
|
+
/** How far a model's reasoning goes. Every reasoning model offers up to
|
|
24
|
+
* "high"; the two levels above it exist only for a model whose catalog entry
|
|
25
|
+
* says so, which is the one thing a Console-defined model could not say. */
|
|
26
|
+
export const MODEL_EFFORTS = ["high", "xhigh", "max"];
|
|
27
|
+
/** The rules of the ProviderSetup seam, in one place: agent/ enforces them on
|
|
28
|
+
* write and web/ pre-checks them at its HTTP boundary. Throws the message the
|
|
29
|
+
* surface shows. */
|
|
30
|
+
export function validateProviderSetup(input) {
|
|
31
|
+
if (input.id.length > 100 || !/^[a-z0-9][a-z0-9._-]*$/.test(input.id)) {
|
|
32
|
+
throw new Error("invalid provider id");
|
|
33
|
+
}
|
|
34
|
+
if (input.endpoint) {
|
|
35
|
+
if (input.endpoint.length > 2048 || input.endpoint !== input.endpoint.trim()) {
|
|
36
|
+
throw new Error("invalid endpoint");
|
|
37
|
+
}
|
|
38
|
+
validateEndpoint(input.endpoint);
|
|
39
|
+
}
|
|
40
|
+
if (input.kind === "builtin")
|
|
41
|
+
return;
|
|
42
|
+
if (!input.endpoint)
|
|
43
|
+
throw new Error("custom provider endpoint required");
|
|
44
|
+
if (input.name && (input.name.length > 200 || input.name !== input.name.trim())) {
|
|
45
|
+
throw new Error("invalid provider name");
|
|
46
|
+
}
|
|
47
|
+
if (!isProviderApi(input.api))
|
|
48
|
+
throw new Error("unsupported provider API");
|
|
49
|
+
if (!input.models.length || input.models.length > 100)
|
|
50
|
+
throw new Error("1-100 models required");
|
|
51
|
+
const ids = input.models.map((model) => model.id);
|
|
52
|
+
if (ids.some((id) => !id || id.length > 200 || id !== id.trim()) || new Set(ids).size !== ids.length) {
|
|
53
|
+
throw new Error("model ids must be non-empty, trimmed and unique");
|
|
54
|
+
}
|
|
55
|
+
// An effort ceiling on a model that does not reason would be written into
|
|
56
|
+
// the catalog and never offered — a setting that lies about itself.
|
|
57
|
+
if (input.models.some((model) => model.effort !== undefined && !model.reasoning)) {
|
|
58
|
+
throw new Error("effort requires reasoning");
|
|
59
|
+
}
|
|
60
|
+
if (input.models.some((model) => model.effort !== undefined && !MODEL_EFFORTS.includes(model.effort))) {
|
|
61
|
+
throw new Error("unsupported model effort");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function validateEndpoint(endpoint) {
|
|
65
|
+
let url;
|
|
66
|
+
try {
|
|
67
|
+
url = new URL(endpoint);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new Error("endpoint must be an http(s) URL");
|
|
71
|
+
}
|
|
72
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
73
|
+
throw new Error("endpoint must be an http(s) URL");
|
|
74
|
+
}
|
|
75
|
+
if (url.username || url.password || url.search || url.hash) {
|
|
76
|
+
throw new Error("endpoint must not contain credentials, query or fragment");
|
|
77
|
+
}
|
|
78
|
+
}
|
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
|
}
|
|
@@ -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) {
|
|
@@ -91,11 +79,10 @@ export class LarkOutbound {
|
|
|
91
79
|
}
|
|
92
80
|
/** No footer: the turn this input triggers has not ended. Answers with the
|
|
93
81
|
* 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");
|
|
82
|
+
async note(to, note) {
|
|
96
83
|
let messageId;
|
|
97
|
-
for (const part of chunk(
|
|
98
|
-
messageId = (await this.
|
|
84
|
+
for (const part of chunk(noteBody(note, "*"), LARK_MAX)) {
|
|
85
|
+
messageId = (await this.post(to, card([markdown(part)]))).messageId;
|
|
99
86
|
}
|
|
100
87
|
return messageId;
|
|
101
88
|
}
|
|
@@ -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 = ["`", "`"];
|