@trim21/personal-pi-extensions 0.0.192 → 0.0.194
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 +64 -1
- package/package.json +4 -3
- package/src/opencode-edit-engine.ts +3 -2
- package/src/opencode-edit.ts +41 -3
- package/src/opencode-read.ts +91 -122
- package/src/{todowrite.ts → opencode-todo.ts} +6 -0
- package/src/opencode-write.ts +53 -3
- package/src/question.ts +5 -0
- package/src/talk/core.ts +630 -0
- package/src/talk/format.ts +54 -0
- package/src/talk/index.ts +351 -0
- package/src/talk/mailbox.ts +306 -0
- package/src/talk/policy.ts +84 -0
- package/src/talk/registry.ts +148 -0
- package/src/talk/storage.ts +142 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop-breaking and inbound policy. Core layer, pi-free.
|
|
3
|
+
*
|
|
4
|
+
* A loop between two agents must terminate independently of what either
|
|
5
|
+
* model decides: repeats, rate, and backlog are all capped here, at the
|
|
6
|
+
* transport, not in the prompts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { MAX_BODY_CHARS } from "./mailbox.js";
|
|
10
|
+
|
|
11
|
+
export const DEDUPE_WINDOW_MS = 10_000;
|
|
12
|
+
export const RATE_LIMIT_MAX = 8;
|
|
13
|
+
export const RATE_LIMIT_WINDOW_MS = 30_000;
|
|
14
|
+
export const BACKLOG_CAP = 50;
|
|
15
|
+
|
|
16
|
+
export type OutboundVerdict = { ok: true } | { ok: false; reason: string };
|
|
17
|
+
|
|
18
|
+
export class OutboundPolicy {
|
|
19
|
+
private readonly sentAt: number[] = [];
|
|
20
|
+
private readonly recentBodies = new Map<string, number>();
|
|
21
|
+
private readonly now: () => number;
|
|
22
|
+
|
|
23
|
+
constructor(now: () => number = Date.now) {
|
|
24
|
+
this.now = now;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Gate one outbound letter. `unreadBacklog` is the target's current
|
|
29
|
+
* unread count (report 0 when the target is idle — an idle agent has by
|
|
30
|
+
* definition worked through what it was handed). `target` scopes the
|
|
31
|
+
* identical-body dedupe to a single peer (loop-breaking) so a broadcast
|
|
32
|
+
* of one body to N peers is not deduped after the first.
|
|
33
|
+
*/
|
|
34
|
+
check(body: string, unreadBacklog: number, target?: string): OutboundVerdict {
|
|
35
|
+
if (body.length > MAX_BODY_CHARS) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
reason: `Message is ${body.length} chars; cap is ${MAX_BODY_CHARS}. Send a summary and a path, not a payload.`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
if (unreadBacklog >= BACKLOG_CAP) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
reason: `Peer has ${unreadBacklog} unread messages (cap ${BACKLOG_CAP}); wait for it to drain.`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const now = this.now();
|
|
48
|
+
const dedupeKey = `${body}\u0000${target ?? ""}`;
|
|
49
|
+
const lastSame = this.recentBodies.get(dedupeKey);
|
|
50
|
+
if (lastSame !== undefined && now - lastSame < DEDUPE_WINDOW_MS) {
|
|
51
|
+
return {
|
|
52
|
+
ok: false,
|
|
53
|
+
reason: "Identical message to the same peer less than 10s ago — dropped to break loops.",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
|
57
|
+
while (this.sentAt.length > 0 && this.sentAt[0] < windowStart) this.sentAt.shift();
|
|
58
|
+
if (this.sentAt.length >= RATE_LIMIT_MAX) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
reason: `Rate limited: ${RATE_LIMIT_MAX} messages per ${RATE_LIMIT_WINDOW_MS / 1000}s per session.`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return { ok: true };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Record a successful send so dedupe/rate state stays current. */
|
|
68
|
+
recordSend(body: string, target?: string): void {
|
|
69
|
+
this.sentAt.push(this.now());
|
|
70
|
+
this.recentBodies.set(`${body}\u0000${target ?? ""}`, this.now());
|
|
71
|
+
// bound the dedupe map
|
|
72
|
+
if (this.recentBodies.size > 200) {
|
|
73
|
+
const cutoff = this.now() - DEDUPE_WINDOW_MS;
|
|
74
|
+
for (const [key, ts] of this.recentBodies) {
|
|
75
|
+
if (ts < cutoff) this.recentBodies.delete(key);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Inbound guard: PI_TALK_INBOUND=refuse drops all peer mail. */
|
|
82
|
+
export function inboundAccepts(env: string | undefined = process.env.PI_TALK_INBOUND): boolean {
|
|
83
|
+
return env !== "refuse";
|
|
84
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session registry for the talk mailbox: who is around, where, and whether
|
|
3
|
+
* they are reachable. Core layer — depends only on TalkStorage, never on pi.
|
|
4
|
+
*
|
|
5
|
+
* Design:
|
|
6
|
+
* - An address belongs to a conversation, not a process: hash of cwd + pi
|
|
7
|
+
* session id, so a resumed session (`pi -c`) answers to the same address and
|
|
8
|
+
* two sessions on one directory never share an inbox.
|
|
9
|
+
* - A record outlives the process that wrote it — that's what makes a session
|
|
10
|
+
* addressable while it's down (mail waits on disk).
|
|
11
|
+
* - Presence is a pid PLUS a heartbeat: pid alone can't tell wedged from
|
|
12
|
+
* healthy (and pids get reused); heartbeat alone can't tell crash from pause.
|
|
13
|
+
* - Listing has NO side effects.
|
|
14
|
+
*
|
|
15
|
+
* All values read from storage are validated with TypeBox before use.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
|
|
20
|
+
import { type Static, Type } from "typebox";
|
|
21
|
+
import { Value } from "typebox/value";
|
|
22
|
+
|
|
23
|
+
import type { TalkStorage } from "./storage.js";
|
|
24
|
+
|
|
25
|
+
export const SessionRecordSchema = Type.Object({
|
|
26
|
+
addr: Type.String(),
|
|
27
|
+
sessionId: Type.String(),
|
|
28
|
+
name: Type.String(),
|
|
29
|
+
cwd: Type.String(),
|
|
30
|
+
pid: Type.Number(),
|
|
31
|
+
startedAt: Type.Number(),
|
|
32
|
+
lastSeenAt: Type.Number(),
|
|
33
|
+
status: Type.Union([Type.Literal("idle"), Type.Literal("working")]),
|
|
34
|
+
offline: Type.Optional(Type.Boolean()),
|
|
35
|
+
});
|
|
36
|
+
export type SessionRecord = Static<typeof SessionRecordSchema>;
|
|
37
|
+
|
|
38
|
+
export type Presence = "live" | "stalled" | "offline";
|
|
39
|
+
|
|
40
|
+
export const HEARTBEAT_STALE_MS = 45_000;
|
|
41
|
+
/** A mailbox holding undelivered mail is kept this long after last contact. */
|
|
42
|
+
export const SWEEP_MAIL_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
|
|
43
|
+
|
|
44
|
+
const ADDRESS_PATTERN = /^[a-f0-9]{12}$/;
|
|
45
|
+
|
|
46
|
+
export function deriveAddr(cwd: string, sessionId: string): string {
|
|
47
|
+
return createHash("sha256").update(`${cwd}${sessionId}`).digest("hex").slice(0, 12);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Validate a talk address before it becomes a storage key. */
|
|
51
|
+
export function assertAddress(addr: string): void {
|
|
52
|
+
if (!ADDRESS_PATTERN.test(addr)) throw new TypeError(`Invalid talk address: ${addr}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Storage namespaces ───────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export const RECORDS_NS = "records";
|
|
58
|
+
|
|
59
|
+
export function inboxNs(addr: string): string {
|
|
60
|
+
assertAddress(addr);
|
|
61
|
+
return `inbox/${addr}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function asksNs(addr: string): string {
|
|
65
|
+
assertAddress(addr);
|
|
66
|
+
return `asks/${addr}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function recordKey(addr: string): string {
|
|
70
|
+
assertAddress(addr);
|
|
71
|
+
return `${addr}.json`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Session records ──────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export async function writeRecord(storage: TalkStorage, record: SessionRecord): Promise<void> {
|
|
77
|
+
await storage.writeJson(RECORDS_NS, recordKey(record.addr), record);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function readRecord(
|
|
81
|
+
storage: TalkStorage,
|
|
82
|
+
addr: string,
|
|
83
|
+
): Promise<SessionRecord | null> {
|
|
84
|
+
const raw = await storage.readJson(RECORDS_NS, recordKey(addr));
|
|
85
|
+
return Value.Check(SessionRecordSchema, raw) ? raw : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Read-only listing, oldest first. Never mutates anything. */
|
|
89
|
+
export async function listRecords(storage: TalkStorage): Promise<SessionRecord[]> {
|
|
90
|
+
const out: SessionRecord[] = [];
|
|
91
|
+
for (const key of await storage.listKeys(RECORDS_NS)) {
|
|
92
|
+
const addr = key.slice(0, -".json".length);
|
|
93
|
+
if (!ADDRESS_PATTERN.test(addr)) continue;
|
|
94
|
+
const record = await readRecord(storage, addr);
|
|
95
|
+
if (record) out.push(record);
|
|
96
|
+
}
|
|
97
|
+
return out.toSorted((a, b) => a.startedAt - b.startedAt);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function pidAlive(pid: number): boolean {
|
|
101
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
102
|
+
try {
|
|
103
|
+
process.kill(pid, 0);
|
|
104
|
+
return true;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// EPERM means the process exists but isn't ours — still alive
|
|
107
|
+
return (error as NodeJS.ErrnoException)?.code === "EPERM";
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function presenceOf(record: SessionRecord, now: number = Date.now()): Presence {
|
|
112
|
+
if (record.offline) return "offline";
|
|
113
|
+
if (!pidAlive(record.pid)) return "offline";
|
|
114
|
+
return now - record.lastSeenAt < HEARTBEAT_STALE_MS ? "live" : "stalled";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Sweep ────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reclaim dead sessions' data. Rules (mail outranks tidiness):
|
|
121
|
+
* - a running session is never touched;
|
|
122
|
+
* - a mailbox holding undelivered mail is kept for SWEEP_MAIL_KEEP_MS;
|
|
123
|
+
* - an offline but resumable session keeps its record (its address — new
|
|
124
|
+
* mail must remain deliverable while it's down);
|
|
125
|
+
* - only an empty mailbox of a session that can no longer be resumed is
|
|
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).
|
|
131
|
+
*/
|
|
132
|
+
export async function sweep(
|
|
133
|
+
storage: TalkStorage,
|
|
134
|
+
now: number = Date.now(),
|
|
135
|
+
sessionExists?: (sessionId: string) => boolean,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
for (const record of await listRecords(storage)) {
|
|
138
|
+
if (presenceOf(record, now) !== "offline") continue;
|
|
139
|
+
const hasMail =
|
|
140
|
+
(await storage.hasKeys(inboxNs(record.addr))) || (await storage.hasKeys(asksNs(record.addr)));
|
|
141
|
+
const expired = now - record.lastSeenAt >= SWEEP_MAIL_KEEP_MS;
|
|
142
|
+
if (hasMail && !expired) continue;
|
|
143
|
+
if (!expired && (sessionExists?.(record.sessionId) ?? true)) continue;
|
|
144
|
+
await storage.removeNamespace(inboxNs(record.addr));
|
|
145
|
+
await storage.removeNamespace(asksNs(record.addr));
|
|
146
|
+
await storage.removeKey(RECORDS_NS, recordKey(record.addr));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage abstraction for the talk store.
|
|
3
|
+
*
|
|
4
|
+
* The talk core (registry + mailbox) is written against this interface, so
|
|
5
|
+
* the persistence backend can be swapped: today SQLite, later a remote/HTTP
|
|
6
|
+
* service without touching the talk logic.
|
|
7
|
+
*
|
|
8
|
+
* The model is deliberately minimal and HTTP-friendly:
|
|
9
|
+
* - a `namespace` is a collection;
|
|
10
|
+
* - a `key` is a single entry inside a namespace;
|
|
11
|
+
* - values are opaque JSON (validated by the core layer, not here);
|
|
12
|
+
* - audit is an append-only log.
|
|
13
|
+
*
|
|
14
|
+
* `readJson` returns `unknown` on purpose: the core layer validates it with
|
|
15
|
+
* TypeBox schemas, so a corrupt/foreign payload is rejected rather than
|
|
16
|
+
* blindly cast.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { DatabaseSync } from "node:sqlite";
|
|
20
|
+
|
|
21
|
+
export interface TalkStorage {
|
|
22
|
+
/** Ensure the store is ready (create root, connect, handshake, etc.). */
|
|
23
|
+
init(): Promise<void>;
|
|
24
|
+
/** List all keys in a namespace, sorted. No side effects. */
|
|
25
|
+
listKeys(namespace: string): Promise<string[]>;
|
|
26
|
+
/** Read a JSON value by key; null when absent or not valid JSON. */
|
|
27
|
+
readJson(namespace: string, key: string): Promise<unknown>;
|
|
28
|
+
/** Atomically write a JSON value under a key. */
|
|
29
|
+
writeJson(namespace: string, key: string, value: unknown): Promise<void>;
|
|
30
|
+
/** Remove a key. Returns true when the key existed and was removed. */
|
|
31
|
+
removeKey(namespace: string, key: string): Promise<boolean>;
|
|
32
|
+
/** Whether a namespace holds at least one key. */
|
|
33
|
+
hasKeys(namespace: string): Promise<boolean>;
|
|
34
|
+
/** Remove an entire namespace and everything in it. */
|
|
35
|
+
removeNamespace(namespace: string): Promise<void>;
|
|
36
|
+
/** Append one line to a named log. */
|
|
37
|
+
appendLog(logName: string, line: string): Promise<void>;
|
|
38
|
+
/** Read all lines of a named log, oldest first. */
|
|
39
|
+
readLog(logName: string): Promise<string[]>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* SQLite backend (Node's built-in `node:sqlite`, no npm dependency).
|
|
44
|
+
*
|
|
45
|
+
* A single database file holds everything; WAL mode plus a busy timeout lets
|
|
46
|
+
* multiple pi sessions read and write it concurrently. SQL parameter binding
|
|
47
|
+
* removes the need for path/symlink hardening entirely.
|
|
48
|
+
*
|
|
49
|
+
* The backend is synchronous; each method wraps its result in a resolved
|
|
50
|
+
* promise to satisfy the async interface that a future HTTP backend needs.
|
|
51
|
+
*/
|
|
52
|
+
export class SqliteTalkStorage implements TalkStorage {
|
|
53
|
+
private readonly db: DatabaseSync;
|
|
54
|
+
|
|
55
|
+
constructor(dbPath: string) {
|
|
56
|
+
this.db = new DatabaseSync(dbPath);
|
|
57
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
58
|
+
this.db.exec("PRAGMA busy_timeout = 5000");
|
|
59
|
+
this.db.exec(`
|
|
60
|
+
CREATE TABLE IF NOT EXISTS talk_kv (
|
|
61
|
+
namespace TEXT NOT NULL,
|
|
62
|
+
key TEXT NOT NULL,
|
|
63
|
+
value TEXT NOT NULL,
|
|
64
|
+
PRIMARY KEY (namespace, key)
|
|
65
|
+
)
|
|
66
|
+
`);
|
|
67
|
+
this.db.exec(`
|
|
68
|
+
CREATE TABLE IF NOT EXISTS talk_log (
|
|
69
|
+
name TEXT NOT NULL,
|
|
70
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
71
|
+
line TEXT NOT NULL
|
|
72
|
+
)
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
init(): Promise<void> {
|
|
77
|
+
// schema is created in the constructor
|
|
78
|
+
return Promise.resolve();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Close the underlying database handle (test teardown). */
|
|
82
|
+
close(): void {
|
|
83
|
+
this.db.close();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
listKeys(namespace: string): Promise<string[]> {
|
|
87
|
+
const rows = this.db
|
|
88
|
+
.prepare("SELECT key FROM talk_kv WHERE namespace = ? ORDER BY key")
|
|
89
|
+
.all(namespace) as unknown as { key: string }[];
|
|
90
|
+
return Promise.resolve(rows.map((r) => r.key));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
readJson(namespace: string, key: string): Promise<unknown> {
|
|
94
|
+
const row = this.db
|
|
95
|
+
.prepare("SELECT value FROM talk_kv WHERE namespace = ? AND key = ?")
|
|
96
|
+
.get(namespace, key) as { value: string } | undefined;
|
|
97
|
+
if (row === undefined) return Promise.resolve(null);
|
|
98
|
+
try {
|
|
99
|
+
return Promise.resolve(JSON.parse(row.value) as unknown);
|
|
100
|
+
} catch {
|
|
101
|
+
return Promise.resolve(null);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
writeJson(namespace: string, key: string, value: unknown): Promise<void> {
|
|
106
|
+
this.db
|
|
107
|
+
.prepare("INSERT OR REPLACE INTO talk_kv (namespace, key, value) VALUES (?, ?, ?)")
|
|
108
|
+
.run(namespace, key, JSON.stringify(value));
|
|
109
|
+
return Promise.resolve();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
removeKey(namespace: string, key: string): Promise<boolean> {
|
|
113
|
+
const result = this.db
|
|
114
|
+
.prepare("DELETE FROM talk_kv WHERE namespace = ? AND key = ?")
|
|
115
|
+
.run(namespace, key);
|
|
116
|
+
return Promise.resolve(result.changes > 0);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
hasKeys(namespace: string): Promise<boolean> {
|
|
120
|
+
return Promise.resolve(
|
|
121
|
+
this.db.prepare("SELECT 1 FROM talk_kv WHERE namespace = ? LIMIT 1").get(namespace) !==
|
|
122
|
+
undefined,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
removeNamespace(namespace: string): Promise<void> {
|
|
127
|
+
this.db.prepare("DELETE FROM talk_kv WHERE namespace = ?").run(namespace);
|
|
128
|
+
return Promise.resolve();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
appendLog(logName: string, line: string): Promise<void> {
|
|
132
|
+
this.db.prepare("INSERT INTO talk_log (name, line) VALUES (?, ?)").run(logName, line);
|
|
133
|
+
return Promise.resolve();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
readLog(logName: string): Promise<string[]> {
|
|
137
|
+
const rows = this.db
|
|
138
|
+
.prepare("SELECT line FROM talk_log WHERE name = ? ORDER BY seq")
|
|
139
|
+
.all(logName) as unknown as { line: string }[];
|
|
140
|
+
return Promise.resolve(rows.map((r) => r.line));
|
|
141
|
+
}
|
|
142
|
+
}
|