@nifrajs/coding-agent 3.2.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 (211) hide show
  1. package/EXTENSIONS.md +25 -0
  2. package/PERFORMANCE.md +19 -0
  3. package/README.md +69 -0
  4. package/REGISTRY.md +15 -0
  5. package/SECURITY.md +32 -0
  6. package/dist/approvals.d.ts +70 -0
  7. package/dist/approvals.d.ts.map +1 -0
  8. package/dist/approvals.js +174 -0
  9. package/dist/approvals.js.map +1 -0
  10. package/dist/capabilities.d.ts +20 -0
  11. package/dist/capabilities.d.ts.map +1 -0
  12. package/dist/capabilities.js +49 -0
  13. package/dist/capabilities.js.map +1 -0
  14. package/dist/cli.d.ts +24 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +380 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/context.d.ts +17 -0
  19. package/dist/context.d.ts.map +1 -0
  20. package/dist/context.js +66 -0
  21. package/dist/context.js.map +1 -0
  22. package/dist/deployment-adapters.d.ts +40 -0
  23. package/dist/deployment-adapters.d.ts.map +1 -0
  24. package/dist/deployment-adapters.js +147 -0
  25. package/dist/deployment-adapters.js.map +1 -0
  26. package/dist/diff.d.ts +17 -0
  27. package/dist/diff.d.ts.map +1 -0
  28. package/dist/diff.js +61 -0
  29. package/dist/diff.js.map +1 -0
  30. package/dist/errors.d.ts +9 -0
  31. package/dist/errors.d.ts.map +1 -0
  32. package/dist/errors.js +35 -0
  33. package/dist/errors.js.map +1 -0
  34. package/dist/extensions.d.ts +104 -0
  35. package/dist/extensions.d.ts.map +1 -0
  36. package/dist/extensions.js +284 -0
  37. package/dist/extensions.js.map +1 -0
  38. package/dist/handoffs.d.ts +97 -0
  39. package/dist/handoffs.d.ts.map +1 -0
  40. package/dist/handoffs.js +199 -0
  41. package/dist/handoffs.js.map +1 -0
  42. package/dist/healing.d.ts +56 -0
  43. package/dist/healing.d.ts.map +1 -0
  44. package/dist/healing.js +54 -0
  45. package/dist/healing.js.map +1 -0
  46. package/dist/host.d.ts +61 -0
  47. package/dist/host.d.ts.map +1 -0
  48. package/dist/host.js +369 -0
  49. package/dist/host.js.map +1 -0
  50. package/dist/index.d.ts +26 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +26 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/isolated-worker.d.ts +2 -0
  55. package/dist/isolated-worker.d.ts.map +1 -0
  56. package/dist/isolated-worker.js +155 -0
  57. package/dist/isolated-worker.js.map +1 -0
  58. package/dist/isolated.d.ts +52 -0
  59. package/dist/isolated.d.ts.map +1 -0
  60. package/dist/isolated.js +293 -0
  61. package/dist/isolated.js.map +1 -0
  62. package/dist/native.d.ts +92 -0
  63. package/dist/native.d.ts.map +1 -0
  64. package/dist/native.js +339 -0
  65. package/dist/native.js.map +1 -0
  66. package/dist/orchestration/artifact-port.d.ts +30 -0
  67. package/dist/orchestration/artifact-port.d.ts.map +1 -0
  68. package/dist/orchestration/artifact-port.js +59 -0
  69. package/dist/orchestration/artifact-port.js.map +1 -0
  70. package/dist/orchestration/catalog.d.ts +71 -0
  71. package/dist/orchestration/catalog.d.ts.map +1 -0
  72. package/dist/orchestration/catalog.js +46 -0
  73. package/dist/orchestration/catalog.js.map +1 -0
  74. package/dist/orchestration/compile.d.ts +59 -0
  75. package/dist/orchestration/compile.d.ts.map +1 -0
  76. package/dist/orchestration/compile.js +268 -0
  77. package/dist/orchestration/compile.js.map +1 -0
  78. package/dist/orchestration/dispatch.d.ts +115 -0
  79. package/dist/orchestration/dispatch.d.ts.map +1 -0
  80. package/dist/orchestration/dispatch.js +220 -0
  81. package/dist/orchestration/dispatch.js.map +1 -0
  82. package/dist/orchestration/durable-jobs.d.ts +35 -0
  83. package/dist/orchestration/durable-jobs.d.ts.map +1 -0
  84. package/dist/orchestration/durable-jobs.js +273 -0
  85. package/dist/orchestration/durable-jobs.js.map +1 -0
  86. package/dist/orchestration/effect-key.d.ts +23 -0
  87. package/dist/orchestration/effect-key.d.ts.map +1 -0
  88. package/dist/orchestration/effect-key.js +24 -0
  89. package/dist/orchestration/effect-key.js.map +1 -0
  90. package/dist/orchestration/evidence-store.d.ts +75 -0
  91. package/dist/orchestration/evidence-store.d.ts.map +1 -0
  92. package/dist/orchestration/evidence-store.js +183 -0
  93. package/dist/orchestration/evidence-store.js.map +1 -0
  94. package/dist/orchestration/hash.d.ts +12 -0
  95. package/dist/orchestration/hash.d.ts.map +1 -0
  96. package/dist/orchestration/hash.js +43 -0
  97. package/dist/orchestration/hash.js.map +1 -0
  98. package/dist/orchestration/host.d.ts +121 -0
  99. package/dist/orchestration/host.d.ts.map +1 -0
  100. package/dist/orchestration/host.js +334 -0
  101. package/dist/orchestration/host.js.map +1 -0
  102. package/dist/orchestration/index.d.ts +20 -0
  103. package/dist/orchestration/index.d.ts.map +1 -0
  104. package/dist/orchestration/index.js +19 -0
  105. package/dist/orchestration/index.js.map +1 -0
  106. package/dist/orchestration/policy.d.ts +63 -0
  107. package/dist/orchestration/policy.d.ts.map +1 -0
  108. package/dist/orchestration/policy.js +80 -0
  109. package/dist/orchestration/policy.js.map +1 -0
  110. package/dist/orchestration/recovery.d.ts +87 -0
  111. package/dist/orchestration/recovery.d.ts.map +1 -0
  112. package/dist/orchestration/recovery.js +197 -0
  113. package/dist/orchestration/recovery.js.map +1 -0
  114. package/dist/orchestration/tracer.d.ts +31 -0
  115. package/dist/orchestration/tracer.d.ts.map +1 -0
  116. package/dist/orchestration/tracer.js +141 -0
  117. package/dist/orchestration/tracer.js.map +1 -0
  118. package/dist/plans.d.ts +35 -0
  119. package/dist/plans.d.ts.map +1 -0
  120. package/dist/plans.js +63 -0
  121. package/dist/plans.js.map +1 -0
  122. package/dist/presets.d.ts +10 -0
  123. package/dist/presets.d.ts.map +1 -0
  124. package/dist/presets.js +70 -0
  125. package/dist/presets.js.map +1 -0
  126. package/dist/process.d.ts +6 -0
  127. package/dist/process.d.ts.map +1 -0
  128. package/dist/process.js +42 -0
  129. package/dist/process.js.map +1 -0
  130. package/dist/registry.d.ts +37 -0
  131. package/dist/registry.d.ts.map +1 -0
  132. package/dist/registry.js +51 -0
  133. package/dist/registry.js.map +1 -0
  134. package/dist/replay.d.ts +24 -0
  135. package/dist/replay.d.ts.map +1 -0
  136. package/dist/replay.js +154 -0
  137. package/dist/replay.js.map +1 -0
  138. package/dist/rpc.d.ts +51 -0
  139. package/dist/rpc.d.ts.map +1 -0
  140. package/dist/rpc.js +412 -0
  141. package/dist/rpc.js.map +1 -0
  142. package/dist/session-migration.d.ts +59 -0
  143. package/dist/session-migration.d.ts.map +1 -0
  144. package/dist/session-migration.js +315 -0
  145. package/dist/session-migration.js.map +1 -0
  146. package/dist/sessions.d.ts +74 -0
  147. package/dist/sessions.d.ts.map +1 -0
  148. package/dist/sessions.js +222 -0
  149. package/dist/sessions.js.map +1 -0
  150. package/dist/subagents.d.ts +51 -0
  151. package/dist/subagents.d.ts.map +1 -0
  152. package/dist/subagents.js +141 -0
  153. package/dist/subagents.js.map +1 -0
  154. package/dist/tools.d.ts +13 -0
  155. package/dist/tools.d.ts.map +1 -0
  156. package/dist/tools.js +37 -0
  157. package/dist/tools.js.map +1 -0
  158. package/dist/ui.d.ts +42 -0
  159. package/dist/ui.d.ts.map +1 -0
  160. package/dist/ui.js +93 -0
  161. package/dist/ui.js.map +1 -0
  162. package/dist/verification.d.ts +34 -0
  163. package/dist/verification.d.ts.map +1 -0
  164. package/dist/verification.js +91 -0
  165. package/dist/verification.js.map +1 -0
  166. package/dist/workflows.d.ts +98 -0
  167. package/dist/workflows.d.ts.map +1 -0
  168. package/dist/workflows.js +174 -0
  169. package/dist/workflows.js.map +1 -0
  170. package/package.json +84 -0
  171. package/src/approvals.ts +249 -0
  172. package/src/capabilities.ts +74 -0
  173. package/src/cli.ts +405 -0
  174. package/src/context.ts +81 -0
  175. package/src/deployment-adapters.ts +181 -0
  176. package/src/diff.ts +80 -0
  177. package/src/errors.ts +46 -0
  178. package/src/extensions.ts +402 -0
  179. package/src/handoffs.ts +299 -0
  180. package/src/healing.ts +93 -0
  181. package/src/host.ts +465 -0
  182. package/src/index.ts +165 -0
  183. package/src/isolated-worker.ts +177 -0
  184. package/src/isolated.ts +344 -0
  185. package/src/native.ts +478 -0
  186. package/src/orchestration/artifact-port.ts +75 -0
  187. package/src/orchestration/catalog.ts +102 -0
  188. package/src/orchestration/compile.ts +373 -0
  189. package/src/orchestration/dispatch.ts +383 -0
  190. package/src/orchestration/durable-jobs.ts +374 -0
  191. package/src/orchestration/effect-key.ts +35 -0
  192. package/src/orchestration/evidence-store.ts +233 -0
  193. package/src/orchestration/hash.ts +43 -0
  194. package/src/orchestration/host.ts +445 -0
  195. package/src/orchestration/index.ts +131 -0
  196. package/src/orchestration/policy.ts +116 -0
  197. package/src/orchestration/recovery.ts +271 -0
  198. package/src/orchestration/tracer.ts +177 -0
  199. package/src/plans.ts +105 -0
  200. package/src/presets.ts +90 -0
  201. package/src/process.ts +47 -0
  202. package/src/registry.ts +80 -0
  203. package/src/replay.ts +190 -0
  204. package/src/rpc.ts +598 -0
  205. package/src/session-migration.ts +401 -0
  206. package/src/sessions.ts +303 -0
  207. package/src/subagents.ts +210 -0
  208. package/src/tools.ts +52 -0
  209. package/src/ui.ts +137 -0
  210. package/src/verification.ts +133 -0
  211. package/src/workflows.ts +279 -0
@@ -0,0 +1,401 @@
1
+ import { access, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises"
2
+ import { basename, join, resolve } from "node:path"
3
+
4
+ /** Version of the evidence-only legacy session file format. */
5
+ export const SESSION_EVIDENCE_VERSION = 1 as const
6
+
7
+ const HEX64 = /^[0-9a-f]{64}$/
8
+ const SESSION_TOKEN = /^[a-zA-Z0-9._:-]{1,128}$/
9
+ const EVENT_TYPE = /^[a-z][a-z0-9._:-]{0,63}$/
10
+ const MAX_SOURCE_BYTES = 64 * 1024 * 1024
11
+
12
+ /**
13
+ * A single legacy session record after content has been removed.
14
+ *
15
+ * `digest` covers this record without the digest field. It is an integrity identifier, not a
16
+ * digest of the legacy payload. The payload is never written to the migration target.
17
+ */
18
+ export interface SessionEvidenceRecord {
19
+ readonly version: typeof SESSION_EVIDENCE_VERSION
20
+ readonly sessionId: string
21
+ readonly seq: number
22
+ readonly at: number
23
+ readonly code: string
24
+ readonly count: 1
25
+ readonly digest: string
26
+ readonly pinned?: boolean
27
+ }
28
+
29
+ /** Counts and integrity metadata emitted after a target has been fully validated. */
30
+ export interface SessionMigrationReport {
31
+ readonly version: typeof SESSION_EVIDENCE_VERSION
32
+ readonly sessionId: string
33
+ readonly records: number
34
+ /** Number of source event types replaced by the stable unknown-event code. */
35
+ readonly skipped: number
36
+ readonly skippedByCode: Readonly<Record<string, number>>
37
+ readonly counts: Readonly<Record<string, number>>
38
+ readonly firstSeq?: number
39
+ readonly lastSeq?: number
40
+ /** Digest of the complete evidence-only target file, including line separators. */
41
+ readonly digest: string
42
+ }
43
+
44
+ export interface MigrateLegacySessionOptions {
45
+ readonly sourceRoot: string
46
+ readonly targetRoot: string
47
+ readonly sessionId: string
48
+ /** Abort before any target commit. The legacy source is never modified. */
49
+ readonly signal?: AbortSignal
50
+ /** Bound the amount of legacy data read into transient memory. */
51
+ readonly maxSourceBytes?: number
52
+ }
53
+
54
+ export class SessionMigrationError extends Error {
55
+ constructor(
56
+ readonly code: string,
57
+ message: string,
58
+ ) {
59
+ super(`session migration [${code}]: ${message}`)
60
+ this.name = "SessionMigrationError"
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Convert a legacy event type into a stable, content-free evidence code.
66
+ * Unknown event names are intentionally not copied to the target: their SHA-256 prefix gives a
67
+ * repeatable grouping key without disclosing an arbitrary source string.
68
+ */
69
+ export async function stableSessionEventCode(
70
+ type: string,
71
+ ): Promise<{ readonly code: string; readonly replaced: boolean }> {
72
+ if (KNOWN_EVENT_CODES.has(type)) return { code: type, replaced: false }
73
+ const digest = await sha256Hex(type)
74
+ return { code: `legacy.unknown.${digest.slice(0, 16)}`, replaced: true }
75
+ }
76
+
77
+ /** Parse one evidence record strictly. Payload-bearing or unknown fields are rejected. */
78
+ export function parseSessionEvidenceRecord(value: unknown): SessionEvidenceRecord {
79
+ if (!isRecord(value))
80
+ throw new SessionMigrationError("invalid_record", "record must be an object")
81
+ const allowed = ["version", "sessionId", "seq", "at", "code", "count", "digest", "pinned"]
82
+ for (const key of Object.keys(value))
83
+ if (!allowed.includes(key))
84
+ throw new SessionMigrationError("invalid_record", `unknown evidence field '${key}'`)
85
+ if (value.version !== SESSION_EVIDENCE_VERSION)
86
+ throw new SessionMigrationError("invalid_record", "unsupported evidence version")
87
+ const sessionId = requiredToken(value.sessionId, "sessionId")
88
+ const seq = nonNegativeInteger(value.seq, "seq")
89
+ const at = nonNegativeInteger(value.at, "at")
90
+ const code = requiredCode(value.code)
91
+ if (value.count !== 1) throw new SessionMigrationError("invalid_record", "count must be 1")
92
+ const digest = value.digest
93
+ if (typeof digest !== "string" || !HEX64.test(digest))
94
+ throw new SessionMigrationError("invalid_record", "digest must be sha256 hex")
95
+ if (value.pinned !== undefined && typeof value.pinned !== "boolean")
96
+ throw new SessionMigrationError("invalid_record", "pinned must be boolean")
97
+ return {
98
+ version: SESSION_EVIDENCE_VERSION,
99
+ sessionId,
100
+ seq,
101
+ at,
102
+ code,
103
+ count: 1,
104
+ digest,
105
+ ...(value.pinned === true ? { pinned: true } : {}),
106
+ }
107
+ }
108
+
109
+ /** Read, project, validate, and atomically commit one legacy local session. */
110
+ export async function migrateLegacySession(
111
+ options: MigrateLegacySessionOptions,
112
+ ): Promise<SessionMigrationReport> {
113
+ assertNotAborted(options.signal)
114
+ const sourceRoot = resolve(options.sourceRoot)
115
+ const targetRoot = resolve(options.targetRoot)
116
+ const sessionId = validateSessionId(options.sessionId)
117
+ const maxSourceBytes = options.maxSourceBytes ?? MAX_SOURCE_BYTES
118
+ if (!Number.isSafeInteger(maxSourceBytes) || maxSourceBytes < 1)
119
+ throw new SessionMigrationError("invalid_options", "maxSourceBytes must be positive")
120
+ if (
121
+ sourceRoot === targetRoot ||
122
+ isWithin(sourceRoot, targetRoot) ||
123
+ isWithin(targetRoot, sourceRoot)
124
+ )
125
+ throw new SessionMigrationError(
126
+ "overlapping_roots",
127
+ "sourceRoot and targetRoot must be separate directories",
128
+ )
129
+
130
+ const sourceEntries = await readLegacyEntries(sourceRoot, sessionId, maxSourceBytes)
131
+ const records: SessionEvidenceRecord[] = []
132
+ const counts: Record<string, number> = Object.create(null) as Record<string, number>
133
+ const skippedByCode: Record<string, number> = Object.create(null) as Record<string, number>
134
+ let previousSeq = -1
135
+ for (const entry of sourceEntries) {
136
+ assertNotAborted(options.signal)
137
+ if (entry.seq <= previousSeq)
138
+ throw new SessionMigrationError("invalid_source", "legacy sequence must increase strictly")
139
+ previousSeq = entry.seq
140
+ const mapped = await stableSessionEventCode(entry.type)
141
+ const base = {
142
+ version: SESSION_EVIDENCE_VERSION,
143
+ sessionId,
144
+ seq: entry.seq,
145
+ at: entry.at,
146
+ code: mapped.code,
147
+ count: 1 as const,
148
+ ...(entry.pinned === true ? { pinned: true } : {}),
149
+ }
150
+ const record: SessionEvidenceRecord = {
151
+ ...base,
152
+ digest: await sha256Hex(JSON.stringify(base)),
153
+ }
154
+ parseSessionEvidenceRecord(record)
155
+ records.push(Object.freeze(record))
156
+ counts[mapped.code] = (counts[mapped.code] ?? 0) + 1
157
+ if (mapped.replaced) skippedByCode[mapped.code] = (skippedByCode[mapped.code] ?? 0) + 1
158
+ }
159
+
160
+ const lines = records.map((record) => JSON.stringify(record))
161
+ const content = lines.length === 0 ? "" : `${lines.join("\n")}\n`
162
+ const digest = await sha256Hex(content)
163
+ const targetFile = join(targetRoot, `${sessionId}.jsonl`)
164
+ await commitTarget(targetRoot, targetFile, content, options.signal)
165
+ // Re-read the committed target and validate every line before the caller treats this as active.
166
+ const committed = await readEvidenceTarget(targetFile, sessionId)
167
+ if (committed.records.length !== records.length || (await sha256Hex(committed.raw)) !== digest)
168
+ throw new SessionMigrationError("target_validation", "committed evidence target changed")
169
+ for (let index = 0; index < committed.records.length; index++) {
170
+ const actual = committed.records[index]!
171
+ const expected = records[index]!
172
+ if (canonicalRecord(actual) !== canonicalRecord(expected))
173
+ throw new SessionMigrationError("target_validation", "committed evidence record differs")
174
+ }
175
+ const report: SessionMigrationReport = {
176
+ version: SESSION_EVIDENCE_VERSION,
177
+ sessionId,
178
+ records: records.length,
179
+ skipped: Object.values(skippedByCode).reduce((sum, value) => sum + value, 0),
180
+ skippedByCode: Object.freeze({ ...skippedByCode }),
181
+ counts: Object.freeze({ ...counts }),
182
+ ...(records[0] === undefined ? {} : { firstSeq: records[0].seq }),
183
+ ...(records.at(-1) === undefined ? {} : { lastSeq: records.at(-1)!.seq }),
184
+ digest,
185
+ }
186
+ return Object.freeze(report)
187
+ }
188
+
189
+ const KNOWN_EVENT_CODES = new Set([
190
+ "session.started",
191
+ "session.updated",
192
+ "turn.started",
193
+ "assistant.delta",
194
+ "assistant.message",
195
+ "tool.started",
196
+ "tool.delta",
197
+ "tool.completed",
198
+ "approval.required",
199
+ "approval.resolved",
200
+ "repair.required",
201
+ "verification.completed",
202
+ "memory.compacted",
203
+ "extension.reloaded",
204
+ "session.completed",
205
+ "session.failed",
206
+ "session.stopped",
207
+ "session.checkpoint",
208
+ "session.forked",
209
+ ])
210
+
211
+ interface LegacyEntry {
212
+ readonly sessionId: string
213
+ readonly seq: number
214
+ readonly at: number
215
+ readonly type: string
216
+ readonly pinned?: boolean
217
+ }
218
+
219
+ async function readLegacyEntries(
220
+ sourceRoot: string,
221
+ sessionId: string,
222
+ maxSourceBytes: number,
223
+ ): Promise<readonly LegacyEntry[]> {
224
+ const path = join(sourceRoot, `${sessionId}.jsonl`)
225
+ let text: string
226
+ try {
227
+ const bytes = await readFile(path)
228
+ if (bytes.byteLength > maxSourceBytes)
229
+ throw new SessionMigrationError("source_too_large", "legacy session exceeds maxSourceBytes")
230
+ text = bytes.toString("utf8")
231
+ } catch (error) {
232
+ if (isNotFound(error)) return Object.freeze([])
233
+ throw error
234
+ }
235
+ const entries: LegacyEntry[] = []
236
+ const lines = text.split("\n")
237
+ for (let index = 0; index < lines.length; index++) {
238
+ const line = lines[index]!
239
+ if (line.trim().length === 0) continue
240
+ let value: unknown
241
+ try {
242
+ value = JSON.parse(line)
243
+ } catch {
244
+ throw new SessionMigrationError("invalid_source", `legacy line ${index + 1} is not JSON`)
245
+ }
246
+ if (!isRecord(value))
247
+ throw new SessionMigrationError("invalid_source", `legacy line ${index + 1} is not an object`)
248
+ if (value.version !== 1 || value.sessionId !== sessionId)
249
+ throw new SessionMigrationError(
250
+ "invalid_source",
251
+ `legacy line ${index + 1} has invalid identity`,
252
+ )
253
+ const seq = nonNegativeInteger(value.seq, `legacy line ${index + 1}.seq`)
254
+ const at = nonNegativeInteger(value.at, `legacy line ${index + 1}.at`)
255
+ if (typeof value.type !== "string" || !EVENT_TYPE.test(value.type))
256
+ throw new SessionMigrationError("invalid_source", `legacy line ${index + 1} has invalid type`)
257
+ if (value.pinned !== undefined && typeof value.pinned !== "boolean")
258
+ throw new SessionMigrationError(
259
+ "invalid_source",
260
+ `legacy line ${index + 1} has invalid pinned flag`,
261
+ )
262
+ entries.push({
263
+ sessionId,
264
+ seq,
265
+ at,
266
+ type: value.type,
267
+ ...(value.pinned === true ? { pinned: true } : {}),
268
+ })
269
+ }
270
+ return Object.freeze(entries)
271
+ }
272
+
273
+ async function commitTarget(
274
+ targetRoot: string,
275
+ targetFile: string,
276
+ content: string,
277
+ signal?: AbortSignal,
278
+ ): Promise<void> {
279
+ await mkdir(targetRoot, { recursive: true, mode: 0o700 })
280
+ try {
281
+ await access(targetFile)
282
+ throw new SessionMigrationError("target_exists", "migration target already exists")
283
+ } catch (error) {
284
+ if (!(error instanceof SessionMigrationError) && !isNotFound(error)) throw error
285
+ if (error instanceof SessionMigrationError) throw error
286
+ }
287
+ assertNotAborted(signal)
288
+ const temporary = join(targetRoot, `.${basename(targetFile)}.${process.pid}.${randomToken()}.tmp`)
289
+ try {
290
+ await writeFile(temporary, content, { encoding: "utf8", mode: 0o600, flag: "wx" })
291
+ assertNotAborted(signal)
292
+ // The target is created only after the complete temporary file exists. A pre-existing target
293
+ // was rejected above; callers must treat a concurrent target race as a failed migration.
294
+ try {
295
+ await access(targetFile)
296
+ throw new SessionMigrationError("target_exists", "migration target appeared during migration")
297
+ } catch (error) {
298
+ if (error instanceof SessionMigrationError) throw error
299
+ if (!isNotFound(error)) throw error
300
+ }
301
+ await rename(temporary, targetFile)
302
+ } catch (error) {
303
+ await unlink(temporary).catch(() => undefined)
304
+ throw error
305
+ }
306
+ }
307
+
308
+ async function readEvidenceTarget(
309
+ path: string,
310
+ sessionId: string,
311
+ ): Promise<{ readonly records: readonly SessionEvidenceRecord[]; readonly raw: string }> {
312
+ const raw = await readFile(path, "utf8")
313
+ const records: SessionEvidenceRecord[] = []
314
+ let previousSeq = -1
315
+ for (const line of raw.split("\n")) {
316
+ if (line.trim().length === 0) continue
317
+ const record = parseSessionEvidenceRecord(JSON.parse(line))
318
+ if (record.sessionId !== sessionId || record.seq <= previousSeq)
319
+ throw new SessionMigrationError("target_validation", "target identity or order is invalid")
320
+ const base = {
321
+ version: record.version,
322
+ sessionId: record.sessionId,
323
+ seq: record.seq,
324
+ at: record.at,
325
+ code: record.code,
326
+ count: record.count,
327
+ ...(record.pinned === true ? { pinned: true } : {}),
328
+ }
329
+ if ((await sha256Hex(JSON.stringify(base))) !== record.digest)
330
+ throw new SessionMigrationError("target_validation", "target record digest is invalid")
331
+ previousSeq = record.seq
332
+ records.push(record)
333
+ }
334
+ return { records: Object.freeze(records), raw }
335
+ }
336
+
337
+ function validateSessionId(value: string): string {
338
+ if (!SESSION_TOKEN.test(value))
339
+ throw new SessionMigrationError("invalid_options", "invalid sessionId")
340
+ return value
341
+ }
342
+
343
+ function requiredToken(value: unknown, name: string): string {
344
+ if (typeof value !== "string" || !SESSION_TOKEN.test(value))
345
+ throw new SessionMigrationError("invalid_record", `${name} must be a bounded token`)
346
+ return value
347
+ }
348
+
349
+ function requiredCode(value: unknown): string {
350
+ if (
351
+ typeof value !== "string" ||
352
+ (!/^legacy\.[a-z0-9.-]+$/.test(value) && !KNOWN_EVENT_CODES.has(value))
353
+ )
354
+ throw new SessionMigrationError("invalid_record", "code is invalid")
355
+ return value
356
+ }
357
+
358
+ function nonNegativeInteger(value: unknown, name: string): number {
359
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
360
+ throw new SessionMigrationError("invalid_record", `${name} must be a non-negative integer`)
361
+ return value
362
+ }
363
+
364
+ function isRecord(value: unknown): value is Record<string, unknown> {
365
+ return typeof value === "object" && value !== null && !Array.isArray(value)
366
+ }
367
+
368
+ function isNotFound(error: unknown): boolean {
369
+ return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT"
370
+ }
371
+
372
+ function isWithin(parent: string, child: string): boolean {
373
+ const prefix = parent.endsWith("/") ? parent : `${parent}/`
374
+ return child.startsWith(prefix)
375
+ }
376
+
377
+ function assertNotAborted(signal?: AbortSignal): void {
378
+ if (signal?.aborted) throw new SessionMigrationError("aborted", "migration was interrupted")
379
+ }
380
+
381
+ function randomToken(): string {
382
+ return crypto.randomUUID().replaceAll("-", "")
383
+ }
384
+
385
+ async function sha256Hex(value: string): Promise<string> {
386
+ const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))
387
+ return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("")
388
+ }
389
+
390
+ function canonicalRecord(record: SessionEvidenceRecord): string {
391
+ return JSON.stringify({
392
+ version: record.version,
393
+ sessionId: record.sessionId,
394
+ seq: record.seq,
395
+ at: record.at,
396
+ code: record.code,
397
+ count: record.count,
398
+ ...(record.pinned === true ? { pinned: true } : {}),
399
+ digest: record.digest,
400
+ })
401
+ }
@@ -0,0 +1,303 @@
1
+ import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises"
2
+ import { dirname, join } from "node:path"
3
+
4
+ export interface SessionLogEntry {
5
+ readonly version: 1
6
+ readonly sessionId: string
7
+ readonly seq: number
8
+ readonly at: number
9
+ readonly type: string
10
+ readonly payload?: unknown
11
+ readonly pinned?: boolean
12
+ }
13
+
14
+ export interface SessionStore {
15
+ append(
16
+ sessionId: string,
17
+ type: string,
18
+ payload?: unknown,
19
+ options?: { readonly pinned?: boolean },
20
+ ): Promise<SessionLogEntry>
21
+ read(sessionId: string): Promise<readonly SessionLogEntry[]>
22
+ checkpoint(sessionId: string, payload: unknown): Promise<void>
23
+ fork(sessionId: string, targetSessionId?: string): Promise<string>
24
+ }
25
+
26
+ export interface FileSessionStoreOptions {
27
+ readonly root: string
28
+ readonly maxEntryBytes?: number
29
+ }
30
+
31
+ /**
32
+ * Small append-only JSONL store. It stores redacted, bounded event evidence rather than raw model
33
+ * transcripts, so session recovery is useful without turning the agent into a memory sink.
34
+ */
35
+ export class FileSessionStore implements SessionStore {
36
+ private readonly root: string
37
+ private readonly maxEntryBytes: number
38
+ private readonly sequences = new Map<string, number>()
39
+
40
+ constructor(options: FileSessionStoreOptions) {
41
+ this.root = options.root
42
+ this.maxEntryBytes = options.maxEntryBytes ?? 256 * 1024
43
+ if (!Number.isSafeInteger(this.maxEntryBytes) || this.maxEntryBytes < 1024)
44
+ throw new RangeError("session store: maxEntryBytes must be at least 1024")
45
+ }
46
+
47
+ async append(
48
+ sessionId: string,
49
+ type: string,
50
+ payload?: unknown,
51
+ options: { readonly pinned?: boolean } = {},
52
+ ): Promise<SessionLogEntry> {
53
+ validateToken(sessionId, "sessionId")
54
+ if (!/^[a-z][a-z0-9._:-]{0,63}$/.test(type))
55
+ throw new TypeError("session store: invalid event type")
56
+ await mkdir(this.root, { recursive: true })
57
+ const nextSeq = await this.nextSequence(sessionId)
58
+ const entry: SessionLogEntry = Object.freeze({
59
+ version: 1,
60
+ sessionId,
61
+ seq: nextSeq,
62
+ at: Date.now(),
63
+ type,
64
+ ...(payload === undefined
65
+ ? {}
66
+ : { payload: boundValue(redactValue(payload), this.maxEntryBytes) }),
67
+ ...(options.pinned === true ? { pinned: true } : {}),
68
+ })
69
+ const line = JSON.stringify(entry)
70
+ if (Buffer.byteLength(line, "utf8") > this.maxEntryBytes)
71
+ throw new RangeError("session store: event exceeds maxEntryBytes")
72
+ await appendFile(this.pathFor(sessionId), `${line}\n`, "utf8")
73
+ this.sequences.set(sessionId, nextSeq)
74
+ return entry
75
+ }
76
+
77
+ async read(sessionId: string): Promise<readonly SessionLogEntry[]> {
78
+ validateToken(sessionId, "sessionId")
79
+ try {
80
+ const text = await readFile(this.pathFor(sessionId), "utf8")
81
+ const entries: SessionLogEntry[] = []
82
+ for (const line of text.split("\n")) {
83
+ if (line.trim().length === 0) continue
84
+ const value: unknown = JSON.parse(line)
85
+ if (isSessionLogEntry(value, sessionId)) entries.push(Object.freeze(value))
86
+ }
87
+ const last = entries.at(-1)?.seq
88
+ if (last !== undefined) this.sequences.set(sessionId, last)
89
+ return Object.freeze(entries)
90
+ } catch (error) {
91
+ if (isNotFound(error)) return Object.freeze([])
92
+ throw error
93
+ }
94
+ }
95
+
96
+ async checkpoint(sessionId: string, payload: unknown): Promise<void> {
97
+ validateToken(sessionId, "sessionId")
98
+ const target = join(this.root, `${sessionId}.checkpoint.json`)
99
+ const temporary = `${target}.${process.pid}.${Date.now()}.tmp`
100
+ await mkdir(dirname(target), { recursive: true })
101
+ const content = JSON.stringify({
102
+ version: 1,
103
+ sessionId,
104
+ at: Date.now(),
105
+ payload: boundValue(redactValue(payload), this.maxEntryBytes),
106
+ })
107
+ await writeFile(temporary, content, "utf8")
108
+ await rename(temporary, target)
109
+ }
110
+
111
+ async fork(
112
+ sessionId: string,
113
+ targetSessionId = `${sessionId}:fork:${Date.now().toString(36)}`,
114
+ ): Promise<string> {
115
+ validateToken(sessionId, "sessionId")
116
+ validateToken(targetSessionId, "targetSessionId")
117
+ const entries = await this.read(sessionId)
118
+ await mkdir(this.root, { recursive: true })
119
+ const target = this.pathFor(targetSessionId)
120
+ const lines = entries.map((entry, index) =>
121
+ JSON.stringify({ ...entry, sessionId: targetSessionId, seq: index }),
122
+ )
123
+ await writeFile(target, lines.length === 0 ? "" : `${lines.join("\n")}\n`, "utf8")
124
+ this.sequences.set(targetSessionId, Math.max(0, lines.length - 1))
125
+ return targetSessionId
126
+ }
127
+
128
+ private pathFor(sessionId: string): string {
129
+ return join(this.root, `${sessionId}.jsonl`)
130
+ }
131
+
132
+ private async nextSequence(sessionId: string): Promise<number> {
133
+ const cached = this.sequences.get(sessionId)
134
+ if (cached !== undefined) return cached + 1
135
+ const entries = await this.read(sessionId)
136
+ return (entries.at(-1)?.seq ?? -1) + 1
137
+ }
138
+ }
139
+
140
+ export interface ContextRecord {
141
+ readonly kind: string
142
+ readonly content: string
143
+ readonly pinned?: boolean
144
+ }
145
+
146
+ export interface CompactionReport {
147
+ readonly before: number
148
+ readonly after: number
149
+ readonly removed: number
150
+ readonly reason: "manual" | "threshold" | "overflow" | "workflow"
151
+ }
152
+
153
+ export interface ContextWindowOptions {
154
+ readonly maxTokens?: number
155
+ readonly keepRecent?: number
156
+ readonly maxSummaryChars?: number
157
+ /** Optional extension-owned summary function. It cannot alter authoritative records. */
158
+ readonly summarize?: (records: readonly ContextRecord[], maxChars: number) => string
159
+ }
160
+
161
+ /** In-memory prompt window with automatic, deterministic compaction and a hard size ceiling. */
162
+ export class ContextWindow {
163
+ private readonly maxTokens: number
164
+ private readonly keepRecent: number
165
+ private readonly maxSummaryChars: number
166
+ private readonly summarizeRecord: (records: readonly ContextRecord[], maxChars: number) => string
167
+ private records: ContextRecord[] = []
168
+
169
+ constructor(options: ContextWindowOptions = {}) {
170
+ this.maxTokens = options.maxTokens ?? 32_000
171
+ this.keepRecent = options.keepRecent ?? 24
172
+ this.maxSummaryChars = options.maxSummaryChars ?? 4_096
173
+ this.summarizeRecord = options.summarize ?? summarize
174
+ if (!Number.isSafeInteger(this.maxTokens) || this.maxTokens < 256)
175
+ throw new RangeError("context window: maxTokens must be at least 256")
176
+ if (!Number.isSafeInteger(this.keepRecent) || this.keepRecent < 1)
177
+ throw new RangeError("context window: keepRecent must be positive")
178
+ }
179
+
180
+ get tokens(): number {
181
+ return estimateTokens(this.records)
182
+ }
183
+
184
+ get size(): number {
185
+ return this.records.length
186
+ }
187
+
188
+ snapshot(): readonly ContextRecord[] {
189
+ return Object.freeze(this.records.map((record) => Object.freeze({ ...record })))
190
+ }
191
+
192
+ /** Restore bounded prompt context from persisted evidence without bypassing compaction rules. */
193
+ restore(records: readonly ContextRecord[]): void {
194
+ this.records = []
195
+ for (const record of records.slice(-Math.max(this.keepRecent * 8, 256))) this.append(record)
196
+ }
197
+
198
+ append(record: ContextRecord): CompactionReport | undefined {
199
+ this.records.push({
200
+ kind: record.kind,
201
+ content: record.content.slice(0, this.maxSummaryChars),
202
+ ...(record.pinned === true ? { pinned: true } : {}),
203
+ })
204
+ if (this.tokens <= this.maxTokens) return undefined
205
+ return this.compact("threshold")
206
+ }
207
+
208
+ compact(reason: CompactionReport["reason"] = "manual"): CompactionReport {
209
+ const before = this.tokens
210
+ if (this.records.length <= this.keepRecent) return { before, after: before, removed: 0, reason }
211
+ // A compacted summary is replaceable context, not authoritative evidence. Keeping old pinned
212
+ // summaries in `protectedRecords` would make every threshold compaction grow the live window.
213
+ const protectedRecords = this.records.filter(
214
+ (record) =>
215
+ record.kind !== "memory.summary" &&
216
+ (record.pinned === true || isAuthoritative(record.kind)),
217
+ )
218
+ const recent = this.records.slice(-this.keepRecent)
219
+ const retained = new Set([...protectedRecords, ...recent])
220
+ const omitted = this.records.filter((record) => !retained.has(record))
221
+ if (omitted.length === 0) return { before, after: before, removed: 0, reason }
222
+ const summary = this.summarizeRecord(omitted, this.maxSummaryChars).slice(
223
+ 0,
224
+ this.maxSummaryChars,
225
+ )
226
+ this.records = [
227
+ ...protectedRecords,
228
+ { kind: "memory.summary", content: summary, pinned: true },
229
+ ...recent.filter(
230
+ (record) => record.kind !== "memory.summary" && !protectedRecords.includes(record),
231
+ ),
232
+ ]
233
+ const after = this.tokens
234
+ return { before, after, removed: omitted.length, reason }
235
+ }
236
+ }
237
+
238
+ function validateToken(value: string, name: string): void {
239
+ if (!/^[a-zA-Z0-9._:-]{1,128}$/.test(value))
240
+ throw new TypeError(`session store: ${name} must be a bounded token`)
241
+ }
242
+
243
+ function isNotFound(error: unknown): boolean {
244
+ return error !== null && typeof error === "object" && "code" in error && error.code === "ENOENT"
245
+ }
246
+
247
+ function isSessionLogEntry(value: unknown, sessionId: string): value is SessionLogEntry {
248
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false
249
+ const record = value as Record<string, unknown>
250
+ return (
251
+ record.version === 1 &&
252
+ record.sessionId === sessionId &&
253
+ typeof record.seq === "number" &&
254
+ Number.isSafeInteger(record.seq) &&
255
+ typeof record.at === "number" &&
256
+ typeof record.type === "string"
257
+ )
258
+ }
259
+
260
+ function redactValue(value: unknown, key = "", depth = 0): unknown {
261
+ if (depth > 5) return "[depth-limited]"
262
+ if (/(?:secret|token|password|passwd|authorization|api[-_]?key|private[-_]?key)/i.test(key))
263
+ return "[redacted]"
264
+ if (typeof value === "string")
265
+ return value.length > 16_384 ? `${value.slice(0, 16_384)}…[truncated]` : value
266
+ if (Array.isArray(value))
267
+ return value.slice(0, 256).map((item) => redactValue(item, "", depth + 1))
268
+ if (value !== null && typeof value === "object") {
269
+ const result: Record<string, unknown> = {}
270
+ for (const [childKey, childValue] of Object.entries(value).slice(0, 256))
271
+ result[childKey] = redactValue(childValue, childKey, depth + 1)
272
+ return result
273
+ }
274
+ return value
275
+ }
276
+
277
+ function boundValue(value: unknown, maxBytes: number): unknown {
278
+ const text = JSON.stringify(value)
279
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return value
280
+ return "[payload-truncated]"
281
+ }
282
+
283
+ function estimateTokens(records: readonly ContextRecord[]): number {
284
+ return Math.ceil(
285
+ records.reduce((total, record) => total + record.kind.length + record.content.length, 0) / 4,
286
+ )
287
+ }
288
+
289
+ function isAuthoritative(kind: string): boolean {
290
+ return (
291
+ kind.startsWith("approval") || kind.startsWith("verification") || kind === "session.checkpoint"
292
+ )
293
+ }
294
+
295
+ function summarize(records: readonly ContextRecord[], maxChars: number): string {
296
+ const lines = [`Compacted ${records.length} earlier context entries.`]
297
+ for (const record of records.slice(-32)) {
298
+ const line = `- ${record.kind}: ${record.content.replace(/\s+/g, " ").slice(0, 180)}`
299
+ if (lines.join("\n").length + line.length + 1 > maxChars) break
300
+ lines.push(line)
301
+ }
302
+ return lines.join("\n")
303
+ }