@gmickel/gno 2.5.1 → 2.6.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 (105) hide show
  1. package/README.md +60 -5
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +75 -1
  4. package/assets/skill/cli-reference.md +123 -0
  5. package/assets/skill/examples.md +30 -0
  6. package/assets/skill/mcp-reference.md +52 -0
  7. package/assets/skill/recipes/capture-and-file.md +6 -0
  8. package/assets/skill/recipes/memory-file-decision.md +6 -0
  9. package/assets/skill/recipes/memory-supersede-fact.md +5 -0
  10. package/assets/skill/recipes/session-evidence-lookup.md +98 -0
  11. package/assets/spa-production.json.gz +0 -0
  12. package/browser-extension/artifacts/{gno-browser-clipper-v2.5.1.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
  14. package/browser-extension/dist/manifest.json +1 -1
  15. package/package.json +2 -1
  16. package/spec/cli.md +347 -24
  17. package/spec/mcp.md +198 -2
  18. package/spec/output-schemas/capture-receipt.schema.json +3 -0
  19. package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
  20. package/spec/output-schemas/memory-remember.schema.json +8 -2
  21. package/spec/output-schemas/request-status.schema.json +113 -0
  22. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  23. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  24. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  25. package/spec/output-schemas/sessions-status.schema.json +432 -0
  26. package/src/cli/commands/ask.ts +14 -2
  27. package/src/cli/commands/capture.ts +55 -96
  28. package/src/cli/commands/daemon.ts +41 -0
  29. package/src/cli/commands/ls.ts +3 -0
  30. package/src/cli/commands/memory.ts +12 -3
  31. package/src/cli/commands/request-status.ts +59 -0
  32. package/src/cli/commands/reset.ts +39 -5
  33. package/src/cli/commands/sessions.ts +713 -0
  34. package/src/cli/commands/shared.ts +14 -1
  35. package/src/cli/program.ts +388 -1
  36. package/src/cli/session-binding.ts +49 -0
  37. package/src/config/types.ts +8 -0
  38. package/src/core/capture-publish.ts +239 -0
  39. package/src/core/capture-sync.ts +3 -0
  40. package/src/core/memory-remember.ts +233 -122
  41. package/src/core/memory-types.ts +11 -0
  42. package/src/core/network-boundary-inventory.ts +8 -0
  43. package/src/core/request-receipts.ts +671 -0
  44. package/src/index.ts +9 -0
  45. package/src/mcp/context.ts +8 -0
  46. package/src/mcp/http-egress.ts +4 -0
  47. package/src/mcp/http-transport.ts +2 -0
  48. package/src/mcp/tools/capture.ts +87 -83
  49. package/src/mcp/tools/index.ts +66 -0
  50. package/src/mcp/tools/memory-remember.ts +7 -0
  51. package/src/mcp/tools/memory-shared.ts +7 -1
  52. package/src/mcp/tools/request-status.ts +73 -0
  53. package/src/mcp/tools/sessions.ts +208 -0
  54. package/src/sdk/client.ts +180 -84
  55. package/src/sdk/index.ts +6 -0
  56. package/src/sdk/types.ts +54 -2
  57. package/src/serve/capture-service.ts +98 -32
  58. package/src/serve/config-sync.ts +3 -2
  59. package/src/serve/public/app.tsx +4 -1
  60. package/src/serve/public/components/CaptureModal.tsx +26 -8
  61. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  62. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  63. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  64. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  65. package/src/serve/public/components/sessions/api.ts +40 -0
  66. package/src/serve/public/components/sessions/snippet.tsx +53 -0
  67. package/src/serve/public/globals.built.css +1 -1
  68. package/src/serve/public/hooks/use-api.ts +10 -2
  69. package/src/serve/public/lib/request-intent.ts +69 -0
  70. package/src/serve/public/lib/workspace-actions.ts +12 -1
  71. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  72. package/src/serve/public/pages/Dashboard.tsx +10 -0
  73. package/src/serve/public/pages/DocView.tsx +15 -1
  74. package/src/serve/public/pages/DocumentEditor.tsx +139 -96
  75. package/src/serve/public/pages/Sessions.tsx +350 -0
  76. package/src/serve/resident-runtime.ts +43 -3
  77. package/src/serve/routes/api.ts +476 -147
  78. package/src/serve/routes/sessions.ts +766 -0
  79. package/src/serve/security.ts +9 -0
  80. package/src/serve/server.ts +205 -1
  81. package/src/serve/session-automation.ts +146 -0
  82. package/src/sessions/archive.ts +348 -0
  83. package/src/sessions/automation-state.ts +444 -0
  84. package/src/sessions/automation-status.ts +239 -0
  85. package/src/sessions/automation.ts +1169 -0
  86. package/src/sessions/binding.ts +105 -0
  87. package/src/sessions/claude-hook.ts +240 -0
  88. package/src/sessions/config.ts +176 -0
  89. package/src/sessions/format.ts +191 -0
  90. package/src/sessions/import-child-env.ts +8 -0
  91. package/src/sessions/import-child.ts +152 -0
  92. package/src/sessions/parsers/claude-code.ts +259 -0
  93. package/src/sessions/parsers/codex.ts +303 -0
  94. package/src/sessions/parsers/hermes.ts +248 -0
  95. package/src/sessions/parsers/openclaw.ts +496 -0
  96. package/src/sessions/parsers/shared.ts +184 -0
  97. package/src/sessions/sanitize.ts +222 -0
  98. package/src/sessions/service.ts +1533 -0
  99. package/src/sessions/setup.ts +477 -0
  100. package/src/sessions/sources.ts +518 -0
  101. package/src/sessions/state.ts +118 -0
  102. package/src/sessions/types.ts +457 -0
  103. package/src/store/sqlite/adapter.ts +54 -15
  104. package/src/store/sqlite/scoped-index.ts +9 -0
  105. package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.zip.sha256 +0 -1
@@ -0,0 +1,1533 @@
1
+ /**
2
+ * Transport-neutral session ingestion service.
3
+ *
4
+ * CLI, MCP, REST, SDK and Web UI are thin adapters over this module. The
5
+ * service never runs on its own: nothing here watches, schedules or hooks
6
+ * into a harness (opt-in automation in ./automation calls `import` like any
7
+ * other surface). Imports are explicit, serialized per archive, idempotent,
8
+ * and only advance a unit's checkpoint after a clean, complete read.
9
+ *
10
+ * @module src/sessions/service
11
+ */
12
+
13
+ // node:fs/promises: directory creation/removal and listing have no Bun equivalents.
14
+ import {
15
+ mkdir,
16
+ readdir,
17
+ realpath,
18
+ rename,
19
+ stat,
20
+ unlink,
21
+ } from "node:fs/promises";
22
+ // node:path: no Bun path utilities.
23
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
24
+
25
+ import type { Config } from "../config/types";
26
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
27
+ import type { SessionsConfig } from "./config";
28
+
29
+ import { loadConfig } from "../config";
30
+ import { hashRecordValue } from "../converters/adapters/shared/record-utils";
31
+ import { acquireWriteLock } from "../core/file-lock";
32
+ import { atomicWrite } from "../core/file-ops";
33
+ import { defaultSyncService, withContentTypeRules } from "../ingestion";
34
+ import {
35
+ archiveFilePath,
36
+ renderThread,
37
+ threadRelPath,
38
+ rescanArchiveContent,
39
+ SESSION_STATE_DIRNAME,
40
+ } from "./archive";
41
+ import { readAutomationStatus } from "./automation-status";
42
+ import { canonicalConfigPath, writeIndexBinding } from "./binding";
43
+ import { redactionStamp, sanitizeValue } from "./sanitize";
44
+ import {
45
+ archiveCollection,
46
+ protectedRoots,
47
+ requireSessionsConfig,
48
+ } from "./setup";
49
+ import {
50
+ assertNotFilesystemRootAnyForm,
51
+ assertSafeSourceRoot,
52
+ canonicalPath,
53
+ defaultDiscoveryRoots,
54
+ detectHarness,
55
+ detectRootHarness,
56
+ enumerateUnits,
57
+ isReadableRoot,
58
+ type ReadDirectory,
59
+ readFailureReason,
60
+ parseUnit,
61
+ SESSION_PARSERS,
62
+ type SessionUnit,
63
+ } from "./sources";
64
+ import {
65
+ importLockPath,
66
+ loadState,
67
+ saveState,
68
+ type SourceState,
69
+ unitFingerprint,
70
+ unitKey,
71
+ type UnitState,
72
+ withheldPath,
73
+ } from "./state";
74
+ import {
75
+ MAX_IMPORT_LIMIT,
76
+ type ParsedThread,
77
+ SESSION_ARCHIVE_FORMAT_VERSION,
78
+ SESSION_HARNESSES,
79
+ SESSION_LIMITS,
80
+ type SessionDiscoveryCandidate,
81
+ type SessionHarness,
82
+ type SessionImportCounts,
83
+ type SessionImportReceipt,
84
+ type SessionsDiscovery,
85
+ SessionsError,
86
+ type SessionSourceStatus,
87
+ type SessionsStatus,
88
+ type SessionTurnCounts,
89
+ type SessionUnitReceipt,
90
+ } from "./types";
91
+
92
+ const IMPORT_LOCK_WAIT_MS = 2_000;
93
+ const DISCOVERY_UNIT_LIMIT = 20_000;
94
+ const PATH_SOURCE_PREFIX = "path-";
95
+
96
+ export interface SessionsServiceDeps {
97
+ config: Config;
98
+ /** Actual config file path in use (the archive config). */
99
+ configPath: string;
100
+ indexName: string;
101
+ /** Open store for this config/index pair; required for non-dry-run imports. */
102
+ store?: SqliteAdapter;
103
+ syncService?: Pick<typeof defaultSyncService, "syncPaths" | "syncCollection">;
104
+ env?: NodeJS.ProcessEnv;
105
+ now?: () => Date;
106
+ /** Directory listing used for source roots (tests inject failures). */
107
+ readDirectory?: ReadDirectory;
108
+ }
109
+
110
+ export interface SessionImportInput {
111
+ /** Registered source profile to import. */
112
+ sourceId?: string;
113
+ /** Explicit host paths (local owner CLI/SDK only). */
114
+ paths?: string[];
115
+ /** Destination collection for path imports. */
116
+ collection?: string;
117
+ /** Harness override for path imports. */
118
+ format?: SessionHarness;
119
+ dryRun?: boolean;
120
+ /** Maximum number of changed units processed in this run. */
121
+ limit?: number;
122
+ }
123
+
124
+ export interface SessionImportOptions {
125
+ /** Remote/unauthenticated surfaces may only import registered sources. */
126
+ allowPaths: boolean;
127
+ }
128
+
129
+ export interface SessionPrunePreview {
130
+ schemaVersion: "1";
131
+ sourceId: string;
132
+ applied: boolean;
133
+ units: Array<{ locator: string; threads: number }>;
134
+ archiveFiles: number;
135
+ /** Set when the index sync failed; nothing was recorded and a rerun retries. */
136
+ error?: string;
137
+ }
138
+
139
+ interface PendingWithdrawal {
140
+ units: Record<string, UnitState>;
141
+ key: string;
142
+ thread: { collection: string; relPath: string };
143
+ }
144
+
145
+ interface ResolvedSource {
146
+ id: string;
147
+ harness: SessionHarness | null;
148
+ /** Canonical root, or null when a registered root is gone (archive-only). */
149
+ root: string | null;
150
+ collection: string;
151
+ projects: Array<{ prefix: string; collection: string }>;
152
+ }
153
+
154
+ // ─────────────────────────────────────────────────────────────────────────────
155
+ // Helpers
156
+ // ─────────────────────────────────────────────────────────────────────────────
157
+
158
+ const emptyCounts = (): SessionImportCounts => ({
159
+ imported: 0,
160
+ updated: 0,
161
+ unchanged: 0,
162
+ skippedPolicy: 0,
163
+ unsupported: 0,
164
+ incomplete: 0,
165
+ failed: 0,
166
+ });
167
+
168
+ const emptyTurns = (): SessionTurnCounts => ({
169
+ human: 0,
170
+ assistant: 0,
171
+ redactions: 0,
172
+ injectedSkipped: 0,
173
+ copiedHistorySkipped: 0,
174
+ overLimit: 0,
175
+ });
176
+
177
+ /** Identity of the routing settings; a change re-imports the source's units. */
178
+ function destinationsStamp(source: ResolvedSource): string {
179
+ const projects = [...source.projects]
180
+ .map((mapping) => `${mapping.prefix}\0${mapping.collection}`)
181
+ .sort();
182
+ return hashRecordValue(
183
+ "gno-session-destinations-v1",
184
+ JSON.stringify([source.collection, projects])
185
+ ).slice(0, 16);
186
+ }
187
+
188
+ function projectDestination(
189
+ cwd: string | undefined,
190
+ source: ResolvedSource
191
+ ): string {
192
+ if (!cwd) return source.collection;
193
+ const normalized = cwd.replaceAll("\\", "/");
194
+ let best: { prefix: string; collection: string } | undefined;
195
+ for (const mapping of source.projects) {
196
+ const prefix = mapping.prefix.replaceAll("\\", "/").replace(/\/+$/, "");
197
+ if (normalized === prefix || normalized.startsWith(`${prefix}/`)) {
198
+ if (!best || prefix.length > best.prefix.length) {
199
+ best = { prefix, collection: mapping.collection };
200
+ }
201
+ }
202
+ }
203
+ return best?.collection ?? source.collection;
204
+ }
205
+
206
+ /** Destination for a thread; null when its working directories disagree. */
207
+ function threadDestination(
208
+ thread: ParsedThread,
209
+ source: ResolvedSource
210
+ ): string | null {
211
+ const cwds = new Set<string | undefined>([thread.cwd]);
212
+ for (const turn of thread.turns) if (turn.cwd) cwds.add(turn.cwd);
213
+ const destinations = new Set(
214
+ [...cwds].map((cwd) => projectDestination(cwd, source))
215
+ );
216
+ return destinations.size === 1 ? [...destinations][0]! : null;
217
+ }
218
+
219
+ async function readText(path: string): Promise<string | null> {
220
+ const file = Bun.file(path);
221
+ return (await file.exists()) ? file.text() : null;
222
+ }
223
+
224
+ function unitReason(error: unknown): string {
225
+ const message = error instanceof Error ? error.message : String(error);
226
+ if (/database|sqlite|SQLITE/i.test(message)) return "snapshot_read_failed";
227
+ return readFailureReason(error);
228
+ }
229
+
230
+ // ─────────────────────────────────────────────────────────────────────────────
231
+ // Service
232
+ // ─────────────────────────────────────────────────────────────────────────────
233
+
234
+ export class SessionsService {
235
+ private readonly deps: SessionsServiceDeps;
236
+
237
+ constructor(deps: SessionsServiceDeps) {
238
+ this.deps = deps;
239
+ }
240
+
241
+ private get now(): Date {
242
+ return this.deps.now?.() ?? new Date();
243
+ }
244
+
245
+ /** Preview supported local sources on this host. Never imports. */
246
+ async discover(): Promise<SessionsDiscovery> {
247
+ const sessions = this.deps.config.sessions;
248
+ const excluded = protectedRoots(sessions);
249
+ const candidates: SessionDiscoveryCandidate[] = [];
250
+ const warnings: string[] = [];
251
+ for (const root of defaultDiscoveryRoots(this.deps.env)) {
252
+ const canonical = await canonicalPath(root.path);
253
+ if (!canonical) continue;
254
+ let enumerated;
255
+ try {
256
+ enumerated = await enumerateUnits({
257
+ harness: root.harness,
258
+ root: canonical,
259
+ excluded,
260
+ limit: DISCOVERY_UNIT_LIMIT,
261
+ readDirectory: this.deps.readDirectory,
262
+ });
263
+ } catch {
264
+ warnings.push(`${root.harness}: source root could not be read`);
265
+ continue;
266
+ }
267
+ if (enumerated.unreadable.length > 0) {
268
+ warnings.push(
269
+ `${root.harness}: ${enumerated.unreadable.length} entries under the source root could not be read`
270
+ );
271
+ }
272
+ if (enumerated.units.length === 0) continue;
273
+ const registered = sessions?.sources.find(
274
+ (source) =>
275
+ source.harness === root.harness && resolve(source.path) === canonical
276
+ );
277
+ candidates.push({
278
+ harness: root.harness,
279
+ path: canonical,
280
+ units: enumerated.units.length,
281
+ bytes: enumerated.units.reduce((sum, unit) => sum + unit.size, 0),
282
+ truncated: enumerated.truncated,
283
+ formatVersions: await sampleVersions(enumerated.units),
284
+ registeredAs: registered?.id ?? null,
285
+ });
286
+ }
287
+ return { schemaVersion: "1", candidates, warnings };
288
+ }
289
+
290
+ /** Archive, source and checkpoint status. Never modifies anything. */
291
+ async status(): Promise<SessionsStatus> {
292
+ const sessions = requireSessionsConfig(this.deps.config);
293
+ const state = await loadState(sessions.archiveRoot);
294
+ const warnings: string[] = [];
295
+ const collections: SessionsStatus["collections"] = [];
296
+ const archiveNames = new Set(
297
+ sessions.sources.flatMap((source) => [
298
+ source.collection,
299
+ ...(source.projects ?? []).map((mapping) => mapping.collection),
300
+ ])
301
+ );
302
+ for (const collection of this.deps.config.collections) {
303
+ if (
304
+ resolve(collection.path) ===
305
+ resolve(join(sessions.archiveRoot, collection.name))
306
+ ) {
307
+ archiveNames.add(collection.name);
308
+ }
309
+ }
310
+ for (const name of [...archiveNames].sort()) {
311
+ collections.push({
312
+ name,
313
+ threads: await countArchiveFiles(join(sessions.archiveRoot, name)),
314
+ });
315
+ }
316
+
317
+ const sources: SessionSourceStatus[] = [];
318
+ for (const source of sessions.sources) {
319
+ const sourceState = state.sources[source.id];
320
+ const known = sourceState?.units ?? {};
321
+ const canonical = await canonicalPath(source.path);
322
+ let units: SessionUnit[] = [];
323
+ let readable = false;
324
+ if (canonical) {
325
+ try {
326
+ const enumerated = await enumerateUnits({
327
+ harness: source.harness,
328
+ root: canonical,
329
+ excluded: protectedRoots(sessions),
330
+ readDirectory: this.deps.readDirectory,
331
+ });
332
+ units = enumerated.units;
333
+ readable = true;
334
+ if (enumerated.unreadable.length > 0) {
335
+ warnings.push(
336
+ `${source.id}: ${enumerated.unreadable.length} entries could not be read; their units are not counted`
337
+ );
338
+ }
339
+ } catch {
340
+ warnings.push(`${source.id}: source could not be read`);
341
+ }
342
+ }
343
+ const present = new Set<string>();
344
+ let pending = 0;
345
+ const destinations = destinationsStamp({
346
+ id: source.id,
347
+ harness: source.harness,
348
+ root: canonical ?? source.path,
349
+ collection: source.collection,
350
+ projects: source.projects ?? [],
351
+ });
352
+ // One batch of stats: status stays responsive while an import runs
353
+ // in the same process.
354
+ const fingerprints = await Promise.all(
355
+ units.map((unit) => unitFingerprint(unit).catch(() => ""))
356
+ );
357
+ for (const [index, unit] of units.entries()) {
358
+ const key = unitKey(source.id, unit.locator);
359
+ present.add(key);
360
+ const previous = known[key];
361
+ if (
362
+ !previous ||
363
+ previous.status !== "complete" ||
364
+ previous.destinations !== destinations ||
365
+ previous.fingerprint !== fingerprints[index]
366
+ ) {
367
+ pending += 1;
368
+ }
369
+ }
370
+ const stateUnits = Object.entries(known);
371
+ const presentUnits = stateUnits.filter(([key]) => present.has(key));
372
+ sources.push({
373
+ id: source.id,
374
+ harness: source.harness,
375
+ collection: source.collection,
376
+ available: readable,
377
+ units: {
378
+ total: units.length,
379
+ // Counted over present units only; units whose source is gone
380
+ // are reported as sourceUnavailable.
381
+ complete: presentUnits.filter(
382
+ ([, unit]) => unit.status === "complete"
383
+ ).length,
384
+ incomplete: presentUnits.filter(
385
+ ([, unit]) => unit.status === "incomplete"
386
+ ).length,
387
+ failed: presentUnits.filter(
388
+ ([, unit]) =>
389
+ unit.status === "failed" || unit.status === "unsupported"
390
+ ).length,
391
+ pending,
392
+ },
393
+ archivedThreads: stateUnits.reduce(
394
+ (sum, [, unit]) => sum + unit.threads.length,
395
+ 0
396
+ ),
397
+ staleParser: stateUnits.filter(
398
+ ([key, unit]) =>
399
+ unit.parser !== null &&
400
+ !present.has(key) &&
401
+ (unit.format !== SESSION_ARCHIVE_FORMAT_VERSION ||
402
+ unit.parser !== SESSION_PARSERS[unit.harness ?? source.harness])
403
+ ).length,
404
+ sourceUnavailable: stateUnits.filter(([key]) => !present.has(key))
405
+ .length,
406
+ lastImportAt: sourceState?.lastImportAt ?? null,
407
+ });
408
+ }
409
+ // Profiles are read from the config on disk, so a long-running server
410
+ // shows triggers enabled or paused by another process.
411
+ const onDisk = await loadConfig(this.deps.configPath).catch(() => null);
412
+ const automation = await readAutomationStatus({
413
+ sessions:
414
+ onDisk?.ok &&
415
+ onDisk.value.sessions?.archiveRoot === sessions.archiveRoot
416
+ ? onDisk.value.sessions
417
+ : sessions,
418
+ configPath: await canonicalConfigPath(this.deps.configPath),
419
+ indexName: this.deps.indexName,
420
+ now: this.now,
421
+ });
422
+ return {
423
+ schemaVersion: "1",
424
+ configured: true,
425
+ index: this.deps.indexName,
426
+ collections,
427
+ sources,
428
+ automation: automation.status,
429
+ warnings: [...warnings, ...automation.warnings],
430
+ };
431
+ }
432
+
433
+ private async resolveSources(
434
+ sessions: SessionsConfig,
435
+ input: SessionImportInput,
436
+ options: SessionImportOptions
437
+ ): Promise<ResolvedSource[]> {
438
+ const paths = (input.paths ?? []).filter((path) => path.trim());
439
+ if (paths.length > 0 && !options.allowPaths) {
440
+ throw new SessionsError(
441
+ "SESSIONS_UNSAFE_PATH",
442
+ "Host paths cannot be named on this surface; import a registered source by its ID."
443
+ );
444
+ }
445
+ if (input.sourceId && paths.length > 0) {
446
+ throw new SessionsError(
447
+ "SESSIONS_INVALID_INPUT",
448
+ "Select either a registered source or explicit paths, not both."
449
+ );
450
+ }
451
+ if (input.format && !SESSION_HARNESSES.includes(input.format)) {
452
+ throw new SessionsError(
453
+ "SESSIONS_UNSUPPORTED_FORMAT",
454
+ `Unsupported format "${String(input.format)}". Supported: ${SESSION_HARNESSES.join(", ")}.`
455
+ );
456
+ }
457
+ if (input.sourceId) {
458
+ if (input.collection) {
459
+ throw new SessionsError(
460
+ "SESSIONS_INVALID_INPUT",
461
+ "A registered source imports into its registered collection and project mappings; --collection applies to path imports only."
462
+ );
463
+ }
464
+ const source = sessions.sources.find(
465
+ (item) => item.id === input.sourceId
466
+ );
467
+ if (!source) {
468
+ throw new SessionsError(
469
+ "SESSIONS_UNKNOWN_SOURCE",
470
+ `Unknown session source "${input.sourceId}". Registered: ${sessions.sources.map((item) => item.id).join(", ") || "(none)"}.`
471
+ );
472
+ }
473
+ await assertNotFilesystemRootAnyForm(source.path, "A session source");
474
+ const found = await canonicalPath(source.path);
475
+ if (found) assertSafeSourceRoot(found, protectedRoots(sessions));
476
+ const canonical = found && (await isReadableRoot(found)) ? found : null;
477
+ if (!canonical) {
478
+ // With archived units the run still maintains the retained archive
479
+ // (redaction rescans) before failing; without any there is nothing
480
+ // to maintain.
481
+ const state = await loadState(sessions.archiveRoot);
482
+ const archived = Object.keys(state.sources[source.id]?.units ?? {});
483
+ if (archived.length === 0) {
484
+ throw new SessionsError(
485
+ "SESSIONS_SOURCE_UNAVAILABLE",
486
+ `Session source "${source.id}" is not readable right now.`
487
+ );
488
+ }
489
+ }
490
+ return [
491
+ {
492
+ id: source.id,
493
+ harness: input.format ?? source.harness,
494
+ root: canonical,
495
+ collection: source.collection,
496
+ projects: source.projects ?? [],
497
+ },
498
+ ];
499
+ }
500
+ if (paths.length === 0) {
501
+ throw new SessionsError(
502
+ "SESSIONS_SELECTION_REQUIRED",
503
+ "Select what to import: --source <id> for a registered source, or explicit session paths. Run gno sessions discover to preview local sources."
504
+ );
505
+ }
506
+ if (!input.collection) {
507
+ throw new SessionsError(
508
+ "SESSIONS_DESTINATION_REQUIRED",
509
+ "Path imports need an explicit destination: --collection <archive collection>."
510
+ );
511
+ }
512
+ // Validate the destination before touching the filesystem.
513
+ archiveCollection(this.deps.config, sessions, input.collection);
514
+ const resolved: ResolvedSource[] = [];
515
+ for (const path of paths) {
516
+ if (!isAbsolute(path)) {
517
+ throw new SessionsError(
518
+ "SESSIONS_INVALID_INPUT",
519
+ "Session paths must be absolute."
520
+ );
521
+ }
522
+ await assertNotFilesystemRootAnyForm(path, "A session source");
523
+ const canonical = await canonicalPath(path);
524
+ if (!(canonical && (await isReadableRoot(canonical)))) {
525
+ throw new SessionsError(
526
+ "SESSIONS_SOURCE_UNAVAILABLE",
527
+ "A selected session path does not exist or is not readable."
528
+ );
529
+ }
530
+ assertSafeSourceRoot(canonical, protectedRoots(sessions));
531
+ resolved.push({
532
+ id: `${PATH_SOURCE_PREFIX}${hashRecordValue("gno-session-path-source-v1", canonical).slice(0, 12)}`,
533
+ harness: input.format ?? null,
534
+ root: canonical,
535
+ collection: input.collection,
536
+ projects: [],
537
+ });
538
+ }
539
+ return resolved;
540
+ }
541
+
542
+ /** Import selected sources into the archive and sync affected collections. */
543
+ async import(
544
+ input: SessionImportInput,
545
+ options: SessionImportOptions
546
+ ): Promise<SessionImportReceipt> {
547
+ const sessions = requireSessionsConfig(this.deps.config);
548
+ const dryRun = input.dryRun === true;
549
+ const limit = input.limit;
550
+ if (
551
+ limit !== undefined &&
552
+ (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_IMPORT_LIMIT)
553
+ ) {
554
+ throw new SessionsError(
555
+ "SESSIONS_INVALID_INPUT",
556
+ `limit must be an integer between 1 and ${MAX_IMPORT_LIMIT}.`
557
+ );
558
+ }
559
+ const sources = await this.resolveSources(sessions, input, options);
560
+ for (const source of sources) {
561
+ archiveCollection(this.deps.config, sessions, source.collection);
562
+ for (const mapping of source.projects) {
563
+ archiveCollection(this.deps.config, sessions, mapping.collection);
564
+ }
565
+ }
566
+ if (!dryRun && !this.deps.store) {
567
+ throw new SessionsError(
568
+ "SESSIONS_INVALID_INPUT",
569
+ "An open archive index is required to import."
570
+ );
571
+ }
572
+
573
+ if (!dryRun) {
574
+ await mkdir(join(sessions.archiveRoot, SESSION_STATE_DIRNAME), {
575
+ recursive: true,
576
+ });
577
+ }
578
+ const lock = dryRun
579
+ ? null
580
+ : await acquireWriteLock(
581
+ importLockPath(sessions.archiveRoot),
582
+ IMPORT_LOCK_WAIT_MS
583
+ );
584
+ if (!dryRun && !lock) {
585
+ throw new SessionsError(
586
+ "SESSIONS_BUSY",
587
+ "Another session import is running for this archive; retry when it finishes."
588
+ );
589
+ }
590
+ try {
591
+ return await this.runImport(sessions, sources, { dryRun, limit });
592
+ } finally {
593
+ await lock?.release();
594
+ }
595
+ }
596
+
597
+ private async runImport(
598
+ sessions: SessionsConfig,
599
+ sources: ResolvedSource[],
600
+ options: { dryRun: boolean; limit?: number }
601
+ ): Promise<SessionImportReceipt> {
602
+ const state = await loadState(sessions.archiveRoot);
603
+ const counts = emptyCounts();
604
+ const turns = emptyTurns();
605
+ const units: SessionUnitReceipt[] = [];
606
+ const warnings: string[] = [];
607
+ const changed = new Map<string, Set<string>>();
608
+ const excluded = protectedRoots(sessions);
609
+ const redaction = { literals: sessions.redaction?.literals ?? [] };
610
+ const stamp = redactionStamp(redaction);
611
+ // Units whose state changed this run; reverted if the index sync fails so
612
+ // completion is only recorded once the archive is searchable.
613
+ const touched: Array<{ units: Record<string, UnitState>; key: string }> =
614
+ [];
615
+ const revertStamp = new Map<UnitState, string>();
616
+ const pendingWithdrawals: PendingWithdrawal[] = [];
617
+ let processed = 0;
618
+ let deferred = 0;
619
+ let unitsTruncated = false;
620
+
621
+ const recordUnit = (receipt: SessionUnitReceipt): void => {
622
+ if (units.length < SESSION_LIMITS.maxReceiptUnits) units.push(receipt);
623
+ else unitsTruncated = true;
624
+ };
625
+ const markChanged = (collection: string, relPath: string): void => {
626
+ const set = changed.get(collection) ?? new Set<string>();
627
+ set.add(relPath);
628
+ changed.set(collection, set);
629
+ };
630
+
631
+ // Sources whose root is missing or cannot be read: their retained
632
+ // archive is still maintained, then the run fails.
633
+ const unavailable: string[] = [];
634
+ const nothingListed: Awaited<ReturnType<typeof enumerateUnits>> = {
635
+ units: [],
636
+ truncated: false,
637
+ unreadable: [],
638
+ };
639
+
640
+ for (const source of sources) {
641
+ const sourceState: SourceState = state.sources[source.id] ?? {
642
+ lastImportAt: null,
643
+ units: {},
644
+ };
645
+ let enumerated: Awaited<ReturnType<typeof enumerateUnits>>;
646
+ if (source.root === null) {
647
+ unavailable.push(source.id);
648
+ enumerated = nothingListed;
649
+ } else {
650
+ try {
651
+ const root = source.root;
652
+ const harness =
653
+ source.harness ??
654
+ (await detectRootHarness(root, excluded, this.deps.readDirectory));
655
+ enumerated = harness
656
+ ? await enumerateUnits({
657
+ harness,
658
+ root,
659
+ excluded,
660
+ readDirectory: this.deps.readDirectory,
661
+ })
662
+ : nothingListed;
663
+ if (!harness) {
664
+ counts.unsupported += 1;
665
+ // A selected file is named by its safe (redacted) file name.
666
+ const selectedFile = (await stat(root)).isFile();
667
+ recordUnit({
668
+ sourceId: source.id,
669
+ harness: null,
670
+ locator: selectedFile
671
+ ? sanitizeValue(basename(root), redaction)
672
+ : ".",
673
+ outcome: "unsupported",
674
+ reason: "format_not_recognised",
675
+ threads: 0,
676
+ turns: 0,
677
+ collections: [],
678
+ });
679
+ continue;
680
+ }
681
+ } catch (error) {
682
+ const reason = unitReason(error);
683
+ if (reason === "source_missing" || reason === "permission_denied") {
684
+ // The root became unreadable after preflight.
685
+ unavailable.push(source.id);
686
+ enumerated = nothingListed;
687
+ } else {
688
+ counts.failed += 1;
689
+ recordUnit({
690
+ sourceId: source.id,
691
+ harness: source.harness,
692
+ locator: ".",
693
+ outcome: "failed",
694
+ reason,
695
+ threads: 0,
696
+ turns: 0,
697
+ collections: [],
698
+ });
699
+ continue;
700
+ }
701
+ }
702
+ }
703
+ if (enumerated.truncated) {
704
+ warnings.push(
705
+ `${source.id}: more than ${SESSION_LIMITS.maxUnitsPerSource} units; the rest are left for a later run`
706
+ );
707
+ }
708
+ // Unread parts of a source fail explicitly; their units keep their
709
+ // checkpoints and archives untouched.
710
+ for (const entry of enumerated.unreadable) {
711
+ counts.failed += 1;
712
+ recordUnit({
713
+ sourceId: source.id,
714
+ harness: source.harness,
715
+ locator:
716
+ entry.locator === null
717
+ ? "."
718
+ : sanitizeValue(entry.locator, redaction),
719
+ outcome: "failed",
720
+ reason: entry.reason,
721
+ threads: 0,
722
+ turns: 0,
723
+ collections: [],
724
+ });
725
+ }
726
+ const present = new Set<string>();
727
+ const destinations = destinationsStamp(source);
728
+ for (const found of enumerated.units) {
729
+ const key = unitKey(source.id, found.locator);
730
+ // Receipts, warnings and checkpoint state show the locator with the
731
+ // owner's redaction applied; identity stays in the opaque key.
732
+ const unit: SessionUnit = {
733
+ ...found,
734
+ locator: sanitizeValue(found.locator, redaction),
735
+ };
736
+ if (present.has(key)) {
737
+ // Two units with the same locator would share archive files.
738
+ counts.failed += 1;
739
+ recordUnit({
740
+ sourceId: source.id,
741
+ harness: unit.harness,
742
+ locator: unit.locator,
743
+ outcome: "failed",
744
+ reason: "unit_conflict",
745
+ threads: 0,
746
+ turns: 0,
747
+ collections: [],
748
+ });
749
+ continue;
750
+ }
751
+ present.add(key);
752
+ let fingerprint: string;
753
+ try {
754
+ fingerprint = await unitFingerprint(unit);
755
+ } catch (error) {
756
+ counts.failed += 1;
757
+ recordUnit({
758
+ sourceId: source.id,
759
+ harness: unit.harness,
760
+ locator: unit.locator,
761
+ outcome: "failed",
762
+ reason: unitReason(error),
763
+ threads: 0,
764
+ turns: 0,
765
+ collections: [],
766
+ });
767
+ continue;
768
+ }
769
+ const previous = sourceState.units[key];
770
+ const current =
771
+ previous?.status === "complete" &&
772
+ previous.fingerprint === fingerprint &&
773
+ previous.parser ===
774
+ SESSION_PARSERS[previous.harness ?? unit.harness] &&
775
+ previous.redaction === stamp &&
776
+ previous.destinations === destinations &&
777
+ previous.format === SESSION_ARCHIVE_FORMAT_VERSION;
778
+ if (current) {
779
+ // Already archived and up to date: reported, not re-read.
780
+ counts.unchanged += previous.threads.length;
781
+ continue;
782
+ }
783
+ if (options.limit !== undefined && processed >= options.limit) {
784
+ deferred += 1;
785
+ continue;
786
+ }
787
+ processed += 1;
788
+ if (unit.size > SESSION_LIMITS.maxSourceBytes) {
789
+ counts.skippedPolicy += 1;
790
+ recordUnit({
791
+ sourceId: source.id,
792
+ harness: unit.harness,
793
+ locator: unit.locator,
794
+ outcome: "skipped_policy",
795
+ reason: "over_limit",
796
+ threads: 0,
797
+ turns: 0,
798
+ collections: [],
799
+ });
800
+ continue;
801
+ }
802
+ touched.push({ units: sourceState.units, key });
803
+ const outcome = await this.importUnit({
804
+ source,
805
+ unit,
806
+ key,
807
+ destinations,
808
+ fingerprint,
809
+ previous,
810
+ sourceState,
811
+ dryRun: options.dryRun,
812
+ redaction,
813
+ counts,
814
+ turns,
815
+ markChanged,
816
+ pendingWithdrawals,
817
+ });
818
+ recordUnit(outcome);
819
+ }
820
+
821
+ // Units whose source vanished keep their archive. Stale redaction is
822
+ // rescanned in place from the durable sanitized archive.
823
+ for (const [key, unitState] of Object.entries(sourceState.units)) {
824
+ if (present.has(key)) continue;
825
+ // Retained locators follow the current redaction policy too.
826
+ const locator = sanitizeValue(unitState.locator, redaction);
827
+ if (!options.dryRun) unitState.locator = locator;
828
+ if (unitState.redaction !== stamp && !options.dryRun) {
829
+ const previousStamp = unitState.redaction;
830
+ const withheld = await this.rescanUnavailable(
831
+ sessions,
832
+ unitState,
833
+ redaction,
834
+ markChanged
835
+ );
836
+ if (withheld > 0) {
837
+ warnings.push(
838
+ `${source.id}: ${withheld} archived threads of ${locator} could not be rescanned with the current redaction rules and were withheld from retrieval`
839
+ );
840
+ } else {
841
+ unitState.redaction = stamp;
842
+ touched.push({ units: sourceState.units, key });
843
+ revertStamp.set(unitState, previousStamp);
844
+ }
845
+ }
846
+ if (
847
+ unitState.parser !== null &&
848
+ (unitState.format !== SESSION_ARCHIVE_FORMAT_VERSION ||
849
+ unitState.parser !==
850
+ SESSION_PARSERS[unitState.harness ?? source.harness ?? "codex"])
851
+ ) {
852
+ warnings.push(
853
+ `${source.id}: ${locator} was archived by an older parser and its source is unavailable; the archive is retained without reparsing`
854
+ );
855
+ }
856
+ }
857
+ if (!options.dryRun) {
858
+ sourceState.lastImportAt = this.now.toISOString();
859
+ state.sources[source.id] = sourceState;
860
+ }
861
+ }
862
+
863
+ let lexical: SessionImportReceipt["lexical"] = {
864
+ status: "skipped",
865
+ collections: [],
866
+ };
867
+ let backlog: number | null = null;
868
+ if (!options.dryRun) {
869
+ lexical = await this.syncChanged(sessions, changed);
870
+ if (lexical.status === "ready") {
871
+ for (const { units: stateUnits, key, thread } of pendingWithdrawals) {
872
+ const unitState = stateUnits[key];
873
+ if (!unitState) continue;
874
+ unitState.threads = unitState.threads.filter(
875
+ (item) =>
876
+ item.collection !== thread.collection ||
877
+ item.relPath !== thread.relPath
878
+ );
879
+ }
880
+ }
881
+ if (lexical.status === "failed") {
882
+ for (const { units: stateUnits, key } of touched) {
883
+ const unitState = stateUnits[key];
884
+ if (!unitState) continue;
885
+ const previousStamp = revertStamp.get(unitState);
886
+ if (previousStamp !== undefined) unitState.redaction = previousStamp;
887
+ else if (unitState.status === "complete")
888
+ unitState.status = "incomplete";
889
+ }
890
+ }
891
+ await saveState(sessions.archiveRoot, state);
892
+ backlog = await this.embeddingBacklog();
893
+ }
894
+ if (unavailable.length > 0) {
895
+ // State is saved: the retained archive was maintained; the run failed.
896
+ throw new SessionsError(
897
+ "SESSIONS_SOURCE_UNAVAILABLE",
898
+ `Session source "${unavailable[0]}" is missing or not readable right now; its archive is retained.`
899
+ );
900
+ }
901
+
902
+ const failures = counts.failed + counts.incomplete + counts.unsupported;
903
+ const work =
904
+ counts.imported +
905
+ counts.updated +
906
+ counts.unchanged +
907
+ counts.skippedPolicy;
908
+ let status: SessionImportReceipt["status"] = "complete";
909
+ if (failures > 0 && work === 0 && counts.incomplete === 0)
910
+ status = "failed";
911
+ else if (failures > 0 || deferred > 0 || lexical.status === "failed") {
912
+ status = "partial";
913
+ } else if (processed === 0) status = "nothing_to_do";
914
+
915
+ return {
916
+ schemaVersion: "1",
917
+ dryRun: options.dryRun,
918
+ index: this.deps.indexName,
919
+ sourceIds: sources.map((source) => source.id),
920
+ status,
921
+ counts,
922
+ turns,
923
+ units,
924
+ unitsTruncated,
925
+ deferredUnits: deferred,
926
+ lexical,
927
+ embedding: { backlog },
928
+ warnings,
929
+ };
930
+ }
931
+
932
+ private async importUnit(options: {
933
+ source: ResolvedSource;
934
+ unit: SessionUnit;
935
+ key: string;
936
+ destinations: string;
937
+ fingerprint: string;
938
+ previous: UnitState | undefined;
939
+ sourceState: SourceState;
940
+ dryRun: boolean;
941
+ redaction: { literals: readonly string[] };
942
+ counts: SessionImportCounts;
943
+ turns: SessionTurnCounts;
944
+ markChanged: (collection: string, relPath: string) => void;
945
+ pendingWithdrawals: PendingWithdrawal[];
946
+ }): Promise<SessionUnitReceipt> {
947
+ const { source, counts, turns } = options;
948
+ const sessions = this.deps.config.sessions as SessionsConfig;
949
+ let unit = options.unit;
950
+ const base: SessionUnitReceipt = {
951
+ sourceId: source.id,
952
+ harness: unit.harness,
953
+ locator: unit.locator,
954
+ outcome: "unchanged",
955
+ threads: 0,
956
+ turns: 0,
957
+ collections: [],
958
+ };
959
+ const setState = (patch: Partial<UnitState>): void => {
960
+ if (options.dryRun) return;
961
+ options.sourceState.units[options.key] = {
962
+ locator: unit.locator,
963
+ fingerprint: options.fingerprint,
964
+ status: "failed",
965
+ parser: null,
966
+ redaction: redactionStamp(options.redaction),
967
+ destinations: options.destinations,
968
+ format: SESSION_ARCHIVE_FORMAT_VERSION,
969
+ threads: options.previous?.threads ?? [],
970
+ updatedAt: this.now.toISOString(),
971
+ ...patch,
972
+ };
973
+ };
974
+
975
+ if (source.harness === null) {
976
+ const detected = await detectHarness(unit.path);
977
+ if (!detected) {
978
+ counts.unsupported += 1;
979
+ setState({ status: "unsupported" });
980
+ return {
981
+ ...base,
982
+ harness: null,
983
+ outcome: "unsupported",
984
+ reason: "format_not_recognised",
985
+ };
986
+ }
987
+ unit = {
988
+ ...unit,
989
+ harness: detected,
990
+ storage: /\.(?:sqlite|db)$/i.test(unit.path) ? "sqlite" : "jsonl",
991
+ };
992
+ }
993
+
994
+ let parsed;
995
+ try {
996
+ parsed = await parseUnit(unit);
997
+ } catch (error) {
998
+ counts.failed += 1;
999
+ setState({ status: "failed" });
1000
+ return {
1001
+ ...base,
1002
+ harness: unit.harness,
1003
+ outcome: "failed",
1004
+ reason: unitReason(error),
1005
+ };
1006
+ }
1007
+ const { diagnostics } = parsed;
1008
+ turns.injectedSkipped += diagnostics.injectedSkipped;
1009
+ turns.copiedHistorySkipped += diagnostics.copiedHistorySkipped;
1010
+ turns.overLimit +=
1011
+ diagnostics.overLimitTurns + diagnostics.overLimitRecords;
1012
+ const receiptWarnings: string[] = [];
1013
+ if (diagnostics.truncatedTail) {
1014
+ receiptWarnings.push(
1015
+ "final record is incomplete (file still being written)"
1016
+ );
1017
+ }
1018
+ if (diagnostics.threadsWithoutHuman > 0) {
1019
+ receiptWarnings.push(
1020
+ `${diagnostics.threadsWithoutHuman} main threads have assistant turns but no recognised human turn`
1021
+ );
1022
+ }
1023
+ if (diagnostics.threadsOverLimit > 0) {
1024
+ receiptWarnings.push(
1025
+ `${diagnostics.threadsOverLimit} threads beyond the per-unit thread limit were not read`
1026
+ );
1027
+ }
1028
+ if (diagnostics.humanTurnsMissing) {
1029
+ receiptWarnings.push(
1030
+ "assistant turns without any recognised human turn: possible format drift"
1031
+ );
1032
+ }
1033
+ if (diagnostics.malformedRecords > 0) {
1034
+ receiptWarnings.push(
1035
+ `${diagnostics.malformedRecords} malformed records skipped`
1036
+ );
1037
+ }
1038
+ if (diagnostics.overLimitRecords + diagnostics.overLimitTurns > 0) {
1039
+ receiptWarnings.push(
1040
+ `${diagnostics.overLimitRecords + diagnostics.overLimitTurns} over-limit records or turns skipped`
1041
+ );
1042
+ }
1043
+ if (parsed.threads.length === 0 && parsed.complete) {
1044
+ receiptWarnings.push("no conversation threads recognised");
1045
+ }
1046
+
1047
+ const written: Array<{ collection: string; relPath: string }> = [];
1048
+ const collections = new Set<string>();
1049
+ let unitImported = 0;
1050
+ let unitUpdated = 0;
1051
+ let unitSkipped = 0;
1052
+ let unitTurns = 0;
1053
+ // Threads withheld by policy; an earlier archived copy is withdrawn too.
1054
+ const withdrawn = new Set<string>();
1055
+ const withhold = (thread: ParsedThread, warning: string): void => {
1056
+ counts.skippedPolicy += 1;
1057
+ unitSkipped += 1;
1058
+ receiptWarnings.push(warning);
1059
+ withdrawn.add(
1060
+ threadRelPath(source.id, thread.harness, options.key, thread.threadId)
1061
+ );
1062
+ };
1063
+ for (const thread of parsed.threads) {
1064
+ const destination = threadDestination(thread, source);
1065
+ if (destination === null) {
1066
+ withhold(
1067
+ thread,
1068
+ "a thread spans working directories mapped to different collections and was quarantined (mixed_domain)"
1069
+ );
1070
+ continue;
1071
+ }
1072
+ if (thread.turns.length >= SESSION_LIMITS.maxTurnsPerThread) {
1073
+ withhold(
1074
+ thread,
1075
+ "a thread reached the per-thread turn limit and was skipped rather than archived truncated (over_limit)"
1076
+ );
1077
+ continue;
1078
+ }
1079
+ archiveCollection(this.deps.config, sessions, destination);
1080
+ const rendered = renderThread({
1081
+ thread,
1082
+ sourceId: source.id,
1083
+ unitKey: options.key,
1084
+ unitLocator: unit.locator,
1085
+ parser: parsed.parser,
1086
+ redaction: options.redaction,
1087
+ });
1088
+ if (rendered.overLimit) {
1089
+ withhold(
1090
+ thread,
1091
+ "a thread exceeded the archive size limit and was skipped (over_limit)"
1092
+ );
1093
+ continue;
1094
+ }
1095
+ if (rendered.lines === 0) continue;
1096
+ turns.human += rendered.humanTurns;
1097
+ turns.assistant += rendered.assistantTurns;
1098
+ turns.redactions += rendered.redactions;
1099
+ unitTurns += rendered.lines;
1100
+ collections.add(destination);
1101
+ written.push({ collection: destination, relPath: rendered.relPath });
1102
+ const path = archiveFilePath(
1103
+ sessions.archiveRoot,
1104
+ destination,
1105
+ rendered.relPath
1106
+ );
1107
+ const existing = await readText(path);
1108
+ if (existing === rendered.content) {
1109
+ counts.unchanged += 1;
1110
+ // Re-sync anyway: a run interrupted after writing but before syncing
1111
+ // leaves an archive file the index has not seen. Syncing an unchanged
1112
+ // file is a cheap hash comparison.
1113
+ if (!options.dryRun) {
1114
+ options.markChanged(destination, rendered.relPath);
1115
+ }
1116
+ continue;
1117
+ }
1118
+ if (existing === null) {
1119
+ counts.imported += 1;
1120
+ unitImported += 1;
1121
+ } else {
1122
+ counts.updated += 1;
1123
+ unitUpdated += 1;
1124
+ }
1125
+ if (!options.dryRun) {
1126
+ await mkdir(dirname(path), { recursive: true });
1127
+ await atomicWrite(path, rendered.content);
1128
+ options.markChanged(destination, rendered.relPath);
1129
+ }
1130
+ }
1131
+
1132
+ // A thread that moved to another collection leaves its old copy behind;
1133
+ // remove it so no stale searchable duplicate survives. Threads that are
1134
+ // simply absent from the source now stay archived and tracked.
1135
+ const retained: Array<{ collection: string; relPath: string }> = [];
1136
+ const keep = new Set(
1137
+ written.map((item) => `${item.collection}\0${item.relPath}`)
1138
+ );
1139
+ for (const old of options.previous?.threads ?? []) {
1140
+ if (withdrawn.has(old.relPath)) {
1141
+ // Stays tracked until the index sync confirms the removal, so a
1142
+ // failed sync is retried by the next run.
1143
+ retained.push(old);
1144
+ if (!options.dryRun) {
1145
+ await this.withdraw(sessions, old, options.markChanged);
1146
+ options.pendingWithdrawals.push({
1147
+ units: options.sourceState.units,
1148
+ key: options.key,
1149
+ thread: old,
1150
+ });
1151
+ }
1152
+ continue;
1153
+ }
1154
+ const moved =
1155
+ !keep.has(`${old.collection}\0${old.relPath}`) &&
1156
+ written.some((item) => item.relPath === old.relPath);
1157
+ if (!moved) {
1158
+ retained.push(old);
1159
+ continue;
1160
+ }
1161
+ if (!options.dryRun) {
1162
+ const oldPath = archiveFilePath(
1163
+ sessions.archiveRoot,
1164
+ old.collection,
1165
+ old.relPath
1166
+ );
1167
+ await unlink(oldPath).catch(() => undefined);
1168
+ options.markChanged(old.collection, old.relPath);
1169
+ }
1170
+ }
1171
+
1172
+ // Dropped malformed input keeps the unit incomplete, so it is retried and
1173
+ // never reported as fully imported.
1174
+ const complete = parsed.complete && diagnostics.malformedRecords === 0;
1175
+ if (!complete) counts.incomplete += 1;
1176
+ setState({
1177
+ status: complete ? "complete" : "incomplete",
1178
+ harness: unit.harness,
1179
+ parser: parsed.parser,
1180
+ threads: mergeThreads(retained, written),
1181
+ });
1182
+
1183
+ let outcome: SessionUnitReceipt["outcome"] = "unchanged";
1184
+ if (!complete) outcome = "incomplete";
1185
+ else if (unitImported > 0) outcome = "imported";
1186
+ else if (unitUpdated > 0) outcome = "updated";
1187
+ else if (unitSkipped > 0 && written.length === 0)
1188
+ outcome = "skipped_policy";
1189
+
1190
+ const unknownKinds = Object.keys(diagnostics.unknownKinds).length
1191
+ ? diagnostics.unknownKinds
1192
+ : undefined;
1193
+ return {
1194
+ ...base,
1195
+ harness: unit.harness,
1196
+ outcome,
1197
+ ...(outcome === "incomplete"
1198
+ ? {
1199
+ reason: diagnostics.truncatedTail
1200
+ ? "truncated_tail"
1201
+ : diagnostics.threadsOverLimit > 0
1202
+ ? "over_limit"
1203
+ : diagnostics.malformedRecords > 0
1204
+ ? "malformed_records"
1205
+ : "format_drift",
1206
+ }
1207
+ : {}),
1208
+ threads: written.length,
1209
+ turns: unitTurns,
1210
+ collections: [...collections].sort(),
1211
+ ...(unknownKinds ? { unknownKinds } : {}),
1212
+ ...(receiptWarnings.length > 0 ? { warnings: receiptWarnings } : {}),
1213
+ };
1214
+ }
1215
+
1216
+ /**
1217
+ * Rescan the archive of a unit whose source is gone. Files that no longer
1218
+ * parse are moved out of their collection (withheld from retrieval) and
1219
+ * counted; the caller only records the new redaction stamp when none were.
1220
+ */
1221
+ private async rescanUnavailable(
1222
+ sessions: SessionsConfig,
1223
+ unitState: UnitState,
1224
+ redaction: { literals: readonly string[] },
1225
+ markChanged: (collection: string, relPath: string) => void
1226
+ ): Promise<number> {
1227
+ let withheld = 0;
1228
+ for (const thread of unitState.threads) {
1229
+ // Always resync: a rescan whose earlier sync failed left the archive
1230
+ // already rewritten, and the index must still catch up.
1231
+ markChanged(thread.collection, thread.relPath);
1232
+ const path = archiveFilePath(
1233
+ sessions.archiveRoot,
1234
+ thread.collection,
1235
+ thread.relPath
1236
+ );
1237
+ const content = await readText(path);
1238
+ if (content === null) {
1239
+ if (
1240
+ await Bun.file(
1241
+ withheldPath(
1242
+ sessions.archiveRoot,
1243
+ thread.collection,
1244
+ thread.relPath
1245
+ )
1246
+ ).exists()
1247
+ ) {
1248
+ withheld += 1;
1249
+ }
1250
+ continue;
1251
+ }
1252
+ const rescanned = rescanArchiveContent(content, redaction);
1253
+ if (!rescanned) {
1254
+ await this.withdraw(sessions, thread, markChanged);
1255
+ withheld += 1;
1256
+ continue;
1257
+ }
1258
+ if (rescanned.content !== content) {
1259
+ await atomicWrite(path, rescanned.content);
1260
+ }
1261
+ }
1262
+ return withheld;
1263
+ }
1264
+
1265
+ /** Move an archive file out of its collection (out of retrieval). */
1266
+ private async withdraw(
1267
+ sessions: SessionsConfig,
1268
+ thread: { collection: string; relPath: string },
1269
+ markChanged: (collection: string, relPath: string) => void
1270
+ ): Promise<void> {
1271
+ const path = archiveFilePath(
1272
+ sessions.archiveRoot,
1273
+ thread.collection,
1274
+ thread.relPath
1275
+ );
1276
+ markChanged(thread.collection, thread.relPath);
1277
+ if (!(await Bun.file(path).exists())) return;
1278
+ const aside = withheldPath(
1279
+ sessions.archiveRoot,
1280
+ thread.collection,
1281
+ thread.relPath
1282
+ );
1283
+ await mkdir(dirname(aside), { recursive: true });
1284
+ await rename(path, aside);
1285
+ }
1286
+
1287
+ private async syncChanged(
1288
+ sessions: SessionsConfig,
1289
+ changed: Map<string, Set<string>>
1290
+ ): Promise<SessionImportReceipt["lexical"]> {
1291
+ const store = this.deps.store;
1292
+ if (!store || changed.size === 0) {
1293
+ return { status: "ready", collections: [] };
1294
+ }
1295
+ const database = store.getRawDb();
1296
+ writeIndexBinding(
1297
+ database,
1298
+ await canonicalConfigPath(this.deps.configPath)
1299
+ );
1300
+ const syncService = this.deps.syncService ?? defaultSyncService;
1301
+ const names = [...changed.keys()].sort();
1302
+ for (const name of names) {
1303
+ const collection = archiveCollection(this.deps.config, sessions, name);
1304
+ const relPaths = [...(changed.get(name) ?? [])].sort();
1305
+ try {
1306
+ const result = await syncService.syncPaths(
1307
+ collection,
1308
+ store,
1309
+ relPaths,
1310
+ withContentTypeRules(
1311
+ { runUpdateCmd: false, gitPull: false },
1312
+ this.deps.config
1313
+ )
1314
+ );
1315
+ const failed = (result.files ?? []).find(
1316
+ (file) => file.status === "error"
1317
+ );
1318
+ if (failed) {
1319
+ return {
1320
+ status: "failed",
1321
+ collections: names,
1322
+ error: `${name}: ${failed.errorCode ?? "sync_error"}; the next import retries the sync.`,
1323
+ };
1324
+ }
1325
+ } catch {
1326
+ // The message can carry host paths; receipts stay path-free.
1327
+ return {
1328
+ status: "failed",
1329
+ collections: names,
1330
+ error: `${name}: sync_failed; the next import retries the sync.`,
1331
+ };
1332
+ }
1333
+ }
1334
+ return { status: "ready", collections: names };
1335
+ }
1336
+
1337
+ private async embeddingBacklog(): Promise<number | null> {
1338
+ const status = await this.deps.store?.getStatus();
1339
+ return status?.ok ? status.value.embeddingBacklog : null;
1340
+ }
1341
+
1342
+ /**
1343
+ * Preview (or apply) removal of archived threads whose source unit no
1344
+ * longer exists. Source deletion alone never removes archive files.
1345
+ */
1346
+ async prune(options: {
1347
+ sourceId: string;
1348
+ apply: boolean;
1349
+ }): Promise<SessionPrunePreview> {
1350
+ const sessions = requireSessionsConfig(this.deps.config);
1351
+ if (!options.apply) {
1352
+ return (await this.planPrune(sessions, options.sourceId)).preview;
1353
+ }
1354
+ if (!this.deps.store) {
1355
+ throw new SessionsError(
1356
+ "SESSIONS_INVALID_INPUT",
1357
+ "An open archive index is required to prune."
1358
+ );
1359
+ }
1360
+ const lock = await acquireWriteLock(
1361
+ importLockPath(sessions.archiveRoot),
1362
+ IMPORT_LOCK_WAIT_MS
1363
+ );
1364
+ if (!lock) {
1365
+ throw new SessionsError(
1366
+ "SESSIONS_BUSY",
1367
+ "A session import is running for this archive; retry when it finishes."
1368
+ );
1369
+ }
1370
+ try {
1371
+ // Plan under the lock so a concurrent import cannot change the state
1372
+ // this run acts on.
1373
+ const plan = await this.planPrune(sessions, options.sourceId);
1374
+ if (plan.removable.length === 0 || !plan.sourceState) return plan.preview;
1375
+ const changed = new Map<string, Set<string>>();
1376
+ for (const [, unit] of plan.removable) {
1377
+ for (const thread of unit.threads) {
1378
+ if (plan.referenced.has(`${thread.collection}\0${thread.relPath}`)) {
1379
+ continue;
1380
+ }
1381
+ await unlink(
1382
+ archiveFilePath(
1383
+ sessions.archiveRoot,
1384
+ thread.collection,
1385
+ thread.relPath
1386
+ )
1387
+ ).catch(() => undefined);
1388
+ const set = changed.get(thread.collection) ?? new Set<string>();
1389
+ set.add(thread.relPath);
1390
+ changed.set(thread.collection, set);
1391
+ }
1392
+ }
1393
+ const lexical = await this.syncChanged(sessions, changed);
1394
+ if (lexical.status === "failed") {
1395
+ // State keeps the units, so the next prune retries the removal.
1396
+ return { ...plan.preview, error: lexical.error };
1397
+ }
1398
+ for (const [key] of plan.removable) delete plan.sourceState.units[key];
1399
+ await saveState(sessions.archiveRoot, plan.state);
1400
+ return { ...plan.preview, applied: true };
1401
+ } finally {
1402
+ await lock.release();
1403
+ }
1404
+ }
1405
+
1406
+ private async planPrune(sessions: SessionsConfig, sourceId: string) {
1407
+ const source = sessions.sources.find((item) => item.id === sourceId);
1408
+ const state = await loadState(sessions.archiveRoot);
1409
+ const sourceState = state.sources[sourceId];
1410
+ if (!source && !sourceState) {
1411
+ throw new SessionsError(
1412
+ "SESSIONS_UNKNOWN_SOURCE",
1413
+ `Unknown session source "${sourceId}".`
1414
+ );
1415
+ }
1416
+ const present = new Set<string>();
1417
+ let canonical: string | null = null;
1418
+ if (source) {
1419
+ try {
1420
+ canonical = await realpath(source.path);
1421
+ } catch (error) {
1422
+ // Only a root that is really gone counts as deleted; a root that
1423
+ // cannot be resolved (for example an unreadable parent) is unread.
1424
+ if (readFailureReason(error) !== "source_missing") {
1425
+ throw new SessionsError(
1426
+ "SESSIONS_SOURCE_UNAVAILABLE",
1427
+ `Session source "${sourceId}" could not be read completely; prune needs a complete listing.`
1428
+ );
1429
+ }
1430
+ }
1431
+ }
1432
+ if (source && canonical) {
1433
+ // Prune removes archives whose unit is absent, so it runs only over a
1434
+ // complete listing: an unread part of the source is not a deletion.
1435
+ let enumerated;
1436
+ try {
1437
+ enumerated = await enumerateUnits({
1438
+ harness: source.harness,
1439
+ root: canonical,
1440
+ excluded: protectedRoots(sessions),
1441
+ readDirectory: this.deps.readDirectory,
1442
+ });
1443
+ } catch {
1444
+ enumerated = null;
1445
+ }
1446
+ if (
1447
+ !enumerated ||
1448
+ enumerated.truncated ||
1449
+ enumerated.unreadable.length > 0
1450
+ ) {
1451
+ throw new SessionsError(
1452
+ "SESSIONS_SOURCE_UNAVAILABLE",
1453
+ `Session source "${sourceId}" could not be read completely; prune needs a complete listing.`
1454
+ );
1455
+ }
1456
+ for (const unit of enumerated.units) {
1457
+ present.add(unitKey(source.id, unit.locator));
1458
+ }
1459
+ }
1460
+ const entries = Object.entries(sourceState?.units ?? {});
1461
+ const removable = entries.filter(([key]) => !present.has(key));
1462
+ // A file a present unit still references is never removed.
1463
+ const referenced = new Set(
1464
+ entries
1465
+ .filter(([key]) => present.has(key))
1466
+ .flatMap(([, unit]) =>
1467
+ unit.threads.map(
1468
+ (thread) => `${thread.collection}\0${thread.relPath}`
1469
+ )
1470
+ )
1471
+ );
1472
+ const preview: SessionPrunePreview = {
1473
+ schemaVersion: "1",
1474
+ sourceId,
1475
+ applied: false,
1476
+ units: removable.map(([, unit]) => ({
1477
+ locator: sanitizeValue(unit.locator, {
1478
+ literals: sessions.redaction?.literals ?? [],
1479
+ }),
1480
+ threads: unit.threads.length,
1481
+ })),
1482
+ archiveFiles: removable.reduce(
1483
+ (sum, [, unit]) =>
1484
+ sum +
1485
+ unit.threads.filter(
1486
+ (thread) =>
1487
+ !referenced.has(`${thread.collection}\0${thread.relPath}`)
1488
+ ).length,
1489
+ 0
1490
+ ),
1491
+ };
1492
+ return { preview, removable, referenced, state, sourceState };
1493
+ }
1494
+ }
1495
+
1496
+ function mergeThreads(
1497
+ previous: Array<{ collection: string; relPath: string }>,
1498
+ written: Array<{ collection: string; relPath: string }>
1499
+ ): Array<{ collection: string; relPath: string }> {
1500
+ const merged = new Map<string, { collection: string; relPath: string }>();
1501
+ for (const item of [...previous, ...written]) {
1502
+ merged.set(`${item.collection}\0${item.relPath}`, item);
1503
+ }
1504
+ return [...merged.values()];
1505
+ }
1506
+
1507
+ async function countArchiveFiles(root: string): Promise<number> {
1508
+ const entries = await readdir(root, {
1509
+ recursive: true,
1510
+ withFileTypes: true,
1511
+ }).catch(() => []);
1512
+ return entries.filter(
1513
+ (entry) => entry.isFile() && entry.name.endsWith(".jsonl")
1514
+ ).length;
1515
+ }
1516
+
1517
+ async function sampleVersions(
1518
+ units: readonly SessionUnit[]
1519
+ ): Promise<string[]> {
1520
+ const versions = new Set<string>();
1521
+ const newest = [...units].sort((left, right) => right.mtimeMs - left.mtimeMs);
1522
+ for (const unit of newest.slice(0, 3)) {
1523
+ try {
1524
+ const parsed = await parseUnit(unit);
1525
+ if (parsed.diagnostics.formatVersion) {
1526
+ versions.add(parsed.diagnostics.formatVersion);
1527
+ }
1528
+ } catch {
1529
+ // Discovery reports structure only; unreadable samples are skipped.
1530
+ }
1531
+ }
1532
+ return [...versions].sort();
1533
+ }