@aiwg/cli 2026.9.12 → 2026.9.15

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.
@@ -1,7 +1,8 @@
1
- import { existsSync, mkdirSync, realpathSync, statSync, } from 'node:fs';
2
- import { dirname, isAbsolute, resolve, } from 'node:path';
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync, } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { dirname, isAbsolute, join, resolve, } from 'node:path';
3
4
  import { pathToFileURL } from 'node:url';
4
- import { CLAUDE_ADAPTER_VERSION, ClaudeSessionAdapter, CODEX_ADAPTER_VERSION, CodexSessionAdapter, COPILOT_ADAPTER_VERSION, CopilotSessionAdapter, CURSOR_ADAPTER_VERSION, CursorSessionAdapter, FACTORY_ADAPTER_VERSION, FactorySessionAdapter, HERMES_ADAPTER_VERSION, HermesSessionAdapter, OPENCODE_ADAPTER_VERSION, OpenCodeSessionAdapter, OPENCLAW_ADAPTER_VERSION, OpenClawSessionAdapter, OPENHUMAN_ADAPTER_VERSION, OpenHumanSessionAdapter, PI_ADAPTER_VERSION, PiSessionAdapter, DEEPSEEK_HARNESS_ADAPTER_VERSION, DeepSeekHarnessSessionAdapter, OmpSessionAdapter, OMP_ADAPTER_VERSION, WARP_ADAPTER_VERSION, WarpSessionAdapter, DEVIN_DESKTOP_ADAPTER_VERSION, DevinDesktopSessionAdapter, CandidateExtractionService, GENERIC_ADAPTER_VERSION, GenericSessionInterchangeAdapter, IncrementalSessionImporter, ImportLeaseContentionError, FilesystemMemoryDestination, FilesystemPromotionDispositionCoordinator, MemoryPromotionGateway, SESSION_CONTRACT_VERSION, SESSION_PROVIDER_IDS, SessionContractError, SessionRepository, SessionSourceSchema, StructuralCandidateExtractor, resolveMemoryConsumerManifest, assertSessionProviderId, acquireImportLease, defaultDiscoveryManifestPath, discoverWorkspaceHistories, deriveSessionTimeline, importDiscoveryManifest, previewDiscoveryImport, publicDiscoveryManifest, readDiscoveryManifest, redactSourceLocator, sha256, parseTimelineGap, writeDiscoveryManifest, } from '../../sessions/index.js';
5
+ import { CLAUDE_ADAPTER_VERSION, ClaudeSessionAdapter, CODEX_ADAPTER_VERSION, CodexSessionAdapter, COPILOT_ADAPTER_VERSION, CopilotSessionAdapter, CURSOR_ADAPTER_VERSION, CursorSessionAdapter, FACTORY_ADAPTER_VERSION, FactorySessionAdapter, HERMES_ADAPTER_VERSION, HermesSessionAdapter, OPENCODE_ADAPTER_VERSION, OpenCodeSessionAdapter, OPENCLAW_ADAPTER_VERSION, OpenClawSessionAdapter, OPENHUMAN_ADAPTER_VERSION, OpenHumanSessionAdapter, PI_ADAPTER_VERSION, PiSessionAdapter, DEEPSEEK_HARNESS_ADAPTER_VERSION, DeepSeekHarnessSessionAdapter, OmpSessionAdapter, OMP_ADAPTER_VERSION, WARP_ADAPTER_VERSION, WarpSessionAdapter, DEVIN_DESKTOP_ADAPTER_VERSION, DevinDesktopSessionAdapter, CandidateExtractionService, GENERIC_ADAPTER_VERSION, GenericSessionInterchangeAdapter, IncrementalSessionImporter, ImportLeaseContentionError, FilesystemMemoryDestination, FilesystemPromotionDispositionCoordinator, MemoryPromotionGateway, SESSION_CONTRACT_VERSION, SESSION_PROVIDER_IDS, SessionContractError, SessionRepository, SessionSourceSchema, StructuralCandidateExtractor, resolveMemoryConsumerManifest, assertSessionProviderId, acquireImportLease, defaultDiscoveryManifestPath, discoverWorkspaceHistories, deriveSessionTimeline, importDiscoveryManifest, previewDiscoveryImport, publicDiscoveryManifest, readDiscoveryManifest, redactSourceLocator, sha256, parseTimelineGap, writeDiscoveryManifest, buildSessionExportPlan, reverifySessionExportPlan, buildSessionAiwgFortemiIndexExport, recoverAiwgFortemiIndexFromFullV1Shard, SESSION_EXPORT_PLAN_SCHEMA_VERSION, } from '../../sessions/index.js';
5
6
  const JSON_CONTRACT_VERSION = '1.0.0';
6
7
  async function createLineMemoryPromotionDestination(projectRoot, manifestPath) {
7
8
  const modulePath = resolve(dirname(manifestPath), 'commands', 'line-memory.mjs');
@@ -33,6 +34,10 @@ Commands:
33
34
  show <session-id> Show a session with events and tags
34
35
  search <query> --workspace <id> Search authorized normalized content
35
36
  extract [session-id] --workspace <id> Extract structural candidates
37
+ export plan --workspace <id> --out <file> <session-id...> Select sessions into a reviewable plan
38
+ export build --plan <file> --out <dir> Build a Fortemi Knowledge Shard from a plan
39
+ export verify --input <shard> Validate a shard and its receipt
40
+ export unpack --input <shard> --out <dir> Recover a shard's records to disk
36
41
  candidates [--state <state>] List the candidate review queue
37
42
  review <id> <version> <state> Record an explicit review transition
38
43
  promote <id> <version> Preview promotion; use --confirm to write
@@ -409,6 +414,7 @@ async function executeCommand(ctx, args) {
409
414
  ? preview(command, { items, count: items.length, durableMemoryWrites: 0, scan })
410
415
  : ok(command, { items, count: items.length, durableMemoryWrites: 0, scan });
411
416
  }
417
+ case 'export': return exportCommand(ctx, args, repository);
412
418
  case 'candidates': {
413
419
  const { workspaceId } = readAuthorizationContext(ctx, args, command, repository);
414
420
  const state = candidateState(args.values.get('--state'));
@@ -694,7 +700,7 @@ async function importSource(ctx, args) {
694
700
  ? new DevinDesktopSessionAdapter()
695
701
  : new GenericSessionInterchangeAdapter();
696
702
  const locatorClass = isDsh ? 'deepseek-harness-session-v2-jsonl' : isOmp ? 'omp-session-v3-jsonl' : isClaude
697
- ? (input.endsWith('.hooks.jsonl') ? 'claude-hook-jsonl' : 'claude-transcript-jsonl')
703
+ ? claudeLocatorClass(input)
698
704
  : isCodex
699
705
  ? (input.endsWith('.app-server.jsonl') ? 'codex-app-server-jsonl' : 'codex-rollout-jsonl')
700
706
  : isCopilot
@@ -726,7 +732,7 @@ async function importSource(ctx, args) {
726
732
  const source = SessionSourceSchema.parse({
727
733
  contractVersion: SESSION_CONTRACT_VERSION, sourceId, provider,
728
734
  providerProfile: isDsh ? 'native-session-v2-jsonl' : isOmp ? 'native-title-slot-v3' : isClaude
729
- ? 'documented-local-jsonl'
735
+ ? claudeProviderProfile(locatorClass)
730
736
  : isCodex
731
737
  ? 'app-server-v2-rollout-fallback'
732
738
  : isCopilot
@@ -837,6 +843,178 @@ async function importSource(ctx, args) {
837
843
  await lease.release();
838
844
  }
839
845
  }
846
+ /**
847
+ * `aiwg sessions export {plan,build,verify,unpack}` (#2564). Sub-verb after
848
+ * `export`, matching the issue's proposed shape:
849
+ * export plan --workspace ID --out selection.json SESSION_ID...
850
+ * export build --plan selection.json --out DIRECTORY
851
+ * export verify --input evidence.shard
852
+ * export unpack --input evidence.shard --out DIRECTORY
853
+ */
854
+ async function exportCommand(ctx, args, repository) {
855
+ const subVerb = requiredPositional(args, 0, 'export sub-command (plan|build|verify|unpack)');
856
+ const command = `export.${subVerb}`;
857
+ if (subVerb === 'plan')
858
+ return exportPlan(ctx, args, repository, command);
859
+ if (subVerb === 'build')
860
+ return exportBuild(ctx, args, repository, command);
861
+ if (subVerb === 'verify')
862
+ return exportVerify(ctx, args, command);
863
+ if (subVerb === 'unpack')
864
+ return exportUnpack(ctx, args, command);
865
+ throw new CliError('UNKNOWN_COMMAND', `unknown export sub-command: ${subVerb}`, EXIT.usage);
866
+ }
867
+ function exportPlan(ctx, args, repository, command) {
868
+ const workspaceId = normalizeWorkspaceId(ctx.cwd, requiredValue(args, '--workspace'));
869
+ const out = resolve(ctx.cwd, requiredValue(args, '--out'));
870
+ const flagSelection = args.values.get('--session')?.split(',').map((value) => value.trim()).filter(Boolean) ?? [];
871
+ const sessionIds = [...new Set([...flagSelection, ...args.positionals.slice(1)])];
872
+ const { plan } = buildSessionExportPlan(repository, { workspaceId, sessionIds, projectRoot: ctx.cwd });
873
+ mkdirSync(dirname(out), { recursive: true, mode: 0o700 });
874
+ writeFileSync(out, `${JSON.stringify(plan, null, 2)}\n`, { mode: 0o600 });
875
+ return ok(command, {
876
+ plan: out,
877
+ totals: plan.totals,
878
+ sessions: plan.sessions.map((entry) => entry.sessionId),
879
+ outputs: plan.outputs.length,
880
+ });
881
+ }
882
+ async function exportBuild(ctx, args, repository, command) {
883
+ const planPath = resolve(ctx.cwd, requiredValue(args, '--plan'));
884
+ const outDirectory = resolve(ctx.cwd, requiredValue(args, '--out'));
885
+ const shardName = args.values.get('--shard-name') ?? 'evidence.shard';
886
+ let plan;
887
+ try {
888
+ plan = JSON.parse(readFileSync(planPath, 'utf8'));
889
+ }
890
+ catch {
891
+ throw new CliError('MALFORMED_SOURCE', `plan file is not readable JSON: ${planPath}`, EXIT.usage);
892
+ }
893
+ if (plan.schemaVersion !== SESSION_EXPORT_PLAN_SCHEMA_VERSION) {
894
+ throw new CliError('UNKNOWN_SCHEMA_MAJOR', `plan schemaVersion ${plan.schemaVersion} is not supported by this build (expected ${SESSION_EXPORT_PLAN_SCHEMA_VERSION})`, EXIT.contract);
895
+ }
896
+ // Recheck source changes before writing (#2564 acceptance criteria):
897
+ // re-select from the live repository and reject on any digest drift.
898
+ const { sessions, eventsBySessionId } = reverifySessionExportPlan(repository, plan);
899
+ const index = buildSessionAiwgFortemiIndexExport(plan.workspaceId, sessions, eventsBySessionId, plan.outputs);
900
+ // aiwgFortemiIndexToKnowledgeShardWithReport targets the declared
901
+ // full-v1/2.0.0 archive contract and validates losslessness -- unlike the
902
+ // plain aiwgFortemiIndexToKnowledgeShard (core-v1), which silently embeds
903
+ // the whole input record as opaque note metadata regardless of whether it
904
+ // maps to any native component. The mapping in fortemi-export-mapping.ts
905
+ // was built specifically so every AIWG-specific field lands in a real
906
+ // full-v1 component (tags, provenance_events) rather than being smuggled
907
+ // through as an unmapped blob.
908
+ const { aiwgFortemiIndexToKnowledgeShardWithReport } = await import('@fortemi/core');
909
+ const result = await aiwgFortemiIndexToKnowledgeShardWithReport(index);
910
+ if (!result.success || !result.archive) {
911
+ throw new CliError('MALFORMED_SOURCE', `full-v1 shard conversion failed: ${JSON.stringify(result.losses)}`, EXIT.contract);
912
+ }
913
+ const bytes = result.archive;
914
+ mkdirSync(outDirectory, { recursive: true, mode: 0o700 });
915
+ const finalPath = join(outDirectory, shardName);
916
+ // Interrupted writes must never be mistaken for a completed export: write
917
+ // to a sibling temp file, then rename (atomic on the same filesystem), and
918
+ // only then write the receipt -- the receipt's existence is the completion
919
+ // signal, not the shard file's.
920
+ const tempPath = join(outDirectory, `.${shardName}.${randomUUID()}.tmp`);
921
+ writeFileSync(tempPath, bytes, { mode: 0o600 });
922
+ renameSync(tempPath, finalPath);
923
+ const readBack = readFileSync(finalPath);
924
+ const shardDigest = sha256(readBack);
925
+ if (readBack.length !== bytes.length) {
926
+ throw new CliError('IMPORT_INTERRUPTED', 'shard readback size did not match the written archive', EXIT.storage);
927
+ }
928
+ const receipt = {
929
+ schemaVersion: '1.0.0',
930
+ generatedAt: new Date().toISOString(),
931
+ workspaceId: plan.workspaceId,
932
+ planGeneratedAt: plan.generatedAt,
933
+ sessionIds: plan.sessions.map((entry) => entry.sessionId),
934
+ totals: plan.totals,
935
+ indexSchemaVersion: index.schema_version,
936
+ archiveProfile: result.profile,
937
+ archiveSchemaVersion: result.schema_version,
938
+ lossless: result.lossless,
939
+ losses: result.losses,
940
+ fortemiReceipt: result.receipt,
941
+ shardFile: shardName,
942
+ shardDigest,
943
+ shardBytes: readBack.length,
944
+ };
945
+ writeFileSync(`${finalPath}.receipt.json`, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
946
+ return ok(command, { shard: finalPath, receipt: `${finalPath}.receipt.json`, ...receipt });
947
+ }
948
+ async function exportVerify(ctx, args, command) {
949
+ const input = resolve(ctx.cwd, requiredValue(args, '--input'));
950
+ let bytes;
951
+ try {
952
+ bytes = readFileSync(input);
953
+ }
954
+ catch {
955
+ throw new CliError('SOURCE_NOT_AUTHORIZED', `shard file is not readable: ${input}`, EXIT.usage);
956
+ }
957
+ let recovered;
958
+ try {
959
+ recovered = recoverAiwgFortemiIndexFromFullV1Shard(bytes);
960
+ }
961
+ catch (error) {
962
+ const message = error instanceof Error ? error.message : String(error);
963
+ throw new CliError('MALFORMED_SOURCE', `shard failed validation: ${message}`, EXIT.contract);
964
+ }
965
+ const shardDigest = sha256(bytes);
966
+ const receiptPath = `${input}.receipt.json`;
967
+ let receiptMatches = null;
968
+ if (existsSync(receiptPath)) {
969
+ try {
970
+ const receipt = JSON.parse(readFileSync(receiptPath, 'utf8'));
971
+ receiptMatches = receipt.shardDigest === shardDigest && receipt.shardBytes === bytes.length;
972
+ }
973
+ catch {
974
+ receiptMatches = false;
975
+ }
976
+ }
977
+ return ok(command, {
978
+ valid: true,
979
+ schemaVersion: recovered.schema_version,
980
+ itemCount: recovered.items.length,
981
+ shardDigest,
982
+ shardBytes: bytes.length,
983
+ receiptChecked: existsSync(receiptPath),
984
+ receiptMatches,
985
+ });
986
+ }
987
+ async function exportUnpack(ctx, args, command) {
988
+ const input = resolve(ctx.cwd, requiredValue(args, '--input'));
989
+ const outDirectory = resolve(ctx.cwd, requiredValue(args, '--out'));
990
+ let bytes;
991
+ try {
992
+ bytes = readFileSync(input);
993
+ }
994
+ catch {
995
+ throw new CliError('SOURCE_NOT_AUTHORIZED', `shard file is not readable: ${input}`, EXIT.usage);
996
+ }
997
+ let recovered;
998
+ try {
999
+ recovered = recoverAiwgFortemiIndexFromFullV1Shard(bytes);
1000
+ }
1001
+ catch (error) {
1002
+ const message = error instanceof Error ? error.message : String(error);
1003
+ throw new CliError('MALFORMED_SOURCE', `shard failed validation: ${message}`, EXIT.contract);
1004
+ }
1005
+ mkdirSync(outDirectory, { recursive: true, mode: 0o700 });
1006
+ const finalPath = join(outDirectory, 'index.json');
1007
+ const tempPath = join(outDirectory, `.index.json.${randomUUID()}.tmp`);
1008
+ writeFileSync(tempPath, `${JSON.stringify(recovered, null, 2)}\n`, { mode: 0o600 });
1009
+ renameSync(tempPath, finalPath);
1010
+ return ok(command, {
1011
+ index: finalPath,
1012
+ schemaVersion: recovered.schema_version,
1013
+ itemCount: recovered.items.length,
1014
+ sessionRecordCount: recovered.items.filter((item) => item.type === 'aiwg.session').length,
1015
+ eventRecordCount: recovered.items.filter((item) => item.type === 'aiwg.session-event').length,
1016
+ });
1017
+ }
840
1018
  async function discoverWorkspace(ctx, args) {
841
1019
  const workspace = resolve(ctx.cwd, requiredValue(args, '--workspace'));
842
1020
  const manifest = await discoverWorkspaceHistories({
@@ -935,12 +1113,13 @@ function providerDisposition(provider) {
935
1113
  return {
936
1114
  provider, disposition: 'implemented', operationalState: 'available',
937
1115
  supportedOperations: ['discover', 'inspect', 'stream'],
938
- acquisitionModes: ['jsonl', 'hook'],
1116
+ acquisitionModes: ['jsonl', 'hook', 'manual-export'],
939
1117
  reasonCode: null,
940
- remediation: 'Authorize a Claude projects or hook root, then import an explicit JSONL file.',
1118
+ remediation: 'Authorize a Claude projects or hook root and import a JSONL file, '
1119
+ + 'or import an explicitly selected Claude web/account conversations.json export.',
941
1120
  evidence: {
942
1121
  adapterVersion: CLAUDE_ADAPTER_VERSION,
943
- verifiedAt: '2026-07-27',
1122
+ verifiedAt: '2026-09-15',
944
1123
  documentation: 'https://code.claude.com/docs/en/sessions',
945
1124
  },
946
1125
  };
@@ -1131,6 +1310,18 @@ function providerDisposition(provider) {
1131
1310
  evidence: { adapterVersion: null, verifiedAt: '2026-07-26' },
1132
1311
  };
1133
1312
  }
1313
+ function claudeLocatorClass(input) {
1314
+ if (input.endsWith('.hooks.jsonl') || input.endsWith('.hook.jsonl'))
1315
+ return 'claude-hook-jsonl';
1316
+ if (input.endsWith('.json'))
1317
+ return 'claude-web-export-json';
1318
+ return 'claude-transcript-jsonl';
1319
+ }
1320
+ function claudeProviderProfile(locatorClass) {
1321
+ if (locatorClass === 'claude-web-export-json')
1322
+ return 'web-account-export-conversations-json';
1323
+ return 'documented-local-jsonl';
1324
+ }
1134
1325
  function cursorLocatorClass(input) {
1135
1326
  if (input.endsWith('.md') || input.endsWith('.markdown'))
1136
1327
  return 'cursor-editor-markdown';
@@ -1182,6 +1373,7 @@ function parseArgs(argv) {
1182
1373
  '--inactivity-threshold',
1183
1374
  '--control-events',
1184
1375
  '--session', '--status', '--actor', '--group-by',
1376
+ '--out', '--plan', '--input', '--shard-name',
1185
1377
  ]);
1186
1378
  let command;
1187
1379
  for (let index = 0; index < argv.length; index += 1) {
@@ -3,9 +3,12 @@ import { basename, extname, resolve } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { SessionContractError, assertSupportedSchemaMajor, sha256, } from '../contracts.js';
5
5
  import { redactSourceLocator } from '../discovery.js';
6
- import { readBoundedJsonLines, streamBoundedJsonLines, } from '../readers.js';
7
- export const CLAUDE_ADAPTER_VERSION = '1.0.0';
6
+ import { readBoundedJson, readBoundedJsonLines, streamBoundedJsonLines, } from '../readers.js';
7
+ export const CLAUDE_ADAPTER_VERSION = '1.1.0';
8
8
  export const CLAUDE_TRANSCRIPT_SCHEMA_VERSION = '1.0.0';
9
+ /** AIWG's own understanding of the layout it supports; Claude web/account
10
+ * exports carry no in-band schema version to read instead. */
11
+ export const CLAUDE_WEB_EXPORT_SCHEMA_VERSION = '1.0.0';
9
12
  const ClaudeRecordSchema = z.object({
10
13
  type: z.string().min(1),
11
14
  subtype: z.string().optional(),
@@ -40,6 +43,43 @@ const ClaudeHookSchema = z.object({
40
43
  schemaVersion: z.string().optional(),
41
44
  timestamp: z.string().datetime({ offset: true }).optional(),
42
45
  }).passthrough();
46
+ /**
47
+ * Claude.ai web/account data export (`conversations.json`): a JSON array of
48
+ * conversations, each carrying its own `chat_messages`. This is a distinct
49
+ * source format from local JSONL transcripts/hooks -- renaming the file does
50
+ * not make it one (#2565). Unrecognized fields are captured via unknownFields
51
+ * rather than dropped, and content the export marks as an attachment/file is
52
+ * recorded as a reference (bytes are not embedded in this export format).
53
+ */
54
+ const ClaudeWebExportAttachmentSchema = z.object({
55
+ file_name: z.string().optional(),
56
+ file_type: z.string().optional(),
57
+ file_size: z.number().optional(),
58
+ extracted_content: z.string().optional(),
59
+ }).passthrough();
60
+ const ClaudeWebExportContentBlockSchema = z.object({
61
+ type: z.string().optional(),
62
+ text: z.string().optional(),
63
+ }).passthrough();
64
+ const ClaudeWebExportMessageSchema = z.object({
65
+ uuid: z.string().min(1),
66
+ text: z.string().optional(),
67
+ sender: z.enum(['human', 'assistant']),
68
+ created_at: z.string().optional(),
69
+ updated_at: z.string().optional(),
70
+ content: z.array(ClaudeWebExportContentBlockSchema).optional(),
71
+ attachments: z.array(ClaudeWebExportAttachmentSchema).optional(),
72
+ files: z.array(ClaudeWebExportAttachmentSchema).optional(),
73
+ }).passthrough();
74
+ const ClaudeWebExportConversationSchema = z.object({
75
+ uuid: z.string().min(1),
76
+ name: z.string().optional(),
77
+ created_at: z.string().optional(),
78
+ updated_at: z.string().optional(),
79
+ account: z.object({ uuid: z.string().optional() }).passthrough().optional(),
80
+ chat_messages: z.array(ClaudeWebExportMessageSchema),
81
+ }).passthrough();
82
+ const ClaudeWebExportSchema = z.array(ClaudeWebExportConversationSchema);
43
83
  export class ClaudeSessionAdapter {
44
84
  limits;
45
85
  discoveryLimits;
@@ -47,7 +87,7 @@ export class ClaudeSessionAdapter {
47
87
  adapterVersion = CLAUDE_ADAPTER_VERSION;
48
88
  disposition = 'implemented';
49
89
  supportedOperations = ['discover', 'inspect', 'stream'];
50
- acquisitionModes = ['jsonl', 'hook'];
90
+ acquisitionModes = ['jsonl', 'hook', 'manual-export'];
51
91
  constructor(limits, discoveryLimits = { maxDepth: 8, maxFiles: 10_000 }) {
52
92
  this.limits = limits;
53
93
  this.discoveryLimits = discoveryLimits;
@@ -80,6 +120,13 @@ export class ClaudeSessionAdapter {
80
120
  };
81
121
  }
82
122
  async *stream(source, cursor) {
123
+ if (source.locatorClass === 'claude-web-export-json') {
124
+ const parsed = await this.readWebExportSource(source);
125
+ const start = parseRecordCursor(cursor?.value);
126
+ for (const record of parsed.records.slice(start))
127
+ yield record;
128
+ return;
129
+ }
83
130
  const start = parseRecordCursor(cursor?.value);
84
131
  const input = await streamBoundedJsonLines({
85
132
  selectedPath: source.locator,
@@ -112,6 +159,9 @@ export class ClaudeSessionAdapter {
112
159
  }
113
160
  }
114
161
  async readSource(source) {
162
+ if (source.locatorClass === 'claude-web-export-json') {
163
+ return this.readWebExportSource(source);
164
+ }
115
165
  const result = await readBoundedJsonLines({
116
166
  selectedPath: source.locator,
117
167
  allowedRoots: source.authorizedScope.allowedRoots,
@@ -132,7 +182,81 @@ export class ClaudeSessionAdapter {
132
182
  consistency: normalized.complete ? 'complete' : 'provisional',
133
183
  };
134
184
  }
185
+ async readWebExportSource(source) {
186
+ const { value } = await readBoundedJson({ selectedPath: source.locator, allowedRoots: source.authorizedScope.allowedRoots }, this.limits);
187
+ const parsed = ClaudeWebExportSchema.safeParse(value);
188
+ if (!parsed.success) {
189
+ throw new SessionContractError('MALFORMED_SOURCE', 'Claude web/account export is not a recognized conversations.json layout');
190
+ }
191
+ if (parsed.data.length === 0) {
192
+ throw new SessionContractError('MALFORMED_SOURCE', 'Claude web/account export is empty');
193
+ }
194
+ return {
195
+ records: normalizeWebExport(parsed.data),
196
+ schemaVersion: CLAUDE_WEB_EXPORT_SCHEMA_VERSION,
197
+ // The export is a completed snapshot at request time, not a live tail.
198
+ consistency: 'complete',
199
+ };
200
+ }
135
201
  }
202
+ function normalizeWebExport(conversations) {
203
+ const output = [];
204
+ const seenConversationIds = new Set();
205
+ for (const conversation of conversations) {
206
+ if (seenConversationIds.has(conversation.uuid)) {
207
+ throw new SessionContractError('DUPLICATE_NATIVE_ID', `Claude web export declares the same conversation uuid twice: ${conversation.uuid}`);
208
+ }
209
+ seenConversationIds.add(conversation.uuid);
210
+ const seenMessageIds = new Set();
211
+ for (const [index, message] of conversation.chat_messages.entries()) {
212
+ if (seenMessageIds.has(message.uuid)) {
213
+ throw new SessionContractError('DUPLICATE_NATIVE_ID', `Claude web export conversation ${conversation.uuid} declares the same message uuid twice: ${message.uuid}`);
214
+ }
215
+ seenMessageIds.add(message.uuid);
216
+ output.push(webExportProviderRecord(conversation, message, index));
217
+ }
218
+ }
219
+ return output;
220
+ }
221
+ function webExportProviderRecord(conversation, message, sequence) {
222
+ const text = message.text && message.text.length > 0
223
+ ? message.text
224
+ : (message.content ?? []).map((block) => block.text ?? '').filter(Boolean).join('\n');
225
+ const attachments = [...(message.attachments ?? []), ...(message.files ?? [])];
226
+ return {
227
+ nativeSessionId: conversation.uuid,
228
+ nativeEventId: message.uuid,
229
+ sequence,
230
+ kind: 'message',
231
+ role: message.sender,
232
+ participant: message.sender,
233
+ occurredAt: message.created_at,
234
+ text,
235
+ rawReference: { locatorClass: 'claude-web-export-json', sequence },
236
+ extensions: {
237
+ conversationName: conversation.name,
238
+ conversationCreatedAt: conversation.created_at,
239
+ conversationUpdatedAt: conversation.updated_at,
240
+ provenance: { acquisition: 'claude-web-export-json', schema: CLAUDE_WEB_EXPORT_SCHEMA_VERSION },
241
+ // Web/account exports reference attachments/files by metadata only; the
242
+ // export format does not embed original bytes, so this is a coverage
243
+ // note (#2565 acceptance criteria), not a loss AIWG introduced.
244
+ ...(attachments.length > 0 ? {
245
+ attachmentReferences: attachments.map((attachment) => ({
246
+ fileName: attachment.file_name,
247
+ fileType: attachment.file_type,
248
+ fileSize: attachment.file_size,
249
+ extractedContentAvailable: typeof attachment.extracted_content === 'string',
250
+ })),
251
+ metadataLoss: ['attachment bytes unavailable in web/account export format'],
252
+ } : {}),
253
+ unknownFields: unknownFields(message, WEB_EXPORT_MESSAGE_KEYS),
254
+ },
255
+ };
256
+ }
257
+ const WEB_EXPORT_MESSAGE_KEYS = new Set([
258
+ 'uuid', 'text', 'sender', 'created_at', 'updated_at', 'content', 'attachments', 'files',
259
+ ]);
136
260
  function normalizeTranscript(records, locator) {
137
261
  const filenameSessionId = basename(locator, extname(locator));
138
262
  const output = [];
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Builds and re-verifies a session export selection plan (#2564). A plan
3
+ * binds a reviewable, human-inspectable selection to the exact session ids
4
+ * and source/event digests seen at plan time, so `sessions export build`
5
+ * can detect a stale plan (source changed since planning) rather than
6
+ * silently exporting drifted data.
7
+ */
8
+ import { createHash } from 'node:crypto';
9
+ import { SessionContractError } from './contracts.js';
10
+ import { buildSessionAiwgFortemiIndexExport, SESSION_EXPORT_INDEX_SCHEMA_VERSION, } from './fortemi-export-mapping.js';
11
+ import { discoverSessionOutputCandidates } from './output-lineage.js';
12
+ import { FilesystemDerivedOutputIndex } from './output-registration.js';
13
+ export const SESSION_EXPORT_PLAN_SCHEMA_VERSION = '1.0.0';
14
+ function eventDigestSummary(events) {
15
+ const hash = createHash('sha256');
16
+ for (const event of events)
17
+ hash.update(event.digest);
18
+ return `sha256:${hash.digest('hex')}`;
19
+ }
20
+ /**
21
+ * Selects the requested sessions from the repository and builds a plan.
22
+ * Rejects an empty or duplicate selection, and any session id the
23
+ * repository cannot authorize for the given workspace -- an unauthorized
24
+ * or unknown id is a planning error, not something to silently skip.
25
+ */
26
+ export function buildSessionExportPlan(repository, options) {
27
+ const requested = [...new Set(options.sessionIds)];
28
+ if (requested.length === 0) {
29
+ throw new SessionContractError('INVALID_ARGUMENT', 'session export plan requires at least one session id');
30
+ }
31
+ if (requested.length !== options.sessionIds.length) {
32
+ throw new SessionContractError('INVALID_ARGUMENT', 'session export plan selection contains duplicate session ids');
33
+ }
34
+ const sessions = [];
35
+ const eventsBySessionId = new Map();
36
+ const entries = [];
37
+ for (const sessionId of requested) {
38
+ const session = repository.getSession(sessionId, options.workspaceId);
39
+ if (!session) {
40
+ throw new SessionContractError('SOURCE_NOT_AUTHORIZED', `session is unknown or not authorized for this workspace: ${sessionId}`);
41
+ }
42
+ const events = repository.listEvents(sessionId, options.workspaceId);
43
+ sessions.push(session);
44
+ eventsBySessionId.set(sessionId, events);
45
+ entries.push({
46
+ sessionId: session.sessionId,
47
+ nativeSessionId: session.nativeSessionId,
48
+ provider: session.provider,
49
+ sourceId: session.sourceId,
50
+ sourceDigest: session.sourceDigest,
51
+ eventCount: events.length,
52
+ eventDigestSummary: eventDigestSummary(events),
53
+ });
54
+ }
55
+ const sampleSize = options.previewSampleSize ?? 5;
56
+ const outputCandidates = options.projectRoot
57
+ ? discoverSessionOutputCandidates(new FilesystemDerivedOutputIndex(options.projectRoot).registrations(), sessions)
58
+ : [];
59
+ const outputs = outputCandidates.map((candidate) => ({
60
+ sessionId: candidate.sessionId,
61
+ registrationId: candidate.registration.registrationId,
62
+ outputLocator: candidate.registration.output.locator,
63
+ outputDigest: candidate.registration.output.digest,
64
+ mediaType: candidate.registration.output.mediaType,
65
+ lineage: candidate.lineage,
66
+ matchReason: candidate.matchReason,
67
+ }));
68
+ const index = buildSessionAiwgFortemiIndexExport(options.workspaceId, sessions, eventsBySessionId, outputs);
69
+ const plan = {
70
+ schemaVersion: SESSION_EXPORT_PLAN_SCHEMA_VERSION,
71
+ indexSchemaVersion: SESSION_EXPORT_INDEX_SCHEMA_VERSION,
72
+ generatedAt: new Date().toISOString(),
73
+ workspaceId: options.workspaceId,
74
+ sessions: entries,
75
+ outputs,
76
+ totals: {
77
+ sessionCount: sessions.length,
78
+ eventCount: entries.reduce((sum, entry) => sum + entry.eventCount, 0),
79
+ outputCount: outputs.length,
80
+ recordCount: index.items.length,
81
+ },
82
+ preview: {
83
+ recordCount: index.items.length,
84
+ sampleRecordIds: index.items.slice(0, sampleSize).map((item) => item.id),
85
+ },
86
+ };
87
+ return { plan, sessions, eventsBySessionId };
88
+ }
89
+ /**
90
+ * Re-selects the plan's sessions from the repository and compares digests
91
+ * against what the plan recorded. Any drift -- a changed source, a
92
+ * changed/reimported event set, or a session/id that has since disappeared
93
+ * -- fails closed rather than exporting silently-stale data (#2564
94
+ * acceptance criteria: "Plans reject stale sources ... changed analysis
95
+ * bytes").
96
+ */
97
+ export function reverifySessionExportPlan(repository, plan) {
98
+ const sessions = [];
99
+ const eventsBySessionId = new Map();
100
+ for (const entry of plan.sessions) {
101
+ const session = repository.getSession(entry.sessionId, plan.workspaceId);
102
+ if (!session) {
103
+ throw new SessionContractError('SOURCE_NOT_AUTHORIZED', `planned session no longer exists or is not authorized: ${entry.sessionId}`);
104
+ }
105
+ if (session.sourceDigest !== entry.sourceDigest) {
106
+ throw new SessionContractError('SCHEMA_DRIFT', `session source changed since the plan was generated: ${entry.sessionId}`);
107
+ }
108
+ const events = repository.listEvents(entry.sessionId, plan.workspaceId);
109
+ if (events.length !== entry.eventCount || eventDigestSummary(events) !== entry.eventDigestSummary) {
110
+ throw new SessionContractError('SCHEMA_DRIFT', `session events changed since the plan was generated: ${entry.sessionId}`);
111
+ }
112
+ sessions.push(session);
113
+ eventsBySessionId.set(entry.sessionId, events);
114
+ }
115
+ return { sessions, eventsBySessionId };
116
+ }
117
+ //# sourceMappingURL=export-plan.js.map
@@ -0,0 +1,296 @@
1
+ /**
2
+ * `record.v1`/`export.v1` forbid exactly the fields this mapping needs
3
+ * (`source.origin`/`generated`/`checksum`/`updated_at`, relationship
4
+ * `direction`/`confidence`/`privacy`) -- verified empirically against
5
+ * @fortemi/core's bundled AJV schema, since the type declarations alone
6
+ * don't encode this v1/v2 split. Use v2 throughout.
7
+ */
8
+ export const SESSION_EXPORT_RECORD_SCHEMA_VERSION = 'aiwg.fortemi.index.record.v2';
9
+ export const SESSION_EXPORT_INDEX_SCHEMA_VERSION = 'aiwg.fortemi.index.export.v2';
10
+ const INDEX_GRAPH_ID = 'aiwg-sessions';
11
+ function tag(key, value) {
12
+ return `${key}:${value}`;
13
+ }
14
+ export function sessionRecordLocator(session) {
15
+ return `aiwg-session://${session.provider}/${session.sessionId}`;
16
+ }
17
+ export function sessionEventRecordLocator(session, event) {
18
+ return `${sessionRecordLocator(session)}/event/${event.eventId}`;
19
+ }
20
+ export function sessionToAiwgFortemiRecord(session) {
21
+ const locator = sessionRecordLocator(session);
22
+ const updatedAt = session.updatedAt ?? session.startedAt ?? new Date(0).toISOString();
23
+ return {
24
+ schema_version: SESSION_EXPORT_RECORD_SCHEMA_VERSION,
25
+ id: session.sessionId,
26
+ type: 'aiwg.session',
27
+ source: {
28
+ path: locator,
29
+ repo_relative_path: locator,
30
+ locator,
31
+ origin: 'aiwg-session-catalog',
32
+ generated: true,
33
+ checksum: session.sourceDigest,
34
+ updated_at: updatedAt,
35
+ },
36
+ // `title`/`text` are required by @fortemi/core's runtime schema even
37
+ // though the published type marks them optional -- always emit a
38
+ // deterministic fallback rather than an empty/undefined value.
39
+ title: session.intent.title ?? `${session.provider} session ${session.nativeSessionId}`,
40
+ text: session.intent.summary ?? `AIWG session ${session.sessionId} (${session.provider}, ${session.lifecycle})`,
41
+ facets: {},
42
+ tags: [tag('provider', session.provider), tag('lifecycle', session.lifecycle)],
43
+ concepts: [],
44
+ relationships: [],
45
+ provenance: [{
46
+ field: 'source',
47
+ source: 'aiwg-session-catalog',
48
+ path: locator,
49
+ confidence: 'source',
50
+ privacy: 'private',
51
+ }],
52
+ // Structural identifiers this export exists to preserve, carried as a
53
+ // native provenance_events attribute bag rather than an unmapped
54
+ // `compatibility` field the full-v1 converter would refuse to archive.
55
+ provenance_events: [{
56
+ activity: 'aiwg.session',
57
+ agent: session.provider,
58
+ started_at: session.startedAt ?? updatedAt,
59
+ source: 'aiwg-session-catalog',
60
+ path: locator,
61
+ confidence: 'source',
62
+ privacy: 'private',
63
+ attributes: {
64
+ nativeSessionId: session.nativeSessionId,
65
+ sourceId: session.sourceId,
66
+ workspaceId: session.workspaceId,
67
+ consistency: session.consistency,
68
+ },
69
+ }],
70
+ privacy: { classification: 'public', pii: false },
71
+ updated_at: updatedAt,
72
+ };
73
+ }
74
+ export function sessionEventToAiwgFortemiRecord(session, event) {
75
+ const locator = sessionEventRecordLocator(session, event);
76
+ const parentLocator = sessionRecordLocator(session);
77
+ const updatedAt = event.occurredAt ?? new Date(0).toISOString();
78
+ const relationships = [{
79
+ type: 'parent-session',
80
+ target_id: session.sessionId,
81
+ target_path: parentLocator,
82
+ direction: 'upstream',
83
+ // This edge is a structural fact from the session catalog, not a
84
+ // probabilistic inference -- maximum confidence, not a default.
85
+ confidence: 1,
86
+ privacy: event.sensitivity.classification === 'sensitive' ? 'private' : 'public',
87
+ }];
88
+ const provenance = [{
89
+ field: 'text',
90
+ source: 'aiwg-session-catalog',
91
+ path: locator,
92
+ confidence: 'source',
93
+ privacy: event.sensitivity.classification === 'sensitive' ? 'private' : 'public',
94
+ }];
95
+ const provenanceEvents = [{
96
+ activity: `aiwg.session-event.${event.kind}`,
97
+ agent: event.model ?? session.provider,
98
+ started_at: event.occurredAt ?? updatedAt,
99
+ source: 'aiwg-session-catalog',
100
+ path: locator,
101
+ confidence: 'source',
102
+ privacy: event.sensitivity.classification === 'sensitive' ? 'private' : 'public',
103
+ attributes: {
104
+ sessionId: event.sessionId,
105
+ sourceId: event.sourceId,
106
+ nativeId: event.nativeId,
107
+ sequence: event.sequence,
108
+ toolCallId: event.toolCallId,
109
+ },
110
+ }];
111
+ const tags = [
112
+ tag('kind', event.kind),
113
+ ...(event.role ? [tag('role', event.role)] : []),
114
+ ...(event.toolName ? [tag('tool', event.toolName)] : []),
115
+ ...(event.entities ?? []).map((entity) => tag('entity', entity)),
116
+ ];
117
+ return {
118
+ schema_version: SESSION_EXPORT_RECORD_SCHEMA_VERSION,
119
+ id: event.eventId,
120
+ type: 'aiwg.session-event',
121
+ source: {
122
+ path: locator,
123
+ repo_relative_path: locator,
124
+ locator,
125
+ origin: 'aiwg-session-catalog',
126
+ generated: true,
127
+ checksum: event.digest,
128
+ updated_at: updatedAt,
129
+ },
130
+ title: `${event.kind}${event.role ? ` (${event.role})` : ''} -- ${session.sessionId}`,
131
+ text: event.searchableText.length > 0 ? event.searchableText : `[${event.kind}: no text content]`,
132
+ facets: {},
133
+ tags,
134
+ // Entities are AIWG-extracted strings, not real SKOS taxonomy concepts
135
+ // with definitions -- represented as `entity:` tags above, not as
136
+ // `concepts` (which the full-v1 converter treats as SKOS references and
137
+ // refuses to archive without SKOS metadata backing them).
138
+ concepts: [],
139
+ relationships,
140
+ provenance,
141
+ provenance_events: provenanceEvents,
142
+ privacy: {
143
+ classification: event.sensitivity.classification === 'sensitive' ? 'private' : 'public',
144
+ pii: event.sensitivity.classes.length > 0,
145
+ },
146
+ updated_at: updatedAt,
147
+ };
148
+ }
149
+ /**
150
+ * A registered analysis output associated with a session (#2566), mapped as
151
+ * a reference record. Like session-event attachments (#2565), this does not
152
+ * embed the output's original bytes -- only its registration metadata and
153
+ * digest, which is enough for a consumer to locate and verify the output
154
+ * against the original registration, not to recover it from the shard
155
+ * alone. Full byte embedding needs a blob-store integration that neither
156
+ * #2564 nor #2566 has landed yet; that limitation is shared and explicit,
157
+ * not silently dropped.
158
+ */
159
+ export function sessionOutputToAiwgFortemiRecord(session, output) {
160
+ const locator = `${sessionRecordLocator(session)}/output/${output.registrationId}`;
161
+ const now = new Date().toISOString();
162
+ return {
163
+ schema_version: SESSION_EXPORT_RECORD_SCHEMA_VERSION,
164
+ id: `output_${output.registrationId}`,
165
+ type: 'aiwg.session-output',
166
+ source: {
167
+ path: locator,
168
+ repo_relative_path: output.outputLocator,
169
+ locator,
170
+ origin: 'aiwg-output-registration',
171
+ generated: true,
172
+ checksum: output.outputDigest,
173
+ updated_at: now,
174
+ },
175
+ title: `registered output for ${session.sessionId}`,
176
+ text: `${output.mediaType} output registered at ${output.outputLocator} (${output.matchReason}); `
177
+ + 'bytes not embedded -- reference and digest only.',
178
+ facets: {},
179
+ tags: [tag('mediaType', output.mediaType), tag('lineage', 'registered')],
180
+ concepts: [],
181
+ relationships: [{
182
+ type: 'parent-session',
183
+ target_id: session.sessionId,
184
+ target_path: sessionRecordLocator(session),
185
+ direction: 'upstream',
186
+ confidence: 1,
187
+ privacy: 'private',
188
+ }],
189
+ provenance: [{
190
+ field: 'source',
191
+ source: 'aiwg-output-registration',
192
+ path: locator,
193
+ confidence: 'source',
194
+ privacy: 'private',
195
+ }],
196
+ provenance_events: [{
197
+ activity: 'aiwg.session-output',
198
+ agent: 'aiwg-output-registration',
199
+ started_at: now,
200
+ source: 'aiwg-output-registration',
201
+ path: locator,
202
+ confidence: 'source',
203
+ privacy: 'private',
204
+ attributes: {
205
+ sessionId: output.sessionId,
206
+ registrationId: output.registrationId,
207
+ outputLocator: output.outputLocator,
208
+ matchReason: output.matchReason,
209
+ bytesEmbedded: false,
210
+ },
211
+ }],
212
+ privacy: { classification: 'private', pii: false },
213
+ updated_at: now,
214
+ };
215
+ }
216
+ /**
217
+ * A full-v1 archive has no per-archive metadata sidecar -- only the
218
+ * `aiwg_source` embedded on each individual note. Without this record, the
219
+ * index-level wrapper (`source.repo`/`privacy`/`graph`, `generated_at`)
220
+ * would be a real, disclosed recovery gap. Representing it as one more
221
+ * ordinary record closes that gap using the exact mechanism already built
222
+ * for every other field (`provenance_events[0].attributes`), rather than
223
+ * inventing a second, parallel embedding path.
224
+ *
225
+ * The id is prefixed to sort after every `event_`/`output_`/`session_` id
226
+ * (deterministic regardless of content), and the type is namespaced so
227
+ * recovery can find and exclude it from the recovered `items` unambiguously.
228
+ */
229
+ export const EXPORT_MANIFEST_RECORD_TYPE = 'aiwg.session-catalog-export-manifest';
230
+ export const EXPORT_MANIFEST_RECORD_ID = 'zzz-aiwg-session-catalog-export-manifest';
231
+ function exportManifestRecord(repoRef, generatedAt) {
232
+ const locator = `aiwg-session-catalog://export-manifest/${repoRef}`;
233
+ return {
234
+ schema_version: SESSION_EXPORT_RECORD_SCHEMA_VERSION,
235
+ id: EXPORT_MANIFEST_RECORD_ID,
236
+ type: EXPORT_MANIFEST_RECORD_TYPE,
237
+ source: {
238
+ path: locator,
239
+ repo_relative_path: locator,
240
+ locator,
241
+ origin: 'aiwg-session-catalog',
242
+ generated: true,
243
+ updated_at: generatedAt,
244
+ },
245
+ title: 'AIWG session export manifest',
246
+ text: `Index-level metadata for this session export (${repoRef}). Not a session, event, or output record.`,
247
+ facets: {},
248
+ tags: [tag('graph', INDEX_GRAPH_ID)],
249
+ concepts: [],
250
+ relationships: [],
251
+ provenance: [{
252
+ field: 'source', source: 'aiwg-session-catalog', path: locator, confidence: 'source', privacy: 'private',
253
+ }],
254
+ provenance_events: [{
255
+ activity: EXPORT_MANIFEST_RECORD_TYPE,
256
+ agent: 'aiwg-session-catalog',
257
+ started_at: generatedAt,
258
+ source: 'aiwg-session-catalog',
259
+ path: locator,
260
+ confidence: 'source',
261
+ privacy: 'private',
262
+ attributes: { repo: repoRef, privacy: 'private', graph: INDEX_GRAPH_ID, generatedAt },
263
+ }],
264
+ privacy: { classification: 'private', pii: false },
265
+ updated_at: generatedAt,
266
+ };
267
+ }
268
+ /**
269
+ * Builds the full record graph for a selected set of sessions. `repoRef` is
270
+ * a caller-supplied identity for `AiwgFortemiIndexExport.source.repo` (the
271
+ * session catalog's workspace id, not a git repo -- the field is repurposed
272
+ * honestly rather than left to imply something untrue).
273
+ */
274
+ export function buildSessionAiwgFortemiIndexExport(repoRef, sessions, eventsBySessionId, outputs = []) {
275
+ const generatedAt = new Date().toISOString();
276
+ const items = [exportManifestRecord(repoRef, generatedAt)];
277
+ for (const session of sessions) {
278
+ items.push(sessionToAiwgFortemiRecord(session));
279
+ for (const event of eventsBySessionId.get(session.sessionId) ?? []) {
280
+ items.push(sessionEventToAiwgFortemiRecord(session, event));
281
+ }
282
+ for (const output of outputs.filter((entry) => entry.sessionId === session.sessionId)) {
283
+ items.push(sessionOutputToAiwgFortemiRecord(session, output));
284
+ }
285
+ }
286
+ // Deterministic ordering: reproducible archives, easier diffing.
287
+ items.sort((left, right) => left.id.localeCompare(right.id));
288
+ return {
289
+ schema_version: SESSION_EXPORT_INDEX_SCHEMA_VERSION,
290
+ generated_at: generatedAt,
291
+ source: { repo: repoRef, privacy: 'private', graph: INDEX_GRAPH_ID },
292
+ compatibility: { previous_schema_version: 'aiwg.fortemi.index.export.v1', strategy: 'supported' },
293
+ items,
294
+ };
295
+ }
296
+ //# sourceMappingURL=fortemi-export-mapping.js.map
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Recovers an `AiwgFortemiIndexExport`-shaped record graph from a real
3
+ * `full-v1`/`2.0.0` Knowledge Shard archive built by
4
+ * `aiwgFortemiIndexToKnowledgeShardWithReport` (#2564).
5
+ *
6
+ * `@fortemi/core`'s own `aiwgFortemiIndexFromKnowledgeShard` only recovers
7
+ * archives built by the plain `aiwgFortemiIndexToKnowledgeShard` (`core-v1`):
8
+ * that function embeds the entire original record verbatim as opaque note
9
+ * metadata (`metadata.aiwg_fortemi_index`), which is what its reader looks
10
+ * for. The strict `full-v1` converter deliberately does not do this -- the
11
+ * point of `full-v1` is a genuinely native note/tag/link/provenance
12
+ * representation any Fortemi consumer can read, not an AIWG-only escape
13
+ * hatch. Verified empirically: `aiwgFortemiIndexFromKnowledgeShard` throws
14
+ * "has no AIWG index metadata" against a `full-v1` archive, and even AIWG's
15
+ * own artifact-index shard export test only round-trips through it using
16
+ * `profile: "core-v1"`.
17
+ *
18
+ * So recovering a `full-v1` archive means reading the native shard files
19
+ * directly and inverting this module's own mapping:
20
+ * - id/type/source/privacy <- note.metadata.aiwg_source
21
+ * - title/text <- note.title / note.original_content
22
+ * - tags <- note.tags
23
+ * - relationships <- links.jsonl (from_note_id/to_note_id/kind/score)
24
+ * - provenance/provenance_events <- provenance_activities.jsonl, split by
25
+ * the `source:<field>` activity_type convention this module uses to
26
+ * distinguish `provenance` entries from `provenance_events` entries
27
+ *
28
+ * The index-level wrapper (`AiwgFortemiIndexExport.source`/`compatibility`)
29
+ * is genuinely not preserved anywhere in a full-v1 archive -- there is no
30
+ * per-archive metadata sidecar for it, only per-note. Recovery reports this
31
+ * honestly with a fixed placeholder rather than inventing a value.
32
+ */
33
+ import { unpackTarGz } from '@fortemi/core';
34
+ import { EXPORT_MANIFEST_RECORD_TYPE, SESSION_EXPORT_INDEX_SCHEMA_VERSION, SESSION_EXPORT_RECORD_SCHEMA_VERSION, } from './fortemi-export-mapping.js';
35
+ const SOURCE_PROVENANCE_PREFIX = 'source:';
36
+ function decodeJsonLines(bytes) {
37
+ if (!bytes)
38
+ return [];
39
+ const text = new TextDecoder().decode(bytes);
40
+ return text.split('\n').filter((line) => line.trim().length > 0).map((line) => JSON.parse(line));
41
+ }
42
+ function isAiwgSourceSidecar(value) {
43
+ if (!value || typeof value !== 'object')
44
+ return false;
45
+ const candidate = value;
46
+ return typeof candidate.record_id === 'string' && typeof candidate.record_type === 'string';
47
+ }
48
+ export class FortemiShardRecoveryError extends Error {
49
+ }
50
+ /**
51
+ * Recovers the record graph from a `full-v1` archive. Throws
52
+ * `FortemiShardRecoveryError` if the archive has no notes, or any note is
53
+ * missing the `aiwg_source` sidecar this module's own mapping always writes
54
+ * (meaning the shard wasn't produced by this mapping and can't be recovered
55
+ * this way).
56
+ */
57
+ export function recoverAiwgFortemiIndexFromFullV1Shard(bytes) {
58
+ const files = unpackTarGz(bytes);
59
+ const notes = decodeJsonLines(files.get('notes.jsonl'));
60
+ if (notes.length === 0) {
61
+ throw new FortemiShardRecoveryError('Knowledge Shard contains no notes to recover');
62
+ }
63
+ const links = decodeJsonLines(files.get('links.jsonl'));
64
+ const activities = decodeJsonLines(files.get('provenance_activities.jsonl'));
65
+ const noteIdToRecordId = new Map();
66
+ for (const note of notes) {
67
+ const sidecar = note.metadata?.aiwg_source;
68
+ if (!isAiwgSourceSidecar(sidecar)) {
69
+ throw new FortemiShardRecoveryError(`Knowledge Shard note ${note.id} has no aiwg_source metadata -- `
70
+ + 'this archive was not produced by the AIWG session export mapping');
71
+ }
72
+ noteIdToRecordId.set(note.id, sidecar.record_id);
73
+ }
74
+ const items = notes.map((note) => {
75
+ const sidecar = note.metadata.aiwg_source;
76
+ const relationships = links
77
+ .filter((link) => link.from_note_id === note.id)
78
+ .map((link) => ({
79
+ type: link.kind,
80
+ target_id: link.to_note_id ? (noteIdToRecordId.get(link.to_note_id) ?? link.to_note_id) : '',
81
+ confidence: link.score ?? undefined,
82
+ }));
83
+ const noteActivities = activities.filter((activity) => activity.note_id === note.id);
84
+ const provenance = [];
85
+ const provenanceEvents = [];
86
+ for (const activity of noteActivities) {
87
+ const metadata = activity.metadata ?? {};
88
+ if (activity.activity_type.startsWith(SOURCE_PROVENANCE_PREFIX)) {
89
+ provenance.push({
90
+ field: activity.activity_type.slice(SOURCE_PROVENANCE_PREFIX.length),
91
+ source: metadata.source ?? activity.model_name ?? '',
92
+ path: metadata.path ?? '',
93
+ confidence: metadata.confidence ?? 'source',
94
+ privacy: metadata.privacy ?? 'private',
95
+ });
96
+ }
97
+ else {
98
+ provenanceEvents.push({
99
+ activity: activity.activity_type,
100
+ agent: activity.model_name ?? undefined,
101
+ started_at: activity.started_at ?? undefined,
102
+ ended_at: activity.ended_at ?? undefined,
103
+ source: metadata.source ?? undefined,
104
+ path: metadata.path ?? undefined,
105
+ confidence: metadata.confidence ?? undefined,
106
+ privacy: metadata.privacy ?? undefined,
107
+ attributes: metadata.attributes ?? undefined,
108
+ });
109
+ }
110
+ }
111
+ return {
112
+ schema_version: SESSION_EXPORT_RECORD_SCHEMA_VERSION,
113
+ id: sidecar.record_id,
114
+ type: sidecar.record_type,
115
+ source: sidecar.source,
116
+ title: note.title,
117
+ text: note.original_content,
118
+ facets: {},
119
+ tags: note.tags,
120
+ concepts: [],
121
+ relationships,
122
+ provenance,
123
+ provenance_events: provenanceEvents,
124
+ privacy: sidecar.privacy,
125
+ updated_at: sidecar.source.updated_at ?? new Date(0).toISOString(),
126
+ };
127
+ });
128
+ items.sort((left, right) => left.id.localeCompare(right.id));
129
+ // The export-manifest record (see fortemi-export-mapping.ts) carries the
130
+ // index-level wrapper fields through the same provenance_events mechanism
131
+ // as everything else. Extract it and exclude it from the recovered items
132
+ // -- it isn't a session, event, or output record.
133
+ const manifestIndex = items.findIndex((item) => item.type === EXPORT_MANIFEST_RECORD_TYPE);
134
+ const manifestAttributes = manifestIndex >= 0
135
+ ? items[manifestIndex].provenance_events?.[0]?.attributes
136
+ : undefined;
137
+ const recoveredItems = manifestIndex >= 0
138
+ ? items.filter((_, index) => index !== manifestIndex)
139
+ : items;
140
+ return {
141
+ schema_version: SESSION_EXPORT_INDEX_SCHEMA_VERSION,
142
+ generated_at: manifestAttributes?.generatedAt ?? new Date(0).toISOString(),
143
+ source: manifestAttributes
144
+ ? { repo: manifestAttributes.repo ?? 'unknown', privacy: manifestAttributes.privacy ?? 'private' }
145
+ // No manifest record present (e.g. an archive built before this record
146
+ // existed) -- labeled honestly rather than invented.
147
+ : { repo: 'recovered-from-full-v1-shard', privacy: 'private' },
148
+ items: recoveredItems,
149
+ };
150
+ }
151
+ //# sourceMappingURL=fortemi-shard-recovery.js.map
@@ -7,6 +7,10 @@ export * from './repository.js';
7
7
  export * from './ports.js';
8
8
  export * from './optional-backends.js';
9
9
  export * from './knowledge-shard.js';
10
+ export * from './fortemi-export-mapping.js';
11
+ export * from './fortemi-shard-recovery.js';
12
+ export * from './output-lineage.js';
13
+ export * from './export-plan.js';
10
14
  export * from './candidates.js';
11
15
  export * from './promotion.js';
12
16
  export * from './output-registration.js';
@@ -0,0 +1,43 @@
1
+ import { sessionRecordLocator } from './fortemi-export-mapping.js';
2
+ /** Known, exact conventions a `kind: 'session'` source ref may use to name a session. Order is the preference used to report `matchReason`. */
3
+ function sessionRefCandidates(session) {
4
+ return [
5
+ { ref: session.sessionId, reason: 'ref equals the stable session id' },
6
+ { ref: `session:${session.sessionId}`, reason: "ref equals 'session:<stable session id>'" },
7
+ { ref: sessionRecordLocator(session), reason: 'ref equals the aiwg-session:// export locator' },
8
+ { ref: session.nativeSessionId, reason: 'ref equals the native session id' },
9
+ { ref: `session:${session.nativeSessionId}`, reason: "ref equals 'session:<native session id>'" },
10
+ ];
11
+ }
12
+ function matchSource(source, session) {
13
+ if (source.kind !== 'session')
14
+ return null;
15
+ for (const candidate of sessionRefCandidates(session)) {
16
+ if (source.ref === candidate.ref)
17
+ return candidate.reason;
18
+ }
19
+ return null;
20
+ }
21
+ /**
22
+ * Registrations are scanned once and matched against every selected session
23
+ * -- bounded by the selection, not an unbounded corpus walk, satisfying
24
+ * "bound any file discovery to an explicit workspace/source scope."
25
+ */
26
+ export function discoverSessionOutputCandidates(registrations, sessions) {
27
+ const candidates = [];
28
+ for (const registration of registrations) {
29
+ for (const session of sessions) {
30
+ for (const source of registration.contextPack.sources) {
31
+ const reason = matchSource(source, session);
32
+ if (reason) {
33
+ candidates.push({
34
+ registration, sessionId: session.sessionId, lineage: 'registered', matchReason: reason,
35
+ });
36
+ break;
37
+ }
38
+ }
39
+ }
40
+ }
41
+ return candidates;
42
+ }
43
+ //# sourceMappingURL=output-lineage.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cli",
3
- "version": "2026.9.12",
3
+ "version": "2026.9.15",
4
4
  "description": "Lightweight AIWG CLI for signed, versioned web-backed resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",