@timqi/pier 0.1.3 → 0.1.5
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 +12 -7
- package/dist/agent/config.js +9 -3
- package/dist/agent/events.js +13 -5
- package/dist/agent/listing.js +10 -2
- package/dist/agent/packages.js +4 -20
- package/dist/agent/pi.js +18 -86
- package/dist/channels/attach.js +2 -2
- package/dist/channels/commands.js +10 -8
- package/dist/channels/config.js +32 -24
- package/dist/channels/control.js +60 -14
- package/dist/channels/conversations.js +60 -13
- package/dist/channels/handoff.js +90 -0
- package/dist/channels/lark-api.js +7 -0
- package/dist/channels/lark-outbound.js +19 -0
- package/dist/channels/lark-panel.js +41 -26
- package/dist/channels/lark-render.js +2 -1
- package/dist/channels/lark.js +35 -26
- package/dist/channels/lines.js +1 -1
- package/dist/channels/panel.js +324 -115
- package/dist/channels/receipts.js +25 -12
- package/dist/channels/routes.js +21 -7
- package/dist/channels/runtime.js +21 -7
- package/dist/channels/slack-api.js +19 -10
- package/dist/channels/slack-cli.js +503 -0
- package/dist/channels/slack-directory.js +2 -2
- package/dist/channels/slack-outbound.js +11 -1
- package/dist/channels/slack-panel.js +81 -40
- package/dist/channels/slack-render.js +3 -1
- package/dist/channels/slack-thread.js +41 -0
- package/dist/channels/slack-transcript.js +107 -0
- package/dist/channels/slack.js +38 -31
- package/dist/channels/types.js +1 -3
- package/dist/cli.js +166 -7
- package/dist/core/identity.js +61 -17
- package/dist/core/reply.js +19 -15
- package/dist/core/router.js +23 -3
- package/dist/db.js +33 -0
- package/dist/main.js +61 -41
- package/dist/paths.js +3 -0
- package/dist/secrets.js +2 -1
- package/dist/settings.js +3 -12
- package/dist/socket.js +99 -0
- package/dist/tasks/agent.js +5 -6
- package/dist/tasks/callbacks.js +2 -2
- package/dist/tasks/cli.js +225 -0
- package/dist/tasks/definitions.js +3 -3
- package/dist/tasks/execution.js +1 -3
- package/dist/tasks/groups.js +2 -5
- package/dist/tasks/messages.js +18 -158
- package/dist/tasks/operations.js +335 -0
- package/dist/tasks/routes.js +1 -11
- package/dist/tasks/runs.js +5 -20
- package/dist/tasks/service.js +18 -43
- package/dist/tasks/store.js +15 -15
- package/dist/tools.js +18 -3
- package/dist/vault.js +107 -0
- package/dist/web/auth.js +2 -1
- package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-CrybM-E8.js} +2 -2
- package/dist/web/public/assets/activity-CrybM-E8.js.br +0 -0
- package/dist/web/public/assets/activity-CrybM-E8.js.gz +0 -0
- package/dist/web/public/assets/{boards-BCWQMZry.js → boards-Cw7_6J6L.js} +1 -1
- package/dist/web/public/assets/boards-Cw7_6J6L.js.br +0 -0
- package/dist/web/public/assets/boards-Cw7_6J6L.js.gz +0 -0
- package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-DV066dUD.js} +1 -1
- package/dist/web/public/assets/explorer-DV066dUD.js.br +0 -0
- package/dist/web/public/assets/explorer-DV066dUD.js.gz +0 -0
- package/dist/web/public/assets/index-BrNHu2qj.js +85 -0
- package/dist/web/public/assets/index-BrNHu2qj.js.br +0 -0
- package/dist/web/public/assets/index-BrNHu2qj.js.gz +0 -0
- package/dist/web/public/assets/index-DLszkDUV.css +2 -0
- package/dist/web/public/assets/index-DLszkDUV.css.br +0 -0
- package/dist/web/public/assets/index-DLszkDUV.css.gz +0 -0
- package/dist/web/public/assets/{runs-DdERzeac.js → runs-DDagTNaM.js} +1 -1
- package/dist/web/public/assets/runs-DDagTNaM.js.br +0 -0
- package/dist/web/public/assets/runs-DDagTNaM.js.gz +0 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js +5 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js.br +0 -0
- package/dist/web/public/assets/settings-BEdSeXpm.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-BnMack9t.js} +1 -1
- package/dist/web/public/assets/task-runs-BnMack9t.js.br +0 -0
- package/dist/web/public/assets/task-runs-BnMack9t.js.gz +0 -0
- package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-BkW7YShZ.js} +1 -1
- package/dist/web/public/assets/tasks-BkW7YShZ.js.br +0 -0
- package/dist/web/public/assets/tasks-BkW7YShZ.js.gz +0 -0
- package/dist/web/public/index.html +2 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/push.js +4 -10
- package/dist/web/vault.js +68 -0
- package/dist/{extensions/web → websearch}/artifacts.js +2 -2
- package/dist/websearch/cli.js +73 -0
- package/dist/websearch/run.js +273 -0
- package/docs/deploy.md +34 -11
- package/package.json +2 -3
- package/skills/pier-help/SKILL.md +32 -16
- package/skills/pier-slack/SKILL.md +52 -71
- package/skills/pier-tasks/SKILL.md +60 -148
- package/skills/pier-vault/SKILL.md +36 -0
- package/skills/pier-web/SKILL.md +50 -0
- package/dist/channels/slack-tool.js +0 -416
- package/dist/channels/telegram-api.js +0 -86
- package/dist/channels/telegram-panel.js +0 -97
- package/dist/channels/telegram-render.js +0 -69
- package/dist/channels/telegram.js +0 -421
- package/dist/extensions/index.js +0 -10
- package/dist/extensions/web/index.js +0 -9
- package/dist/extensions/web/tools.js +0 -265
- package/dist/tasks/tool.js +0 -416
- package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
- package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
- package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
- package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
- package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
- package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
- package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
- package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
- package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
- package/dist/web/public/assets/index-DVIt5Gio.css +0 -2
- package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
- package/dist/web/public/assets/index-DVIt5Gio.css.gz +0 -0
- package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
- package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
- package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
- package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
- package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
- package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
- package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
- package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
- package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
- /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
- /package/dist/{extensions/web → websearch}/content.js +0 -0
- /package/dist/{extensions/web → websearch}/http.js +0 -0
- /package/dist/{extensions/web → websearch}/json.js +0 -0
- /package/dist/{extensions/web → websearch}/language.js +0 -0
- /package/dist/{extensions/web → websearch}/openai.js +0 -0
- /package/dist/{extensions/web → websearch}/provider.js +0 -0
package/dist/channels/panel.js
CHANGED
|
@@ -1,23 +1,118 @@
|
|
|
1
1
|
// The in-chat settings panel, minus the platform: one message edited in place,
|
|
2
2
|
// every payload namespaced `cfg:` so a tap never reaches the agent. Choices
|
|
3
|
-
// travel as an index:
|
|
4
|
-
|
|
3
|
+
// travel as an index: platform callback payloads are small and opaque. A thread
|
|
4
|
+
// without a session shows a draft — cwd, model, reasoning, a pending question —
|
|
5
|
+
// that rides every button's value, so the card is the store and a restart
|
|
6
|
+
// loses nothing.
|
|
7
|
+
import { sessionLabel, splitSpeaker } from "../core/identity.js";
|
|
8
|
+
import { splitInboundFiles } from "../core/inbound-file.js";
|
|
9
|
+
import { compact, cut, splitReply, thinkingLabel } from "../core/reply.js";
|
|
10
|
+
import { isThinkingLevel, } from "../core/types.js";
|
|
11
|
+
import { HAS_SESSION, NO_SESSION } from "./control.js";
|
|
12
|
+
import { HandoffError } from "./handoff.js";
|
|
5
13
|
export const PANEL_PREFIX = "cfg:";
|
|
6
|
-
const
|
|
7
|
-
|
|
14
|
+
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
|
+
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
|
+
/** Characters of the serialized draft — what a button actually carries. Slack
|
|
24
|
+
* caps a value at 2000 and Lark a card at 30 KB; the rest of the 2000 is room
|
|
25
|
+
* for the cwd, model and reasoning a pick adds after the question. */
|
|
26
|
+
const DRAFT_CHARS = 1700;
|
|
27
|
+
/** Pages whose every pick Pi's fixed-at-creation cwd or the row would refuse:
|
|
28
|
+
* reachable only by tapping a card that gained a session since it was drawn. */
|
|
29
|
+
const DRAFT_ONLY = new Set(["cwd", "cwdtype", "sessions", "session"]);
|
|
30
|
+
export const CWD_DRAFT_TAIL = "Start creates the session there.";
|
|
8
31
|
export const CWD_PLACEHOLDER = "/path/to/project";
|
|
9
|
-
const
|
|
32
|
+
export const QUESTION_TOO_LONG = "Your question is too long to hold — send it again after Start.";
|
|
10
33
|
const btn = (label, action) => ({ label, action });
|
|
34
|
+
/** A button label is the last two segments; the numbered line above has the whole path. */
|
|
35
|
+
const shortDir = (path) => {
|
|
36
|
+
const parts = path.split("/").filter(Boolean);
|
|
37
|
+
return parts.length > 2 ? `…/${parts.slice(-2).join("/")}` : path;
|
|
38
|
+
};
|
|
39
|
+
/** One transcript line as a reader sees it: what was said, minus what was
|
|
40
|
+
* written for the model (the speaker header, attachment markers, the
|
|
41
|
+
* next-step block, a silent turn's reason). */
|
|
42
|
+
const excerpt = (text, role) => {
|
|
43
|
+
const said = role === "user" ? splitInboundFiles(splitSpeaker(text).text).text : splitReply(text).text;
|
|
44
|
+
return cut(said.replace(/\s+/g, " ").trim(), EXCERPT_CHARS);
|
|
45
|
+
};
|
|
46
|
+
const shortTitle = (s) => cut(sessionLabel(s), TITLE_CHARS);
|
|
47
|
+
/** Compact age, as the web sidebar spells it ("now", "12m", "3h", "2d"). */
|
|
48
|
+
const age = (ts, now) => {
|
|
49
|
+
const mins = Math.round((now - ts) / 60_000);
|
|
50
|
+
if (mins < 1)
|
|
51
|
+
return "now";
|
|
52
|
+
if (mins < 60)
|
|
53
|
+
return `${String(mins)}m`;
|
|
54
|
+
if (mins < 1440)
|
|
55
|
+
return `${String(Math.round(mins / 60))}h`;
|
|
56
|
+
return `${String(Math.round(mins / 1440))}d`;
|
|
57
|
+
};
|
|
58
|
+
/** Page `page` of `items`, clamped: a stale Next past the end lands on the last page. */
|
|
59
|
+
const paged = (items, page) => {
|
|
60
|
+
const pages = Math.max(1, Math.ceil(items.length / PER_PAGE));
|
|
61
|
+
const at = Math.min(Math.max(page, 0), pages - 1);
|
|
62
|
+
const from = at * PER_PAGE;
|
|
63
|
+
return { at, pages, slice: items.slice(from, from + PER_PAGE), from };
|
|
64
|
+
};
|
|
65
|
+
const pager = (action, at, pages) => [
|
|
66
|
+
...(at > 0 ? [btn("‹ Prev", `${action}:${String(at - 1)}`)] : []),
|
|
67
|
+
...(at < pages - 1 ? [btn("Next ›", `${action}:${String(at + 1)}`)] : []),
|
|
68
|
+
btn("‹ Back", "panel"),
|
|
69
|
+
];
|
|
70
|
+
/** A refusal's sentence (a control refusal, a HandoffError) is the whole
|
|
71
|
+
* answer; any other failure names what was attempted. */
|
|
72
|
+
const failed = (what, err) => err instanceof HandoffError || (err instanceof Error && (err.message === NO_SESSION || err.message === HAS_SESSION))
|
|
73
|
+
? err.message
|
|
74
|
+
: `${what}: ${String(err)}`;
|
|
75
|
+
/** `undefined` when there is nothing to carry, so a with-session panel's buttons stay bare. */
|
|
76
|
+
export const serializeDraft = (draft) => Object.keys(draft).length ? JSON.stringify(draft) : undefined;
|
|
77
|
+
/** The question, or the fact that it was too long — measured as the button
|
|
78
|
+
* carries it, escapes included, not as the bytes the user typed. */
|
|
79
|
+
export const holdQuestion = (q) => {
|
|
80
|
+
if (!q)
|
|
81
|
+
return {};
|
|
82
|
+
return JSON.stringify({ q }).length > DRAFT_CHARS ? { dropped: true } : { q };
|
|
83
|
+
};
|
|
84
|
+
/** The held question as the card shows it: one line, its attachments counted, not listed. */
|
|
85
|
+
const heldLine = (q) => {
|
|
86
|
+
const { text, paths } = splitInboundFiles(q);
|
|
87
|
+
const files = paths.length ? ` · ${paths.length} file${paths.length > 1 ? "s" : ""}` : "";
|
|
88
|
+
return `${text.replace(/\s+/g, " ").trim()}${files}`;
|
|
89
|
+
};
|
|
90
|
+
/** A platform echoed this; only the fields the draft knows, each type-checked. */
|
|
91
|
+
export const readDraft = (raw) => {
|
|
92
|
+
const draft = {};
|
|
93
|
+
if (!raw || typeof raw !== "object")
|
|
94
|
+
return draft;
|
|
95
|
+
const v = raw;
|
|
96
|
+
if (typeof v.cwd === "string")
|
|
97
|
+
draft.cwd = v.cwd;
|
|
98
|
+
const model = v.model;
|
|
99
|
+
if (model && typeof model.provider === "string" && typeof model.id === "string") {
|
|
100
|
+
draft.model = { provider: model.provider, id: model.id };
|
|
101
|
+
}
|
|
102
|
+
if (isThinkingLevel(v.thinking))
|
|
103
|
+
draft.thinking = v.thinking;
|
|
104
|
+
if (typeof v.q === "string" && v.q)
|
|
105
|
+
draft.q = v.q;
|
|
106
|
+
if (v.dropped === true)
|
|
107
|
+
draft.dropped = true;
|
|
108
|
+
return draft;
|
|
109
|
+
};
|
|
11
110
|
export class ChatPanel {
|
|
12
111
|
deps;
|
|
13
112
|
panels = new Map();
|
|
14
113
|
constructor(deps) {
|
|
15
114
|
this.deps = deps;
|
|
16
115
|
}
|
|
17
|
-
/** Gates this platform has and the other does not. */
|
|
18
|
-
gateExtras(_chat, _policy) {
|
|
19
|
-
return "";
|
|
20
|
-
}
|
|
21
116
|
code(text) {
|
|
22
117
|
return `${this.fence[0]}${this.esc(text)}${this.fence[1]}`;
|
|
23
118
|
}
|
|
@@ -28,190 +123,304 @@ export class ChatPanel {
|
|
|
28
123
|
return this.panels.get(key.conversationId);
|
|
29
124
|
}
|
|
30
125
|
// --- rendering ---------------------------------------------------------------
|
|
31
|
-
async view(key,
|
|
126
|
+
async view(key, state) {
|
|
32
127
|
const status = await this.deps.control.status(key);
|
|
128
|
+
if (!status)
|
|
129
|
+
return this.draftView(key, state.draft);
|
|
130
|
+
// The conversation is right above the card: no excerpt, and no way to a
|
|
131
|
+
// second session — that is a new thread.
|
|
132
|
+
return {
|
|
133
|
+
groups: [{ title: "Session", lines: this.sessionLines(status) }],
|
|
134
|
+
rows: [[
|
|
135
|
+
btn("Model & reasoning", "pins:0"),
|
|
136
|
+
...(status.state === "streaming" ? [btn("⏹ Stop", "stop")] : []),
|
|
137
|
+
]],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
draftView(key, draft) {
|
|
141
|
+
const { cwd, model, thinking } = this.effective(key, draft);
|
|
142
|
+
const question = draft.q ? [`▸ ${this.esc(cut(heldLine(draft.q), QUESTION_CHARS))}`]
|
|
143
|
+
: draft.dropped ? [QUESTION_TOO_LONG] : [];
|
|
33
144
|
return {
|
|
34
|
-
groups: [
|
|
35
|
-
{
|
|
145
|
+
groups: [{
|
|
36
146
|
title: "Session",
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
lines: this.chatLines(chatId),
|
|
44
|
-
},
|
|
45
|
-
],
|
|
147
|
+
suffix: draft.cwd || draft.model || draft.thinking ? undefined : " · chat defaults",
|
|
148
|
+
lines: [
|
|
149
|
+
`Starts in ${cwd ? this.code(cwd) : "Pier's directory"} · ${model ? this.esc(model.id) : "Pi default"} · ${thinking ? `reasoning ${thinking}` : "default reasoning"}`,
|
|
150
|
+
...question,
|
|
151
|
+
],
|
|
152
|
+
}],
|
|
46
153
|
rows: [
|
|
47
|
-
[btn("Model", "
|
|
48
|
-
[btn("
|
|
49
|
-
[
|
|
50
|
-
...(status?.state === "streaming" ? [btn("⏹ Stop", "stop")] : []),
|
|
51
|
-
btn("Close", "close"),
|
|
52
|
-
],
|
|
154
|
+
[btn("Model & reasoning", "pins:0"), btn("Directory…", "cwd"), btn("Continue web session…", "sessions:0")],
|
|
155
|
+
[btn("Start", "start"), btn("Close", "close")],
|
|
53
156
|
],
|
|
54
157
|
};
|
|
55
158
|
}
|
|
159
|
+
/** What Start would create with: the draft over the chat defaults. */
|
|
160
|
+
effective(key, draft) {
|
|
161
|
+
const launch = this.deps.control.launchFor(key);
|
|
162
|
+
return {
|
|
163
|
+
cwd: draft.cwd ?? launch.cwd,
|
|
164
|
+
model: draft.model ?? launch.model,
|
|
165
|
+
thinking: draft.thinking ?? launch.thinking,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
56
168
|
sessionLines(status) {
|
|
57
169
|
const usage = status.tokens !== null && status.contextWindow
|
|
58
170
|
? `${compact(status.tokens)}/${compact(status.contextWindow)} tok`
|
|
59
171
|
: "not measured yet";
|
|
172
|
+
const fresh = status.empty && status.state === "idle";
|
|
60
173
|
return [
|
|
61
|
-
`${this.code(status.sessionId.slice(0, 8))} · ${status.state}`,
|
|
174
|
+
`${this.code(status.sessionId.slice(0, 8))} · ${fresh ? "created, no message yet" : status.state}`,
|
|
62
175
|
`Directory: ${this.code(status.cwd || "?")}`,
|
|
63
176
|
`Model: ${status.model ? this.esc(status.model.id) : "Pi default"} · ${thinkingLabel(status.thinking)}`,
|
|
64
|
-
`Context: ${usage}`,
|
|
177
|
+
`Context: ${status.empty ? "empty — the first message you send runs here." : usage}`,
|
|
65
178
|
];
|
|
66
179
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
180
|
+
/** A listing that failed is one line on the card, logged — never an empty
|
|
181
|
+
* list that reads as "nothing here". */
|
|
182
|
+
async listed(read, what) {
|
|
183
|
+
try {
|
|
184
|
+
return { items: await read };
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
const unavailable = `${what}: ${String(err)}`;
|
|
188
|
+
this.deps.log(unavailable);
|
|
189
|
+
return { items: [], unavailable };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** An excerpt, not a summary: the last exchanges are what makes a session
|
|
193
|
+
* one has been away from recognisable on a phone. */
|
|
194
|
+
async recentGroups(key) {
|
|
195
|
+
const { items: exchanges, unavailable } = await this.listed(this.deps.control.recent(key, RECENT_EXCHANGES), "Could not read the transcript");
|
|
196
|
+
if (unavailable)
|
|
197
|
+
return [{ title: "Recent", lines: [unavailable] }];
|
|
198
|
+
const lines = exchanges.flatMap(({ user, assistant }) => {
|
|
199
|
+
const reply = assistant === undefined ? "" : excerpt(assistant, "assistant");
|
|
200
|
+
return [
|
|
201
|
+
`▸ ${this.esc(excerpt(user, "user"))}`,
|
|
202
|
+
...(reply ? [`◂ ${this.esc(reply)}`] : []),
|
|
203
|
+
];
|
|
204
|
+
});
|
|
205
|
+
return lines.length ? [{ title: "Recent", lines }] : [];
|
|
77
206
|
}
|
|
78
207
|
async refresh(key, note) {
|
|
79
208
|
const state = this.state(key);
|
|
80
209
|
if (!state)
|
|
81
210
|
return;
|
|
82
|
-
await this.draw(state, await this.view(key, state
|
|
211
|
+
await this.draw(state, await this.view(key, state), note);
|
|
212
|
+
}
|
|
213
|
+
/** The card's last draw, once an operation completed: the session as it now
|
|
214
|
+
* is, no button. The panel is released with it, so nothing can tap it again
|
|
215
|
+
* and the card stays as a record; another @bot opens a fresh one. `recent`
|
|
216
|
+
* excerpts the transcript — what tells a phone reader which conversation
|
|
217
|
+
* was continued. */
|
|
218
|
+
async settle(key, state, note, recent) {
|
|
219
|
+
const status = await this.deps.control.status(key);
|
|
220
|
+
if (!status)
|
|
221
|
+
return this.refresh(key, note);
|
|
222
|
+
await this.draw(state, {
|
|
223
|
+
groups: [{ title: "Session", lines: this.sessionLines(status) }, ...recent ? await this.recentGroups(key) : []],
|
|
224
|
+
rows: [],
|
|
225
|
+
}, note);
|
|
226
|
+
this.panels.delete(key.conversationId);
|
|
83
227
|
}
|
|
84
228
|
// --- actions -----------------------------------------------------------------
|
|
85
|
-
/** Returns false when the payload is not ours. `
|
|
86
|
-
* whose
|
|
87
|
-
|
|
229
|
+
/** Returns false when the payload is not ours. `recover` rebuilds the state
|
|
230
|
+
* of a panel whose process died — from the tapped button's value, so the
|
|
231
|
+
* tap is honoured on the card the user is looking at. `run` hands Start's
|
|
232
|
+
* pending question to the router as the tapper's message. */
|
|
233
|
+
async dispatch(key, payload, ctx, recover, run) {
|
|
88
234
|
if (!payload.startsWith(PANEL_PREFIX))
|
|
89
235
|
return false;
|
|
90
236
|
const [action = "", arg = ""] = payload.slice(PANEL_PREFIX.length).split(":");
|
|
91
|
-
|
|
92
|
-
if (!state
|
|
93
|
-
|
|
237
|
+
let state = this.state(key);
|
|
238
|
+
if (!state) {
|
|
239
|
+
state = recover();
|
|
240
|
+
this.remember(key, state);
|
|
241
|
+
}
|
|
242
|
+
if (DRAFT_ONLY.has(action) && this.deps.control.knows(key)) {
|
|
243
|
+
await this.refresh(key, HAS_SESSION);
|
|
94
244
|
return true;
|
|
95
245
|
}
|
|
96
246
|
switch (action) {
|
|
97
247
|
case "close":
|
|
98
248
|
this.panels.delete(key.conversationId);
|
|
99
|
-
|
|
100
|
-
await this.erase(state);
|
|
249
|
+
await this.erase(state);
|
|
101
250
|
return true;
|
|
102
251
|
case "panel":
|
|
103
252
|
await this.refresh(key);
|
|
104
253
|
return true;
|
|
105
|
-
case "
|
|
106
|
-
await this.
|
|
254
|
+
case "pins":
|
|
255
|
+
await this.showPins(key, Number(arg) || 0);
|
|
107
256
|
return true;
|
|
108
|
-
case "
|
|
109
|
-
await this.
|
|
257
|
+
case "pin":
|
|
258
|
+
await this.pickPin(key, Number(arg));
|
|
110
259
|
return true;
|
|
111
|
-
case "
|
|
112
|
-
|
|
113
|
-
await this.pickThinking(key, arg);
|
|
114
|
-
else
|
|
115
|
-
await this.showThinking(key);
|
|
260
|
+
case "sessions":
|
|
261
|
+
await this.showSessions(key, Number(arg) || 0);
|
|
116
262
|
return true;
|
|
117
|
-
case "
|
|
118
|
-
|
|
119
|
-
await this.refresh(key, `Started session ${id.slice(0, 8)}.`);
|
|
263
|
+
case "session":
|
|
264
|
+
await this.pickSession(key, Number(arg));
|
|
120
265
|
return true;
|
|
121
|
-
}
|
|
122
266
|
case "cwd":
|
|
267
|
+
if (arg)
|
|
268
|
+
await this.pickDir(key, Number(arg));
|
|
269
|
+
else
|
|
270
|
+
await this.showDirs(key);
|
|
271
|
+
return true;
|
|
272
|
+
case "cwdtype":
|
|
123
273
|
await this.promptCwd(key, state, ctx);
|
|
124
274
|
return true;
|
|
275
|
+
case "start":
|
|
276
|
+
await this.start(key, state, run);
|
|
277
|
+
return true;
|
|
125
278
|
case "stop":
|
|
126
279
|
await this.deps.control.abort(key);
|
|
127
|
-
await this.
|
|
280
|
+
await this.settle(key, state, "Stop requested.", false);
|
|
128
281
|
return true;
|
|
129
282
|
default:
|
|
130
283
|
this.deps.log(`unknown panel action: ${action}`);
|
|
131
284
|
return true;
|
|
132
285
|
}
|
|
133
286
|
}
|
|
134
|
-
|
|
287
|
+
/** The operator's pins, model and reasoning in one pick: the catalog is the
|
|
288
|
+
* Console's business, and a chat is the wrong place to browse it. */
|
|
289
|
+
async showPins(key, page) {
|
|
135
290
|
const state = this.state(key);
|
|
136
291
|
if (!state)
|
|
137
292
|
return;
|
|
138
|
-
|
|
139
|
-
// answers; the second must not draw as the first.
|
|
140
|
-
let unavailable;
|
|
141
|
-
state.models = await this.deps.control.models().catch((err) => {
|
|
142
|
-
unavailable = `Could not list models: ${String(err)}`;
|
|
143
|
-
this.deps.log(unavailable);
|
|
144
|
-
return [];
|
|
145
|
-
});
|
|
293
|
+
const pins = this.deps.control.pins();
|
|
146
294
|
const status = await this.deps.control.status(key);
|
|
147
|
-
const
|
|
148
|
-
const at
|
|
149
|
-
const slice = state.models.slice(at * MODELS_PER_PAGE, (at + 1) * MODELS_PER_PAGE);
|
|
295
|
+
const current = status ?? this.effective(key, state.draft);
|
|
296
|
+
const { at, pages, slice, from } = paged(pins, page);
|
|
150
297
|
await this.draw(state, {
|
|
151
298
|
groups: [{
|
|
152
|
-
title: "Model",
|
|
299
|
+
title: "Model & reasoning",
|
|
153
300
|
suffix: ` · page ${at + 1}/${pages}`,
|
|
154
|
-
lines:
|
|
301
|
+
lines: slice.length
|
|
302
|
+
? slice.map((pin, i) => {
|
|
303
|
+
const ticked = current.model?.provider === pin.provider && current.model.id === pin.id
|
|
304
|
+
&& current.thinking === pin.thinking;
|
|
305
|
+
return `${String(from + i + 1)}. ${ticked ? "✓ " : ""}${this.esc(pin.id)} · ${thinkingLabel(pin.thinking)}${pin.note ? ` — ${this.esc(pin.note)}` : ""}`;
|
|
306
|
+
})
|
|
307
|
+
: ["No pinned models — Settings → Models → Model menu."],
|
|
155
308
|
}],
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
}),
|
|
160
|
-
rows: [[
|
|
161
|
-
...(at > 0 ? [btn("‹ Prev", `models:${at - 1}`)] : []),
|
|
162
|
-
...(at < pages - 1 ? [btn("Next ›", `models:${at + 1}`)] : []),
|
|
163
|
-
btn("‹ Back", "panel"),
|
|
164
|
-
]],
|
|
309
|
+
// The note stays on the line: a platform truncates a button label.
|
|
310
|
+
picks: slice.map((pin, i) => btn(`${String(from + i + 1)} ${pin.id}`, `pin:${String(from + i)}`)),
|
|
311
|
+
rows: [pager("pins", at, pages)],
|
|
165
312
|
});
|
|
166
313
|
}
|
|
167
|
-
async
|
|
168
|
-
const
|
|
169
|
-
if (!
|
|
314
|
+
async showSessions(key, page) {
|
|
315
|
+
const state = this.state(key);
|
|
316
|
+
if (!state)
|
|
317
|
+
return;
|
|
318
|
+
const { items, unavailable } = await this.listed(this.deps.handoff.unbound(SESSIONS_LISTED), "Could not list sessions");
|
|
319
|
+
state.sessions = items;
|
|
320
|
+
const { at, pages, slice, from } = paged(state.sessions, page);
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
await this.draw(state, {
|
|
323
|
+
groups: [{
|
|
324
|
+
title: "Continue web session",
|
|
325
|
+
suffix: ` · page ${at + 1}/${pages}`,
|
|
326
|
+
lines: slice.length
|
|
327
|
+
? slice.map((s, i) => `${String(from + i + 1)}. ${this.esc(shortTitle(s))} · ${this.code(s.cwd.split("/").filter(Boolean).at(-1) ?? s.cwd)} · ${age(s.modified ?? s.createdAt, now)}`)
|
|
328
|
+
: [unavailable ?? "No unbound sessions."],
|
|
329
|
+
}],
|
|
330
|
+
picks: slice.map((s, i) => btn(`${String(from + i + 1)} ${shortTitle(s)}`, `session:${String(from + i)}`)),
|
|
331
|
+
rows: [pager("sessions", at, pages)],
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
async pickSession(key, index) {
|
|
335
|
+
const state = this.state(key);
|
|
336
|
+
const session = state?.sessions[index];
|
|
337
|
+
if (!state || !session)
|
|
338
|
+
return this.refresh(key, "That session is no longer listed.");
|
|
339
|
+
try {
|
|
340
|
+
await this.deps.handoff.continueHere(key, session.id);
|
|
341
|
+
// The bound session has its own settings; the draft is spent.
|
|
342
|
+
state.draft = {};
|
|
343
|
+
await this.settle(key, state, `Continuing session ${session.id.slice(0, 8)} — reply in this thread.`, true);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
await this.refresh(key, failed("Could not continue that session", err));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async pickPin(key, index) {
|
|
350
|
+
const state = this.state(key);
|
|
351
|
+
const pin = this.deps.control.pins()[index];
|
|
352
|
+
if (!state || !pin)
|
|
170
353
|
return this.refresh(key, "That model is no longer listed.");
|
|
354
|
+
const model = { provider: pin.provider, id: pin.id };
|
|
355
|
+
if (!this.deps.control.knows(key)) {
|
|
356
|
+
state.draft = { ...state.draft, model, thinking: pin.thinking };
|
|
357
|
+
return this.refresh(key);
|
|
358
|
+
}
|
|
171
359
|
try {
|
|
172
360
|
await this.deps.control.setModel(key, model);
|
|
173
|
-
await this.
|
|
361
|
+
await this.deps.control.setThinking(key, pin.thinking);
|
|
362
|
+
await this.settle(key, state, `Model set to ${pin.id} · ${thinkingLabel(pin.thinking)}.`, false);
|
|
174
363
|
}
|
|
175
364
|
catch (err) {
|
|
176
|
-
await this.refresh(key,
|
|
365
|
+
await this.refresh(key, failed("Could not set that model", err));
|
|
177
366
|
}
|
|
178
367
|
}
|
|
179
|
-
async
|
|
368
|
+
async showDirs(key) {
|
|
180
369
|
const state = this.state(key);
|
|
181
370
|
if (!state)
|
|
182
371
|
return;
|
|
183
|
-
const
|
|
184
|
-
|
|
372
|
+
const { items, unavailable } = await this.listed(this.deps.control.recentDirs(key), "Could not list recent directories");
|
|
373
|
+
state.dirs = items;
|
|
185
374
|
await this.draw(state, {
|
|
186
375
|
groups: [{
|
|
187
|
-
title: "
|
|
188
|
-
lines:
|
|
376
|
+
title: "Directory",
|
|
377
|
+
lines: state.dirs.length
|
|
378
|
+
? state.dirs.map((dir, i) => `${String(i + 1)}. ${this.code(dir)}`)
|
|
379
|
+
: [unavailable ?? "No sessions yet — type a path."],
|
|
189
380
|
}],
|
|
190
|
-
picks:
|
|
191
|
-
rows: [[btn("‹ Back", "panel")]],
|
|
381
|
+
picks: state.dirs.map((dir, i) => btn(`${String(i + 1)} ${shortDir(dir)}`, `cwd:${String(i)}`)),
|
|
382
|
+
rows: [[btn("Type a path…", "cwdtype"), btn("‹ Back", "panel")]],
|
|
192
383
|
});
|
|
193
384
|
}
|
|
194
|
-
async
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
385
|
+
async pickDir(key, index) {
|
|
386
|
+
const dir = this.state(key)?.dirs[index];
|
|
387
|
+
if (!dir)
|
|
388
|
+
return this.refresh(key, "That directory is no longer listed.");
|
|
389
|
+
await this.chooseDir(key, dir);
|
|
390
|
+
}
|
|
391
|
+
/** Pi fixes cwd at session creation, so a directory is only ever the draft's:
|
|
392
|
+
* a thread that already has a session keeps it, and a new one is a new thread. */
|
|
393
|
+
async chooseDir(key, path) {
|
|
394
|
+
const state = this.state(key);
|
|
395
|
+
if (!state)
|
|
396
|
+
return;
|
|
397
|
+
if (this.deps.control.knows(key))
|
|
398
|
+
return this.refresh(key, HAS_SESSION);
|
|
399
|
+
if (!path.startsWith("/"))
|
|
400
|
+
return this.refresh(key, "That is not an absolute path — nothing changed.");
|
|
401
|
+
state.draft = { ...state.draft, cwd: path };
|
|
402
|
+
await this.refresh(key);
|
|
403
|
+
}
|
|
404
|
+
/** Create with the draft, bind the thread, run the question as the tapper's
|
|
405
|
+
* first message. A session that appeared meanwhile (a message raced the
|
|
406
|
+
* tap) is not replaced: the draft would orphan it. */
|
|
407
|
+
async start(key, state, run) {
|
|
408
|
+
if (this.deps.control.knows(key))
|
|
409
|
+
return this.refresh(key, HAS_SESSION);
|
|
410
|
+
const { q, dropped: _dropped, ...launch } = state.draft;
|
|
411
|
+
let id;
|
|
206
412
|
try {
|
|
207
|
-
|
|
208
|
-
await this.refresh(key, `Started session ${id.slice(0, 8)} in ${path}.`);
|
|
209
|
-
return { id };
|
|
413
|
+
id = await this.deps.control.newSession(key, launch);
|
|
210
414
|
}
|
|
211
415
|
catch (err) {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
416
|
+
return this.refresh(key, failed("Could not start a session", err));
|
|
417
|
+
}
|
|
418
|
+
state.draft = {};
|
|
419
|
+
if (q) {
|
|
420
|
+
await run(q);
|
|
421
|
+
return this.settle(key, state, `Started ${id.slice(0, 8)} — running your question.`, false);
|
|
215
422
|
}
|
|
423
|
+
const cwd = (await this.deps.control.status(key))?.cwd ?? launch.cwd ?? "?";
|
|
424
|
+
await this.settle(key, state, `Started ${id.slice(0, 8)} in ${cwd}.`, false);
|
|
216
425
|
}
|
|
217
426
|
}
|
|
@@ -17,13 +17,13 @@ export class ReceiptLedger {
|
|
|
17
17
|
this.platform = platform;
|
|
18
18
|
this.db = db;
|
|
19
19
|
}
|
|
20
|
-
add(receipt) {
|
|
20
|
+
add(receipt, createdAt = Date.now()) {
|
|
21
21
|
this.db.prepare(`
|
|
22
22
|
INSERT INTO receipts(platform, conversation_id, chat_id, message_id, created_at)
|
|
23
23
|
VALUES (?, ?, ?, ?, ?)
|
|
24
24
|
ON CONFLICT(platform, chat_id, message_id) DO UPDATE SET
|
|
25
25
|
conversation_id = excluded.conversation_id, created_at = excluded.created_at
|
|
26
|
-
`).run(this.platform, receipt.conversationId, receipt.chatId, receipt.messageId,
|
|
26
|
+
`).run(this.platform, receipt.conversationId, receipt.chatId, receipt.messageId, createdAt);
|
|
27
27
|
}
|
|
28
28
|
/** Returned once, then gone. `bookedBy` claims only what was on the books by
|
|
29
29
|
* then — see `Receipts.settle`. */
|
|
@@ -38,16 +38,20 @@ export class ReceiptLedger {
|
|
|
38
38
|
.run(this.platform, conversationId, ...args);
|
|
39
39
|
return rows.map(toReceipt);
|
|
40
40
|
}
|
|
41
|
-
/** Claim receipts older than `ageMs`; `0` claims everything (startup sweep).
|
|
42
|
-
|
|
41
|
+
/** Claim receipts older than `ageMs`; `0` claims everything (startup sweep).
|
|
42
|
+
* A conversation `working` says yes to is skipped, whatever its age: its
|
|
43
|
+
* turn is still going to settle. */
|
|
44
|
+
takeStale(ageMs, working = () => false, now = Date.now()) {
|
|
43
45
|
const cutoff = now - ageMs;
|
|
44
46
|
const rows = this.db.prepare(`
|
|
45
47
|
SELECT conversation_id, chat_id, message_id FROM receipts
|
|
46
48
|
WHERE platform = ? AND created_at <= ?
|
|
47
49
|
`).all(this.platform, cutoff);
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
const claimed = rows.map(toReceipt).filter(({ conversationId }) => !working(conversationId));
|
|
51
|
+
const drop = this.db.prepare("DELETE FROM receipts WHERE platform = ? AND chat_id = ? AND message_id = ?");
|
|
52
|
+
for (const { chatId, messageId } of claimed)
|
|
53
|
+
drop.run(this.platform, chatId, messageId);
|
|
54
|
+
return claimed;
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
57
|
/** A receipt is booked synchronously (an instant turn must not clear an
|
|
@@ -59,21 +63,29 @@ export class Receipts {
|
|
|
59
63
|
log;
|
|
60
64
|
emoji;
|
|
61
65
|
staleMs;
|
|
66
|
+
working;
|
|
62
67
|
applying = new Map();
|
|
63
68
|
sweptAt = 0;
|
|
64
69
|
constructor(api, ledger, log, emoji,
|
|
65
|
-
/** After this, a receipt
|
|
66
|
-
|
|
70
|
+
/** After this, a receipt whose conversation is idle is assumed never to
|
|
71
|
+
* settle. A turn still running keeps its 👀 however long it takes. */
|
|
72
|
+
staleMs, working) {
|
|
67
73
|
this.api = api;
|
|
68
74
|
this.ledger = ledger;
|
|
69
75
|
this.log = log;
|
|
70
76
|
this.emoji = emoji;
|
|
71
77
|
this.staleMs = staleMs;
|
|
78
|
+
this.working = working;
|
|
72
79
|
}
|
|
73
|
-
|
|
80
|
+
/** `at` is when the turn this receipt belongs to began; it defaults to now,
|
|
81
|
+
* which is right for a message someone typed. A system note is posted *by*
|
|
82
|
+
* the turn that will clear it, so booking it at `now` — a round trip after
|
|
83
|
+
* the turn started — would put it outside that turn's scope and leave the
|
|
84
|
+
* reaction up until the stale sweep. */
|
|
85
|
+
mark(conversationId, chatId, messageId, at) {
|
|
74
86
|
this.applying.set(`${chatId}:${messageId}`, this.api.setReaction(chatId, messageId, this.emoji)
|
|
75
87
|
.catch((err) => this.log(`reaction failed: ${String(err)}`)));
|
|
76
|
-
this.ledger.add({ conversationId, chatId, messageId });
|
|
88
|
+
this.ledger.add({ conversationId, chatId, messageId }, at);
|
|
77
89
|
}
|
|
78
90
|
/** Only the messages *this* turn was working on: a message queued mid-turn
|
|
79
91
|
* is still owed an answer. `meta` says when the turn began; no meta clears
|
|
@@ -98,7 +110,8 @@ export class Receipts {
|
|
|
98
110
|
if (!all && now - this.sweptAt < SWEEP_EVERY_MS)
|
|
99
111
|
return Promise.resolve();
|
|
100
112
|
this.sweptAt = now;
|
|
101
|
-
|
|
113
|
+
// The startup sweep needs no liveness check: no turn survives the process.
|
|
114
|
+
return this.clear(this.ledger.takeStale(all ? 0 : this.staleMs, all ? undefined : this.working));
|
|
102
115
|
}
|
|
103
116
|
async clear(receipts) {
|
|
104
117
|
// A slow apply must not let a later receipt clear first.
|