@trim21/personal-pi-extensions 0.0.195 → 0.0.198
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -14
- package/package.json +1 -1
- package/src/lib/jsonc.ts +67 -0
- package/src/talk/core.ts +120 -69
- package/src/talk/format.ts +17 -11
- package/src/talk/index.ts +72 -57
- package/src/talk/registry.ts +14 -18
package/README.md
CHANGED
|
@@ -230,23 +230,29 @@ index.ts —— pi adapter:把 core 接到 pi 的 sendMessage / 生命周
|
|
|
230
230
|
|
|
231
231
|
### 工具(LLM 可见)
|
|
232
232
|
|
|
233
|
-
| 工具 | 作用
|
|
234
|
-
| -------------------- |
|
|
235
|
-
| `talk-list-sessions` | 列出其他 session
|
|
236
|
-
| `talk-
|
|
237
|
-
| `talk-
|
|
238
|
-
| `talk-
|
|
239
|
-
| `talk-
|
|
240
|
-
| `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断) |
|
|
233
|
+
| 工具 | 作用 |
|
|
234
|
+
| -------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
235
|
+
| `talk-list-sessions` | 列出其他 session,返回 JSON 数组(`status` / `work_dir` / `id` / `name`);默认只列有心跳的,`includeOffline: true` 列全部 |
|
|
236
|
+
| `talk-ask` | 向某个 session 提问并阻塞等待回复(默认 30 分钟超时) |
|
|
237
|
+
| `talk-wait` | 阻塞等待新消息到达 |
|
|
238
|
+
| `talk-send` | 发送纯文本消息(`to: "*"` 广播所有,`to: "cwd"` 广播同 cwd) |
|
|
239
|
+
| `talk-reply` | 回复一个 ask(`replyTo` 为 ask id,显式关联、不推断) |
|
|
241
240
|
|
|
242
|
-
|
|
241
|
+
对端消息自动投递(无需主动拉取):投递方式由 `talk.deliver` 配置,`steer` 在模型工作过程中打断/唤醒,`queue` 排队到 session 下一轮自然 turn 时注入。
|
|
242
|
+
|
|
243
|
+
**定位只认 session id**:`talk-send` / `talk-ask` / `talk-watch` 的 `to` 只接受 `talk-list-sessions` 返回的 `id`(pi 的 session uuid)精确匹配,不做 name/路径/前缀匹配。
|
|
244
|
+
|
|
245
|
+
**标记废弃 session**:`/talk-dead` 把 session 的 `lastSeenAt` 置 0(从默认列表消失,下次 sweep 无 mail 即回收):无参标记当前 session(同时停止其心跳),`/talk-dead <sessionId>` 标记指定 session,`/talk-dead --all` 标记所有其他可见 session。
|
|
243
246
|
|
|
244
247
|
### 关键设计
|
|
245
248
|
|
|
249
|
+
- **心跳即活跃**:每个 session 每 15s 写一次 `lastSeenAt`;`talk-list-sessions` 默认只显示最近 15 分钟内有心跳的 session,已结束/挂起的 session 自动从默认列表消失(`includeOffline: true` 可见全部)。`status` 用 45s 心跳阈值区分 live / not responding / offline。
|
|
250
|
+
- **定期清理**:心跳停止超过 24h 且无未投递 mail 的记录会被定期 sweep(30 分钟一次)回收;有 mail 的保留 30 天。resume 后 session 会自动重新注册,无 mail 即无损失。
|
|
246
251
|
- **投递成功才消费**:信件只在成功交给 `sendMessage` 后才从 inbox 删除,投递失败留在 inbox 下次重试——不会因 `sendMessage` 吞异常而静默丢信。
|
|
247
252
|
- **双向 ask 仲裁**:`talk-ask` 发起前先检查收件箱(有对方消息就先读/先回);阻塞等待期间若收到对方的 ask(而非 reply),按两个 ask 的 `ts` 字段仲裁——先 ask 者主导继续等,后 ask 者让位并先回复对方。`ts` 是信件内固定字段,双方读到同一对值,结论天然对称;同毫秒碰撞用 `session dir + session id` 字符串比较兜底。
|
|
248
253
|
- **typebox runtime 验证**:所有从存储读出的值经 TypeBox schema 校验,损坏/伪造数据被拒绝,不做 `as T` 强转。
|
|
249
254
|
- **安全**:纯文本 ≤32KB;10s 去重 / 30s 限速 8 条 / 50 积压上限(防环);每条投递带「来自其他 session、无权威」声明。
|
|
255
|
+
- **workspace 可见性**:每个 workspace 通过 `<cwd>/.pi/talk.json` 的 `allowed` 控制自己能看到哪些 session,见下方配置。
|
|
250
256
|
|
|
251
257
|
### 配置
|
|
252
258
|
|
|
@@ -261,14 +267,39 @@ sqlite 文件路径按优先级取第一个可用值:
|
|
|
261
267
|
```jsonc
|
|
262
268
|
// ~/.pi/agent/settings.json
|
|
263
269
|
{
|
|
264
|
-
"talk": { "db_path": "~/data/talk.db" },
|
|
270
|
+
"talk": { "db_path": "~/data/talk.db", "deliver": "queue" },
|
|
271
|
+
}
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`talk.deliver` 控制对端消息的投递方式:
|
|
275
|
+
|
|
276
|
+
- `"steer"`:消息到达时打断当前工作(工具调用间隙注入),空闲 session 被唤醒;
|
|
277
|
+
- `"queue"`:消息排队,在 session 下一轮自然 turn(如用户发消息)时注入,不主动唤醒。
|
|
278
|
+
|
|
279
|
+
默认 `"queue"`。
|
|
280
|
+
|
|
281
|
+
### workspace 可见性
|
|
282
|
+
|
|
283
|
+
每个 workspace 通过 `<cwd>/.pi/talk.json` 的 `allowed` 决定自己能看到哪些 session:
|
|
284
|
+
|
|
285
|
+
```jsonc
|
|
286
|
+
// ~/projects/company1/.pi/talk.json
|
|
287
|
+
{
|
|
288
|
+
"allowed": ["~/projects/company1/"],
|
|
265
289
|
}
|
|
266
290
|
```
|
|
267
291
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
292
|
+
- `allowed` 是路径前缀列表:能看到前缀本身及其子目录下的 session(`~/projects/company1/*`),看不到 `~/projects/company2/` 下的 session;`company1` 不会误匹配 `company12`
|
|
293
|
+
- 路径支持 `~` 展开,相对路径相对该 workspace 的 cwd 解析
|
|
294
|
+
- 无 `allowed` 字段 → 全部可见;`"allowed": []` → 谁都看不到
|
|
295
|
+
- 可见性单向生效:A 的配置只决定 A 能看到谁,不影响 B
|
|
296
|
+
- 不可见的 session 不仅 list 不到,也无法 `talk-send` / `talk-ask` 寻址(即使知道 id 也会被拒绝)
|
|
297
|
+
|
|
298
|
+
| 变量 | 默认 | 含义 |
|
|
299
|
+
| ----------------- | --------------------------------- | ------------------------------- |
|
|
300
|
+
| `PI_TALK_DB` | settings 或 `~/.pi/agent/talk.db` | SQLite 邮箱数据库路径 |
|
|
301
|
+
| `PI_TALK_INBOUND` | `accept` | `refuse` 时丢弃所有 peer 消息 |
|
|
302
|
+
| `talk.deliver` | `queue` | 消息投递方式:`steer` / `queue` |
|
|
272
303
|
|
|
273
304
|
### 使用
|
|
274
305
|
|
package/package.json
CHANGED
package/src/lib/jsonc.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal JSONC → JSON conversion for small user-edited config files.
|
|
3
|
+
*
|
|
4
|
+
* Strips line and block comments and trailing commas outside of string
|
|
5
|
+
* literals, so a stray comment in a config file does not silently void the
|
|
6
|
+
* whole file (the way a plain `JSON.parse` would).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function jsoncToJson(raw: string): string {
|
|
10
|
+
let out = "";
|
|
11
|
+
let inString = false;
|
|
12
|
+
let i = 0;
|
|
13
|
+
while (i < raw.length) {
|
|
14
|
+
const c = raw[i];
|
|
15
|
+
if (inString) {
|
|
16
|
+
out += c;
|
|
17
|
+
if (c === "\\" && i + 1 < raw.length) {
|
|
18
|
+
out += raw[i + 1];
|
|
19
|
+
i += 2;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (c === '"') inString = false;
|
|
23
|
+
i++;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
switch (c) {
|
|
27
|
+
case '"': {
|
|
28
|
+
inString = true;
|
|
29
|
+
out += c;
|
|
30
|
+
i++;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
case "/": {
|
|
34
|
+
if (raw[i + 1] === "/") {
|
|
35
|
+
while (i < raw.length && raw[i] !== "\n") i++;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (raw[i + 1] === "*") {
|
|
39
|
+
i += 2;
|
|
40
|
+
while (i < raw.length && !(raw[i] === "*" && raw[i + 1] === "/")) i++;
|
|
41
|
+
i += 2;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
out += c;
|
|
45
|
+
i++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
case ",": {
|
|
49
|
+
// Drop a trailing comma before } or ] (outside strings).
|
|
50
|
+
let j = i + 1;
|
|
51
|
+
while (j < raw.length && /\s/.test(raw[j])) j++;
|
|
52
|
+
if (raw[j] === "}" || raw[j] === "]") {
|
|
53
|
+
i++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
out += c;
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
default: {
|
|
61
|
+
out += c;
|
|
62
|
+
i++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
package/src/talk/core.ts
CHANGED
|
@@ -9,13 +9,10 @@
|
|
|
9
9
|
* poll — so a swallowed sendMessage error no longer destroys the letter.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
refusalUnknown,
|
|
17
|
-
shortAddr,
|
|
18
|
-
} from "./format.js";
|
|
12
|
+
import { isAbsolute, resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
import { expandHome } from "../lib/path.js";
|
|
15
|
+
import { formatDelivery, formatListing, refusalUnknown, shortAddr } from "./format.js";
|
|
19
16
|
import {
|
|
20
17
|
appendAudit,
|
|
21
18
|
awaitReceipt,
|
|
@@ -38,6 +35,7 @@ import {
|
|
|
38
35
|
} from "./mailbox.js";
|
|
39
36
|
import { inboundAccepts, OutboundPolicy } from "./policy.js";
|
|
40
37
|
import {
|
|
38
|
+
LIST_ACTIVE_MS,
|
|
41
39
|
listRecords,
|
|
42
40
|
type Presence,
|
|
43
41
|
presenceOf,
|
|
@@ -63,8 +61,6 @@ export interface TalkCoreEvents {
|
|
|
63
61
|
export interface TalkCoreOptions {
|
|
64
62
|
storage: TalkStorage;
|
|
65
63
|
events: TalkCoreEvents;
|
|
66
|
-
/** Adapter-provided: which session ids pi can still resume (for sweep). */
|
|
67
|
-
collectResumableSessionIds?: () => Set<string>;
|
|
68
64
|
now?: () => number;
|
|
69
65
|
}
|
|
70
66
|
|
|
@@ -74,11 +70,34 @@ const WATCH_POLL_MS = 5000;
|
|
|
74
70
|
const DELIVERY_BACKOFF_MS = 5000;
|
|
75
71
|
const INITIAL_DRAIN_DELAY_MS = 1200;
|
|
76
72
|
const WAIT_POLL_MS = 500;
|
|
73
|
+
const SWEEP_INTERVAL_MS = 30 * 60 * 1000;
|
|
77
74
|
|
|
78
75
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
79
76
|
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
/** Normalize an allowed path: expand ~, resolve relative against baseCwd, strip trailing slashes. */
|
|
78
|
+
function normalizeAllowedPath(p: string, baseCwd: string): string {
|
|
79
|
+
const expanded = expandHome(p);
|
|
80
|
+
const abs = isAbsolute(expanded) ? resolve(expanded) : resolve(baseCwd, expanded);
|
|
81
|
+
return abs.replace(/[\\/]+$/, "") || "/";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the workspace-visibility gate for one session. `allowed` is the
|
|
86
|
+
* `allowed` array from `<cwd>/.pi/talk.json` (undefined when the file or key
|
|
87
|
+
* is absent). A peer session is visible when its cwd equals an allowed prefix
|
|
88
|
+
* or sits below it (`prefix` or `prefix/*`); `company1` never matches
|
|
89
|
+
* `company12`. An undefined list shows everything; an explicit empty list
|
|
90
|
+
* shows nothing.
|
|
91
|
+
*/
|
|
92
|
+
export function buildVisibilityFilter(
|
|
93
|
+
allowed: string[] | undefined,
|
|
94
|
+
baseCwd: string,
|
|
95
|
+
): (peerCwd: string) => boolean {
|
|
96
|
+
if (allowed === undefined) return () => true;
|
|
97
|
+
if (allowed.length === 0) return () => false;
|
|
98
|
+
const prefixes = allowed.map((p) => normalizeAllowedPath(p, baseCwd));
|
|
99
|
+
return (peerCwd) =>
|
|
100
|
+
prefixes.some((prefix) => peerCwd === prefix || peerCwd.startsWith(`${prefix}/`));
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
/**
|
|
@@ -99,7 +118,6 @@ export function peerAskedFirst(
|
|
|
99
118
|
export class TalkCore {
|
|
100
119
|
private readonly storage: TalkStorage;
|
|
101
120
|
private readonly events: TalkCoreEvents;
|
|
102
|
-
private readonly collectResumableSessionIds?: () => Set<string>;
|
|
103
121
|
private readonly now: () => number;
|
|
104
122
|
|
|
105
123
|
private self: SessionRecord | undefined;
|
|
@@ -108,16 +126,20 @@ export class TalkCore {
|
|
|
108
126
|
private readonly watched = new Map<string, Presence>();
|
|
109
127
|
/** Message ids already handed to the adapter but not yet removed from the inbox. */
|
|
110
128
|
private readonly deliveredIds = new Set<string>();
|
|
129
|
+
/** Visibility gate over peer working directories; defaults to everything visible. */
|
|
130
|
+
private isPeerVisible: (peerCwd: string) => boolean = () => true;
|
|
131
|
+
/** Manually marked dead: heartbeat stopped, lastSeenAt pinned to 0. */
|
|
132
|
+
private dead = false;
|
|
111
133
|
|
|
112
134
|
private inboxPoll: ReturnType<typeof setInterval> | undefined;
|
|
113
135
|
private heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
114
136
|
private watchPoller: ReturnType<typeof setInterval> | undefined;
|
|
137
|
+
private sweeper: ReturnType<typeof setInterval> | undefined;
|
|
115
138
|
private lastDeliveryFailureAt = 0;
|
|
116
139
|
|
|
117
140
|
constructor(options: TalkCoreOptions) {
|
|
118
141
|
this.storage = options.storage;
|
|
119
142
|
this.events = options.events;
|
|
120
|
-
this.collectResumableSessionIds = options.collectResumableSessionIds;
|
|
121
143
|
this.now = options.now ?? Date.now;
|
|
122
144
|
}
|
|
123
145
|
|
|
@@ -125,6 +147,11 @@ export class TalkCore {
|
|
|
125
147
|
return this.self?.addr;
|
|
126
148
|
}
|
|
127
149
|
|
|
150
|
+
/** Replace the peer-visibility gate (from the session's `.pi/talk.json`). */
|
|
151
|
+
setPeerVisibility(filter: (peerCwd: string) => boolean): void {
|
|
152
|
+
this.isPeerVisible = filter;
|
|
153
|
+
}
|
|
154
|
+
|
|
128
155
|
private requireSelf(): SessionRecord {
|
|
129
156
|
const self = this.self;
|
|
130
157
|
if (!self) throw new Error("Talk core is not started");
|
|
@@ -138,11 +165,7 @@ export class TalkCore {
|
|
|
138
165
|
this.self = self;
|
|
139
166
|
await writeRecord(this.storage, self);
|
|
140
167
|
try {
|
|
141
|
-
|
|
142
|
-
const sessionExists = collectResumable
|
|
143
|
-
? (id: string) => collectResumable().has(id)
|
|
144
|
-
: undefined;
|
|
145
|
-
await sweep(this.storage, this.now(), sessionExists);
|
|
168
|
+
await sweep(this.storage, this.now());
|
|
146
169
|
} catch {
|
|
147
170
|
// sweep failure never breaks the session
|
|
148
171
|
}
|
|
@@ -151,6 +174,11 @@ export class TalkCore {
|
|
|
151
174
|
void this.writeSelf({});
|
|
152
175
|
}, HEARTBEAT_MS);
|
|
153
176
|
this.heartbeat.unref();
|
|
177
|
+
// Reclaim dead records periodically, not just at startup.
|
|
178
|
+
this.sweeper = setInterval(() => {
|
|
179
|
+
void sweep(this.storage, this.now());
|
|
180
|
+
}, SWEEP_INTERVAL_MS);
|
|
181
|
+
this.sweeper.unref();
|
|
154
182
|
// Drain mail queued while offline — deferred: delivering during
|
|
155
183
|
// session_start races the session's own first turn.
|
|
156
184
|
const initial = setTimeout(() => {
|
|
@@ -163,6 +191,7 @@ export class TalkCore {
|
|
|
163
191
|
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
164
192
|
if (this.watchPoller) clearInterval(this.watchPoller);
|
|
165
193
|
if (this.inboxPoll) clearInterval(this.inboxPoll);
|
|
194
|
+
if (this.sweeper) clearInterval(this.sweeper);
|
|
166
195
|
if (this.self) {
|
|
167
196
|
try {
|
|
168
197
|
await this.writeSelf({ status: "idle", offline: true });
|
|
@@ -186,7 +215,8 @@ export class TalkCore {
|
|
|
186
215
|
|
|
187
216
|
private async writeSelf(patch: Partial<SessionRecord>): Promise<void> {
|
|
188
217
|
if (!this.self) return;
|
|
189
|
-
|
|
218
|
+
// A dead session pins lastSeenAt to 0 so no later event re-freshens it.
|
|
219
|
+
this.self = { ...this.self, ...patch, lastSeenAt: this.dead ? 0 : this.now() };
|
|
190
220
|
try {
|
|
191
221
|
await writeRecord(this.storage, this.self);
|
|
192
222
|
} catch {
|
|
@@ -314,38 +344,18 @@ export class TalkCore {
|
|
|
314
344
|
|
|
315
345
|
// ── Outbound ───────────────────────────────────────────────────────────
|
|
316
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Resolve a target by its exact session id (uuid). A peer must be visible
|
|
349
|
+
* from this session (`setPeerVisibility`); invisible peers are unreachable
|
|
350
|
+
* even with a known id.
|
|
351
|
+
*/
|
|
317
352
|
private async resolveTarget(to: string): Promise<TargetResult> {
|
|
318
353
|
const self = this.requireSelf();
|
|
319
354
|
const records = await listRecords(this.storage);
|
|
320
|
-
const others = records.filter((r) => r.addr !== self.addr);
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
return {
|
|
325
|
-
ok: false,
|
|
326
|
-
error: refusalUnknown(
|
|
327
|
-
to,
|
|
328
|
-
others.map((r) => recordLabel(r)),
|
|
329
|
-
),
|
|
330
|
-
};
|
|
331
|
-
if (matches.length > 1)
|
|
332
|
-
return {
|
|
333
|
-
ok: false,
|
|
334
|
-
error: refusalAmbiguous(
|
|
335
|
-
to,
|
|
336
|
-
matches.map((r) => recordLabel(r)),
|
|
337
|
-
),
|
|
338
|
-
};
|
|
339
|
-
const record = matches[0];
|
|
340
|
-
if (!record)
|
|
341
|
-
return {
|
|
342
|
-
ok: false,
|
|
343
|
-
error: refusalUnknown(
|
|
344
|
-
to,
|
|
345
|
-
others.map((r) => recordLabel(r)),
|
|
346
|
-
),
|
|
347
|
-
};
|
|
348
|
-
return { ok: true, record };
|
|
355
|
+
const others = records.filter((r) => r.addr !== self.addr && this.isPeerVisible(r.cwd));
|
|
356
|
+
const target = others.find((r) => r.sessionId === to);
|
|
357
|
+
if (!target) return { ok: false, error: refusalUnknown(to) };
|
|
358
|
+
return { ok: true, record: target };
|
|
349
359
|
}
|
|
350
360
|
|
|
351
361
|
private async sendLetter(
|
|
@@ -459,20 +469,6 @@ export class TalkCore {
|
|
|
459
469
|
|
|
460
470
|
// ── Tool actions ───────────────────────────────────────────────────────
|
|
461
471
|
|
|
462
|
-
/** Actively read (and consume) inbox letters. Returns delivery-formatted text. */
|
|
463
|
-
async readMessages(from?: string): Promise<string> {
|
|
464
|
-
const self = this.requireSelf();
|
|
465
|
-
let items = await listInbox(this.storage, self.addr);
|
|
466
|
-
if (from && from !== "*") {
|
|
467
|
-
const resolved = await this.resolveTarget(from);
|
|
468
|
-
if (!resolved.ok) return resolved.error;
|
|
469
|
-
items = items.filter((i) => i.letter.from.addr === resolved.record.addr);
|
|
470
|
-
}
|
|
471
|
-
const fresh = await this.consumeFresh(items);
|
|
472
|
-
if (fresh.length === 0) return "No messages.";
|
|
473
|
-
return fresh.map((l) => formatDelivery(l)).join("\n\n");
|
|
474
|
-
}
|
|
475
|
-
|
|
476
472
|
/** Block until a message arrives (or timeout/abort). */
|
|
477
473
|
async wait(timeoutMs: number, signal?: AbortSignal): Promise<string> {
|
|
478
474
|
const self = this.requireSelf();
|
|
@@ -487,17 +483,71 @@ export class TalkCore {
|
|
|
487
483
|
}
|
|
488
484
|
}
|
|
489
485
|
|
|
490
|
-
|
|
486
|
+
/**
|
|
487
|
+
* JSON listing of visible peer sessions. Defaults to sessions whose
|
|
488
|
+
* heartbeat is fresh (within LIST_ACTIVE_MS); pass includeOffline to show
|
|
489
|
+
* every visible peer regardless of last contact.
|
|
490
|
+
*/
|
|
491
|
+
async list(includeOffline = false): Promise<string> {
|
|
491
492
|
const self = this.requireSelf();
|
|
493
|
+
const now = this.now();
|
|
494
|
+
const all = await listRecords(this.storage);
|
|
495
|
+
const records = all.filter(
|
|
496
|
+
(r) => this.isPeerVisible(r.cwd) && (includeOffline || now - r.lastSeenAt < LIST_ACTIVE_MS),
|
|
497
|
+
);
|
|
498
|
+
return formatListing(records, self.addr, (r) => presenceOf(r, now));
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Same as list(), filtered to one working directory. */
|
|
502
|
+
async listCwd(cwd: string, includeOffline = false): Promise<string> {
|
|
503
|
+
const self = this.requireSelf();
|
|
504
|
+
const now = this.now();
|
|
492
505
|
const records = await listRecords(this.storage);
|
|
493
|
-
|
|
506
|
+
const filtered = records.filter(
|
|
507
|
+
(r) =>
|
|
508
|
+
r.cwd === cwd &&
|
|
509
|
+
this.isPeerVisible(r.cwd) &&
|
|
510
|
+
(includeOffline || now - r.lastSeenAt < LIST_ACTIVE_MS),
|
|
511
|
+
);
|
|
512
|
+
return formatListing(filtered, self.addr, (r) => presenceOf(r, now));
|
|
494
513
|
}
|
|
495
514
|
|
|
496
|
-
|
|
515
|
+
/** Visible peer records (excluding self), e.g. for command completions. */
|
|
516
|
+
async listPeers(): Promise<SessionRecord[]> {
|
|
497
517
|
const self = this.requireSelf();
|
|
498
518
|
const records = await listRecords(this.storage);
|
|
499
|
-
|
|
500
|
-
|
|
519
|
+
return records.filter((r) => r.addr !== self.addr && this.isPeerVisible(r.cwd));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Mark a session as dead by pinning lastSeenAt to 0: it vanishes from the
|
|
524
|
+
* default listing and the next sweep reaps it (empty mailbox). Without a
|
|
525
|
+
* target, marks this session — its heartbeat is stopped and later
|
|
526
|
+
* writeSelf calls no longer refresh lastSeenAt.
|
|
527
|
+
*/
|
|
528
|
+
async markDead(target?: string): Promise<string> {
|
|
529
|
+
if (!target) {
|
|
530
|
+
this.dead = true;
|
|
531
|
+
if (this.heartbeat) {
|
|
532
|
+
clearInterval(this.heartbeat);
|
|
533
|
+
this.heartbeat = undefined;
|
|
534
|
+
}
|
|
535
|
+
await this.writeSelf({});
|
|
536
|
+
return "Marked this session as dead.";
|
|
537
|
+
}
|
|
538
|
+
const resolved = await this.resolveTarget(target);
|
|
539
|
+
if (!resolved.ok) return resolved.error;
|
|
540
|
+
await writeRecord(this.storage, { ...resolved.record, lastSeenAt: 0 });
|
|
541
|
+
return `Marked "${resolved.record.name}" as dead.`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** Mark every visible peer (except self) as dead. */
|
|
545
|
+
async markAllDead(): Promise<string> {
|
|
546
|
+
const peers = await this.listPeers();
|
|
547
|
+
for (const peer of peers) {
|
|
548
|
+
await writeRecord(this.storage, { ...peer, lastSeenAt: 0 });
|
|
549
|
+
}
|
|
550
|
+
return `Marked ${peers.length} session(s) as dead.`;
|
|
501
551
|
}
|
|
502
552
|
|
|
503
553
|
async send(to: string, body: string): Promise<string> {
|
|
@@ -510,6 +560,7 @@ export class TalkCore {
|
|
|
510
560
|
const records = await listRecords(this.storage);
|
|
511
561
|
const peers = records.filter((r) => {
|
|
512
562
|
if (r.addr === self.addr) return false;
|
|
563
|
+
if (!this.isPeerVisible(r.cwd)) return false;
|
|
513
564
|
return to === "*" ? true : r.cwd === self.cwd;
|
|
514
565
|
});
|
|
515
566
|
if (peers.length === 0) return "No other sessions to broadcast to.";
|
|
@@ -547,7 +598,7 @@ export class TalkCore {
|
|
|
547
598
|
const inbox = await listInbox(this.storage, self.addr);
|
|
548
599
|
const fromTarget = inbox.filter((item) => item.letter.from.addr === record.addr);
|
|
549
600
|
if (fromTarget.length > 0) {
|
|
550
|
-
return `You have ${fromTarget.length} unread message(s) from "${record.name}"
|
|
601
|
+
return `You have ${fromTarget.length} unread message(s) from "${record.name}" — reply before asking.`;
|
|
551
602
|
}
|
|
552
603
|
const sent = await this.sendLetter(record, "ask", body);
|
|
553
604
|
if (!sent.ok) return sent.error;
|
|
@@ -567,7 +618,7 @@ export class TalkCore {
|
|
|
567
618
|
async reply(replyTo: string, body: string): Promise<string> {
|
|
568
619
|
if (!body) return "reply requires 'message'.";
|
|
569
620
|
if (!replyTo) {
|
|
570
|
-
return "reply requires 'replyTo' (the ask/message id
|
|
621
|
+
return "reply requires 'replyTo' (the ask/message id, shown in the delivered message).";
|
|
571
622
|
}
|
|
572
623
|
const self = this.requireSelf();
|
|
573
624
|
const ask = await resolveAskByRef(this.storage, self.addr, replyTo);
|
package/src/talk/format.ts
CHANGED
|
@@ -30,25 +30,31 @@ export function formatDelivery(letter: Letter, now: number = Date.now()): string
|
|
|
30
30
|
return `${BOUNDARY_PREAMBLE}\n\n${header}:\n\n${letter.body}\n\n${meta}${hint}`;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** One peer session as the model sees it in a listing. `id` is the stable
|
|
34
|
+
* pi session uuid; `name` is the display name when one was set. */
|
|
35
|
+
export interface SessionListItem {
|
|
36
|
+
status: string;
|
|
37
|
+
work_dir: string;
|
|
38
|
+
id: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Machine-readable JSON listing of peer sessions (what the model sees). */
|
|
33
43
|
export function formatListing(
|
|
34
44
|
records: SessionRecord[],
|
|
35
45
|
selfAddr: string,
|
|
36
46
|
presence: (r: SessionRecord) => Presence,
|
|
37
47
|
): string {
|
|
38
48
|
const others = records.filter((r) => r.addr !== selfAddr);
|
|
39
|
-
if (others.length === 0) return "
|
|
40
|
-
const
|
|
49
|
+
if (others.length === 0) return "[]";
|
|
50
|
+
const items: SessionListItem[] = others.map((r) => {
|
|
41
51
|
const p = presence(r);
|
|
42
|
-
const
|
|
43
|
-
return
|
|
52
|
+
const status = p === "live" ? r.status : p === "stalled" ? "not responding" : "offline";
|
|
53
|
+
return { status, work_dir: r.cwd, id: r.sessionId, name: r.name };
|
|
44
54
|
});
|
|
45
|
-
return
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function refusalUnknown(to: string, reachable: string[]): string {
|
|
49
|
-
return `No session matches '${to}'. Reachable: ${reachable.length > 0 ? reachable.join(", ") : "(none)"}.`;
|
|
55
|
+
return JSON.stringify(items, null, 2);
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
export function
|
|
53
|
-
return `'${to}'
|
|
58
|
+
export function refusalUnknown(to: string): string {
|
|
59
|
+
return `Unknown session id '${to}'. Get session ids with talk-list-sessions.`;
|
|
54
60
|
}
|
package/src/talk/index.ts
CHANGED
|
@@ -17,8 +17,9 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
17
17
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
18
18
|
import { Type } from "typebox";
|
|
19
19
|
|
|
20
|
+
import { jsoncToJson } from "../lib/jsonc.js";
|
|
20
21
|
import { resolveHomePath } from "../lib/path.js";
|
|
21
|
-
import { TalkCore } from "./core.js";
|
|
22
|
+
import { buildVisibilityFilter, TalkCore } from "./core.js";
|
|
22
23
|
import { formatDelivery } from "./format.js";
|
|
23
24
|
import type { Letter } from "./mailbox.js";
|
|
24
25
|
import { deriveAddr, type SessionRecord } from "./registry.js";
|
|
@@ -28,7 +29,7 @@ const DELIVERY_TYPE = "talk:delivery";
|
|
|
28
29
|
const LIST_TYPE = "talk:list";
|
|
29
30
|
const NOTIFY_TYPE = "talk:notify";
|
|
30
31
|
|
|
31
|
-
const ASK_TIMEOUT_MS =
|
|
32
|
+
const ASK_TIMEOUT_MS = 30 * 60 * 1000;
|
|
32
33
|
|
|
33
34
|
function toolResult(text: string) {
|
|
34
35
|
return { content: [{ type: "text" as const, text }], details: {} };
|
|
@@ -80,48 +81,54 @@ interface DeliveryDetails {
|
|
|
80
81
|
replyTo?: string;
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
function collectResumableSessionIds(): Set<string> {
|
|
89
|
-
const ids = new Set<string>();
|
|
90
|
-
let dirs: string[];
|
|
84
|
+
/**
|
|
85
|
+
* Read the default sqlite path and delivery mode from global settings.json:
|
|
86
|
+
* `{ "talk": { "db_path": "...", "deliver": "steer" | "queue" } }`.
|
|
87
|
+
*/
|
|
88
|
+
function readTalkSettings(): { dbPath?: string; deliver?: "steer" | "queue" } {
|
|
91
89
|
try {
|
|
92
|
-
|
|
90
|
+
const raw = fs.readFileSync(path.join(getAgentDir(), "settings.json"), "utf8");
|
|
91
|
+
const parsed = JSON.parse(raw) as { talk?: { db_path?: unknown; deliver?: unknown } };
|
|
92
|
+
const talk = parsed.talk;
|
|
93
|
+
if (!talk) return {};
|
|
94
|
+
const dbPath = typeof talk.db_path === "string" ? talk.db_path : undefined;
|
|
95
|
+
const deliver = talk.deliver === "steer" || talk.deliver === "queue" ? talk.deliver : undefined;
|
|
96
|
+
return { dbPath, deliver };
|
|
93
97
|
} catch {
|
|
94
|
-
return
|
|
95
|
-
}
|
|
96
|
-
for (const dir of dirs) {
|
|
97
|
-
try {
|
|
98
|
-
for (const file of fs.readdirSync(path.join(getAgentDir(), "sessions", dir))) {
|
|
99
|
-
const match = /_([0-9a-f-]{36})\.jsonl$/.exec(file);
|
|
100
|
-
if (match) ids.add(match[1]);
|
|
101
|
-
}
|
|
102
|
-
} catch {
|
|
103
|
-
// skip unreadable dir
|
|
104
|
-
}
|
|
98
|
+
return {};
|
|
105
99
|
}
|
|
106
|
-
return ids;
|
|
107
100
|
}
|
|
108
101
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Read the workspace visibility config from `<cwd>/.pi/talk.json`:
|
|
104
|
+
* `{ "allowed": ["~/projects/company1/"] }`. Missing file/key → undefined
|
|
105
|
+
* (everything visible); an explicit `"allowed": []` hides every peer.
|
|
106
|
+
*/
|
|
107
|
+
function readWorkspaceTalkConfig(cwd: string): { allowed?: string[] } {
|
|
108
|
+
const configPath = path.join(cwd, ".pi", "talk.json");
|
|
111
109
|
try {
|
|
112
|
-
const raw = fs.readFileSync(
|
|
113
|
-
const parsed = JSON.parse(raw) as {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
110
|
+
const raw = fs.readFileSync(configPath, "utf8");
|
|
111
|
+
const parsed = JSON.parse(jsoncToJson(raw)) as { allowed?: unknown };
|
|
112
|
+
if (Array.isArray(parsed.allowed)) {
|
|
113
|
+
return { allowed: parsed.allowed.filter((p): p is string => typeof p === "string") };
|
|
114
|
+
}
|
|
115
|
+
return {};
|
|
116
|
+
} catch (error) {
|
|
117
|
+
// eslint-disable-next-line no-console -- config errors must be visible, not silent
|
|
118
|
+
console.error(`Warning: could not parse ${configPath}: ${String(error)}`);
|
|
119
|
+
return {};
|
|
117
120
|
}
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
export default function talk(pi: ExtensionAPI) {
|
|
121
|
-
const
|
|
124
|
+
const settings = readTalkSettings();
|
|
125
|
+
const configured = process.env.PI_TALK_DB ?? settings.dbPath;
|
|
122
126
|
const dbPath = configured
|
|
123
127
|
? resolveHomePath(configured, getAgentDir())
|
|
124
128
|
: path.join(getAgentDir(), "talk.db");
|
|
129
|
+
// "queue": deliver on the session's next natural turn without waking it;
|
|
130
|
+
// "steer": interrupt mid-run / wake an idle session immediately.
|
|
131
|
+
const deliverMode: "steer" | "queue" = settings.deliver ?? "queue";
|
|
125
132
|
const storage = new SqliteTalkStorage(dbPath);
|
|
126
133
|
|
|
127
134
|
let self: SessionRecord | undefined;
|
|
@@ -136,12 +143,13 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
136
143
|
...(letter.replyTo !== undefined && { replyTo: letter.replyTo }),
|
|
137
144
|
};
|
|
138
145
|
try {
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
// returns true, so a failure keeps it queued for a later poll.
|
|
146
|
+
// The core only removes the letter from the inbox after this returns
|
|
147
|
+
// true, so a failure keeps it queued for a later poll.
|
|
142
148
|
pi.sendMessage(
|
|
143
149
|
{ customType: DELIVERY_TYPE, content: formatDelivery(letter), display: true, details },
|
|
144
|
-
|
|
150
|
+
deliverMode === "steer"
|
|
151
|
+
? { triggerTurn: true, deliverAs: "steer" }
|
|
152
|
+
: { deliverAs: "nextTurn" },
|
|
145
153
|
);
|
|
146
154
|
return true;
|
|
147
155
|
} catch {
|
|
@@ -162,7 +170,6 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
162
170
|
);
|
|
163
171
|
},
|
|
164
172
|
},
|
|
165
|
-
collectResumableSessionIds,
|
|
166
173
|
});
|
|
167
174
|
|
|
168
175
|
function requireInit(): string | undefined {
|
|
@@ -186,6 +193,7 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
186
193
|
lastSeenAt: now,
|
|
187
194
|
status: "idle",
|
|
188
195
|
};
|
|
196
|
+
core.setPeerVisibility(buildVisibilityFilter(readWorkspaceTalkConfig(cwd).allowed, cwd));
|
|
189
197
|
void core.start(self);
|
|
190
198
|
});
|
|
191
199
|
|
|
@@ -205,33 +213,23 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
205
213
|
name: "talk-list-sessions",
|
|
206
214
|
label: "List Talk Sessions",
|
|
207
215
|
description:
|
|
208
|
-
"List other pi sessions
|
|
216
|
+
"List other pi sessions with a recent heartbeat (id, status, work_dir, name). Pass includeOffline to also list stale sessions.",
|
|
209
217
|
promptSnippet: "List other pi sessions on this machine",
|
|
210
218
|
parameters: Type.Object({
|
|
211
219
|
cwd: Type.Optional(Type.String({ description: "Only list sessions in this directory" })),
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const initError = requireInit();
|
|
215
|
-
if (initError) return toolResult(initError);
|
|
216
|
-
return toolResult(params.cwd ? await core.listCwd(params.cwd) : await core.list());
|
|
217
|
-
},
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
pi.registerTool({
|
|
221
|
-
name: "talk-read-messages",
|
|
222
|
-
label: "Read Talk Messages",
|
|
223
|
-
description:
|
|
224
|
-
"Read and consume messages from your talk inbox. Use this to actively check for incoming messages instead of waiting for a steer notification.",
|
|
225
|
-
promptSnippet: "Read incoming talk messages",
|
|
226
|
-
parameters: Type.Object({
|
|
227
|
-
from: Type.Optional(
|
|
228
|
-
Type.String({ description: "Only read messages from this session (name/address/@alias)" }),
|
|
220
|
+
includeOffline: Type.Optional(
|
|
221
|
+
Type.Boolean({ description: "Include sessions without a recent heartbeat" }),
|
|
229
222
|
),
|
|
230
223
|
}),
|
|
231
224
|
async execute(_toolCallId, params) {
|
|
232
225
|
const initError = requireInit();
|
|
233
226
|
if (initError) return toolResult(initError);
|
|
234
|
-
|
|
227
|
+
const includeOffline = params.includeOffline === true;
|
|
228
|
+
return toolResult(
|
|
229
|
+
params.cwd
|
|
230
|
+
? await core.listCwd(params.cwd, includeOffline)
|
|
231
|
+
: await core.list(includeOffline),
|
|
232
|
+
);
|
|
235
233
|
},
|
|
236
234
|
});
|
|
237
235
|
|
|
@@ -303,7 +301,7 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
303
301
|
name: "talk-reply",
|
|
304
302
|
label: "Reply Talk",
|
|
305
303
|
description:
|
|
306
|
-
"Reply to a received ask. `replyTo` is the ask/message id (shown in the delivered message
|
|
304
|
+
"Reply to a received ask. `replyTo` is the ask/message id (shown in the delivered message).",
|
|
307
305
|
promptSnippet: "Reply to a talk ask",
|
|
308
306
|
parameters: Type.Object({
|
|
309
307
|
replyTo: Type.String({ description: "The ask/message id to reply to" }),
|
|
@@ -316,7 +314,7 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
316
314
|
},
|
|
317
315
|
});
|
|
318
316
|
|
|
319
|
-
// ── /talk
|
|
317
|
+
// ── /talk commands ────────────────────────────────────────────────────
|
|
320
318
|
|
|
321
319
|
pi.registerCommand("talk", {
|
|
322
320
|
description: "List registered pi sessions",
|
|
@@ -326,6 +324,23 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
326
324
|
},
|
|
327
325
|
});
|
|
328
326
|
|
|
327
|
+
pi.registerCommand("talk-dead", {
|
|
328
|
+
description:
|
|
329
|
+
"Mark a talk session as dead (removed from listings, swept soon): no arg = this session, <sessionId> = that session, --all = every other visible session",
|
|
330
|
+
async handler(args) {
|
|
331
|
+
const initError = requireInit();
|
|
332
|
+
const trimmed = args.trim();
|
|
333
|
+
const text =
|
|
334
|
+
initError ??
|
|
335
|
+
(trimmed === "--all"
|
|
336
|
+
? await core.markAllDead()
|
|
337
|
+
: trimmed
|
|
338
|
+
? await core.markDead(trimmed)
|
|
339
|
+
: await core.markDead());
|
|
340
|
+
pi.sendMessage({ customType: LIST_TYPE, content: text, display: true });
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
|
|
329
344
|
// ── Delivery card ──────────────────────────────────────────────────────
|
|
330
345
|
|
|
331
346
|
pi.registerMessageRenderer<DeliveryDetails>(DELIVERY_TYPE, (message, _options, theme) => {
|
package/src/talk/registry.ts
CHANGED
|
@@ -38,6 +38,10 @@ export type SessionRecord = Static<typeof SessionRecordSchema>;
|
|
|
38
38
|
export type Presence = "live" | "stalled" | "offline";
|
|
39
39
|
|
|
40
40
|
export const HEARTBEAT_STALE_MS = 45_000;
|
|
41
|
+
/** A session is shown in the default listing while its heartbeat is this fresh. */
|
|
42
|
+
export const LIST_ACTIVE_MS = 15 * 60 * 1000;
|
|
43
|
+
/** Sweep leaves a record alone until its heartbeat has been quiet this long. */
|
|
44
|
+
export const SWEEP_OFFLINE_GRACE_MS = 24 * 60 * 60 * 1000;
|
|
41
45
|
/** A mailbox holding undelivered mail is kept this long after last contact. */
|
|
42
46
|
export const SWEEP_MAIL_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
|
|
43
47
|
|
|
@@ -118,29 +122,21 @@ export function presenceOf(record: SessionRecord, now: number = Date.now()): Pre
|
|
|
118
122
|
|
|
119
123
|
/**
|
|
120
124
|
* Reclaim dead sessions' data. Rules (mail outranks tidiness):
|
|
121
|
-
* - a
|
|
125
|
+
* - a record whose heartbeat went quiet less than SWEEP_OFFLINE_GRACE_MS ago
|
|
126
|
+
* is never touched — it may be merely down or suspended, and a resume will
|
|
127
|
+
* re-register it under the same id anyway;
|
|
122
128
|
* - a mailbox holding undelivered mail is kept for SWEEP_MAIL_KEEP_MS;
|
|
123
|
-
* -
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* discarded promptly.
|
|
127
|
-
*
|
|
128
|
-
* `sessionExists(sessionId)` reports whether pi can still resume the session
|
|
129
|
-
* (its session file is present). When omitted, every offline session is
|
|
130
|
-
* treated as resumable (the conservative choice).
|
|
129
|
+
* - once the grace period has passed, an empty mailbox is discarded promptly
|
|
130
|
+
* regardless of whether pi could still resume the session (resume re-creates
|
|
131
|
+
* the record; with no mail nothing is lost).
|
|
131
132
|
*/
|
|
132
|
-
export async function sweep(
|
|
133
|
-
storage: TalkStorage,
|
|
134
|
-
now: number = Date.now(),
|
|
135
|
-
sessionExists?: (sessionId: string) => boolean,
|
|
136
|
-
): Promise<void> {
|
|
133
|
+
export async function sweep(storage: TalkStorage, now: number = Date.now()): Promise<void> {
|
|
137
134
|
for (const record of await listRecords(storage)) {
|
|
138
|
-
|
|
135
|
+
const quietFor = now - record.lastSeenAt;
|
|
136
|
+
if (quietFor < SWEEP_OFFLINE_GRACE_MS) continue;
|
|
139
137
|
const hasMail =
|
|
140
138
|
(await storage.hasKeys(inboxNs(record.addr))) || (await storage.hasKeys(asksNs(record.addr)));
|
|
141
|
-
|
|
142
|
-
if (hasMail && !expired) continue;
|
|
143
|
-
if (!expired && (sessionExists?.(record.sessionId) ?? true)) continue;
|
|
139
|
+
if (hasMail && quietFor < SWEEP_MAIL_KEEP_MS) continue;
|
|
144
140
|
await storage.removeNamespace(inboxNs(record.addr));
|
|
145
141
|
await storage.removeNamespace(asksNs(record.addr));
|
|
146
142
|
await storage.removeKey(RECORDS_NS, recordKey(record.addr));
|