@timqi/pier 0.0.17 → 0.0.20
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/dist/agent/config-sync.js +123 -0
- package/dist/agent/config.js +95 -3
- package/dist/agent/events.js +26 -5
- package/dist/agent/listing.js +12 -13
- package/dist/agent/pi.js +75 -27
- package/dist/boards/boards.js +7 -6
- package/dist/channels/attach.js +18 -4
- package/dist/channels/config.js +7 -2
- package/dist/channels/dedup.js +9 -1
- package/dist/channels/lark.js +2 -6
- package/dist/channels/receipts.js +21 -6
- package/dist/channels/slack-tool.js +86 -25
- package/dist/channels/slack.js +2 -10
- package/dist/channels/telegram.js +1 -0
- package/dist/config-sync-fetch.js +97 -0
- package/dist/config-sync-task.js +59 -0
- package/dist/config-sync.js +179 -0
- package/dist/core/hub.js +23 -6
- package/dist/core/inbox.js +1 -1
- package/dist/core/reply.js +76 -2
- package/dist/core/router.js +162 -37
- package/dist/db.js +50 -0
- package/dist/main.js +32 -1
- package/dist/secrets.js +18 -6
- package/dist/tasks/agent.js +25 -23
- package/dist/tasks/callbacks.js +1 -1
- package/dist/tasks/definitions.js +28 -17
- package/dist/tasks/execution.js +11 -3
- package/dist/tasks/messages.js +58 -16
- package/dist/tasks/routes.js +58 -10
- package/dist/tasks/runs.js +0 -5
- package/dist/tasks/service.js +14 -4
- package/dist/tasks/store.js +87 -10
- package/dist/tasks/tool.js +8 -4
- package/dist/tools.js +32 -2
- package/dist/web/auth.js +17 -23
- package/dist/web/config-sync.js +91 -0
- package/dist/web/config.js +61 -0
- package/dist/web/explorer.js +20 -70
- package/dist/web/fs.js +159 -0
- package/dist/web/instance.js +11 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js +5 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
- package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js +1 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
- package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
- package/dist/web/public/assets/code-p-Tq1bMK.js +1 -0
- package/dist/web/public/assets/explorer-50no_DrD.js +5 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
- package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
- package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-Bvhh3ZFk.js} +1 -1
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js +3 -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-CtR-Ob4T.css → index-DuZ2vERd.css} +1 -1
- package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
- package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js +79 -0
- package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
- package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js +1 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
- package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js +6 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
- package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js +3 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
- package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js +4 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
- package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js +2 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
- package/dist/web/public/assets/terminal-m2_WYu5W.js.gz +0 -0
- package/dist/web/public/icon.svg.br +0 -0
- package/dist/web/public/icon.svg.gz +0 -0
- package/dist/web/public/index.html +13 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/route.js +17 -0
- package/dist/web/server.js +152 -66
- package/dist/web/terminal.js +2 -7
- package/package.json +2 -2
- package/skills/pier-slack/SKILL.md +4 -16
- package/skills/pier-tasks/SKILL.md +14 -9
- package/dist/web/files.js +0 -183
- package/dist/web/public/assets/index-DAxKtidH.js +0 -93
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// One serialized configuration subscriber, shared by Settings and the hourly
|
|
2
|
+
// task. Only a successfully applied response advances its persistent ETag.
|
|
3
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { configSourceUrl, CONFIG_SYNC_BYTES, downloadConfig } from "./config-sync-fetch.js";
|
|
5
|
+
import { transact } from "./db.js";
|
|
6
|
+
import { logger } from "./log.js";
|
|
7
|
+
import { normalizeModelMenu } from "./settings.js";
|
|
8
|
+
const log = logger("config-sync");
|
|
9
|
+
const KEY = "configSync";
|
|
10
|
+
// Object order is not a change; array order (model menu priority) is.
|
|
11
|
+
export function configJson(value) {
|
|
12
|
+
return JSON.stringify(value, (_key, entry) => entry && typeof entry === "object" && !Array.isArray(entry)
|
|
13
|
+
? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b, "en"))) : entry);
|
|
14
|
+
}
|
|
15
|
+
const hash = (value) => createHash("sha256").update(configJson(value)).digest("hex");
|
|
16
|
+
export class ConfigSync {
|
|
17
|
+
deps;
|
|
18
|
+
#state;
|
|
19
|
+
#queue = Promise.resolve();
|
|
20
|
+
constructor(deps) {
|
|
21
|
+
this.deps = deps;
|
|
22
|
+
const row = deps.db.prepare("SELECT value FROM settings WHERE key = ?").get(KEY);
|
|
23
|
+
this.#state = row ? JSON.parse(row.value) : {
|
|
24
|
+
instanceId: randomUUID(), token: null, url: "", enabled: false, etag: null,
|
|
25
|
+
appliedHash: null, lastChecked: null, lastApplied: null, error: null, needsReload: false,
|
|
26
|
+
};
|
|
27
|
+
if (!row)
|
|
28
|
+
this.#save();
|
|
29
|
+
}
|
|
30
|
+
#save(patch = {}) {
|
|
31
|
+
const next = { ...this.#state, ...patch };
|
|
32
|
+
this.deps.db.prepare("INSERT INTO settings(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
|
|
33
|
+
.run(KEY, JSON.stringify(next));
|
|
34
|
+
this.#state = next;
|
|
35
|
+
}
|
|
36
|
+
#exclusive(work) {
|
|
37
|
+
const result = this.#queue.then(work);
|
|
38
|
+
this.#queue = result.then(() => undefined, () => undefined);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
status() {
|
|
42
|
+
const state = this.#state;
|
|
43
|
+
return {
|
|
44
|
+
publishedPath: state.token ? `/config-sync/${state.token}` : null,
|
|
45
|
+
sourceUrl: state.url, enabled: state.enabled,
|
|
46
|
+
lastChecked: state.lastChecked, lastApplied: state.lastApplied,
|
|
47
|
+
error: state.error, needsReload: state.needsReload,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async #local() {
|
|
51
|
+
return { agent: await this.deps.config.exportSnapshot(), modelMenu: this.deps.settings.get().modelMenu };
|
|
52
|
+
}
|
|
53
|
+
publish() {
|
|
54
|
+
return this.#exclusive(async () => {
|
|
55
|
+
if (this.#state.enabled)
|
|
56
|
+
throw new Error("Pause the configuration subscription before publishing a link");
|
|
57
|
+
await this.#local();
|
|
58
|
+
this.#save({ token: randomBytes(32).toString("hex") });
|
|
59
|
+
return this.status();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
revoke() {
|
|
63
|
+
return this.#exclusive(async () => {
|
|
64
|
+
this.#save({ token: null });
|
|
65
|
+
return this.status();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async published(token) {
|
|
69
|
+
const expected = this.#state.token;
|
|
70
|
+
if (!expected || !/^[a-f0-9]{64}$/.test(token) || !timingSafeEqual(Buffer.from(token), Buffer.from(expected)))
|
|
71
|
+
return null;
|
|
72
|
+
const local = await this.#local();
|
|
73
|
+
if (this.#state.token !== expected)
|
|
74
|
+
return null;
|
|
75
|
+
const document = { schemaVersion: 1, instanceId: this.#state.instanceId, ...local };
|
|
76
|
+
const body = configJson(document);
|
|
77
|
+
if (Buffer.byteLength(body) > CONFIG_SYNC_BYTES)
|
|
78
|
+
throw new Error("Configuration exceeds 1 MiB");
|
|
79
|
+
return { body, etag: `"${hash(document)}"` };
|
|
80
|
+
}
|
|
81
|
+
#parse(raw) {
|
|
82
|
+
if (Buffer.byteLength(raw) > CONFIG_SYNC_BYTES)
|
|
83
|
+
throw new Error("Configuration exceeds 1 MiB");
|
|
84
|
+
let value;
|
|
85
|
+
try {
|
|
86
|
+
value = JSON.parse(raw);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw new Error("Source returned malformed JSON");
|
|
90
|
+
}
|
|
91
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
92
|
+
throw new Error("Invalid configuration document");
|
|
93
|
+
const object = value;
|
|
94
|
+
if (object.schemaVersion !== 1)
|
|
95
|
+
throw new Error("Unsupported configuration schema version");
|
|
96
|
+
if (Object.keys(object).some((key) => !["schemaVersion", "instanceId", "agent", "modelMenu"].includes(key))) {
|
|
97
|
+
throw new Error("Unexpected configuration fields");
|
|
98
|
+
}
|
|
99
|
+
if (typeof object.instanceId !== "string" || !object.instanceId || object.instanceId.length > 100)
|
|
100
|
+
throw new Error("Invalid source instance ID");
|
|
101
|
+
if (object.instanceId === this.#state.instanceId)
|
|
102
|
+
throw new Error("An instance cannot subscribe to itself");
|
|
103
|
+
const modelMenu = normalizeModelMenu(object.modelMenu);
|
|
104
|
+
if (!modelMenu)
|
|
105
|
+
throw new Error("Invalid model menu");
|
|
106
|
+
return { agent: this.deps.normalizeAgent(object.agent), modelMenu };
|
|
107
|
+
}
|
|
108
|
+
subscribe(url) {
|
|
109
|
+
return this.#exclusive(async () => {
|
|
110
|
+
if (this.#state.token)
|
|
111
|
+
throw new Error("Revoke the sharing link before enabling a configuration subscription");
|
|
112
|
+
return this.#sync(url);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
pause() {
|
|
116
|
+
return this.#exclusive(async () => {
|
|
117
|
+
this.#save({ enabled: false });
|
|
118
|
+
return this.status();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
sync(signal) {
|
|
122
|
+
return this.#exclusive(() => this.#state.enabled
|
|
123
|
+
? this.#sync(this.#state.url, signal)
|
|
124
|
+
: Promise.resolve("Configuration subscription is paused"));
|
|
125
|
+
}
|
|
126
|
+
async #sync(rawUrl, signal) {
|
|
127
|
+
try {
|
|
128
|
+
const url = configSourceUrl(rawUrl).href;
|
|
129
|
+
const local = await this.#local();
|
|
130
|
+
const etag = url === this.#state.url && hash(local) === this.#state.appliedHash ? this.#state.etag : null;
|
|
131
|
+
const timeout = AbortSignal.timeout(15_000);
|
|
132
|
+
const response = await (this.deps.download ?? downloadConfig)(url, etag, signal ? AbortSignal.any([signal, timeout]) : timeout);
|
|
133
|
+
signal?.throwIfAborted();
|
|
134
|
+
if (response.status === 304 && !etag)
|
|
135
|
+
throw new Error("Source returned 304 without a matching local configuration");
|
|
136
|
+
const next = response.status === 304 ? local : this.#parse(response.body);
|
|
137
|
+
let changed = false;
|
|
138
|
+
const previous = structuredClone(this.#state);
|
|
139
|
+
const commit = (filesChanged) => transact(this.deps.db, () => {
|
|
140
|
+
signal?.throwIfAborted();
|
|
141
|
+
const menuChanged = configJson(next.modelMenu) !== configJson(this.deps.settings.get().modelMenu);
|
|
142
|
+
changed = filesChanged || menuChanged;
|
|
143
|
+
if (menuChanged)
|
|
144
|
+
this.deps.settings.setModelMenu(next.modelMenu);
|
|
145
|
+
this.#save({
|
|
146
|
+
url, enabled: true, etag: response.status === 304 ? etag : response.etag,
|
|
147
|
+
appliedHash: hash(next), lastChecked: Date.now(),
|
|
148
|
+
lastApplied: changed ? Date.now() : previous.lastApplied,
|
|
149
|
+
needsReload: changed || previous.needsReload, error: null,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
// Reconcile under the file lock even on 304: local edits may have
|
|
154
|
+
// completed while the conditional download was in flight.
|
|
155
|
+
await this.deps.config.applySnapshot(next.agent, commit);
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
this.#state = previous;
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
if (this.#state.needsReload) {
|
|
162
|
+
try {
|
|
163
|
+
await this.deps.reload();
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new Error("Configuration saved, but reload failed; retry synchronization");
|
|
167
|
+
}
|
|
168
|
+
this.#save({ needsReload: false });
|
|
169
|
+
}
|
|
170
|
+
return changed ? "Configuration applied; reload completed" : `Configuration unchanged (${response.status})`;
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
const error = err instanceof Error ? err.message : "Configuration sync failed";
|
|
174
|
+
log.error(error);
|
|
175
|
+
this.#save({ lastChecked: Date.now(), error });
|
|
176
|
+
throw new Error(error);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
package/dist/core/hub.js
CHANGED
|
@@ -23,7 +23,7 @@ export class EventHub {
|
|
|
23
23
|
bus(sessionId) {
|
|
24
24
|
let b = this.buses.get(sessionId);
|
|
25
25
|
if (!b) {
|
|
26
|
-
b = { seq: 0, buffer: [], subscribers: new Set() };
|
|
26
|
+
b = { seq: 0, replayFloor: 0, buffer: [], subscribers: new Set() };
|
|
27
27
|
this.buses.set(sessionId, b);
|
|
28
28
|
}
|
|
29
29
|
return b;
|
|
@@ -36,9 +36,18 @@ export class EventHub {
|
|
|
36
36
|
sessionId,
|
|
37
37
|
...payload,
|
|
38
38
|
};
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
// Text deltas fan out live but never enter the ring: one long reply emits
|
|
40
|
+
// thousands of them, so a ring that held them would hold *only* them and
|
|
41
|
+
// would have evicted the turn-start, tool and turn-end events a
|
|
42
|
+
// reconnecting client replays for. The text is not lost — `turn-end`
|
|
43
|
+
// carries the full reply (web/ui/chat.ts treats it as authoritative).
|
|
44
|
+
// Thinking stays replayable because a native EventSource reconnect does
|
|
45
|
+
// not reload the transcript snapshot that would otherwise restore it.
|
|
46
|
+
if (payload.type !== "text-delta") {
|
|
47
|
+
b.buffer.push(event);
|
|
48
|
+
if (b.buffer.length > RING_SIZE)
|
|
49
|
+
b.replayFloor = b.buffer.shift().seq;
|
|
50
|
+
}
|
|
42
51
|
fanOut(b.subscribers, event);
|
|
43
52
|
}
|
|
44
53
|
subscribe(sessionId, fn) {
|
|
@@ -46,10 +55,16 @@ export class EventHub {
|
|
|
46
55
|
b.subscribers.add(fn);
|
|
47
56
|
return () => b.subscribers.delete(fn);
|
|
48
57
|
}
|
|
49
|
-
/** Events with seq > afterSeq still held in the ring buffer
|
|
58
|
+
/** Events with seq > afterSeq still held in the ring buffer — oldest first,
|
|
59
|
+
* and without text deltas, which are live-only. */
|
|
50
60
|
replay(sessionId, afterSeq) {
|
|
51
61
|
return this.bus(sessionId).buffer.filter((e) => e.seq > afterSeq);
|
|
52
62
|
}
|
|
63
|
+
/** Whether replay covers this cursor; live-only text gaps are intentional. */
|
|
64
|
+
covers(sessionId, afterSeq) {
|
|
65
|
+
const b = this.bus(sessionId);
|
|
66
|
+
return Number.isSafeInteger(afterSeq) && afterSeq >= b.replayFloor && afterSeq <= b.seq;
|
|
67
|
+
}
|
|
53
68
|
emitWorkspace(event) {
|
|
54
69
|
fanOut(this.workspace, event);
|
|
55
70
|
}
|
|
@@ -78,7 +93,9 @@ export class EventHub {
|
|
|
78
93
|
if (this.hasSubscribers(sessionId))
|
|
79
94
|
return;
|
|
80
95
|
const b = this.buses.get(sessionId);
|
|
81
|
-
if (b)
|
|
96
|
+
if (b) {
|
|
97
|
+
b.replayFloor = b.buffer.at(-1)?.seq ?? b.replayFloor;
|
|
82
98
|
b.buffer = [];
|
|
99
|
+
}
|
|
83
100
|
}
|
|
84
101
|
}
|
package/dist/core/inbox.js
CHANGED
|
@@ -11,7 +11,7 @@ import { randomBytes } from "node:crypto";
|
|
|
11
11
|
import { basename, join } from "node:path";
|
|
12
12
|
import { pierPath } from "../paths.js";
|
|
13
13
|
import { fileMarker, lostMarker, MAX_INBOUND_BYTES, safeName } from "./inbound-file.js";
|
|
14
|
-
/** Where every inbound file lives;
|
|
14
|
+
/** Where every inbound file lives; the attachment route allowlists this root. */
|
|
15
15
|
export const INBOX_DIR = pierPath("inbox");
|
|
16
16
|
/**
|
|
17
17
|
* Write one inbound file and return its absolute path. The timestamp-random
|
package/dist/core/reply.js
CHANGED
|
@@ -40,14 +40,20 @@ new day — so the last one still applies; a gap alone shows as time only, like
|
|
|
40
40
|
\`[14:23]\`. Use that \`id\` to mention someone; never ask for their own.
|
|
41
41
|
`;
|
|
42
42
|
/**
|
|
43
|
-
* The contract above plus the
|
|
43
|
+
* The contract above plus the facts about *this* deployment that an agent
|
|
44
44
|
* cannot work out for itself.
|
|
45
45
|
*
|
|
46
|
-
* Told, not discovered. Pier knows
|
|
46
|
+
* Told, not discovered. Pier knows them — the home is its own startup argument
|
|
47
47
|
* and the address is a file it wrote — while an agent can only guess at a
|
|
48
48
|
* path and shell out to look. The guess is wrong on every instance that moves
|
|
49
49
|
* `PIER_HOME`, and it fails as "nothing is configured": indistinguishable from
|
|
50
50
|
* the truth, and reported to the user as such. Which is what happened.
|
|
51
|
+
*
|
|
52
|
+
* The editing line is the same shape of fact, learned the same way: GPT models
|
|
53
|
+
* carry `apply_patch` from their own post-training, and not finding it in the
|
|
54
|
+
* tool list they go looking in the shell — one failed call, then a filesystem
|
|
55
|
+
* hunt, and in one session a stale Codex binary that worked, which put every
|
|
56
|
+
* later edit outside Pi's tooling.
|
|
51
57
|
*/
|
|
52
58
|
export function surfacePrompt(instance) {
|
|
53
59
|
const reach = instance.publicUrl
|
|
@@ -60,6 +66,10 @@ export function surfacePrompt(instance) {
|
|
|
60
66
|
## This Pier instance
|
|
61
67
|
|
|
62
68
|
Boards: \`${instance.boardsDir}/<slug>/\` — this path, not \`~/.pier\`. ${reach}
|
|
69
|
+
|
|
70
|
+
Editing: files change through the \`edit\` tool (exact text replacement) or
|
|
71
|
+
\`write\`. There is no \`apply_patch\` here — not as a tool, not as a command —
|
|
72
|
+
so do not call one or go looking for one in the shell.
|
|
63
73
|
`;
|
|
64
74
|
}
|
|
65
75
|
/**
|
|
@@ -216,3 +226,67 @@ export function splitReply(rawMarkdown, meta) {
|
|
|
216
226
|
return { text: markdown, suggestions: [], meta, silence };
|
|
217
227
|
return { text: markdown.slice(0, m.index).trimEnd(), suggestions, meta, silence };
|
|
218
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* The body of a block that is still being written, for a surface rendering it
|
|
231
|
+
* before the turn ends: everything `splitReply` repairs, minus the next-step
|
|
232
|
+
* block. That block is only a next-step block at the very *end* of a turn, so
|
|
233
|
+
* text a later paragraph has already closed behind keeps it as body — which is
|
|
234
|
+
* what the final render will do with it too.
|
|
235
|
+
*/
|
|
236
|
+
export const streamBody = (markdown) => cjkFriendly(markdown.replace(SILENT, "").trim());
|
|
237
|
+
/** Lines a blank line does not necessarily separate: a list item, a quote and
|
|
238
|
+
* an indented continuation all survive one — a loose list is still one list.
|
|
239
|
+
* Deliberately over-matching (`*` and `-` also catch emphasis and a rule):
|
|
240
|
+
* claiming one boundary too few costs nothing but a repaint. */
|
|
241
|
+
const CONTINUES = /^(?:\s|[-*+>]|\d+[.)])/;
|
|
242
|
+
/**
|
|
243
|
+
* How much of `markdown` after `from` can no longer change as more text
|
|
244
|
+
* arrives: the offset just past the last blank line that closes a block, or
|
|
245
|
+
* `from` if there is none.
|
|
246
|
+
*
|
|
247
|
+
* Streaming markdown is the caller. Re-parsing the whole reply on every
|
|
248
|
+
* repaint costs O(N²) over a turn, so the web chat renders each closed prefix
|
|
249
|
+
* once and keeps its DOM. A boundary is therefore only claimed where parsing
|
|
250
|
+
* the two sides separately renders the same as parsing them together: never
|
|
251
|
+
* inside a backtick or tilde fence, inside a block stripped on completion, or
|
|
252
|
+
* between two lines a blank line leaves in the same list or quote. Fence runs
|
|
253
|
+
* keep their length as in `channels/chunk.ts`: a ```` fence is not closed by
|
|
254
|
+
* the ``` it quotes.
|
|
255
|
+
*/
|
|
256
|
+
export function stableBlockEnd(markdown, from = 0) {
|
|
257
|
+
// The last line is still growing, so it decides nothing — not the fence
|
|
258
|
+
// state, and not whether the blank line above it ended a block.
|
|
259
|
+
const end = markdown.lastIndexOf("\n") + 1;
|
|
260
|
+
/** Marker run of the fence currently open; empty = closed. */
|
|
261
|
+
let open = "";
|
|
262
|
+
let silent = false;
|
|
263
|
+
let cut = from;
|
|
264
|
+
/** Start of the block after a blank line, waiting for its first line. */
|
|
265
|
+
let pending = -1;
|
|
266
|
+
let prev = "";
|
|
267
|
+
for (let at = from; at < end;) {
|
|
268
|
+
const nl = markdown.indexOf("\n", at);
|
|
269
|
+
const line = markdown.slice(at, nl);
|
|
270
|
+
at = nl + 1;
|
|
271
|
+
const fence = /^\s*(`{3,}|~{3,})(.*)$/.exec(line);
|
|
272
|
+
if (!open && !silent && !line.trim()) {
|
|
273
|
+
if (pending < 0 && prev)
|
|
274
|
+
pending = at;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!open && !silent && pending >= 0 && !(CONTINUES.test(prev) && CONTINUES.test(line)))
|
|
278
|
+
cut = pending;
|
|
279
|
+
const run = fence?.[1] ?? "";
|
|
280
|
+
if (!open && run && !silent)
|
|
281
|
+
open = run;
|
|
282
|
+
else if (open && run[0] === open[0] && run.length >= open.length && !fence?.[2]?.trim())
|
|
283
|
+
open = "";
|
|
284
|
+
if (!open && (!run || silent)) {
|
|
285
|
+
for (const tag of line.matchAll(/<(\/?)silent>/gi))
|
|
286
|
+
silent = !tag[1];
|
|
287
|
+
}
|
|
288
|
+
pending = -1;
|
|
289
|
+
prev = line;
|
|
290
|
+
}
|
|
291
|
+
return cut;
|
|
292
|
+
}
|
package/dist/core/router.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Conversation → session routing plus event wiring. In-memory on purpose:
|
|
2
2
|
// the durable chat → session map lives in channels/conversations.ts.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { logger } from "../log.js";
|
|
4
5
|
import { EventHub } from "./hub.js";
|
|
5
6
|
import { SenderPrefix, withPrefix } from "./identity.js";
|
|
@@ -22,6 +23,13 @@ function keyOf(key) {
|
|
|
22
23
|
* `ensure`, and which of the two a session records costs nothing but the
|
|
23
24
|
* answer to "what is it answering". */
|
|
24
25
|
const isAlias = (key) => key.channelId === "web" || key.channelId === "task";
|
|
26
|
+
export class QueueOperationError extends Error {
|
|
27
|
+
reason;
|
|
28
|
+
constructor(reason, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.reason = reason;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
25
33
|
export class Router {
|
|
26
34
|
hub;
|
|
27
35
|
resolve;
|
|
@@ -112,6 +120,8 @@ export class Router {
|
|
|
112
120
|
for (const [id, attached] of [...this.bySession]) {
|
|
113
121
|
if (attached.session.state === "streaming")
|
|
114
122
|
continue;
|
|
123
|
+
if (this.queueOperations.has(id) || this.recoveries.get(id)?.some((b) => b.status === "submitting"))
|
|
124
|
+
continue;
|
|
115
125
|
if (!includeWatched && this.hub.hasSubscribers(id))
|
|
116
126
|
continue;
|
|
117
127
|
if (now - attached.activeAt < ttlMs)
|
|
@@ -231,7 +241,7 @@ export class Router {
|
|
|
231
241
|
// its own queues up to the agent_end handler, so a non-empty queue on an
|
|
232
242
|
// idle session is exactly the message that missed that window.
|
|
233
243
|
if (payload.type === "queue-state" && (payload.steering.length || payload.followUp.length)) {
|
|
234
|
-
this.promoteQueued(session
|
|
244
|
+
this.promoteQueued(session);
|
|
235
245
|
}
|
|
236
246
|
// Every turn-end reaches the channel, empty text included: an adapter's
|
|
237
247
|
// per-turn UI (Telegram's 👀 receipts) is retired here, and a turn that
|
|
@@ -254,10 +264,124 @@ export class Router {
|
|
|
254
264
|
unsubscribe,
|
|
255
265
|
});
|
|
256
266
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
267
|
+
queueOperations = new Set();
|
|
268
|
+
promotionRequested = new Set();
|
|
269
|
+
recoveries = new Map();
|
|
270
|
+
// Keep the latest failed batch ID so a clear cannot erase a newer rejection
|
|
271
|
+
// that arrived while it awaited the backend. ACK affects only the copy.
|
|
272
|
+
uncertaintyHeld = new Map();
|
|
273
|
+
queueUncertain(sessionId) {
|
|
274
|
+
return this.uncertaintyHeld.has(sessionId);
|
|
275
|
+
}
|
|
276
|
+
recoveryOf(sessionId) {
|
|
277
|
+
return structuredClone(this.recoveries.get(sessionId) ?? []);
|
|
278
|
+
}
|
|
279
|
+
recoveryChanged(sessionId) {
|
|
280
|
+
if (!this.recoveries.get(sessionId)?.length)
|
|
281
|
+
this.recoveries.delete(sessionId);
|
|
282
|
+
this.hub.emit(sessionId, {
|
|
283
|
+
type: "queue-recovery", batches: this.recoveryOf(sessionId), uncertain: this.queueUncertain(sessionId),
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
acknowledgeRecovery(sessionId, batchId) {
|
|
287
|
+
const batches = this.recoveries.get(sessionId);
|
|
288
|
+
const batch = batches?.find((b) => b.id === batchId);
|
|
289
|
+
if (!batch)
|
|
290
|
+
throw new QueueOperationError("missing", "No such recovery batch");
|
|
291
|
+
if (batch.status === "submitting")
|
|
292
|
+
throw new QueueOperationError("busy", "Submission has not settled");
|
|
293
|
+
this.recoveries.set(sessionId, batches.filter((b) => b !== batch));
|
|
294
|
+
this.promotionRequested.delete(sessionId);
|
|
295
|
+
this.recoveryChanged(sessionId);
|
|
296
|
+
}
|
|
297
|
+
/** Only queue mutation owns this lock. A model turn owns its batch, not the
|
|
298
|
+
* next queue: manual controls remain available while that turn runs. */
|
|
299
|
+
async useQueue(sessionId, action, key = { channelId: "web", conversationId: sessionId }) {
|
|
300
|
+
if (this.queueOperations.has(sessionId))
|
|
301
|
+
throw new QueueOperationError("busy", "Queue operation in progress");
|
|
302
|
+
this.queueOperations.add(sessionId);
|
|
303
|
+
try {
|
|
304
|
+
return await action(await this.ensure(key));
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
this.queueOperations.delete(sessionId);
|
|
308
|
+
this.resumePromotion(sessionId);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async recallQueue(sessionId) {
|
|
312
|
+
return this.useQueue(sessionId, async (session) => {
|
|
313
|
+
const held = this.uncertaintyHeld.get(sessionId);
|
|
314
|
+
const queue = await session.clearQueue();
|
|
315
|
+
if (this.uncertaintyHeld.get(sessionId) === held && this.uncertaintyHeld.delete(sessionId))
|
|
316
|
+
this.recoveryChanged(sessionId);
|
|
317
|
+
if (queue.steering.length || queue.followUp.length)
|
|
318
|
+
this.forgetSender(sessionId);
|
|
319
|
+
return queue;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
checkQueueDrain() {
|
|
323
|
+
if (this.draining)
|
|
324
|
+
throw new QueueOperationError("draining", "Pier is restarting; queued messages were not submitted");
|
|
325
|
+
}
|
|
326
|
+
async deliverQueue(sessionId, mode) {
|
|
327
|
+
let retained = false;
|
|
328
|
+
try {
|
|
329
|
+
this.checkQueueDrain();
|
|
330
|
+
return await this.useQueue(sessionId, async (session) => {
|
|
331
|
+
this.checkQueueDrain();
|
|
332
|
+
if (mode === "auto" && (session.state !== "idle" || this.queueUncertain(sessionId) || this.recoveries.get(sessionId)?.length))
|
|
333
|
+
return "";
|
|
334
|
+
const held = this.uncertaintyHeld.get(sessionId);
|
|
335
|
+
const queue = await session.clearQueue();
|
|
336
|
+
if (mode !== "auto" && this.uncertaintyHeld.get(sessionId) === held && this.uncertaintyHeld.delete(sessionId))
|
|
337
|
+
this.recoveryChanged(sessionId);
|
|
338
|
+
if (!queue.steering.length && !queue.followUp.length)
|
|
339
|
+
throw new QueueOperationError("empty", "Queue is empty");
|
|
340
|
+
const batch = { id: randomUUID(), ...queue, status: "submitting" };
|
|
341
|
+
this.recoveries.set(sessionId, [...(this.recoveries.get(sessionId) ?? []), batch]);
|
|
342
|
+
retained = true;
|
|
343
|
+
this.recoveryChanged(sessionId);
|
|
344
|
+
const text = [...batch.steering, ...batch.followUp].join("\n");
|
|
345
|
+
let invoked = false;
|
|
346
|
+
const failed = (err) => {
|
|
347
|
+
this.forgetSender(sessionId);
|
|
348
|
+
this.promotionRequested.delete(sessionId);
|
|
349
|
+
batch.status = invoked ? "uncertain" : "not-submitted";
|
|
350
|
+
if (invoked)
|
|
351
|
+
this.uncertaintyHeld.set(sessionId, batch.id);
|
|
352
|
+
batch.error = String(err);
|
|
353
|
+
this.recoveryChanged(sessionId);
|
|
354
|
+
this.reportTo(sessionId, `Queue promotion failed (${batch.status}); automatic queue paused, originals remain available in queue recovery: ${String(err)}`);
|
|
355
|
+
};
|
|
356
|
+
try {
|
|
357
|
+
this.checkQueueDrain();
|
|
358
|
+
if (mode === "restart")
|
|
359
|
+
await this.abort(sessionId);
|
|
360
|
+
this.checkQueueDrain();
|
|
361
|
+
// Already headed at original dispatch. Calling dispatch again would
|
|
362
|
+
// discard rejection and could attribute these words to the operator.
|
|
363
|
+
invoked = true;
|
|
364
|
+
const submitted = mode === "steer" && session.state === "streaming"
|
|
365
|
+
? session.steer(text) : session.prompt(text);
|
|
366
|
+
void submitted.then(() => {
|
|
367
|
+
this.recoveries.set(sessionId, (this.recoveries.get(sessionId) ?? []).filter((b) => b !== batch));
|
|
368
|
+
this.recoveryChanged(sessionId);
|
|
369
|
+
}, failed).finally(() => this.resumePromotion(sessionId));
|
|
370
|
+
return text;
|
|
371
|
+
}
|
|
372
|
+
catch (err) {
|
|
373
|
+
failed(err);
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
}, mode === "auto" ? this.conversationOf(sessionId) : undefined);
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
if (!retained && !(err instanceof QueueOperationError && (err.reason === "busy" || err.reason === "empty"))) {
|
|
380
|
+
this.reportTo(sessionId, `Could not promote queued messages: ${String(err)}`);
|
|
381
|
+
}
|
|
382
|
+
throw err;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
261
385
|
/**
|
|
262
386
|
* Turn a stranded queue into the turn it was waiting for. Not routed through
|
|
263
387
|
* `dispatch`: the text was prefixed when it was first dispatched
|
|
@@ -268,40 +392,39 @@ export class Router {
|
|
|
268
392
|
* start the very turn it was asked to stop. Recovering *those* messages stays
|
|
269
393
|
* the web's recall route, which hands them back to the composer.
|
|
270
394
|
*/
|
|
271
|
-
promoteQueued(session
|
|
272
|
-
if (session.state !== "idle"
|
|
395
|
+
promoteQueued(session) {
|
|
396
|
+
if (session.state !== "idle")
|
|
273
397
|
return;
|
|
274
|
-
this.
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
log.
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
catch (err) {
|
|
296
|
-
this.report(session.id, key, `delivering the queued messages failed: ${String(err)}`);
|
|
297
|
-
}
|
|
298
|
-
finally {
|
|
299
|
-
this.promoting.delete(session.id);
|
|
300
|
-
}
|
|
301
|
-
})();
|
|
398
|
+
this.promotionRequested.add(session.id);
|
|
399
|
+
this.resumePromotion(session.id);
|
|
400
|
+
}
|
|
401
|
+
resumePromotion(sessionId) {
|
|
402
|
+
// queue_update precedes the backend's enqueue. Never clear it reentrantly.
|
|
403
|
+
queueMicrotask(() => {
|
|
404
|
+
if (!this.promotionRequested.has(sessionId) || this.queueOperations.has(sessionId))
|
|
405
|
+
return;
|
|
406
|
+
// A queue event followed by rejection might describe the same input.
|
|
407
|
+
// Retained failures require a human decision, not an automatic resend.
|
|
408
|
+
if (this.queueUncertain(sessionId) || this.recoveries.get(sessionId)?.length)
|
|
409
|
+
return;
|
|
410
|
+
this.promotionRequested.delete(sessionId);
|
|
411
|
+
const attached = this.bySession.get(sessionId);
|
|
412
|
+
if (!attached || attached.session.state !== "idle")
|
|
413
|
+
return;
|
|
414
|
+
void this.deliverQueue(sessionId, "auto").catch((err) => {
|
|
415
|
+
// deliverQueue reports failures; empty/busy simply lost the race.
|
|
416
|
+
log.debug(`automatic promotion of ${sessionId} stopped: ${String(err)}`);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
302
419
|
}
|
|
303
420
|
async abort(sessionId) {
|
|
304
|
-
|
|
421
|
+
this.promotionRequested.delete(sessionId);
|
|
422
|
+
try {
|
|
423
|
+
await this.bySession.get(sessionId)?.session.abort();
|
|
424
|
+
}
|
|
425
|
+
finally {
|
|
426
|
+
this.promotionRequested.delete(sessionId);
|
|
427
|
+
}
|
|
305
428
|
}
|
|
306
429
|
/**
|
|
307
430
|
* Drop what this session was told about who is speaking, so the next message
|
|
@@ -358,7 +481,9 @@ export class Router {
|
|
|
358
481
|
return this.byKey.get(keyOf(key));
|
|
359
482
|
}
|
|
360
483
|
async abortConversation(key) {
|
|
361
|
-
|
|
484
|
+
const session = this.sessionOf(key);
|
|
485
|
+
if (session)
|
|
486
|
+
await this.abort(session.id);
|
|
362
487
|
}
|
|
363
488
|
/** Session owning a conversation, resolving and attaching it on first use. */
|
|
364
489
|
async ensure(key) {
|
package/dist/db.js
CHANGED
|
@@ -289,6 +289,33 @@ const MIGRATIONS = [
|
|
|
289
289
|
session_id TEXT NOT NULL REFERENCES web_sessions(id) ON DELETE CASCADE
|
|
290
290
|
);
|
|
291
291
|
`,
|
|
292
|
+
// 15 — the scheduler's tick stops reading the rows it cannot deliver.
|
|
293
|
+
`
|
|
294
|
+
-- Once a second, tasks/ asks for the runs whose callback is still owed and
|
|
295
|
+
-- the messages whose injection has not landed. Both are a handful of rows
|
|
296
|
+
-- filtered on one low-cardinality column, and without an index both are a
|
|
297
|
+
-- full scan of a table that only grows — the sweep got slower with every
|
|
298
|
+
-- run that finished cleanly and can never match again.
|
|
299
|
+
CREATE INDEX task_runs_callback_state ON task_runs(callback_state);
|
|
300
|
+
CREATE INDEX task_messages_state ON task_messages(state);
|
|
301
|
+
`,
|
|
302
|
+
// 16 — the same tick stops parsing every task document to find none due.
|
|
303
|
+
`
|
|
304
|
+
-- When a task is next due is the one field the scheduler asks about once a
|
|
305
|
+
-- second, and it lived only inside the JSON: answering meant reading and
|
|
306
|
+
-- parsing every definition, due or not. A generated column keeps the JSON
|
|
307
|
+
-- as the only record while giving the query planner a value it can index; a
|
|
308
|
+
-- disabled or archived task has no next run, so NULLs stay out of the index.
|
|
309
|
+
ALTER TABLE tasks ADD COLUMN next_run_at INTEGER
|
|
310
|
+
GENERATED ALWAYS AS (json_extract(json, '$.nextRunAt')) VIRTUAL;
|
|
311
|
+
CREATE INDEX tasks_due ON tasks(next_run_at) WHERE next_run_at IS NOT NULL;
|
|
312
|
+
`,
|
|
313
|
+
// 17 — stable global pages, including sparse lists with unmatched probes hidden.
|
|
314
|
+
`
|
|
315
|
+
CREATE INDEX task_runs_time_id ON task_runs(queued_at DESC, id DESC);
|
|
316
|
+
CREATE INDEX task_runs_visible_time ON task_runs(queued_at DESC, id DESC)
|
|
317
|
+
WHERE NOT (state = 'succeeded' AND json_extract(json, '$.matched') IS 0);
|
|
318
|
+
`,
|
|
292
319
|
];
|
|
293
320
|
/**
|
|
294
321
|
* Several writes as one, or none. `BEGIN IMMEDIATE` because every writer here
|
|
@@ -310,6 +337,24 @@ export function transact(db, work) {
|
|
|
310
337
|
throw err;
|
|
311
338
|
}
|
|
312
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Prepared statements, memoized by their SQL. `prepare()` compiles, and the
|
|
342
|
+
* callers here hand it the same handful of strings forever — twice per
|
|
343
|
+
* authenticated request, once a second per scheduler sweep. A `StatementSync`
|
|
344
|
+
* is reusable with different bound parameters, so one per SQL string per
|
|
345
|
+
* connection is the whole cache. Bound to the connection because a statement
|
|
346
|
+
* belongs to the database that compiled it; SQL built per call does not
|
|
347
|
+
* belong in here, since the cache would then grow without a bound.
|
|
348
|
+
*/
|
|
349
|
+
export function statements(db) {
|
|
350
|
+
const cache = new Map();
|
|
351
|
+
return (sql) => {
|
|
352
|
+
let stmt = cache.get(sql);
|
|
353
|
+
if (!stmt)
|
|
354
|
+
cache.set(sql, (stmt = db.prepare(sql)));
|
|
355
|
+
return stmt;
|
|
356
|
+
};
|
|
357
|
+
}
|
|
313
358
|
let shared;
|
|
314
359
|
/**
|
|
315
360
|
* The process's one connection, opened and migrated on first use. Every store
|
|
@@ -350,6 +395,11 @@ export function openDb(path, migrations = MIGRATIONS) {
|
|
|
350
395
|
// file, and SQLite refuses to change it inside one.
|
|
351
396
|
db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
352
397
|
db.exec("PRAGMA journal_mode = WAL");
|
|
398
|
+
// WAL's default leaves every commit waiting on an fsync, and DatabaseSync is
|
|
399
|
+
// synchronous — that wait is the event loop's. NORMAL still survives a
|
|
400
|
+
// process crash; only a power loss can cost the last transactions, which for
|
|
401
|
+
// routing state and task bookkeeping is a fair trade for not blocking.
|
|
402
|
+
db.exec("PRAGMA synchronous = NORMAL");
|
|
353
403
|
// Off by default in SQLite, and a declared relationship nothing enforces is
|
|
354
404
|
// a comment. Set before migrate(): it is a per-connection switch and a no-op
|
|
355
405
|
// inside a transaction. Nothing older declares a key, so this changes the
|