@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/channels/lark.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
// Lark (Feishu) adapter: normalize long-connection events, render outbound
|
|
2
|
-
// turns as cards. As on Slack, Pier never posts into a chat's main flow
|
|
3
|
-
// conversation is
|
|
4
|
-
//
|
|
2
|
+
// turns as cards. As on Slack, Pier never posts into a chat's main flow but the
|
|
3
|
+
// home chat's (docs/design/11-im-conversation.md), which is one conversation
|
|
4
|
+
// keyed `<chatId>`: any other is `<chatId>/<rootMessageId>` (`reply_in_thread`;
|
|
5
|
+
// DMs thread too). Lark-specific: `content` is a double-encoded JSON string and a mention
|
|
5
6
|
// is a `@_user_N` placeholder resolved through `mentions[]`; 👀 is the reaction
|
|
6
7
|
// key `OnIt`, removed by reaction_id; a card callback does not say which thread
|
|
7
8
|
// its message lives in, so every button value carries the root; delivery is
|
|
8
9
|
// at-least-once and acked when the handler returns, so handlers queue and return.
|
|
10
|
+
import { isChatCommand } from "../core/types.js";
|
|
9
11
|
import { saveInboundAll } from "../core/inbox.js";
|
|
10
12
|
import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
13
|
+
import { skillsText } from "../core/chain.js";
|
|
11
14
|
import { awaitsTurn } from "../core/reply.js";
|
|
12
15
|
import { bindHint, bindResult, picked, STALE_OPTION, STOPPED } from "./lines.js";
|
|
13
16
|
import { logger } from "../log.js";
|
|
@@ -31,7 +34,7 @@ const DEDUP_TTL_MS = 5 * 60_000;
|
|
|
31
34
|
const DEDUP_MAX = 2000;
|
|
32
35
|
/** The only definition of the conversation id format; control.ts decodes with it. */
|
|
33
36
|
const conversationId = (chatId, root) => `${chatId}/${root}`;
|
|
34
|
-
|
|
37
|
+
const parseConversation = (id) => {
|
|
35
38
|
const at = id.indexOf("/");
|
|
36
39
|
return at < 0
|
|
37
40
|
? { chatId: id, root: "" }
|
|
@@ -75,13 +78,8 @@ export class LarkChannel {
|
|
|
75
78
|
? this.api.addReaction(messageId, emoji)
|
|
76
79
|
: this.api.removeReaction(messageId, WORKING),
|
|
77
80
|
}, deps.receipts ?? new ReceiptLedger("lark"), this.log, WORKING, RECEIPT_STALE_MS, (conversationId) => deps.control?.working({ channelId: this.id, conversationId }) ?? false);
|
|
78
|
-
if (deps.control
|
|
79
|
-
this.panel = new LarkPanel({
|
|
80
|
-
api: this.api,
|
|
81
|
-
control: deps.control,
|
|
82
|
-
handoff: deps.handoff,
|
|
83
|
-
log: this.log,
|
|
84
|
-
});
|
|
81
|
+
if (deps.control) {
|
|
82
|
+
this.panel = new LarkPanel({ api: this.api, control: deps.control, log: this.log });
|
|
85
83
|
}
|
|
86
84
|
}
|
|
87
85
|
async start(onMessage) {
|
|
@@ -162,13 +160,16 @@ export class LarkChannel {
|
|
|
162
160
|
const text = raw.trim();
|
|
163
161
|
const command = parseCommand(text);
|
|
164
162
|
const root = threadOf(msg);
|
|
165
|
-
|
|
163
|
+
// The home chat is the head's, in a topic or not, under one key — except a
|
|
164
|
+
// child's topic (docs/design/11 §Child threads), which is that session's.
|
|
165
|
+
const home = this.isHead(msg.chatId, msg.rootId);
|
|
166
|
+
const here = { channelId: this.id, conversationId: home ? msg.chatId : conversationId(msg.chatId, root) };
|
|
166
167
|
const bindRequest = command?.name === "bind" && isDm;
|
|
167
168
|
const admitted = this.gate.admit("message", msg.chatId, {
|
|
168
169
|
isDm,
|
|
169
170
|
// Mentioned, or continuing a topic Pier already owns — durable, so it
|
|
170
171
|
// holds after a restart.
|
|
171
|
-
addressed: mentioned || (!!msg.rootId && !!this.deps.control?.knows(here)),
|
|
172
|
+
addressed: mentioned || (!home && !!msg.rootId && !!this.deps.control?.knows(here)),
|
|
172
173
|
userId: senderId,
|
|
173
174
|
bindRequest,
|
|
174
175
|
});
|
|
@@ -179,22 +180,29 @@ export class LarkChannel {
|
|
|
179
180
|
}
|
|
180
181
|
if (bindRequest)
|
|
181
182
|
return this.bind(senderId, msg.messageId, command?.args ?? "");
|
|
182
|
-
|
|
183
|
+
// The head has no panel and takes `/stop` and `/skills` as chat commands (core/chain.ts).
|
|
184
|
+
if (!text && !attachments.length)
|
|
185
|
+
return this.log(`empty message in ${here.conversationId}, dropped`);
|
|
186
|
+
if (!home && command?.name === "stop")
|
|
183
187
|
return this.abortTurn(here, msg.messageId);
|
|
188
|
+
if (!home && command?.name === "skills")
|
|
189
|
+
return this.listSkills(here);
|
|
184
190
|
// Downloading only past the gate: an unauthorized sender must not make the
|
|
185
191
|
// bot pull bytes on their behalf.
|
|
186
192
|
const markers = await this.saveAttachments(msg.messageId, attachments);
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// The held question carries its markers, so Start sends what the user sent.
|
|
193
|
+
// `/s <text>` drafts a session, so only where this message would start one:
|
|
194
|
+
// outside any topic. The held question carries its markers, so Start sends what the user sent.
|
|
190
195
|
const question = msg.rootId ? undefined : settingsDraft(text);
|
|
191
|
-
if (this.panel && (question || command?.name === "settings"
|
|
196
|
+
if (this.panel && !home && (question || command?.name === "settings")) {
|
|
192
197
|
return this.panel.open(here, root, question && [question, ...markers].join("\n"));
|
|
193
198
|
}
|
|
194
199
|
// Resolved before the mark: any await between mark() and dispatch is a
|
|
195
200
|
// window in which a previous turn can settle and take this receipt with it.
|
|
196
201
|
const sender = { id: senderId, name: await this.userName(senderId) };
|
|
197
|
-
|
|
202
|
+
// The head answers a chat command with a note, not a turn: nothing would take a 👀 off it.
|
|
203
|
+
const answered = home && command && !command.args && isChatCommand(command.name);
|
|
204
|
+
if (!answered)
|
|
205
|
+
this.receipts.mark(here.conversationId, msg.chatId, msg.messageId);
|
|
198
206
|
// Steer: a follow-up is the wrong default when the human is watching a 👀.
|
|
199
207
|
onMessage({
|
|
200
208
|
key: here,
|
|
@@ -301,14 +309,18 @@ export class LarkChannel {
|
|
|
301
309
|
? action.name.slice(CWD_SUBMIT_PREFIX.length)
|
|
302
310
|
: "";
|
|
303
311
|
const root = action.value?.root ?? formRoot;
|
|
304
|
-
|
|
312
|
+
// Any tap in the home chat is the head's, echoed where the card was,
|
|
313
|
+
// unless a child's topic; anywhere else only a topic's card is Pier's.
|
|
314
|
+
const home = this.isHead(action.chatId, root);
|
|
315
|
+
if (!root && !(home && payload.startsWith(OFFER_PREFIX))) {
|
|
305
316
|
this.log(`card action without a thread root in ${action.chatId}, dropped`);
|
|
306
317
|
return;
|
|
307
318
|
}
|
|
308
319
|
const key = {
|
|
309
320
|
channelId: this.id,
|
|
310
|
-
conversationId: conversationId(action.chatId, root),
|
|
321
|
+
conversationId: home ? action.chatId : conversationId(action.chatId, root),
|
|
311
322
|
};
|
|
323
|
+
const to = root ? { root } : { chatId: action.chatId };
|
|
312
324
|
const admitted = this.gate.admit("action", action.chatId, {
|
|
313
325
|
isDm: this.deps.store.chat("lark", action.chatId)?.kind === "dm",
|
|
314
326
|
addressed: true, // clicking the bot's own button is addressing it
|
|
@@ -316,11 +328,12 @@ export class LarkChannel {
|
|
|
316
328
|
});
|
|
317
329
|
if (!admitted)
|
|
318
330
|
return;
|
|
319
|
-
|
|
331
|
+
// The head has no panel: a card left in the chat before it became the home is stale.
|
|
332
|
+
if (!home && formRoot && action.formValue) {
|
|
320
333
|
await this.panel?.onCwdSubmit(key, action, root);
|
|
321
334
|
return;
|
|
322
335
|
}
|
|
323
|
-
if (payload.startsWith(PANEL_PREFIX)) {
|
|
336
|
+
if (!home && payload.startsWith(PANEL_PREFIX)) {
|
|
324
337
|
// Start's question: the card is the message the tap was on, so it carries the 👀.
|
|
325
338
|
const run = (text) => this.deliver(key, action, action.messageId, text, onMessage);
|
|
326
339
|
if (!(await this.panel?.onAction(action, key, payload, root, run))) {
|
|
@@ -336,14 +349,14 @@ export class LarkChannel {
|
|
|
336
349
|
: undefined;
|
|
337
350
|
if (label === undefined) {
|
|
338
351
|
this.log(`unknown or stale action ${payload} in chat ${action.chatId}`);
|
|
339
|
-
await this.
|
|
352
|
+
await this.out.post(to, card([markdown(STALE_OPTION)]))
|
|
340
353
|
.catch((err) => this.log(`stale-option notice failed: ${String(err)}`));
|
|
341
354
|
return;
|
|
342
355
|
}
|
|
343
356
|
// A bot cannot post as the user, so the pick is echoed: otherwise the
|
|
344
357
|
// topic shows an answer to a request nobody can see, with nothing to carry the eyes.
|
|
345
358
|
await this.out.retire(action.messageId);
|
|
346
|
-
const echo = await this.
|
|
359
|
+
const echo = await this.out.post(to, card([markdown(picked(label))]))
|
|
347
360
|
.catch((err) => {
|
|
348
361
|
this.log(`option echo failed: ${String(err)}`);
|
|
349
362
|
return undefined;
|
|
@@ -363,6 +376,11 @@ export class LarkChannel {
|
|
|
363
376
|
await this.deps.control?.abort(key);
|
|
364
377
|
await this.api.replyCard(messageId, card([markdown(STOPPED)]));
|
|
365
378
|
}
|
|
379
|
+
/** The head's `/skills` is MainChain's; a thread has no chain, so it is answered here. */
|
|
380
|
+
async listSkills(key) {
|
|
381
|
+
const text = skillsText((await this.deps.control?.skills(key)) ?? []);
|
|
382
|
+
await this.notify(key.conversationId, { text, origin: { kind: "chat-command", command: "skills" } });
|
|
383
|
+
}
|
|
366
384
|
// --- bind ------------------------------------------------------------------
|
|
367
385
|
/** Groups stay silent, but a DM that swallows every message looks broken
|
|
368
386
|
* rather than locked. */
|
|
@@ -378,6 +396,21 @@ export class LarkChannel {
|
|
|
378
396
|
await this.api.replyCard(messageId, card([markdown(bindResult(outcome, name))]));
|
|
379
397
|
}
|
|
380
398
|
// --- lookups ---------------------------------------------------------------
|
|
399
|
+
isHome(chatId) {
|
|
400
|
+
return this.deps.control?.isHome({ channelId: this.id, conversationId: chatId }) ?? false;
|
|
401
|
+
}
|
|
402
|
+
/** The head's: the home chat, outside any topic a session of its own is bound to. */
|
|
403
|
+
isHead(chatId, root) {
|
|
404
|
+
return this.isHome(chatId) &&
|
|
405
|
+
!(root && this.deps.control?.knows({ channelId: this.id, conversationId: conversationId(chatId, root) }));
|
|
406
|
+
}
|
|
407
|
+
/** A topic, or the home chat's main flow; undefined for any other chat's. */
|
|
408
|
+
target(conversation) {
|
|
409
|
+
const { chatId, root } = parseConversation(conversation);
|
|
410
|
+
if (root)
|
|
411
|
+
return { root };
|
|
412
|
+
return this.isHome(chatId) ? { chatId } : undefined;
|
|
413
|
+
}
|
|
381
414
|
async userName(openId) {
|
|
382
415
|
const hit = this.names.get(openId);
|
|
383
416
|
if (hit)
|
|
@@ -400,33 +433,42 @@ export class LarkChannel {
|
|
|
400
433
|
}
|
|
401
434
|
// --- outbound --------------------------------------------------------------
|
|
402
435
|
async send(conversation, reply) {
|
|
403
|
-
const
|
|
404
|
-
// No root is a foreign id; posting it would put a
|
|
405
|
-
// flow. Refused loudly, receipts still cleared.
|
|
406
|
-
if (!
|
|
436
|
+
const to = this.target(conversation);
|
|
437
|
+
// No root outside the home chat is a foreign id; posting it would put a
|
|
438
|
+
// turn in the chat's main flow. Refused loudly, receipts still cleared.
|
|
439
|
+
if (!to) {
|
|
407
440
|
this.log(`refusing to answer ${conversation}: no thread root in the conversation id`);
|
|
408
441
|
await this.receipts.settle(conversation);
|
|
409
442
|
return;
|
|
410
443
|
}
|
|
411
444
|
// The turn ended either way; a 👀 left up by a failed send looks like work.
|
|
412
|
-
await this.receipts.settleAfter(conversation, () => this.out.reply(
|
|
413
|
-
}
|
|
414
|
-
/** A web session's topic: the root is the one card Pier posts into a
|
|
415
|
-
* chat's main flow (channels/handoff.ts). */
|
|
416
|
-
async openThread(chatId, note) {
|
|
417
|
-
return conversationId(chatId, await this.out.open(chatId, note));
|
|
445
|
+
await this.receipts.settleAfter(conversation, () => this.out.reply(to, reply), reply.meta);
|
|
418
446
|
}
|
|
419
447
|
/** The 👀 goes on the note itself: the turn it triggers has no message of
|
|
420
448
|
* the user's to carry them. */
|
|
421
449
|
async notify(conversation, note) {
|
|
422
|
-
const
|
|
423
|
-
if (!
|
|
450
|
+
const to = this.target(conversation);
|
|
451
|
+
if (!to) {
|
|
424
452
|
this.log(`refusing to post a system note to ${conversation}: no thread root in the conversation id`);
|
|
425
453
|
return;
|
|
426
454
|
}
|
|
427
|
-
const messageId = await this.out.note(
|
|
455
|
+
const messageId = await this.out.note(to, note);
|
|
428
456
|
if (messageId && awaitsTurn(note.origin)) {
|
|
429
|
-
this.receipts.mark(conversation, chatId, messageId, note.at);
|
|
457
|
+
this.receipts.mark(conversation, parseConversation(conversation).chatId, messageId, note.at);
|
|
430
458
|
}
|
|
431
459
|
}
|
|
460
|
+
async openThread(chatId, note) {
|
|
461
|
+
if (!this.isHome(chatId))
|
|
462
|
+
throw new Error(`refusing to open a topic in ${chatId}: not the home chat`);
|
|
463
|
+
const messageId = await this.out.note({ chatId }, note);
|
|
464
|
+
if (!messageId)
|
|
465
|
+
throw new Error(`Lark returned no message id for the root in ${chatId}`);
|
|
466
|
+
return conversationId(chatId, messageId);
|
|
467
|
+
}
|
|
468
|
+
async editRoot(conversation, note) {
|
|
469
|
+
const { root } = parseConversation(conversation);
|
|
470
|
+
if (!root)
|
|
471
|
+
throw new Error(`refusing to edit ${conversation}: no thread root in the conversation id`);
|
|
472
|
+
await this.out.edit(root, note);
|
|
473
|
+
}
|
|
432
474
|
}
|
package/dist/channels/lines.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// What the shared control moments say — one spelling for both platforms. The
|
|
2
|
-
// only legitimate
|
|
2
|
+
// only legitimate variations are platform spellings (the bind command, a label's
|
|
3
|
+
// emphasis), so they are the parameters.
|
|
4
|
+
import { originLabel } from "../core/reply.js";
|
|
3
5
|
export const bindHint = (command) => `You are not bound yet. Ask the operator for a bind code, then send ${command}.`;
|
|
4
6
|
/** The name arrives escaped by the caller. */
|
|
5
7
|
export const bindResult = (outcome, name) => {
|
|
@@ -15,3 +17,33 @@ export const STOPPED = "\u23f9 Stopped.";
|
|
|
15
17
|
export const picked = (label) => `\u25b8 ${label}`;
|
|
16
18
|
/** Said in the chat: the person clicked and would otherwise see nothing happen (§5). */
|
|
17
19
|
export const STALE_OPTION = "⚠ That option is no longer available — please type the choice instead.";
|
|
20
|
+
/** What a chat window gets of a system input; a task callback carries up to
|
|
21
|
+
* 8000 characters of result text (tasks/callbacks.ts). */
|
|
22
|
+
const NOTE_CHARS = 200;
|
|
23
|
+
const NOTE_LINES = 4;
|
|
24
|
+
function digest(text) {
|
|
25
|
+
const body = text.trimEnd();
|
|
26
|
+
let head = body.split("\n").slice(0, NOTE_LINES).join("\n");
|
|
27
|
+
if (head.length > NOTE_CHARS) {
|
|
28
|
+
const capped = head.slice(0, NOTE_CHARS);
|
|
29
|
+
// A boundary before the midpoint loses more than the ragged edge costs.
|
|
30
|
+
const boundary = Math.max(capped.lastIndexOf("\n"), capped.lastIndexOf(" "));
|
|
31
|
+
head = capped.slice(0, boundary > NOTE_CHARS / 2 ? boundary : NOTE_CHARS);
|
|
32
|
+
}
|
|
33
|
+
const rest = body.slice(head.length).trim();
|
|
34
|
+
if (!rest)
|
|
35
|
+
return body;
|
|
36
|
+
const dropped = rest.split("\n").length;
|
|
37
|
+
return `${head.trimEnd()}\n… +${String(dropped)} more line${dropped === 1 ? "" : "s"}`;
|
|
38
|
+
}
|
|
39
|
+
/** The origin label in the platform's `emphasis` over the quoted body. A system
|
|
40
|
+
* input is context for the turn it precedes: pasted whole, a run result buries
|
|
41
|
+
* a chat that cannot collapse it, so it is digested (the hub keeps it all). An
|
|
42
|
+
* error is quoted whole, core already capped it; so is a chat command's
|
|
43
|
+
* answer, bounded and the reason it was asked. */
|
|
44
|
+
export function noteBody(note, emphasis) {
|
|
45
|
+
const whole = note.origin.kind === "error" || note.origin.kind === "chat-command";
|
|
46
|
+
const text = whole ? note.text : digest(note.text);
|
|
47
|
+
const body = text.split("\n").map((line) => `> ${line}`).join("\n");
|
|
48
|
+
return `${emphasis}${originLabel(note.origin)}${emphasis}\n${body}`;
|
|
49
|
+
}
|
package/dist/channels/panel.js
CHANGED
|
@@ -4,54 +4,26 @@
|
|
|
4
4
|
// without a session shows a draft — cwd, model, reasoning, a pending question —
|
|
5
5
|
// that rides every button's value, so the card is the store and a restart
|
|
6
6
|
// loses nothing.
|
|
7
|
-
import { sessionLabel, splitSpeaker } from "../core/identity.js";
|
|
8
7
|
import { splitInboundFiles } from "../core/inbound-file.js";
|
|
9
|
-
import { compact, cut,
|
|
8
|
+
import { compact, cut, thinkingLabel } from "../core/reply.js";
|
|
10
9
|
import { isThinkingLevel, } from "../core/types.js";
|
|
11
10
|
import { HAS_SESSION, NO_SESSION } from "./control.js";
|
|
12
|
-
import { HandoffError } from "./handoff.js";
|
|
13
11
|
export const PANEL_PREFIX = "cfg:";
|
|
14
12
|
const PER_PAGE = 8;
|
|
15
|
-
/** The picker's reach: five pages of the newest unbound sessions. */
|
|
16
|
-
const SESSIONS_LISTED = 40;
|
|
17
|
-
const TITLE_CHARS = 40;
|
|
18
13
|
const QUESTION_CHARS = 80;
|
|
19
|
-
/** Exchanges excerpted under the session, and the width of one line: four of
|
|
20
|
-
* these stay far inside Slack's 3000-character section. */
|
|
21
|
-
const RECENT_EXCHANGES = 2;
|
|
22
|
-
const EXCERPT_CHARS = 150;
|
|
23
14
|
/** Characters of the serialized draft — what a button actually carries. Slack
|
|
24
15
|
* caps a value at 2000 and Lark a card at 30 KB; the rest of the 2000 is room
|
|
25
16
|
* for the cwd, model and reasoning a pick adds after the question. */
|
|
26
17
|
const DRAFT_CHARS = 1700;
|
|
27
18
|
/** Pages whose every pick Pi's fixed-at-creation cwd or the row would refuse:
|
|
28
19
|
* reachable only by tapping a card that gained a session since it was drawn. */
|
|
29
|
-
const DRAFT_ONLY = new Set(["cwd", "cwdtype"
|
|
20
|
+
const DRAFT_ONLY = new Set(["cwd", "cwdtype"]);
|
|
30
21
|
export const CWD_DRAFT_TAIL = "Start creates the session there.";
|
|
31
22
|
export const CWD_PLACEHOLDER = "/path/to/project";
|
|
32
23
|
export const QUESTION_TOO_LONG = "Your question is too long to hold — send it again after Start.";
|
|
33
24
|
const btn = (label, action) => ({ label, action });
|
|
34
25
|
/** A button label is the last segment; the numbered line above has the whole path. */
|
|
35
26
|
const shortDir = (path) => path.split("/").filter(Boolean).at(-1) ?? path;
|
|
36
|
-
/** One transcript line as a reader sees it: what was said, minus what was
|
|
37
|
-
* written for the model (the speaker header, attachment markers, the
|
|
38
|
-
* next-step block, a silent turn's reason). */
|
|
39
|
-
const excerpt = (text, role) => {
|
|
40
|
-
const said = role === "user" ? splitInboundFiles(splitSpeaker(text).text).text : splitReply(text).text;
|
|
41
|
-
return cut(said.replace(/\s+/g, " ").trim(), EXCERPT_CHARS);
|
|
42
|
-
};
|
|
43
|
-
const shortTitle = (s) => cut(sessionLabel(s), TITLE_CHARS);
|
|
44
|
-
/** Compact age, as the web sidebar spells it ("now", "12m", "3h", "2d"). */
|
|
45
|
-
const age = (ts, now) => {
|
|
46
|
-
const mins = Math.round((now - ts) / 60_000);
|
|
47
|
-
if (mins < 1)
|
|
48
|
-
return "now";
|
|
49
|
-
if (mins < 60)
|
|
50
|
-
return `${String(mins)}m`;
|
|
51
|
-
if (mins < 1440)
|
|
52
|
-
return `${String(Math.round(mins / 60))}h`;
|
|
53
|
-
return `${String(Math.round(mins / 1440))}d`;
|
|
54
|
-
};
|
|
55
27
|
/** Page `page` of `items`, clamped: a stale Next past the end lands on the last page. */
|
|
56
28
|
const paged = (items, page) => {
|
|
57
29
|
const pages = Math.max(1, Math.ceil(items.length / PER_PAGE));
|
|
@@ -64,9 +36,9 @@ const pager = (action, at, pages) => [
|
|
|
64
36
|
...(at < pages - 1 ? [btn("Next ›", `${action}:${String(at + 1)}`)] : []),
|
|
65
37
|
btn("‹ Back", "panel"),
|
|
66
38
|
];
|
|
67
|
-
/** A refusal's sentence (a control refusal
|
|
68
|
-
*
|
|
69
|
-
const failed = (what, err) => err instanceof
|
|
39
|
+
/** A refusal's sentence (a control refusal) is the whole answer; any other
|
|
40
|
+
* failure names what was attempted. */
|
|
41
|
+
const failed = (what, err) => err instanceof Error && (err.message === NO_SESSION || err.message === HAS_SESSION)
|
|
70
42
|
? err.message
|
|
71
43
|
: `${what}: ${String(err)}`;
|
|
72
44
|
/** `undefined` when there is nothing to carry, so a with-session panel's buttons stay bare. */
|
|
@@ -124,8 +96,7 @@ export class ChatPanel {
|
|
|
124
96
|
const status = await this.deps.control.status(key);
|
|
125
97
|
if (!status)
|
|
126
98
|
return this.draftView(key, state.draft);
|
|
127
|
-
// The conversation is right above the card
|
|
128
|
-
// second session — that is a new thread.
|
|
99
|
+
// The conversation is right above the card; a second session is a new thread.
|
|
129
100
|
return {
|
|
130
101
|
groups: [{ title: "Session", lines: this.sessionLines(status) }],
|
|
131
102
|
rows: [[
|
|
@@ -148,7 +119,7 @@ export class ChatPanel {
|
|
|
148
119
|
],
|
|
149
120
|
}],
|
|
150
121
|
rows: [
|
|
151
|
-
[btn("Model & reasoning", "pins:0"), btn("Directory…", "cwd")
|
|
122
|
+
[btn("Model & reasoning", "pins:0"), btn("Directory…", "cwd")],
|
|
152
123
|
[btn("Start", "start"), btn("Close", "close")],
|
|
153
124
|
],
|
|
154
125
|
};
|
|
@@ -163,8 +134,8 @@ export class ChatPanel {
|
|
|
163
134
|
};
|
|
164
135
|
}
|
|
165
136
|
sessionLines(status) {
|
|
166
|
-
const usage = status.tokens !== null && status.
|
|
167
|
-
? `${compact(status.tokens)}/${compact(status.
|
|
137
|
+
const usage = status.tokens !== null && status.compactAt
|
|
138
|
+
? `${compact(status.tokens)}/${compact(status.compactAt)} tok`
|
|
168
139
|
: "not measured yet";
|
|
169
140
|
const fresh = status.empty && status.state === "idle";
|
|
170
141
|
return [
|
|
@@ -186,21 +157,6 @@ export class ChatPanel {
|
|
|
186
157
|
return { items: [], unavailable };
|
|
187
158
|
}
|
|
188
159
|
}
|
|
189
|
-
/** An excerpt, not a summary: the last exchanges are what makes a session
|
|
190
|
-
* one has been away from recognisable on a phone. */
|
|
191
|
-
async recentGroups(key) {
|
|
192
|
-
const { items: exchanges, unavailable } = await this.listed(this.deps.control.recent(key, RECENT_EXCHANGES), "Could not read the transcript");
|
|
193
|
-
if (unavailable)
|
|
194
|
-
return [{ title: "Recent", lines: [unavailable] }];
|
|
195
|
-
const lines = exchanges.flatMap(({ user, assistant }) => {
|
|
196
|
-
const reply = assistant === undefined ? "" : excerpt(assistant, "assistant");
|
|
197
|
-
return [
|
|
198
|
-
`▸ ${this.esc(excerpt(user, "user"))}`,
|
|
199
|
-
...(reply ? [`◂ ${this.esc(reply)}`] : []),
|
|
200
|
-
];
|
|
201
|
-
});
|
|
202
|
-
return lines.length ? [{ title: "Recent", lines }] : [];
|
|
203
|
-
}
|
|
204
160
|
async refresh(key, note) {
|
|
205
161
|
const state = this.state(key);
|
|
206
162
|
if (!state)
|
|
@@ -209,17 +165,12 @@ export class ChatPanel {
|
|
|
209
165
|
}
|
|
210
166
|
/** The card's last draw, once an operation completed: the session as it now
|
|
211
167
|
* is, no button. The panel is released with it, so nothing can tap it again
|
|
212
|
-
* and the card stays as a record; another
|
|
213
|
-
|
|
214
|
-
* was continued. */
|
|
215
|
-
async settle(key, state, note, recent) {
|
|
168
|
+
* and the card stays as a record; another `/settings` opens a fresh one. */
|
|
169
|
+
async settle(key, state, note) {
|
|
216
170
|
const status = await this.deps.control.status(key);
|
|
217
171
|
if (!status)
|
|
218
172
|
return this.refresh(key, note);
|
|
219
|
-
await this.draw(state, {
|
|
220
|
-
groups: [{ title: "Session", lines: this.sessionLines(status) }, ...recent ? await this.recentGroups(key) : []],
|
|
221
|
-
rows: [],
|
|
222
|
-
}, note);
|
|
173
|
+
await this.draw(state, { groups: [{ title: "Session", lines: this.sessionLines(status) }], rows: [] }, note);
|
|
223
174
|
this.panels.delete(key.conversationId);
|
|
224
175
|
}
|
|
225
176
|
// --- actions -----------------------------------------------------------------
|
|
@@ -254,12 +205,6 @@ export class ChatPanel {
|
|
|
254
205
|
case "pin":
|
|
255
206
|
await this.pickPin(key, Number(arg));
|
|
256
207
|
return true;
|
|
257
|
-
case "sessions":
|
|
258
|
-
await this.showSessions(key, Number(arg) || 0);
|
|
259
|
-
return true;
|
|
260
|
-
case "session":
|
|
261
|
-
await this.pickSession(key, Number(arg));
|
|
262
|
-
return true;
|
|
263
208
|
case "cwd":
|
|
264
209
|
if (arg)
|
|
265
210
|
await this.pickDir(key, Number(arg));
|
|
@@ -274,7 +219,7 @@ export class ChatPanel {
|
|
|
274
219
|
return true;
|
|
275
220
|
case "stop":
|
|
276
221
|
await this.deps.control.abort(key);
|
|
277
|
-
await this.settle(key, state, "Stop requested."
|
|
222
|
+
await this.settle(key, state, "Stop requested.");
|
|
278
223
|
return true;
|
|
279
224
|
default:
|
|
280
225
|
this.deps.log(`unknown panel action: ${action}`);
|
|
@@ -299,7 +244,7 @@ export class ChatPanel {
|
|
|
299
244
|
? slice.map((pin, i) => {
|
|
300
245
|
const ticked = current.model?.provider === pin.provider && current.model.id === pin.id
|
|
301
246
|
&& current.thinking === pin.thinking;
|
|
302
|
-
return `${String(from + i + 1)}. ${ticked ? "✓ " : ""}${this.esc(pin.id)} · ${thinkingLabel(pin.thinking)}
|
|
247
|
+
return `${String(from + i + 1)}. ${ticked ? "✓ " : ""}${this.esc(pin.id)} · ${thinkingLabel(pin.thinking)}`;
|
|
303
248
|
})
|
|
304
249
|
: ["No pinned models — Settings → Models → Model menu."],
|
|
305
250
|
}],
|
|
@@ -308,41 +253,6 @@ export class ChatPanel {
|
|
|
308
253
|
rows: [pager("pins", at, pages)],
|
|
309
254
|
});
|
|
310
255
|
}
|
|
311
|
-
async showSessions(key, page) {
|
|
312
|
-
const state = this.state(key);
|
|
313
|
-
if (!state)
|
|
314
|
-
return;
|
|
315
|
-
const { items, unavailable } = await this.listed(this.deps.handoff.unbound(SESSIONS_LISTED), "Could not list sessions");
|
|
316
|
-
state.sessions = items;
|
|
317
|
-
const { at, pages, slice, from } = paged(state.sessions, page);
|
|
318
|
-
const now = Date.now();
|
|
319
|
-
await this.draw(state, {
|
|
320
|
-
groups: [{
|
|
321
|
-
title: "Continue web session",
|
|
322
|
-
suffix: ` · page ${at + 1}/${pages}`,
|
|
323
|
-
lines: slice.length
|
|
324
|
-
? slice.map((s, i) => `${String(from + i + 1)}. ${this.esc(shortTitle(s))} · ${this.code(shortDir(s.cwd))} · ${age(s.modified ?? s.createdAt, now)}`)
|
|
325
|
-
: [unavailable ?? "No unbound sessions."],
|
|
326
|
-
}],
|
|
327
|
-
picks: slice.map((s, i) => btn(`${String(from + i + 1)} ${shortTitle(s)}`, `session:${String(from + i)}`)),
|
|
328
|
-
rows: [pager("sessions", at, pages)],
|
|
329
|
-
});
|
|
330
|
-
}
|
|
331
|
-
async pickSession(key, index) {
|
|
332
|
-
const state = this.state(key);
|
|
333
|
-
const session = state?.sessions[index];
|
|
334
|
-
if (!state || !session)
|
|
335
|
-
return this.refresh(key, "That session is no longer listed.");
|
|
336
|
-
try {
|
|
337
|
-
await this.deps.handoff.continueHere(key, session.id);
|
|
338
|
-
// The bound session has its own settings; the draft is spent.
|
|
339
|
-
state.draft = {};
|
|
340
|
-
await this.settle(key, state, `Continuing session ${session.id.slice(0, 8)} — reply in this thread.`, true);
|
|
341
|
-
}
|
|
342
|
-
catch (err) {
|
|
343
|
-
await this.refresh(key, failed("Could not continue that session", err));
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
256
|
async pickPin(key, index) {
|
|
347
257
|
const state = this.state(key);
|
|
348
258
|
const pin = this.deps.control.pins()[index];
|
|
@@ -356,7 +266,7 @@ export class ChatPanel {
|
|
|
356
266
|
try {
|
|
357
267
|
await this.deps.control.setModel(key, model);
|
|
358
268
|
await this.deps.control.setThinking(key, pin.thinking);
|
|
359
|
-
await this.settle(key, state, `Model set to ${pin.id} · ${thinkingLabel(pin.thinking)}
|
|
269
|
+
await this.settle(key, state, `Model set to ${pin.id} · ${thinkingLabel(pin.thinking)}.`);
|
|
360
270
|
}
|
|
361
271
|
catch (err) {
|
|
362
272
|
await this.refresh(key, failed("Could not set that model", err));
|
|
@@ -415,9 +325,9 @@ export class ChatPanel {
|
|
|
415
325
|
state.draft = {};
|
|
416
326
|
if (q) {
|
|
417
327
|
await run(q);
|
|
418
|
-
return this.settle(key, state, `Started ${id.slice(0, 8)} — running your question
|
|
328
|
+
return this.settle(key, state, `Started ${id.slice(0, 8)} — running your question.`);
|
|
419
329
|
}
|
|
420
330
|
const cwd = (await this.deps.control.status(key))?.cwd ?? launch.cwd ?? "?";
|
|
421
|
-
await this.settle(key, state, `Started ${id.slice(0, 8)} in ${cwd}
|
|
331
|
+
await this.settle(key, state, `Started ${id.slice(0, 8)} in ${cwd}.`);
|
|
422
332
|
}
|
|
423
333
|
}
|
package/dist/channels/routes.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
// Settings → Channels HTTP surface: one document per platform
|
|
2
|
-
// web → IM handoff beside it.
|
|
1
|
+
// Settings → Channels HTTP surface: one document per platform.
|
|
3
2
|
import { isThinkingLevel } from "../core/types.js";
|
|
4
|
-
import { HandoffError } from "./handoff.js";
|
|
5
3
|
import { defaultChannelConfig, isChannelPlatform, } from "./types.js";
|
|
6
4
|
/** Stable mask: recomputable at write time, so "unchanged" is detectable. */
|
|
7
5
|
const maskToken = (token) => token ? `${"•".repeat(8)}${token.slice(-4)}` : "";
|
|
@@ -34,6 +32,7 @@ function parseChats(raw, known) {
|
|
|
34
32
|
return base;
|
|
35
33
|
return {
|
|
36
34
|
...base,
|
|
35
|
+
home: edit.home === true && base.kind === "dm" ? true : undefined,
|
|
37
36
|
enabled: asBool(edit.enabled),
|
|
38
37
|
requireMention: asBool(edit.requireMention),
|
|
39
38
|
requireBind: asBool(edit.requireBind),
|
|
@@ -43,7 +42,7 @@ function parseChats(raw, known) {
|
|
|
43
42
|
};
|
|
44
43
|
});
|
|
45
44
|
}
|
|
46
|
-
export function registerChannelRoutes(app, store, runtime,
|
|
45
|
+
export function registerChannelRoutes(app, store, runtime, conversations) {
|
|
47
46
|
app.get("/api/channels/:platform", (c) => {
|
|
48
47
|
const platform = c.req.param("platform");
|
|
49
48
|
if (!isChannelPlatform(platform))
|
|
@@ -83,7 +82,15 @@ export function registerChannelRoutes(app, store, runtime, handoff, conversation
|
|
|
83
82
|
// next start read as the first one and skip the swapped bot's cleanup.
|
|
84
83
|
botId: current.botId,
|
|
85
84
|
};
|
|
85
|
+
const before = store.home();
|
|
86
86
|
store.save(platform, next);
|
|
87
|
+
const home = next.chats.find((chat) => chat.home);
|
|
88
|
+
if (home) {
|
|
89
|
+
store.setHome(platform, home.id);
|
|
90
|
+
// A thread session opened here before is unreachable once the chat is the head's.
|
|
91
|
+
if (before?.platform !== platform || before.chatId !== home.id)
|
|
92
|
+
conversations.forgetChat(platform, home.id);
|
|
93
|
+
}
|
|
87
94
|
await runtime.reload();
|
|
88
95
|
return c.json({ ok: true });
|
|
89
96
|
});
|
|
@@ -112,21 +119,4 @@ export function registerChannelRoutes(app, store, runtime, handoff, conversation
|
|
|
112
119
|
store.unbind(platform, c.req.param("id"));
|
|
113
120
|
return c.json({ ok: true });
|
|
114
121
|
});
|
|
115
|
-
app.get("/api/handoff/targets", (c) => c.json({ targets: handoff.targets() }));
|
|
116
|
-
app.post("/api/handoff", async (c) => {
|
|
117
|
-
const body = (await c.req.json().catch(() => null));
|
|
118
|
-
const sessionId = asString(body?.sessionId);
|
|
119
|
-
const chatId = asString(body?.chatId);
|
|
120
|
-
const platform = body?.platform;
|
|
121
|
-
if (!sessionId || !chatId || !isChannelPlatform(platform))
|
|
122
|
-
return c.json({ error: "invalid body" }, 400);
|
|
123
|
-
try {
|
|
124
|
-
return c.json(await handoff.continueIn({ sessionId, platform, chatId }), 201);
|
|
125
|
-
}
|
|
126
|
-
catch (err) {
|
|
127
|
-
if (err instanceof HandoffError)
|
|
128
|
-
return c.json({ error: err.message }, err.status);
|
|
129
|
-
throw err;
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
122
|
}
|