@volter/supercode-interchange 0.1.22 → 0.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE-diffy-MIT +23 -0
  2. package/README.md +422 -2
  3. package/channel-records.mjs +71 -0
  4. package/channel-session-counts.mjs +81 -0
  5. package/channels-read.mjs +90 -0
  6. package/corpus-audit-records.mjs +192 -0
  7. package/corpus-audit-render.mjs +47 -0
  8. package/corpus-audit-schema.mjs +89 -0
  9. package/corpus-audit.d.ts +16 -0
  10. package/corpus-audit.mjs +132 -0
  11. package/file-io.mjs +21 -2
  12. package/harness-settings.mjs +19 -0
  13. package/hermes-fire-links.mjs +135 -0
  14. package/hermes-fires.mjs +106 -0
  15. package/hermes-read-home.d.ts +3 -0
  16. package/hermes-read-home.mjs +235 -0
  17. package/hermes-read-store.mjs +49 -0
  18. package/hermes-read-validation.mjs +170 -0
  19. package/hermes-read-workflow.mjs +164 -0
  20. package/hermes-read-yaml.mjs +68 -0
  21. package/hermes-store.mjs +1 -1
  22. package/homes.mjs +2 -2
  23. package/index.d.ts +14 -1
  24. package/index.mjs +1 -1
  25. package/inspection-builtin-tools.json +261 -0
  26. package/inspection-config-fields.json +765 -0
  27. package/inspection-schemas.d.ts +4 -0
  28. package/inspection-schemas.mjs +117 -0
  29. package/job-notepad-read.mjs +97 -0
  30. package/jobs-control.mjs +250 -0
  31. package/jobs-read.mjs +198 -0
  32. package/jsonl.mjs +4 -3
  33. package/memory-show.mjs +187 -0
  34. package/metadata.mjs +45 -3
  35. package/native-json.mjs +46 -3
  36. package/ontology-contracts.d.ts +5 -0
  37. package/ontology-contracts.mjs +29 -0
  38. package/ontology-operations.json +198 -0
  39. package/ontology-schemas.json +7946 -0
  40. package/openclaw-jobs-read.mjs +117 -0
  41. package/openclaw-profile-home.mjs +142 -0
  42. package/orchestrator-read-records.mjs +112 -0
  43. package/orchestrator-store.mjs +21 -9
  44. package/package.json +59 -4
  45. package/parity-ledger.json +6984 -0
  46. package/parity-orchestration.json +5538 -0
  47. package/parity-report.d.ts +7 -0
  48. package/parity-report.mjs +46 -0
  49. package/preset-profiles.mjs +27 -0
  50. package/profile-command.mjs +32 -0
  51. package/profile-orchestrator-door.mjs +104 -0
  52. package/profile-paths.mjs +25 -0
  53. package/profiles-control.mjs +98 -0
  54. package/profiles-read.mjs +119 -0
  55. package/reduction-attribution.mjs +54 -0
  56. package/reduction-diff.mjs +165 -0
  57. package/reduction-log.mjs +177 -0
  58. package/reduction-view.mjs +145 -0
  59. package/retained-projection.mjs +9 -0
  60. package/routes-read.mjs +103 -0
  61. package/runs-read.mjs +224 -0
  62. package/session-activity.d.ts +23 -0
  63. package/session-activity.mjs +124 -0
  64. package/session-append-scan.mjs +27 -17
  65. package/session-branch-handoff.mjs +115 -0
  66. package/session-claude-graph.mjs +29 -7
  67. package/session-claude-index.mjs +732 -114
  68. package/session-claude-runtime.d.ts +28 -0
  69. package/session-claude-runtime.mjs +171 -6
  70. package/session-claude-scan-store.mjs +164 -0
  71. package/session-claude.mjs +9 -0
  72. package/session-codex-activity.d.ts +6 -0
  73. package/session-codex-activity.mjs +120 -0
  74. package/session-codex-window.mjs +24 -4
  75. package/session-config-scan.mjs +141 -0
  76. package/session-convert.d.ts +33 -0
  77. package/session-convert.mjs +179 -0
  78. package/session-emission-version.json +1 -0
  79. package/session-file.mjs +11 -1
  80. package/session-follower.mjs +17 -5
  81. package/session-fresh-id.mjs +15 -0
  82. package/session-hermes-import.mjs +61 -0
  83. package/session-hermes.mjs +1 -1
  84. package/session-import.mjs +37 -0
  85. package/session-inspect.d.ts +18 -0
  86. package/session-inspect.mjs +122 -0
  87. package/session-machine-tree.d.ts +33 -0
  88. package/session-machine-tree.mjs +274 -0
  89. package/session-materialize-files.mjs +78 -0
  90. package/session-materialize-opencode.mjs +137 -0
  91. package/session-materialize.mjs +186 -0
  92. package/session-message.mjs +85 -0
  93. package/session-model.d.ts +9 -0
  94. package/session-model.mjs +1 -0
  95. package/session-opencode-store.mjs +39 -3
  96. package/session-path.mjs +6 -3
  97. package/session-pi-content.mjs +7 -2
  98. package/session-read.d.ts +134 -2
  99. package/session-read.mjs +86 -9
  100. package/session-recorded-config.mjs +3 -2
  101. package/session-records.mjs +12 -6
  102. package/session-residue-store.mjs +90 -0
  103. package/session-residue.mjs +150 -0
  104. package/session-resume-instructions.mjs +1 -1
  105. package/session-saved-read.mjs +105 -0
  106. package/session-transfer.mjs +141 -0
  107. package/session-views.mjs +1 -0
  108. package/session-write-claude.mjs +135 -0
  109. package/session-write-codex.mjs +146 -0
  110. package/session-write-gemini.mjs +77 -0
  111. package/session-write-goose.mjs +99 -0
  112. package/session-write-grok.mjs +70 -0
  113. package/session-write-native.mjs +41 -0
  114. package/session-write-opencode.mjs +197 -0
  115. package/session-write-pi.mjs +130 -0
  116. package/session-write-residue.mjs +19 -0
  117. package/session-write-utils.mjs +77 -0
  118. package/session-write.d.ts +10 -0
  119. package/session-write.mjs +37 -0
  120. package/skill-toml.mjs +80 -0
  121. package/skills-control.mjs +193 -0
  122. package/skills-list.mjs +237 -0
  123. package/source-files.mjs +12 -2
  124. package/sqlite-reader.mjs +134 -18
  125. package/support-registry.json +402 -0
  126. package/support-report.mjs +69 -0
  127. package/trigger-records.mjs +77 -0
  128. package/triggers-read.mjs +67 -0
  129. package/workflow-read.mjs +165 -0
@@ -0,0 +1,90 @@
1
+ // Recorded channel inventory/status. Configured means credential presence;
2
+ // connection health is always unknown. This reader creates no endpoint.
3
+ import { isAbsolute } from 'node:path';
4
+ import { resolveHarnessHomes } from './homes.mjs';
5
+ import { profilePathJoin as join, profilePathParent as parent } from './profile-paths.mjs';
6
+ import { readChannelFolderHome } from './hermes-read-home.mjs';
7
+ import { readOpenclawChannelHome } from './openclaw-profile-home.mjs';
8
+ import { channelSessionCounts } from './channel-session-counts.mjs';
9
+ import { nativeDuplicateObjectKeys, parseNativeJson, stringifyNativeJsonMember, setNativeInteger } from './native-json.mjs';
10
+
11
+ export const CHANNEL_HARNESSES = Object.freeze(['hermes', 'openclaw', 'orchestrator']);
12
+ const object = value => value != null && typeof value === 'object' && !Array.isArray(value);
13
+ const compare = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
14
+ const refuse = (message, rpcCode = -32602) => { throw Object.assign(new Error(message), { rpcCode }); };
15
+ const filesystem = (path, cwd) => !path.length || isAbsolute(path) || process.platform === 'win32' && /^[A-Za-z]:/u.test(path) ? path : join(cwd, path);
16
+ function struct(value, fields, name) {
17
+ if (Array.isArray(value)) {
18
+ if (value.length > fields.length) refuse(`${name} has too many fields`);
19
+ return parseNativeJson(`{${value.map((_, at) => `${JSON.stringify(fields[at])}:${stringifyNativeJsonMember(value, String(at))}`).join(',')}}`);
20
+ }
21
+ if (!object(value)) refuse(`${name} must be a struct`);
22
+ if (nativeDuplicateObjectKeys(value).some(key => fields.includes(key))) refuse(`${name} has a duplicate field`);
23
+ return value;
24
+ }
25
+ function query(params, env) {
26
+ const selected = struct(params, ['harness', 'name', 'homes'], 'channels params');
27
+ for (const key of ['harness', 'name']) if (selected[key] != null && typeof selected[key] !== 'string') refuse(`${key} must be a string`);
28
+ const homes = resolveHarnessHomes(env);
29
+ if (Object.hasOwn(selected, 'homes')) {
30
+ const supplied = struct(selected.homes, Object.keys(homes), 'homes');
31
+ for (const key of Object.keys(homes)) if (Object.hasOwn(supplied, key)) {
32
+ if (typeof supplied[key] !== 'string') refuse(`homes.${key} must be a string`);
33
+ homes[key] = supplied[key];
34
+ }
35
+ }
36
+ return { harness: selected.harness ?? null, name: selected.name ?? null, homes };
37
+ }
38
+ const envCredentials = [
39
+ ['telegram', ['TELEGRAM_BOT_TOKEN']], ['discord', ['DISCORD_BOT_TOKEN']], ['slack', ['SLACK_BOT_TOKEN']],
40
+ ['whatsapp_cloud', ['WHATSAPP_CLOUD_PHONE_NUMBER_ID', 'WHATSAPP_CLOUD_ACCESS_TOKEN'], true], ['signal', ['SIGNAL_HTTP_URL']], ['mattermost', ['MATTERMOST_TOKEN']],
41
+ ['matrix', ['MATRIX_ACCESS_TOKEN', 'MATRIX_PASSWORD']], ['homeassistant', ['HASS_TOKEN']], ['email', ['EMAIL_ADDRESS', 'EMAIL_PASSWORD', 'EMAIL_IMAP_HOST', 'EMAIL_SMTP_HOST'], true],
42
+ ['sms', ['TWILIO_ACCOUNT_SID']], ['dingtalk', ['DINGTALK_CLIENT_ID', 'DINGTALK_CLIENT_SECRET'], true], ['feishu', ['FEISHU_APP_ID', 'FEISHU_APP_SECRET'], true],
43
+ ['wecom', ['WECOM_BOT_ID', 'WECOM_SECRET'], true], ['wecom_callback', ['WECOM_CALLBACK_CORP_ID', 'WECOM_CALLBACK_CORP_SECRET'], true],
44
+ ['weixin', ['WEIXIN_TOKEN', 'WEIXIN_ACCOUNT_ID']], ['bluebubbles', ['BLUEBUBBLES_SERVER_URL', 'BLUEBUBBLES_PASSWORD'], true],
45
+ ['qqbot', ['QQ_APP_ID', 'QQ_CLIENT_SECRET']], ['yuanbao', ['YUANBAO_APP_ID', 'YUANBAO_APP_SECRET'], true], ['relay', ['GATEWAY_RELAY_URL']], ['api_server', ['API_SERVER_KEY']],
46
+ ];
47
+ const presence = (platform, env) => {
48
+ const entry = envCredentials.find(([name]) => name === platform);
49
+ return entry == null ? null : entry[2] ? entry[1].every(key => Object.hasOwn(env, key)) : entry[1].some(key => Object.hasOwn(env, key));
50
+ };
51
+ async function rows(selected, context) {
52
+ if (selected.harness != null && !CHANNEL_HARNESSES.includes(selected.harness)) refuse(`harness \`${selected.harness}\` has no channel concept (channels exist for: ${CHANNEL_HARNESSES.join(', ')})`, -32020);
53
+ const homes = Object.fromEntries(Object.entries(selected.homes).map(([key, path]) => [key, filesystem(path, context.cwd)])), result = [];
54
+ for (const harness of CHANNEL_HARNESSES) {
55
+ if (selected.harness != null && selected.harness !== harness) continue;
56
+ const counts = await channelSessionCounts(harness, homes);
57
+ let channels;
58
+ try {
59
+ if (harness === 'openclaw') channels = readOpenclawChannelHome(homes.openclaw);
60
+ else {
61
+ const profiles = readChannelFolderHome(harness === 'hermes' ? parent(homes.hermes) ?? '.' : homes.orchestrator, harness);
62
+ channels = [];
63
+ for (const name of [...profiles.keys()].filter(name => harness !== 'hermes' || name === 'default').sort((a, b) => Number(a !== 'default') - Number(b !== 'default') || compare(a, b))) {
64
+ const recorded = new Map(profiles.get(name).map(row => [row.name, row]));
65
+ if (harness === 'hermes') for (const [platform] of envCredentials) if (!recorded.has(platform) && presence(platform, context.env) === true) recorded.set(platform, { name: platform, account: null, enabled: true, configured: false });
66
+ for (const key of [...recorded.keys()].sort(compare)) {
67
+ const row = recorded.get(key), present = harness === 'hermes' ? presence(key, context.env) : null;
68
+ channels.push({ ...row, kind: key, configured: present === true || row.configured || present == null });
69
+ }
70
+ }
71
+ }
72
+ } catch { continue; }
73
+ for (const row of channels) {
74
+ const value = { name: row.name, harness, kind: row.kind, account: row.account, enabled: row.enabled, configured: row.configured, status: 'unknown', sessions: null };
75
+ setNativeInteger(value, 'sessions', counts.get(row.kind) ?? 0n); result.push(value);
76
+ }
77
+ }
78
+ return result;
79
+ }
80
+ export async function listChannels(params, { env = process.env, cwd = process.cwd() } = {}) {
81
+ return { schema: 'supercode.channels.v1', channels: await rows(query(params, env), { env, cwd }) };
82
+ }
83
+ export async function channelStatus(params, { env = process.env, cwd = process.cwd() } = {}) {
84
+ const selected = query(params, env);
85
+ if (selected.harness == null) refuse('`harness` is required');
86
+ if (selected.name == null) refuse('`name` is required');
87
+ const channel = (await rows(selected, { env, cwd })).find(row => row.name === selected.name);
88
+ if (channel == null) refuse(`\`${selected.harness}\` has no channel \`${selected.name}\``);
89
+ return { schema: 'supercode.channels.v1', channel };
90
+ }
@@ -0,0 +1,192 @@
1
+ import {parseNativeJson, setNativeInteger, stringifyNativeJson} from './native-json.mjs';
2
+ import {field, stringField} from './session-codec-utils.mjs';
3
+ import {nativeTrim} from './text.mjs';
4
+ import {opencodeImagePart} from './session-opencode-content.mjs';
5
+ import {piImageShape} from './session-pi-content.mjs';
6
+ import {auditClaudeRecord, auditCodexRecord} from './corpus-audit-schema.mjs';
7
+
8
+ export const AUDIT_COVERAGE = Object.freeze(['normalized', 'retained', 'dropped', 'unmodeled']);
9
+ export const auditOrder = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
10
+ const sorted = map => [...map].sort(([a], [b]) => auditOrder(a, b));
11
+ const next = count => ((count ?? 0n) + 1n) & ((1n << 64n) - 1n);
12
+ const countValue = (count, coverage, unmodeled_fields) => {
13
+ const out = {coverage}; setNativeInteger(out, 'count', count);
14
+ if (unmodeled_fields !== undefined) out.unmodeled_fields = unmodeled_fields;
15
+ return out;
16
+ };
17
+ const ordered = entries => parseNativeJson(`{${entries.map(([key, value]) => `${JSON.stringify(key)}:${stringifyNativeJson(value)}`).join(',')}}`);
18
+
19
+ /** Counter state is exact u64; the public value keeps ordinary JS numbers
20
+ * with the shared native-JSON facts used by the lossless serializer. */
21
+ export class CorpusAuditReport {
22
+ constructor(corpus) {
23
+ this.corpus = corpus; this.files = 0n; this.lines = 0n; this.parse_errors = 0n;
24
+ this.records = new Map(); this.blocks = new Map(); this.tools = new Map(); this.notes = new Map();
25
+ }
26
+ bump(key, coverage, extra = []) {
27
+ const tally = this.records.get(key) ?? {coverage, count:0n, fields:new Map()};
28
+ tally.coverage = coverage; tally.count = next(tally.count);
29
+ for (const key of extra) tally.fields.set(key, next(tally.fields.get(key)));
30
+ this.records.set(key, tally);
31
+ }
32
+ block(tag, coverage) {
33
+ const buckets = this.blocks.get(tag) ?? new Map();
34
+ buckets.set(coverage, next(buckets.get(coverage))); this.blocks.set(tag, buckets);
35
+ }
36
+ tool(name) { this.tools.set(name, next(this.tools.get(name))); }
37
+ note(name) { this.notes.set(name, next(this.notes.get(name))); }
38
+ toJSON() {
39
+ const records = ordered(sorted(this.records).map(([key, tally]) => [key, countValue(tally.count, tally.coverage, [...tally.fields.keys()].sort(auditOrder))]));
40
+ const blocks = ordered(sorted(this.blocks).map(([key, buckets]) => [key, AUDIT_COVERAGE.filter(c => buckets.has(c)).map(c => countValue(buckets.get(c), c))]));
41
+ const tools = ordered(sorted(this.tools)), notes = ordered(sorted(this.notes));
42
+ const out = {corpus:this.corpus};
43
+ for (const key of ['files', 'lines', 'parse_errors']) setNativeInteger(out, key, this[key]);
44
+ Object.assign(out, {records, blocks, tools, notes});
45
+ return out;
46
+ }
47
+ }
48
+
49
+ // The CLI enables serde_json preserve_order: raw Grok scalar/object text
50
+ // and constructed report objects retain their native insertion order.
51
+ export const auditJsonText = stringifyNativeJson;
52
+ function imageCoverage(source) {
53
+ const type = stringField(source, 'type');
54
+ if (type === 'base64') return stringField(source, 'media_type') && stringField(source, 'data') ? 'normalized' : 'dropped';
55
+ return type === 'url' && stringField(source, 'url') ? 'normalized' : 'dropped';
56
+ }
57
+ function auditBlocks(blocks, rawPayload, report) {
58
+ const raw = field(rawPayload, 'content');
59
+ for (const [at, block] of blocks.entries()) {
60
+ const tag = block.tag ?? stringField(Array.isArray(raw) ? raw[at] : null, 'type') ?? '<no-type>';
61
+ const coverage = block.tag == null ? 'unmodeled' : block.tag === 'image' ? imageCoverage(field(block.value, 'source'))
62
+ : ['thinking', 'redacted_thinking'].includes(block.tag) ? 'dropped' : 'normalized';
63
+ report.block(tag, coverage);
64
+ const nested = field(block.value, 'content');
65
+ if (block.tag === 'tool_result' && Array.isArray(nested)) for (const item of nested) {
66
+ if (stringField(item, 'type') === 'image') report.block('tool_result/image', imageCoverage(field(item, 'source')));
67
+ }
68
+ }
69
+ }
70
+ function claude(raw, report) {
71
+ const record = auditClaudeRecord(raw), {tag, extra} = record;
72
+ if (tag == null) { report.bump(`<line>/${stringField(raw, 'type') ?? '<no-type>'}`, 'unmodeled'); return; }
73
+ if (tag === 'user' || tag === 'assistant') {
74
+ report.bump(tag, 'normalized', extra);
75
+ if (record.sidechain) report.note('sidechain (subagent) lines — flattened, not separated');
76
+ if (typeof record.content === 'string') report.block('text', 'normalized');
77
+ else {
78
+ // This lane deliberately receives no raw block array, as the replaced
79
+ // reader did: unknown Claude block tags are tallied as <no-type>.
80
+ auditBlocks(record.content, null, report);
81
+ for (const block of record.content) if (block.tag === 'tool_use') report.tool(block.value.name);
82
+ }
83
+ } else if (tag === 'system') report.bump(`system/${record.subtype}`, ['scheduled_task_fire', 'local_command', 'away_summary'].includes(record.subtype) ? 'normalized' : 'dropped', extra);
84
+ else report.bump(tag, ['attachment', 'fork-context-ref'].includes(tag) ? 'normalized'
85
+ : ['permission-mode', 'mode', 'last-prompt', 'queue-operation', 'ai-title', 'pr-link', 'frame-link', 'agent-name', 'worktree-state'].includes(tag) ? 'dropped' : 'unmodeled', extra);
86
+ }
87
+ function codex(raw, report) {
88
+ const record = auditCodexRecord(raw), {tag, extra} = record;
89
+ if (tag == null) { report.bump(`<line>/${stringField(raw, 'type') ?? '<no-type>'}`, 'unmodeled'); return; }
90
+ if (tag === 'response_item') {
91
+ const item = record.item, name = item.tag ?? stringField(raw.payload, 'type') ?? '<no-type>';
92
+ const coverage = item.tag == null ? 'unmodeled' : name === 'reasoning' ? 'retained' : 'normalized';
93
+ report.bump(`response_item/${name}${name === 'message' ? `[${item.value.role}]` : ''}`, coverage, item.extra);
94
+ if (name === 'message') auditBlocks(item.content, raw.payload, report);
95
+ if (name === 'function_call' || name === 'custom_tool_call' && item.value.name != null) report.tool(item.value.name);
96
+ } else if (tag === 'event_msg') {
97
+ const sub = record.payload.type ?? '?';
98
+ report.bump(`event_msg/${sub}`, ['agent_message', 'thread_rolled_back', 'thread_goal_updated', 'entered_review_mode', 'exited_review_mode'].includes(sub) ? 'retained' : 'dropped', extra);
99
+ } else report.bump(tag, tag === 'compacted' ? 'normalized' : 'retained', extra);
100
+ }
101
+ function pi(raw, report) {
102
+ const type = stringField(raw, 'type');
103
+ if (type === 'message') {
104
+ const message = field(raw, 'message'), role = stringField(message, 'role'), content = field(message, 'content');
105
+ const unknownImage = ['user', 'toolResult', 'custom'].includes(role) && Array.isArray(content)
106
+ && content.some(item => stringField(item, 'type') === 'image' && piImageShape(item) == null);
107
+ report.bump(unknownImage ? 'message/UnknownImageShape' : ['user', 'assistant', 'toolResult', 'bashExecution', 'custom'].includes(role) ? `message/${role}` : `message/UnknownRole:${role ?? '<none>'}`,
108
+ unknownImage || !['user', 'assistant', 'toolResult', 'bashExecution', 'custom'].includes(role) ? 'unmodeled' : 'normalized');
109
+ } else if (['session', 'custom_message', 'compaction', 'branch_summary', 'model_change', 'session_info'].includes(type)) report.bump(type, 'normalized');
110
+ else if (['thinking_level_change', 'custom', 'label'].includes(type)) report.bump(type, 'dropped');
111
+ else report.bump(`<line>/${type ?? '<no-type>'}`, 'unmodeled');
112
+ }
113
+ function gemini(raw, report) {
114
+ const type = stringField(raw, 'type');
115
+ if (type == null) {
116
+ const key = stringField(raw, 'sessionId') != null ? 'session_header' : field(raw, '$set') !== undefined ? 'metadata_update' : '<line>/<no-type>';
117
+ report.bump(key, key.startsWith('<line>') ? 'unmodeled' : 'retained'); return;
118
+ }
119
+ if (type === 'user' || type === 'gemini') {
120
+ report.bump(type, 'normalized');
121
+ const content = field(raw, 'content');
122
+ if (Array.isArray(content)) for (const part of content) {
123
+ if (field(part, 'text') !== undefined) report.bump('content/text', 'normalized');
124
+ else if (field(part, 'inlineData') !== undefined) report.bump('content/inlineData', 'normalized');
125
+ else if (field(part, 'functionCall') !== undefined || field(part, 'functionResponse') !== undefined) {
126
+ const key = field(part, 'functionCall') !== undefined ? 'functionCall' : 'functionResponse';
127
+ report.bump(`content/${key}`, 'normalized'); const name = stringField(field(part, key), 'name'); if (name != null) report.tool(name);
128
+ } else report.bump('content/unknown', 'unmodeled');
129
+ } else if (content !== null && typeof content !== 'string') report.bump('content/nonstandard', 'unmodeled');
130
+ if (field(raw, 'thoughts') !== undefined) report.bump('thoughts', 'retained');
131
+ } else if (type === 'info' || type === 'error') report.bump(type, 'retained');
132
+ else report.bump(`<line>/${type}`, 'unmodeled');
133
+ }
134
+ function grokContent(content, prefix, coverage, report) {
135
+ if (Array.isArray(content)) for (const item of content) {
136
+ const modeled = typeof item === 'string' || stringField(item, 'text') != null;
137
+ report.bump(`${prefix}/content/${stringField(item, 'type') ?? '<no-type>'}`, coverage === 'dropped' ? 'dropped' : modeled ? 'normalized' : 'unmodeled');
138
+ } else report.bump(`${prefix}/content${content === undefined ? ':<missing>' : ''}`, content === undefined && coverage !== 'dropped' ? 'unmodeled' : coverage);
139
+ }
140
+ function grok(raw, report) {
141
+ const type = stringField(raw, 'type'), content = field(raw, 'content');
142
+ if (type === 'system') report.bump('system', typeof content === 'string' ? 'retained' : 'unmodeled');
143
+ else if (type === 'user') {
144
+ const text = nativeTrim(typeof content === 'string' ? content : Array.isArray(content)
145
+ ? content.map(item => stringField(item, 'text') ?? (typeof item === 'string' ? item : null)).filter(item => item != null).join('\n')
146
+ : content === undefined ? '' : auditJsonText(content));
147
+ let key = 'user', coverage = 'normalized';
148
+ if (stringField(raw, 'synthetic_reason') === 'supercode_system_event') key = 'user/system_event';
149
+ else if (text.startsWith('<user_info>')) { key = 'user/injected_context:user_info'; coverage = 'dropped'; }
150
+ else if (text.startsWith('<system-reminder>')) { key = 'user/injected_context:system-reminder'; coverage = 'dropped'; }
151
+ else if (text === '') { key = 'user/empty'; coverage = 'dropped'; }
152
+ else if (text.startsWith('<user_query>') && text.endsWith('</user_query>') && nativeTrim(text.slice(12, -13)) === '') { key = 'user/empty_query'; coverage = 'dropped'; }
153
+ report.bump(key, coverage); grokContent(content, key, coverage, report);
154
+ } else if (type === 'assistant') {
155
+ report.bump('assistant', content == null || typeof content === 'string' ? 'normalized' : 'unmodeled');
156
+ const calls = field(raw, 'tool_calls');
157
+ if (Array.isArray(calls)) for (const call of calls) {
158
+ const name = stringField(call, 'name'), modeled = name != null && stringField(call, 'id') != null;
159
+ report.bump(`assistant/tool_call${modeled ? '' : ':invalid'}`, modeled ? 'normalized' : 'unmodeled'); if (name != null) report.tool(name);
160
+ } else if (calls !== undefined) report.bump('assistant/tool_calls:non-array', 'unmodeled');
161
+ } else if (type === 'tool_result') {
162
+ report.bump(type, stringField(raw, 'tool_call_id') != null ? 'normalized' : 'unmodeled'); grokContent(content, type, 'normalized', report);
163
+ } else if (type === 'reasoning' || type === 'backend_tool_call') report.bump(type, 'dropped');
164
+ else report.bump(`<line>/${type ?? '<no-type>'}`, 'unmodeled');
165
+ }
166
+ const partTypes = new Set(['text', 'reasoning', 'tool', 'file', 'step-start', 'step-finish', 'snapshot', 'patch', 'agent', 'subtask', 'retry', 'compaction']);
167
+ function opencode(raw, report) {
168
+ const key = field(raw, 'key'), value = field(raw, 'value');
169
+ if (!Array.isArray(key)) { report.bump('<line>/<no-key>', 'unmodeled'); return; }
170
+ const kind = typeof key[0] === 'string' ? key[0] : '<no-kind>';
171
+ if (kind === 'message') {
172
+ const role = stringField(value, 'role'); report.bump(['user', 'assistant'].includes(role) ? `message/${role}` : `message/UnknownRole:${role ?? '<none>'}`, ['user', 'assistant'].includes(role) ? 'normalized' : 'unmodeled');
173
+ } else if (kind === 'part') {
174
+ const type = stringField(value, 'type');
175
+ if (!partTypes.has(type)) report.bump(`part/UnknownType:${type ?? '<none>'}`, 'unmodeled');
176
+ else if (type === 'tool') {
177
+ const name = stringField(value, 'tool'); if (name != null) report.tool(name);
178
+ const status = stringField(field(value, 'state'), 'status'), known = ['pending', 'running', 'completed', 'error'].includes(status);
179
+ report.bump(known ? `part/tool/${status}` : `part/tool/UnknownStatus:${status ?? '<none>'}`, known ? 'normalized' : 'unmodeled');
180
+ } else if (type === 'text') report.bump(`part/text${field(value, 'ignored') === true ? ':ignored' : ''}`, field(value, 'ignored') === true ? 'dropped' : 'normalized');
181
+ else if (type === 'file') { const modeled = opencodeImagePart(value) != null; report.bump(`part/file${modeled ? '' : ':residue'}`, modeled ? 'normalized' : 'dropped'); }
182
+ else report.bump(`part/${type}`, type === 'compaction' ? 'normalized' : 'dropped');
183
+ } else if (['session', 'session_diff', 'todo'].includes(kind)) report.bump(kind, 'normalized');
184
+ else report.bump(`<line>/${kind}`, 'unmodeled');
185
+ }
186
+ const classifiers = {'claude-code':claude, codex, pi, opencode, grok, gemini};
187
+ export function auditCorpusLine(line, report) {
188
+ let raw;
189
+ try { raw = parseNativeJson(line); classifiers[report.corpus](raw, report); }
190
+ catch (error) { if (!(error instanceof SyntaxError || error instanceof TypeError)) throw error; report.parse_errors = next(report.parse_errors); }
191
+ return raw;
192
+ }
@@ -0,0 +1,47 @@
1
+ import {nativeInteger, nativeObjectKeys} from './native-json.mjs';
2
+
3
+ const scalars = text => [...text];
4
+ const pad = (text, size, left = false) => { const blank = ' '.repeat(Math.max(0, size - scalars(text).length)); return left ? blank + text : text + blank; };
5
+ const truncate = (text, size) => scalars(text).length <= size ? text : `${scalars(text).slice(0, size - 1).join('')}…`;
6
+ const thousands = value => String(value).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
7
+ const count = (value, key = 'count') => nativeInteger(value, key) ?? 0n;
8
+ const descending = (a, b) => a > b ? -1 : a < b ? 1 : 0;
9
+ /** Compact terminal rendering retains the native caps, stable count order,
10
+ * per-instance block buckets and gap summary. Notes remain in the JSON view. */
11
+ export function formatCorpusAudit(report, {paint = (_role, text) => text, bold = text => text} = {}) {
12
+ const section = title => `${paint('ACCENT', '▌')} ${bold(title)}`;
13
+ const rule = paint('DIM', '─'.repeat(60));
14
+ const kv = (key, value) => ` ${paint('MUTED', pad(key, 12))} ${value}`;
15
+ const glyph = coverage => paint(coverage === 'normalized' ? 'OK' : coverage === 'retained' ? 'TEAL' : coverage === 'dropped' ? 'DIM' : 'ERR', coverage === 'normalized' ? '✓' : coverage === 'retained' ? '◆' : coverage === 'dropped' ? '◦' : '✗');
16
+ const row = (label, value, extra = '') => {
17
+ const name = truncate(label, 38), number = thousands(count(value));
18
+ return ` ${glyph(value.coverage)} ${paint('MUTED', name)} ${paint('DIM', '.'.repeat(Math.max(0, 40 - scalars(name).length)))} ${' '.repeat(Math.max(0, 9 - scalars(number).length))}${bold(number)}${extra}\n`;
19
+ };
20
+ const entries = object => nativeObjectKeys(object).map(key => [key, object[key]]);
21
+ const sort = rows => rows.sort(([, a], [, b]) => descending(count(a), count(b)));
22
+ let text = `\n${section(`coverage audit · ${report.corpus}`)}\n${rule}\n\n`;
23
+ text += `${kv('files', bold(String(count(report, 'files'))))}\n${kv('lines', bold(thousands(count(report, 'lines'))))}\n${kv('parse errors', paint(count(report, 'parse_errors') === 0n ? 'OK' : 'ERR', String(count(report, 'parse_errors'))))}\n${rule}\n\n`;
24
+ const records = entries(report.records), blocks = entries(report.blocks).flatMap(([tag, rows]) => rows.map(value => [tag, value]));
25
+ text += `${section(`Records (${records.length} types)`)}\n`;
26
+ for (const [key, value] of sort(records).slice(0, 12)) text += row(key, value, value.unmodeled_fields.length ? paint('DIM', ` +${value.unmodeled_fields.length}`) : '');
27
+ if (records.length > 12) text += ` ${paint('DIM', ' ')} ${paint('DIM', `… ${records.length - 12} more — use --json for the full report`)}\n`;
28
+ text += '\n';
29
+ if (blocks.length) { text += `${section(`Content blocks (${nativeObjectKeys(report.blocks).length} types)`)}\n`; for (const [key, value] of sort(blocks)) text += row(key, value); text += '\n'; }
30
+ const tools = entries(report.tools).sort(([a], [b]) => descending(count(report.tools, a), count(report.tools, b)));
31
+ if (tools.length) {
32
+ text += `${section('Tools observed')}\n`;
33
+ const max = count(report.tools, tools[0][0]) || 1n;
34
+ for (const [key] of tools.slice(0, 8)) {
35
+ const n = count(report.tools, key), bar = '█'.repeat(Math.max(1, Math.round(Number(n) / Number(max) * 28)));
36
+ text += ` ${paint('MUTED', pad(truncate(key, 16), 16))} ${paint('DIM', pad(thousands(n), 8, true))} ${paint('ACCENT', bar)}\n`;
37
+ }
38
+ text += '\n';
39
+ }
40
+ const gaps = [...entries(report.records).filter(([, value]) => value.coverage === 'unmodeled'), ...entries(report.blocks).flatMap(([tag, rows]) => rows.filter(value => value.coverage === 'unmodeled').map(value => [`content block \`${tag}\``, value]))];
41
+ if (!gaps.length) text += ` ${paint('OK', '✓')} ${paint('MUTED', 'every record type is modeled — no gaps')}\n`;
42
+ else {
43
+ text += `${section('Gaps (not yet modeled)')}\n`;
44
+ for (const [key, value] of sort(gaps)) text += ` ${paint('ERR', '✗')} ${paint('MUTED', pad(key, 40))} ${paint('DIM', `${thousands(count(value))} occurrence(s)`)}\n`;
45
+ }
46
+ return `${text}\n`;
47
+ }
@@ -0,0 +1,89 @@
1
+ // The audit's typed envelope boundary. Unknown variants accept their opaque
2
+ // fields; modeled fields keep their required/default/nullable native meanings.
3
+ import {nativeDuplicateObjectKeys, nativeObjectKeys} from './native-json.mjs';
4
+ import {isObject} from './session-codec-utils.mjs';
5
+
6
+ const invalid = () => { throw new TypeError('record does not match the corpus schema'); };
7
+ const string = value => typeof value === 'string';
8
+ const nullableString = value => value === null || string(value);
9
+ const any = () => true;
10
+ const required = check => ({check, required:true});
11
+ const fields = (value, model) => {
12
+ if (!isObject(value)) invalid();
13
+ if (nativeDuplicateObjectKeys(value).some(key => Object.hasOwn(model, key))) invalid();
14
+ for (const [key, rule] of Object.entries(model)) {
15
+ if (!Object.hasOwn(value, key)) { if (rule.required) invalid(); }
16
+ else if (!(rule.check ?? rule)(value[key])) invalid();
17
+ }
18
+ return nativeObjectKeys(value).filter(key => !Object.hasOwn(model, key));
19
+ };
20
+ const tagged = value => { fields(value, {type:required(string)}); return value.type; };
21
+ const blockModels = {
22
+ text:{text:string}, thinking:{thinking:string}, redacted_thinking:{data:nullableString},
23
+ tool_use:{id:required(string), name:required(string), input:any},
24
+ tool_result:{tool_use_id:nullableString, content:any}, image:{source:any},
25
+ input_text:{text:string}, output_text:{text:string}, input_image:{}, fallback:{},
26
+ };
27
+
28
+ export function auditBlock(value) {
29
+ const tag = tagged(value);
30
+ if (!Object.hasOwn(blockModels, tag)) return {tag:null, value};
31
+ fields(value, {type:required(string), ...blockModels[tag]});
32
+ return {tag, value};
33
+ }
34
+ function blocks(value) {
35
+ if (!Array.isArray(value)) invalid();
36
+ return value.map(auditBlock);
37
+ }
38
+ const claudeMarkers = new Set(['attachment', 'file-history-snapshot', 'file-history-delta', 'ai-title',
39
+ 'permission-mode', 'mode', 'last-prompt', 'queue-operation', 'pr-link', 'frame-link', 'agent-name',
40
+ 'started', 'result', 'worktree-state', 'fork-context-ref']);
41
+ const meta = {uuid:nullableString, parent_uuid:nullableString, is_sidechain:value => typeof value === 'boolean',
42
+ session_id:nullableString, cwd:nullableString, git_branch:nullableString};
43
+
44
+ export function auditClaudeRecord(value) {
45
+ const tag = tagged(value);
46
+ if (tag === 'user' || tag === 'assistant') {
47
+ const extra = fields(value, {type:required(string), message:required(isObject), ...meta});
48
+ fields(value.message, {role:nullableString, model:nullableString, content:any});
49
+ const content = Object.hasOwn(value.message, 'content') ? value.message.content : [];
50
+ return {tag, extra, content:typeof content === 'string' ? content : blocks(content), sidechain:value.is_sidechain === true};
51
+ }
52
+ if (tag === 'system') return {tag, extra:fields(value, {type:required(string), subtype:nullableString}), subtype:value.subtype ?? '?'};
53
+ if (claudeMarkers.has(tag)) return {tag, extra:fields(value, {type:required(string)})};
54
+ return {tag:null, extra:[]};
55
+ }
56
+
57
+ const itemModels = {
58
+ message:{role:required(string), content:any},
59
+ function_call:{name:required(string), arguments:string, call_id:required(string)},
60
+ function_call_output:{call_id:required(string), output:any},
61
+ custom_tool_call:{name:nullableString, input:any, call_id:nullableString},
62
+ custom_tool_call_output:{call_id:nullableString, output:any},
63
+ reasoning:{}, web_search_call:{}, tool_search_call:{}, tool_search_output:{}, image_generation_call:{},
64
+ };
65
+ function auditCodexItem(value) {
66
+ const tag = tagged(value);
67
+ if (!Object.hasOwn(itemModels, tag)) return {tag:null, extra:[], value};
68
+ const extra = fields(value, {type:required(string), ...itemModels[tag]});
69
+ return {tag, extra, value, ...(tag === 'message' ? {content:blocks(Object.hasOwn(value, 'content') ? value.content : [])} : {})};
70
+ }
71
+ const payloadModels = {
72
+ session_meta:{id:nullableString, cwd:nullableString, base_instructions:any, model_provider:nullableString},
73
+ turn_context:{model:nullableString, cwd:nullableString}, event_msg:{type:nullableString},
74
+ };
75
+
76
+ export function auditCodexRecord(value) {
77
+ fields(value, {timestamp:nullableString});
78
+ const tag = tagged(value);
79
+ if (tag === 'response_item') {
80
+ fields(value, {timestamp:nullableString, type:required(string), payload:required(isObject)});
81
+ return {tag, item:auditCodexItem(value.payload)};
82
+ }
83
+ if (Object.hasOwn(payloadModels, tag)) {
84
+ fields(value, {timestamp:nullableString, type:required(string), payload:required(isObject)});
85
+ return {tag, extra:fields(value.payload, payloadModels[tag]), payload:value.payload};
86
+ }
87
+ if (tag === 'compacted') { fields(value, {timestamp:nullableString, type:required(string), payload:any}); return {tag, extra:[]}; }
88
+ return {tag:null, extra:[]};
89
+ }
@@ -0,0 +1,16 @@
1
+ export type AuditFormat = 'claude-code' | 'codex' | 'opencode' | 'pi' | 'grok' | 'gemini' | 'goose';
2
+ export type AuditCoverage = 'normalized' | 'retained' | 'dropped' | 'unmodeled';
3
+ export interface AuditTally {coverage:AuditCoverage; count:number; unmodeled_fields:string[]}
4
+ export interface CorpusAuditReport {
5
+ corpus:AuditFormat; files:number; lines:number; parse_errors:number;
6
+ records:Record<string, AuditTally>;
7
+ blocks:Record<string, {coverage:AuditCoverage; count:number}[]>;
8
+ tools:Record<string, number>; notes:Record<string, number>;
9
+ }
10
+ export const AUDIT_FORMATS: readonly AuditFormat[];
11
+ export const AUDIT_COVERAGE: readonly AuditCoverage[];
12
+ /** Counts expose JS numbers; corpusAuditJson retains the exact native u64 wire values. */
13
+ export function auditCorpus(root:string, options:{format:AuditFormat; limit?:number | bigint | null}):Promise<CorpusAuditReport>;
14
+ export function corpusAuditJson(report:CorpusAuditReport):string;
15
+ export function formatCorpusAuditInventory(report:CorpusAuditReport):string;
16
+ export function formatCorpusAudit(report:CorpusAuditReport, style?:{paint?:(role:string, text:string) => string; bold?:(text:string) => string}):string;
@@ -0,0 +1,132 @@
1
+ import {readFile, readdir, stat} from 'node:fs/promises';
2
+ import {basename, extname, join} from 'node:path';
3
+ import {decodeSessionUtf8} from './session-file.mjs';
4
+ import {nativeTrim} from './text.mjs';
5
+ import {field} from './session-codec-utils.mjs';
6
+ import {nativeInteger, stringifyNativeJsonPretty} from './native-json.mjs';
7
+ import {readForeignStore} from './foreign-store.mjs';
8
+ import {readGooseSqlite} from './session-goose.mjs';
9
+ import {readOpenCodeCorpus} from './session-opencode-store.mjs';
10
+ import {CorpusAuditReport, auditCorpusLine, auditOrder} from './corpus-audit-records.mjs';
11
+
12
+ export {formatCorpusAudit} from './corpus-audit-render.mjs';
13
+ export {AUDIT_COVERAGE} from './corpus-audit-records.mjs';
14
+ export const AUDIT_FORMATS = Object.freeze(['claude-code', 'codex', 'opencode', 'pi', 'grok', 'gemini', 'goose']);
15
+ const info = async path => { try { return await stat(path); } catch { return null; } };
16
+ const entries = async path => { try { return await readdir(path, {withFileTypes:true}); } catch { return []; } };
17
+ const cap = (rows, limit) => limit == null || limit >= BigInt(rows.length) ? rows : rows.slice(0, Number(limit));
18
+ function nativeLimit(value) {
19
+ if (value == null) return null;
20
+ if (typeof value !== 'bigint' && !Number.isSafeInteger(value)) throw new TypeError('audit limit must be an exact nonnegative integer');
21
+ const whole = BigInt(value);
22
+ if (whole < 0n || whole > (1n << 64n) - 1n) throw new RangeError('audit limit is outside native usize');
23
+ return whole;
24
+ }
25
+ async function jsonlFiles(root) {
26
+ const out = [], pending = [root];
27
+ // Native walks ordinary directories without following child symlinks;
28
+ // root symlinks are resolved by the root's own filesystem lookup.
29
+ if (extname(root) === '.jsonl' && await info(root)) out.push(root);
30
+ if (!(await info(root))?.isDirectory()) return out;
31
+ while (pending.length) {
32
+ for (const entry of await entries(pending.pop())) {
33
+ const path = join(entry.parentPath ?? entry.path, entry.name);
34
+ if (extname(path) === '.jsonl') out.push(path);
35
+ if (entry.isDirectory()) pending.push(path);
36
+ }
37
+ }
38
+ return out.sort(auditOrder);
39
+ }
40
+ async function openCodeDatabase(root) {
41
+ const override = process.env.OPENCODE_DB;
42
+ if (override !== undefined && (await info(override))?.isFile()) return override;
43
+ const candidates = (await entries(root)).filter(entry => entry.name.startsWith('opencode') && entry.name.endsWith('.db'))
44
+ .map(entry => join(root, entry.name)).sort(auditOrder);
45
+ return candidates.find(path => basename(path) === 'opencode.db') ?? candidates[0] ?? null;
46
+ }
47
+ async function goose(root, limit, report) {
48
+ // Native discovery quietly excludes a store it cannot read. A selected
49
+ // session that fails full normalization is instead named in report.notes.
50
+ const direct = join(root, 'sessions.db');
51
+ const path = (await info(root))?.isFile() ? root : (await info(direct))?.isFile() ? direct : join(root, 'sessions', 'sessions.db');
52
+ let rows;
53
+ try { rows = (await readForeignStore('goose', path)).result.rows; }
54
+ catch { return; }
55
+ rows.sort((a, b) => {
56
+ const left = a.updated_at_ms, right = b.updated_at_ms;
57
+ return left === right ? auditOrder(a.locator.session_id, b.locator.session_id) : left == null ? 1 : right == null ? -1 : right - left;
58
+ });
59
+ for (const row of cap(rows, limit)) {
60
+ const id = row.locator.session_id;
61
+ let session;
62
+ try { session = (await readGooseSqlite(path, id)).result; }
63
+ catch (error) { report.note(`Goose session ${id} failed to load: ${error.message}`); continue; }
64
+ report.files++;
65
+ for (const message of session.messages) {
66
+ report.lines++; report.bump(`message/${message.role.toLowerCase()}`, 'normalized');
67
+ for (const call of message.tool_calls ?? []) report.tool(call.function.name);
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Read-only corpus audit. JSONL counts selected paths before reading them;
73
+ * store formats count the native selected sessions, not database files. */
74
+ export async function auditCorpus(root, {format, limit = null} = {}) {
75
+ if (typeof root !== 'string') throw new TypeError('audit root must be a path string');
76
+ if (!AUDIT_FORMATS.includes(format)) throw new TypeError(`unknown corpus format ${format}`);
77
+ limit = nativeLimit(limit);
78
+ const report = new CorpusAuditReport(format);
79
+ if (format === 'goose') await goose(root, limit, report);
80
+ else {
81
+ const database = format === 'opencode' ? await openCodeDatabase(root) : null;
82
+ if (database != null) {
83
+ try {
84
+ const lines = await readOpenCodeCorpus(database, limit);
85
+ for (const source of lines) {
86
+ const line = nativeTrim(source); if (line === '') continue;
87
+ report.lines++; const raw = auditCorpusLine(line, report);
88
+ if (field(raw, 'key')?.[0] === 'session') report.files++;
89
+ }
90
+ } catch (error) { report.note(`opencode_sqlite_error: ${error.message}`); }
91
+ } else {
92
+ let files = await jsonlFiles(root);
93
+ if (format === 'grok') files = files.filter(path => basename(path) === 'chat_history.jsonl');
94
+ for (const path of cap(files, limit)) {
95
+ report.files++;
96
+ let source;
97
+ try { source = decodeSessionUtf8(await readFile(path)); } catch { continue; }
98
+ for (const line of source.split('\n').map(nativeTrim)) {
99
+ if (line === '') continue;
100
+ report.lines++; auditCorpusLine(line, report);
101
+ }
102
+ }
103
+ }
104
+ }
105
+ return report.toJSON();
106
+ }
107
+
108
+ /** Native-compatible report bytes, including exact integer counters and LF. */
109
+ export function corpusAuditJson(report) { return `${stringifyNativeJsonPretty(report)}\n`; }
110
+
111
+ /** The library inventory view, separate from the CLI's compact terminal view. */
112
+ export function formatCorpusAuditInventory(report) {
113
+ const symbols = {normalized:'✅ normalized', retained:'◆ retained ', dropped:'➖ dropped ', unmodeled:'❌ UNMODELED '};
114
+ const count = (row, key = 'count') => nativeInteger(row, key) ?? 0n;
115
+ const sorted = object => Object.entries(object).sort(([a], [b]) => auditOrder(a, b));
116
+ const pad = (text, width) => text + ' '.repeat(Math.max(0, width - [...text].length));
117
+ let text = `# Coverage audit: ${report.corpus ?? '?'}\nfiles=${count(report, 'files')} lines=${count(report, 'lines')} parse_errors=${count(report, 'parse_errors')}\n\n`;
118
+ text += '## Records (discriminant → coverage, count, unmodeled fields)\n';
119
+ for (const [key, row] of sorted(report.records)) text += ` ${symbols[row.coverage]} ${pad(key, 40)} ${String(count(row)).padStart(9)}${row.unmodeled_fields.length ? ` unmodeled fields: ${row.unmodeled_fields.join(', ')}` : ''}\n`;
120
+ const blocks = sorted(report.blocks).flatMap(([key, rows]) => rows.map(row => [key, row]));
121
+ if (blocks.length) { text += '\n## Content blocks\n'; for (const [key, row] of blocks) text += ` ${symbols[row.coverage]} ${pad(key, 28)} ${String(count(row)).padStart(9)}\n`; }
122
+ if (Object.keys(report.notes).length) { text += '\n## Structural notes\n'; for (const [key] of sorted(report.notes)) text += ` ${key}: ${count(report.notes, key)}\n`; }
123
+ if (Object.keys(report.tools).length) {
124
+ text += '\n## Tools observed (top 30 by frequency)\n';
125
+ const rows = sorted(report.tools).sort(([a], [b]) => count(report.tools, a) > count(report.tools, b) ? -1 : count(report.tools, a) < count(report.tools, b) ? 1 : 0);
126
+ for (const [key] of rows.slice(0, 30)) text += ` ${String(count(report.tools, key)).padStart(9)} ${key}\n`;
127
+ }
128
+ text += '\n## Summary of gaps (UNMODELED or dropped, non-UI)\n';
129
+ for (const [key, row] of sorted(report.records)) if (row.coverage === 'unmodeled') text += ` ❌ ${key} (${count(row)} occurrences) — not modeled\n`;
130
+ for (const [key, row] of blocks) if (row.coverage === 'unmodeled') text += ` ❌ content block \`${key}\` (${count(row)}) — not modeled\n`;
131
+ return text;
132
+ }
package/file-io.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { open, stat } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
2
3
  import { parseNativeJson } from './native-json.mjs';
3
4
 
4
5
  const stale = (path) => Object.assign(new Error(`${path} changed during its metadata read; previous index rows are retained`), { code: 'ESTALE' });
@@ -27,12 +28,21 @@ export function appendableFingerprint(previous, current) {
27
28
  * in place is stale, while one only appended to answers for the size it was
28
29
  * opened at. The caller owns failure retry. */
29
30
  export async function withSessionFile(path, work, metrics = {}) {
30
- const handle = await open(path, 'r');
31
+ // Native homes can use symlinks: stat follows the target just as open does.
32
+ // Refuse a known special file before opening; nonblocking admission also
33
+ // lets fstat reject a FIFO substituted after this observation without
34
+ // waiting for a writer. The descriptor and final named target still own
35
+ // identity, and an append before open keeps the existing snapshot rule.
36
+ const named = await stat(path, { bigint: true });
37
+ if (!named.isFile()) throw Object.assign(new Error(`${path} is not a session file`), { code: 'EINVAL' });
38
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
31
39
  metrics.files_opened = (metrics.files_opened ?? 0) + 1;
32
40
  let countedRead = false;
41
+ let readError;
33
42
  try {
34
43
  const before = await handle.stat({ bigint: true });
35
44
  if (!before.isFile()) throw Object.assign(new Error(`${path} is not a session file`), { code: 'EINVAL' });
45
+ if (!sameOrAppended(named, before)) throw stale(path);
36
46
  if (before.size > BigInt(Number.MAX_SAFE_INTEGER)) throw Object.assign(new Error(`${path} exceeds Node's exact file-offset range`), { code: 'EFBIG' });
37
47
  const size = Number(before.size);
38
48
  const reader = {
@@ -59,7 +69,16 @@ export async function withSessionFile(path, work, metrics = {}) {
59
69
  try { current = await stat(path, { bigint: true }); } catch { throw stale(path); }
60
70
  if (!sameOrAppended(before, after) || !sameOrAppended(before, current) || current.size < after.size) throw stale(path);
61
71
  return { result, fingerprint: reader.fingerprint, size, modified_ms: reader.modified_ms };
62
- } finally { await handle.close(); }
72
+ } catch (error) {
73
+ readError = error;
74
+ throw error;
75
+ } finally {
76
+ try { await handle.close(); }
77
+ catch (closeError) {
78
+ if (readError) throw new AggregateError([readError, closeError], `${path} session-file read and close failed`);
79
+ throw closeError;
80
+ }
81
+ }
63
82
  }
64
83
 
65
84
  export function parseJsonLine(buffer, { strictUtf8 = false } = {}) {