@timqi/pier 0.1.0 → 0.1.1

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 (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +7 -23
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +4 -12
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-B89_hH7q.js} +1 -1
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-BeKW0ZXK.js} +1 -1
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cwy0mN8i.js} +1 -1
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-DzZLmujq.js} +1 -1
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-BCakxFk8.js} +1 -1
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +30 -16
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
@@ -1,46 +1,23 @@
1
- // Which sessions exist on disk, and what to call them — without reading a
2
- // transcript that has not changed since the last scan.
3
- //
4
- // Pi's SessionManager.listAll() parses every line of every session file to
5
- // answer that: ~250ms for 84 sessions of 30MB here, growing with the total
6
- // bytes ever written rather than with the number of sessions. Every surface
7
- // asks (the rail, Activity, a task lookup, every reconnect), so the scan sits
8
- // on the interactive path and gets slower for the life of the instance. But a
9
- // transcript is append-only and the facts a listing needs are all derived from
10
- // bytes already read, so each byte is read once: rows live in pier.db, keyed by
11
- // path and validated by (size, mtime), and a file that grew is picked up at the
12
- // byte the last scan stopped on.
13
- //
14
- // The same pass indexes what was said. Every user message and every reply
15
- // goes into `session_fts` beside the index row, in the same transaction, so
16
- // the palette can search messages without a second reader of the same bytes.
17
- //
18
- // Reading Pi's on-disk entries is the cost of that; agent/ is where the
19
- // knowledge of Pi's formats already lives, and Pi's own reader is not
20
- // incremental.
1
+ // Which sessions exist on disk and what to call them, reading each transcript
2
+ // byte once: Pi's own listing parses every file whole (~250ms for 30MB) and
3
+ // every surface asks. Rows live in pier.db keyed by path and validated by
4
+ // (size, mtime); a file that grew resumes where the last scan stopped. The same
5
+ // pass indexes what was said into `session_fts` for the palette.
21
6
  import { createReadStream, promises as fs } from "node:fs";
22
7
  import { join } from "node:path";
23
8
  import { pierDb, statements, transact } from "../db.js";
24
- import { SESSION_TITLE_MAX } from "../limits.js";
9
+ import { SESSION_TITLE_MAX } from "../core/types.js";
25
10
  import { logger } from "../log.js";
26
11
  import { defaultAgentDir } from "./config.js";
27
12
  import { hasToolCalls, textOf } from "./events.js";
28
13
  const log = logger("agent");
29
- /** How many of the newest sessions a cross-check reads: enough that a format
30
- * change surfaces within a boot or two, few enough to cost nothing. */
14
+ /** Enough that a format change surfaces within a boot or two. */
31
15
  const AUDIT_SAMPLE = 5;
32
- /** The name a session was given, else its first message. Two arguments rather
33
- * than a row, because the stored row and the parsed one spell the second one
34
- * differently and an object would silently accept either. */
35
16
  const titleOf = (name, first) => name || first || undefined;
36
17
  const str = (v) => (typeof v === "string" ? v : undefined);
37
- /** What a search may find: what the person said and what the agent answered.
38
- * A reply that calls a tool is work in progress, not a reply (events.ts
39
- * `toChatTurns`) — its text is a step, and steps are not indexed; neither is
40
- * thinking, a tool result, or a system input. `clean` takes off what was
41
- * written for the model rather than said — the speaker header a channel
42
- * prefixes (core/identity.ts), which as text would make "operator" hit every
43
- * session the workbench ever opened. */
18
+ /** A reply that calls a tool is a step, not a reply (events.ts), and steps are
19
+ * not indexed. `clean` takes off the speaker header (core/identity.ts), which
20
+ * as text would make "operator" hit every session. */
44
21
  function saidIn(message, entryAt, clean) {
45
22
  if (message?.role !== "user" && message?.role !== "assistant")
46
23
  return undefined;
@@ -52,16 +29,11 @@ function saidIn(message, entryAt, clean) {
52
29
  const at = typeof message.timestamp === "number" ? message.timestamp : Date.parse(entryAt ?? "");
53
30
  return { role: message.role, at: Number.isFinite(at) ? at : 0, text };
54
31
  }
55
- /**
56
- * One entry folded into what a listing needs. Three answers, not two:
57
- * `undefined` is "no header yet" (blank and unparseable lines before it are
58
- * skipped, as Pi's own reader does), `null` is "not a session file" — a first
59
- * entry that is not a session header — and anything else is the state so far.
60
- */
32
+ /** `undefined` is "no header yet" (leading junk is skipped, as Pi's reader
33
+ * does), `null` is "not a session file". */
61
34
  function fold(acc, line, clean) {
62
- // The bulk of a transcript is entries this has no use for — tool results,
63
- // model changes, compaction — each longer than this file. Two substring tests
64
- // say whether a line could matter, at a fraction of parsing one that does not.
35
+ // The bulk of a transcript is tool results; two substring tests cost a
36
+ // fraction of parsing one.
65
37
  if (acc && !line.includes('"message"') && !line.includes('"session_info"'))
66
38
  return acc;
67
39
  let entry;
@@ -71,6 +43,7 @@ function fold(acc, line, clean) {
71
43
  entry = value;
72
44
  }
73
45
  catch {
46
+ // The tail of a transcript Pi is still appending to; the next scan reads it whole.
74
47
  return acc ?? undefined;
75
48
  }
76
49
  if (!acc) {
@@ -90,23 +63,22 @@ function fold(acc, line, clean) {
90
63
  if (!said)
91
64
  return acc;
92
65
  acc.said.push(said);
93
- // The title keeps the header the index drops: every surface reads it back
94
- // through core/identity.ts, and a second rule here would be a second rule.
66
+ // The title keeps the header the index drops; surfaces read it back
67
+ // through core/identity.ts.
95
68
  if (acc.first || said.role !== "user")
96
69
  return acc;
97
70
  return { ...acc, first: textOf(message?.content).trim().slice(0, SESSION_TITLE_MAX) };
98
71
  }
99
72
  return acc;
100
73
  }
101
- /** Hits a search answers with at most: a picker, not a results page. */
74
+ /** A picker, not a results page. */
102
75
  const SEARCH_LIMIT = 8;
103
- /** Characters a snippet runs to. A trigram token is one character, so FTS's
104
- * `snippet()` is asked for its ceiling of 64 tokens — the default 12 fits one
105
- * word and its neighbours — and the LIKE path centres the same span itself. */
76
+ /** A trigram token is one character, so `snippet()` is asked for its ceiling
77
+ * of 64 tokens; the default 12 fits one word. */
106
78
  const SNIPPET_CHARS = 64;
107
- /** The match and its neighbourhood, delimited like `snippet()` does it
108
- * (\u0001 … \u0002, `…` for a cut), so a surface draws one shape for both
109
- * paths. Case folds ASCII only — LIKE found the row by the same rule. */
79
+ /** Delimited like `snippet()` (\u0001 … \u0002, `…` for a cut), so a surface
80
+ * draws one shape for both paths. ASCII case folding: LIKE found the row by
81
+ * the same rule. */
110
82
  function around(text, query) {
111
83
  const hit = text.toLowerCase().indexOf(query.toLowerCase());
112
84
  if (hit < 0)
@@ -115,29 +87,23 @@ function around(text, query) {
115
87
  const end = Math.min(text.length, hit + query.length + SNIPPET_CHARS / 2);
116
88
  return `${start ? "…" : ""}${text.slice(start, hit)}\u0001${text.slice(hit, hit + query.length)}\u0002${text.slice(hit + query.length, end)}${end < text.length ? "…" : ""}`;
117
89
  }
118
- /** The listing Pier runs on: Pi's session directory, remembered in pier.db. */
119
90
  export class IndexedListing {
120
91
  dir;
121
92
  clean;
122
93
  #db;
123
94
  #statements;
124
95
  constructor(dir = join(defaultAgentDir(), "sessions"), db,
125
- /** What to take off a message before it is indexed as said. Handed in
126
- * rather than imported: the header rule is core's (identity.ts), and
127
- * agent/ does not import core at runtime — main.ts, which imports both,
128
- * joins them. */
96
+ /** Handed in rather than imported: the header rule is core's, and agent/
97
+ * does not import core at runtime. */
129
98
  clean = (text) => text) {
130
99
  this.dir = dir;
131
100
  this.clean = clean;
132
101
  this.#db = db;
133
102
  }
134
- /** Opened on the first scan rather than in the constructor: building an
135
- * agent factory must not open the instance database — tests build bare ones
136
- * and never list. */
103
+ /** Opened on the first scan: building a factory must not open the database. */
137
104
  #store() {
138
105
  return (this.#db ??= pierDb());
139
106
  }
140
- /** The scan's three statements, compiled on the first scan and not again. */
141
107
  #sql() {
142
108
  return (this.#statements ??= statements(this.#store()));
143
109
  }
@@ -160,11 +126,8 @@ export class IndexedListing {
160
126
  });
161
127
  continue;
162
128
  }
163
- // Only a file that *grew* can be resumed mid-way. Anything else is read
164
- // whole — new, truncated, or rewritten. Rewritten includes the same
165
- // length with a different mtime, which resuming would answer with the
166
- // old derived data stamped with the new mtime: a row that then matches on
167
- // every later scan and is never corrected.
129
+ // Only a file that grew can be resumed. Same length with a new mtime is
130
+ // rewritten, and resuming would stamp old data with the new mtime forever.
168
131
  const resumed = row !== undefined && file.size > row.size;
169
132
  const parsed = await this.#read(file.path, resumed
170
133
  ? {
@@ -205,14 +168,9 @@ export class IndexedListing {
205
168
  this.#save(write, [...known.keys()]);
206
169
  return records.sort((a, b) => b.modified - a.modified);
207
170
  }
208
- /**
209
- * The reason this file is allowed to exist: it parses Pi's transcripts
210
- * itself, and nothing but a comparison keeps it honest when that format
211
- * moves. Reads the newest few both ways; a disagreement is logged with both
212
- * answers and the index row is dropped, so the next scan reads that file
213
- * whole rather than trusting what this one derived. Returns how many
214
- * disagreed — off the interactive path, never blocking a boot.
215
- */
171
+ /** This file parses Pi's transcripts itself, and only a comparison keeps it
172
+ * honest when the format moves. A disagreement drops the index row, so the
173
+ * next scan reads that file whole. */
216
174
  async audit(native) {
217
175
  const sample = (await this.scan()).slice(0, AUDIT_SAMPLE);
218
176
  if (!sample.length)
@@ -236,24 +194,18 @@ export class IndexedListing {
236
194
  this.#save([], stale);
237
195
  return stale.length;
238
196
  }
239
- /**
240
- * Sessions by what was said in them, best hit first and one per session.
241
- * Three characters or more is a trigram phrase, ranked by FTS; shorter is a
242
- * substring scan, newest first — the tokenizer has nothing to match under
243
- * three, and a two-character query is what a CJK word often is. Counted in
244
- * code points: the tokenizer counts characters, not UTF-16 units.
245
- */
197
+ /** Under three code points the trigram tokenizer has nothing to match, and a
198
+ * two-character query is what a CJK word often is: substring scan instead. */
246
199
  search(query, limit = SEARCH_LIMIT) {
247
200
  const sql = this.#sql();
248
201
  const rows = [...query].length >= 3
249
- // A quoted phrase: the query is a string to find, never FTS syntax.
202
+ // Quoted: the query is a string to find, never FTS syntax.
250
203
  ? sql(`SELECT session_id, role, at, snippet(session_fts, 0, char(1), char(2), '…', ${SNIPPET_CHARS}) AS snippet
251
204
  FROM session_fts WHERE text MATCH ? ORDER BY bm25(session_fts), at DESC`).iterate(`"${query.replaceAll('"', '""')}"`)
252
205
  : sql(`SELECT session_id, role, at, text FROM session_fts WHERE text LIKE ? ESCAPE '\\' ORDER BY at DESC`).iterate(`%${query.replaceAll(/[\\%_]/g, "\\$&")}%`);
253
206
  const hits = [];
254
207
  const seen = new Set();
255
- // Walked, not fetched: a chatty session has hundreds of rows for one hit,
256
- // and the walk stops at the first row of the `limit`th session.
208
+ // Walked, not fetched: a chatty session has hundreds of rows for one hit.
257
209
  for (const row of rows) {
258
210
  if (seen.has(row.session_id))
259
211
  continue;
@@ -269,8 +221,6 @@ export class IndexedListing {
269
221
  }
270
222
  return hits;
271
223
  }
272
- /** Every session file with its size and mtime — the only syscalls a scan
273
- * where nothing changed makes. */
274
224
  async #files() {
275
225
  const entries = await fs.readdir(this.dir, { withFileTypes: true }).catch(() => []);
276
226
  const dirs = entries.filter((e) => e.isDirectory() || e.isSymbolicLink());
@@ -287,7 +237,6 @@ export class IndexedListing {
287
237
  }));
288
238
  return found.flat().filter((f) => !!f);
289
239
  }
290
- /** From `from.at`, or from the start when there is nothing to resume. */
291
240
  async #read(path, from) {
292
241
  let acc = from;
293
242
  let at = from?.at ?? 0;
@@ -307,17 +256,15 @@ export class IndexedListing {
307
256
  }
308
257
  }
309
258
  catch (err) {
310
- // A file that cannot be read is left out of this listing and out of the
311
- // index, so the next scan tries it again rather than remembering a gap.
259
+ // Left out of the index too, so the next scan tries it again.
312
260
  log.warn(`session file ${path} could not be read`, err);
313
261
  return null;
314
262
  }
315
263
  return acc ? { ...acc, at } : null;
316
264
  }
317
- /** One transaction, after all reading: a half-written index would hand the
318
- * next scan a size it never parsed to — or a search rows the index row does
319
- * not account for. A file read whole drops what it had said before, since
320
- * its bytes are all being said again; a resumed read only adds. */
265
+ /** One transaction after all reading: a half-written index would hand the
266
+ * next scan a size it never parsed to. A whole read replaces what the file
267
+ * had said; a resumed read only adds. */
321
268
  #save(rows, gone) {
322
269
  if (!rows.length && !gone.length)
323
270
  return;