@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
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,15 @@ export class LarkChannel {
|
|
|
162
160
|
const text = raw.trim();
|
|
163
161
|
const command = parseCommand(text);
|
|
164
162
|
const root = threadOf(msg);
|
|
165
|
-
|
|
163
|
+
// Every message in the home chat, in a topic or not, is the head's, under one key.
|
|
164
|
+
const home = this.isHome(msg.chatId);
|
|
165
|
+
const here = { channelId: this.id, conversationId: home ? msg.chatId : conversationId(msg.chatId, root) };
|
|
166
166
|
const bindRequest = command?.name === "bind" && isDm;
|
|
167
167
|
const admitted = this.gate.admit("message", msg.chatId, {
|
|
168
168
|
isDm,
|
|
169
169
|
// Mentioned, or continuing a topic Pier already owns — durable, so it
|
|
170
170
|
// holds after a restart.
|
|
171
|
-
addressed: mentioned || (!!msg.rootId && !!this.deps.control?.knows(here)),
|
|
171
|
+
addressed: mentioned || (!home && !!msg.rootId && !!this.deps.control?.knows(here)),
|
|
172
172
|
userId: senderId,
|
|
173
173
|
bindRequest,
|
|
174
174
|
});
|
|
@@ -179,22 +179,29 @@ export class LarkChannel {
|
|
|
179
179
|
}
|
|
180
180
|
if (bindRequest)
|
|
181
181
|
return this.bind(senderId, msg.messageId, command?.args ?? "");
|
|
182
|
-
|
|
182
|
+
// The head has no panel and takes `/stop` and `/skills` as chat commands (core/chain.ts).
|
|
183
|
+
if (!text && !attachments.length)
|
|
184
|
+
return this.log(`empty message in ${here.conversationId}, dropped`);
|
|
185
|
+
if (!home && command?.name === "stop")
|
|
183
186
|
return this.abortTurn(here, msg.messageId);
|
|
187
|
+
if (!home && command?.name === "skills")
|
|
188
|
+
return this.listSkills(here);
|
|
184
189
|
// Downloading only past the gate: an unauthorized sender must not make the
|
|
185
190
|
// bot pull bytes on their behalf.
|
|
186
191
|
const markers = await this.saveAttachments(msg.messageId, attachments);
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
// The held question carries its markers, so Start sends what the user sent.
|
|
192
|
+
// `/s <text>` drafts a session, so only where this message would start one:
|
|
193
|
+
// outside any topic. The held question carries its markers, so Start sends what the user sent.
|
|
190
194
|
const question = msg.rootId ? undefined : settingsDraft(text);
|
|
191
|
-
if (this.panel && (question || command?.name === "settings"
|
|
195
|
+
if (this.panel && !home && (question || command?.name === "settings")) {
|
|
192
196
|
return this.panel.open(here, root, question && [question, ...markers].join("\n"));
|
|
193
197
|
}
|
|
194
198
|
// Resolved before the mark: any await between mark() and dispatch is a
|
|
195
199
|
// window in which a previous turn can settle and take this receipt with it.
|
|
196
200
|
const sender = { id: senderId, name: await this.userName(senderId) };
|
|
197
|
-
|
|
201
|
+
// The head answers a chat command with a note, not a turn: nothing would take a 👀 off it.
|
|
202
|
+
const answered = home && command && !command.args && isChatCommand(command.name);
|
|
203
|
+
if (!answered)
|
|
204
|
+
this.receipts.mark(here.conversationId, msg.chatId, msg.messageId);
|
|
198
205
|
// Steer: a follow-up is the wrong default when the human is watching a 👀.
|
|
199
206
|
onMessage({
|
|
200
207
|
key: here,
|
|
@@ -301,14 +308,18 @@ export class LarkChannel {
|
|
|
301
308
|
? action.name.slice(CWD_SUBMIT_PREFIX.length)
|
|
302
309
|
: "";
|
|
303
310
|
const root = action.value?.root ?? formRoot;
|
|
304
|
-
|
|
311
|
+
// Any tap in the home chat is the head's, echoed where the card was;
|
|
312
|
+
// anywhere else only a topic's card is Pier's.
|
|
313
|
+
const home = this.isHome(action.chatId);
|
|
314
|
+
if (!root && !(home && payload.startsWith(OFFER_PREFIX))) {
|
|
305
315
|
this.log(`card action without a thread root in ${action.chatId}, dropped`);
|
|
306
316
|
return;
|
|
307
317
|
}
|
|
308
318
|
const key = {
|
|
309
319
|
channelId: this.id,
|
|
310
|
-
conversationId: conversationId(action.chatId, root),
|
|
320
|
+
conversationId: home ? action.chatId : conversationId(action.chatId, root),
|
|
311
321
|
};
|
|
322
|
+
const to = root ? { root } : { chatId: action.chatId };
|
|
312
323
|
const admitted = this.gate.admit("action", action.chatId, {
|
|
313
324
|
isDm: this.deps.store.chat("lark", action.chatId)?.kind === "dm",
|
|
314
325
|
addressed: true, // clicking the bot's own button is addressing it
|
|
@@ -316,11 +327,12 @@ export class LarkChannel {
|
|
|
316
327
|
});
|
|
317
328
|
if (!admitted)
|
|
318
329
|
return;
|
|
319
|
-
|
|
330
|
+
// The head has no panel: a card left in the chat before it became the home is stale.
|
|
331
|
+
if (!home && formRoot && action.formValue) {
|
|
320
332
|
await this.panel?.onCwdSubmit(key, action, root);
|
|
321
333
|
return;
|
|
322
334
|
}
|
|
323
|
-
if (payload.startsWith(PANEL_PREFIX)) {
|
|
335
|
+
if (!home && payload.startsWith(PANEL_PREFIX)) {
|
|
324
336
|
// Start's question: the card is the message the tap was on, so it carries the 👀.
|
|
325
337
|
const run = (text) => this.deliver(key, action, action.messageId, text, onMessage);
|
|
326
338
|
if (!(await this.panel?.onAction(action, key, payload, root, run))) {
|
|
@@ -336,14 +348,14 @@ export class LarkChannel {
|
|
|
336
348
|
: undefined;
|
|
337
349
|
if (label === undefined) {
|
|
338
350
|
this.log(`unknown or stale action ${payload} in chat ${action.chatId}`);
|
|
339
|
-
await this.
|
|
351
|
+
await this.out.post(to, card([markdown(STALE_OPTION)]))
|
|
340
352
|
.catch((err) => this.log(`stale-option notice failed: ${String(err)}`));
|
|
341
353
|
return;
|
|
342
354
|
}
|
|
343
355
|
// A bot cannot post as the user, so the pick is echoed: otherwise the
|
|
344
356
|
// topic shows an answer to a request nobody can see, with nothing to carry the eyes.
|
|
345
357
|
await this.out.retire(action.messageId);
|
|
346
|
-
const echo = await this.
|
|
358
|
+
const echo = await this.out.post(to, card([markdown(picked(label))]))
|
|
347
359
|
.catch((err) => {
|
|
348
360
|
this.log(`option echo failed: ${String(err)}`);
|
|
349
361
|
return undefined;
|
|
@@ -363,6 +375,11 @@ export class LarkChannel {
|
|
|
363
375
|
await this.deps.control?.abort(key);
|
|
364
376
|
await this.api.replyCard(messageId, card([markdown(STOPPED)]));
|
|
365
377
|
}
|
|
378
|
+
/** The head's `/skills` is MainChain's; a thread has no chain, so it is answered here. */
|
|
379
|
+
async listSkills(key) {
|
|
380
|
+
const text = skillsText((await this.deps.control?.skills(key)) ?? []);
|
|
381
|
+
await this.notify(key.conversationId, { text, origin: { kind: "chat-command", command: "skills" } });
|
|
382
|
+
}
|
|
366
383
|
// --- bind ------------------------------------------------------------------
|
|
367
384
|
/** Groups stay silent, but a DM that swallows every message looks broken
|
|
368
385
|
* rather than locked. */
|
|
@@ -378,6 +395,16 @@ export class LarkChannel {
|
|
|
378
395
|
await this.api.replyCard(messageId, card([markdown(bindResult(outcome, name))]));
|
|
379
396
|
}
|
|
380
397
|
// --- lookups ---------------------------------------------------------------
|
|
398
|
+
isHome(chatId) {
|
|
399
|
+
return this.deps.control?.isHome({ channelId: this.id, conversationId: chatId }) ?? false;
|
|
400
|
+
}
|
|
401
|
+
/** A topic, or the home chat's main flow; undefined for any other chat's. */
|
|
402
|
+
target(conversation) {
|
|
403
|
+
const { chatId, root } = parseConversation(conversation);
|
|
404
|
+
if (root)
|
|
405
|
+
return { root };
|
|
406
|
+
return this.isHome(chatId) ? { chatId } : undefined;
|
|
407
|
+
}
|
|
381
408
|
async userName(openId) {
|
|
382
409
|
const hit = this.names.get(openId);
|
|
383
410
|
if (hit)
|
|
@@ -400,33 +427,28 @@ export class LarkChannel {
|
|
|
400
427
|
}
|
|
401
428
|
// --- outbound --------------------------------------------------------------
|
|
402
429
|
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 (!
|
|
430
|
+
const to = this.target(conversation);
|
|
431
|
+
// No root outside the home chat is a foreign id; posting it would put a
|
|
432
|
+
// turn in the chat's main flow. Refused loudly, receipts still cleared.
|
|
433
|
+
if (!to) {
|
|
407
434
|
this.log(`refusing to answer ${conversation}: no thread root in the conversation id`);
|
|
408
435
|
await this.receipts.settle(conversation);
|
|
409
436
|
return;
|
|
410
437
|
}
|
|
411
438
|
// 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));
|
|
439
|
+
await this.receipts.settleAfter(conversation, () => this.out.reply(to, reply), reply.meta);
|
|
418
440
|
}
|
|
419
441
|
/** The 👀 goes on the note itself: the turn it triggers has no message of
|
|
420
442
|
* the user's to carry them. */
|
|
421
443
|
async notify(conversation, note) {
|
|
422
|
-
const
|
|
423
|
-
if (!
|
|
444
|
+
const to = this.target(conversation);
|
|
445
|
+
if (!to) {
|
|
424
446
|
this.log(`refusing to post a system note to ${conversation}: no thread root in the conversation id`);
|
|
425
447
|
return;
|
|
426
448
|
}
|
|
427
|
-
const messageId = await this.out.note(
|
|
449
|
+
const messageId = await this.out.note(to, note);
|
|
428
450
|
if (messageId && awaitsTurn(note.origin)) {
|
|
429
|
-
this.receipts.mark(conversation, chatId, messageId, note.at);
|
|
451
|
+
this.receipts.mark(conversation, parseConversation(conversation).chatId, messageId, note.at);
|
|
430
452
|
}
|
|
431
453
|
}
|
|
432
454
|
}
|
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
|
}
|