@timqi/pier 0.2.0 → 0.3.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 (77) hide show
  1. package/dist/agent/listing.js +14 -10
  2. package/dist/agent/pi.js +2 -2
  3. package/dist/agent/roles.js +5 -4
  4. package/dist/channels/conversations.js +4 -4
  5. package/dist/channels/lark-outbound.js +5 -0
  6. package/dist/channels/lark.js +25 -5
  7. package/dist/channels/runtime.js +47 -2
  8. package/dist/channels/slack-outbound.js +7 -0
  9. package/dist/channels/slack-thread.js +65 -0
  10. package/dist/channels/slack.js +27 -72
  11. package/dist/cli.js +7 -2
  12. package/dist/core/chain.js +27 -5
  13. package/dist/core/identity.js +2 -2
  14. package/dist/core/inbound-file.js +9 -3
  15. package/dist/core/reply.js +17 -3
  16. package/dist/core/search.js +65 -0
  17. package/dist/core/types.js +2 -0
  18. package/dist/db.js +8 -0
  19. package/dist/drain.js +1 -1
  20. package/dist/main.js +21 -5
  21. package/dist/settings.js +1 -1
  22. package/dist/socket.js +1 -0
  23. package/dist/tasks/agent.js +1 -1
  24. package/dist/tasks/callbacks.js +1 -1
  25. package/dist/tasks/execution.js +9 -3
  26. package/dist/tasks/open-items.js +53 -35
  27. package/dist/tasks/service.js +22 -1
  28. package/dist/tasks/store.js +5 -2
  29. package/dist/tasks/types.js +0 -2
  30. package/dist/tools.js +10 -3
  31. package/dist/web/public/assets/{code-BZwvPani.js → code-CWT27KH1.js} +1 -1
  32. package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
  33. package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
  34. package/dist/web/public/assets/{explorer-DDoVzMLQ.js → explorer-BoU77faI.js} +4 -4
  35. package/dist/web/public/assets/explorer-BoU77faI.js.br +0 -0
  36. package/dist/web/public/assets/explorer-BoU77faI.js.gz +0 -0
  37. package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
  38. package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
  39. package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
  40. package/dist/web/public/assets/index-DqLJpDE0.js +14 -0
  41. package/dist/web/public/assets/index-DqLJpDE0.js.br +0 -0
  42. package/dist/web/public/assets/index-DqLJpDE0.js.gz +0 -0
  43. package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
  44. package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
  45. package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
  46. package/dist/web/public/assets/settings-BprIId_R.js +9 -0
  47. package/dist/web/public/assets/settings-BprIId_R.js.br +0 -0
  48. package/dist/web/public/assets/settings-BprIId_R.js.gz +0 -0
  49. package/dist/web/public/index.html +3 -4
  50. package/dist/web/public/index.html.br +0 -0
  51. package/dist/web/public/index.html.gz +0 -0
  52. package/dist/web/push.js +2 -2
  53. package/dist/web/server.js +2 -4
  54. package/dist/web/session-state.js +1 -2
  55. package/package.json +1 -1
  56. package/skills/pier-help/SKILL.md +6 -1
  57. package/skills/pier-search/SKILL.md +27 -0
  58. package/skills/pier-tasks/SKILL.md +17 -3
  59. package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
  60. package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
  61. package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
  62. package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
  63. package/dist/web/public/assets/highlight-5Bl8zZ8q.js +0 -72
  64. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
  65. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
  66. package/dist/web/public/assets/index-BpFzCwlo.js +0 -7
  67. package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
  68. package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
  69. package/dist/web/public/assets/index-wAGxFFPj.css +0 -2
  70. package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
  71. package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
  72. package/dist/web/public/assets/report-zjE8Bt8S.js +0 -8
  73. package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
  74. package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
  75. package/dist/web/public/assets/settings-Cv7rbeqf.js +0 -9
  76. package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
  77. package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
@@ -80,16 +80,15 @@ function fold(acc, line, clean) {
80
80
  }
81
81
  /** A picker, not a results page. */
82
82
  const SEARCH_LIMIT = 8;
83
- /** A trigram token is one character, so `snippet()` is asked for its ceiling
84
- * of 64 tokens; the default 12 fits one word. */
83
+ /** Characters of context a snippet keeps around its first match. */
85
84
  const SNIPPET_CHARS = 64;
86
85
  /** Whitespace splits a query: a space is "and this too", not a character to
87
86
  * find. A term is quoted where it has to be, so a phrase is still findable. */
88
87
  const termsOf = (query) => query.split(/\s+/).filter(Boolean);
89
- /** Delimited like `snippet()` (\u0001 … \u0002, `…` for a cut), so a surface
90
- * draws one shape for both paths. Every term is marked where it first
91
- * appears inside the window, as `snippet()` marks every phrase it kept.
92
- * ASCII case folding: LIKE found the row by the same rule. */
88
+ /** Cut here rather than by FTS `snippet()`, which can only cut the indexed
89
+ * text, markup and all. Each match delimited \u0001 … \u0002, `…` for a cut;
90
+ * every term is marked where it first appears inside the window. A match only
91
+ * inside stripped markup leaves the message's opening. */
93
92
  function around(text, terms) {
94
93
  const lower = text.toLowerCase();
95
94
  const found = terms
@@ -98,7 +97,7 @@ function around(text, terms) {
98
97
  .sort((a, b) => a.at - b.at);
99
98
  const first = found[0];
100
99
  if (!first)
101
- return text.slice(0, SNIPPET_CHARS);
100
+ return text.length > SNIPPET_CHARS ? `${text.slice(0, SNIPPET_CHARS)}…` : text;
102
101
  const start = Math.max(0, first.at - SNIPPET_CHARS / 2);
103
102
  const end = Math.min(text.length, first.at + first.length + SNIPPET_CHARS / 2);
104
103
  let out = start ? "…" : "";
@@ -115,14 +114,19 @@ function around(text, terms) {
115
114
  export class IndexedListing {
116
115
  dir;
117
116
  clean;
117
+ plain;
118
118
  #db;
119
119
  #statements;
120
120
  constructor(dir = join(defaultAgentDir(), "sessions"), db,
121
121
  /** Handed in rather than imported: the header rule is core's, and agent/
122
122
  * does not import core at runtime. */
123
- clean = (text) => text) {
123
+ clean = (text) => text,
124
+ /** Chat markup off what was said, before a snippet is cut (core/reply.ts):
125
+ * applied at search time, so rows indexed before it are covered too. */
126
+ plain = (text) => text) {
124
127
  this.dir = dir;
125
128
  this.clean = clean;
129
+ this.plain = plain;
126
130
  this.#db = db;
127
131
  }
128
132
  /** Opened on the first scan: building a factory must not open the database. */
@@ -231,7 +235,7 @@ export class IndexedListing {
231
235
  return [];
232
236
  const rows = terms.every((term) => [...term].length >= 3)
233
237
  // Quoted: a term is a string to find, never FTS syntax.
234
- ? sql(`SELECT session_id, role, at, snippet(session_fts, 0, char(1), char(2), '…', ${SNIPPET_CHARS}) AS snippet
238
+ ? sql(`SELECT session_id, role, at, text
235
239
  FROM session_fts WHERE text MATCH ? ORDER BY bm25(session_fts), at DESC`).iterate(terms.map((term) => `"${term.replaceAll('"', '""')}"`).join(" AND "))
236
240
  : sql(`SELECT session_id, role, at, text FROM session_fts WHERE ${terms.map(() => "text LIKE ? ESCAPE '\\'").join(" AND ")} ORDER BY at DESC`).iterate(...terms.map((term) => `%${term.replaceAll(/[\\%_]/g, "\\$&")}%`));
237
241
  const hits = [];
@@ -245,7 +249,7 @@ export class IndexedListing {
245
249
  sessionId: row.session_id,
246
250
  role: row.role,
247
251
  at: row.at,
248
- snippet: row.snippet ?? around(row.text ?? "", terms),
252
+ snippet: around(this.plain(row.text), terms),
249
253
  });
250
254
  if (hits.length >= limit)
251
255
  break;
package/dist/agent/pi.js CHANGED
@@ -757,8 +757,8 @@ export class PiAgentFactory {
757
757
  return (await this.listed()).map(summaryOf);
758
758
  }
759
759
  /** After a listing, so a transcript that grew is indexed before it is asked about. */
760
- async search(query) {
760
+ async search(query, limit) {
761
761
  await this.listed();
762
- return this.listings.search?.(query) ?? [];
762
+ return this.listings.search?.(query, limit) ?? [];
763
763
  }
764
764
  }
@@ -18,10 +18,11 @@ The user talks to Pier as one conversation; you are its current session, in the
18
18
 
19
19
  ## Memory
20
20
  - \`MEMORY.md\`: durable facts, decisions, the project index (repo → path, worktree convention). \`memory/YYYY-MM-DD.md\`: daily notes, local date.
21
- - MEMORY.md is re-read in full at every session open: a list of facts, one line each. Rationale, narrative and working notes go in the daily note. Never record what this contract, AGENTS.md or a skill already says.
21
+ - MEMORY.md is re-read in full at every session open: a list of facts, one line each. Never record what this contract, AGENTS.md or a skill already says.
22
22
  - Edit MEMORY.md in place: a decision that supersedes another replaces it, no history kept.
23
- - A callback is the ledger's and the transcript's record already: it writes no note. A note records a decision, or a fact the ledger does not hold. Repo knowledge belongs in that repo's own AGENTS.md, written by a child.
24
- - Recall is files plus transcripts: \`rg\` over \`memory/\` and the Pi session directory.
23
+ - A daily-note line holds only a decision (what + one clause why) or a fact git, the ledger and transcripts do not hold: a live-verified result, a user preference, a flaky test, a manual step the user owes. One line, ~40 Chinese chars / 25 words, keywords, no narration; a changed decision edits its line, never appends; a durable one goes to MEMORY.md, not the note.
24
+ - Never noted: dispatches, run ids, merges, commit hashes, test counts, restarts — git log, \`pier task runs\` and transcripts hold them; read them on demand. Repo knowledge belongs in that repo's own AGENTS.md, written by a child.
25
+ - Recall is files plus transcripts: \`rg\` over \`memory/\`, \`pier search <words>\` over the earlier sessions (skills/pier-search).
25
26
  - A new session of this conversation opens with a seed: MEMORY.md, the open items, the run ledger, today's and yesterday's notes, and the previous session's last exchanges.
26
27
 
27
28
  ## Open items
@@ -44,7 +45,7 @@ You own one feature, in this worktree. The design doc you keep here is the state
44
45
  - Decompose it into worker runs: \`pier task run --name "<a few words>" --prompt … --cwd <worker worktree>\`, one \`wt\` worktree each (\`wt switch -c <branch> --no-cd -y --format json\` in the repo). The prompt is the worker's whole handoff; a worker never delegates.
45
46
  - Workers run on \`--model balanced\` for code, \`--model cheap\` for research and mechanical work.
46
47
  - Never launch another lead (\`--role lead\` is refused).
47
- - Each worker's result comes back to you: review it and integrate its branch here. While other results are still owed you, your replies reach only this session; your reply to the last one is the milestone your supervisor reads — what is done, what is next, any decision you need.
48
+ - Each worker's result comes back to you: review it and integrate its branch here. While other results are still owed you, your replies reach only this session; your reply to the last one is the milestone your supervisor reads, in the run result's two parts: the conclusion (done, next, risks one line each), then \`Needs your decision\` only when something does. A worker takes reversible choices itself and names them in its result; it stops only on a destructive step or a question only you or the user can answer — answer with \`--run <id> --prompt\`, or carry it up.
48
49
  - The build is yours to declare done, never the user's to confirm: a reply that leaves nothing owed you, workers or none, is that milestone.
49
50
  - \`pier task runs\` lists the runs you launched, for orientation, never for waiting.`;
50
51
  /** The surface contract handed to every agent Pier launches (main.ts); the
@@ -18,11 +18,11 @@ export class ConversationStore {
18
18
  * one launched from the chat defaults, which a re-create reads again. */
19
19
  set(key, sessionId, launch) {
20
20
  this.db.prepare(`
21
- INSERT INTO conversations(channel_id, conversation_id, session_id, updated_at, launch)
22
- VALUES (?, ?, ?, ?, ?)
21
+ INSERT INTO conversations(channel_id, conversation_id, session_id, launch)
22
+ VALUES (?, ?, ?, ?)
23
23
  ON CONFLICT(channel_id, conversation_id) DO UPDATE SET
24
- session_id = excluded.session_id, updated_at = excluded.updated_at, launch = excluded.launch
25
- `).run(key.channelId, key.conversationId, sessionId, Date.now(), launch ? JSON.stringify(launch) : null);
24
+ session_id = excluded.session_id, launch = excluded.launch
25
+ `).run(key.channelId, key.conversationId, sessionId, launch ? JSON.stringify(launch) : null);
26
26
  }
27
27
  launchOf(key) {
28
28
  const row = this.db.prepare(`
@@ -68,6 +68,11 @@ export class LarkOutbound {
68
68
  const kept = withoutButtons(known);
69
69
  await this.api.patchCard(messageId, kept.body.elements.length ? kept : card([footer("Option taken.")])).catch((err) => this.log(`retiring options failed: ${String(err)}`));
70
70
  }
71
+ /** A note's root rewritten in place (a child thread's state); one card, so
72
+ * the body is cut to the budget rather than split. */
73
+ async edit(messageId, note) {
74
+ await this.api.patchCard(messageId, card([markdown(chunk(noteBody(note, "*"), LARK_MAX)[0] ?? "")]));
75
+ }
71
76
  remember(messageId, sent) {
72
77
  this.sent.set(messageId, sent);
73
78
  while (this.sent.size > SENT_CACHE) {
@@ -160,8 +160,9 @@ export class LarkChannel {
160
160
  const text = raw.trim();
161
161
  const command = parseCommand(text);
162
162
  const root = threadOf(msg);
163
- // Every message in the home chat, in a topic or not, is the head's, under one key.
164
- const home = this.isHome(msg.chatId);
163
+ // The home chat is the head's, in a topic or not, under one key — except a
164
+ // child's topic (docs/design/11 §Child threads), which is that session's.
165
+ const home = this.isHead(msg.chatId, msg.rootId);
165
166
  const here = { channelId: this.id, conversationId: home ? msg.chatId : conversationId(msg.chatId, root) };
166
167
  const bindRequest = command?.name === "bind" && isDm;
167
168
  const admitted = this.gate.admit("message", msg.chatId, {
@@ -308,9 +309,9 @@ export class LarkChannel {
308
309
  ? action.name.slice(CWD_SUBMIT_PREFIX.length)
309
310
  : "";
310
311
  const root = action.value?.root ?? formRoot;
311
- // Any tap in the home chat is the head's, echoed where the card was;
312
- // anywhere else only a topic's card is Pier's.
313
- const home = this.isHome(action.chatId);
312
+ // Any tap in the home chat is the head's, echoed where the card was,
313
+ // unless a child's topic; anywhere else only a topic's card is Pier's.
314
+ const home = this.isHead(action.chatId, root);
314
315
  if (!root && !(home && payload.startsWith(OFFER_PREFIX))) {
315
316
  this.log(`card action without a thread root in ${action.chatId}, dropped`);
316
317
  return;
@@ -398,6 +399,11 @@ export class LarkChannel {
398
399
  isHome(chatId) {
399
400
  return this.deps.control?.isHome({ channelId: this.id, conversationId: chatId }) ?? false;
400
401
  }
402
+ /** The head's: the home chat, outside any topic a session of its own is bound to. */
403
+ isHead(chatId, root) {
404
+ return this.isHome(chatId) &&
405
+ !(root && this.deps.control?.knows({ channelId: this.id, conversationId: conversationId(chatId, root) }));
406
+ }
401
407
  /** A topic, or the home chat's main flow; undefined for any other chat's. */
402
408
  target(conversation) {
403
409
  const { chatId, root } = parseConversation(conversation);
@@ -451,4 +457,18 @@ export class LarkChannel {
451
457
  this.receipts.mark(conversation, parseConversation(conversation).chatId, messageId, note.at);
452
458
  }
453
459
  }
460
+ async openThread(chatId, note) {
461
+ if (!this.isHome(chatId))
462
+ throw new Error(`refusing to open a topic in ${chatId}: not the home chat`);
463
+ const messageId = await this.out.note({ chatId }, note);
464
+ if (!messageId)
465
+ throw new Error(`Lark returned no message id for the root in ${chatId}`);
466
+ return conversationId(chatId, messageId);
467
+ }
468
+ async editRoot(conversation, note) {
469
+ const { root } = parseConversation(conversation);
470
+ if (!root)
471
+ throw new Error(`refusing to edit ${conversation}: no thread root in the conversation id`);
472
+ await this.out.edit(root, note);
473
+ }
454
474
  }
@@ -1,5 +1,6 @@
1
1
  // Channel lifecycle: which adapters are running; one call for the Console to
2
2
  // apply a config change.
3
+ import { splitReply } from "../core/reply.js";
3
4
  import { Refused } from "../core/router.js";
4
5
  import { logger } from "../log.js";
5
6
  import { LarkChannel } from "./lark.js";
@@ -10,6 +11,9 @@ const ADAPTERS = [
10
11
  // Lark's "token" is the App ID and "appToken" the App Secret.
11
12
  { platform: "lark", build: (deps) => new LarkChannel(deps) },
12
13
  ];
14
+ const rootLine = (lead, state) => state === "waiting" ? `▷ ${lead.name} · design — waiting for you`
15
+ : state === "final" ? `✓ ${lead.name} · design final`
16
+ : `⚠ ${lead.name} · design — ${lead.text}`;
13
17
  // The injected sink is for warnings; "slack started" is not one.
14
18
  const log = logger("channels");
15
19
  // The parameter below shadows `log` inside its own default expression.
@@ -19,15 +23,17 @@ export class ChannelRuntime {
19
23
  router;
20
24
  chain;
21
25
  control;
26
+ conversations;
22
27
  log;
23
28
  running = new Map();
24
29
  reloading = Promise.resolve();
25
30
  stopped = false;
26
- constructor(store, router, chain, control, log = warn) {
31
+ constructor(store, router, chain, control, conversations, log = warn) {
27
32
  this.store = store;
28
33
  this.router = router;
29
34
  this.chain = chain;
30
35
  this.control = control;
36
+ this.conversations = conversations;
31
37
  this.log = log;
32
38
  }
33
39
  /** Serialized: two concurrent Console saves would race into duplicate live
@@ -70,7 +76,8 @@ export class ChannelRuntime {
70
76
  });
71
77
  try {
72
78
  await channel.start((msg) => {
73
- if (this.control.isHome(msg.key))
79
+ // A thread of the home chat bound to a child (§Child threads) is that session's.
80
+ if (this.control.isHome(msg.key) && !this.control.knows(msg.key))
74
81
  return this.toHead(channel, msg);
75
82
  void this.router.dispatch(msg).catch((err) => this.log(`dispatch failed: ${String(err)}`));
76
83
  });
@@ -100,6 +107,44 @@ export class ChannelRuntime {
100
107
  live(platform) {
101
108
  return this.running.has(platform);
102
109
  }
110
+ /** A design lead waiting on the user gets a thread in the home DM bound to
111
+ * its session (docs/design/11 §Child threads); its later states edit the
112
+ * root. No home, its adapter down, or the session already in a chat: nothing
113
+ * — the web's needs-you carries it. Every failure is a note in the chat. */
114
+ async designLead(lead, state) {
115
+ const home = this.store.home();
116
+ const channel = home && this.running.get(home.platform);
117
+ if (!home || !channel)
118
+ return;
119
+ const note = { text: rootLine(lead, state), origin: lead.origin };
120
+ const bound = this.conversations.keyOf(lead.sessionId);
121
+ if (state === "waiting") {
122
+ if (bound)
123
+ return;
124
+ let thread;
125
+ try {
126
+ thread = await channel.openThread(home.chatId, note);
127
+ // The row first: `ensure` resolves the thread through it, as any thread's message would.
128
+ this.conversations.set({ channelId: home.platform, conversationId: thread }, lead.sessionId);
129
+ await this.router.ensure({ channelId: home.platform, conversationId: thread });
130
+ await channel.send(thread, splitReply(lead.text));
131
+ }
132
+ catch (err) {
133
+ // A root already posted is the thread's failure to report; none, the main flow's.
134
+ await this.report(channel, thread ?? home.chatId, `"${lead.name}" waits for you on the web; ${thread ? "its turn did not reach this thread" : "its thread could not be opened"}: ${String(err)}`);
135
+ }
136
+ return;
137
+ }
138
+ // Only a thread of the home chat has a root of ours to edit.
139
+ if (!bound || bound.channelId !== home.platform || chatOf(bound.conversationId) !== home.chatId)
140
+ return;
141
+ await channel.editRoot(bound.conversationId, note).catch((err) => this.report(channel, bound.conversationId, `the thread's root could not be updated to "${note.text}": ${String(err)}`));
142
+ }
143
+ async report(channel, conversationId, text) {
144
+ this.log(text);
145
+ await channel.notify(conversationId, { text, origin: { kind: "error" } })
146
+ .catch((e) => this.log(`could not report it to ${channel.id}: ${String(e)}`));
147
+ }
103
148
  /** For restart-note delivery (src/drain.ts), which has no session to report
104
149
  * through; false means the platform is not running. */
105
150
  async notify(platform, conversationId, text) {
@@ -51,6 +51,13 @@ export class SlackOutbound {
51
51
  }
52
52
  return ts;
53
53
  }
54
+ /** A note's root rewritten in place (a child thread's state); one message,
55
+ * so the body is cut to the smaller budget rather than split. */
56
+ async edit(channel, ts, note) {
57
+ const body = chunk(noteBody(note, "_"), MRKDWN_MAX)[0] ?? "";
58
+ const blocks = this.markdownBlocks ? [markdown(body)] : sections(toMrkdwn(body));
59
+ await this.api.updateMessage({ channel, ts, text: body, blocks });
60
+ }
54
61
  budget() {
55
62
  return this.markdownBlocks ? MARKDOWN_MAX : MRKDWN_MAX;
56
63
  }
@@ -39,3 +39,68 @@ export async function readThread(directory, client, channel, threadTs, limit) {
39
39
  messages: transcript(window, names, { ts: true, ids: true, thread: true }),
40
40
  };
41
41
  }
42
+ /** A token budget, not a Slack limit: past this the agent gets the
43
+ * coordinates and decides for itself. */
44
+ const INLINE_REPLY_MAX = 30;
45
+ /** The eager thread read is not gated: a human handing the agent a message
46
+ * is the same act as an upload. */
47
+ export async function sharedBlock(directory, api, log, share) {
48
+ const source = share.original_message;
49
+ const ts = share.ts ?? source?.ts;
50
+ const threadTs = share.thread_ts ?? source?.thread_ts ?? ts;
51
+ const replies = share.reply_count ?? source?.reply_count;
52
+ // A share of a reply is one message; only a parent has a thread.
53
+ const parent = share.channel_id && ts && threadTs === ts && replies
54
+ ? { channel: share.channel_id, ts, replies }
55
+ : null;
56
+ // `name<id>` is the sender prefix's grammar (core/identity.ts).
57
+ const author = share.author_id
58
+ ? `${await directory.user(api, share.author_id)}<${share.author_id}>`
59
+ : share.author_name || share.author_subname;
60
+ const where = share.channel_name
61
+ ? `#${share.channel_name}${share.channel_id ? `<${share.channel_id}>` : ""}`
62
+ : share.channel_id;
63
+ const head = [
64
+ "shared message",
65
+ author && `from ${author}`,
66
+ where && `in ${where}`,
67
+ ts && `at ${ts}`,
68
+ ].filter(Boolean).join(" ");
69
+ // `fallback` is Slack's plain-text rendering when a share's `text` is
70
+ // empty (a file-only forward, or a body that is all blocks); "" is normal.
71
+ const body = (share.text || share.fallback || source?.text || "").trim();
72
+ const thread = parent && parent.replies <= INLINE_REPLY_MAX
73
+ ? await sharedThread(directory, api, log, parent.channel, parent.ts, parent.replies)
74
+ : { transcript: false, lines: [] };
75
+ // The coordinates in `pier slack`'s own words (skills/pier-slack).
76
+ const hint = parent && !thread.transcript
77
+ ? `[thread: ${parent.replies} replies — channel ${parent.channel}, thread_ts ${parent.ts}]`
78
+ : "";
79
+ // The transcript opens with the shared message itself.
80
+ return [`[${head}]`, thread.transcript ? "" : body, ...thread.lines, hint]
81
+ .filter(Boolean).join("\n");
82
+ }
83
+ /** A read that fails or comes back cut says so in the prompt (§5). */
84
+ export async function sharedThread(directory, api, log, channel, ts, replies) {
85
+ try {
86
+ // One over the budget, so an undercounting reply_count still reports as cut.
87
+ const read = await readThread(directory, api, channel, ts, INLINE_REPLY_MAX + 2);
88
+ if (!read.messages.length)
89
+ return { transcript: false, lines: [] };
90
+ return {
91
+ transcript: true,
92
+ lines: [
93
+ `[thread: ${replies} replies, oldest first — ${read.format}]`,
94
+ ...read.messages,
95
+ ...(read.truncated ? [`[thread partly read: cut at ${read.count} lines]`] : []),
96
+ ],
97
+ };
98
+ }
99
+ catch (err) {
100
+ log(`shared thread ${channel}/${ts} not read: ${String(err)}`);
101
+ return {
102
+ transcript: false,
103
+ lines: [`[thread not read: ${err instanceof Error ? err.message : String(err)}]`],
104
+ };
105
+ }
106
+ }
@@ -21,7 +21,7 @@ import { SlackDirectory } from "./slack-directory.js";
21
21
  import { SlackApi, } from "./slack-api.js";
22
22
  import { SlackOutbound } from "./slack-outbound.js";
23
23
  import { SlackPanel } from "./slack-panel.js";
24
- import { readThread } from "./slack-thread.js";
24
+ import { sharedBlock } from "./slack-thread.js";
25
25
  import { context, escapeMrkdwn, offeredLabel } from "./slack-render.js";
26
26
  const WORKING = "eyes";
27
27
  // The envelope is already acked, so this bounds concurrency (sockets,
@@ -51,9 +51,6 @@ const READABLE_SUBTYPES = new Set(["file_share", "thread_broadcast", "message_sh
51
51
  * sender did not choose to forward; a real share carries both flags. */
52
52
  const sharesOf = (event) => (event.attachments ?? []).filter((a) => a.is_share === true || (event.subtype === "message_share" && !a.is_msg_unfurl));
53
53
  const sharedFiles = (share) => share.files ?? share.original_message?.files ?? [];
54
- /** A token budget, not a Slack limit: past this the agent gets the
55
- * coordinates and decides for itself. */
56
- const INLINE_REPLY_MAX = 30;
57
54
  export class SlackChannel {
58
55
  deps;
59
56
  id = "slack";
@@ -188,8 +185,9 @@ export class SlackChannel {
188
185
  const text = this.stripMention(raw);
189
186
  const command = parseCommand(text);
190
187
  const threadTs = threadOf(event);
191
- // Every message in the home DM, in a thread or not, is the head's, under one key.
192
- const home = this.isHome(channel);
188
+ // The home DM is the head's, in a thread or not, under one key — except a
189
+ // child's thread (docs/design/11 §Child threads), which is that session's.
190
+ const home = this.isHead(channel, event.thread_ts);
193
191
  const here = { channelId: this.id, conversationId: home ? channel : conversationId(channel, threadTs) };
194
192
  const bindRequest = command?.name === "bind" && isDm;
195
193
  const admitted = this.gate.admit("message", channel, {
@@ -215,7 +213,7 @@ export class SlackChannel {
215
213
  // Downloading only past the gate: an unauthorized sender must not make the
216
214
  // bot pull bytes on their behalf.
217
215
  const markers = await this.saveAttachments(files);
218
- const shared = await Promise.all(shares.map((share) => this.sharedBlock(share)));
216
+ const shared = await Promise.all(shares.map((share) => sharedBlock(this.directory, this.api, this.log, share)));
219
217
  // `/s <text>` drafts a session, so only where this message would start one:
220
218
  // a thread root. The held question carries its markers, so Start sends what the user sent.
221
219
  const question = threadTs === ts ? settingsDraft(text) : undefined;
@@ -259,9 +257,9 @@ export class SlackChannel {
259
257
  this.log("incomplete block_actions payload, dropped");
260
258
  return;
261
259
  }
262
- // Any click in the home DM is the head's, echoed where the button was;
263
- // anywhere else a top-level message roots a thread.
264
- const home = this.isHome(channel);
260
+ // Any click in the home DM is the head's, echoed where the button was,
261
+ // unless a child's thread; anywhere else a top-level message roots a thread.
262
+ const home = this.isHead(channel, message.thread_ts);
265
263
  const threadTs = home ? message.thread_ts : message.thread_ts ?? message.ts;
266
264
  const key = {
267
265
  channelId: this.id,
@@ -357,6 +355,11 @@ export class SlackChannel {
357
355
  isHome(channel) {
358
356
  return this.deps.control?.isHome({ channelId: this.id, conversationId: channel }) ?? false;
359
357
  }
358
+ /** The head's: the home DM, outside any thread a session of its own is bound to. */
359
+ isHead(channel, threadTs) {
360
+ return this.isHome(channel) &&
361
+ !(threadTs && this.deps.control?.knows({ channelId: this.id, conversationId: conversationId(channel, threadTs) }));
362
+ }
360
363
  /** A thread, or the home DM's main flow (no `threadTs`); undefined for any other channel's. */
361
364
  target(conversation) {
362
365
  const { channel, threadTs } = parseConversation(conversation);
@@ -378,68 +381,6 @@ export class SlackChannel {
378
381
  }
379
382
  return name ?? channel;
380
383
  }
381
- /** The eager thread read is not gated: a human handing the agent a message
382
- * is the same act as an upload. */
383
- async sharedBlock(share) {
384
- const source = share.original_message;
385
- const ts = share.ts ?? source?.ts;
386
- const threadTs = share.thread_ts ?? source?.thread_ts ?? ts;
387
- const replies = share.reply_count ?? source?.reply_count;
388
- // A share of a reply is one message; only a parent has a thread.
389
- const parent = share.channel_id && ts && threadTs === ts && replies
390
- ? { channel: share.channel_id, ts, replies }
391
- : null;
392
- // `name<id>` is the sender prefix's grammar (core/identity.ts).
393
- const author = share.author_id
394
- ? `${await this.directory.user(this.api, share.author_id)}<${share.author_id}>`
395
- : share.author_name || share.author_subname;
396
- const where = share.channel_name
397
- ? `#${share.channel_name}${share.channel_id ? `<${share.channel_id}>` : ""}`
398
- : share.channel_id;
399
- const head = [
400
- "shared message",
401
- author && `from ${author}`,
402
- where && `in ${where}`,
403
- ts && `at ${ts}`,
404
- ].filter(Boolean).join(" ");
405
- // `fallback` is Slack's plain-text rendering when a share's `text` is
406
- // empty (a file-only forward, or a body that is all blocks); "" is normal.
407
- const body = (share.text || share.fallback || source?.text || "").trim();
408
- const thread = parent && parent.replies <= INLINE_REPLY_MAX
409
- ? await this.sharedThread(parent.channel, parent.ts, parent.replies)
410
- : { transcript: false, lines: [] };
411
- // The coordinates in `pier slack`'s own words (skills/pier-slack).
412
- const hint = parent && !thread.transcript
413
- ? `[thread: ${parent.replies} replies — channel ${parent.channel}, thread_ts ${parent.ts}]`
414
- : "";
415
- // The transcript opens with the shared message itself.
416
- return [`[${head}]`, thread.transcript ? "" : body, ...thread.lines, hint]
417
- .filter(Boolean).join("\n");
418
- }
419
- /** A read that fails or comes back cut says so in the prompt (§5). */
420
- async sharedThread(channel, ts, replies) {
421
- try {
422
- // One over the budget, so an undercounting reply_count still reports as cut.
423
- const read = await readThread(this.directory, this.api, channel, ts, INLINE_REPLY_MAX + 2);
424
- if (!read.messages.length)
425
- return { transcript: false, lines: [] };
426
- return {
427
- transcript: true,
428
- lines: [
429
- `[thread: ${replies} replies, oldest first — ${read.format}]`,
430
- ...read.messages,
431
- ...(read.truncated ? [`[thread partly read: cut at ${read.count} lines]`] : []),
432
- ],
433
- };
434
- }
435
- catch (err) {
436
- this.log(`shared thread ${channel}/${ts} not read: ${String(err)}`);
437
- return {
438
- transcript: false,
439
- lines: [`[thread not read: ${err instanceof Error ? err.message : String(err)}]`],
440
- };
441
- }
442
- }
443
384
  saveAttachments(files) {
444
385
  return saveInboundAll(this.id, files.map((file) => ({
445
386
  label: file.name ?? "attachment",
@@ -475,4 +416,18 @@ export class SlackChannel {
475
416
  if (ts && awaitsTurn(note.origin))
476
417
  this.receipts.mark(conversation, to.channel, ts, note.at);
477
418
  }
419
+ async openThread(channel, note) {
420
+ if (!this.isHome(channel))
421
+ throw new Error(`refusing to open a thread in ${channel}: not the home DM`);
422
+ const ts = await this.out.note(channel, undefined, note);
423
+ if (!ts)
424
+ throw new Error(`Slack returned no ts for the root in ${channel}`);
425
+ return conversationId(channel, ts);
426
+ }
427
+ async editRoot(conversation, note) {
428
+ const { channel, threadTs } = parseConversation(conversation);
429
+ if (!threadTs)
430
+ throw new Error(`refusing to edit ${conversation}: no thread in the conversation id`);
431
+ await this.out.edit(channel, threadTs, note);
432
+ }
478
433
  }
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}"`);
@@ -7,9 +7,28 @@ import { join } from "node:path";
7
7
  import { transact } from "../db.js";
8
8
  import { logger } from "../log.js";
9
9
  import { day } from "./identity.js";
10
+ import { cut } from "./reply.js";
10
11
  import { CHAIN_FULL_TOKENS, CHAIN_IDLE_MS, isChatCommand } from "./types.js";
11
12
  const log = logger("core");
12
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
+ }
13
32
  const WHY = {
14
33
  first: "the first one",
15
34
  idle: "the previous one was idle for an hour",
@@ -32,6 +51,8 @@ function lastExchanges(turns, n) {
32
51
  .map((t) => `${t.role}: ${t.text}`).join("\n\n");
33
52
  }
34
53
  const ledgerLine = (r) => `${r.runId} · ${r.name} · ${r.state} · session ${r.targetSessionId ?? "—"} · ${r.cwd ?? "—"}`;
54
+ /** The seed lists what still needs the head: a run that ended well is `pier task runs`' to show. */
55
+ const SEED_RUNS_HIDDEN = new Set(["succeeded", "skipped"]);
35
56
  /** `/skills`' answer on every surface, the head's and a thread's (channels/). */
36
57
  export const skillsText = (skills) => skills.map((s) => `${s.name} — ${s.description}`).join("\n") || "no skills";
37
58
  /** Only the exact word is a command: the composer is not a shell. */
@@ -177,15 +198,16 @@ export class MainChain {
177
198
  async seed(reason, previous, open) {
178
199
  const today = new Date(this.now());
179
200
  const days = [new Date(today.getTime() - 86_400_000), today].map(day);
180
- const runs = this.deps.ledger(this.members().map((m) => m.sessionId), previous?.startedAt ?? this.now());
201
+ const runs = this.deps.ledger(this.members().map((m) => m.sessionId), previous?.startedAt ?? this.now())
202
+ .filter((r) => !SEED_RUNS_HIDDEN.has(r.state));
181
203
  const section = (title, text) => (text ? `## ${title}\n\n${text}` : "");
182
204
  return [
183
205
  `[Pier: a new session of the continuous conversation — ${WHY[reason]}. The rest of this note is context, not a message.]`,
184
- section("MEMORY.md", await this.read("MEMORY.md")),
206
+ section("MEMORY.md", cut(await this.read("MEMORY.md"), MEMORY_CHARS)),
185
207
  section("Open", this.deps.status(this.now()).text),
186
- section("Runs — in flight, and finished since the previous session started", runs.map(ledgerLine).join("\n") || "none"),
187
- ...(await Promise.all(days.map(async (date) => section(`memory/${date}.md`, await this.read(join("memory", `${date}.md`)))))),
188
- section("The previous session's last exchanges", open ? lastExchanges(await open.history(), EXCHANGES) : ""),
208
+ section("Runs — in flight, or ended short of success since the previous session started (succeeded and skipped: `pier task runs`)", cut(runs.map(ledgerLine).join("\n"), LEDGER_CHARS) || "none"),
209
+ ...(await Promise.all(days.map(async (date) => section(`memory/${date}.md`, tail(await this.read(join("memory", `${date}.md`)), NOTES_CHARS, `memory/${date}.md`))))),
210
+ section("The previous session's last exchanges", open ? tail(lastExchanges(await open.history(), EXCHANGES), EXCHANGES_CHARS, `session ${open.id}`) : ""),
189
211
  ].filter(Boolean).join("\n\n");
190
212
  }
191
213
  async read(name) {
@@ -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, "")
@@ -138,7 +138,7 @@ export function readableTitle(title) {
138
138
  export const sessionLabel = (s) => readableTitle(s?.title)?.replace(/\s+/g, " ").trim() || s?.cwd.split("/").filter(Boolean).at(-1) || "Pier session";
139
139
  /** Distinct directories, newest session first: the ground `projectCwds` picks
140
140
  * from. */
141
- 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))];
142
142
  /** The distinct directories less the worktrees: `wt` puts a checkout beside its
143
143
  * repository as `<repo>.<branch>`, and the next conversation about a project
144
144
  * belongs in the project. A worktree with no such sibling stays. What every