@trim21/personal-pi-extensions 0.0.194 → 0.0.197
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/bwrap/index.ts +3 -10
- package/src/lib/path.ts +28 -0
- package/src/talk/core.ts +79 -69
- package/src/talk/format.ts +17 -11
- package/src/talk/index.ts +53 -56
- package/src/talk/registry.ts +14 -18
- package/src/workspace-guard.ts +4 -15
package/README.md
CHANGED
|
@@ -230,23 +230,27 @@ 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/路径/前缀匹配。
|
|
243
244
|
|
|
244
245
|
### 关键设计
|
|
245
246
|
|
|
247
|
+
- **心跳即活跃**:每个 session 每 15s 写一次 `lastSeenAt`;`talk-list-sessions` 默认只显示最近 15 分钟内有心跳的 session,已结束/挂起的 session 自动从默认列表消失(`includeOffline: true` 可见全部)。`status` 用 45s 心跳阈值区分 live / not responding / offline。
|
|
248
|
+
- **定期清理**:心跳停止超过 24h 且无未投递 mail 的记录会被定期 sweep(30 分钟一次)回收;有 mail 的保留 30 天。resume 后 session 会自动重新注册,无 mail 即无损失。
|
|
246
249
|
- **投递成功才消费**:信件只在成功交给 `sendMessage` 后才从 inbox 删除,投递失败留在 inbox 下次重试——不会因 `sendMessage` 吞异常而静默丢信。
|
|
247
250
|
- **双向 ask 仲裁**:`talk-ask` 发起前先检查收件箱(有对方消息就先读/先回);阻塞等待期间若收到对方的 ask(而非 reply),按两个 ask 的 `ts` 字段仲裁——先 ask 者主导继续等,后 ask 者让位并先回复对方。`ts` 是信件内固定字段,双方读到同一对值,结论天然对称;同毫秒碰撞用 `session dir + session id` 字符串比较兜底。
|
|
248
251
|
- **typebox runtime 验证**:所有从存储读出的值经 TypeBox schema 校验,损坏/伪造数据被拒绝,不做 `as T` 强转。
|
|
249
252
|
- **安全**:纯文本 ≤32KB;10s 去重 / 30s 限速 8 条 / 50 积压上限(防环);每条投递带「来自其他 session、无权威」声明。
|
|
253
|
+
- **workspace 可见性**:每个 workspace 通过 `<cwd>/.pi/talk.json` 的 `allowed` 控制自己能看到哪些 session,见下方配置。
|
|
250
254
|
|
|
251
255
|
### 配置
|
|
252
256
|
|
|
@@ -256,17 +260,44 @@ sqlite 文件路径按优先级取第一个可用值:
|
|
|
256
260
|
2. global `~/.pi/agent/settings.json` 里的 `talk.db_path`
|
|
257
261
|
3. 默认 `~/.pi/agent/talk.db`
|
|
258
262
|
|
|
263
|
+
`db_path` 支持 `~` 展开(`~/…` → 用户主目录),相对路径相对 `~/.pi/agent` 解析;绝对路径原样使用。
|
|
264
|
+
|
|
259
265
|
```jsonc
|
|
260
266
|
// ~/.pi/agent/settings.json
|
|
261
267
|
{
|
|
262
|
-
"talk": { "db_path": "/
|
|
268
|
+
"talk": { "db_path": "~/data/talk.db", "deliver": "queue" },
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
`talk.deliver` 控制对端消息的投递方式:
|
|
273
|
+
|
|
274
|
+
- `"steer"`:消息到达时打断当前工作(工具调用间隙注入),空闲 session 被唤醒;
|
|
275
|
+
- `"queue"`:消息排队,在 session 下一轮自然 turn(如用户发消息)时注入,不主动唤醒。
|
|
276
|
+
|
|
277
|
+
默认 `"queue"`。
|
|
278
|
+
|
|
279
|
+
### workspace 可见性
|
|
280
|
+
|
|
281
|
+
每个 workspace 通过 `<cwd>/.pi/talk.json` 的 `allowed` 决定自己能看到哪些 session:
|
|
282
|
+
|
|
283
|
+
```jsonc
|
|
284
|
+
// ~/projects/company1/.pi/talk.json
|
|
285
|
+
{
|
|
286
|
+
"allowed": ["~/projects/company1/"],
|
|
263
287
|
}
|
|
264
288
|
```
|
|
265
289
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
290
|
+
- `allowed` 是路径前缀列表:能看到前缀本身及其子目录下的 session(`~/projects/company1/*`),看不到 `~/projects/company2/` 下的 session;`company1` 不会误匹配 `company12`
|
|
291
|
+
- 路径支持 `~` 展开,相对路径相对该 workspace 的 cwd 解析
|
|
292
|
+
- 无 `allowed` 字段 → 全部可见;`"allowed": []` → 谁都看不到
|
|
293
|
+
- 可见性单向生效:A 的配置只决定 A 能看到谁,不影响 B
|
|
294
|
+
- 不可见的 session 不仅 list 不到,也无法 `talk-send` / `talk-ask` 寻址(即使知道 id 也会被拒绝)
|
|
295
|
+
|
|
296
|
+
| 变量 | 默认 | 含义 |
|
|
297
|
+
| ----------------- | --------------------------------- | ------------------------------- |
|
|
298
|
+
| `PI_TALK_DB` | settings 或 `~/.pi/agent/talk.db` | SQLite 邮箱数据库路径 |
|
|
299
|
+
| `PI_TALK_INBOUND` | `accept` | `refuse` 时丢弃所有 peer 消息 |
|
|
300
|
+
| `talk.deliver` | `queue` | 消息投递方式:`steer` / `queue` |
|
|
270
301
|
|
|
271
302
|
### 使用
|
|
272
303
|
|
package/package.json
CHANGED
package/src/bwrap/index.ts
CHANGED
|
@@ -67,6 +67,8 @@ import { type BashOperations, createBashTool, getAgentDir } from "@earendil-work
|
|
|
67
67
|
import { Type } from "typebox";
|
|
68
68
|
import { Value } from "typebox/value";
|
|
69
69
|
|
|
70
|
+
import { expandHome } from "../lib/path.js";
|
|
71
|
+
|
|
70
72
|
const SANDBOX_PROMPT = `
|
|
71
73
|
## Command Execution
|
|
72
74
|
You are running inside a sandbox.
|
|
@@ -232,17 +234,8 @@ function deepMerge(base: BwrapConfig, overrides: Partial<BwrapConfig>): BwrapCon
|
|
|
232
234
|
};
|
|
233
235
|
}
|
|
234
236
|
|
|
235
|
-
function expandPath(p: string): string {
|
|
236
|
-
if (p.startsWith("~/")) {
|
|
237
|
-
const home = process.env.HOME;
|
|
238
|
-
if (home === undefined) return p;
|
|
239
|
-
return join(home, p.slice(2));
|
|
240
|
-
}
|
|
241
|
-
return p;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
237
|
function resolvePath(p: string, cwd: string): string {
|
|
245
|
-
const expanded =
|
|
238
|
+
const expanded = expandHome(p);
|
|
246
239
|
if (expanded === ".") return cwd;
|
|
247
240
|
return expanded;
|
|
248
241
|
}
|
package/src/lib/path.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared path helpers used by multiple extensions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Expand a leading `~` to the user's home directory.
|
|
10
|
+
* - `~` → home
|
|
11
|
+
* - `~/rest` → `home/rest`
|
|
12
|
+
* - anything else (including `~user`) is left untouched.
|
|
13
|
+
*/
|
|
14
|
+
export function expandHome(p: string): string {
|
|
15
|
+
if (p === "~") return homedir();
|
|
16
|
+
if (p.startsWith("~/")) return join(homedir(), p.slice(2));
|
|
17
|
+
return p;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a path that may start with `~` (expanded to home) and may be
|
|
22
|
+
* relative (resolved against `baseDir`). Absolute paths are returned
|
|
23
|
+
* normalized.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveHomePath(p: string, baseDir: string): string {
|
|
26
|
+
const expanded = expandHome(p);
|
|
27
|
+
return isAbsolute(expanded) ? resolve(expanded) : resolve(baseDir, expanded);
|
|
28
|
+
}
|
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,18 @@ 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;
|
|
111
131
|
|
|
112
132
|
private inboxPoll: ReturnType<typeof setInterval> | undefined;
|
|
113
133
|
private heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
114
134
|
private watchPoller: ReturnType<typeof setInterval> | undefined;
|
|
135
|
+
private sweeper: ReturnType<typeof setInterval> | undefined;
|
|
115
136
|
private lastDeliveryFailureAt = 0;
|
|
116
137
|
|
|
117
138
|
constructor(options: TalkCoreOptions) {
|
|
118
139
|
this.storage = options.storage;
|
|
119
140
|
this.events = options.events;
|
|
120
|
-
this.collectResumableSessionIds = options.collectResumableSessionIds;
|
|
121
141
|
this.now = options.now ?? Date.now;
|
|
122
142
|
}
|
|
123
143
|
|
|
@@ -125,6 +145,11 @@ export class TalkCore {
|
|
|
125
145
|
return this.self?.addr;
|
|
126
146
|
}
|
|
127
147
|
|
|
148
|
+
/** Replace the peer-visibility gate (from the session's `.pi/talk.json`). */
|
|
149
|
+
setPeerVisibility(filter: (peerCwd: string) => boolean): void {
|
|
150
|
+
this.isPeerVisible = filter;
|
|
151
|
+
}
|
|
152
|
+
|
|
128
153
|
private requireSelf(): SessionRecord {
|
|
129
154
|
const self = this.self;
|
|
130
155
|
if (!self) throw new Error("Talk core is not started");
|
|
@@ -138,11 +163,7 @@ export class TalkCore {
|
|
|
138
163
|
this.self = self;
|
|
139
164
|
await writeRecord(this.storage, self);
|
|
140
165
|
try {
|
|
141
|
-
|
|
142
|
-
const sessionExists = collectResumable
|
|
143
|
-
? (id: string) => collectResumable().has(id)
|
|
144
|
-
: undefined;
|
|
145
|
-
await sweep(this.storage, this.now(), sessionExists);
|
|
166
|
+
await sweep(this.storage, this.now());
|
|
146
167
|
} catch {
|
|
147
168
|
// sweep failure never breaks the session
|
|
148
169
|
}
|
|
@@ -151,6 +172,11 @@ export class TalkCore {
|
|
|
151
172
|
void this.writeSelf({});
|
|
152
173
|
}, HEARTBEAT_MS);
|
|
153
174
|
this.heartbeat.unref();
|
|
175
|
+
// Reclaim dead records periodically, not just at startup.
|
|
176
|
+
this.sweeper = setInterval(() => {
|
|
177
|
+
void sweep(this.storage, this.now());
|
|
178
|
+
}, SWEEP_INTERVAL_MS);
|
|
179
|
+
this.sweeper.unref();
|
|
154
180
|
// Drain mail queued while offline — deferred: delivering during
|
|
155
181
|
// session_start races the session's own first turn.
|
|
156
182
|
const initial = setTimeout(() => {
|
|
@@ -163,6 +189,7 @@ export class TalkCore {
|
|
|
163
189
|
if (this.heartbeat) clearInterval(this.heartbeat);
|
|
164
190
|
if (this.watchPoller) clearInterval(this.watchPoller);
|
|
165
191
|
if (this.inboxPoll) clearInterval(this.inboxPoll);
|
|
192
|
+
if (this.sweeper) clearInterval(this.sweeper);
|
|
166
193
|
if (this.self) {
|
|
167
194
|
try {
|
|
168
195
|
await this.writeSelf({ status: "idle", offline: true });
|
|
@@ -314,38 +341,18 @@ export class TalkCore {
|
|
|
314
341
|
|
|
315
342
|
// ── Outbound ───────────────────────────────────────────────────────────
|
|
316
343
|
|
|
344
|
+
/**
|
|
345
|
+
* Resolve a target by its exact session id (uuid). A peer must be visible
|
|
346
|
+
* from this session (`setPeerVisibility`); invisible peers are unreachable
|
|
347
|
+
* even with a known id.
|
|
348
|
+
*/
|
|
317
349
|
private async resolveTarget(to: string): Promise<TargetResult> {
|
|
318
350
|
const self = this.requireSelf();
|
|
319
351
|
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 };
|
|
352
|
+
const others = records.filter((r) => r.addr !== self.addr && this.isPeerVisible(r.cwd));
|
|
353
|
+
const target = others.find((r) => r.sessionId === to);
|
|
354
|
+
if (!target) return { ok: false, error: refusalUnknown(to) };
|
|
355
|
+
return { ok: true, record: target };
|
|
349
356
|
}
|
|
350
357
|
|
|
351
358
|
private async sendLetter(
|
|
@@ -459,20 +466,6 @@ export class TalkCore {
|
|
|
459
466
|
|
|
460
467
|
// ── Tool actions ───────────────────────────────────────────────────────
|
|
461
468
|
|
|
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
469
|
/** Block until a message arrives (or timeout/abort). */
|
|
477
470
|
async wait(timeoutMs: number, signal?: AbortSignal): Promise<string> {
|
|
478
471
|
const self = this.requireSelf();
|
|
@@ -487,17 +480,33 @@ export class TalkCore {
|
|
|
487
480
|
}
|
|
488
481
|
}
|
|
489
482
|
|
|
490
|
-
|
|
483
|
+
/**
|
|
484
|
+
* JSON listing of visible peer sessions. Defaults to sessions whose
|
|
485
|
+
* heartbeat is fresh (within LIST_ACTIVE_MS); pass includeOffline to show
|
|
486
|
+
* every visible peer regardless of last contact.
|
|
487
|
+
*/
|
|
488
|
+
async list(includeOffline = false): Promise<string> {
|
|
491
489
|
const self = this.requireSelf();
|
|
492
|
-
const
|
|
493
|
-
|
|
490
|
+
const now = this.now();
|
|
491
|
+
const all = await listRecords(this.storage);
|
|
492
|
+
const records = all.filter(
|
|
493
|
+
(r) => this.isPeerVisible(r.cwd) && (includeOffline || now - r.lastSeenAt < LIST_ACTIVE_MS),
|
|
494
|
+
);
|
|
495
|
+
return formatListing(records, self.addr, (r) => presenceOf(r, now));
|
|
494
496
|
}
|
|
495
497
|
|
|
496
|
-
|
|
498
|
+
/** Same as list(), filtered to one working directory. */
|
|
499
|
+
async listCwd(cwd: string, includeOffline = false): Promise<string> {
|
|
497
500
|
const self = this.requireSelf();
|
|
501
|
+
const now = this.now();
|
|
498
502
|
const records = await listRecords(this.storage);
|
|
499
|
-
const filtered = records.filter(
|
|
500
|
-
|
|
503
|
+
const filtered = records.filter(
|
|
504
|
+
(r) =>
|
|
505
|
+
r.cwd === cwd &&
|
|
506
|
+
this.isPeerVisible(r.cwd) &&
|
|
507
|
+
(includeOffline || now - r.lastSeenAt < LIST_ACTIVE_MS),
|
|
508
|
+
);
|
|
509
|
+
return formatListing(filtered, self.addr, (r) => presenceOf(r, now));
|
|
501
510
|
}
|
|
502
511
|
|
|
503
512
|
async send(to: string, body: string): Promise<string> {
|
|
@@ -510,6 +519,7 @@ export class TalkCore {
|
|
|
510
519
|
const records = await listRecords(this.storage);
|
|
511
520
|
const peers = records.filter((r) => {
|
|
512
521
|
if (r.addr === self.addr) return false;
|
|
522
|
+
if (!this.isPeerVisible(r.cwd)) return false;
|
|
513
523
|
return to === "*" ? true : r.cwd === self.cwd;
|
|
514
524
|
});
|
|
515
525
|
if (peers.length === 0) return "No other sessions to broadcast to.";
|
|
@@ -547,7 +557,7 @@ export class TalkCore {
|
|
|
547
557
|
const inbox = await listInbox(this.storage, self.addr);
|
|
548
558
|
const fromTarget = inbox.filter((item) => item.letter.from.addr === record.addr);
|
|
549
559
|
if (fromTarget.length > 0) {
|
|
550
|
-
return `You have ${fromTarget.length} unread message(s) from "${record.name}"
|
|
560
|
+
return `You have ${fromTarget.length} unread message(s) from "${record.name}" — reply before asking.`;
|
|
551
561
|
}
|
|
552
562
|
const sent = await this.sendLetter(record, "ask", body);
|
|
553
563
|
if (!sent.ok) return sent.error;
|
|
@@ -567,7 +577,7 @@ export class TalkCore {
|
|
|
567
577
|
async reply(replyTo: string, body: string): Promise<string> {
|
|
568
578
|
if (!body) return "reply requires 'message'.";
|
|
569
579
|
if (!replyTo) {
|
|
570
|
-
return "reply requires 'replyTo' (the ask/message id
|
|
580
|
+
return "reply requires 'replyTo' (the ask/message id, shown in the delivered message).";
|
|
571
581
|
}
|
|
572
582
|
const self = this.requireSelf();
|
|
573
583
|
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,7 +17,8 @@ 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 {
|
|
20
|
+
import { resolveHomePath } from "../lib/path.js";
|
|
21
|
+
import { buildVisibilityFilter, TalkCore } from "./core.js";
|
|
21
22
|
import { formatDelivery } from "./format.js";
|
|
22
23
|
import type { Letter } from "./mailbox.js";
|
|
23
24
|
import { deriveAddr, type SessionRecord } from "./registry.js";
|
|
@@ -27,7 +28,7 @@ const DELIVERY_TYPE = "talk:delivery";
|
|
|
27
28
|
const LIST_TYPE = "talk:list";
|
|
28
29
|
const NOTIFY_TYPE = "talk:notify";
|
|
29
30
|
|
|
30
|
-
const ASK_TIMEOUT_MS =
|
|
31
|
+
const ASK_TIMEOUT_MS = 30 * 60 * 1000;
|
|
31
32
|
|
|
32
33
|
function toolResult(text: string) {
|
|
33
34
|
return { content: [{ type: "text" as const, text }], details: {} };
|
|
@@ -79,46 +80,51 @@ interface DeliveryDetails {
|
|
|
79
80
|
replyTo?: string;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
function collectResumableSessionIds(): Set<string> {
|
|
88
|
-
const ids = new Set<string>();
|
|
89
|
-
let dirs: string[];
|
|
83
|
+
/**
|
|
84
|
+
* Read the default sqlite path and delivery mode from global settings.json:
|
|
85
|
+
* `{ "talk": { "db_path": "...", "deliver": "steer" | "queue" } }`.
|
|
86
|
+
*/
|
|
87
|
+
function readTalkSettings(): { dbPath?: string; deliver?: "steer" | "queue" } {
|
|
90
88
|
try {
|
|
91
|
-
|
|
89
|
+
const raw = fs.readFileSync(path.join(getAgentDir(), "settings.json"), "utf8");
|
|
90
|
+
const parsed = JSON.parse(raw) as { talk?: { db_path?: unknown; deliver?: unknown } };
|
|
91
|
+
const talk = parsed.talk;
|
|
92
|
+
if (!talk) return {};
|
|
93
|
+
const dbPath = typeof talk.db_path === "string" ? talk.db_path : undefined;
|
|
94
|
+
const deliver = talk.deliver === "steer" || talk.deliver === "queue" ? talk.deliver : undefined;
|
|
95
|
+
return { dbPath, deliver };
|
|
92
96
|
} catch {
|
|
93
|
-
return
|
|
97
|
+
return {};
|
|
94
98
|
}
|
|
95
|
-
for (const dir of dirs) {
|
|
96
|
-
try {
|
|
97
|
-
for (const file of fs.readdirSync(path.join(getAgentDir(), "sessions", dir))) {
|
|
98
|
-
const match = /_([0-9a-f-]{36})\.jsonl$/.exec(file);
|
|
99
|
-
if (match) ids.add(match[1]);
|
|
100
|
-
}
|
|
101
|
-
} catch {
|
|
102
|
-
// skip unreadable dir
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return ids;
|
|
106
99
|
}
|
|
107
100
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Read the workspace visibility config from `<cwd>/.pi/talk.json`:
|
|
103
|
+
* `{ "allowed": ["~/projects/company1/"] }`. Missing file/key → undefined
|
|
104
|
+
* (everything visible); an explicit `"allowed": []` hides every peer.
|
|
105
|
+
*/
|
|
106
|
+
function readWorkspaceTalkConfig(cwd: string): { allowed?: string[] } {
|
|
110
107
|
try {
|
|
111
|
-
const raw = fs.readFileSync(path.join(
|
|
112
|
-
const parsed = JSON.parse(raw) as {
|
|
113
|
-
|
|
108
|
+
const raw = fs.readFileSync(path.join(cwd, ".pi", "talk.json"), "utf8");
|
|
109
|
+
const parsed = JSON.parse(raw) as { allowed?: unknown };
|
|
110
|
+
if (Array.isArray(parsed.allowed)) {
|
|
111
|
+
return { allowed: parsed.allowed.filter((p): p is string => typeof p === "string") };
|
|
112
|
+
}
|
|
113
|
+
return {};
|
|
114
114
|
} catch {
|
|
115
|
-
return
|
|
115
|
+
return {};
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
|
|
119
119
|
export default function talk(pi: ExtensionAPI) {
|
|
120
|
-
const
|
|
121
|
-
|
|
120
|
+
const settings = readTalkSettings();
|
|
121
|
+
const configured = process.env.PI_TALK_DB ?? settings.dbPath;
|
|
122
|
+
const dbPath = configured
|
|
123
|
+
? resolveHomePath(configured, getAgentDir())
|
|
124
|
+
: path.join(getAgentDir(), "talk.db");
|
|
125
|
+
// "queue": deliver on the session's next natural turn without waking it;
|
|
126
|
+
// "steer": interrupt mid-run / wake an idle session immediately.
|
|
127
|
+
const deliverMode: "steer" | "queue" = settings.deliver ?? "queue";
|
|
122
128
|
const storage = new SqliteTalkStorage(dbPath);
|
|
123
129
|
|
|
124
130
|
let self: SessionRecord | undefined;
|
|
@@ -133,12 +139,13 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
133
139
|
...(letter.replyTo !== undefined && { replyTo: letter.replyTo }),
|
|
134
140
|
};
|
|
135
141
|
try {
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
// returns true, so a failure keeps it queued for a later poll.
|
|
142
|
+
// The core only removes the letter from the inbox after this returns
|
|
143
|
+
// true, so a failure keeps it queued for a later poll.
|
|
139
144
|
pi.sendMessage(
|
|
140
145
|
{ customType: DELIVERY_TYPE, content: formatDelivery(letter), display: true, details },
|
|
141
|
-
|
|
146
|
+
deliverMode === "steer"
|
|
147
|
+
? { triggerTurn: true, deliverAs: "steer" }
|
|
148
|
+
: { deliverAs: "nextTurn" },
|
|
142
149
|
);
|
|
143
150
|
return true;
|
|
144
151
|
} catch {
|
|
@@ -159,7 +166,6 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
159
166
|
);
|
|
160
167
|
},
|
|
161
168
|
},
|
|
162
|
-
collectResumableSessionIds,
|
|
163
169
|
});
|
|
164
170
|
|
|
165
171
|
function requireInit(): string | undefined {
|
|
@@ -183,6 +189,7 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
183
189
|
lastSeenAt: now,
|
|
184
190
|
status: "idle",
|
|
185
191
|
};
|
|
192
|
+
core.setPeerVisibility(buildVisibilityFilter(readWorkspaceTalkConfig(cwd).allowed, cwd));
|
|
186
193
|
void core.start(self);
|
|
187
194
|
});
|
|
188
195
|
|
|
@@ -202,33 +209,23 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
202
209
|
name: "talk-list-sessions",
|
|
203
210
|
label: "List Talk Sessions",
|
|
204
211
|
description:
|
|
205
|
-
"List other pi sessions
|
|
212
|
+
"List other pi sessions with a recent heartbeat (id, status, work_dir, name). Pass includeOffline to also list stale sessions.",
|
|
206
213
|
promptSnippet: "List other pi sessions on this machine",
|
|
207
214
|
parameters: Type.Object({
|
|
208
215
|
cwd: Type.Optional(Type.String({ description: "Only list sessions in this directory" })),
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const initError = requireInit();
|
|
212
|
-
if (initError) return toolResult(initError);
|
|
213
|
-
return toolResult(params.cwd ? await core.listCwd(params.cwd) : await core.list());
|
|
214
|
-
},
|
|
215
|
-
});
|
|
216
|
-
|
|
217
|
-
pi.registerTool({
|
|
218
|
-
name: "talk-read-messages",
|
|
219
|
-
label: "Read Talk Messages",
|
|
220
|
-
description:
|
|
221
|
-
"Read and consume messages from your talk inbox. Use this to actively check for incoming messages instead of waiting for a steer notification.",
|
|
222
|
-
promptSnippet: "Read incoming talk messages",
|
|
223
|
-
parameters: Type.Object({
|
|
224
|
-
from: Type.Optional(
|
|
225
|
-
Type.String({ description: "Only read messages from this session (name/address/@alias)" }),
|
|
216
|
+
includeOffline: Type.Optional(
|
|
217
|
+
Type.Boolean({ description: "Include sessions without a recent heartbeat" }),
|
|
226
218
|
),
|
|
227
219
|
}),
|
|
228
220
|
async execute(_toolCallId, params) {
|
|
229
221
|
const initError = requireInit();
|
|
230
222
|
if (initError) return toolResult(initError);
|
|
231
|
-
|
|
223
|
+
const includeOffline = params.includeOffline === true;
|
|
224
|
+
return toolResult(
|
|
225
|
+
params.cwd
|
|
226
|
+
? await core.listCwd(params.cwd, includeOffline)
|
|
227
|
+
: await core.list(includeOffline),
|
|
228
|
+
);
|
|
232
229
|
},
|
|
233
230
|
});
|
|
234
231
|
|
|
@@ -300,7 +297,7 @@ export default function talk(pi: ExtensionAPI) {
|
|
|
300
297
|
name: "talk-reply",
|
|
301
298
|
label: "Reply Talk",
|
|
302
299
|
description:
|
|
303
|
-
"Reply to a received ask. `replyTo` is the ask/message id (shown in the delivered message
|
|
300
|
+
"Reply to a received ask. `replyTo` is the ask/message id (shown in the delivered message).",
|
|
304
301
|
promptSnippet: "Reply to a talk ask",
|
|
305
302
|
parameters: Type.Object({
|
|
306
303
|
replyTo: Type.String({ description: "The ask/message id to reply to" }),
|
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));
|
package/src/workspace-guard.ts
CHANGED
|
@@ -13,8 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { readFile } from "node:fs/promises";
|
|
16
|
-
import {
|
|
17
|
-
import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
16
|
+
import { basename, isAbsolute, relative, sep } from "node:path";
|
|
18
17
|
|
|
19
18
|
import {
|
|
20
19
|
type ExtensionAPI,
|
|
@@ -22,6 +21,7 @@ import {
|
|
|
22
21
|
type ToolCallEvent,
|
|
23
22
|
} from "@earendil-works/pi-coding-agent";
|
|
24
23
|
|
|
24
|
+
import { resolveHomePath } from "./lib/path.js";
|
|
25
25
|
import { normalizeForEdit, replace } from "./opencode-edit-engine.js";
|
|
26
26
|
|
|
27
27
|
const WRITE_TOOLS = new Set(["write", "edit"]);
|
|
@@ -37,17 +37,6 @@ export function getWriteTarget(input: ToolCallEvent["input"]): string | undefine
|
|
|
37
37
|
return undefined;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
function resolvePath(filePath: string, cwd: string): string {
|
|
41
|
-
let p = filePath;
|
|
42
|
-
if (p.startsWith("~")) {
|
|
43
|
-
const rest = p.slice(1);
|
|
44
|
-
if (rest === "" || rest.startsWith("/")) {
|
|
45
|
-
p = join(homedir(), rest.slice(1));
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return isAbsolute(p) ? resolve(p) : resolve(cwd, p);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
40
|
function isInside(dir: string, filePath: string): boolean {
|
|
52
41
|
const rel = relative(dir, filePath);
|
|
53
42
|
return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
@@ -57,7 +46,7 @@ function isPathAllowed(resolvedPath: string, cwd: string): boolean {
|
|
|
57
46
|
if (isInside(cwd, resolvedPath)) return true;
|
|
58
47
|
|
|
59
48
|
for (const allowed of ALWAYS_ALLOW) {
|
|
60
|
-
const resolvedAllowed =
|
|
49
|
+
const resolvedAllowed = resolveHomePath(allowed, cwd);
|
|
61
50
|
if (isInside(resolvedAllowed, resolvedPath)) return true;
|
|
62
51
|
}
|
|
63
52
|
|
|
@@ -214,7 +203,7 @@ export default function workspaceGuard(pi: ExtensionAPI) {
|
|
|
214
203
|
const rawPath = getWriteTarget(event.input);
|
|
215
204
|
if (!rawPath) return;
|
|
216
205
|
|
|
217
|
-
const resolved =
|
|
206
|
+
const resolved = resolveHomePath(rawPath, ctx.cwd);
|
|
218
207
|
|
|
219
208
|
if (isPathAllowed(resolved, ctx.cwd)) return;
|
|
220
209
|
|