@nexus-cortex/core 4.54.0 → 4.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,719 +0,0 @@
1
- /**
2
- * canonTranslate — the canon gateway leg, graduated from
3
- * `scripts/canon/canon-translate.ts` (Phase C part 2; the script is now a thin
4
- * wrapper over this module, so the cron and the CLI run ONE implementation).
5
- *
6
- * Reads native session files in the canon store and maintains the canonical
7
- * line: /native/claude-code/** → /canon/claude-code/**, /native/nexus-cortex/**
8
- * → /canon/nexus-cortex/** (≈ identity), plus /projections refs and the
9
- * self-documenting MAPPING/TRANSLATED/PROJECTIONS docs.
10
- *
11
- * The transform bodies are the script's PROVEN logic, transplanted verbatim
12
- * (byte-identical output is the graduation's regression gate). The canonical
13
- * record schema authority is `@nexus-cortex/types` / MessageTypes.ts — the
14
- * transforms operate structurally on raw JSONL records BY DESIGN (canon =
15
- * verbatim superset; `message` bodies pass through untouched), so they neither
16
- * construct nor need typed Message values.
17
- *
18
- * @module canon/canonTranslate
19
- */
20
- import * as fs from 'node:fs';
21
- import * as path from 'node:path';
22
- import * as readline from 'node:readline';
23
- import { execFileSync } from 'node:child_process';
24
- export async function canonTranslate(o = {}) {
25
- const HOME = o.home ?? process.env.HOME ?? '/home/runner/workspace';
26
- const DRY = o.dryRun ?? false;
27
- const STORE = o.store ?? '/tmp/canon-store';
28
- const MANIFEST_PATH = path.join(HOME, '.canon', 'translate-manifest.json');
29
- const MAX_BYTES = 50 * 1024 * 1024;
30
- const PART_BYTES = 25 * 1024 * 1024;
31
- const SCRIPT_VERSION = 'a3.13'; // bump to force full re-translate
32
- const MESSAGE_TYPES = new Set(['user', 'assistant', 'system', 'file-history-snapshot']);
33
- const manifest = fs.existsSync(MANIFEST_PATH)
34
- ? JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'))
35
- : {};
36
- const errors = [];
37
- const stats = {};
38
- const eventTypeCounts = {};
39
- let unchanged = 0;
40
- let dupResults = 0;
41
- let orphanRepairs = 0;
42
- let blobs = new Map(); // set in main, before discovery
43
- /** path (store-relative) → git blob SHA. Stable across clones/mtimes; the
44
- * Merkle anchor for both provenance and the incremental manifest. */
45
- function blobMap() {
46
- const out = new Map();
47
- const ls = execFileSync('git', ['ls-files', '-s', '--', 'native'], { cwd: STORE, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
48
- for (const line of ls.split('\n')) {
49
- const m = line.match(/^\d+ ([0-9a-f]{40}) \d\t(.+)$/);
50
- if (m)
51
- out.set(m[2], m[1].slice(0, 12));
52
- }
53
- return out;
54
- }
55
- function discover(rootAbs, relPrefix) {
56
- const groups = new Map();
57
- const walk = (dir) => {
58
- let entries = [];
59
- try {
60
- entries = fs.readdirSync(dir, { withFileTypes: true });
61
- }
62
- catch {
63
- return;
64
- }
65
- for (const e of entries) {
66
- const p = path.join(dir, e.name);
67
- if (e.isDirectory()) {
68
- walk(p);
69
- continue;
70
- }
71
- if (!e.isFile())
72
- continue;
73
- const m = e.name.match(/^(.*\.jsonl)\.part-\d{4}$/);
74
- const logical = m ? path.join(dir, m[1]) : p;
75
- if (!logical.endsWith('.jsonl'))
76
- continue;
77
- const g = groups.get(logical) ?? [];
78
- g.push(p);
79
- groups.set(logical, g);
80
- }
81
- };
82
- walk(rootAbs);
83
- const out = [];
84
- for (const [logical, parts] of groups) {
85
- parts.sort();
86
- // Signature = git blob SHAs (content-stable across clones; mtime:size only
87
- // as a fallback for not-yet-committed files, which then retry next run).
88
- const sig = SCRIPT_VERSION + '|' + parts
89
- .map((p) => blobs.get(path.relative(STORE, p))
90
- ?? (() => { const st = fs.statSync(p); return `${st.mtimeMs}:${st.size}`; })())
91
- .join('|');
92
- out.push({ rel: path.join(relPrefix, path.relative(rootAbs, logical)), parts, sig });
93
- }
94
- return out.sort((a, b) => a.rel.localeCompare(b.rel));
95
- }
96
- /** yields [line, blobShaOfThePartContainingIt] */
97
- async function* logicalLines(parts) {
98
- for (const p of parts) {
99
- const blob = blobs.get(path.relative(STORE, p)) ?? 'untracked';
100
- const rl = readline.createInterface({ input: fs.createReadStream(p), crlfDelay: Infinity });
101
- for await (const line of rl)
102
- if (line.trim())
103
- yield [line, blob];
104
- }
105
- }
106
- // ── chunk-aware output: single file ≤50MB, else 25MB .part-NNNN files ──────
107
- function finalizeOutput(destAbs, tmpAbs) {
108
- const size = fs.statSync(tmpAbs).size;
109
- const staleParts = () => {
110
- const dir = path.dirname(destAbs);
111
- const base = path.basename(destAbs);
112
- let names = [];
113
- try {
114
- names = fs.readdirSync(dir);
115
- }
116
- catch {
117
- return [];
118
- }
119
- return names.filter((n) => n.startsWith(base + '.part-')).map((n) => path.join(dir, n));
120
- };
121
- if (size <= MAX_BYTES) {
122
- for (const p of staleParts())
123
- fs.unlinkSync(p);
124
- fs.renameSync(tmpAbs, destAbs);
125
- return;
126
- }
127
- // split at line boundaries into parts; remove single-file form + extra parts
128
- const content = fs.readFileSync(tmpAbs, 'utf8');
129
- let offset = 0, part = 0;
130
- while (offset < content.length) {
131
- let end = Math.min(offset + PART_BYTES, content.length);
132
- if (end < content.length) {
133
- const nl = content.lastIndexOf('\n', end);
134
- if (nl > offset)
135
- end = nl + 1;
136
- }
137
- fs.writeFileSync(`${destAbs}.part-${String(part).padStart(4, '0')}`, content.slice(offset, end));
138
- offset = end;
139
- part++;
140
- }
141
- for (const p of staleParts()) {
142
- const idx = Number(p.slice(-4));
143
- if (idx >= part)
144
- fs.unlinkSync(p);
145
- }
146
- try {
147
- fs.unlinkSync(destAbs);
148
- }
149
- catch { /* none */ }
150
- fs.unlinkSync(tmpAbs);
151
- }
152
- function writeIfChanged(abs, content) {
153
- if (fs.existsSync(abs) && fs.readFileSync(abs, 'utf8') === content)
154
- return;
155
- fs.mkdirSync(path.dirname(abs), { recursive: true });
156
- fs.writeFileSync(abs, content);
157
- }
158
- // ── claude-code fragment merging ───────────────────────────────────────────
159
- // Claude Code's writer emits ONE RECORD PER CONTENT BLOCK: a response with
160
- // parallel tool calls becomes N assistant records (same requestId/message.id),
161
- // and their results N separate user records. Canon's contract is one canonical
162
- // message per logical API message — strict providers (ChatCompletions) reject
163
- // assistant→assistant→tool sequences, and orphan-repair heuristics misfire on
164
- // fragments. Reconstruct true boundaries here so EVERY consumer is healed.
165
- // parentUuid links to merged-away fragments are remapped (children always
166
- // follow parents in an append-only log, so a forward-pass map suffices).
167
- // Native granularity is fully retained in /native; merged canon records carry
168
- // mergedFrom (absorbed uuids) for traceability.
169
- function asBlocks(content) {
170
- if (Array.isArray(content))
171
- return content;
172
- if (typeof content === 'string')
173
- return [{ type: 'text', text: content }];
174
- return [];
175
- }
176
- function isToolResultOnly(rec) {
177
- const c = rec?.message?.content;
178
- return Array.isArray(c) && c.length > 0 && c.every((b) => b?.type === 'tool_result');
179
- }
180
- function asstGroupKey(rec) {
181
- return rec.requestId ?? rec.message?.id ?? rec.uuid;
182
- }
183
- function mergeFragments(frags) {
184
- if (frags.length === 1)
185
- return frags[0];
186
- const merged = { ...frags[0] };
187
- merged.message = { ...frags[0].message };
188
- merged.message.content = frags.flatMap((f) => asBlocks(f.message?.content));
189
- // usage/model: the last fragment carrying them describes the full response
190
- for (const f of frags) {
191
- if (f.message?.usage)
192
- merged.message.usage = f.message.usage;
193
- if (f.message?.model)
194
- merged.message.model = f.message.model;
195
- }
196
- merged.mergedFrom = frags.slice(1).map((f) => f.uuid).filter(Boolean);
197
- return merged;
198
- }
199
- function toCanonClaude(rec, ctx) {
200
- const t = rec.type;
201
- if (!MESSAGE_TYPES.has(t))
202
- return { event: rec };
203
- if (t === 'user') {
204
- const c = rec.message?.content;
205
- const isToolResult = Array.isArray(c) && c.some((b) => b?.type === 'tool_result');
206
- if (!isToolResult)
207
- ctx.turn++;
208
- }
209
- const canon = { ...rec };
210
- if (t === 'file-history-snapshot') {
211
- canon.uuid = canon.uuid ?? `fhs-${rec.messageId}`;
212
- canon.timestamp = canon.timestamp ?? rec.snapshot?.timestamp;
213
- }
214
- // Canon SystemMessage.content is a required string; Claude Code emits
215
- // metadata-only system events (turn_duration, stop_hook_summary) without one.
216
- if (t === 'system' && canon.content === undefined)
217
- canon.content = '';
218
- const sid = rec.sessionId ?? ctx.sessionId;
219
- canon.timeline = {
220
- sessionId: sid,
221
- conversationId: sid, // claude-code has no sub-session conversations
222
- turnNumber: Math.max(0, ctx.turn),
223
- };
224
- if (t === 'assistant' && rec.message?.model) {
225
- canon.model = { id: rec.message.model, provider: 'anthropic', apiPattern: 'messages' };
226
- }
227
- const u = t === 'assistant' ? rec.message?.usage : undefined;
228
- if (u) {
229
- canon.usage = {
230
- inputTokens: u.input_tokens ?? 0,
231
- outputTokens: u.output_tokens ?? 0,
232
- cache: {
233
- cacheCreationTokens: u.cache_creation_input_tokens,
234
- cacheReadTokens: u.cache_read_input_tokens,
235
- },
236
- };
237
- }
238
- canon.provenance = { harness: 'claude-code', native: ctx.nativeRel, line: ctx.line, ref: ctx.ref };
239
- return { canon };
240
- }
241
- // ── translate one logical native file ──────────────────────────────────────
242
- async function translateFile(lf, harness) {
243
- if (manifest[lf.rel] === lf.sig) {
244
- unchanged++;
245
- return;
246
- }
247
- const st = (stats[harness] ??= { files: 0, messages: 0, events: 0 });
248
- const destRel = path.join('canon', lf.rel);
249
- const destAbs = path.join(STORE, destRel);
250
- const eventsAbs = destAbs.replace(/\.jsonl$/, '.events.jsonl');
251
- const ctx = {
252
- sessionId: path.basename(lf.rel, '.jsonl'),
253
- nativeRel: path.join('native', lf.rel),
254
- ref: '', line: 0, turn: -1,
255
- };
256
- let msgCount = 0;
257
- const events = [];
258
- const fileErrors = [];
259
- if (DRY) {
260
- st.files++;
261
- return;
262
- }
263
- fs.mkdirSync(path.dirname(destAbs), { recursive: true });
264
- const tmpAbs = destAbs + '.tmp';
265
- const out = fs.createWriteStream(tmpAbs);
266
- const remap = new Map();
267
- // Sidecarred events (attachments, snapshots) can sit INSIDE the parentUuid
268
- // chain. Claude Code's renderer walks that chain and silently EXCLUDES
269
- // everything upstream of a broken link (measured: a 555KB skill doc vanished
270
- // from rendered context). Re-parent chain children through sidecarred
271
- // events to the nearest surviving message ancestor.
272
- const eventParent = new Map();
273
- const emittedUuids = new Set();
274
- let lastEmittedUuid = null;
275
- const resolveParent = (p) => {
276
- const seen = new Set();
277
- while (typeof p === 'string' && !seen.has(p)) {
278
- seen.add(p);
279
- if (remap.has(p)) {
280
- p = remap.get(p);
281
- continue;
282
- }
283
- if (eventParent.has(p)) {
284
- p = eventParent.get(p);
285
- continue;
286
- }
287
- break;
288
- }
289
- return p ?? null;
290
- };
291
- const usesSeen = new Set();
292
- const cortexUses = new Set();
293
- const cortexAnswered = new Set();
294
- let cortexOpen = []; // pending calls from the last assistant
295
- // claude-code abandoned-call repair: tool_use ids emitted by flushAsst but
296
- // not yet answered by a flushRes. When a non-result MESSAGE record interposes
297
- // (a user interrupted a long tool call mid-conversation), the call is
298
- // abandoned — synthesize an error-marked result so the A4 pairing lint and
299
- // strict providers accept the history. Mirror of the nexus-cortex path's
300
- // repair; only true tail calls (EOF) stay open ("next sync completes them").
301
- // Fixes the A4 red streak: the claude-code path had orphan-RESULT repair but
302
- // no abandoned-CALL repair — built on the wrong assumption that Claude Code
303
- // always writes "[interrupted]" results (this session is the counterexample).
304
- let ccOpen = [];
305
- // last-seen valid native timestamp — fallback for synthetic repair records
306
- // whose stranding/current source record carries none (e.g. Claude Code's
307
- // file-history-snapshot records: {type,messageId,snapshot,isSnapshotUpdate},
308
- // no timestamp field). Without this, drainAbandoned wrote timestamp:undefined
309
- // → JSON.stringify dropped the key → verify's 'missing timestamp' failure.
310
- let lastTs = undefined;
311
- let pendAsst = [];
312
- let pendRes = [];
313
- const emitCanon = (rec, line, ref) => {
314
- if (rec.parentUuid)
315
- rec.parentUuid = resolveParent(rec.parentUuid);
316
- // Chain integrity is an ABSOLUTE invariant of the canonical line (lint-
317
- // enforced): a parent that still doesn't resolve (dropped upstream for any
318
- // reason) re-parents to the previous emitted record.
319
- if (typeof rec.parentUuid === 'string' && !emittedUuids.has(rec.parentUuid)) {
320
- rec.parentUuid = lastEmittedUuid;
321
- }
322
- const bs = Array.isArray(rec.message?.content) ? rec.message.content : [];
323
- for (const b of bs)
324
- if (b?.type === 'tool_use' && b.id)
325
- usesSeen.add(b.id);
326
- ctx.line = line;
327
- ctx.ref = ref;
328
- const { canon } = toCanonClaude(rec, ctx);
329
- if (canon.uuid) {
330
- emittedUuids.add(canon.uuid);
331
- lastEmittedUuid = canon.uuid;
332
- }
333
- out.write(JSON.stringify(canon) + '\n');
334
- msgCount++;
335
- };
336
- const flushAsst = () => {
337
- if (!pendAsst.length)
338
- return;
339
- const first = pendAsst[0];
340
- const merged = mergeFragments(pendAsst.map((g) => g.rec));
341
- for (const g of pendAsst.slice(1))
342
- if (g.rec.uuid)
343
- remap.set(g.rec.uuid, first.rec.uuid);
344
- emitCanon(merged, first.line, first.ref);
345
- for (const b of asBlocks(merged.message?.content))
346
- if (b?.type === 'tool_use' && b.id)
347
- ccOpen.push({ id: b.id, name: b.name });
348
- pendAsst = [];
349
- };
350
- // Emit synthetic error results for any calls abandoned mid-conversation
351
- // (called only when a non-result record definitively strands them). True
352
- // tail calls are never drained — the loop's final flush leaves ccOpen intact.
353
- const drainAbandoned = (ts, line, blob) => {
354
- if (!ccOpen.length)
355
- return;
356
- orphanRepairs += ccOpen.length;
357
- out.write(JSON.stringify({
358
- uuid: `synth-result-${ccOpen[0].id}`,
359
- timestamp: ts,
360
- type: 'user',
361
- synthetic: 'canon-abandoned-call-repair',
362
- message: {
363
- role: 'user',
364
- content: ccOpen.map((o) => ({
365
- type: 'tool_result', tool_use_id: o.id, is_error: true,
366
- content: '[canon repair: no result was recorded for this call — the harness turn was interrupted]',
367
- })),
368
- },
369
- provenance: { harness, native: ctx.nativeRel, line, ref: blob },
370
- }) + '\n');
371
- msgCount++;
372
- ccOpen = [];
373
- };
374
- // Orphan-result repair happens at result-group flush, preserving STRICT pair
375
- // interleaving (assistant-uses → its results ; synth-use → orphan results).
376
- // Observed source: user-backgrounded tools — Claude Code skips the assistant
377
- // tool_use record on mid-turn interruption but still writes the result,
378
- // sometimes interleaved INSIDE another pair. Emitting the synthetic use
379
- // adjacent to the orphan result (and only after the real pair closes) keeps
380
- // the canonical line valid for every consumer. Explicit, never silent.
381
- const flushRes = () => {
382
- if (!pendRes.length)
383
- return;
384
- const first = pendRes[0];
385
- const merged = mergeFragments(pendRes.map((g) => g.rec));
386
- for (const g of pendRes.slice(1))
387
- if (g.rec.uuid)
388
- remap.set(g.rec.uuid, first.rec.uuid);
389
- const bs = asBlocks(merged.message?.content);
390
- for (const b of bs)
391
- if (b?.type === 'tool_result' && b.tool_use_id)
392
- ccOpen = ccOpen.filter((o) => o.id !== b.tool_use_id);
393
- const orphans = bs.filter((b) => b?.type === 'tool_result' && b.tool_use_id && !usesSeen.has(b.tool_use_id));
394
- if (!orphans.length) {
395
- emitCanon(merged, first.line, first.ref);
396
- }
397
- else {
398
- const paired = bs.filter((b) => !orphans.includes(b));
399
- if (paired.length) {
400
- emitCanon({ ...merged, message: { ...merged.message, content: paired } }, first.line, first.ref);
401
- }
402
- emitCanon({
403
- uuid: `synth-use-${orphans[0].tool_use_id}`,
404
- timestamp: merged.timestamp,
405
- type: 'assistant',
406
- synthetic: 'canon-orphan-result-repair',
407
- sessionId: merged.sessionId,
408
- message: {
409
- role: 'assistant',
410
- content: orphans.map((b) => ({
411
- type: 'tool_use', id: b.tool_use_id, name: b.tool_name ?? 'unknown_tool', input: {},
412
- })),
413
- },
414
- }, first.line, first.ref);
415
- emitCanon({
416
- ...merged,
417
- uuid: paired.length ? `${merged.uuid}-orphan-results` : merged.uuid,
418
- message: { ...merged.message, content: orphans },
419
- }, first.line, first.ref);
420
- }
421
- pendRes = [];
422
- };
423
- let lineNo = 0;
424
- for await (const [line, blob] of logicalLines(lf.parts)) {
425
- lineNo++;
426
- let rec;
427
- try {
428
- rec = JSON.parse(line);
429
- }
430
- catch (e) {
431
- fileErrors.push(`${ctx.nativeRel}:${lineNo} — unparseable JSON: ${String(e).slice(0, 120)}`);
432
- continue;
433
- }
434
- if (rec && rec.timestamp)
435
- lastTs = rec.timestamp; // track for synthetic-record fallback
436
- if (harness === 'nexus-cortex') {
437
- // Identity — EXCEPT structural repairs the A4 pairing lint demands:
438
- // (1) wrapped-block normalization: older JSONLHistoryStore versions
439
- // wrote {type:'tool_use', toolUse:{id,name,input,metadata}} instead
440
- // of the flat Anthropic wire shape the canon spec declares;
441
- // (2) duplicate tool_results dropped (legacy retry-loop artifact wrote
442
- // the same results dozens of times — providers reject duplicate
443
- // responses; count is reported, native keeps them verbatim);
444
- // (3) orphan tool_results get a marked synthetic tool_use, same repair
445
- // contract as the claude-code path.
446
- if (Array.isArray(rec.message?.content)) {
447
- rec.message.content = rec.message.content.map((b) => b?.type === 'tool_use' && b.toolUse
448
- ? { type: 'tool_use', id: b.toolUse.id, name: b.toolUse.name, input: b.toolUse.input, ...(b.toolUse.metadata ? { metadata: b.toolUse.metadata } : {}) }
449
- : b);
450
- const kept = [];
451
- const orphanIds = [];
452
- for (const b of rec.message.content) {
453
- if (b?.type === 'tool_use' && b.id) {
454
- cortexUses.add(b.id);
455
- kept.push(b);
456
- continue;
457
- }
458
- if (b?.type === 'tool_result' && b.tool_use_id) {
459
- if (cortexAnswered.has(b.tool_use_id)) {
460
- dupResults++;
461
- continue;
462
- }
463
- if (!cortexUses.has(b.tool_use_id))
464
- orphanIds.push({ id: b.tool_use_id, name: b.tool_name });
465
- cortexAnswered.add(b.tool_use_id);
466
- }
467
- kept.push(b);
468
- }
469
- if (!kept.length)
470
- continue; // record was entirely duplicate results
471
- rec.message.content = kept;
472
- // Abandoned-call repair (mirror of orphan-result): a pending call
473
- // followed by a non-result record never got its result recorded
474
- // (crashed/killed turn — Claude Code writes "[interrupted]" results
475
- // itself; older cortex server sessions did not). Synthesize an
476
- // error-marked result so strict providers accept the history. Tail
477
- // in-flight calls (EOF) stay untouched — next sync completes them.
478
- const hasResult = kept.some((b) => b?.type === 'tool_result');
479
- if (!hasResult && cortexOpen.length) {
480
- orphanRepairs += cortexOpen.length;
481
- out.write(JSON.stringify({
482
- uuid: `synth-result-${cortexOpen[0].id}`,
483
- timestamp: rec.timestamp ?? lastTs,
484
- type: 'user',
485
- synthetic: 'canon-abandoned-call-repair',
486
- message: {
487
- role: 'user',
488
- content: cortexOpen.map((o) => ({
489
- type: 'tool_result', tool_use_id: o.id, is_error: true,
490
- content: '[canon repair: no result was recorded for this call — the harness turn was interrupted]',
491
- })),
492
- },
493
- provenance: { harness, native: ctx.nativeRel, line: lineNo, ref: blob },
494
- }) + '\n');
495
- msgCount++;
496
- for (const o of cortexOpen)
497
- cortexAnswered.add(o.id);
498
- cortexOpen = [];
499
- }
500
- for (const b of kept) {
501
- if (b?.type === 'tool_use' && b.id)
502
- cortexOpen.push({ id: b.id, name: b.name });
503
- if (b?.type === 'tool_result' && b.tool_use_id)
504
- cortexOpen = cortexOpen.filter((o) => o.id !== b.tool_use_id);
505
- }
506
- if (orphanIds.length) {
507
- orphanRepairs += orphanIds.length;
508
- for (const o of orphanIds)
509
- cortexUses.add(o.id);
510
- out.write(JSON.stringify({
511
- uuid: `synth-use-${orphanIds[0].id}`,
512
- timestamp: rec.timestamp ?? lastTs,
513
- type: 'assistant',
514
- synthetic: 'canon-orphan-result-repair',
515
- message: { role: 'assistant', content: orphanIds.map((o) => ({ type: 'tool_use', id: o.id, name: o.name ?? 'unknown_tool', input: {} })) },
516
- provenance: { harness, native: ctx.nativeRel, line: lineNo, ref: blob },
517
- }) + '\n');
518
- msgCount++;
519
- }
520
- }
521
- // same chain-integrity guarantee on the identity path (second-generation
522
- // files — pulled sessions re-synced as cortex natives — carry parents
523
- // that were sidecarred in their first translation)
524
- if (typeof rec.parentUuid === 'string' && !emittedUuids.has(rec.parentUuid)) {
525
- rec.parentUuid = lastEmittedUuid;
526
- }
527
- if (rec.uuid) {
528
- emittedUuids.add(rec.uuid);
529
- lastEmittedUuid = rec.uuid;
530
- }
531
- rec.provenance = { harness, native: ctx.nativeRel, line: lineNo, ref: blob };
532
- out.write(JSON.stringify(rec) + '\n');
533
- msgCount++;
534
- continue;
535
- }
536
- if (!MESSAGE_TYPES.has(rec.type)) {
537
- // sidecar events never break a fragment run (attachments etc. interleave)
538
- if (rec.uuid)
539
- eventParent.set(rec.uuid, rec.parentUuid ?? null);
540
- events.push(line);
541
- eventTypeCounts[rec.type ?? '?'] = (eventTypeCounts[rec.type ?? '?'] ?? 0) + 1;
542
- continue;
543
- }
544
- if (rec.type === 'assistant') {
545
- flushRes();
546
- if (pendAsst.length && asstGroupKey(pendAsst[0].rec) !== asstGroupKey(rec))
547
- flushAsst();
548
- pendAsst.push({ rec, line: lineNo, ref: blob });
549
- }
550
- else if (rec.type === 'user' && isToolResultOnly(rec)) {
551
- flushAsst();
552
- pendRes.push({ rec, line: lineNo, ref: blob });
553
- }
554
- else {
555
- flushAsst();
556
- flushRes();
557
- // A non-result message record interposes — any still-open call from the
558
- // just-flushed assistant was abandoned mid-conversation. Repair before
559
- // emitting this record so the tool_use/tool_result pair stays adjacent.
560
- drainAbandoned(rec.timestamp ?? lastTs, lineNo, blob);
561
- emitCanon(rec, lineNo, blob);
562
- }
563
- }
564
- // EOF flush: tail in-flight calls are deliberately left in ccOpen (undrained)
565
- // — verify's tail window allows them; the next sync completes the pair.
566
- flushAsst();
567
- flushRes();
568
- await new Promise((res, rej) => out.end((e) => (e ? rej(e) : res())));
569
- if (msgCount === 0 && events.length === 0 && fileErrors.length === 0) {
570
- // nothing translatable (e.g. empty file) — visible, not silent
571
- fs.unlinkSync(tmpAbs);
572
- errors.push(`${ctx.nativeRel} — empty logical file (0 records)`);
573
- manifest[lf.rel] = lf.sig;
574
- return;
575
- }
576
- finalizeOutput(destAbs, tmpAbs);
577
- if (events.length)
578
- fs.writeFileSync(eventsAbs, events.join('\n') + '\n');
579
- else if (fs.existsSync(eventsAbs))
580
- fs.unlinkSync(eventsAbs);
581
- // projection: canon IS the nexus-cortex dialect — materialize by reference
582
- writeIfChanged(path.join(STORE, 'projections', 'nexus-cortex', lf.rel + '.ref'), destRel + '\n');
583
- errors.push(...fileErrors);
584
- if (!fileErrors.length)
585
- manifest[lf.rel] = lf.sig; // failed files retry next run
586
- st.files++;
587
- st.messages += msgCount;
588
- st.events += events.length;
589
- }
590
- // ── docs the store carries about its own translation ───────────────────────
591
- const MAPPING_MD = `# /canon — the canonical line
592
-
593
- One canonical Message per line (schema: nexus-cortex \`packages/core/src/session/MessageTypes.ts\`,
594
- spec: \`docs/CANON.md\`). Derived from /native by \`canon-translate\`; re-derivable at any time.
595
-
596
- ## claude-code → canon
597
- - \`user\` / \`assistant\` / \`system\` / \`file-history-snapshot\` → canon Message.
598
- \`message\` bodies are VERBATIM (canonical ContentBlock is the Anthropic wire shape).
599
- Added: \`timeline\` {sessionId, conversationId=sessionId, turnNumber}, top-level
600
- \`model\` {id: message.model, provider: anthropic, apiPattern: messages}, top-level
601
- \`usage\` (camelCase view of message.usage), \`provenance\` {harness, native, line, ref}.
602
- All native fields retained — canon is a superset; nothing is lost at write time.
603
- - turnNumber semantics: increments on each user PROMPT (user message with no
604
- tool_result block); assistant/tool/system records share the current turn.
605
- - FRAGMENT MERGING: Claude Code's writer emits one record per content block
606
- (parallel tool calls = N assistant records sharing a requestId; results = N
607
- user records). Canon reconstructs the TRUE message boundary: consecutive
608
- same-requestId assistant fragments merge into one canonical assistant message
609
- (content blocks concatenated; \`mergedFrom\` lists absorbed uuids; parentUuid
610
- links into absorbed fragments are remapped), and consecutive tool-result-only
611
- user records merge likewise. Native fragment granularity remains in /native.
612
- - ORPHAN-RESULT REPAIR: a tool_result whose tool_use record was never written
613
- (user-backgrounded tools interrupted mid-turn) gets a SYNTHETIC assistant
614
- tool_use emitted before it, marked \`synthetic: canon-orphan-result-repair\` —
615
- the canonical line is structurally valid for every consumer, and the repair
616
- is explicit, never silent. Tail-side DANGLING tool_use (live session snapshotted
617
- mid-call) is left as-is: the receiving harness's crash-repair handles it and
618
- the next sync completes the pair append-only.
619
- - Every other record type (mode, permission-mode, last-prompt, ai-title, attachment,
620
- queue-operation, file-history-delta, started, result, unknown) → the
621
- \`<session>.events.jsonl\` sidecar, verbatim. Carried, never dropped.
622
- - \`file-history-snapshot\` lacks uuid/timestamp natively → uuid=\`fhs-<messageId>\`,
623
- timestamp from snapshot.timestamp.
624
-
625
- ## nexus-cortex → canon
626
- Identity + \`provenance\` stamp (the harness's native format IS canon).
627
-
628
- ## Provenance
629
- \`provenance.ref\` = git BLOB SHA (12 hex) of the native file (or .part chunk)
630
- containing the source line — content-stable, so re-translating unchanged natives
631
- yields byte-identical canon. Each record is Merkle-anchored to its verbatim
632
- native source through git's object model.
633
-
634
- ## Not yet translated
635
- grok-build and gemini-cli natives are stored but have no adapter yet — see
636
- TRANSLATED.md. Adapters live in the nexus-cortex library and arrive with Phase C.
637
- `;
638
- const PROJECTIONS_MD = `# /projections — canon fanned back out into each harness dialect
639
-
640
- ## nexus-cortex/
641
- Canon IS the nexus-cortex dialect, so these projections are materialized BY
642
- REFERENCE: each \`<path>.ref\` file contains the store-relative path of the canon
643
- file to load. Resolution: read the ref, open that path (a \`.jsonl\`, or its
644
- \`.part-NNNN\` chunks concatenated in order). To resume a session in the
645
- nexus-cortex TUI, copy/cat the resolved file into \`.cortex/sessions/\`.
646
-
647
- Byte-duplicating identity projections would double the repo for zero information;
648
- refs keep the "repo speaks every dialect" contract explicit and cheap.
649
-
650
- ## Other dialects
651
- Divergent projections (claude-code, gemini, grok renderings of canon) are produced
652
- by the library's gateway adapters and arrive with Phase C (\`cortex canon translate\`).
653
- Until then their absence is stated here rather than implied.
654
- `;
655
- const CANON_REPO = o.repoUrl ?? process.env.CANON_REPO ?? 'https://github.com/Spitfire-Products/nexus-canon-store';
656
- if (!fs.existsSync(path.join(STORE, '.git'))) {
657
- // Working clone is disposable (quota lesson 2026-07-27: keep it OFF the
658
- // workspace quota — pass --store /tmp/canon-store); remote is the truth.
659
- console.log(`[canon-translate] no store at ${STORE} — cloning ${CANON_REPO}`);
660
- execFileSync('git', ['clone', '-q', CANON_REPO, STORE], {
661
- encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
662
- });
663
- }
664
- const git = (a) => execFileSync('git', a, { cwd: STORE, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
665
- blobs = blobMap();
666
- const claudeFiles = discover(path.join(STORE, 'native', 'claude-code'), 'claude-code');
667
- const cortexFiles = discover(path.join(STORE, 'native', 'nexus-cortex'), 'nexus-cortex');
668
- for (const lf of claudeFiles)
669
- await translateFile(lf, 'claude-code');
670
- for (const lf of cortexFiles)
671
- await translateFile(lf, 'nexus-cortex');
672
- const translated = Object.values(stats).reduce((n, s) => n + s.files, 0);
673
- const repairNote = dupResults + orphanRepairs > 0 ? `, ${orphanRepairs} orphan-repair(s), ${dupResults} dup result(s) dropped` : '';
674
- const summary = `${translated} translated, ${unchanged} unchanged, ${errors.length} error(s)${repairNote}`;
675
- if (DRY) {
676
- console.log(`[canon-translate DRY] would translate ${translated} logical file(s), ${unchanged} unchanged`);
677
- return { translated, unchanged, errors, summary, pushed: false };
678
- }
679
- if (translated || errors.length) {
680
- const statLines = Object.entries(stats)
681
- .map(([h, s]) => `| ${h} | ${s.files} | ${s.messages} | ${s.events} |`)
682
- .join('\n');
683
- const eventLines = Object.entries(eventTypeCounts).sort()
684
- .map(([t, n]) => `- \`${t}\`: ${n}`).join('\n');
685
- writeIfChanged(path.join(STORE, 'canon', 'TRANSLATED.md'), `# Translation census (regenerated by canon-translate; counts are per-run deltas)\n\n` +
686
- `| harness | files (this run) | messages | events |\n|---|---|---|---|\n${statLines || '| — | 0 | 0 | 0 |'}\n\n` +
687
- `Sidecar event records this run:\n${eventLines || '- none'}\n\n` +
688
- `## Native-only (no adapter yet — visible, not silent)\n- grok-build\n- gemini-cli\n` +
689
- `- nexus-cortex \`*.json\` metadata files (session metadata stays native)\n`);
690
- writeIfChanged(path.join(STORE, 'canon', 'MAPPING.md'), MAPPING_MD);
691
- writeIfChanged(path.join(STORE, 'projections', 'nexus-cortex', 'PROJECTIONS.md'), PROJECTIONS_MD);
692
- }
693
- const errPath = path.join(STORE, 'canon', 'TRANSLATE_ERRORS.md');
694
- if (errors.length) {
695
- fs.writeFileSync(errPath, `# Translate errors (current as of last run — failed files retry next run)\n\n` +
696
- errors.map((e) => `- ${e}\n`).join(''));
697
- }
698
- else if (fs.existsSync(errPath))
699
- fs.unlinkSync(errPath);
700
- fs.mkdirSync(path.dirname(MANIFEST_PATH), { recursive: true });
701
- fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest));
702
- let pushed = false;
703
- git(['add', '-A']);
704
- if (git(['status', '--porcelain']).trim()) {
705
- git(['commit', '-q', '-m', `canon-translate: ${summary}`]);
706
- git(['push', '-q', 'origin', 'main']);
707
- console.log(`[canon-translate] pushed: ${summary}`);
708
- pushed = true;
709
- }
710
- else {
711
- console.log(`[canon-translate] no changes (${summary})`);
712
- }
713
- if (errors.length) {
714
- for (const e of errors.slice(0, 10))
715
- console.error(' error:', e);
716
- }
717
- return { translated, unchanged, errors, summary, pushed };
718
- }
719
- //# sourceMappingURL=canonTranslate.js.map