gentle-pi 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +8 -5
  2. package/assets/agents/gentle-ai-worker.md +1 -1
  3. package/assets/design-canvas/canvas.css +648 -0
  4. package/assets/design-canvas/canvas.js +1497 -0
  5. package/assets/design-canvas/index.html +88 -0
  6. package/assets/design-canvas/inspector.js +290 -0
  7. package/assets/orchestrator-delegation-reference.md +2 -2
  8. package/assets/orchestrator-verification.md +1 -1
  9. package/assets/orchestrator.md +2 -2
  10. package/bin/engram-guard +1 -1
  11. package/bin/gentle-shell.mjs +101 -151
  12. package/docs/bundled-install.md +47 -42
  13. package/docs/delegated-verification.md +6 -0
  14. package/docs/gentle-agents-activity.md +5 -1
  15. package/docs/gentle-music.md +61 -0
  16. package/docs/gentle-shell.md +77 -7
  17. package/docs/install-wizard.md +30 -6
  18. package/docs/permission-modes.md +7 -2
  19. package/docs/readme-reference.md +19 -15
  20. package/docs/session-profile-format.md +26 -0
  21. package/docs/voice.md +259 -0
  22. package/docs/yolo-mode.md +17 -2
  23. package/extensions/ask-user-choice.ts +4 -1
  24. package/extensions/gentle-agents.ts +173 -38
  25. package/extensions/gentle-ai.ts +214 -69
  26. package/extensions/gentle-design.ts +158 -0
  27. package/extensions/gentle-music.ts +289 -0
  28. package/extensions/gentle-shell.ts +332 -73
  29. package/extensions/gentle-voice.ts +11 -0
  30. package/extensions/session-scratch.ts +41 -22
  31. package/extensions/skill-registry.ts +63 -8
  32. package/lib/agent-profiles.ts +26 -0
  33. package/lib/agents-config.ts +35 -0
  34. package/lib/agents-protocol.ts +57 -1
  35. package/lib/agents-runner.ts +52 -3
  36. package/lib/agents-session-transport.ts +101 -6
  37. package/lib/agents-widget.ts +14 -1
  38. package/lib/auto-permission-memory.ts +48 -18
  39. package/lib/auto-permission-runtime.ts +21 -6
  40. package/lib/clipboard-image.ts +147 -0
  41. package/lib/command-palette-catalog.ts +2 -0
  42. package/lib/commit-slice-signal.ts +1 -1
  43. package/lib/context-backstop.ts +36 -0
  44. package/lib/design-canvas-server.ts +947 -0
  45. package/lib/design-comments.ts +437 -0
  46. package/lib/design-handoffs.ts +313 -0
  47. package/lib/design-open-browser.ts +58 -0
  48. package/lib/design-sources.ts +120 -0
  49. package/lib/external-editor.ts +31 -0
  50. package/lib/gentle-design.ts +513 -0
  51. package/lib/gentle-shell-bundled.ts +123 -49
  52. package/lib/gentle-shell-launcher.ts +9 -4
  53. package/lib/gentle-shell-startup-update.ts +61 -9
  54. package/lib/gentle-shell-uninstall.ts +33 -1
  55. package/lib/image-attachments.ts +150 -0
  56. package/lib/inprocess-reviewer.ts +33 -2
  57. package/lib/nan-provider.ts +3 -0
  58. package/lib/native-review-cli.ts +11 -4
  59. package/lib/notification-audio-native.ts +149 -13
  60. package/lib/notification-audio.ts +4 -0
  61. package/lib/notification-pulse-worker.ts +82 -18
  62. package/lib/notification-service.ts +5 -1
  63. package/lib/orchestrator-presence.ts +78 -8
  64. package/lib/orchestrator-scope.ts +10 -0
  65. package/lib/orchestrator-state.ts +11 -3
  66. package/lib/permission-mode-policy.ts +2 -2
  67. package/lib/review-host-relay.ts +11 -2
  68. package/lib/review-reminder-receipt.ts +24 -1
  69. package/lib/session-profile-append-controller.ts +713 -0
  70. package/lib/session-profile-disk-reader.ts +182 -0
  71. package/lib/session-profile-persistence.ts +15 -7
  72. package/lib/session-scratch.ts +52 -3
  73. package/lib/session-worktree-registry.ts +7 -0
  74. package/lib/shell-bar.ts +4 -3
  75. package/lib/shell-changes.ts +81 -5
  76. package/lib/shell-prompt.ts +4 -4
  77. package/lib/shell-sidebar-layout.ts +26 -6
  78. package/lib/shell-todo.ts +1 -1
  79. package/lib/verifier-admission.ts +5 -4
  80. package/lib/vim-editor-adapter.ts +11 -8
  81. package/lib/voice-artifacts.ts +188 -0
  82. package/lib/voice-command.ts +576 -0
  83. package/lib/voice-install.ts +455 -0
  84. package/lib/voice-platform.ts +99 -0
  85. package/lib/voice-push-to-talk.ts +262 -0
  86. package/lib/voice-session.ts +460 -0
  87. package/lib/voice-settings.ts +50 -0
  88. package/lib/voice-ui.ts +158 -0
  89. package/lib/yolo-session-policy.ts +58 -22
  90. package/package.json +1 -1
  91. package/runtime/gentle-shell-bundled.mjs +122 -48
  92. package/runtime/gentle-shell-launcher.mjs +9 -4
  93. package/runtime/gentle-shell-startup-update.mjs +61 -9
  94. package/runtime/gentle-shell-uninstall.mjs +33 -1
  95. package/runtime/native-review-cli.mjs +11 -4
  96. package/scripts/bundled-engram-migration.mjs +0 -6
  97. package/scripts/gentle-ai-installer.mjs +10 -10
  98. package/scripts/installer-downloads.mjs +7 -7
  99. package/scripts/installer-preflight.mjs +5 -3
  100. package/scripts/installer-windows.mjs +37 -7
  101. package/scripts/main-channel.mjs +91 -17
  102. package/scripts/pi-update.mjs +34 -6
  103. package/scripts/verify-package-files.mjs +26 -2
  104. package/scripts/voice-worker.cjs +476 -0
  105. package/skills/design/SKILL.md +110 -0
  106. package/tests/agent-profiles.test.ts +24 -0
  107. package/tests/agents-config.test.ts +61 -0
  108. package/tests/agents-fake-child.ts +8 -1
  109. package/tests/agents-protocol.test.ts +32 -0
  110. package/tests/agents-runner.test.ts +226 -5
  111. package/tests/agents-session-transport.test.ts +53 -3
  112. package/tests/ask-user-choice.test.ts +30 -0
  113. package/tests/auto-mode.test.ts +17 -0
  114. package/tests/auto-permission-memory-diagnostics.test.ts +56 -0
  115. package/tests/auto-permission-runtime.test.ts +85 -1
  116. package/tests/autonomous-guard.test.ts +20 -0
  117. package/tests/bundled-install.test.ts +3 -3
  118. package/tests/clipboard-image.test.ts +114 -0
  119. package/tests/command-palette.test.ts +1 -1
  120. package/tests/context-backstop.test.ts +70 -4
  121. package/tests/delegate-for-reason-contract.test.ts +2 -1
  122. package/tests/design-canvas-assets.test.ts +710 -0
  123. package/tests/design-canvas-server.test.ts +1096 -0
  124. package/tests/design-comments.test.ts +201 -0
  125. package/tests/design-handoffs.test.ts +282 -0
  126. package/tests/design-open-browser.test.ts +70 -0
  127. package/tests/design-sources.test.ts +132 -0
  128. package/tests/engram-safety.test.ts +132 -57
  129. package/tests/external-editor.test.ts +84 -0
  130. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +3 -3
  131. package/tests/gentle-agents.test.ts +365 -20
  132. package/tests/gentle-ai-binary.test.ts +1 -1
  133. package/tests/gentle-ai-installer.test.ts +51 -51
  134. package/tests/gentle-ai.test.ts +292 -13
  135. package/tests/gentle-design.test.ts +1313 -0
  136. package/tests/gentle-music.test.ts +335 -0
  137. package/tests/gentle-shell-bundled.test.ts +301 -248
  138. package/tests/gentle-shell-launcher.test.ts +28 -2
  139. package/tests/gentle-shell-startup-update.test.ts +77 -1
  140. package/tests/gentle-shell-uninstall.test.ts +59 -0
  141. package/tests/gentle-shell.test.ts +183 -8
  142. package/tests/gentle-todo.test.ts +11 -0
  143. package/tests/image-attachments.test.ts +119 -0
  144. package/tests/image-paste-chips.test.ts +236 -0
  145. package/tests/inprocess-reviewer.test.ts +55 -2
  146. package/tests/installer-bundles.test.ts +12 -6
  147. package/tests/installer-posix-bootstrap.test.ts +8 -8
  148. package/tests/installer-preflight.test.ts +16 -0
  149. package/tests/installer-server.test.ts +2 -2
  150. package/tests/installer-windows-bootstrap.test.ts +61 -3
  151. package/tests/installer-windows-diagnostics.test.ts +111 -0
  152. package/tests/main-channel-upgrade.test.ts +232 -2
  153. package/tests/main-channel.test.ts +140 -21
  154. package/tests/nan-provider.test.ts +36 -0
  155. package/tests/native-review-capability-contract.test.ts +11 -4
  156. package/tests/native-review-parity.test.ts +1 -1
  157. package/tests/notification-audio-native.test.ts +128 -9
  158. package/tests/notification-customize.test.ts +17 -5
  159. package/tests/odd-routing-canonical-ratchet.test.ts +2 -1
  160. package/tests/orchestrator-budget.test.ts +5 -2
  161. package/tests/orchestrator-presence.test.ts +100 -2
  162. package/tests/orchestrator-rdd-ownership.test.ts +2 -2
  163. package/tests/orchestrator-scope.test.ts +14 -0
  164. package/tests/orchestrator-state.test.ts +29 -0
  165. package/tests/package-files.test.ts +34 -0
  166. package/tests/package-manifest.test.ts +6 -6
  167. package/tests/permission-mode-policy.test.ts +10 -1
  168. package/tests/pi-update.test.ts +99 -4
  169. package/tests/rdd-aware-verification-contract.test.ts +1 -1
  170. package/tests/review-host-relay-routing.test.ts +88 -1
  171. package/tests/review-host-relay.test.ts +9 -2
  172. package/tests/review-json-arguments.test.ts +20 -3
  173. package/tests/review-loadout-rdd-off-sdk.test.ts +23 -2
  174. package/tests/review-relay-transport-agent.test.ts +26 -0
  175. package/tests/review-reminder-receipt.test.ts +31 -1
  176. package/tests/session-changes.test.ts +2 -1
  177. package/tests/session-profile-append-controller-fork.test.ts +458 -0
  178. package/tests/session-profile-append-controller-pi.test.ts +290 -0
  179. package/tests/session-profile-append-controller.test.ts +731 -0
  180. package/tests/session-profile-disk-reader-pi.test.ts +183 -0
  181. package/tests/session-profile-disk-reader.test.ts +311 -0
  182. package/tests/session-scratch.test.ts +68 -2
  183. package/tests/session-worktree-registry.test.ts +13 -1
  184. package/tests/shell-bar.test.ts +14 -2
  185. package/tests/shell-changes.test.ts +148 -1
  186. package/tests/shell-prompt.test.ts +3 -3
  187. package/tests/shell-render-cache.test.ts +134 -1
  188. package/tests/shell-sidebar-layout.test.ts +50 -0
  189. package/tests/short-check-routing-contract.test.ts +1 -1
  190. package/tests/skill-collision-prefixes.test.ts +3 -1
  191. package/tests/skill-registry-watcher.test.ts +17 -5
  192. package/tests/skill-registry.test.ts +47 -3
  193. package/tests/task-size-routing-contract.test.ts +1 -1
  194. package/tests/test-strength-contract.test.ts +1 -1
  195. package/tests/verifier-admission.test.ts +18 -1
  196. package/tests/voice-command.test.ts +773 -0
  197. package/tests/voice-install.test.ts +323 -0
  198. package/tests/voice-platform.test.ts +176 -0
  199. package/tests/voice-push-to-talk.test.ts +444 -0
  200. package/tests/voice-session.test.ts +434 -0
  201. package/tests/voice-settings.test.ts +49 -0
  202. package/tests/voice-ui.test.ts +97 -0
  203. package/tests/voice-worker.test.ts +445 -0
  204. package/tests/weak-model-economy-prompt.test.ts +32 -0
  205. package/tests/windows-hidden-processes.test.ts +25 -2
  206. package/tests/yolo-customize.test.ts +5 -1
  207. package/tests/yolo-mode.test.ts +1 -1
  208. package/tests/yolo-session-policy-capture.test.ts +104 -0
@@ -0,0 +1,713 @@
1
+ // Observable append authority and explicit lifecycle seams; no SDK or consumers.
2
+ import { readFileSync } from "node:fs";
3
+ import { isDeepStrictEqual } from "node:util";
4
+ import {
5
+ hasSessionProfileCandidateMetadata,
6
+ readSessionProfileDisk,
7
+ type DiskProfileResult,
8
+ type SessionProfileSource,
9
+ } from "./session-profile-disk-reader.ts";
10
+ import {
11
+ createSessionProfileBind,
12
+ createSessionProfileClear,
13
+ isSessionProfileFamilyEntry,
14
+ readSessionProfileEntry,
15
+ SESSION_PROFILE_CUSTOM_TYPE,
16
+ type SessionProfileBindPayload,
17
+ type SessionProfileClearPayload,
18
+ } from "./session-profile-persistence.ts";
19
+
20
+ type Entry = {
21
+ id: string;
22
+ type: string;
23
+ customType?: string;
24
+ data?: unknown;
25
+ [key: string]: unknown;
26
+ };
27
+ type Capture = { session: string; file: string | undefined; branch: Entry[] };
28
+ type Scope = {
29
+ failed: Set<string>;
30
+ established: boolean;
31
+ ambiguous: boolean;
32
+ // Ambiguity may clear after exact recovery, but historical unknown or changed
33
+ // whole records remain untrusted on branch return. Source/session/file scope
34
+ // belongs to this controller and its enclosing scopes map.
35
+ uncertain: boolean;
36
+ certified: Map<string, Entry>;
37
+ // Copied uncertainty is not evidence of a failed append and is never excluded
38
+ // through the reader's known-failed-ID seam.
39
+ untrusted: Set<string>;
40
+ pending?: Entry;
41
+ };
42
+ /** Closed reasons the controller adds on top of the disk reader's own. */
43
+ export type SessionProfileControllerReason =
44
+ | "ambiguous-append"
45
+ | "append-failed-authority-unavailable"
46
+ | "append-not-corroborated"
47
+ | "append-record-ambiguous"
48
+ | "append-scope-changed"
49
+ | "detached-source"
50
+ | "source-changed"
51
+ | "unobservable-append"
52
+ | "unobservable-source";
53
+ export type SessionProfileAppendOutcome = {
54
+ status:
55
+ | "persisted"
56
+ | "not-persisted"
57
+ | "append-failed-preserved"
58
+ | "indeterminate";
59
+ state:
60
+ | DiskProfileResult
61
+ | { status: "indeterminate"; reason: SessionProfileControllerReason };
62
+ };
63
+ export interface SessionProfileAppendOptions {
64
+ readFile?: (path: string) => string;
65
+ }
66
+ export interface SessionProfileForkView extends SessionProfileSource {
67
+ getBranch(fromId?: string): readonly unknown[];
68
+ getEntry(id: string): unknown;
69
+ getHeader(): unknown;
70
+ }
71
+ export type SessionProfileForkTarget = {
72
+ entryId: string;
73
+ position: "before" | "at";
74
+ };
75
+ declare const forkProof: unique symbol;
76
+ export type SessionProfileForkPreparation = {
77
+ readonly [forkProof]: "preparation";
78
+ };
79
+ export type SessionProfileForkHandoff = { readonly [forkProof]: "handoff" };
80
+ export type SessionProfileForkEvidence =
81
+ | { status: "captured"; evidence: SessionProfileForkPreparation }
82
+ | { status: "indeterminate"; reason: string };
83
+ export interface SessionProfileAttachment {
84
+ bind(
85
+ name: string,
86
+ models: SessionProfileBindPayload["modelProfiles"],
87
+ ): SessionProfileAppendOutcome;
88
+ clear(): SessionProfileAppendOutcome;
89
+ refresh(): SessionProfileAppendOutcome;
90
+ }
91
+ export type SessionProfileAttachContext = {
92
+ reason: "startup" | "reload" | "new" | "resume" | "fork";
93
+ previousSessionFile?: string;
94
+ fork?: {
95
+ handoff: SessionProfileForkHandoff;
96
+ publicView: SessionProfileForkView;
97
+ };
98
+ };
99
+ export type SessionProfileDetachContext = {
100
+ reason: "reload" | "new" | "resume" | "fork" | "quit";
101
+ targetSessionFile?: string;
102
+ preparation?: SessionProfileForkPreparation;
103
+ };
104
+ type ForkSnapshot = {
105
+ owner: object;
106
+ generation: number;
107
+ source: SessionProfileSource;
108
+ scope: Capture;
109
+ path: Entry[];
110
+ leaf: string | null;
111
+ failed: Set<string>;
112
+ untrusted: Set<string>;
113
+ ambiguous: boolean;
114
+ };
115
+ type ForkTransfer = ForkSnapshot & { targetFile?: string; consumed: boolean };
116
+ type ForkStore = {
117
+ version: 1;
118
+ preparations: WeakMap<object, ForkSnapshot>;
119
+ transfers: WeakMap<object, ForkTransfer>;
120
+ };
121
+ // Keep the key stable across code reloads; the value owns protocol negotiation.
122
+ const forkStoreKey = Symbol.for("gentle-pi.session-profile.fork-evidence");
123
+ function forkStore(): ForkStore {
124
+ const existing = Reflect.get(globalThis, forkStoreKey) as
125
+ | ForkStore
126
+ | undefined;
127
+ if (existing) {
128
+ if (
129
+ existing.version !== 1 ||
130
+ !(existing.preparations instanceof WeakMap) ||
131
+ !(existing.transfers instanceof WeakMap)
132
+ )
133
+ throw new Error("fork-protocol");
134
+ return existing;
135
+ }
136
+ const store: ForkStore = {
137
+ version: 1,
138
+ preparations: new WeakMap(),
139
+ transfers: new WeakMap(),
140
+ };
141
+ Object.defineProperty(globalThis, forkStoreKey, { value: store });
142
+ return store;
143
+ }
144
+ function detached<T>(value: T): T {
145
+ try {
146
+ return JSON.parse(JSON.stringify(value));
147
+ } catch {
148
+ // Callers fail closed without exposing getter, toJSON or serialization errors.
149
+ throw new TypeError("Unserializable controller snapshot");
150
+ }
151
+ }
152
+ function unavailable(reason: SessionProfileControllerReason): SessionProfileAppendOutcome {
153
+ return { status: "indeterminate", state: { status: "indeterminate", reason } };
154
+ }
155
+ function sameScope(a: Capture, b: Capture): boolean {
156
+ return a.session === b.session && a.file === b.file;
157
+ }
158
+ function prefix(a: Entry[], b: Entry[]): boolean {
159
+ return b.length >= a.length && isDeepStrictEqual(a, b.slice(0, a.length));
160
+ }
161
+ function noConversation(branch: Entry[]): boolean {
162
+ return !branch.some((entry) => entry.type === "message");
163
+ }
164
+
165
+ /**
166
+ * appendEntry is the synchronous public extension seam (void, not an ID).
167
+ * Only an explicit operation can activate preflush memory. Refresh never
168
+ * restores unwritten getBranch selections. Quarantine survives branch changes
169
+ * for each source/session/file. Only confirmed opaque fork evidence can convey
170
+ * copied-path quarantine; attachment handles never transfer activation.
171
+ * Corroboration does not promise fsync, full ancestry or external-writer safety.
172
+ */
173
+ export function createSessionProfileAppendController(
174
+ initialSource: SessionProfileSource,
175
+ initialAppendEntry: (customType: string, data: unknown) => void,
176
+ options: SessionProfileAppendOptions = {},
177
+ ) {
178
+ // Evidence survives detach, but source identity still partitions all scopes.
179
+ const sourceScopes = new WeakMap<
180
+ SessionProfileSource,
181
+ Map<string, Map<string | undefined, Scope>>
182
+ >();
183
+ let scopes = new Map<string, Map<string | undefined, Scope>>();
184
+ sourceScopes.set(initialSource, scopes);
185
+ let source: SessionProfileSource | undefined = initialSource;
186
+ let appendEntry: ((customType: string, data: unknown) => void) | undefined =
187
+ initialAppendEntry;
188
+ let generation = 0;
189
+ const identity = {};
190
+ let active: Scope | undefined;
191
+ let readFile: ((file: string) => string) | undefined =
192
+ options.readFile ?? ((file: string) => readFileSync(file, "utf8"));
193
+ function capture(): Capture {
194
+ if (!source) throw new Error("detached-source");
195
+ const session = source.getSessionId(),
196
+ file = source.getSessionFile();
197
+ if (!session || (file !== undefined && (typeof file !== "string" || !file)))
198
+ throw new Error("source");
199
+ const raw = source.getBranch();
200
+ if (!Array.isArray(raw)) throw new Error("branch");
201
+ // Match the reader's own-field requirements before JSON can hide inherited
202
+ // metadata or an entry's toJSON can manufacture otherwise missing fields.
203
+ for (const value of raw) {
204
+ if (
205
+ isSessionProfileFamilyEntry(value) &&
206
+ !hasSessionProfileCandidateMetadata(value)
207
+ )
208
+ throw new Error("profile-metadata");
209
+ }
210
+ const branch = detached(raw) as Entry[];
211
+ if (
212
+ branch.some(
213
+ (entry) =>
214
+ !entry ||
215
+ typeof entry !== "object" ||
216
+ typeof entry.id !== "string" ||
217
+ !entry.id,
218
+ )
219
+ )
220
+ throw new Error("metadata");
221
+ if (new Set(branch.map((entry) => entry.id)).size !== branch.length)
222
+ throw new Error("duplicate");
223
+ return { session, file, branch };
224
+ }
225
+ function scope(c: Capture): Scope {
226
+ let files = scopes.get(c.session);
227
+ if (!files) {
228
+ files = new Map();
229
+ scopes.set(c.session, files);
230
+ }
231
+ let state = files.get(c.file);
232
+ if (!state) {
233
+ state = {
234
+ failed: new Set(),
235
+ established: false,
236
+ ambiguous: false,
237
+ uncertain: false,
238
+ certified: new Map(),
239
+ untrusted: new Set(),
240
+ };
241
+ files.set(c.file, state);
242
+ }
243
+ if (active !== state) {
244
+ if (active) active.pending = undefined;
245
+ active = state;
246
+ }
247
+ return state;
248
+ }
249
+ function stable(c: Capture): boolean {
250
+ const now = capture();
251
+ return sameScope(c, now) && isDeepStrictEqual(c.branch, now.branch);
252
+ }
253
+ function authority(c: Capture, s: Scope) {
254
+ let missing = c.file === undefined;
255
+ const state = readSessionProfileDisk(source, {
256
+ knownFailedEntryIds: s.failed,
257
+ readFile(file) {
258
+ try {
259
+ if (!readFile) throw new Error("detached-reader");
260
+ const text = readFile(file);
261
+ s.established = true;
262
+ return text;
263
+ } catch (error) {
264
+ missing = (error as NodeJS.ErrnoException)?.code === "ENOENT";
265
+ if (!missing) s.established = true;
266
+ throw error;
267
+ }
268
+ },
269
+ });
270
+ // Accepted observable evidence, not proof of first flush. Only append's exact
271
+ // NEW record or this controller's still-exact pending selection can activate.
272
+ // A fresh refresh never restores older unwritten branch entries.
273
+ return {
274
+ state,
275
+ preflush:
276
+ missing &&
277
+ !s.established &&
278
+ !s.ambiguous &&
279
+ s.failed.size === 0 &&
280
+ noConversation(c.branch) &&
281
+ stable(c),
282
+ };
283
+ }
284
+ function selected(c: Capture, s: Scope): Entry | undefined {
285
+ return c.branch.findLast((entry) => isSessionProfileFamilyEntry(entry) && !s.failed.has(entry.id));
286
+ }
287
+ function uncertainSelection(c: Capture, s: Scope): boolean {
288
+ // Under unknown branch movement, even the raw newest family entry must be
289
+ // a separately corroborated recovery. Exclusion cannot revive an older
290
+ // certified selection when the newest entry's provenance is ambiguous.
291
+ const latest = c.branch.findLast(isSessionProfileFamilyEntry);
292
+ return (
293
+ (latest !== undefined && s.untrusted.has(latest.id)) ||
294
+ (s.uncertain &&
295
+ (!latest || !isDeepStrictEqual(s.certified.get(latest.id), latest)))
296
+ );
297
+ }
298
+ function refresh(): SessionProfileAppendOutcome {
299
+ try {
300
+ const c = capture(),
301
+ s = scope(c),
302
+ latest = selected(c, s);
303
+ if (s.pending && !isDeepStrictEqual(s.pending, latest))
304
+ s.pending = undefined;
305
+ const observed = authority(c, s);
306
+ if (!stable(c)) {
307
+ s.pending = undefined;
308
+ return unavailable("source-changed");
309
+ }
310
+ if (observed.state.status !== "indeterminate") {
311
+ s.pending = undefined;
312
+ if (s.ambiguous || uncertainSelection(c, s))
313
+ return unavailable("ambiguous-append");
314
+ return detached({ status: "persisted", state: observed.state });
315
+ }
316
+ if (observed.preflush && s.pending)
317
+ return detached({
318
+ status: "not-persisted",
319
+ state: readSessionProfileEntry(s.pending),
320
+ });
321
+ s.pending = undefined;
322
+ return { status: "indeterminate", state: observed.state };
323
+ } catch {
324
+ if (active) active.pending = undefined;
325
+ return unavailable("unobservable-source");
326
+ }
327
+ }
328
+ function append(
329
+ payload: SessionProfileBindPayload | SessionProfileClearPayload,
330
+ ): SessionProfileAppendOutcome {
331
+ let before: Capture;
332
+ try {
333
+ before = capture();
334
+ } catch {
335
+ if (active) active.pending = undefined;
336
+ return unavailable("unobservable-source");
337
+ }
338
+ const s = scope(before);
339
+ s.pending = undefined;
340
+ let preflush: boolean;
341
+ try {
342
+ preflush = authority(before, s).preflush;
343
+ if (!stable(before)) return unavailable("source-changed");
344
+ } catch {
345
+ return unavailable("unobservable-source");
346
+ }
347
+ let threw = false;
348
+ try {
349
+ if (!appendEntry) return unavailable("detached-source");
350
+ appendEntry(SESSION_PROFILE_CUSTOM_TYPE, detached(payload));
351
+ } catch {
352
+ threw = true;
353
+ }
354
+ let after: Capture;
355
+ try {
356
+ after = capture();
357
+ } catch {
358
+ s.ambiguous = true;
359
+ s.uncertain = true;
360
+ // Snapshot failure may still leave observable public ID evidence. Never
361
+ // invent an ID or move quarantine into an unconfirmed session/file.
362
+ if (threw) {
363
+ try {
364
+ if (
365
+ source.getSessionId() === before.session &&
366
+ source.getSessionFile() === before.file
367
+ ) {
368
+ const old = new Set(before.branch.map((entry) => entry.id));
369
+ for (const value of source.getBranch()) {
370
+ if (value && typeof value === "object") {
371
+ const entry = value as Entry;
372
+ if (
373
+ isSessionProfileFamilyEntry(entry) &&
374
+ typeof entry.id === "string" &&
375
+ entry.id &&
376
+ !old.has(entry.id)
377
+ )
378
+ s.failed.add(entry.id);
379
+ }
380
+ }
381
+ }
382
+ } catch {
383
+ /* No manufactured evidence when public access itself fails. */
384
+ }
385
+ }
386
+ return unavailable("unobservable-append");
387
+ }
388
+ if (!sameScope(before, after)) {
389
+ s.ambiguous = true;
390
+ s.uncertain = true;
391
+ const changed = scope(after);
392
+ changed.ambiguous = true;
393
+ changed.uncertain = true;
394
+ changed.pending = undefined;
395
+ return unavailable("append-scope-changed");
396
+ }
397
+ const oldIds = new Set(before.branch.map((entry) => entry.id));
398
+ const added = after.branch.filter((entry) => !oldIds.has(entry.id));
399
+ if (threw) {
400
+ for (const entry of added) if (isSessionProfileFamilyEntry(entry)) s.failed.add(entry.id);
401
+ if (!prefix(before.branch, after.branch)) {
402
+ s.ambiguous = true;
403
+ s.uncertain = true;
404
+ }
405
+ try {
406
+ const observed = authority(after, s).state;
407
+ if (
408
+ !stable(after) ||
409
+ s.ambiguous ||
410
+ uncertainSelection(after, s) ||
411
+ observed.status === "indeterminate"
412
+ )
413
+ return unavailable("append-failed-authority-unavailable");
414
+ return detached({ status: "append-failed-preserved", state: observed });
415
+ } catch {
416
+ return unavailable("append-failed-authority-unavailable");
417
+ }
418
+ }
419
+ // Public branch evidence, never the callback's return value, identifies the
420
+ // one new selected profile record. Neither old nor mismatched records count.
421
+ const entry = added[0];
422
+ if (
423
+ !prefix(before.branch, after.branch) ||
424
+ added.length !== 1 ||
425
+ !entry ||
426
+ after.branch.at(-1)?.id !== entry.id ||
427
+ entry.type !== "custom" ||
428
+ entry.customType !== SESSION_PROFILE_CUSTOM_TYPE ||
429
+ !isDeepStrictEqual(entry.data, payload) ||
430
+ s.failed.has(entry.id)
431
+ ) {
432
+ s.ambiguous = true;
433
+ s.uncertain = true;
434
+ return unavailable("append-record-ambiguous");
435
+ }
436
+ try {
437
+ const observed = authority(after, s);
438
+ if (!stable(after)) return unavailable("source-changed");
439
+ if (
440
+ observed.state.status !== "indeterminate" &&
441
+ observed.state.entryIndex === after.branch.length - 1
442
+ ) {
443
+ s.ambiguous = false;
444
+ s.certified.set(entry.id, detached(entry));
445
+ return detached({ status: "persisted", state: observed.state });
446
+ }
447
+ // The new exact explicit record is locally witnessed, not restored memory.
448
+ if (preflush && observed.preflush) {
449
+ s.pending = detached(entry);
450
+ return detached({
451
+ status: "not-persisted",
452
+ state: readSessionProfileEntry(entry),
453
+ });
454
+ }
455
+ // Reported as not adopted: a later disk recovery must not adopt it
456
+ // silently. Only a fresh explicit, corroborated bind or clear recovers.
457
+ s.uncertain = true;
458
+ return unavailable("append-not-corroborated");
459
+ } catch {
460
+ s.uncertain = true;
461
+ return unavailable("append-not-corroborated");
462
+ }
463
+ }
464
+ function attachment(): SessionProfileAttachment {
465
+ const epoch = generation;
466
+ const live = () => source !== undefined && epoch === generation;
467
+ return {
468
+ bind: (name, models) =>
469
+ live()
470
+ ? append(createSessionProfileBind(name, models))
471
+ : unavailable("detached-source"),
472
+ clear: () =>
473
+ live()
474
+ ? append(createSessionProfileClear())
475
+ : unavailable("detached-source"),
476
+ refresh: () => (live() ? refresh() : unavailable("detached-source")),
477
+ };
478
+ }
479
+ function negativeEvidence(path: Entry[], s?: Scope) {
480
+ const failed = new Set<string>(),
481
+ untrusted = new Set<string>();
482
+ for (const entry of path) {
483
+ if (!isSessionProfileFamilyEntry(entry)) continue;
484
+ if (s?.failed.has(entry.id)) failed.add(entry.id);
485
+ if (
486
+ s?.untrusted.has(entry.id) ||
487
+ (s?.uncertain && !isDeepStrictEqual(s.certified.get(entry.id), entry))
488
+ )
489
+ untrusted.add(entry.id);
490
+ }
491
+ return { failed, untrusted, ambiguous: s?.ambiguous ?? false };
492
+ }
493
+ function captureForkEvidence(
494
+ target: SessionProfileForkTarget,
495
+ view: SessionProfileForkView,
496
+ ): SessionProfileForkEvidence {
497
+ try {
498
+ if (
499
+ source !== view ||
500
+ !target.entryId ||
501
+ !["at", "before"].includes(target.position)
502
+ )
503
+ throw new Error("fork-source");
504
+ const c = capture();
505
+ const selected = detached(view.getEntry(target.entryId)) as Entry & {
506
+ message?: { role?: string };
507
+ };
508
+ if (
509
+ !selected ||
510
+ selected.id !== target.entryId ||
511
+ (target.position === "before" &&
512
+ (selected.type !== "message" || selected.message?.role !== "user"))
513
+ )
514
+ throw new Error("fork-target");
515
+ const leaf =
516
+ target.position === "at"
517
+ ? selected.id
518
+ : (selected.parentId as string | null);
519
+ if (leaf !== null && (typeof leaf !== "string" || !leaf))
520
+ throw new Error("fork-target");
521
+ const raw = leaf === null ? [] : view.getBranch(leaf);
522
+ if (!Array.isArray(raw)) throw new Error("fork-path");
523
+ for (const value of raw) {
524
+ if (!value || typeof value !== "object") throw new Error("fork-metadata");
525
+ const e = value as Entry;
526
+ if (
527
+ isSessionProfileFamilyEntry(e) &&
528
+ !hasSessionProfileCandidateMetadata(e)
529
+ )
530
+ throw new Error("fork-metadata");
531
+ }
532
+ const path = detached(raw) as Entry[];
533
+ if (
534
+ (leaf !== null && path.at(-1)?.id !== leaf) ||
535
+ path.some((e) => typeof e.id !== "string" || !e.id) ||
536
+ new Set(path.map((e) => e.id)).size !== path.length ||
537
+ !stable(c)
538
+ )
539
+ throw new Error("fork-path");
540
+ const s = scopes.get(c.session)?.get(c.file);
541
+ const token = Object.freeze({}) as SessionProfileForkPreparation;
542
+ forkStore().preparations.set(token, {
543
+ owner: identity,
544
+ generation,
545
+ source,
546
+ scope: c,
547
+ path,
548
+ leaf,
549
+ ...negativeEvidence(path, s),
550
+ });
551
+ return { status: "captured", evidence: token };
552
+ } catch {
553
+ return { status: "indeterminate", reason: "unobservable-fork" };
554
+ }
555
+ }
556
+ function detach(
557
+ context?: SessionProfileDetachContext,
558
+ ): SessionProfileForkHandoff | undefined {
559
+ let token: SessionProfileForkHandoff | undefined;
560
+ try {
561
+ if (context?.reason === "fork" && context.preparation) {
562
+ const prepared = forkStore().preparations.get(context.preparation);
563
+ const current = capture();
564
+ if (
565
+ !prepared ||
566
+ prepared.owner !== identity ||
567
+ prepared.generation !== generation ||
568
+ prepared.source !== source ||
569
+ !sameScope(prepared.scope, current)
570
+ )
571
+ throw new Error("fork-preparation");
572
+ const view = source as SessionProfileForkView;
573
+ const path =
574
+ prepared.leaf === null ? [] : detached(view.getBranch(prepared.leaf));
575
+ if (
576
+ !isDeepStrictEqual(prepared.path, path) ||
577
+ !stable(current) ||
578
+ (current.file !== undefined && !context.targetSessionFile)
579
+ )
580
+ throw new Error("fork-transition");
581
+ const latest = negativeEvidence(
582
+ prepared.path,
583
+ scopes.get(current.session)?.get(current.file),
584
+ );
585
+ token = Object.freeze({}) as SessionProfileForkHandoff;
586
+ forkStore().transfers.set(token, {
587
+ ...prepared,
588
+ failed: new Set([...prepared.failed, ...latest.failed]),
589
+ untrusted: new Set([...prepared.untrusted, ...latest.untrusted]),
590
+ ambiguous: prepared.ambiguous || latest.ambiguous,
591
+ targetFile: context.targetSessionFile,
592
+ consumed: false,
593
+ });
594
+ forkStore().preparations.delete(context.preparation);
595
+ }
596
+ } catch {
597
+ token = undefined;
598
+ }
599
+ // Even a failed proof capture must release the old runtime capability.
600
+ for (const files of scopes.values())
601
+ for (const s of files.values()) s.pending = undefined;
602
+ generation++;
603
+ source = undefined;
604
+ appendEntry = undefined;
605
+ readFile = undefined;
606
+ active = undefined;
607
+ return token;
608
+ }
609
+ function applyFork(
610
+ context: SessionProfileAttachContext,
611
+ c: Capture,
612
+ s: Scope,
613
+ ) {
614
+ const claim = context.fork;
615
+ const evidence = claim && forkStore().transfers.get(claim.handoff);
616
+ if (!claim || !evidence || evidence.consumed) throw new Error("fork-handoff");
617
+ evidence.consumed = true;
618
+ const view = claim.publicView;
619
+ const header = detached(view.getHeader()) as {
620
+ type?: string;
621
+ id?: string;
622
+ parentSession?: string;
623
+ };
624
+ if (
625
+ view !== source ||
626
+ c.session === evidence.scope.session ||
627
+ c.file !== evidence.targetFile ||
628
+ context.previousSessionFile !== evidence.scope.file ||
629
+ !header ||
630
+ header.type !== "session" ||
631
+ header.id !== c.session ||
632
+ header.parentSession !== evidence.scope.file ||
633
+ (c.file === undefined && view !== evidence.source)
634
+ )
635
+ throw new Error("fork-destination");
636
+ const expected = evidence.path.filter((e) => e.type !== "label");
637
+ // The source cut may itself be a removed label. Its destination boundary is
638
+ // the last retained record, not an original label ID or a recreated label.
639
+ const copyLeaf = expected.at(-1)?.id ?? null;
640
+ // Validate the actual destination cut, not a matching ancestor subbranch.
641
+ // Recreated labels may follow the cut, but no later retained record may.
642
+ const copied = c.branch.filter((e) => e.type !== "label");
643
+ // Orphan recovery can produce a suffix, not a fabricated complete ancestry.
644
+ const suffix = expected.slice(expected.length - copied.length);
645
+ if (
646
+ copied.length > expected.length ||
647
+ copied.length !== suffix.length ||
648
+ (copied.at(-1)?.id ?? null) !== copyLeaf
649
+ )
650
+ throw new Error("fork-path");
651
+ const replacements = new Map<string, string>();
652
+ let labels: string[] = [];
653
+ for (const e of evidence.path) {
654
+ if (e.type === "label") {
655
+ labels.push(e.id);
656
+ continue;
657
+ }
658
+ for (const id of labels) replacements.set(id, e.id);
659
+ labels = [];
660
+ }
661
+ let parentId: string | null = null;
662
+ for (let index = 0; index < copied.length; index++) {
663
+ const from = suffix[index],
664
+ to = copied[index];
665
+ const rewritten: Entry = { ...from, parentId };
666
+ if (from.type === "compaction")
667
+ rewritten.firstKeptEntryId =
668
+ replacements.get(from.firstKeptEntryId as string) ?? from.firstKeptEntryId;
669
+ if (!isDeepStrictEqual(rewritten, to)) throw new Error("fork-record");
670
+ parentId = to.id;
671
+ if (evidence.failed.has(to.id)) s.failed.add(to.id);
672
+ if (evidence.untrusted.has(to.id)) s.untrusted.add(to.id);
673
+ }
674
+ if (!stable(c)) throw new Error("fork-source-changed");
675
+ if (copied.some(isSessionProfileFamilyEntry) && evidence.ambiguous) {
676
+ s.ambiguous = true;
677
+ s.uncertain = true;
678
+ }
679
+ }
680
+ // Optional callbacks belong to this attachment, not the retained owner.
681
+ // Omitted options use the default disk reader, never the factory's old reader.
682
+ function attach(
683
+ currentSource: SessionProfileSource,
684
+ currentAppend: (customType: string, data: unknown) => void,
685
+ context: SessionProfileAttachContext,
686
+ currentOptions: SessionProfileAppendOptions = {},
687
+ ): SessionProfileAttachment {
688
+ detach();
689
+ source = currentSource;
690
+ appendEntry = currentAppend;
691
+ readFile =
692
+ currentOptions.readFile ?? ((file: string) => readFileSync(file, "utf8"));
693
+ scopes = sourceScopes.get(currentSource) ?? new Map();
694
+ sourceScopes.set(currentSource, scopes);
695
+ if (context.reason === "fork") {
696
+ try {
697
+ const c = capture();
698
+ applyFork(context, c, scope(c));
699
+ } catch {
700
+ // A fresh explicit disk-corroborated bind/clear can recover; no fallback.
701
+ try {
702
+ const s = scope(capture());
703
+ s.ambiguous = true;
704
+ s.uncertain = true;
705
+ } catch {
706
+ /* Source remains unobservable. */
707
+ }
708
+ }
709
+ }
710
+ return attachment();
711
+ }
712
+ return { ...attachment(), captureForkEvidence, detach, attach };
713
+ }