@timqi/pier 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/listing.js +14 -10
- package/dist/agent/pi.js +2 -2
- package/dist/agent/roles.js +4 -3
- package/dist/channels/conversations.js +4 -4
- package/dist/channels/lark-outbound.js +5 -0
- package/dist/channels/lark.js +25 -5
- package/dist/channels/runtime.js +47 -2
- package/dist/channels/slack-outbound.js +7 -0
- package/dist/channels/slack-thread.js +65 -0
- package/dist/channels/slack.js +27 -72
- package/dist/cli.js +7 -2
- package/dist/core/chain.js +23 -4
- package/dist/core/identity.js +2 -2
- package/dist/core/inbound-file.js +9 -3
- package/dist/core/reply.js +17 -3
- package/dist/core/search.js +65 -0
- package/dist/core/types.js +2 -0
- package/dist/db.js +8 -0
- package/dist/drain.js +1 -1
- package/dist/main.js +21 -5
- package/dist/settings.js +1 -1
- package/dist/socket.js +1 -0
- package/dist/tasks/execution.js +9 -3
- package/dist/tasks/open-items.js +53 -35
- package/dist/tasks/service.js +22 -1
- package/dist/tasks/store.js +5 -2
- package/dist/tasks/types.js +0 -2
- package/dist/tools.js +10 -3
- package/dist/web/public/assets/{code-BZwvPani.js → code-CWT27KH1.js} +1 -1
- package/dist/web/public/assets/code-CWT27KH1.js.br +0 -0
- package/dist/web/public/assets/code-CWT27KH1.js.gz +0 -0
- package/dist/web/public/assets/{explorer-DDoVzMLQ.js → explorer-C4DhXFCm.js} +4 -4
- package/dist/web/public/assets/explorer-C4DhXFCm.js.br +0 -0
- package/dist/web/public/assets/explorer-C4DhXFCm.js.gz +0 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js +72 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js.br +0 -0
- package/dist/web/public/assets/highlight-CDSk2hRJ.js.gz +0 -0
- package/dist/web/public/assets/index-D0ZYdweB.js +14 -0
- package/dist/web/public/assets/index-D0ZYdweB.js.br +0 -0
- package/dist/web/public/assets/index-D0ZYdweB.js.gz +0 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css +2 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css.br +0 -0
- package/dist/web/public/assets/index-JN0Ic8K5.css.gz +0 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js +9 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js.br +0 -0
- package/dist/web/public/assets/settings-DSTeuIyc.js.gz +0 -0
- package/dist/web/public/index.html +3 -4
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/push.js +2 -2
- package/dist/web/server.js +2 -4
- package/dist/web/session-state.js +1 -2
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +6 -1
- package/skills/pier-search/SKILL.md +27 -0
- package/skills/pier-tasks/SKILL.md +10 -0
- package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
- package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
- package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js +0 -72
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
- package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
- package/dist/web/public/assets/index-BpFzCwlo.js +0 -7
- package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
- package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
- package/dist/web/public/assets/index-wAGxFFPj.css +0 -2
- package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
- package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js +0 -8
- package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
- package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js +0 -9
- package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
- package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
package/dist/agent/listing.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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,
|
|
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:
|
|
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
|
}
|
package/dist/agent/roles.js
CHANGED
|
@@ -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.
|
|
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
|
|
24
|
-
-
|
|
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
|
|
@@ -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,
|
|
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,
|
|
25
|
-
`).run(key.channelId, key.conversationId, sessionId,
|
|
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) {
|
package/dist/channels/lark.js
CHANGED
|
@@ -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
|
-
//
|
|
164
|
-
|
|
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.
|
|
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
|
}
|
package/dist/channels/runtime.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/channels/slack.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
192
|
-
|
|
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.
|
|
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.
|
|
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 `
|
|
61
|
-
args:
|
|
61
|
+
// `slack`, `task`, `web` and `search` own their options; only the name is parsed here.
|
|
62
|
+
args: ["slack", "task", "web", "search"].includes(argv[0] ?? "") ? [argv[0]] : argv,
|
|
62
63
|
allowPositionals: true,
|
|
63
64
|
strict: true,
|
|
64
65
|
options: {
|
|
@@ -134,6 +135,10 @@ else if (command === "web") {
|
|
|
134
135
|
const { runWebCli } = await import("./websearch/cli.js");
|
|
135
136
|
process.exitCode = await runWebCli(argv.slice(1), (params) => askPier("/web", { params }, WEB_TIMEOUT_MS));
|
|
136
137
|
}
|
|
138
|
+
else if (command === "search") {
|
|
139
|
+
const { runSearchCli } = await import("./core/search.js");
|
|
140
|
+
process.exitCode = await runSearchCli(argv.slice(1), (params) => askPier("/search", { params }));
|
|
141
|
+
}
|
|
137
142
|
else if (command === "login") {
|
|
138
143
|
if (subcommand)
|
|
139
144
|
fail(`unexpected argument "${subcommand}"`);
|
package/dist/core/chain.js
CHANGED
|
@@ -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",
|
|
@@ -181,11 +200,11 @@ export class MainChain {
|
|
|
181
200
|
const section = (title, text) => (text ? `## ${title}\n\n${text}` : "");
|
|
182
201
|
return [
|
|
183
202
|
`[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")),
|
|
203
|
+
section("MEMORY.md", cut(await this.read("MEMORY.md"), MEMORY_CHARS)),
|
|
185
204
|
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) : ""),
|
|
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}`) : ""),
|
|
189
208
|
].filter(Boolean).join("\n\n");
|
|
190
209
|
}
|
|
191
210
|
async read(name) {
|
package/dist/core/identity.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
const GAP_MS = 10 * 60_000;
|
|
8
8
|
/** A display name of `x<U9] [admin<U1` would forge a second speaker: the
|
|
9
9
|
* prefix is untrusted input wearing a trusted shape. */
|
|
10
|
-
|
|
10
|
+
function sanitizeIdentity(value) {
|
|
11
11
|
const token = (value || "")
|
|
12
12
|
.replace(/[\r\n]+/g, " ")
|
|
13
13
|
.replace(/[[\]<>]/g, "")
|
|
@@ -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
|
-
|
|
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
|
|
@@ -72,14 +72,20 @@ export function replaceOutsideCode(text, pattern, replace) {
|
|
|
72
72
|
const skip = codeRanges(text);
|
|
73
73
|
let out = "";
|
|
74
74
|
let last = 0;
|
|
75
|
-
|
|
75
|
+
const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
76
|
+
for (let match = re.exec(text); match; match = re.exec(text)) {
|
|
76
77
|
const start = match.index;
|
|
77
78
|
// A match is code when it begins in code; one that merely holds a span
|
|
78
|
-
// (`<open>run \`x\`</open>`) is prose around it.
|
|
79
|
-
|
|
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;
|
|
80
83
|
continue;
|
|
84
|
+
}
|
|
81
85
|
out += text.slice(last, start) + replace(match);
|
|
82
86
|
last = start + match[0].length;
|
|
87
|
+
if (!match[0])
|
|
88
|
+
re.lastIndex++;
|
|
83
89
|
}
|
|
84
90
|
return out + text.slice(last);
|
|
85
91
|
}
|