@sanqianx/ai-coding-event-bridge-console 0.7.2 → 0.7.3

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 (37) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +81 -81
  3. package/console/index.html +953 -953
  4. package/package.json +26 -26
  5. package/src/agent-detect.js +128 -128
  6. package/src/bin.js +80 -80
  7. package/src/bridge/bin/commit-boundary.js +64 -64
  8. package/src/bridge/connectors/capture-guard.js +24 -24
  9. package/src/bridge/connectors/claude-code/hook-entry.js +45 -45
  10. package/src/bridge/connectors/claude-code/notify.js +57 -57
  11. package/src/bridge/connectors/codex/cursor-store.js +83 -83
  12. package/src/bridge/connectors/codex/hook-entry.js +215 -215
  13. package/src/bridge/connectors/codex/session-parser.js +100 -100
  14. package/src/bridge/connectors/opencode/hook-entry.js +55 -55
  15. package/src/bridge/core/bridge.js +249 -246
  16. package/src/bridge/core/commit-projection.js +288 -288
  17. package/src/bridge/core/compaction.js +23 -23
  18. package/src/bridge/core/consumer-registry.js +111 -111
  19. package/src/bridge/core/event-schema.js +127 -127
  20. package/src/bridge/core/fs-utils.js +81 -81
  21. package/src/bridge/core/journal-router.js +39 -39
  22. package/src/bridge/core/journal.js +507 -507
  23. package/src/bridge/core/lock.js +96 -96
  24. package/src/bridge/core/normalizer.js +114 -114
  25. package/src/bridge/core/paths.js +12 -12
  26. package/src/bridge/core/project-registry.js +176 -176
  27. package/src/bridge/core/repo-context.js +191 -191
  28. package/src/bridge/core/runtime-home.js +189 -189
  29. package/src/bridge/core/semver.js +23 -23
  30. package/src/bridge/core/turn-identity.js +127 -127
  31. package/src/bridge/index.js +86 -86
  32. package/src/bridge/installers/claude-code/installer.js +181 -181
  33. package/src/bridge/installers/codex/installer.js +114 -114
  34. package/src/bridge/installers/opencode/installer.js +96 -96
  35. package/src/bridge/installers/zcode/installer.js +200 -200
  36. package/src/bridge/query/conversation-query.js +194 -194
  37. package/src/server.js +825 -825
@@ -1,288 +1,288 @@
1
- 'use strict';
2
-
3
- const crypto = require('crypto');
4
- const fs = require('fs');
5
- const path = require('path');
6
- const { ensureDirSync, writeTextAtomicSync } = require('./fs-utils');
7
- const { EVENT_SCHEMA, BOUNDARY_SCHEMA } = require('./journal');
8
- const { ConversationQuery } = require('../query/conversation-query');
9
-
10
- // Per-commit conversation sealing: a read-only projection of the routed
11
- // project journal. When a commit boundary lands in a registered project's
12
- // journal, the conversations belonging to that commit are frozen into
13
- // `<store>/commits/<commitSha>.md`. The journal stays the only source of
14
- // truth — sealed files are rebuildable at any time via rebuildSealedFiles.
15
-
16
- const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,64}$/;
17
-
18
- function isUncommittedBoundary(boundary) {
19
- return Boolean(boundary && boundary.meta && boundary.meta.uncommitted);
20
- }
21
-
22
- function sealDirFor(storeDir) {
23
- return path.join(storeDir, 'commits');
24
- }
25
-
26
- function sha256Hex(value) {
27
- return `sha256:${crypto.createHash('sha256').update(value, 'utf8').digest('hex')}`;
28
- }
29
-
30
- function isConversationEvent(record) {
31
- return record.schema === EVENT_SCHEMA && (record.eventType === 'user_prompt' || record.eventType === 'assistant_response');
32
- }
33
-
34
- function isBoundaryRecord(record) {
35
- return record.schema === BOUNDARY_SCHEMA;
36
- }
37
-
38
- // A fence longer than any backtick run inside the content, so bodies can
39
- // never break out of their block.
40
- function fenceFor(content) {
41
- const runs = String(content == null ? '' : content).match(/`+/g) || [];
42
- const longest = runs.reduce((max, run) => Math.max(max, run.length), 0);
43
- return '`'.repeat(Math.max(3, longest + 1));
44
- }
45
-
46
- function yamlScalar(value) {
47
- if (value === null || value === undefined) return 'null';
48
- if (typeof value === 'number') return String(value);
49
- const text = String(value);
50
- if (/^[A-Za-z0-9][A-Za-z0-9_.\/-]*$/.test(text)) return text;
51
- return `'${text.replace(/'/g, "''")}'`;
52
- }
53
-
54
- function localDay(iso) {
55
- if (!iso) return null;
56
- const d = new Date(iso);
57
- if (Number.isNaN(d.getTime())) return null;
58
- const pad = (n) => String(n).padStart(2, '0');
59
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
60
- }
61
-
62
- function localDayTime(iso) {
63
- if (!iso) return '';
64
- const d = new Date(iso);
65
- if (Number.isNaN(d.getTime())) return '';
66
- const pad = (n) => String(n).padStart(2, '0');
67
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
68
- }
69
-
70
- function renderEventBlock(label, event) {
71
- const fence = fenceFor(event.content);
72
- return [`### ${label}`, '', fence, String(event.content == null ? '' : event.content), fence, ''].join('\n');
73
- }
74
-
75
- function renderSealedMarkdown({ boundary, turns, unpaired }) {
76
- // sealedAt is derived from persisted boundary facts (never the wall clock)
77
- // so a rebuild reproduces byte-identical files.
78
- const sealedAt = boundary.capturedAt || boundary.committedAt || null;
79
- const uncommitted = isUncommittedBoundary(boundary);
80
- const lines = ['---'];
81
- if (uncommitted) {
82
- lines.push('subject: 未提交的对话');
83
- lines.push('uncommitted: true');
84
- } else {
85
- lines.push(`commitSha: ${yamlScalar(boundary.commitSha)}`);
86
- if (boundary.subject !== undefined && boundary.subject !== null) {
87
- lines.push(`subject: ${yamlScalar(boundary.subject)}`);
88
- }
89
- }
90
- if (boundary.branch !== undefined && boundary.branch !== null) lines.push(`branch: ${yamlScalar(boundary.branch)}`);
91
- if (boundary.committedAt) lines.push(`committedAt: ${yamlScalar(boundary.committedAt)}`);
92
- if (sealedAt) lines.push(`sealedAt: ${yamlScalar(sealedAt)}`);
93
- lines.push(`boundarySequence: ${yamlScalar(boundary.sequence)}`);
94
- lines.push(`previousBoundarySequence: ${yamlScalar(boundary.previousRepoBoundarySequence == null ? null : boundary.previousRepoBoundarySequence)}`);
95
- lines.push(`turnCount: ${yamlScalar(turns.length)}`);
96
- lines.push(`unpairedEventCount: ${yamlScalar(unpaired.length)}`);
97
- lines.push(`contentHash: ${yamlScalar(sha256Hex(canonicalProof({ boundary, turns, unpaired })))}`);
98
- lines.push('---', '');
99
- turns.forEach((turn, index) => {
100
- const first = (turn.userEvents[0] || turn.assistantEvents[0]);
101
- const when = localDayTime(first && first.capturedAt);
102
- const confidence = (first && first.identityConfidence) || 'unavailable';
103
- lines.push(`## Turn ${index + 1}${when ? ` — ${when}` : ''} (${turn.turnId}, ${confidence})`, '');
104
- turn.userEvents.forEach((event) => lines.push(renderEventBlock('用户', event)));
105
- turn.assistantEvents.forEach((event) => lines.push(renderEventBlock('助手', event)));
106
- });
107
- if (unpaired.length) {
108
- lines.push('## 未配对事件', '');
109
- unpaired.forEach((event) => {
110
- const label = `${event.role === 'user' ? '用户' : '助手'}${event.turnId ? ` — ${event.turnId}` : ''}`;
111
- lines.push(renderEventBlock(label, event));
112
- });
113
- }
114
- return `${lines.join('\n')}`;
115
- }
116
-
117
- // Provenance digest over the sealed bodies (aligned with the knowledge base's
118
- // frozen-snapshot hashing: content-addressed, no volatile fields).
119
- function canonicalProof({ boundary, turns, unpaired }) {
120
- return JSON.stringify({
121
- commitSha: String(boundary.commitSha),
122
- turns: turns.map((turn) => ({
123
- turnId: turn.turnId,
124
- events: turn.userEvents.concat(turn.assistantEvents).map((event) => [event.eventId, event.role, event.content])
125
- })),
126
- unpaired: unpaired.map((event) => [event.eventId, event.role, event.content])
127
- });
128
- }
129
-
130
- /**
131
- * Freeze the conversations belonging to one commit boundary into
132
- * `<sealDir>/<commitSha>.md`.
133
- *
134
- * Belongs-to rule: conversation events with sequence in
135
- * (previousRepoBoundarySequence, boundary.sequence], plus every turn that was
136
- * still open at commit time (openTurnIdsAtCommit — the user's ask is the
137
- * requirement truth even when the reply has not arrived yet). Window events
138
- * that no selected turn consumed (e.g. a reply that arrived after the previous
139
- * commit sealed its turn's prompt) are appended as 未配对事件, so every
140
- * conversation body in the journal lands in at least one sealed file.
141
- *
142
- * @returns {sealed, path?, turnCount, unpairedEventCount?, reason?}
143
- */
144
- async function sealCommitConversations({ journal, sealDir, boundary } = {}) {
145
- if (!journal) throw new Error('sealCommitConversations requires journal');
146
- if (typeof sealDir !== 'string' || !sealDir) throw new Error('sealCommitConversations requires sealDir');
147
- if (!boundary || typeof boundary !== 'object') throw new Error('sealCommitConversations requires boundary');
148
- const uncommitted = isUncommittedBoundary(boundary);
149
- const commitSha = String(boundary.commitSha || '').trim().toLowerCase();
150
- let target;
151
- if (uncommitted) {
152
- const day = localDay(boundary.capturedAt || boundary.committedAt) || 'unknown-date';
153
- if (typeof boundary.sequence !== 'number') {
154
- return { sealed: false, turnCount: 0, reason: 'boundary-sequence-missing' };
155
- }
156
- target = path.join(sealDir, `${day}-uncommitted-${boundary.sequence}.md`);
157
- } else {
158
- if (!COMMIT_SHA_PATTERN.test(commitSha)) {
159
- return { sealed: false, turnCount: 0, reason: 'no-commit-sha' };
160
- }
161
- target = path.join(sealDir, `${commitSha}.md`);
162
- }
163
- if (fs.existsSync(target)) {
164
- return { sealed: false, path: target, turnCount: 0, reason: 'already-sealed' };
165
- }
166
- if (typeof boundary.sequence !== 'number') {
167
- return { sealed: false, turnCount: 0, reason: 'boundary-sequence-missing' };
168
- }
169
- const to = boundary.sequence;
170
- const from = Math.max((typeof boundary.previousRepoBoundarySequence === 'number' ? boundary.previousRepoBoundarySequence : 0) + 1, 1);
171
-
172
- // Open turns may start before the window, so turns are assembled over the
173
- // whole prefix up to the boundary — not just the window itself.
174
- const prefix = (await journal.readEvents({ fromSequence: 1, toSequence: to })).filter(isConversationEvent);
175
- if (!prefix.length) {
176
- return { sealed: false, turnCount: 0, reason: 'no-conversations' };
177
- }
178
- const query = new ConversationQuery({ journal });
179
- // _projectTurns is package-internal reuse: the projection shares the exact
180
- // turn-assembly rule the console's browsing view uses.
181
- const allTurns = query._projectTurns(prefix);
182
- const openIds = new Set(boundary.openTurnIdsAtCommit || []);
183
- const turns = allTurns
184
- .filter((turn) => openIds.has(turn.turnId) || (turn.startSequence >= from && turn.startSequence <= to))
185
- .sort((a, b) => a.startSequence - b.startSequence);
186
-
187
- const consumed = new Set();
188
- for (const turn of turns) {
189
- for (const event of turn.userEvents.concat(turn.assistantEvents)) consumed.add(event.eventId);
190
- }
191
- const unpaired = prefix.filter((event) => event.sequence >= from && event.sequence <= to && !consumed.has(event.eventId));
192
- if (!turns.length && !unpaired.length) {
193
- return { sealed: false, turnCount: 0, reason: 'no-conversations' };
194
- }
195
-
196
- const markdown = renderSealedMarkdown({ boundary: { ...boundary, commitSha }, turns, unpaired });
197
- ensureDirSync(sealDir);
198
- writeTextAtomicSync(target, `${markdown}\n`);
199
- return { sealed: true, path: target, turnCount: turns.length, unpairedEventCount: unpaired.length };
200
- }
201
-
202
- /**
203
- * Age fuse for conversations that will never be committed: when the newest
204
- * conversation event after the last boundary is older than maxAgeDays, append
205
- * a synthetic boundary (meta.uncommitted) and seal the tail into
206
- * commits/<day>-uncommitted-<seq>.md, then trim the journal. Idempotent per
207
- * run: nothing happens while no new events arrived after the last boundary.
208
- */
209
- async function sealUncommittedTail({ journal, sealDir, maxAgeDays = 14, now = Date.now(), throughSequence = Infinity } = {}) {
210
- if (!journal) throw new Error('sealUncommittedTail requires journal');
211
- if (typeof sealDir !== 'string' || !sealDir) throw new Error('sealUncommittedTail requires sealDir');
212
- const records = await journal.readEvents({});
213
- const boundaries = records.filter(isBoundaryRecord);
214
- const lastBoundarySeq = boundaries.reduce((max, b) => Math.max(max, b.sequence), 0);
215
- const tailEvents = records.filter(isConversationEvent).filter((e) => e.sequence > lastBoundarySeq);
216
- if (!tailEvents.length) {
217
- return { sealed: false, reason: 'nothing-after-last-boundary' };
218
- }
219
- const newest = tailEvents.reduce((a, b) => (String(b.capturedAt || '') > String(a.capturedAt || '') ? b : a));
220
- const ageMs = now - Date.parse(newest.capturedAt || '');
221
- if (!Number.isFinite(ageMs) || ageMs < maxAgeDays * 86400000) {
222
- return { sealed: false, reason: 'too-young' };
223
- }
224
- const repoIdentity = newest.repoIdentity;
225
- if (!repoIdentity) {
226
- return { sealed: false, reason: 'no-repo-identity' };
227
- }
228
- const appended = await journal.appendCommitBoundary(repoIdentity, { meta: { uncommitted: true } });
229
- const boundary = {
230
- commitSha: undefined,
231
- capturedAt: newest.capturedAt,
232
- sequence: appended.sequence,
233
- openTurnIdsAtCommit: appended.openTurnIdsAtCommit,
234
- previousRepoBoundarySequence: appended.previousRepoBoundarySequence,
235
- meta: { uncommitted: true }
236
- };
237
- const result = await sealCommitConversations({ journal, sealDir, boundary });
238
- if (result.sealed || (result.reason === 'already-sealed' && fs.existsSync(result.path))) {
239
- const trim = await journal.trimThrough(Math.min(appended.sequence, throughSequence));
240
- return { ...result, trimmedThrough: trim.trimmedThrough };
241
- }
242
- return result;
243
- }
244
-
245
- /**
246
- * Boot-time reconciliation: trim the journal through the newest boundary
247
- * whose sealed file verifiably exists on disk. Heals the window between a
248
- * successful seal and a crashed trim, and adopts pre-upgrade journals that
249
- * already carry sealed commits.
250
- */
251
- async function reconcileTrim({ journal, sealDir, throughSequence = Infinity } = {}) {
252
- if (!journal) throw new Error('reconcileTrim requires journal');
253
- if (typeof sealDir !== 'string' || !sealDir) throw new Error('reconcileTrim requires sealDir');
254
- const records = await journal.readEvents({});
255
- const boundaries = records
256
- .filter(isBoundaryRecord)
257
- .filter((b) => COMMIT_SHA_PATTERN.test(String(b.commitSha || '')))
258
- .sort((a, b) => b.sequence - a.sequence);
259
- for (const boundary of boundaries) {
260
- if (fs.existsSync(path.join(sealDir, `${String(boundary.commitSha).toLowerCase()}.md`))) {
261
- const trim = await journal.trimThrough(Math.min(boundary.sequence, throughSequence));
262
- return { trimmedThrough: trim.trimmedThrough, commitSha: boundary.commitSha, reason: trim.reason };
263
- }
264
- }
265
- return { trimmedThrough: null, reason: 'no-sealed-boundary' };
266
- }
267
-
268
- /**
269
- * Re-create any missing sealed file from the journal's persisted boundary
270
- * records (disaster recovery). Existing files are left untouched, so a rebuild
271
- * never rewrites history.
272
- */
273
- async function rebuildSealedFiles({ journal, sealDir } = {}) {
274
- if (!journal) throw new Error('rebuildSealedFiles requires journal');
275
- if (typeof sealDir !== 'string' || !sealDir) throw new Error('rebuildSealedFiles requires sealDir');
276
- const boundaries = (await journal.readEvents({})).filter(isBoundaryRecord);
277
- const results = [];
278
- for (const boundary of boundaries) {
279
- results.push(await sealCommitConversations({ journal, sealDir, boundary }));
280
- }
281
- return {
282
- boundaryCount: boundaries.length,
283
- sealedCount: results.filter((result) => result.sealed).length,
284
- results
285
- };
286
- }
287
-
288
- module.exports = { sealDirFor, sealCommitConversations, rebuildSealedFiles, sealUncommittedTail, reconcileTrim };
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { ensureDirSync, writeTextAtomicSync } = require('./fs-utils');
7
+ const { EVENT_SCHEMA, BOUNDARY_SCHEMA } = require('./journal');
8
+ const { ConversationQuery } = require('../query/conversation-query');
9
+
10
+ // Per-commit conversation sealing: a read-only projection of the routed
11
+ // project journal. When a commit boundary lands in a registered project's
12
+ // journal, the conversations belonging to that commit are frozen into
13
+ // `<store>/commits/<commitSha>.md`. The journal stays the only source of
14
+ // truth — sealed files are rebuildable at any time via rebuildSealedFiles.
15
+
16
+ const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,64}$/;
17
+
18
+ function isUncommittedBoundary(boundary) {
19
+ return Boolean(boundary && boundary.meta && boundary.meta.uncommitted);
20
+ }
21
+
22
+ function sealDirFor(storeDir) {
23
+ return path.join(storeDir, 'commits');
24
+ }
25
+
26
+ function sha256Hex(value) {
27
+ return `sha256:${crypto.createHash('sha256').update(value, 'utf8').digest('hex')}`;
28
+ }
29
+
30
+ function isConversationEvent(record) {
31
+ return record.schema === EVENT_SCHEMA && (record.eventType === 'user_prompt' || record.eventType === 'assistant_response');
32
+ }
33
+
34
+ function isBoundaryRecord(record) {
35
+ return record.schema === BOUNDARY_SCHEMA;
36
+ }
37
+
38
+ // A fence longer than any backtick run inside the content, so bodies can
39
+ // never break out of their block.
40
+ function fenceFor(content) {
41
+ const runs = String(content == null ? '' : content).match(/`+/g) || [];
42
+ const longest = runs.reduce((max, run) => Math.max(max, run.length), 0);
43
+ return '`'.repeat(Math.max(3, longest + 1));
44
+ }
45
+
46
+ function yamlScalar(value) {
47
+ if (value === null || value === undefined) return 'null';
48
+ if (typeof value === 'number') return String(value);
49
+ const text = String(value);
50
+ if (/^[A-Za-z0-9][A-Za-z0-9_.\/-]*$/.test(text)) return text;
51
+ return `'${text.replace(/'/g, "''")}'`;
52
+ }
53
+
54
+ function localDay(iso) {
55
+ if (!iso) return null;
56
+ const d = new Date(iso);
57
+ if (Number.isNaN(d.getTime())) return null;
58
+ const pad = (n) => String(n).padStart(2, '0');
59
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
60
+ }
61
+
62
+ function localDayTime(iso) {
63
+ if (!iso) return '';
64
+ const d = new Date(iso);
65
+ if (Number.isNaN(d.getTime())) return '';
66
+ const pad = (n) => String(n).padStart(2, '0');
67
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
68
+ }
69
+
70
+ function renderEventBlock(label, event) {
71
+ const fence = fenceFor(event.content);
72
+ return [`### ${label}`, '', fence, String(event.content == null ? '' : event.content), fence, ''].join('\n');
73
+ }
74
+
75
+ function renderSealedMarkdown({ boundary, turns, unpaired }) {
76
+ // sealedAt is derived from persisted boundary facts (never the wall clock)
77
+ // so a rebuild reproduces byte-identical files.
78
+ const sealedAt = boundary.capturedAt || boundary.committedAt || null;
79
+ const uncommitted = isUncommittedBoundary(boundary);
80
+ const lines = ['---'];
81
+ if (uncommitted) {
82
+ lines.push('subject: 未提交的对话');
83
+ lines.push('uncommitted: true');
84
+ } else {
85
+ lines.push(`commitSha: ${yamlScalar(boundary.commitSha)}`);
86
+ if (boundary.subject !== undefined && boundary.subject !== null) {
87
+ lines.push(`subject: ${yamlScalar(boundary.subject)}`);
88
+ }
89
+ }
90
+ if (boundary.branch !== undefined && boundary.branch !== null) lines.push(`branch: ${yamlScalar(boundary.branch)}`);
91
+ if (boundary.committedAt) lines.push(`committedAt: ${yamlScalar(boundary.committedAt)}`);
92
+ if (sealedAt) lines.push(`sealedAt: ${yamlScalar(sealedAt)}`);
93
+ lines.push(`boundarySequence: ${yamlScalar(boundary.sequence)}`);
94
+ lines.push(`previousBoundarySequence: ${yamlScalar(boundary.previousRepoBoundarySequence == null ? null : boundary.previousRepoBoundarySequence)}`);
95
+ lines.push(`turnCount: ${yamlScalar(turns.length)}`);
96
+ lines.push(`unpairedEventCount: ${yamlScalar(unpaired.length)}`);
97
+ lines.push(`contentHash: ${yamlScalar(sha256Hex(canonicalProof({ boundary, turns, unpaired })))}`);
98
+ lines.push('---', '');
99
+ turns.forEach((turn, index) => {
100
+ const first = (turn.userEvents[0] || turn.assistantEvents[0]);
101
+ const when = localDayTime(first && first.capturedAt);
102
+ const confidence = (first && first.identityConfidence) || 'unavailable';
103
+ lines.push(`## Turn ${index + 1}${when ? ` — ${when}` : ''} (${turn.turnId}, ${confidence})`, '');
104
+ turn.userEvents.forEach((event) => lines.push(renderEventBlock('用户', event)));
105
+ turn.assistantEvents.forEach((event) => lines.push(renderEventBlock('助手', event)));
106
+ });
107
+ if (unpaired.length) {
108
+ lines.push('## 未配对事件', '');
109
+ unpaired.forEach((event) => {
110
+ const label = `${event.role === 'user' ? '用户' : '助手'}${event.turnId ? ` — ${event.turnId}` : ''}`;
111
+ lines.push(renderEventBlock(label, event));
112
+ });
113
+ }
114
+ return `${lines.join('\n')}`;
115
+ }
116
+
117
+ // Provenance digest over the sealed bodies (aligned with the knowledge base's
118
+ // frozen-snapshot hashing: content-addressed, no volatile fields).
119
+ function canonicalProof({ boundary, turns, unpaired }) {
120
+ return JSON.stringify({
121
+ commitSha: String(boundary.commitSha),
122
+ turns: turns.map((turn) => ({
123
+ turnId: turn.turnId,
124
+ events: turn.userEvents.concat(turn.assistantEvents).map((event) => [event.eventId, event.role, event.content])
125
+ })),
126
+ unpaired: unpaired.map((event) => [event.eventId, event.role, event.content])
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Freeze the conversations belonging to one commit boundary into
132
+ * `<sealDir>/<commitSha>.md`.
133
+ *
134
+ * Belongs-to rule: conversation events with sequence in
135
+ * (previousRepoBoundarySequence, boundary.sequence], plus every turn that was
136
+ * still open at commit time (openTurnIdsAtCommit — the user's ask is the
137
+ * requirement truth even when the reply has not arrived yet). Window events
138
+ * that no selected turn consumed (e.g. a reply that arrived after the previous
139
+ * commit sealed its turn's prompt) are appended as 未配对事件, so every
140
+ * conversation body in the journal lands in at least one sealed file.
141
+ *
142
+ * @returns {sealed, path?, turnCount, unpairedEventCount?, reason?}
143
+ */
144
+ async function sealCommitConversations({ journal, sealDir, boundary } = {}) {
145
+ if (!journal) throw new Error('sealCommitConversations requires journal');
146
+ if (typeof sealDir !== 'string' || !sealDir) throw new Error('sealCommitConversations requires sealDir');
147
+ if (!boundary || typeof boundary !== 'object') throw new Error('sealCommitConversations requires boundary');
148
+ const uncommitted = isUncommittedBoundary(boundary);
149
+ const commitSha = String(boundary.commitSha || '').trim().toLowerCase();
150
+ let target;
151
+ if (uncommitted) {
152
+ const day = localDay(boundary.capturedAt || boundary.committedAt) || 'unknown-date';
153
+ if (typeof boundary.sequence !== 'number') {
154
+ return { sealed: false, turnCount: 0, reason: 'boundary-sequence-missing' };
155
+ }
156
+ target = path.join(sealDir, `${day}-uncommitted-${boundary.sequence}.md`);
157
+ } else {
158
+ if (!COMMIT_SHA_PATTERN.test(commitSha)) {
159
+ return { sealed: false, turnCount: 0, reason: 'no-commit-sha' };
160
+ }
161
+ target = path.join(sealDir, `${commitSha}.md`);
162
+ }
163
+ if (fs.existsSync(target)) {
164
+ return { sealed: false, path: target, turnCount: 0, reason: 'already-sealed' };
165
+ }
166
+ if (typeof boundary.sequence !== 'number') {
167
+ return { sealed: false, turnCount: 0, reason: 'boundary-sequence-missing' };
168
+ }
169
+ const to = boundary.sequence;
170
+ const from = Math.max((typeof boundary.previousRepoBoundarySequence === 'number' ? boundary.previousRepoBoundarySequence : 0) + 1, 1);
171
+
172
+ // Open turns may start before the window, so turns are assembled over the
173
+ // whole prefix up to the boundary — not just the window itself.
174
+ const prefix = (await journal.readEvents({ fromSequence: 1, toSequence: to })).filter(isConversationEvent);
175
+ if (!prefix.length) {
176
+ return { sealed: false, turnCount: 0, reason: 'no-conversations' };
177
+ }
178
+ const query = new ConversationQuery({ journal });
179
+ // _projectTurns is package-internal reuse: the projection shares the exact
180
+ // turn-assembly rule the console's browsing view uses.
181
+ const allTurns = query._projectTurns(prefix);
182
+ const openIds = new Set(boundary.openTurnIdsAtCommit || []);
183
+ const turns = allTurns
184
+ .filter((turn) => openIds.has(turn.turnId) || (turn.startSequence >= from && turn.startSequence <= to))
185
+ .sort((a, b) => a.startSequence - b.startSequence);
186
+
187
+ const consumed = new Set();
188
+ for (const turn of turns) {
189
+ for (const event of turn.userEvents.concat(turn.assistantEvents)) consumed.add(event.eventId);
190
+ }
191
+ const unpaired = prefix.filter((event) => event.sequence >= from && event.sequence <= to && !consumed.has(event.eventId));
192
+ if (!turns.length && !unpaired.length) {
193
+ return { sealed: false, turnCount: 0, reason: 'no-conversations' };
194
+ }
195
+
196
+ const markdown = renderSealedMarkdown({ boundary: { ...boundary, commitSha }, turns, unpaired });
197
+ ensureDirSync(sealDir);
198
+ writeTextAtomicSync(target, `${markdown}\n`);
199
+ return { sealed: true, path: target, turnCount: turns.length, unpairedEventCount: unpaired.length };
200
+ }
201
+
202
+ /**
203
+ * Age fuse for conversations that will never be committed: when the newest
204
+ * conversation event after the last boundary is older than maxAgeDays, append
205
+ * a synthetic boundary (meta.uncommitted) and seal the tail into
206
+ * commits/<day>-uncommitted-<seq>.md, then trim the journal. Idempotent per
207
+ * run: nothing happens while no new events arrived after the last boundary.
208
+ */
209
+ async function sealUncommittedTail({ journal, sealDir, maxAgeDays = 14, now = Date.now(), throughSequence = Infinity } = {}) {
210
+ if (!journal) throw new Error('sealUncommittedTail requires journal');
211
+ if (typeof sealDir !== 'string' || !sealDir) throw new Error('sealUncommittedTail requires sealDir');
212
+ const records = await journal.readEvents({});
213
+ const boundaries = records.filter(isBoundaryRecord);
214
+ const lastBoundarySeq = boundaries.reduce((max, b) => Math.max(max, b.sequence), 0);
215
+ const tailEvents = records.filter(isConversationEvent).filter((e) => e.sequence > lastBoundarySeq);
216
+ if (!tailEvents.length) {
217
+ return { sealed: false, reason: 'nothing-after-last-boundary' };
218
+ }
219
+ const newest = tailEvents.reduce((a, b) => (String(b.capturedAt || '') > String(a.capturedAt || '') ? b : a));
220
+ const ageMs = now - Date.parse(newest.capturedAt || '');
221
+ if (!Number.isFinite(ageMs) || ageMs < maxAgeDays * 86400000) {
222
+ return { sealed: false, reason: 'too-young' };
223
+ }
224
+ const repoIdentity = newest.repoIdentity;
225
+ if (!repoIdentity) {
226
+ return { sealed: false, reason: 'no-repo-identity' };
227
+ }
228
+ const appended = await journal.appendCommitBoundary(repoIdentity, { meta: { uncommitted: true } });
229
+ const boundary = {
230
+ commitSha: undefined,
231
+ capturedAt: newest.capturedAt,
232
+ sequence: appended.sequence,
233
+ openTurnIdsAtCommit: appended.openTurnIdsAtCommit,
234
+ previousRepoBoundarySequence: appended.previousRepoBoundarySequence,
235
+ meta: { uncommitted: true }
236
+ };
237
+ const result = await sealCommitConversations({ journal, sealDir, boundary });
238
+ if (result.sealed || (result.reason === 'already-sealed' && fs.existsSync(result.path))) {
239
+ const trim = await journal.trimThrough(Math.min(appended.sequence, throughSequence));
240
+ return { ...result, trimmedThrough: trim.trimmedThrough };
241
+ }
242
+ return result;
243
+ }
244
+
245
+ /**
246
+ * Boot-time reconciliation: trim the journal through the newest boundary
247
+ * whose sealed file verifiably exists on disk. Heals the window between a
248
+ * successful seal and a crashed trim, and adopts pre-upgrade journals that
249
+ * already carry sealed commits.
250
+ */
251
+ async function reconcileTrim({ journal, sealDir, throughSequence = Infinity } = {}) {
252
+ if (!journal) throw new Error('reconcileTrim requires journal');
253
+ if (typeof sealDir !== 'string' || !sealDir) throw new Error('reconcileTrim requires sealDir');
254
+ const records = await journal.readEvents({});
255
+ const boundaries = records
256
+ .filter(isBoundaryRecord)
257
+ .filter((b) => COMMIT_SHA_PATTERN.test(String(b.commitSha || '')))
258
+ .sort((a, b) => b.sequence - a.sequence);
259
+ for (const boundary of boundaries) {
260
+ if (fs.existsSync(path.join(sealDir, `${String(boundary.commitSha).toLowerCase()}.md`))) {
261
+ const trim = await journal.trimThrough(Math.min(boundary.sequence, throughSequence));
262
+ return { trimmedThrough: trim.trimmedThrough, commitSha: boundary.commitSha, reason: trim.reason };
263
+ }
264
+ }
265
+ return { trimmedThrough: null, reason: 'no-sealed-boundary' };
266
+ }
267
+
268
+ /**
269
+ * Re-create any missing sealed file from the journal's persisted boundary
270
+ * records (disaster recovery). Existing files are left untouched, so a rebuild
271
+ * never rewrites history.
272
+ */
273
+ async function rebuildSealedFiles({ journal, sealDir } = {}) {
274
+ if (!journal) throw new Error('rebuildSealedFiles requires journal');
275
+ if (typeof sealDir !== 'string' || !sealDir) throw new Error('rebuildSealedFiles requires sealDir');
276
+ const boundaries = (await journal.readEvents({})).filter(isBoundaryRecord);
277
+ const results = [];
278
+ for (const boundary of boundaries) {
279
+ results.push(await sealCommitConversations({ journal, sealDir, boundary }));
280
+ }
281
+ return {
282
+ boundaryCount: boundaries.length,
283
+ sealedCount: results.filter((result) => result.sealed).length,
284
+ results
285
+ };
286
+ }
287
+
288
+ module.exports = { sealDirFor, sealCommitConversations, rebuildSealedFiles, sealUncommittedTail, reconcileTrim };
@@ -1,23 +1,23 @@
1
- 'use strict';
2
-
3
- /**
4
- * Compact the durable journal prefix that every registered consumer has acked.
5
- * Never removes data beyond minAck; requires at least one registered consumer;
6
- * runs under the same journal lock as appendEvent/appendCommitBoundary.
7
- */
8
- async function compactJournal(journal, consumerRegistry, { throughSequence, journalId = 'global' } = {}) {
9
- const minAck = await consumerRegistry.getMinAck({ journalId });
10
- if (minAck === null || minAck === undefined) {
11
- return { compactedThrough: null, removedCount: 0, reason: 'no-consumers' };
12
- }
13
- const target = Math.min(minAck, throughSequence === undefined ? minAck : throughSequence);
14
- return journal._withJournalLock(async () => {
15
- if (target < journal.firstSequence) {
16
- return { compactedThrough: journal.firstSequence - 1, removedCount: 0, reason: 'already-compacted' };
17
- }
18
- const removedCount = journal._compactTo(target);
19
- return { compactedThrough: target, removedCount, reason: 'compacted' };
20
- });
21
- }
22
-
23
- module.exports = { compactJournal };
1
+ 'use strict';
2
+
3
+ /**
4
+ * Compact the durable journal prefix that every registered consumer has acked.
5
+ * Never removes data beyond minAck; requires at least one registered consumer;
6
+ * runs under the same journal lock as appendEvent/appendCommitBoundary.
7
+ */
8
+ async function compactJournal(journal, consumerRegistry, { throughSequence, journalId = 'global' } = {}) {
9
+ const minAck = await consumerRegistry.getMinAck({ journalId });
10
+ if (minAck === null || minAck === undefined) {
11
+ return { compactedThrough: null, removedCount: 0, reason: 'no-consumers' };
12
+ }
13
+ const target = Math.min(minAck, throughSequence === undefined ? minAck : throughSequence);
14
+ return journal._withJournalLock(async () => {
15
+ if (target < journal.firstSequence) {
16
+ return { compactedThrough: journal.firstSequence - 1, removedCount: 0, reason: 'already-compacted' };
17
+ }
18
+ const removedCount = journal._compactTo(target);
19
+ return { compactedThrough: target, removedCount, reason: 'compacted' };
20
+ });
21
+ }
22
+
23
+ module.exports = { compactJournal };