@devspecai/pi-plugin 0.0.0-stage → 0.10.3
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/CHANGELOG.md +899 -0
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/extensions/.gitkeep +0 -0
- package/extensions/core/activation-recovery.ts +135 -0
- package/extensions/core/active-deliveries.ts +171 -0
- package/extensions/core/active-session-plans.ts +374 -0
- package/extensions/core/adapter-overlap.ts +247 -0
- package/extensions/core/assistant-speech.ts +44 -0
- package/extensions/core/attach-context.ts +25 -0
- package/extensions/core/attachment-limits.ts +20 -0
- package/extensions/core/attachments.ts +487 -0
- package/extensions/core/auth-store.ts +91 -0
- package/extensions/core/automation-run-retry.ts +31 -0
- package/extensions/core/background-journal.ts +85 -0
- package/extensions/core/background-observer.ts +279 -0
- package/extensions/core/bond-recovery.ts +65 -0
- package/extensions/core/bond-state.ts +80 -0
- package/extensions/core/clear-conversation.ts +53 -0
- package/extensions/core/client-versions.ts +56 -0
- package/extensions/core/client.ts +704 -0
- package/extensions/core/command-offer.ts +88 -0
- package/extensions/core/command-runner.ts +62 -0
- package/extensions/core/command-turn.ts +88 -0
- package/extensions/core/commit-ledger.ts +195 -0
- package/extensions/core/commit-stamp.ts +98 -0
- package/extensions/core/config.ts +63 -0
- package/extensions/core/connection-context.ts +28 -0
- package/extensions/core/context-budget.ts +175 -0
- package/extensions/core/control-result-outbox.ts +80 -0
- package/extensions/core/control.ts +156 -0
- package/extensions/core/ctx-guard.ts +33 -0
- package/extensions/core/debug.ts +136 -0
- package/extensions/core/delivery-queue.ts +92 -0
- package/extensions/core/dispatch.ts +108 -0
- package/extensions/core/duck.ts +128 -0
- package/extensions/core/git-observer.ts +382 -0
- package/extensions/core/github-identity.ts +26 -0
- package/extensions/core/implementation-contract.ts +773 -0
- package/extensions/core/instructions.ts +40 -0
- package/extensions/core/interaction-events.ts +834 -0
- package/extensions/core/json-schema.ts +16 -0
- package/extensions/core/local-activity.ts +34 -0
- package/extensions/core/mcp-config-guard.ts +117 -0
- package/extensions/core/model-catalog.ts +76 -0
- package/extensions/core/model-surface.ts +125 -0
- package/extensions/core/notice-reply.ts +215 -0
- package/extensions/core/owner-inject.ts +148 -0
- package/extensions/core/post-session-receipt.ts +70 -0
- package/extensions/core/preempt.ts +100 -0
- package/extensions/core/project-choice.ts +145 -0
- package/extensions/core/project-scope.ts +335 -0
- package/extensions/core/project-selection-ui.ts +202 -0
- package/extensions/core/provenance-refs.ts +158 -0
- package/extensions/core/push-channel.ts +281 -0
- package/extensions/core/question-receipt-lifecycle.ts +158 -0
- package/extensions/core/recall.ts +121 -0
- package/extensions/core/remote-payload.ts +1329 -0
- package/extensions/core/remote-session-arg.ts +24 -0
- package/extensions/core/repository-context.ts +35 -0
- package/extensions/core/request-chain.ts +1244 -0
- package/extensions/core/result-projection.ts +939 -0
- package/extensions/core/result.ts +100 -0
- package/extensions/core/review-context.ts +70 -0
- package/extensions/core/review-watch.ts +94 -0
- package/extensions/core/room-access.ts +12 -0
- package/extensions/core/room-context.ts +178 -0
- package/extensions/core/room-reader.ts +308 -0
- package/extensions/core/room-store.ts +381 -0
- package/extensions/core/room-sync.ts +79 -0
- package/extensions/core/room-tools.ts +86 -0
- package/extensions/core/room-unread.ts +173 -0
- package/extensions/core/session-events.ts +62 -0
- package/extensions/core/session-polls.ts +292 -0
- package/extensions/core/settings.ts +39 -0
- package/extensions/core/status-line.ts +432 -0
- package/extensions/core/still-to-discuss.ts +124 -0
- package/extensions/core/subagent-fleet.ts +238 -0
- package/extensions/core/system-notice.ts +227 -0
- package/extensions/core/telemetry.ts +177 -0
- package/extensions/core/tool-selection.ts +72 -0
- package/extensions/core/trail-events.ts +247 -0
- package/extensions/core/trail-projection.ts +373 -0
- package/extensions/core/trail.ts +465 -0
- package/extensions/core/turn-lifecycle.ts +472 -0
- package/extensions/core/version-mismatch.ts +165 -0
- package/extensions/core/work-prompt.ts +44 -0
- package/extensions/core/work-state.ts +179 -0
- package/extensions/devspec-adapter-guard.ts +106 -0
- package/extensions/devspec-auth.ts +191 -0
- package/extensions/devspec-brand.ts +140 -0
- package/extensions/devspec-commit-ledger.ts +477 -0
- package/extensions/devspec-context.ts +146 -0
- package/extensions/devspec-memory.ts +136 -0
- package/extensions/devspec-provenance-assistance.ts +233 -0
- package/extensions/devspec-remote.ts +5152 -0
- package/extensions/devspec-tools.ts +552 -0
- package/extensions/devspec-work.ts +244 -0
- package/extensions/generated/README.md +18 -0
- package/extensions/generated/mcp-catalog.ts +15811 -0
- package/package.json +71 -4
- package/prompts/.gitkeep +0 -0
- package/runtime/commit-stamp.mjs +279 -0
- package/runtime/git-observer-shim.mjs +390 -0
- package/skills/.gitkeep +0 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/** Private transactional read model. Never restores command/interaction authority (829394e9). */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, rmSync, utimesSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { devspecDir } from "./config.ts";
|
|
7
|
+
import type { PollPackage, RoomRecord } from "./remote-payload.ts";
|
|
8
|
+
import type { ActivityIndex, DeletionSeen, RoomAddressee, RoomCoverage, RoomMessage, RoomRowState } from "./room-context.ts";
|
|
9
|
+
import { ROOM_MARKS_TABLE, writeRoomMarks, type RoomMarkKind } from "./room-unread.ts";
|
|
10
|
+
|
|
11
|
+
import type { SessionEvents } from "./session-events.ts";
|
|
12
|
+
import type { NoticeReplySnapshot } from "./notice-reply.ts";
|
|
13
|
+
|
|
14
|
+
export interface StoredEventStatus {
|
|
15
|
+
availability: "available" | "unavailable";
|
|
16
|
+
as_of: string | null;
|
|
17
|
+
response_as_of: string | null;
|
|
18
|
+
checked_at: string;
|
|
19
|
+
revision: string | null;
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RoomStoreScope {
|
|
24
|
+
endpoint: string;
|
|
25
|
+
projectId: string;
|
|
26
|
+
nativeSessionId: string;
|
|
27
|
+
connectionId: string;
|
|
28
|
+
sessionId: string;
|
|
29
|
+
attachmentId: string;
|
|
30
|
+
}
|
|
31
|
+
export interface StoredRoomRecord extends RoomRecord {
|
|
32
|
+
state: RoomRowState;
|
|
33
|
+
addressee: RoomAddressee;
|
|
34
|
+
attachments: RoomMessage["attachments"];
|
|
35
|
+
/** The notices this row replies to, as the server snapshotted them (8885423d). Never on a tombstone. */
|
|
36
|
+
notice_reply?: NoticeReplySnapshot;
|
|
37
|
+
}
|
|
38
|
+
interface Progress {
|
|
39
|
+
version: 1;
|
|
40
|
+
target: RoomCoverage | null;
|
|
41
|
+
frontier: RoomCoverage | null;
|
|
42
|
+
before: string | null;
|
|
43
|
+
/** A gap above the saved older-history frontier; null means no such repair. */
|
|
44
|
+
repair: "head" | { before: string } | null;
|
|
45
|
+
deletionsSeen: DeletionSeen | null;
|
|
46
|
+
observedAt: string | null;
|
|
47
|
+
complete: boolean;
|
|
48
|
+
}
|
|
49
|
+
export type StoredActivityIndex = Omit<ActivityIndex, "status">;
|
|
50
|
+
export interface ObservedSnapshot<T = unknown> {
|
|
51
|
+
availability: "available" | "unavailable";
|
|
52
|
+
observedAt: string | null;
|
|
53
|
+
checkedAt: string;
|
|
54
|
+
value: T | null;
|
|
55
|
+
}
|
|
56
|
+
const EMPTY: Progress = { version: 1, target: null, frontier: null, before: null, repair: null, deletionsSeen: null, observedAt: null, complete: false };
|
|
57
|
+
const rank: Record<RoomRowState, number> = { in_progress: 0, final: 1, deleted: 2 };
|
|
58
|
+
class CorruptRoomCache extends Error {}
|
|
59
|
+
const digest = (x: unknown) => createHash("sha256").update(JSON.stringify(x)).digest("hex");
|
|
60
|
+
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
|
61
|
+
const scopeIdentity = (s: RoomStoreScope) => [s.endpoint, s.projectId, s.nativeSessionId, s.connectionId, s.sessionId, s.attachmentId];
|
|
62
|
+
/** Irreversible selection hint for a same-credential offline reader, never authority. */
|
|
63
|
+
export const roomPrincipalFingerprint = (token: string): string => createHash("sha256").update(token).digest("hex");
|
|
64
|
+
|
|
65
|
+
function directory(path: string): void {
|
|
66
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
67
|
+
if (lstatSync(path).isSymbolicLink() || !lstatSync(path).isDirectory()) throw new Error("unsafe room cache directory");
|
|
68
|
+
if (process.platform !== "win32") chmodSync(path, 0o700);
|
|
69
|
+
}
|
|
70
|
+
export function roomStorePath(scope: RoomStoreScope, root = join(devspecDir(), "rooms")): string {
|
|
71
|
+
return join(root, digest(scope.nativeSessionId), `${digest(scopeIdentity(scope))}.sqlite`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Import lazily: older hosts retain their existing lane, not a broken extension. */
|
|
75
|
+
export async function openRoomStore(scope: RoomStoreScope, options: {
|
|
76
|
+
root?: string;
|
|
77
|
+
principalFingerprint?: string;
|
|
78
|
+
/** This connection's "Tool · Codename" label, or null without a codename (ce1ffca1). */
|
|
79
|
+
selfLabel?: string | null;
|
|
80
|
+
current?: () => boolean;
|
|
81
|
+
crash?: (boundary: "after_records" | "before_commit" | "after_commit") => void;
|
|
82
|
+
} = {}): Promise<RoomStore> {
|
|
83
|
+
const { DatabaseSync } = await import("node:sqlite");
|
|
84
|
+
if (options.current && !options.current()) throw new Error("room attachment changed before storage opened");
|
|
85
|
+
const root = options.root ?? join(devspecDir(), "rooms");
|
|
86
|
+
directory(root);
|
|
87
|
+
const parent = join(root, digest(scope.nativeSessionId)); directory(parent);
|
|
88
|
+
const path = roomStorePath(scope, root);
|
|
89
|
+
// Expire only this native session's old attachment copies, never other agents' data.
|
|
90
|
+
for (const name of readdirSync(parent)) {
|
|
91
|
+
if (!/^[a-f0-9]{64}\.sqlite$/.test(name)) continue;
|
|
92
|
+
const candidate = join(parent, name); const st = lstatSync(candidate);
|
|
93
|
+
if (candidate !== path && !st.isSymbolicLink() && st.isFile() && Date.now() - st.mtimeMs > RETENTION_MS) {
|
|
94
|
+
rmSync(candidate, { force: true }); rmSync(`${candidate}-journal`, { force: true });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (existsSync(path)) {
|
|
98
|
+
const st = lstatSync(path);
|
|
99
|
+
if (st.isSymbolicLink() || !st.isFile() || st.nlink !== 1) throw new Error("unsafe room cache file");
|
|
100
|
+
} else closeSync(openSync(path, "wx", 0o600));
|
|
101
|
+
if (process.platform !== "win32") chmodSync(path, 0o600);
|
|
102
|
+
const construct = () => {
|
|
103
|
+
const db = new DatabaseSync(path);
|
|
104
|
+
try { return new RoomStore(db, path, scope, options.current ?? (() => true), options.crash, options.principalFingerprint, options.selfLabel); }
|
|
105
|
+
catch (e) { db.close(); throw e; }
|
|
106
|
+
};
|
|
107
|
+
try { return construct(); }
|
|
108
|
+
catch (error) {
|
|
109
|
+
const code = Number((error as { errcode?: unknown })?.errcode) & 255;
|
|
110
|
+
if (!(error instanceof CorruptRoomCache) && code !== 11 && code !== 26) throw error;
|
|
111
|
+
if (options.current && !options.current()) throw error;
|
|
112
|
+
// Only recognized corruption of our derived cache is rebuilt. Busy files,
|
|
113
|
+
// unknown future formats and permission failures are never deleted.
|
|
114
|
+
rmSync(path, { force: true }); rmSync(`${path}-journal`, { force: true });
|
|
115
|
+
closeSync(openSync(path, "wx", 0o600));
|
|
116
|
+
return construct();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class RoomStore {
|
|
121
|
+
private closed = false;
|
|
122
|
+
private online = false;
|
|
123
|
+
private failure = false;
|
|
124
|
+
private db: DatabaseSync;
|
|
125
|
+
readonly path: string;
|
|
126
|
+
readonly scope: RoomStoreScope;
|
|
127
|
+
private current: () => boolean;
|
|
128
|
+
private crash?: (boundary: "after_records" | "before_commit" | "after_commit") => void;
|
|
129
|
+
constructor(db: DatabaseSync, path: string, scope: RoomStoreScope,
|
|
130
|
+
current: () => boolean,
|
|
131
|
+
crash?: (boundary: "after_records" | "before_commit" | "after_commit") => void, principalFingerprint?: string, selfLabel?: string | null) {
|
|
132
|
+
this.db = db; this.path = path; this.scope = scope; this.current = current; this.crash = crash;
|
|
133
|
+
const format = Number(db.prepare("PRAGMA user_version").get()?.user_version ?? 0);
|
|
134
|
+
if (format > 1) throw new Error("unsupported future room cache format");
|
|
135
|
+
db.exec("PRAGMA busy_timeout=100; PRAGMA journal_mode=DELETE; PRAGMA synchronous=FULL; PRAGMA secure_delete=ON;");
|
|
136
|
+
db.exec("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, sequence INTEGER UNIQUE NOT NULL, state TEXT NOT NULL, body TEXT NOT NULL); CREATE TABLE IF NOT EXISTS tombstones (id TEXT PRIMARY KEY);");
|
|
137
|
+
// Additive table: existing v1 caches and older readers remain valid.
|
|
138
|
+
db.exec("CREATE TABLE IF NOT EXISTS session_events (id TEXT PRIMARY KEY, body TEXT NOT NULL);");
|
|
139
|
+
db.exec(ROOM_MARKS_TABLE);
|
|
140
|
+
const prior = this.meta<RoomStoreScope>("scope");
|
|
141
|
+
if (prior && JSON.stringify(scopeIdentity(prior)) !== JSON.stringify(scopeIdentity(scope))) throw new Error("room cache scope mismatch");
|
|
142
|
+
const version = this.meta<number>("schema");
|
|
143
|
+
if (version !== undefined && version !== 1) throw new Error("unsupported room cache schema");
|
|
144
|
+
this.setMeta("scope", scope);
|
|
145
|
+
this.setMeta("schema", 1);
|
|
146
|
+
db.exec("PRAGMA user_version=1");
|
|
147
|
+
const progress = this.meta<Progress>("progress");
|
|
148
|
+
if (progress && (progress.version !== 1 || typeof progress.complete !== "boolean" || (progress.before !== null && typeof progress.before !== "string") || (progress.complete && !progress.target))) throw new CorruptRoomCache("invalid recovery checkpoint");
|
|
149
|
+
if (principalFingerprint !== undefined) {
|
|
150
|
+
if (!/^[a-f0-9]{64}$/.test(principalFingerprint)) throw new Error("invalid principal fingerprint");
|
|
151
|
+
this.setMeta("principal_fingerprint", principalFingerprint);
|
|
152
|
+
}
|
|
153
|
+
if (selfLabel !== undefined) this.setMeta("self_label", selfLabel);
|
|
154
|
+
}
|
|
155
|
+
private assertCurrent(): void { if (this.closed || !this.current()) throw new Error("room cache attachment no longer current"); }
|
|
156
|
+
private meta<T>(key: string): T | undefined {
|
|
157
|
+
const row = this.db.prepare("SELECT value FROM meta WHERE key=?").get(key);
|
|
158
|
+
try { return row ? JSON.parse(String(row.value)) as T : undefined; }
|
|
159
|
+
catch { throw new CorruptRoomCache("invalid cache metadata"); }
|
|
160
|
+
}
|
|
161
|
+
private setMeta(key: string, value: unknown): void { this.db.prepare("INSERT INTO meta VALUES (?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value").run(key, JSON.stringify(value)); }
|
|
162
|
+
private progress(): Progress { return this.meta<Progress>("progress") ?? structuredClone(EMPTY); }
|
|
163
|
+
private row(id: string): StoredRoomRecord | undefined {
|
|
164
|
+
const r = this.db.prepare("SELECT body FROM messages WHERE id=?").get(id);
|
|
165
|
+
return r ? JSON.parse(String(r.body)) as StoredRoomRecord : undefined;
|
|
166
|
+
}
|
|
167
|
+
private number(sql: string, ...args: number[]): number { return Number(this.db.prepare(sql).get(...args)?.n ?? 0); }
|
|
168
|
+
private merge(record: RoomRecord, description: RoomMessage): void {
|
|
169
|
+
const id = record.message_id;
|
|
170
|
+
const old = this.row(id);
|
|
171
|
+
const deleted = description.state === "deleted" || old?.state === "deleted" || Boolean(this.db.prepare("SELECT 1 FROM tombstones WHERE id=?").get(id));
|
|
172
|
+
const keepOld = old && rank[old.state] > rank[description.state];
|
|
173
|
+
// The snapshot is fixed when the reply is posted; a later page that does not
|
|
174
|
+
// describe it (an open_check answer) keeps the one already held.
|
|
175
|
+
const noticeReply = description.notice_reply ?? old?.notice_reply;
|
|
176
|
+
const { notice_reply: _dropped, ...tombstoneBase } = old ?? record as StoredRoomRecord;
|
|
177
|
+
const result: StoredRoomRecord = deleted
|
|
178
|
+
? { ...tombstoneBase, message_id: id, content: "", state: "deleted", addressee: old?.addressee ?? description.addressee, attachments: [] }
|
|
179
|
+
: keepOld ? old : { ...record, state: description.state, addressee: description.addressee, attachments: description.attachments, ...(noticeReply ? { notice_reply: noticeReply } : {}) };
|
|
180
|
+
if (deleted) this.db.prepare("INSERT OR IGNORE INTO tombstones VALUES (?)").run(id);
|
|
181
|
+
this.db.prepare("INSERT INTO messages VALUES (?,?,?,?) ON CONFLICT(id) DO UPDATE SET state=excluded.state,body=excluded.body").run(id, record.order.sequence, result.state, JSON.stringify(result));
|
|
182
|
+
}
|
|
183
|
+
private tombstone(id: string): void {
|
|
184
|
+
this.db.prepare("INSERT OR IGNORE INTO tombstones VALUES (?)").run(id);
|
|
185
|
+
const old = this.row(id);
|
|
186
|
+
if (old) this.merge(old, { message_id: id, addressee: old.addressee, attachments: [], state: "deleted" });
|
|
187
|
+
}
|
|
188
|
+
private snapshot(key: string, value: unknown, available: boolean, now: string): void {
|
|
189
|
+
const old = this.meta<ObservedSnapshot>(`snapshot:${key}`);
|
|
190
|
+
this.setMeta(`snapshot:${key}`, { availability: available ? "available" : "unavailable", checkedAt: now, observedAt: available ? now : old?.observedAt ?? null, value: available ? value : old?.value ?? null } satisfies ObservedSnapshot);
|
|
191
|
+
}
|
|
192
|
+
private mergeEvents(section: SessionEvents | null, now: string): void {
|
|
193
|
+
const old = this.meta<StoredEventStatus>("session_events_status");
|
|
194
|
+
// Events arriving late still belong in history; observation time is not a cursor.
|
|
195
|
+
if (section?.status === "available") {
|
|
196
|
+
const insert = this.db.prepare("INSERT OR IGNORE INTO session_events VALUES (?,?)");
|
|
197
|
+
for (const event of section.events) insert.run(event.id, JSON.stringify(event));
|
|
198
|
+
}
|
|
199
|
+
if (section && old?.response_as_of && Date.parse(section.as_of) < Date.parse(old.response_as_of)) return;
|
|
200
|
+
const available = section && (section.status === "available" || (section.status === "unchanged" && section.revision === old?.revision));
|
|
201
|
+
this.setMeta("session_events_status", {
|
|
202
|
+
availability: available ? "available" : "unavailable",
|
|
203
|
+
as_of: available ? section.as_of : old?.as_of ?? null,
|
|
204
|
+
response_as_of: section?.as_of ?? old?.response_as_of ?? null, checked_at: now,
|
|
205
|
+
// Do not acknowledge a revision we did not commit. A mismatch requests a full list.
|
|
206
|
+
revision: available ? section.revision : null,
|
|
207
|
+
truncated: available ? section.truncated : old?.truncated ?? false,
|
|
208
|
+
} satisfies StoredEventStatus);
|
|
209
|
+
}
|
|
210
|
+
private suffixComplete(p: Progress): boolean {
|
|
211
|
+
if (!p.target || !p.frontier) return false;
|
|
212
|
+
const expected = p.target.eligible_count - p.frontier.eligible_count;
|
|
213
|
+
return expected >= 0 && this.number("SELECT count(*) AS n FROM messages WHERE sequence>? AND sequence<=?", p.frontier.through.sequence, p.target.through.sequence) === expected;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** One committed page owns both the data and the next recovery request. */
|
|
217
|
+
apply(pkg: PollPackage, kind: "live" | "older" | "repair" = "live", now = new Date().toISOString()): void {
|
|
218
|
+
this.assertCurrent();
|
|
219
|
+
const page = pkg.roomPage;
|
|
220
|
+
if (!page || pkg.canonicalState !== "valid") { this.markOffline(); return; }
|
|
221
|
+
if (kind !== "live" && (!page.history || pkg.ownerBundle || pkg.control || pkg.systemNotices.length)) throw new Error("non-historical repair rejected");
|
|
222
|
+
let begun = false; let committed = false;
|
|
223
|
+
try {
|
|
224
|
+
this.db.exec("BEGIN IMMEDIATE"); begun = true;
|
|
225
|
+
const p = this.progress();
|
|
226
|
+
const descriptions = new Map(page.room.messages.map(m => [m.message_id, m]));
|
|
227
|
+
for (const record of page.records) {
|
|
228
|
+
const description = descriptions.get(record.message_id);
|
|
229
|
+
if (!description) throw new Error("missing room record metadata");
|
|
230
|
+
this.merge(record, description);
|
|
231
|
+
}
|
|
232
|
+
for (const check of page.room.open_check ?? []) {
|
|
233
|
+
const old = this.row(check.message_id);
|
|
234
|
+
if (check.state === "deleted") this.tombstone(check.message_id);
|
|
235
|
+
else if (check.state === "absent") this.db.prepare("DELETE FROM messages WHERE id=?").run(check.message_id);
|
|
236
|
+
else if (check.state === "final" && check.entry && old) {
|
|
237
|
+
const { message_id, order, actor, source_type, content } = check.entry;
|
|
238
|
+
this.merge({ message_id, order, actor, source_type, content }, { message_id, addressee: old.addressee, attachments: old.attachments, state: "final", ...(old.notice_reply ? { notice_reply: old.notice_reply } : {}) });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const d = page.room.deletions;
|
|
242
|
+
if (d) {
|
|
243
|
+
for (const id of d.message_ids) this.tombstone(id);
|
|
244
|
+
p.deletionsSeen = d.seen;
|
|
245
|
+
if (!d.truncated && this.number("SELECT count(*) AS n FROM tombstones") < d.total) p.deletionsSeen = null;
|
|
246
|
+
}
|
|
247
|
+
this.crash?.("after_records");
|
|
248
|
+
const coverage = page.room.coverage;
|
|
249
|
+
if (coverage && (!p.target || coverage.through.sequence >= p.target.through.sequence)) p.target = coverage;
|
|
250
|
+
if (!p.frontier && !p.before && page.window.omission_reason === "history_before_window") p.before = page.window.next_cursor;
|
|
251
|
+
if (!p.frontier && coverage) {
|
|
252
|
+
p.frontier = coverage;
|
|
253
|
+
p.before = page.window.omission_reason === "history_before_window" ? page.window.next_cursor : null;
|
|
254
|
+
}
|
|
255
|
+
if (kind === "older") {
|
|
256
|
+
p.before = page.window.has_more ? page.window.next_cursor : null;
|
|
257
|
+
if (coverage) p.frontier = coverage;
|
|
258
|
+
}
|
|
259
|
+
if (kind === "repair") {
|
|
260
|
+
if (!p.before && p.repair === "head" && page.window.has_more) p.before = page.window.next_cursor;
|
|
261
|
+
p.repair = page.window.has_more && page.window.next_cursor ? { before: page.window.next_cursor } : null;
|
|
262
|
+
}
|
|
263
|
+
if (p.repair && this.suffixComplete(p)) p.repair = null;
|
|
264
|
+
if (p.target && p.frontier && !this.suffixComplete(p) && !p.repair) {
|
|
265
|
+
p.repair = page.window.omission_reason === "history_before_window" && page.window.next_cursor ? { before: page.window.next_cursor } : "head";
|
|
266
|
+
}
|
|
267
|
+
p.complete = false;
|
|
268
|
+
if (p.target && !d?.truncated && !(d && p.deletionsSeen === null)) {
|
|
269
|
+
const held = this.number("SELECT count(*) AS n FROM messages WHERE sequence<=?", p.target.through.sequence);
|
|
270
|
+
const deleted = this.number("SELECT count(*) AS n FROM messages WHERE sequence<=? AND state='deleted'", p.target.through.sequence);
|
|
271
|
+
const open = new Set(p.target.open_message_ids);
|
|
272
|
+
const unsettled = this.db.prepare("SELECT id FROM messages WHERE sequence<=? AND state='in_progress' LIMIT 501").all(p.target.through.sequence).some(r => !open.has(String(r.id)));
|
|
273
|
+
if (coverage && page.room.open_check !== null && d !== null && held === p.target.eligible_count && deleted >= p.target.deleted_count && !unsettled && !p.repair) {
|
|
274
|
+
p.complete = true; p.before = null; p.frontier = p.target;
|
|
275
|
+
}
|
|
276
|
+
if (held > p.target.eligible_count) {
|
|
277
|
+
// An observed hard delete/corrupt extra row is different from an
|
|
278
|
+
// unfinished history. Rebuild this derived copy, never the server.
|
|
279
|
+
this.db.exec("DELETE FROM messages; DELETE FROM tombstones; DELETE FROM meta WHERE key LIKE 'snapshot:%';");
|
|
280
|
+
Object.assign(p, structuredClone(EMPTY), { repair: "head" });
|
|
281
|
+
} else {
|
|
282
|
+
if (deleted < p.target.deleted_count) p.deletionsSeen = null;
|
|
283
|
+
// Missing prefixes continue; they are never treated as corruption.
|
|
284
|
+
if (!p.before && !p.repair && held < p.target.eligible_count) p.repair = "head";
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
p.observedAt = now;
|
|
288
|
+
this.setMeta("progress", p);
|
|
289
|
+
// A quiet initial room may arrive entirely through read-only recovery.
|
|
290
|
+
// Bootstrap missing views there, but an old background reply cannot
|
|
291
|
+
// overwrite newer foreground snapshots lacking server timestamps.
|
|
292
|
+
const capture = (key: string) => kind === "live" || this.meta(`snapshot:${key}`) === undefined;
|
|
293
|
+
if (capture("plans") && pkg.activeSessionPlans !== undefined) this.snapshot("plans", pkg.activeSessionPlans, true, now);
|
|
294
|
+
if (capture("polls")) {
|
|
295
|
+
if (pkg.sessionPolls !== undefined) this.snapshot("polls", pkg.sessionPolls, true, now);
|
|
296
|
+
else if (pkg.advisoryRejections?.includes("session_polls")) this.snapshot("polls", null, false, now);
|
|
297
|
+
}
|
|
298
|
+
if (capture("discussion") && pkg.stillToDiscuss !== undefined) this.snapshot("discussion", pkg.stillToDiscuss, pkg.stillToDiscuss !== null, now);
|
|
299
|
+
const activity = pkg.sessionActivity;
|
|
300
|
+
const priorActivity = this.meta<ObservedSnapshot<StoredActivityIndex>>("snapshot:activity");
|
|
301
|
+
if (capture("activity") || (activity && Date.parse(activity.as_of) > Date.parse(priorActivity?.value?.as_of ?? "1970-01-01T00:00:00Z"))) {
|
|
302
|
+
const index: StoredActivityIndex | null = activity ? {
|
|
303
|
+
version: 1, as_of: activity.as_of, truncated: activity.truncated,
|
|
304
|
+
entries: activity.entries.map(({ kind, id, relation }) => ({ kind, id, relation })),
|
|
305
|
+
} : null;
|
|
306
|
+
this.snapshot("activity", index, Boolean(activity && activity.status === "available"), now);
|
|
307
|
+
}
|
|
308
|
+
if (pkg.sessionEvents !== undefined) this.mergeEvents(pkg.sessionEvents, now);
|
|
309
|
+
// This is a delivery observation, NOT the current pending-notice inventory.
|
|
310
|
+
if (kind === "live" && pkg.systemNotices.length) this.snapshot("notice_delivery", pkg.systemNotices.map(n => ({ notice_id: n.notice_id, task_id: n.kind === "unlinked_commit_item_review" ? n.task_id : null, kind: n.kind })), true, now);
|
|
311
|
+
this.assertCurrent(); this.crash?.("before_commit");
|
|
312
|
+
this.db.exec("COMMIT"); committed = true;
|
|
313
|
+
this.online = true; this.failure = false;
|
|
314
|
+
this.crash?.("after_commit");
|
|
315
|
+
} catch (error) {
|
|
316
|
+
this.failure = true; this.online = false;
|
|
317
|
+
if (begun && !committed) {
|
|
318
|
+
try { this.db.exec("ROLLBACK"); } catch { /* SQLite may already have rolled back an I/O failure. */ }
|
|
319
|
+
}
|
|
320
|
+
throw error;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
watch(): { room_open_message_ids: string[]; room_deletions_seen: DeletionSeen | null } {
|
|
325
|
+
this.assertCurrent();
|
|
326
|
+
return { room_open_message_ids: this.db.prepare("SELECT id FROM messages WHERE state='in_progress' ORDER BY sequence LIMIT 500").all().map(r => String(r.id)), room_deletions_seen: this.progress().deletionsSeen };
|
|
327
|
+
}
|
|
328
|
+
eventsWatch(): { session_events_version: 1; session_events_revision: string | null } {
|
|
329
|
+
this.assertCurrent();
|
|
330
|
+
return { session_events_version: 1, session_events_revision: this.meta<StoredEventStatus>("session_events_status")?.revision ?? null };
|
|
331
|
+
}
|
|
332
|
+
nextRepair(): { kind: "older" | "repair"; before?: string } | null {
|
|
333
|
+
this.assertCurrent(); const p = this.progress();
|
|
334
|
+
if (this.failure || (!p.target && !p.before)) return { kind: "repair" };
|
|
335
|
+
if (p.repair) return { kind: "repair", ...(p.repair === "head" ? {} : { before: p.repair.before }) };
|
|
336
|
+
if (p.before) return { kind: "older", before: p.before };
|
|
337
|
+
return !p.complete ? { kind: "repair" } : null;
|
|
338
|
+
}
|
|
339
|
+
contact(): void { this.assertCurrent(); this.online = true; utimesSync(this.path, new Date(), new Date()); }
|
|
340
|
+
markOffline(): void { this.online = false; }
|
|
341
|
+
markBehind(): void {
|
|
342
|
+
this.assertCurrent(); const p = this.progress();
|
|
343
|
+
p.complete = false; p.repair = "head";
|
|
344
|
+
this.setMeta("progress", p); this.online = true;
|
|
345
|
+
}
|
|
346
|
+
status(): { complete: boolean; online: boolean; storageError: boolean; observedAt: string | null; through: RoomCoverage["through"] | null; records: number } {
|
|
347
|
+
this.assertCurrent(); const p = this.progress();
|
|
348
|
+
return { complete: p.complete && !this.failure, online: this.online, storageError: this.failure, observedAt: p.observedAt, through: p.target?.through ?? null, records: this.number("SELECT count(*) AS n FROM messages") };
|
|
349
|
+
}
|
|
350
|
+
/** Internal bounded read primitive. Public native readers are a separate slice. */
|
|
351
|
+
read(before = Number.MAX_SAFE_INTEGER, limit = 50): StoredRoomRecord[] {
|
|
352
|
+
this.assertCurrent();
|
|
353
|
+
return this.db.prepare("SELECT body FROM messages WHERE sequence<? ORDER BY sequence DESC LIMIT ?").all(before, Math.max(1, Math.min(100, Math.trunc(limit)))).map(r => JSON.parse(String(r.body)) as StoredRoomRecord);
|
|
354
|
+
}
|
|
355
|
+
activity(): ObservedSnapshot<StoredActivityIndex> | undefined { this.assertCurrent(); return this.meta("snapshot:activity"); }
|
|
356
|
+
/** Versioned last-observed views; absence is never an authoritative empty list. */
|
|
357
|
+
state(): { version: 1; snapshots: Record<string, ObservedSnapshot | null> } {
|
|
358
|
+
this.assertCurrent();
|
|
359
|
+
return { version: 1, snapshots: Object.fromEntries(["plans", "polls", "discussion", "activity", "question_event", "notice_delivery"].map(key => [key, this.meta<ObservedSnapshot>(`snapshot:${key}`) ?? null])) };
|
|
360
|
+
}
|
|
361
|
+
/** A row Pi itself posted (exact receipt id): never unread for this agent (ce1ffca1). */
|
|
362
|
+
markOwn(messageIds: readonly string[]): void { this.mark("own", messageIds.map(id => [id.toLowerCase(), ""] as const)); }
|
|
363
|
+
/** The model was TOLD these exist (a delivered command). Never a read mark. */
|
|
364
|
+
markPresented(keys: readonly string[]): void { this.mark("presented", keys.map(key => [key, ""] as const)); }
|
|
365
|
+
private mark(kind: RoomMarkKind, entries: ReadonlyArray<readonly [string, string]>): void {
|
|
366
|
+
this.assertCurrent();
|
|
367
|
+
if (!entries.length) return;
|
|
368
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
369
|
+
try { writeRoomMarks(this.db, kind, entries); this.db.exec("COMMIT"); }
|
|
370
|
+
catch (error) { try { this.db.exec("ROLLBACK"); } catch { /* already rolled back */ } throw error; }
|
|
371
|
+
}
|
|
372
|
+
/** A last-observed event pointer, not a pending-question inventory or an ACK. */
|
|
373
|
+
observeQuestion(event: { question_id: string; event_id: string; kind: string }, now = new Date().toISOString()): void {
|
|
374
|
+
this.assertCurrent();
|
|
375
|
+
this.snapshot("question_event", { question_id: event.question_id, event_id: event.event_id, kind: event.kind }, true, now);
|
|
376
|
+
}
|
|
377
|
+
close(purge = false): void {
|
|
378
|
+
if (!this.closed) { this.closed = true; this.online = false; this.db.close(); }
|
|
379
|
+
if (purge) { rmSync(this.path, { force: true }); rmSync(`${this.path}-journal`, { force: true }); }
|
|
380
|
+
}
|
|
381
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Bounded, read-only history recovery beside the existing foreground pump. */
|
|
2
|
+
import type { PollPackage } from "./remote-payload.ts";
|
|
3
|
+
import type { RoomStore } from "./room-store.ts";
|
|
4
|
+
|
|
5
|
+
export interface RoomSyncOptions {
|
|
6
|
+
store: RoomStore;
|
|
7
|
+
sessionEventsNegotiated?: boolean;
|
|
8
|
+
/** The served contract advertises notice_reply_version (8885423d). */
|
|
9
|
+
noticeReplyNegotiated?: boolean;
|
|
10
|
+
current(): boolean;
|
|
11
|
+
liveCursor(): string | undefined;
|
|
12
|
+
fetchHistory(args: Record<string, unknown>): Promise<PollPackage>;
|
|
13
|
+
/** Wake the existing transport waiter, never the model. */
|
|
14
|
+
wake(): void;
|
|
15
|
+
onError(): void;
|
|
16
|
+
/** At most12 recovery requests/minute; shared client cooldown still applies. */
|
|
17
|
+
intervalMs?: number;
|
|
18
|
+
}
|
|
19
|
+
export class RoomSync {
|
|
20
|
+
readonly store: RoomStore;
|
|
21
|
+
get sessionEventsNegotiated(): boolean { return this.options.sessionEventsNegotiated === true; }
|
|
22
|
+
eventsWatch(): Record<string, unknown> { return this.sessionEventsNegotiated ? this.store.eventsWatch() : {}; }
|
|
23
|
+
get noticeReplyNegotiated(): boolean { return this.options.noticeReplyNegotiated === true; }
|
|
24
|
+
/** Every room-lane opt-in this copy asks for, beside room_context_version 1. */
|
|
25
|
+
negotiationWatch(): Record<string, unknown> { return { ...this.eventsWatch(), ...(this.noticeReplyNegotiated ? { notice_reply_version: 1 } : {}) }; }
|
|
26
|
+
private options: RoomSyncOptions;
|
|
27
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
28
|
+
private running = false;
|
|
29
|
+
private stopped = false;
|
|
30
|
+
constructor(options: RoomSyncOptions) { this.options = options; this.store = options.store; }
|
|
31
|
+
private current(): boolean { return !this.stopped && this.options.current(); }
|
|
32
|
+
observe(pkg: PollPackage): void {
|
|
33
|
+
if (!this.current()) return;
|
|
34
|
+
try {
|
|
35
|
+
if (pkg.roomPage) this.store.apply(pkg);
|
|
36
|
+
else if (pkg.canonicalState === "idle") this.store.contact();
|
|
37
|
+
else this.store.markBehind();
|
|
38
|
+
// A partial tombstone listing must continue even if SSE sees no insert.
|
|
39
|
+
if (pkg.roomPage?.room.deletions && (pkg.roomPage.room.deletions.truncated || this.store.watch().room_deletions_seen === null)) this.options.wake();
|
|
40
|
+
if (pkg.roomPage?.room.open_check?.some(c => c.state !== "in_progress")) this.options.wake();
|
|
41
|
+
this.schedule();
|
|
42
|
+
} catch { this.store.markOffline(); this.options.onError(); }
|
|
43
|
+
}
|
|
44
|
+
schedule(): void {
|
|
45
|
+
if (!this.current() || this.running || this.timer || !this.options.liveCursor()) return;
|
|
46
|
+
try { if (!this.store.nextRepair()) return; }
|
|
47
|
+
catch { this.store.markOffline(); this.options.onError(); return; }
|
|
48
|
+
this.timer = setTimeout(() => { this.timer = undefined; void this.repairOnce(); }, this.options.intervalMs ?? 5_000);
|
|
49
|
+
this.timer.unref?.();
|
|
50
|
+
}
|
|
51
|
+
/** Public for deterministic transport tests; never a model tool. */
|
|
52
|
+
async repairOnce(): Promise<void> {
|
|
53
|
+
if (!this.current() || this.running) return;
|
|
54
|
+
const cursor = this.options.liveCursor();
|
|
55
|
+
if (!cursor) return; // catch_up WITHOUT a cursor is not a read-only lane.
|
|
56
|
+
this.running = true;
|
|
57
|
+
try {
|
|
58
|
+
const next = this.store.nextRepair();
|
|
59
|
+
if (!next) return;
|
|
60
|
+
const pkg = await this.options.fetchHistory({
|
|
61
|
+
cursor_v2: cursor, catch_up: true,
|
|
62
|
+
...(next.before ? { catch_up_cursor: next.before } : {}),
|
|
63
|
+
...this.store.watch(), ...this.negotiationWatch(),
|
|
64
|
+
});
|
|
65
|
+
if (!this.current()) return;
|
|
66
|
+
if (pkg.canonicalState !== "valid" || !pkg.roomPage?.history || pkg.ownerBundle || pkg.rejectedBundle || pkg.control || pkg.systemNotices.length) throw new Error("repair response is not inactive room history");
|
|
67
|
+
this.store.apply(pkg, next.kind);
|
|
68
|
+
if (pkg.roomPage.room.deletions && (pkg.roomPage.room.deletions.truncated || this.store.watch().room_deletions_seen === null)) this.options.wake();
|
|
69
|
+
} catch { if (this.current()) { this.store.markOffline(); this.options.onError(); } }
|
|
70
|
+
finally { this.running = false; if (this.current()) this.schedule(); }
|
|
71
|
+
}
|
|
72
|
+
close(purge = false): void {
|
|
73
|
+
this.stopped = true;
|
|
74
|
+
if (this.timer) clearTimeout(this.timer);
|
|
75
|
+
this.timer = undefined;
|
|
76
|
+
try { this.store.close(purge); }
|
|
77
|
+
catch { this.options.onError(); } // cleanup cannot break detach/shutdown
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getBond } from "./bond-state.ts";
|
|
3
|
+
import { roomAccess, sameRoomScope } from "./room-access.ts";
|
|
4
|
+
import { resolveEndpoint, resolveToken } from "./auth-store.ts";
|
|
5
|
+
import { roomPrincipalFingerprint, type RoomStoreScope } from "./room-store.ts";
|
|
6
|
+
import { jsonSchemaToTypebox } from "./json-schema.ts";
|
|
7
|
+
import { noteModelToolResult } from "./context-budget.ts";
|
|
8
|
+
import { formatRoomUnreadNotice, noticeLocalRoom, readLocalRoom, roomScopeFromEntries, ROOM_READ_DESCRIPTION, ROOM_READ_SCHEMA, ROOM_READ_TOOL, ROOM_READ_PROMPT_METADATA, ROOM_UNREAD_CUSTOM_TYPE, type RoomReadInput, type RoomUnreadNotice } from "./room-reader.ts";
|
|
9
|
+
|
|
10
|
+
interface RoomReadScope { scope: RoomStoreScope | null; fingerprint: string; offline: boolean; current: () => boolean }
|
|
11
|
+
/** This conversation's attachment (live bond, else its last-known offline copy) and the fence that keeps it. */
|
|
12
|
+
async function roomReadScope(ctx: ExtensionContext): Promise<RoomReadScope | null> {
|
|
13
|
+
const nativeSessionId = ctx.sessionManager.getSessionId();
|
|
14
|
+
const auth = await resolveToken();
|
|
15
|
+
if (!auth) return null;
|
|
16
|
+
const endpoint = new URL(await resolveEndpoint());
|
|
17
|
+
const normalizedEndpoint = `${endpoint.origin}${endpoint.pathname}`;
|
|
18
|
+
const bond = getBond();
|
|
19
|
+
const access = roomAccess(nativeSessionId);
|
|
20
|
+
let scope: RoomStoreScope | null = null;
|
|
21
|
+
if (bond?.projectId && bond.sessionId && bond.speechAttachmentId) {
|
|
22
|
+
scope = { endpoint: normalizedEndpoint, projectId: bond.projectId, nativeSessionId, connectionId: bond.connectionId, sessionId: bond.sessionId, attachmentId: bond.speechAttachmentId };
|
|
23
|
+
} else if (!bond) {
|
|
24
|
+
scope = access ?? roomScopeFromEntries(ctx.sessionManager.getBranch());
|
|
25
|
+
if (scope?.nativeSessionId !== nativeSessionId || scope?.endpoint !== normalizedEndpoint) scope = null;
|
|
26
|
+
}
|
|
27
|
+
if (access === null || (scope && access && !sameRoomScope(scope, access))) scope = null;
|
|
28
|
+
const current = () => {
|
|
29
|
+
try {
|
|
30
|
+
const latestAccess = roomAccess(nativeSessionId);
|
|
31
|
+
if (latestAccess === null || (scope && latestAccess && !sameRoomScope(scope, latestAccess))) return false;
|
|
32
|
+
const now = getBond();
|
|
33
|
+
return ctx.sessionManager.getSessionId() === nativeSessionId && (bond
|
|
34
|
+
? now?.connectionId === bond.connectionId && now?.sessionId === bond.sessionId && now?.speechAttachmentId === bond.speechAttachmentId
|
|
35
|
+
: now === null);
|
|
36
|
+
} catch { return false; }
|
|
37
|
+
};
|
|
38
|
+
return { scope, fingerprint: roomPrincipalFingerprint(auth.token), offline: !bond, current };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function readRoomForContext(ctx: ExtensionContext, input: RoomReadInput = {}): Promise<Record<string, unknown>> {
|
|
42
|
+
try {
|
|
43
|
+
const room = await roomReadScope(ctx);
|
|
44
|
+
if (!room) return { source: "local_room_snapshot", availability: "unavailable", reason: "Log in to select this conversation's saved room copy.", network_used: false };
|
|
45
|
+
return await readLocalRoom(room.scope, room.fingerprint, input, { offline: room.offline, current: room.current });
|
|
46
|
+
}
|
|
47
|
+
catch { return { source: "local_room_snapshot", availability: "unavailable", reason: "Local room storage is unavailable. Use the existing online transcript/record tools.", network_used: false }; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Unread counts for a wake ("always") or Pi's tool-result boundary ("fresh"); null when no copy is readable. */
|
|
51
|
+
export async function roomUnreadForContext(ctx: ExtensionContext, mode: "always" | "fresh"): Promise<RoomUnreadNotice | null> {
|
|
52
|
+
try {
|
|
53
|
+
const room = await roomReadScope(ctx);
|
|
54
|
+
return room ? await noticeLocalRoom(room.scope, room.fingerprint, mode, { offline: room.offline, current: room.current }) : null;
|
|
55
|
+
} catch { return null; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Pi's tool-result boundary (`turn_end`) is where the mid-turn notice goes, and
|
|
60
|
+
* it doubles as the check before Pi's answer: the request that writes the final
|
|
61
|
+
* message always follows either the wake notice or one of these boundaries
|
|
62
|
+
* (ce1ffca1). Appended to the current turn as a hidden entry, so no earlier
|
|
63
|
+
* provider message is re-rendered. Nothing new, or no tool result: nothing added.
|
|
64
|
+
*/
|
|
65
|
+
export async function roomUnreadBoundary(event: { toolResults: readonly unknown[]; outcome: string }, notice: () => Promise<RoomUnreadNotice | null>): Promise<{ entries: Array<{ type: "custom_message"; customType: string; content: string; display: boolean }> } | undefined> {
|
|
66
|
+
if (event.outcome !== "completed" || event.toolResults.length === 0) return undefined;
|
|
67
|
+
const unread = await notice();
|
|
68
|
+
if (!unread?.fresh) return undefined;
|
|
69
|
+
return { entries: [{ type: "custom_message", customType: ROOM_UNREAD_CUSTOM_TYPE, content: formatRoomUnreadNotice(unread), display: false }] };
|
|
70
|
+
}
|
|
71
|
+
export function registerRoomReader(pi: ExtensionAPI, activateFallback?: () => boolean): void {
|
|
72
|
+
pi.registerTool({
|
|
73
|
+
name: ROOM_READ_TOOL, label: "Read room context", description: ROOM_READ_DESCRIPTION, ...ROOM_READ_PROMPT_METADATA,
|
|
74
|
+
parameters: jsonSchemaToTypebox(ROOM_READ_SCHEMA),
|
|
75
|
+
async execute(_id, params, _signal, _update, ctx) {
|
|
76
|
+
const details = await readRoomForContext(ctx, params as RoomReadInput);
|
|
77
|
+
const coverage = details.coverage as { complete_through_cutoff?: boolean } | undefined;
|
|
78
|
+
if (details.availability === "unavailable" || details.availability === "missing" || coverage?.complete_through_cutoff === false) {
|
|
79
|
+
if (activateFallback?.()) details.online_fallback_tool = "get_session_transcript";
|
|
80
|
+
}
|
|
81
|
+
const text = JSON.stringify(details);
|
|
82
|
+
noteModelToolResult(ROOM_READ_TOOL, text.length);
|
|
83
|
+
return { content: [{ type: "text", text }], details };
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|