@aiwg/cli 2026.9.12 → 2026.9.16
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/agentic/code/providers/capability-matrix.yaml +42 -0
- package/agentic/code/providers/model-capabilities.v1.json +119 -34
- package/agentic/code/providers/model-catalog.v1.json +259 -56
- package/dist/src/cli/handlers/sessions.js +348 -42
- package/dist/src/cli/handlers/use.js +20 -1
- package/dist/src/cli/scope-resolver.js +13 -0
- package/dist/src/cli/services/deployment-verification.js +33 -2
- package/dist/src/config/aiwg-config.js +39 -0
- package/dist/src/config/user-registry.js +9 -0
- package/dist/src/extensions/manifest.js +1 -0
- package/dist/src/models/model-capabilities.v1.json +119 -34
- package/dist/src/models/model-catalog.v1.json +259 -56
- package/dist/src/models/model-discovery.js +7 -0
- package/dist/src/models/provider-policy.js +1 -1
- package/dist/src/plugin/skill-command-translator.js +1 -0
- package/dist/src/providers/capability-matrix.yaml +42 -0
- package/dist/src/providers/grokbot-paths.js +103 -0
- package/dist/src/providers/provider-definitions.js +85 -1
- package/dist/src/providers/provider-inventory.js +1 -0
- package/dist/src/sessions/adapters/claude.js +127 -3
- package/dist/src/sessions/adapters/grokbot.js +49 -0
- package/dist/src/sessions/adapters/hermes.js +17 -9
- package/dist/src/sessions/adapters/opencode.js +12 -1
- package/dist/src/sessions/contracts.js +1 -1
- package/dist/src/sessions/export-plan.js +243 -0
- package/dist/src/sessions/fortemi-export-mapping.js +300 -0
- package/dist/src/sessions/fortemi-shard-blobs.js +78 -0
- package/dist/src/sessions/fortemi-shard-recovery.js +151 -0
- package/dist/src/sessions/index.js +6 -0
- package/dist/src/sessions/output-lineage.js +43 -0
- package/dist/src/sessions/workspace-discovery.js +1 -1
- package/dist/src/skills/deployer.js +17 -0
- package/dist/src/smiths/skillsmith/collision-detector.js +1 -0
- package/package.json +1 -1
- package/tools/agents/deploy-agents.mjs +3 -2
- package/tools/agents/providers/grokbot.mjs +285 -0
|
@@ -0,0 +1,243 @@
|
|
|
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 { lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
10
|
+
import { resolve, sep } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { SessionContractError } from './contracts.js';
|
|
13
|
+
import { buildSessionAiwgFortemiIndexExport, SESSION_EXPORT_INDEX_SCHEMA_VERSION, } from './fortemi-export-mapping.js';
|
|
14
|
+
import { discoverSessionOutputCandidates } from './output-lineage.js';
|
|
15
|
+
import { FilesystemDerivedOutputIndex } from './output-registration.js';
|
|
16
|
+
export const SESSION_EXPORT_PLAN_SCHEMA_VERSION = '1.1.0';
|
|
17
|
+
function eventDigestSummary(events) {
|
|
18
|
+
const hash = createHash('sha256');
|
|
19
|
+
for (const event of events)
|
|
20
|
+
hash.update(event.digest);
|
|
21
|
+
return `sha256:${hash.digest('hex')}`;
|
|
22
|
+
}
|
|
23
|
+
function sha256(value) {
|
|
24
|
+
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
|
|
25
|
+
}
|
|
26
|
+
function sessionMetadataDigest(session, catalogTags) {
|
|
27
|
+
return sha256(JSON.stringify({ session, catalogTags: [...catalogTags].sort() }));
|
|
28
|
+
}
|
|
29
|
+
function localEventAttachment(event, projectRoot) {
|
|
30
|
+
const nativeEnvelope = Object.entries(event.extensions)
|
|
31
|
+
.find(([key, value]) => key.startsWith('native.') && value && typeof value === 'object')?.[1];
|
|
32
|
+
const attachment = event.extensions.attachment ?? nativeEnvelope?.attachment;
|
|
33
|
+
if (!attachment || typeof attachment !== 'object')
|
|
34
|
+
return null;
|
|
35
|
+
const value = attachment;
|
|
36
|
+
const filename = typeof value.displayName === 'string' && value.displayName.length > 0
|
|
37
|
+
? value.displayName
|
|
38
|
+
: typeof value.filename === 'string' && value.filename.length > 0 && !value.filename.startsWith('[REDACTED:')
|
|
39
|
+
? value.filename
|
|
40
|
+
: `${event.eventId}.bin`;
|
|
41
|
+
const mediaType = typeof value.mime === 'string' && value.mime.length > 0 ? value.mime : 'application/octet-stream';
|
|
42
|
+
if (Array.isArray(value.dataBytes)
|
|
43
|
+
&& value.dataBytes.every((item) => Number.isInteger(item) && Number(item) >= 0 && Number(item) <= 255)) {
|
|
44
|
+
return { filename, mediaType, bytes: Uint8Array.from(value.dataBytes) };
|
|
45
|
+
}
|
|
46
|
+
if (typeof value.dataUrl === 'string') {
|
|
47
|
+
const match = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(value.dataUrl);
|
|
48
|
+
if (!match)
|
|
49
|
+
throw new SessionContractError('MALFORMED_SOURCE', `invalid data URL attachment: ${event.eventId}`);
|
|
50
|
+
const bytes = match[2]
|
|
51
|
+
? Buffer.from(match[3], 'base64')
|
|
52
|
+
: Buffer.from(decodeURIComponent(match[3]), 'utf8');
|
|
53
|
+
return { filename, mediaType: match[1] || mediaType, bytes };
|
|
54
|
+
}
|
|
55
|
+
if (typeof value.fileUrl === 'string') {
|
|
56
|
+
if (!projectRoot)
|
|
57
|
+
throw new SessionContractError('SOURCE_NOT_AUTHORIZED', 'project root is required for file attachments');
|
|
58
|
+
const root = realpathSync(projectRoot);
|
|
59
|
+
const candidate = fileURLToPath(value.fileUrl);
|
|
60
|
+
const stat = lstatSync(candidate);
|
|
61
|
+
if (stat.isSymbolicLink())
|
|
62
|
+
throw new SessionContractError('SOURCE_NOT_AUTHORIZED', `attachment is a symbolic link: ${event.eventId}`);
|
|
63
|
+
const actual = realpathSync(candidate);
|
|
64
|
+
if (actual !== root && !actual.startsWith(`${root}${sep}`)) {
|
|
65
|
+
throw new SessionContractError('SOURCE_OUTSIDE_ALLOWED_ROOT', `attachment resolves outside the project: ${event.eventId}`);
|
|
66
|
+
}
|
|
67
|
+
return { filename, mediaType, bytes: readFileSync(actual) };
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Selects the requested sessions from the repository and builds a plan.
|
|
73
|
+
* Rejects an empty or duplicate selection, and any session id the
|
|
74
|
+
* repository cannot authorize for the given workspace -- an unauthorized
|
|
75
|
+
* or unknown id is a planning error, not something to silently skip.
|
|
76
|
+
*/
|
|
77
|
+
export function buildSessionExportPlan(repository, options) {
|
|
78
|
+
const requested = [...new Set(options.sessionIds)];
|
|
79
|
+
if (requested.length === 0) {
|
|
80
|
+
throw new SessionContractError('INVALID_ARGUMENT', 'session export plan requires at least one session id');
|
|
81
|
+
}
|
|
82
|
+
if (requested.length !== options.sessionIds.length) {
|
|
83
|
+
throw new SessionContractError('INVALID_ARGUMENT', 'session export plan selection contains duplicate session ids');
|
|
84
|
+
}
|
|
85
|
+
const sessions = [];
|
|
86
|
+
const eventsBySessionId = new Map();
|
|
87
|
+
const entries = [];
|
|
88
|
+
for (const sessionId of requested) {
|
|
89
|
+
const session = repository.getSession(sessionId, options.workspaceId);
|
|
90
|
+
if (!session) {
|
|
91
|
+
throw new SessionContractError('SOURCE_NOT_AUTHORIZED', `session is unknown or not authorized for this workspace: ${sessionId}`);
|
|
92
|
+
}
|
|
93
|
+
const events = repository.listEvents(sessionId, options.workspaceId);
|
|
94
|
+
const catalogTags = repository.listTags(sessionId, options.workspaceId);
|
|
95
|
+
sessions.push(session);
|
|
96
|
+
eventsBySessionId.set(sessionId, events);
|
|
97
|
+
entries.push({
|
|
98
|
+
sessionId: session.sessionId,
|
|
99
|
+
nativeSessionId: session.nativeSessionId,
|
|
100
|
+
provider: session.provider,
|
|
101
|
+
sourceId: session.sourceId,
|
|
102
|
+
sourceDigest: session.sourceDigest,
|
|
103
|
+
metadataDigest: sessionMetadataDigest(session, catalogTags),
|
|
104
|
+
catalogTags,
|
|
105
|
+
eventCount: events.length,
|
|
106
|
+
eventDigestSummary: eventDigestSummary(events),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
const sampleSize = options.previewSampleSize ?? 5;
|
|
110
|
+
const outputCandidates = options.projectRoot
|
|
111
|
+
? discoverSessionOutputCandidates(new FilesystemDerivedOutputIndex(options.projectRoot).registrations(), sessions)
|
|
112
|
+
: [];
|
|
113
|
+
const outputs = outputCandidates.map((candidate) => ({
|
|
114
|
+
sessionId: candidate.sessionId,
|
|
115
|
+
registrationId: candidate.registration.registrationId,
|
|
116
|
+
outputLocator: candidate.registration.output.locator,
|
|
117
|
+
outputDigest: candidate.registration.output.digest,
|
|
118
|
+
outputByteLength: candidate.registration.output.byteLength,
|
|
119
|
+
mediaType: candidate.registration.output.mediaType,
|
|
120
|
+
lineage: candidate.lineage,
|
|
121
|
+
matchReason: candidate.matchReason,
|
|
122
|
+
}));
|
|
123
|
+
const attachments = [];
|
|
124
|
+
for (const session of sessions) {
|
|
125
|
+
for (const event of eventsBySessionId.get(session.sessionId) ?? []) {
|
|
126
|
+
const attachment = localEventAttachment(event, options.projectRoot);
|
|
127
|
+
if (attachment)
|
|
128
|
+
attachments.push({
|
|
129
|
+
sessionId: session.sessionId,
|
|
130
|
+
eventId: event.eventId,
|
|
131
|
+
filename: attachment.filename,
|
|
132
|
+
mediaType: attachment.mediaType,
|
|
133
|
+
digest: sha256(attachment.bytes),
|
|
134
|
+
byteLength: attachment.bytes.length,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const catalogTagsBySessionId = new Map(entries.map((entry) => [entry.sessionId, entry.catalogTags]));
|
|
139
|
+
const index = buildSessionAiwgFortemiIndexExport(options.workspaceId, sessions, eventsBySessionId, outputs, catalogTagsBySessionId);
|
|
140
|
+
const plan = {
|
|
141
|
+
schemaVersion: SESSION_EXPORT_PLAN_SCHEMA_VERSION,
|
|
142
|
+
indexSchemaVersion: SESSION_EXPORT_INDEX_SCHEMA_VERSION,
|
|
143
|
+
generatedAt: new Date().toISOString(),
|
|
144
|
+
workspaceId: options.workspaceId,
|
|
145
|
+
sessions: entries,
|
|
146
|
+
outputs,
|
|
147
|
+
attachments,
|
|
148
|
+
totals: {
|
|
149
|
+
sessionCount: sessions.length,
|
|
150
|
+
eventCount: entries.reduce((sum, entry) => sum + entry.eventCount, 0),
|
|
151
|
+
outputCount: outputs.length,
|
|
152
|
+
attachmentCount: attachments.length,
|
|
153
|
+
recordCount: index.items.length,
|
|
154
|
+
},
|
|
155
|
+
preview: {
|
|
156
|
+
recordCount: index.items.length,
|
|
157
|
+
sampleRecordIds: index.items.slice(0, sampleSize).map((item) => item.id),
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
return { plan, sessions, eventsBySessionId };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Re-selects the plan's sessions from the repository and compares digests
|
|
164
|
+
* against what the plan recorded. Any drift -- a changed source, a
|
|
165
|
+
* changed/reimported event set, or a session/id that has since disappeared
|
|
166
|
+
* -- fails closed rather than exporting silently-stale data (#2564
|
|
167
|
+
* acceptance criteria: "Plans reject stale sources ... changed analysis
|
|
168
|
+
* bytes").
|
|
169
|
+
*/
|
|
170
|
+
export function reverifySessionExportPlan(repository, plan, options = {}) {
|
|
171
|
+
const sessions = [];
|
|
172
|
+
const eventsBySessionId = new Map();
|
|
173
|
+
const catalogTagsBySessionId = new Map();
|
|
174
|
+
for (const entry of plan.sessions) {
|
|
175
|
+
const session = repository.getSession(entry.sessionId, plan.workspaceId);
|
|
176
|
+
if (!session) {
|
|
177
|
+
throw new SessionContractError('SOURCE_NOT_AUTHORIZED', `planned session no longer exists or is not authorized: ${entry.sessionId}`);
|
|
178
|
+
}
|
|
179
|
+
if (session.sourceDigest !== entry.sourceDigest) {
|
|
180
|
+
throw new SessionContractError('SCHEMA_DRIFT', `session source changed since the plan was generated: ${entry.sessionId}`);
|
|
181
|
+
}
|
|
182
|
+
const catalogTags = repository.listTags(entry.sessionId, plan.workspaceId);
|
|
183
|
+
if (entry.metadataDigest !== sessionMetadataDigest(session, catalogTags)
|
|
184
|
+
|| JSON.stringify(entry.catalogTags) !== JSON.stringify(catalogTags)) {
|
|
185
|
+
throw new SessionContractError('SCHEMA_DRIFT', `session metadata or tags changed since the plan was generated: ${entry.sessionId}`);
|
|
186
|
+
}
|
|
187
|
+
const events = repository.listEvents(entry.sessionId, plan.workspaceId);
|
|
188
|
+
if (events.length !== entry.eventCount || eventDigestSummary(events) !== entry.eventDigestSummary) {
|
|
189
|
+
throw new SessionContractError('SCHEMA_DRIFT', `session events changed since the plan was generated: ${entry.sessionId}`);
|
|
190
|
+
}
|
|
191
|
+
sessions.push(session);
|
|
192
|
+
eventsBySessionId.set(entry.sessionId, events);
|
|
193
|
+
catalogTagsBySessionId.set(entry.sessionId, catalogTags);
|
|
194
|
+
}
|
|
195
|
+
const outputBytesByRegistrationId = new Map();
|
|
196
|
+
if (plan.outputs.length > 0) {
|
|
197
|
+
if (!options.projectRoot) {
|
|
198
|
+
throw new SessionContractError('SOURCE_NOT_AUTHORIZED', 'project root is required to verify planned outputs');
|
|
199
|
+
}
|
|
200
|
+
const root = realpathSync(options.projectRoot);
|
|
201
|
+
for (const output of plan.outputs) {
|
|
202
|
+
const candidate = resolve(root, output.outputLocator);
|
|
203
|
+
let stat;
|
|
204
|
+
try {
|
|
205
|
+
stat = lstatSync(candidate);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
throw new SessionContractError('SCHEMA_DRIFT', `registered output is missing since the plan was generated: ${output.outputLocator}`);
|
|
209
|
+
}
|
|
210
|
+
if (stat.isSymbolicLink()) {
|
|
211
|
+
throw new SessionContractError('SOURCE_NOT_AUTHORIZED', `planned output is a symbolic link: ${output.outputLocator}`);
|
|
212
|
+
}
|
|
213
|
+
if (!stat.isFile()) {
|
|
214
|
+
throw new SessionContractError('SCHEMA_DRIFT', `planned output is no longer a regular file: ${output.outputLocator}`);
|
|
215
|
+
}
|
|
216
|
+
if (stat.size !== output.outputByteLength) {
|
|
217
|
+
throw new SessionContractError('SCHEMA_DRIFT', `registered output changed since the plan was generated: ${output.outputLocator}`);
|
|
218
|
+
}
|
|
219
|
+
const actual = realpathSync(candidate);
|
|
220
|
+
if (actual !== root && !actual.startsWith(`${root}${sep}`)) {
|
|
221
|
+
throw new SessionContractError('SOURCE_OUTSIDE_ALLOWED_ROOT', 'planned output resolves outside the project');
|
|
222
|
+
}
|
|
223
|
+
const bytes = readFileSync(actual);
|
|
224
|
+
if (sha256(bytes) !== output.outputDigest) {
|
|
225
|
+
throw new SessionContractError('SCHEMA_DRIFT', `registered output changed since the plan was generated: ${output.outputLocator}`);
|
|
226
|
+
}
|
|
227
|
+
outputBytesByRegistrationId.set(output.registrationId, bytes);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const attachmentBytesByEventId = new Map();
|
|
231
|
+
for (const planned of plan.attachments ?? []) {
|
|
232
|
+
const event = eventsBySessionId.get(planned.sessionId)?.find((candidate) => candidate.eventId === planned.eventId);
|
|
233
|
+
const attachment = event ? localEventAttachment(event, options.projectRoot) : null;
|
|
234
|
+
if (!attachment || attachment.bytes.length !== planned.byteLength || sha256(attachment.bytes) !== planned.digest) {
|
|
235
|
+
throw new SessionContractError('SCHEMA_DRIFT', `session attachment changed since the plan was generated: ${planned.eventId}`);
|
|
236
|
+
}
|
|
237
|
+
attachmentBytesByEventId.set(planned.eventId, attachment.bytes);
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
sessions, eventsBySessionId, catalogTagsBySessionId, outputBytesByRegistrationId, attachmentBytesByEventId,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=export-plan.js.map
|
|
@@ -0,0 +1,300 @@
|
|
|
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, catalogTags = []) {
|
|
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: [
|
|
43
|
+
tag('provider', session.provider),
|
|
44
|
+
tag('lifecycle', session.lifecycle),
|
|
45
|
+
...catalogTags.map((value) => tag('catalogTag', value)),
|
|
46
|
+
],
|
|
47
|
+
concepts: [],
|
|
48
|
+
relationships: [],
|
|
49
|
+
provenance: [{
|
|
50
|
+
field: 'source',
|
|
51
|
+
source: 'aiwg-session-catalog',
|
|
52
|
+
path: locator,
|
|
53
|
+
confidence: 'source',
|
|
54
|
+
privacy: 'private',
|
|
55
|
+
}],
|
|
56
|
+
// Structural identifiers this export exists to preserve, carried as a
|
|
57
|
+
// native provenance_events attribute bag rather than an unmapped
|
|
58
|
+
// `compatibility` field the full-v1 converter would refuse to archive.
|
|
59
|
+
provenance_events: [{
|
|
60
|
+
activity: 'aiwg.session',
|
|
61
|
+
agent: session.provider,
|
|
62
|
+
started_at: session.startedAt ?? updatedAt,
|
|
63
|
+
source: 'aiwg-session-catalog',
|
|
64
|
+
path: locator,
|
|
65
|
+
confidence: 'source',
|
|
66
|
+
privacy: 'private',
|
|
67
|
+
attributes: {
|
|
68
|
+
nativeSessionId: session.nativeSessionId,
|
|
69
|
+
sourceId: session.sourceId,
|
|
70
|
+
workspaceId: session.workspaceId,
|
|
71
|
+
consistency: session.consistency,
|
|
72
|
+
},
|
|
73
|
+
}],
|
|
74
|
+
privacy: { classification: 'public', pii: false },
|
|
75
|
+
updated_at: updatedAt,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function sessionEventToAiwgFortemiRecord(session, event) {
|
|
79
|
+
const locator = sessionEventRecordLocator(session, event);
|
|
80
|
+
const parentLocator = sessionRecordLocator(session);
|
|
81
|
+
const updatedAt = event.occurredAt ?? new Date(0).toISOString();
|
|
82
|
+
const relationships = [{
|
|
83
|
+
type: 'parent-session',
|
|
84
|
+
target_id: session.sessionId,
|
|
85
|
+
target_path: parentLocator,
|
|
86
|
+
direction: 'upstream',
|
|
87
|
+
// This edge is a structural fact from the session catalog, not a
|
|
88
|
+
// probabilistic inference -- maximum confidence, not a default.
|
|
89
|
+
confidence: 1,
|
|
90
|
+
privacy: event.sensitivity.classification === 'sensitive' ? 'private' : 'public',
|
|
91
|
+
}];
|
|
92
|
+
const provenance = [{
|
|
93
|
+
field: 'text',
|
|
94
|
+
source: 'aiwg-session-catalog',
|
|
95
|
+
path: locator,
|
|
96
|
+
confidence: 'source',
|
|
97
|
+
privacy: event.sensitivity.classification === 'sensitive' ? 'private' : 'public',
|
|
98
|
+
}];
|
|
99
|
+
const provenanceEvents = [{
|
|
100
|
+
activity: `aiwg.session-event.${event.kind}`,
|
|
101
|
+
agent: event.model ?? session.provider,
|
|
102
|
+
started_at: event.occurredAt ?? updatedAt,
|
|
103
|
+
source: 'aiwg-session-catalog',
|
|
104
|
+
path: locator,
|
|
105
|
+
confidence: 'source',
|
|
106
|
+
privacy: event.sensitivity.classification === 'sensitive' ? 'private' : 'public',
|
|
107
|
+
attributes: {
|
|
108
|
+
sessionId: event.sessionId,
|
|
109
|
+
sourceId: event.sourceId,
|
|
110
|
+
nativeId: event.nativeId,
|
|
111
|
+
sequence: event.sequence,
|
|
112
|
+
toolCallId: event.toolCallId,
|
|
113
|
+
},
|
|
114
|
+
}];
|
|
115
|
+
const tags = [
|
|
116
|
+
tag('kind', event.kind),
|
|
117
|
+
...(event.role ? [tag('role', event.role)] : []),
|
|
118
|
+
...(event.toolName ? [tag('tool', event.toolName)] : []),
|
|
119
|
+
...(event.entities ?? []).map((entity) => tag('entity', entity)),
|
|
120
|
+
];
|
|
121
|
+
return {
|
|
122
|
+
schema_version: SESSION_EXPORT_RECORD_SCHEMA_VERSION,
|
|
123
|
+
id: event.eventId,
|
|
124
|
+
type: 'aiwg.session-event',
|
|
125
|
+
source: {
|
|
126
|
+
path: locator,
|
|
127
|
+
repo_relative_path: locator,
|
|
128
|
+
locator,
|
|
129
|
+
origin: 'aiwg-session-catalog',
|
|
130
|
+
generated: true,
|
|
131
|
+
checksum: event.digest,
|
|
132
|
+
updated_at: updatedAt,
|
|
133
|
+
},
|
|
134
|
+
title: `${event.kind}${event.role ? ` (${event.role})` : ''} -- ${session.sessionId}`,
|
|
135
|
+
text: event.searchableText.length > 0 ? event.searchableText : `[${event.kind}: no text content]`,
|
|
136
|
+
facets: {},
|
|
137
|
+
tags,
|
|
138
|
+
// Entities are AIWG-extracted strings, not real SKOS taxonomy concepts
|
|
139
|
+
// with definitions -- represented as `entity:` tags above, not as
|
|
140
|
+
// `concepts` (which the full-v1 converter treats as SKOS references and
|
|
141
|
+
// refuses to archive without SKOS metadata backing them).
|
|
142
|
+
concepts: [],
|
|
143
|
+
relationships,
|
|
144
|
+
provenance,
|
|
145
|
+
provenance_events: provenanceEvents,
|
|
146
|
+
privacy: {
|
|
147
|
+
classification: event.sensitivity.classification === 'sensitive' ? 'private' : 'public',
|
|
148
|
+
pii: event.sensitivity.classes.length > 0,
|
|
149
|
+
},
|
|
150
|
+
updated_at: updatedAt,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A registered analysis output associated with a session (#2566), mapped as
|
|
155
|
+
* a reference record. Like session-event attachments (#2565), this does not
|
|
156
|
+
* embed the output's original bytes -- only its registration metadata and
|
|
157
|
+
* digest, which is enough for a consumer to locate and verify the output
|
|
158
|
+
* against the original registration, not to recover it from the shard
|
|
159
|
+
* alone. Full byte embedding needs a blob-store integration that neither
|
|
160
|
+
* #2564 nor #2566 has landed yet; that limitation is shared and explicit,
|
|
161
|
+
* not silently dropped.
|
|
162
|
+
*/
|
|
163
|
+
export function sessionOutputToAiwgFortemiRecord(session, output) {
|
|
164
|
+
const locator = `${sessionRecordLocator(session)}/output/${output.registrationId}`;
|
|
165
|
+
const now = new Date().toISOString();
|
|
166
|
+
return {
|
|
167
|
+
schema_version: SESSION_EXPORT_RECORD_SCHEMA_VERSION,
|
|
168
|
+
id: `output_${output.registrationId}`,
|
|
169
|
+
type: 'aiwg.session-output',
|
|
170
|
+
source: {
|
|
171
|
+
path: locator,
|
|
172
|
+
repo_relative_path: output.outputLocator,
|
|
173
|
+
locator,
|
|
174
|
+
origin: 'aiwg-output-registration',
|
|
175
|
+
generated: true,
|
|
176
|
+
checksum: output.outputDigest,
|
|
177
|
+
updated_at: now,
|
|
178
|
+
},
|
|
179
|
+
title: `registered output for ${session.sessionId}`,
|
|
180
|
+
text: `${output.mediaType} output registered at ${output.outputLocator} (${output.matchReason}); `
|
|
181
|
+
+ (output.bytesEmbedded ? 'original bytes embedded as a native attachment.' : 'bytes not embedded -- reference and digest only.'),
|
|
182
|
+
facets: {},
|
|
183
|
+
tags: [tag('mediaType', output.mediaType), tag('lineage', 'registered')],
|
|
184
|
+
concepts: [],
|
|
185
|
+
relationships: [{
|
|
186
|
+
type: 'parent-session',
|
|
187
|
+
target_id: session.sessionId,
|
|
188
|
+
target_path: sessionRecordLocator(session),
|
|
189
|
+
direction: 'upstream',
|
|
190
|
+
confidence: 1,
|
|
191
|
+
privacy: 'private',
|
|
192
|
+
}],
|
|
193
|
+
provenance: [{
|
|
194
|
+
field: 'source',
|
|
195
|
+
source: 'aiwg-output-registration',
|
|
196
|
+
path: locator,
|
|
197
|
+
confidence: 'source',
|
|
198
|
+
privacy: 'private',
|
|
199
|
+
}],
|
|
200
|
+
provenance_events: [{
|
|
201
|
+
activity: 'aiwg.session-output',
|
|
202
|
+
agent: 'aiwg-output-registration',
|
|
203
|
+
started_at: now,
|
|
204
|
+
source: 'aiwg-output-registration',
|
|
205
|
+
path: locator,
|
|
206
|
+
confidence: 'source',
|
|
207
|
+
privacy: 'private',
|
|
208
|
+
attributes: {
|
|
209
|
+
sessionId: output.sessionId,
|
|
210
|
+
registrationId: output.registrationId,
|
|
211
|
+
outputLocator: output.outputLocator,
|
|
212
|
+
matchReason: output.matchReason,
|
|
213
|
+
bytesEmbedded: output.bytesEmbedded === true,
|
|
214
|
+
},
|
|
215
|
+
}],
|
|
216
|
+
privacy: { classification: 'private', pii: false },
|
|
217
|
+
updated_at: now,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* A full-v1 archive has no per-archive metadata sidecar -- only the
|
|
222
|
+
* `aiwg_source` embedded on each individual note. Without this record, the
|
|
223
|
+
* index-level wrapper (`source.repo`/`privacy`/`graph`, `generated_at`)
|
|
224
|
+
* would be a real, disclosed recovery gap. Representing it as one more
|
|
225
|
+
* ordinary record closes that gap using the exact mechanism already built
|
|
226
|
+
* for every other field (`provenance_events[0].attributes`), rather than
|
|
227
|
+
* inventing a second, parallel embedding path.
|
|
228
|
+
*
|
|
229
|
+
* The id is prefixed to sort after every `event_`/`output_`/`session_` id
|
|
230
|
+
* (deterministic regardless of content), and the type is namespaced so
|
|
231
|
+
* recovery can find and exclude it from the recovered `items` unambiguously.
|
|
232
|
+
*/
|
|
233
|
+
export const EXPORT_MANIFEST_RECORD_TYPE = 'aiwg.session-catalog-export-manifest';
|
|
234
|
+
export const EXPORT_MANIFEST_RECORD_ID = 'zzz-aiwg-session-catalog-export-manifest';
|
|
235
|
+
function exportManifestRecord(repoRef, generatedAt) {
|
|
236
|
+
const locator = `aiwg-session-catalog://export-manifest/${repoRef}`;
|
|
237
|
+
return {
|
|
238
|
+
schema_version: SESSION_EXPORT_RECORD_SCHEMA_VERSION,
|
|
239
|
+
id: EXPORT_MANIFEST_RECORD_ID,
|
|
240
|
+
type: EXPORT_MANIFEST_RECORD_TYPE,
|
|
241
|
+
source: {
|
|
242
|
+
path: locator,
|
|
243
|
+
repo_relative_path: locator,
|
|
244
|
+
locator,
|
|
245
|
+
origin: 'aiwg-session-catalog',
|
|
246
|
+
generated: true,
|
|
247
|
+
updated_at: generatedAt,
|
|
248
|
+
},
|
|
249
|
+
title: 'AIWG session export manifest',
|
|
250
|
+
text: `Index-level metadata for this session export (${repoRef}). Not a session, event, or output record.`,
|
|
251
|
+
facets: {},
|
|
252
|
+
tags: [tag('graph', INDEX_GRAPH_ID)],
|
|
253
|
+
concepts: [],
|
|
254
|
+
relationships: [],
|
|
255
|
+
provenance: [{
|
|
256
|
+
field: 'source', source: 'aiwg-session-catalog', path: locator, confidence: 'source', privacy: 'private',
|
|
257
|
+
}],
|
|
258
|
+
provenance_events: [{
|
|
259
|
+
activity: EXPORT_MANIFEST_RECORD_TYPE,
|
|
260
|
+
agent: 'aiwg-session-catalog',
|
|
261
|
+
started_at: generatedAt,
|
|
262
|
+
source: 'aiwg-session-catalog',
|
|
263
|
+
path: locator,
|
|
264
|
+
confidence: 'source',
|
|
265
|
+
privacy: 'private',
|
|
266
|
+
attributes: { repo: repoRef, privacy: 'private', graph: INDEX_GRAPH_ID, generatedAt },
|
|
267
|
+
}],
|
|
268
|
+
privacy: { classification: 'private', pii: false },
|
|
269
|
+
updated_at: generatedAt,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Builds the full record graph for a selected set of sessions. `repoRef` is
|
|
274
|
+
* a caller-supplied identity for `AiwgFortemiIndexExport.source.repo` (the
|
|
275
|
+
* session catalog's workspace id, not a git repo -- the field is repurposed
|
|
276
|
+
* honestly rather than left to imply something untrue).
|
|
277
|
+
*/
|
|
278
|
+
export function buildSessionAiwgFortemiIndexExport(repoRef, sessions, eventsBySessionId, outputs = [], catalogTagsBySessionId = new Map()) {
|
|
279
|
+
const generatedAt = new Date().toISOString();
|
|
280
|
+
const items = [exportManifestRecord(repoRef, generatedAt)];
|
|
281
|
+
for (const session of sessions) {
|
|
282
|
+
items.push(sessionToAiwgFortemiRecord(session, catalogTagsBySessionId.get(session.sessionId) ?? []));
|
|
283
|
+
for (const event of eventsBySessionId.get(session.sessionId) ?? []) {
|
|
284
|
+
items.push(sessionEventToAiwgFortemiRecord(session, event));
|
|
285
|
+
}
|
|
286
|
+
for (const output of outputs.filter((entry) => entry.sessionId === session.sessionId)) {
|
|
287
|
+
items.push(sessionOutputToAiwgFortemiRecord(session, output));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Deterministic ordering: reproducible archives, easier diffing.
|
|
291
|
+
items.sort((left, right) => left.id.localeCompare(right.id));
|
|
292
|
+
return {
|
|
293
|
+
schema_version: SESSION_EXPORT_INDEX_SCHEMA_VERSION,
|
|
294
|
+
generated_at: generatedAt,
|
|
295
|
+
source: { repo: repoRef, privacy: 'private', graph: INDEX_GRAPH_ID },
|
|
296
|
+
compatibility: { previous_schema_version: 'aiwg.fortemi.index.export.v1', strategy: 'supported' },
|
|
297
|
+
items,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
//# sourceMappingURL=fortemi-export-mapping.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { computeBlobHash, packTarGz, sha256Hex, unpackTarGz, validateFullV1ShardArchive, } from '@fortemi/core';
|
|
2
|
+
function jsonLines(values) {
|
|
3
|
+
return new TextEncoder().encode(`${values.map((value) => JSON.stringify(value)).join('\n')}\n`);
|
|
4
|
+
}
|
|
5
|
+
export async function embedAttachmentsInFullV1Shard(archive, attachments) {
|
|
6
|
+
if (attachments.length === 0)
|
|
7
|
+
return archive;
|
|
8
|
+
const files = unpackTarGz(archive);
|
|
9
|
+
const notesBytes = files.get('notes.jsonl');
|
|
10
|
+
const manifestBytes = files.get('manifest.json');
|
|
11
|
+
if (!notesBytes || !manifestBytes)
|
|
12
|
+
throw new Error('full-v1 shard is missing notes.jsonl or manifest.json');
|
|
13
|
+
const notes = new TextDecoder().decode(notesBytes).split('\n').filter(Boolean)
|
|
14
|
+
.map((line) => JSON.parse(line));
|
|
15
|
+
const byRecordId = new Map(notes.map((note) => [note.metadata?.aiwg_source?.record_id, note]));
|
|
16
|
+
for (const entry of attachments) {
|
|
17
|
+
const note = byRecordId.get(entry.recordId);
|
|
18
|
+
if (!note)
|
|
19
|
+
throw new Error(`cannot attach bytes: shard record is missing: ${entry.recordId}`);
|
|
20
|
+
const checksum = computeBlobHash(entry.bytes);
|
|
21
|
+
note.attachments ??= [];
|
|
22
|
+
note.attachments.push({
|
|
23
|
+
extracted_text: null,
|
|
24
|
+
extraction_status: 'deferred',
|
|
25
|
+
reason: 'no_extracted_text',
|
|
26
|
+
attachment: {
|
|
27
|
+
id: entry.attachmentId,
|
|
28
|
+
path: entry.filename,
|
|
29
|
+
mime: entry.mimeType,
|
|
30
|
+
checksum,
|
|
31
|
+
bytes: entry.bytes.length,
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
files.set(`blobs/${checksum.slice('blake3:'.length)}`, entry.bytes);
|
|
35
|
+
}
|
|
36
|
+
const updatedNotes = jsonLines(notes);
|
|
37
|
+
files.set('notes.jsonl', updatedNotes);
|
|
38
|
+
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes));
|
|
39
|
+
manifest.checksums['notes.jsonl'] = await sha256Hex(updatedNotes);
|
|
40
|
+
files.set('manifest.json', new TextEncoder().encode(JSON.stringify(manifest, null, 2)));
|
|
41
|
+
const packed = packTarGz(files);
|
|
42
|
+
const validation = await validateFullV1ShardArchive(packed);
|
|
43
|
+
if (!validation.valid)
|
|
44
|
+
throw new Error(`embedded shard failed validation: ${validation.errors.join('; ')}`);
|
|
45
|
+
return packed;
|
|
46
|
+
}
|
|
47
|
+
export function recoverEmbeddedAttachments(archive) {
|
|
48
|
+
const files = unpackTarGz(archive);
|
|
49
|
+
const notesBytes = files.get('notes.jsonl');
|
|
50
|
+
if (!notesBytes)
|
|
51
|
+
return [];
|
|
52
|
+
const notes = new TextDecoder().decode(notesBytes).split('\n').filter(Boolean)
|
|
53
|
+
.map((line) => JSON.parse(line));
|
|
54
|
+
const recovered = [];
|
|
55
|
+
for (const note of notes) {
|
|
56
|
+
const recordId = note.metadata?.aiwg_source?.record_id;
|
|
57
|
+
if (!recordId)
|
|
58
|
+
continue;
|
|
59
|
+
for (const projection of note.attachments ?? []) {
|
|
60
|
+
const ref = projection.attachment;
|
|
61
|
+
const bytes = files.get(`blobs/${ref.checksum.replace(/^blake3:/, '')}`);
|
|
62
|
+
if (!bytes)
|
|
63
|
+
continue;
|
|
64
|
+
if (bytes.length !== ref.bytes || computeBlobHash(bytes) !== ref.checksum) {
|
|
65
|
+
throw new Error(`embedded attachment failed size/hash verification: ${ref.id}`);
|
|
66
|
+
}
|
|
67
|
+
recovered.push({
|
|
68
|
+
recordId,
|
|
69
|
+
attachmentId: ref.id,
|
|
70
|
+
filename: ref.path,
|
|
71
|
+
mimeType: ref.mime,
|
|
72
|
+
bytes,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return recovered;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=fortemi-shard-blobs.js.map
|