@aiwg/cli 2026.9.11 → 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.
- package/dist/src/cli/handlers/sessions.js +200 -8
- package/dist/src/sessions/adapters/claude.js +127 -3
- package/dist/src/sessions/export-plan.js +117 -0
- package/dist/src/sessions/fortemi-export-mapping.js +296 -0
- package/dist/src/sessions/fortemi-shard-recovery.js +151 -0
- package/dist/src/sessions/index.js +4 -0
- package/dist/src/sessions/output-lineage.js +43 -0
- package/package.json +1 -1
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, realpathSync, statSync, } from 'node:fs';
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
?
|
|
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
|
|
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-
|
|
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.
|
|
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
|