@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.
Files changed (130) hide show
  1. package/README.md +44 -22
  2. package/dist/agent/config.js +2 -2
  3. package/dist/agent/events.js +32 -3
  4. package/dist/agent/listing.js +14 -10
  5. package/dist/agent/packages.js +6 -10
  6. package/dist/agent/pi.js +113 -70
  7. package/dist/agent/roles.js +104 -0
  8. package/dist/agent/types.js +78 -0
  9. package/dist/boards/boards.js +42 -32
  10. package/dist/channels/chains.js +0 -4
  11. package/dist/channels/commands.js +7 -12
  12. package/dist/channels/config.js +27 -1
  13. package/dist/channels/control.js +5 -16
  14. package/dist/channels/conversations.js +4 -9
  15. package/dist/channels/lark-api.js +20 -26
  16. package/dist/channels/lark-outbound.js +21 -29
  17. package/dist/channels/lark-panel.js +1 -1
  18. package/dist/channels/lark.js +81 -39
  19. package/dist/channels/lines.js +33 -1
  20. package/dist/channels/panel.js +17 -107
  21. package/dist/channels/routes.js +11 -21
  22. package/dist/channels/runtime.js +79 -25
  23. package/dist/channels/slack-outbound.js +12 -14
  24. package/dist/channels/slack-panel.js +1 -1
  25. package/dist/channels/slack-render.js +1 -1
  26. package/dist/channels/slack-thread.js +65 -0
  27. package/dist/channels/slack.js +85 -124
  28. package/dist/channels/types.js +1 -1
  29. package/dist/cli.js +7 -2
  30. package/dist/config-sync.js +42 -37
  31. package/dist/core/chain.js +221 -0
  32. package/dist/core/identity.js +36 -13
  33. package/dist/core/inbound-file.js +11 -4
  34. package/dist/core/reply.js +111 -81
  35. package/dist/core/router.js +111 -118
  36. package/dist/core/search.js +65 -0
  37. package/dist/core/types.js +38 -77
  38. package/dist/db.js +41 -2
  39. package/dist/drain.js +1 -1
  40. package/dist/main.js +65 -40
  41. package/dist/settings.js +26 -19
  42. package/dist/socket.js +1 -0
  43. package/dist/tasks/agent.js +11 -5
  44. package/dist/tasks/callbacks.js +55 -5
  45. package/dist/tasks/cli.js +22 -11
  46. package/dist/tasks/definitions.js +30 -17
  47. package/dist/tasks/execution.js +11 -5
  48. package/dist/tasks/groups.js +5 -2
  49. package/dist/tasks/messages.js +7 -7
  50. package/dist/tasks/open-items.js +88 -0
  51. package/dist/tasks/operations.js +76 -39
  52. package/dist/tasks/outbox.js +19 -6
  53. package/dist/tasks/routes.js +19 -249
  54. package/dist/tasks/service.js +166 -34
  55. package/dist/tasks/store.js +123 -57
  56. package/dist/tasks/types.js +3 -0
  57. package/dist/tools.js +12 -5
  58. package/dist/web/auth.js +2 -2
  59. package/dist/web/config-sync.js +4 -5
  60. package/dist/web/config.js +9 -2
  61. package/dist/web/explorer.js +2 -2
  62. package/dist/web/fs.js +16 -1
  63. package/dist/web/instance.js +8 -5
  64. package/dist/web/packages.js +1 -1
  65. package/dist/web/passkeys.js +3 -3
  66. package/dist/web/providers.js +3 -3
  67. package/dist/web/public/assets/code-CWT27KH1.js +2 -0
  68. package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
  69. package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
  70. package/dist/web/public/assets/explorer-C4DhXFCm.js +5 -0
  71. package/dist/web/public/assets/explorer-C4DhXFCm.js.br +0 -0
  72. package/dist/web/public/assets/explorer-C4DhXFCm.js.gz +0 -0
  73. package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
  74. package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
  75. package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
  76. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  77. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  78. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  79. package/dist/web/public/assets/index-D0ZYdweB.js +14 -0
  80. package/dist/web/public/assets/index-D0ZYdweB.js.br +0 -0
  81. package/dist/web/public/assets/index-D0ZYdweB.js.gz +0 -0
  82. package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
  83. package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
  84. package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
  85. package/dist/web/public/assets/settings-DSTeuIyc.js +9 -0
  86. package/dist/web/public/assets/settings-DSTeuIyc.js.br +0 -0
  87. package/dist/web/public/assets/settings-DSTeuIyc.js.gz +0 -0
  88. package/dist/web/public/index.html +39 -109
  89. package/dist/web/public/index.html.br +0 -0
  90. package/dist/web/public/index.html.gz +0 -0
  91. package/dist/web/public/manifest.webmanifest +0 -2
  92. package/dist/web/push.js +3 -3
  93. package/dist/web/server.js +89 -116
  94. package/dist/web/session-state.js +4 -40
  95. package/docs/deploy.md +5 -3
  96. package/package.json +2 -1
  97. package/skills/pier-boards/SKILL.md +14 -4
  98. package/skills/pier-help/SKILL.md +62 -23
  99. package/skills/pier-search/SKILL.md +27 -0
  100. package/skills/pier-tasks/SKILL.md +68 -18
  101. package/dist/channels/handoff.js +0 -94
  102. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  103. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  104. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  105. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  106. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  107. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  108. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  109. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  110. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  111. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  112. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  113. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  114. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  115. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  116. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  117. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  118. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  119. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  120. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  121. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  122. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  123. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  124. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  125. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  126. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  127. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  128. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  129. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  130. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
@@ -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 `web` own their options; only the name is parsed here.
61
- args: argv[0] === "slack" || argv[0] === "task" || argv[0] === "web" ? [argv[0]] : argv,
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}"`);
@@ -1,14 +1,26 @@
1
1
  // One serialized configuration subscriber, shared by Settings and the hourly
2
- // task. Only a successfully applied response advances its persistent ETag.
3
- // The source is fetched over HTTPS only, redirects followed while they stay
4
- // HTTPS, JSON capped at 1 MiB so a hostile or broken source cannot exhaust memory.
5
- import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
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 { normalizeModelMenu } from "./settings.js";
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, etag, signal) {
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 && res.status !== 304) {
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
- const status = res.status === 304 ? 304 : 200;
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 { status, etag: res.headers.get("etag"), body: await read(res, signal) };
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
- this.#state = row ? JSON.parse(row.value) : {
106
- instanceId: randomUUID(), token: null, url: "", enabled: false, etag: null,
107
- appliedHash: null, lastChecked: null, lastApplied: null, error: null, needsReload: false,
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: 1, instanceId: this.#state.instanceId, ...local };
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 { body, etag: `"${hash(document)}"` };
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 !== 1)
177
- throw new Error("Unsupported configuration schema version");
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 = normalizeModelMenu(object.modelMenu);
186
- if (!modelMenu)
187
- throw new Error("Invalid model menu");
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, etag, signal ? AbortSignal.any([signal, timeout]) : timeout);
222
+ const response = await (this.deps.download ?? downloadConfig)(url, signal ? AbortSignal.any([signal, timeout]) : timeout);
215
223
  signal?.throwIfAborted();
216
- if (response.status === 304 && !etag)
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, etag: response.status === 304 ? etag : response.etag,
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" : `Configuration unchanged (${response.status})`;
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
- this.#save({ lastChecked: Date.now(), error });
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
+ }
@@ -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
- export function sanitizeIdentity(value) {
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
- next(sessionId, sender, at = Date.now(), conversation, opaqueIds = false) {
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
- this.seen.set(sessionId, { senderId: sender.id, at, conversation });
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
- if (!newSpeaker && !gap && !newDay && !newPlace)
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 WITH_ID = new RegExp(String.raw `^\[(?:(?<name>[^\n[\]<>]*)<(?<id>[^\n[\]<>]+)>)? ?${TIME}? ?(?<where>[a-z]+:[^\s[\]<>]+)?\]\n`);
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, a handoff): its
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
- export const distinctCwds = (list) => [...new Set([...list].sort((a, b) => b.createdAt - a.createdAt).map((s) => s.cwd))];
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
- for (const match of text.matchAll(pattern)) {
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
- const end = start + match[0].length;
78
- if (skip.some(([from, to]) => start < to && end > from))
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 = end;
86
+ last = start + match[0].length;
87
+ if (!match[0])
88
+ re.lastIndex++;
82
89
  }
83
90
  return out + text.slice(last);
84
91
  }