@timqi/pier 0.1.14 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -22
- package/dist/agent/config.js +2 -2
- package/dist/agent/events.js +32 -3
- package/dist/agent/listing.js +14 -10
- package/dist/agent/packages.js +6 -10
- package/dist/agent/pi.js +113 -70
- package/dist/agent/roles.js +104 -0
- package/dist/agent/types.js +78 -0
- package/dist/boards/boards.js +42 -32
- package/dist/channels/chains.js +0 -4
- package/dist/channels/commands.js +7 -12
- package/dist/channels/config.js +27 -1
- package/dist/channels/control.js +5 -16
- package/dist/channels/conversations.js +4 -9
- package/dist/channels/lark-api.js +20 -26
- package/dist/channels/lark-outbound.js +21 -29
- package/dist/channels/lark-panel.js +1 -1
- package/dist/channels/lark.js +81 -39
- package/dist/channels/lines.js +33 -1
- package/dist/channels/panel.js +17 -107
- package/dist/channels/routes.js +11 -21
- package/dist/channels/runtime.js +79 -25
- package/dist/channels/slack-outbound.js +12 -14
- package/dist/channels/slack-panel.js +1 -1
- package/dist/channels/slack-render.js +1 -1
- package/dist/channels/slack-thread.js +65 -0
- package/dist/channels/slack.js +85 -124
- package/dist/channels/types.js +1 -1
- package/dist/cli.js +7 -2
- package/dist/config-sync.js +42 -37
- package/dist/core/chain.js +221 -0
- package/dist/core/identity.js +36 -13
- package/dist/core/inbound-file.js +11 -4
- package/dist/core/reply.js +111 -81
- package/dist/core/router.js +111 -118
- package/dist/core/search.js +65 -0
- package/dist/core/types.js +38 -77
- package/dist/db.js +41 -2
- package/dist/drain.js +1 -1
- package/dist/main.js +65 -40
- package/dist/settings.js +26 -19
- package/dist/socket.js +1 -0
- package/dist/tasks/agent.js +11 -5
- package/dist/tasks/callbacks.js +55 -5
- package/dist/tasks/cli.js +22 -11
- package/dist/tasks/definitions.js +30 -17
- package/dist/tasks/execution.js +11 -5
- package/dist/tasks/groups.js +5 -2
- package/dist/tasks/messages.js +7 -7
- package/dist/tasks/open-items.js +88 -0
- package/dist/tasks/operations.js +76 -39
- package/dist/tasks/outbox.js +19 -6
- package/dist/tasks/routes.js +19 -249
- package/dist/tasks/service.js +166 -34
- package/dist/tasks/store.js +123 -57
- package/dist/tasks/types.js +3 -0
- package/dist/tools.js +12 -5
- package/dist/web/auth.js +2 -2
- package/dist/web/config-sync.js +4 -5
- package/dist/web/config.js +9 -2
- package/dist/web/explorer.js +2 -2
- package/dist/web/fs.js +16 -1
- package/dist/web/instance.js +8 -5
- package/dist/web/packages.js +1 -1
- package/dist/web/passkeys.js +3 -3
- package/dist/web/providers.js +3 -3
- package/dist/web/public/assets/code-CWT27KH1.js +2 -0
- package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
- package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js +5 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js.br +0 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js.gz +0 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
- package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
- package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
- package/dist/web/public/assets/index-D0ZYdweB.js +14 -0
- package/dist/web/public/assets/index-D0ZYdweB.js.br +0 -0
- package/dist/web/public/assets/index-D0ZYdweB.js.gz +0 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js +9 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js.br +0 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js.gz +0 -0
- package/dist/web/public/index.html +39 -109
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +0 -2
- package/dist/web/push.js +3 -3
- package/dist/web/server.js +89 -116
- package/dist/web/session-state.js +4 -40
- package/docs/deploy.md +5 -3
- package/package.json +2 -1
- package/skills/pier-boards/SKILL.md +14 -4
- package/skills/pier-help/SKILL.md +62 -23
- package/skills/pier-search/SKILL.md +27 -0
- package/skills/pier-tasks/SKILL.md +68 -18
- package/dist/channels/handoff.js +0 -94
- package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
- package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
- package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
- package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
- package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
- package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
- package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js +0 -85
- package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
- package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
- package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
- package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
- package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
- package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
- package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
- package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
- package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
- package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
- package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
- package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
package/dist/channels/types.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// adapters and the Console view (type-only, so no node builtins here). Defaults
|
|
3
3
|
// are least-privilege, and platform values are seeds copied into a chat on
|
|
4
4
|
// discovery, not a runtime fallback: a switch means what it says.
|
|
5
|
-
const PLATFORMS = ["slack", "lark"];
|
|
5
|
+
export const PLATFORMS = ["slack", "lark"];
|
|
6
6
|
/** Validate at the boundary: an unknown platform is a 404, not a new row. */
|
|
7
7
|
export const isChannelPlatform = (v) => typeof v === "string" && PLATFORMS.includes(v);
|
|
8
8
|
/** Every adapter spells its ids `<chatId>` or `<chatId>/<thread>`, so the chat
|
package/dist/cli.js
CHANGED
|
@@ -29,6 +29,7 @@ Usage
|
|
|
29
29
|
pier slack <subcommand> ... Slack from a shell, token from the vault (pier slack --help)
|
|
30
30
|
pier task <command> ... subagents and scheduled tasks from a shell (pier task --help)
|
|
31
31
|
pier web search|fetch ... the public web through the provider's hosted tools (pier web --help)
|
|
32
|
+
pier search <q...> ... earlier sessions by what was said in them (skills/pier-search)
|
|
32
33
|
pier --version | --help
|
|
33
34
|
|
|
34
35
|
Options for "service install"
|
|
@@ -57,8 +58,8 @@ const argv = process.argv.slice(2);
|
|
|
57
58
|
const parsed = (() => {
|
|
58
59
|
try {
|
|
59
60
|
return parseArgs({
|
|
60
|
-
// `slack`, `task` and `
|
|
61
|
-
args:
|
|
61
|
+
// `slack`, `task`, `web` and `search` own their options; only the name is parsed here.
|
|
62
|
+
args: ["slack", "task", "web", "search"].includes(argv[0] ?? "") ? [argv[0]] : argv,
|
|
62
63
|
allowPositionals: true,
|
|
63
64
|
strict: true,
|
|
64
65
|
options: {
|
|
@@ -134,6 +135,10 @@ else if (command === "web") {
|
|
|
134
135
|
const { runWebCli } = await import("./websearch/cli.js");
|
|
135
136
|
process.exitCode = await runWebCli(argv.slice(1), (params) => askPier("/web", { params }, WEB_TIMEOUT_MS));
|
|
136
137
|
}
|
|
138
|
+
else if (command === "search") {
|
|
139
|
+
const { runSearchCli } = await import("./core/search.js");
|
|
140
|
+
process.exitCode = await runSearchCli(argv.slice(1), (params) => askPier("/search", { params }));
|
|
141
|
+
}
|
|
137
142
|
else if (command === "login") {
|
|
138
143
|
if (subcommand)
|
|
139
144
|
fail(`unexpected argument "${subcommand}"`);
|
package/dist/config-sync.js
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
// One serialized configuration subscriber, shared by Settings and the hourly
|
|
2
|
-
// task.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
2
|
+
// task. The source is the authority: what it publishes replaces the local copy
|
|
3
|
+
// of every field the document carries, and a source on another schema version
|
|
4
|
+
// pauses the subscription rather than being merged. Fetched over HTTPS only,
|
|
5
|
+
// redirects followed while they stay HTTPS, JSON capped at 1 MiB so a hostile
|
|
6
|
+
// or broken source cannot exhaust memory.
|
|
7
|
+
import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
6
8
|
import { transact } from "./db.js";
|
|
7
9
|
import { logger } from "./log.js";
|
|
8
|
-
import {
|
|
10
|
+
import { parseModelMenu } from "./settings.js";
|
|
9
11
|
const log = logger("config-sync");
|
|
10
12
|
export const CONFIG_SYNC_BYTES = 1024 * 1024;
|
|
13
|
+
/** Bumped by any change to the document's shape — a menu row's fields, the
|
|
14
|
+
* snapshot's, the envelope's. A subscriber applies its own version and
|
|
15
|
+
* nothing else: no field-level compatibility, the older instance upgrades. */
|
|
16
|
+
export const CONFIG_SCHEMA_VERSION = 2;
|
|
11
17
|
const MAX_REDIRECTS = 5;
|
|
18
|
+
class SchemaMismatch extends Error {
|
|
19
|
+
constructor(published) {
|
|
20
|
+
super(`Source publishes configuration schema ${typeof published === "number" ? `v${String(published)}` : "of an unknown version"}; `
|
|
21
|
+
+ `this Pier reads v${String(CONFIG_SCHEMA_VERSION)} — upgrade the older instance, then resume the subscription`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
12
24
|
export function configSourceUrl(raw) {
|
|
13
25
|
let url;
|
|
14
26
|
try {
|
|
@@ -22,16 +34,13 @@ export function configSourceUrl(raw) {
|
|
|
22
34
|
}
|
|
23
35
|
return url;
|
|
24
36
|
}
|
|
25
|
-
export async function downloadConfig(raw,
|
|
37
|
+
export async function downloadConfig(raw, signal) {
|
|
26
38
|
let url = configSourceUrl(raw);
|
|
27
39
|
for (let hop = 0;; hop++) {
|
|
28
40
|
signal.throwIfAborted();
|
|
29
41
|
let res;
|
|
30
42
|
try {
|
|
31
|
-
res = await fetch(url, {
|
|
32
|
-
method: "GET", redirect: "manual", signal,
|
|
33
|
-
headers: { accept: "application/json", ...(etag ? { "if-none-match": etag } : {}) },
|
|
34
|
-
});
|
|
43
|
+
res = await fetch(url, { method: "GET", redirect: "manual", signal, headers: { accept: "application/json" } });
|
|
35
44
|
}
|
|
36
45
|
catch {
|
|
37
46
|
throw new Error(signal.aborted ? "Configuration download timed out or was cancelled" : "Could not connect to source");
|
|
@@ -49,18 +58,17 @@ export async function downloadConfig(raw, etag, signal) {
|
|
|
49
58
|
}
|
|
50
59
|
continue;
|
|
51
60
|
}
|
|
52
|
-
if (res.status !== 200
|
|
61
|
+
if (res.status !== 200) {
|
|
53
62
|
await res.body?.cancel().catch(() => { });
|
|
54
63
|
throw new Error(res.status === 404 || res.status === 410
|
|
55
64
|
? "Source link was revoked or does not exist"
|
|
56
65
|
: `Source returned HTTP ${String(res.status)}`);
|
|
57
66
|
}
|
|
58
|
-
|
|
59
|
-
if (status === 200 && !/^application\/json(?:\s*;|$)/i.test(res.headers.get("content-type") ?? "")) {
|
|
67
|
+
if (!/^application\/json(?:\s*;|$)/i.test(res.headers.get("content-type") ?? "")) {
|
|
60
68
|
await res.body?.cancel().catch(() => { });
|
|
61
69
|
throw new Error("Source did not return JSON");
|
|
62
70
|
}
|
|
63
|
-
return
|
|
71
|
+
return read(res, signal);
|
|
64
72
|
}
|
|
65
73
|
}
|
|
66
74
|
async function read(res, signal) {
|
|
@@ -94,7 +102,6 @@ export function configJson(value) {
|
|
|
94
102
|
return JSON.stringify(value, (_key, entry) => entry && typeof entry === "object" && !Array.isArray(entry)
|
|
95
103
|
? Object.fromEntries(Object.entries(entry).sort(([a], [b]) => a.localeCompare(b, "en"))) : entry);
|
|
96
104
|
}
|
|
97
|
-
const hash = (value) => createHash("sha256").update(configJson(value)).digest("hex");
|
|
98
105
|
export class ConfigSync {
|
|
99
106
|
deps;
|
|
100
107
|
#state;
|
|
@@ -102,9 +109,12 @@ export class ConfigSync {
|
|
|
102
109
|
constructor(deps) {
|
|
103
110
|
this.deps = deps;
|
|
104
111
|
const row = deps.db.prepare("SELECT value FROM settings WHERE key = ?").get(KEY);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
// Field by field: a row an older Pier wrote carries keys this one no longer keeps.
|
|
113
|
+
const stored = row ? JSON.parse(row.value) : {};
|
|
114
|
+
this.#state = {
|
|
115
|
+
instanceId: stored.instanceId ?? randomUUID(), token: stored.token ?? null, url: stored.url ?? "",
|
|
116
|
+
enabled: stored.enabled ?? false, lastChecked: stored.lastChecked ?? null, lastApplied: stored.lastApplied ?? null,
|
|
117
|
+
error: stored.error ?? null, needsReload: stored.needsReload ?? false,
|
|
108
118
|
};
|
|
109
119
|
if (!row)
|
|
110
120
|
this.#save();
|
|
@@ -154,11 +164,11 @@ export class ConfigSync {
|
|
|
154
164
|
const local = await this.#local();
|
|
155
165
|
if (this.#state.token !== expected)
|
|
156
166
|
return null;
|
|
157
|
-
const document = { schemaVersion:
|
|
167
|
+
const document = { schemaVersion: CONFIG_SCHEMA_VERSION, instanceId: this.#state.instanceId, ...local };
|
|
158
168
|
const body = configJson(document);
|
|
159
169
|
if (Buffer.byteLength(body) > CONFIG_SYNC_BYTES)
|
|
160
170
|
throw new Error("Configuration exceeds 1 MiB");
|
|
161
|
-
return
|
|
171
|
+
return body;
|
|
162
172
|
}
|
|
163
173
|
#parse(raw) {
|
|
164
174
|
if (Buffer.byteLength(raw) > CONFIG_SYNC_BYTES)
|
|
@@ -173,8 +183,8 @@ export class ConfigSync {
|
|
|
173
183
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
174
184
|
throw new Error("Invalid configuration document");
|
|
175
185
|
const object = value;
|
|
176
|
-
if (object.schemaVersion !==
|
|
177
|
-
throw new
|
|
186
|
+
if (object.schemaVersion !== CONFIG_SCHEMA_VERSION)
|
|
187
|
+
throw new SchemaMismatch(object.schemaVersion);
|
|
178
188
|
if (Object.keys(object).some((key) => !["schemaVersion", "instanceId", "agent", "modelMenu"].includes(key))) {
|
|
179
189
|
throw new Error("Unexpected configuration fields");
|
|
180
190
|
}
|
|
@@ -182,9 +192,9 @@ export class ConfigSync {
|
|
|
182
192
|
throw new Error("Invalid source instance ID");
|
|
183
193
|
if (object.instanceId === this.#state.instanceId)
|
|
184
194
|
throw new Error("An instance cannot subscribe to itself");
|
|
185
|
-
const modelMenu =
|
|
186
|
-
if (
|
|
187
|
-
throw new Error(
|
|
195
|
+
const modelMenu = parseModelMenu(object.modelMenu);
|
|
196
|
+
if (typeof modelMenu === "string")
|
|
197
|
+
throw new Error(`Invalid model menu: ${modelMenu}`);
|
|
188
198
|
return { agent: this.deps.normalizeAgent(object.agent), modelMenu };
|
|
189
199
|
}
|
|
190
200
|
subscribe(url) {
|
|
@@ -208,14 +218,10 @@ export class ConfigSync {
|
|
|
208
218
|
async #sync(rawUrl, signal) {
|
|
209
219
|
try {
|
|
210
220
|
const url = configSourceUrl(rawUrl).href;
|
|
211
|
-
const local = await this.#local();
|
|
212
|
-
const etag = url === this.#state.url && hash(local) === this.#state.appliedHash ? this.#state.etag : null;
|
|
213
221
|
const timeout = AbortSignal.timeout(15_000);
|
|
214
|
-
const response = await (this.deps.download ?? downloadConfig)(url,
|
|
222
|
+
const response = await (this.deps.download ?? downloadConfig)(url, signal ? AbortSignal.any([signal, timeout]) : timeout);
|
|
215
223
|
signal?.throwIfAborted();
|
|
216
|
-
|
|
217
|
-
throw new Error("Source returned 304 without a matching local configuration");
|
|
218
|
-
const next = response.status === 304 ? local : this.#parse(response.body);
|
|
224
|
+
const next = this.#parse(response);
|
|
219
225
|
let changed = false;
|
|
220
226
|
const previous = structuredClone(this.#state);
|
|
221
227
|
const commit = (filesChanged) => transact(this.deps.db, () => {
|
|
@@ -225,15 +231,12 @@ export class ConfigSync {
|
|
|
225
231
|
if (menuChanged)
|
|
226
232
|
this.deps.settings.setModelMenu(next.modelMenu);
|
|
227
233
|
this.#save({
|
|
228
|
-
url, enabled: true,
|
|
229
|
-
appliedHash: hash(next), lastChecked: Date.now(),
|
|
234
|
+
url, enabled: true, lastChecked: Date.now(),
|
|
230
235
|
lastApplied: changed ? Date.now() : previous.lastApplied,
|
|
231
236
|
needsReload: changed || previous.needsReload, error: null,
|
|
232
237
|
});
|
|
233
238
|
});
|
|
234
239
|
try {
|
|
235
|
-
// Reconcile under the file lock even on 304: local edits may have
|
|
236
|
-
// completed while the conditional download was in flight.
|
|
237
240
|
await this.deps.config.applySnapshot(next.agent, commit);
|
|
238
241
|
}
|
|
239
242
|
catch (err) {
|
|
@@ -249,13 +252,15 @@ export class ConfigSync {
|
|
|
249
252
|
}
|
|
250
253
|
this.#save({ needsReload: false });
|
|
251
254
|
}
|
|
252
|
-
return changed ? "Configuration applied; reload completed" :
|
|
255
|
+
return changed ? "Configuration applied; reload completed" : "Configuration unchanged";
|
|
253
256
|
}
|
|
254
257
|
catch (err) {
|
|
255
258
|
const error = err instanceof Error ? err.message : "Configuration sync failed";
|
|
256
259
|
// The message is the operator's; the cause, when there is one, is the log's.
|
|
257
260
|
log.error(error, err instanceof Error ? err.cause : err);
|
|
258
|
-
|
|
261
|
+
// A version gap is not retried hourly: the fix is an upgrade, and the
|
|
262
|
+
// notice stands until the operator resumes.
|
|
263
|
+
this.#save({ lastChecked: Date.now(), error, ...(err instanceof SchemaMismatch ? { enabled: false } : {}) });
|
|
259
264
|
throw new Error(error);
|
|
260
265
|
}
|
|
261
266
|
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Which session takes the user's message: an ordered chain of main sessions in
|
|
2
|
+
// `$PIER_HOME/home`, whose newest — the head — takes every one, rotated lazily
|
|
3
|
+
// once idle past an hour or full (docs/design/10-continuous-session.md).
|
|
4
|
+
import { mkdirSync } from "node:fs";
|
|
5
|
+
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { transact } from "../db.js";
|
|
8
|
+
import { logger } from "../log.js";
|
|
9
|
+
import { day } from "./identity.js";
|
|
10
|
+
import { cut } from "./reply.js";
|
|
11
|
+
import { CHAIN_FULL_TOKENS, CHAIN_IDLE_MS, isChatCommand } from "./types.js";
|
|
12
|
+
const log = logger("core");
|
|
13
|
+
const EXCHANGES = 3;
|
|
14
|
+
// Seed budgets in chars (10 §Head lifecycle); the ledger is newest first, so its oldest lines go.
|
|
15
|
+
const MEMORY_CHARS = 12_000;
|
|
16
|
+
const NOTES_CHARS = 6_000;
|
|
17
|
+
const EXCHANGES_CHARS = 4_000;
|
|
18
|
+
const LEDGER_CHARS = 4_000;
|
|
19
|
+
/** `cut` from the front, whole lines: notes and exchanges grow at the end,
|
|
20
|
+
* so the newest part stays; the marker says how much went and where it is. */
|
|
21
|
+
function tail(text, max, rest) {
|
|
22
|
+
if (text.length <= max)
|
|
23
|
+
return text;
|
|
24
|
+
const lines = text.split("\n");
|
|
25
|
+
let kept = 0;
|
|
26
|
+
let size = 0;
|
|
27
|
+
for (let i = lines.length - 1; i >= 0 && size + lines[i].length + 1 <= max; i--, kept++)
|
|
28
|
+
size += lines[i].length + 1;
|
|
29
|
+
const dropped = lines.length - kept;
|
|
30
|
+
return `… ${String(dropped)} lines omitted, the rest in ${rest}\n${lines.slice(dropped).join("\n")}`;
|
|
31
|
+
}
|
|
32
|
+
const WHY = {
|
|
33
|
+
first: "the first one",
|
|
34
|
+
idle: "the previous one was idle for an hour",
|
|
35
|
+
lost: "the previous one is gone from Pi",
|
|
36
|
+
full: `the previous one reached ${String(CHAIN_FULL_TOKENS / 1000)}K tokens`,
|
|
37
|
+
new: "you asked for one with /new",
|
|
38
|
+
};
|
|
39
|
+
/** A `/new` sent to a head mid-reply: a 409 to the composer, never a card the
|
|
40
|
+
* head's queue would only deliver after the turn it refuses for. */
|
|
41
|
+
export class ChatCommandRefused extends Error {
|
|
42
|
+
}
|
|
43
|
+
const webKey = (sessionId) => ({ channelId: "web", conversationId: sessionId });
|
|
44
|
+
/** The last `n` user turns and the replies after them, text only. */
|
|
45
|
+
function lastExchanges(turns, n) {
|
|
46
|
+
const users = turns.flatMap((t, i) => (t.role === "user" ? [i] : []));
|
|
47
|
+
const from = users[Math.max(0, users.length - n)];
|
|
48
|
+
if (from === undefined)
|
|
49
|
+
return "";
|
|
50
|
+
return turns.slice(from).filter((t) => t.role !== "system" && t.text)
|
|
51
|
+
.map((t) => `${t.role}: ${t.text}`).join("\n\n");
|
|
52
|
+
}
|
|
53
|
+
const ledgerLine = (r) => `${r.runId} · ${r.name} · ${r.state} · session ${r.targetSessionId ?? "—"} · ${r.cwd ?? "—"}`;
|
|
54
|
+
/** `/skills`' answer on every surface, the head's and a thread's (channels/). */
|
|
55
|
+
export const skillsText = (skills) => skills.map((s) => `${s.name} — ${s.description}`).join("\n") || "no skills";
|
|
56
|
+
/** Only the exact word is a command: the composer is not a shell. */
|
|
57
|
+
function chatCommand(text) {
|
|
58
|
+
const draft = text.trim().toLowerCase();
|
|
59
|
+
const word = draft.slice(1);
|
|
60
|
+
return /^[/%]/.test(draft) && isChatCommand(word) ? word : undefined;
|
|
61
|
+
}
|
|
62
|
+
export class MainChain {
|
|
63
|
+
db;
|
|
64
|
+
deps;
|
|
65
|
+
/** Sends pass one at a time, so two cannot both find the head idle and rotate twice. */
|
|
66
|
+
queue = Promise.resolve();
|
|
67
|
+
/** The chat the send in flight came from, if any: the head is attached under
|
|
68
|
+
* it before anything is appended, so the seed reaches the chat too. */
|
|
69
|
+
chat;
|
|
70
|
+
constructor(db, deps) {
|
|
71
|
+
this.db = db;
|
|
72
|
+
this.deps = deps;
|
|
73
|
+
}
|
|
74
|
+
/** Newest first. */
|
|
75
|
+
members() {
|
|
76
|
+
const rows = this.db.prepare("SELECT session_id, started_at, reason FROM main_chain ORDER BY started_at DESC, rowid DESC")
|
|
77
|
+
.all();
|
|
78
|
+
return rows.map((r) => ({ sessionId: r.session_id, startedAt: r.started_at, reason: r.reason }));
|
|
79
|
+
}
|
|
80
|
+
/** Every member's id, newest first, when `sessionId` is one: a member's
|
|
81
|
+
* launches are the whole chain's, and a result owed to it goes to the head. */
|
|
82
|
+
chainOf(sessionId) {
|
|
83
|
+
const ids = this.members().map((m) => m.sessionId);
|
|
84
|
+
return ids.includes(sessionId) ? ids : undefined;
|
|
85
|
+
}
|
|
86
|
+
/** What `/status` answers, now: the Status panel shows this same text. */
|
|
87
|
+
status() {
|
|
88
|
+
return this.deps.status(this.now());
|
|
89
|
+
}
|
|
90
|
+
/** Resolve the head, rotating it first when due, then dispatch to `key` (a
|
|
91
|
+
* chat's) or else the head's own; `command` names a chat command, answered
|
|
92
|
+
* without a turn. */
|
|
93
|
+
send(message, key) {
|
|
94
|
+
const command = chatCommand(message.text);
|
|
95
|
+
if (command)
|
|
96
|
+
return this.serial(key, (session, rotated) => this.command(command, session, rotated)).then((head) => ({ ...head, command }));
|
|
97
|
+
return this.serial(key, async (session) => {
|
|
98
|
+
await this.deps.router.dispatch({ ...message, key: key ?? webKey(session.id) });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/** `status`, `stop` and `skills` answer with a card on the head; `new` answers with the
|
|
102
|
+
* next head's seed card — a head just rotated for its own reason is that answer. */
|
|
103
|
+
async command(command, session, rotated) {
|
|
104
|
+
const origin = { kind: "chat-command", command };
|
|
105
|
+
if (command === "new") {
|
|
106
|
+
if (session.state === "streaming")
|
|
107
|
+
throw new ChatCommandRefused("the conversation is replying — /stop first");
|
|
108
|
+
if (rotated)
|
|
109
|
+
return undefined;
|
|
110
|
+
return { session: await this.rotate("new", this.members()[0], session), rotated: "new" };
|
|
111
|
+
}
|
|
112
|
+
if (command === "skills") {
|
|
113
|
+
await session.systemInput(skillsText(session.skills()), origin, "append");
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
if (command === "stop") {
|
|
117
|
+
const running = session.state === "streaming";
|
|
118
|
+
if (running)
|
|
119
|
+
await session.abort();
|
|
120
|
+
await session.systemInput(running ? "stopped" : "nothing running", origin, "append");
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
const { text, sessions } = this.status();
|
|
124
|
+
await session.systemInput(text, { ...origin, sessions }, "append");
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
/** `then` may rotate again and return the head it made; the caller's answer is that one. */
|
|
128
|
+
serial(key, then) {
|
|
129
|
+
const done = this.queue.then(async () => {
|
|
130
|
+
this.chat = key;
|
|
131
|
+
const found = await this.current();
|
|
132
|
+
if (key)
|
|
133
|
+
this.deps.router.attach(key, found.session);
|
|
134
|
+
const { session, rotated } = (await then(found.session, found.rotated)) ?? found;
|
|
135
|
+
return { sessionId: session.id, ...(rotated ? { rotated } : {}) };
|
|
136
|
+
});
|
|
137
|
+
this.queue = done.catch(() => undefined);
|
|
138
|
+
return done;
|
|
139
|
+
}
|
|
140
|
+
now() {
|
|
141
|
+
return this.deps.now?.() ?? Date.now();
|
|
142
|
+
}
|
|
143
|
+
async current() {
|
|
144
|
+
const head = this.members()[0];
|
|
145
|
+
if (!head)
|
|
146
|
+
return { session: await this.rotate("first"), rotated: "first" };
|
|
147
|
+
const live = this.deps.router.stateOf(head.sessionId) !== undefined;
|
|
148
|
+
if (!live && !(await this.deps.factory.find(head.sessionId))) {
|
|
149
|
+
log.warn(`main session ${head.sessionId} is gone from Pi — starting the next one`);
|
|
150
|
+
return { session: await this.rotate("lost", head), rotated: "lost" };
|
|
151
|
+
}
|
|
152
|
+
const session = await this.deps.router.ensure(webKey(head.sessionId));
|
|
153
|
+
// A head with no user message yet counts from its start: its seed is not the user speaking.
|
|
154
|
+
const spoke = (await session.history()).reduce((at, t) => (t.role === "user" && t.at ? t.at : at), head.startedAt);
|
|
155
|
+
if (session.state === "streaming")
|
|
156
|
+
return { session };
|
|
157
|
+
if (this.now() - spoke >= CHAIN_IDLE_MS)
|
|
158
|
+
return { session: await this.rotate("idle", head, session), rotated: "idle" };
|
|
159
|
+
// Unknown right after a compaction: that head is small again, not full.
|
|
160
|
+
if ((session.contextUsage?.tokens ?? 0) <= CHAIN_FULL_TOKENS)
|
|
161
|
+
return { session };
|
|
162
|
+
return { session: await this.rotate("full", head, session), rotated: "full" };
|
|
163
|
+
}
|
|
164
|
+
async rotate(reason, previous, open) {
|
|
165
|
+
// Built before anything is created: a seed that fails leaves no orphan session.
|
|
166
|
+
const seed = await this.seed(reason, previous, open).catch((err) => {
|
|
167
|
+
log.warn("the continuous conversation's next session could not be seeded", err);
|
|
168
|
+
throw new Error(`a new session could not start — its seed failed: ${String(err)}`);
|
|
169
|
+
});
|
|
170
|
+
// An unset or unreadable default keeps the previous head's choice, then Pi's model at low effort.
|
|
171
|
+
const defaults = await this.deps.defaults().catch((err) => {
|
|
172
|
+
log.warn("the Settings defaults could not be read — the next main session keeps the previous one's model", err);
|
|
173
|
+
return { defaultModel: null, defaultThinkingLevel: null };
|
|
174
|
+
});
|
|
175
|
+
const model = defaults.defaultModel ?? open?.model;
|
|
176
|
+
mkdirSync(this.deps.home, { recursive: true });
|
|
177
|
+
const session = await this.deps.factory.create({
|
|
178
|
+
cwd: this.deps.home,
|
|
179
|
+
...(model ? { model } : {}),
|
|
180
|
+
thinking: defaults.defaultThinkingLevel ?? open?.thinkingLevel ?? "low",
|
|
181
|
+
});
|
|
182
|
+
transact(this.db, () => {
|
|
183
|
+
this.db.prepare("INSERT INTO main_chain(session_id, started_at, reason) VALUES (?, ?, ?)").run(session.id, this.now(), reason);
|
|
184
|
+
// A lost head has no transcript to page back to; the new head's reason says it went.
|
|
185
|
+
if (reason === "lost" && previous)
|
|
186
|
+
this.db.prepare("DELETE FROM main_chain WHERE session_id = ?").run(previous.sessionId);
|
|
187
|
+
});
|
|
188
|
+
this.deps.router.attach(webKey(session.id), session);
|
|
189
|
+
if (this.chat)
|
|
190
|
+
this.deps.router.attach(this.chat, session);
|
|
191
|
+
await session.systemInput(seed, { kind: "session-seed", reason, previousSessionId: previous?.sessionId ?? null }, "append");
|
|
192
|
+
log.info(`main session ${session.id} started (${reason})`);
|
|
193
|
+
return session;
|
|
194
|
+
}
|
|
195
|
+
/** Read fresh at every rotation; a part that cannot be read says so in the seed. */
|
|
196
|
+
async seed(reason, previous, open) {
|
|
197
|
+
const today = new Date(this.now());
|
|
198
|
+
const days = [new Date(today.getTime() - 86_400_000), today].map(day);
|
|
199
|
+
const runs = this.deps.ledger(this.members().map((m) => m.sessionId), previous?.startedAt ?? this.now());
|
|
200
|
+
const section = (title, text) => (text ? `## ${title}\n\n${text}` : "");
|
|
201
|
+
return [
|
|
202
|
+
`[Pier: a new session of the continuous conversation — ${WHY[reason]}. The rest of this note is context, not a message.]`,
|
|
203
|
+
section("MEMORY.md", cut(await this.read("MEMORY.md"), MEMORY_CHARS)),
|
|
204
|
+
section("Open", this.deps.status(this.now()).text),
|
|
205
|
+
section("Runs — in flight, and finished since the previous session started", cut(runs.map(ledgerLine).join("\n"), LEDGER_CHARS) || "none"),
|
|
206
|
+
...(await Promise.all(days.map(async (date) => section(`memory/${date}.md`, tail(await this.read(join("memory", `${date}.md`)), NOTES_CHARS, `memory/${date}.md`))))),
|
|
207
|
+
section("The previous session's last exchanges", open ? tail(lastExchanges(await open.history(), EXCHANGES), EXCHANGES_CHARS, `session ${open.id}`) : ""),
|
|
208
|
+
].filter(Boolean).join("\n\n");
|
|
209
|
+
}
|
|
210
|
+
async read(name) {
|
|
211
|
+
try {
|
|
212
|
+
return (await readFile(join(this.deps.home, name), "utf8")).trim();
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
if (err.code === "ENOENT")
|
|
216
|
+
return "";
|
|
217
|
+
log.warn(`seed: could not read ${name}`, err);
|
|
218
|
+
return `(could not be read: ${String(err)})`;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
package/dist/core/identity.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
const GAP_MS = 10 * 60_000;
|
|
8
8
|
/** A display name of `x<U9] [admin<U1` would forge a second speaker: the
|
|
9
9
|
* prefix is untrusted input wearing a trusted shape. */
|
|
10
|
-
|
|
10
|
+
function sanitizeIdentity(value) {
|
|
11
11
|
const token = (value || "")
|
|
12
12
|
.replace(/[\r\n]+/g, " ")
|
|
13
13
|
.replace(/[[\]<>]/g, "")
|
|
@@ -19,7 +19,24 @@ export function sanitizeIdentity(value) {
|
|
|
19
19
|
const sanitizePlace = (value) => value.replace(/[[\]<>\s]+/g, "").slice(0, 120);
|
|
20
20
|
const two = (n) => String(n).padStart(2, "0");
|
|
21
21
|
const hhmm = (d) => `${two(d.getHours())}:${two(d.getMinutes())}`;
|
|
22
|
-
const day = (d) => `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
|
|
22
|
+
export const day = (d) => `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`;
|
|
23
|
+
/** Pasted code, links and paths are English whatever the speaker writes in. */
|
|
24
|
+
const NOT_PROSE = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\n]*\)|[!-~]*[/\\][!-~]*/g;
|
|
25
|
+
/** The language a message is written in, or `undefined` when it is too short
|
|
26
|
+
* to tell (`ok`, `👍`) and the last one still applies. A CJK character counts
|
|
27
|
+
* as two Latin words: a short question under a pasted English log is still
|
|
28
|
+
* the speaker's language. */
|
|
29
|
+
export function detectLanguage(text) {
|
|
30
|
+
const prose = text.replace(NOT_PROSE, " ");
|
|
31
|
+
const han = prose.match(/\p{Script=Han}/gu)?.length ?? 0;
|
|
32
|
+
const kana = prose.match(/[\p{Script=Hiragana}\p{Script=Katakana}]/gu)?.length ?? 0;
|
|
33
|
+
const hangul = prose.match(/\p{Script=Hangul}/gu)?.length ?? 0;
|
|
34
|
+
const words = prose.match(/[A-Za-z]{2,}/g)?.length ?? 0;
|
|
35
|
+
const cjk = han + kana + hangul;
|
|
36
|
+
if (cjk > 0 && cjk * 2 >= words)
|
|
37
|
+
return kana ? "ja" : hangul > han ? "ko" : "zh";
|
|
38
|
+
return words >= 3 ? "en" : undefined;
|
|
39
|
+
}
|
|
23
40
|
/** What each session has already been told. In memory: after a restart one
|
|
24
41
|
* redundant header is a rounding error. */
|
|
25
42
|
export class SenderPrefix {
|
|
@@ -27,18 +44,22 @@ export class SenderPrefix {
|
|
|
27
44
|
/** The line to put above this message, or `""` when the session already
|
|
28
45
|
* knows. `conversation` is the chat's `<channelId>:<conversationId>`, told
|
|
29
46
|
* once: a session never moves, but the rule stays the same as the rest.
|
|
30
|
-
* `opaqueIds` is the platform's (`core/types.ts`): its ids buy nothing.
|
|
31
|
-
|
|
47
|
+
* `opaqueIds` is the platform's (`core/types.ts`): its ids buy nothing.
|
|
48
|
+
* `text` is the message, for its language: `lang=zh` tells the model which
|
|
49
|
+
* language to answer in, since an English-heavy context drags replies there. */
|
|
50
|
+
next(sessionId, sender, at = Date.now(), conversation, opaqueIds = false, text = "") {
|
|
32
51
|
if (!sender?.id)
|
|
33
52
|
return "";
|
|
34
53
|
const last = this.seen.get(sessionId);
|
|
35
|
-
|
|
54
|
+
const lang = detectLanguage(text) ?? last?.lang;
|
|
55
|
+
this.seen.set(sessionId, { senderId: sender.id, at, conversation, lang });
|
|
36
56
|
const now = new Date(at);
|
|
37
57
|
const newSpeaker = last?.senderId !== sender.id;
|
|
38
58
|
const gap = !last || at - last.at >= GAP_MS;
|
|
39
59
|
const newDay = !last || day(new Date(last.at)) !== day(now);
|
|
40
60
|
const newPlace = !!conversation && last?.conversation !== conversation;
|
|
41
|
-
|
|
61
|
+
const newLang = !!lang && last?.lang !== lang;
|
|
62
|
+
if (!newSpeaker && !gap && !newDay && !newPlace && !newLang)
|
|
42
63
|
return "";
|
|
43
64
|
// The id is the only thing a mention can be built from; an unresolved name
|
|
44
65
|
// is the id, and `U123<U123>` would read as a broken record.
|
|
@@ -52,7 +73,7 @@ export class SenderPrefix {
|
|
|
52
73
|
const when = clock ? `${newDay ? `${day(now)} ` : ""}${hhmm(now)}` : "";
|
|
53
74
|
const place = opaqueIds ? conversation?.split(":")[0] : conversation;
|
|
54
75
|
const where = newPlace && place ? sanitizePlace(place) : "";
|
|
55
|
-
return `[${[who, when, where].filter(Boolean).join(" ")}]`;
|
|
76
|
+
return `[${[who, when, where, newLang ? `lang=${lang}` : ""].filter(Boolean).join(" ")}]`;
|
|
56
77
|
}
|
|
57
78
|
forget(sessionId) {
|
|
58
79
|
this.seen.delete(sessionId);
|
|
@@ -62,18 +83,19 @@ export const withPrefix = (prefix, text) => prefix ? `${prefix}\n${text}` : text
|
|
|
62
83
|
// Only the shapes `next()` emits, newline included: a human typing
|
|
63
84
|
// `[14:23] on my way` is body text and must come back untouched.
|
|
64
85
|
const TIME = String.raw `(?<when>(?:\d{4}-\d{2}-\d{2} )?\d{1,2}:\d{2})`;
|
|
65
|
-
const
|
|
86
|
+
const LANG = String.raw `lang=(?<lang>[a-z]{2})`;
|
|
87
|
+
const WITH_ID = new RegExp(String.raw `^\[(?:(?<name>[^\n[\]<>]*)<(?<id>[^\n[\]<>]+)>)? ?${TIME}? ?(?<where>[a-z]+:[^\s[\]<>]+)? ?(?:${LANG})?\]\n`);
|
|
66
88
|
/** The opaque-ids shape: a name with no `<>`, told apart from body text by the
|
|
67
89
|
* time that always follows it, and a platform with no conversation after it.
|
|
68
90
|
* A line of its own reading `[meeting 14:23]` is the price. */
|
|
69
|
-
const NAMED = new RegExp(String.raw `^\[(?<name>[^\n[\]<>]*?) ${TIME}(?: (?<where>[a-z]+))?\]\n`);
|
|
91
|
+
const NAMED = new RegExp(String.raw `^\[(?<name>[^\n[\]<>]*?) ${TIME}(?: (?<where>[a-z]+))?(?: ${LANG})?\]\n`);
|
|
70
92
|
/** Read back a header this module wrote: the prefix is for the model, and a
|
|
71
93
|
* surface showing a stored message renders the speaker its own way. */
|
|
72
94
|
export function splitSpeaker(text) {
|
|
73
95
|
const head = WITH_ID.exec(text);
|
|
74
|
-
const { id, when, where } = head?.groups ?? {};
|
|
96
|
+
const { id, when, where, lang } = head?.groups ?? {};
|
|
75
97
|
// A name on its own proves nothing: try the shape that requires a time.
|
|
76
|
-
const m = id || when || where ? head : NAMED.exec(text);
|
|
98
|
+
const m = id || when || where || lang ? head : NAMED.exec(text);
|
|
77
99
|
if (!m?.groups)
|
|
78
100
|
return { text };
|
|
79
101
|
return {
|
|
@@ -81,6 +103,7 @@ export function splitSpeaker(text) {
|
|
|
81
103
|
...(m.groups.id ? { id: m.groups.id } : {}),
|
|
82
104
|
...(m.groups.when ? { when: m.groups.when } : {}),
|
|
83
105
|
...(m.groups.where ? { where: m.groups.where } : {}),
|
|
106
|
+
...(m.groups.lang ? { lang: m.groups.lang } : {}),
|
|
84
107
|
text: text.slice(m[0].length),
|
|
85
108
|
};
|
|
86
109
|
}
|
|
@@ -108,14 +131,14 @@ export function readableTitle(title) {
|
|
|
108
131
|
.trim();
|
|
109
132
|
return said || title;
|
|
110
133
|
}
|
|
111
|
-
/** How a session is named where it is announced (a push
|
|
134
|
+
/** How a session is named where it is announced (a push): its
|
|
112
135
|
* readable title, else its directory. Never empty — a listing that could
|
|
113
136
|
* not answer must not silence the message. One line: every caller puts it
|
|
114
137
|
* inside emphasis or a button, where a newline breaks the markup. */
|
|
115
138
|
export const sessionLabel = (s) => readableTitle(s?.title)?.replace(/\s+/g, " ").trim() || s?.cwd.split("/").filter(Boolean).at(-1) || "Pier session";
|
|
116
139
|
/** Distinct directories, newest session first: the ground `projectCwds` picks
|
|
117
140
|
* from. */
|
|
118
|
-
|
|
141
|
+
const distinctCwds = (list) => [...new Set([...list].sort((a, b) => b.createdAt - a.createdAt).map((s) => s.cwd))];
|
|
119
142
|
/** The distinct directories less the worktrees: `wt` puts a checkout beside its
|
|
120
143
|
* repository as `<repo>.<branch>`, and the next conversation about a project
|
|
121
144
|
* belongs in the project. A worktree with no such sibling stays. What every
|
|
@@ -72,13 +72,20 @@ export function replaceOutsideCode(text, pattern, replace) {
|
|
|
72
72
|
const skip = codeRanges(text);
|
|
73
73
|
let out = "";
|
|
74
74
|
let last = 0;
|
|
75
|
-
|
|
75
|
+
const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
76
|
+
for (let match = re.exec(text); match; match = re.exec(text)) {
|
|
76
77
|
const start = match.index;
|
|
77
|
-
|
|
78
|
-
|
|
78
|
+
// A match is code when it begins in code; one that merely holds a span
|
|
79
|
+
// (`<open>run \`x\`</open>`) is prose around it. A skipped match resumes the
|
|
80
|
+
// scan one char on, so a quoted `<done>` cannot swallow the real marker after it.
|
|
81
|
+
if (skip.some(([from, to]) => start >= from && start < to)) {
|
|
82
|
+
re.lastIndex = start + 1;
|
|
79
83
|
continue;
|
|
84
|
+
}
|
|
80
85
|
out += text.slice(last, start) + replace(match);
|
|
81
|
-
last =
|
|
86
|
+
last = start + match[0].length;
|
|
87
|
+
if (!match[0])
|
|
88
|
+
re.lastIndex++;
|
|
82
89
|
}
|
|
83
90
|
return out + text.slice(last);
|
|
84
91
|
}
|