throughline 0.4.12 → 0.6.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.
Files changed (45) hide show
  1. package/.codex-sidecar.yml +5 -0
  2. package/CHANGELOG.md +106 -0
  3. package/README.ja.md +37 -21
  4. package/README.md +47 -26
  5. package/docs/00_overview.md +34 -0
  6. package/docs/{L1_L2_L3_REDESIGN.md → 01_l1_l2_l3_redesign.md} +3 -3
  7. package/docs/{THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md → 02_clear_auto_handoff_plan.md} +6 -6
  8. package/docs/{INHERITANCE_ON_CLEAR_ONLY.md → 03_inheritance_on_clear_only.md} +3 -3
  9. package/docs/{PUBLIC_RELEASE_PLAN.md → 04_public_release_plan.md} +3 -3
  10. package/docs/{THROUGHLINE_CODEX_FIRST_ROADMAP.md → 05_codex_first_roadmap.md} +9 -9
  11. package/docs/{THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md → 06_codex_trim_rollback_fix_plan.md} +6 -6
  12. package/docs/{THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md → 07_codex_trim_implementation_plan.md} +10 -10
  13. package/docs/{THROUGHLINE_CODEX_DUAL_SUPPORT.md → 08_codex_dual_support.md} +8 -8
  14. package/docs/{throughline-rollback-context-trim-insight.md → 09_rollback_context_trim_insight.md} +5 -5
  15. package/docs/10_transcript_injection_plan.md +446 -0
  16. package/docs/{THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md → 11_codex_monitor_implementation_plan.md} +1 -1
  17. package/docs/12_desktop_clear_handoff_plan.md +215 -0
  18. package/docs/adr/0001-claude-primary-codex-adapter.md +22 -0
  19. package/docs/archive/README.md +3 -3
  20. package/docs/archive/THROUGHLINE_NEXT_STEPS.md +3 -3
  21. package/package.json +2 -1
  22. package/rag/01-hooks/raw/hooks-reference-extract.md +250 -0
  23. package/rag/01-hooks/raw/session-end-reasons.md +21 -0
  24. package/rag/02-messages-api/raw/messages-api-extract.md +126 -0
  25. package/rag/03-settings/raw/sessions-extract.md +64 -0
  26. package/rag/04-skills/raw/initialUserMessage-investigation.md +101 -0
  27. package/rag/INDEX.md +164 -0
  28. package/src/baton.mjs +2 -2
  29. package/src/db.mjs +2 -2
  30. package/src/hook-entrypoints.test.mjs +390 -0
  31. package/src/package-files.test.mjs +1 -0
  32. package/src/prompt-submit.mjs +132 -5
  33. package/src/resume-context.mjs +23 -6
  34. package/src/resume-context.test.mjs +21 -5
  35. package/src/session-merger.mjs +1 -1
  36. package/src/session-start.mjs +155 -14
  37. package/src/spike-transcript-writer.mjs +196 -0
  38. package/src/spike-transcript-writer.test.mjs +298 -0
  39. package/src/state-file.mjs +1 -1
  40. package/src/token-monitor.mjs +1 -1
  41. package/src/transcript-reader.mjs +71 -0
  42. package/src/turn-backfill.mjs +131 -0
  43. package/src/turn-backfill.test.mjs +213 -0
  44. package/src/turn-processor.mjs +28 -40
  45. /package/docs/{throughline-codex-trim-rollback-incident-report.md → audit-2026-05/codex-trim-rollback-incident-report.md} +0 -0
@@ -0,0 +1,298 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { spikeInject, generateSpikeTracer } from './spike-transcript-writer.mjs';
8
+
9
+ function makeDb() {
10
+ const db = new DatabaseSync(':memory:');
11
+ db.exec(`
12
+ CREATE TABLE sessions (
13
+ session_id TEXT PRIMARY KEY,
14
+ project_path TEXT NOT NULL,
15
+ status TEXT NOT NULL DEFAULT 'active',
16
+ created_at INTEGER NOT NULL,
17
+ updated_at INTEGER NOT NULL,
18
+ merged_into TEXT
19
+ );
20
+ CREATE TABLE skeletons (
21
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
22
+ session_id TEXT NOT NULL,
23
+ origin_session_id TEXT,
24
+ turn_number INTEGER NOT NULL,
25
+ role TEXT NOT NULL,
26
+ summary TEXT NOT NULL,
27
+ created_at INTEGER NOT NULL
28
+ );
29
+ CREATE TABLE bodies (
30
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
31
+ session_id TEXT NOT NULL,
32
+ origin_session_id TEXT NOT NULL,
33
+ turn_number INTEGER NOT NULL,
34
+ role TEXT NOT NULL,
35
+ text TEXT NOT NULL,
36
+ token_count INTEGER,
37
+ created_at INTEGER NOT NULL
38
+ );
39
+ CREATE TABLE details (
40
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
41
+ session_id TEXT NOT NULL,
42
+ origin_session_id TEXT,
43
+ turn_number INTEGER,
44
+ tool_name TEXT NOT NULL,
45
+ input_text TEXT,
46
+ output_text TEXT,
47
+ token_count INTEGER NOT NULL DEFAULT 0,
48
+ created_at INTEGER NOT NULL,
49
+ kind TEXT,
50
+ source_id TEXT
51
+ );
52
+ `);
53
+ return db;
54
+ }
55
+
56
+ function seedSession(db, { newSessionId = 'NEW', originId = 'ORIG' } = {}) {
57
+ db.prepare(
58
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
59
+ VALUES (?, '/repo', 'active', 1, 2)`,
60
+ ).run(newSessionId);
61
+ // 2 user / 2 assistant turn を新 session 配下に origin_session_id=ORIG で配置
62
+ // (handoff-record は inheritance mode で origin != current を recent bodies に拾う)
63
+ const rows = [
64
+ { turn: 1, role: 'user', text: 'こんにちは。tracer 検証だ', createdAt: 1000 },
65
+ { turn: 2, role: 'assistant', text: '了解です。テスト用ターン 1', createdAt: 1100 },
66
+ { turn: 3, role: 'user', text: '続きを書け', createdAt: 1200 },
67
+ { turn: 4, role: 'assistant', text: '最後のアシスタント発話 (tracer 受け取り側)', createdAt: 1300 },
68
+ ];
69
+ for (const r of rows) {
70
+ db.prepare(
71
+ `INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
72
+ VALUES (?, ?, ?, ?, ?, 1, ?)`,
73
+ ).run(newSessionId, originId, r.turn, r.role, r.text, r.createdAt);
74
+ }
75
+ return rows;
76
+ }
77
+
78
+ function makeTempJsonl() {
79
+ const dir = mkdtempSync(join(tmpdir(), 'spike-writer-test-'));
80
+ return join(dir, 'session.jsonl');
81
+ }
82
+
83
+ test('generateSpikeTracer returns 8 hex chars', () => {
84
+ const t = generateSpikeTracer();
85
+ assert.match(t, /^[0-9a-f]{8}$/);
86
+ assert.notEqual(t, generateSpikeTracer()); // 衝突しない (実質)
87
+ });
88
+
89
+ test('spikeInject: returns skipReason when no recentBodies', () => {
90
+ const db = makeDb();
91
+ db.prepare(
92
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
93
+ VALUES ('NEW', '/repo', 'active', 1, 2)`,
94
+ ).run();
95
+ const target = makeTempJsonl();
96
+ const result = spikeInject({
97
+ db,
98
+ targetJsonlPath: target,
99
+ newSessionId: 'NEW',
100
+ cwd: '/repo',
101
+ version: '2.1.x',
102
+ gitBranch: 'main',
103
+ tracer: 'abcd1234',
104
+ });
105
+ assert.equal(result.appended, 0);
106
+ assert.equal(result.skipReason, 'no_record_or_empty_l2');
107
+ assert.equal(result.tracer, null);
108
+ assert.equal(result.tracerAppendedAt, null);
109
+ });
110
+
111
+ test('spikeInject: appends bodies, embeds tracer in last assistant only', () => {
112
+ const db = makeDb();
113
+ seedSession(db);
114
+ const target = makeTempJsonl();
115
+ const tracer = 'deadbeef';
116
+
117
+ const result = spikeInject({
118
+ db,
119
+ targetJsonlPath: target,
120
+ newSessionId: 'NEW',
121
+ cwd: '/repo',
122
+ version: '2.1.x',
123
+ gitBranch: 'main',
124
+ tracer,
125
+ });
126
+ assert.equal(result.appended, 4);
127
+ assert.equal(result.parentUuidStart, null); // empty file 起点
128
+ assert.equal(result.tracer, tracer);
129
+ assert.equal(result.tracerAppendedAt, 3); // index of last assistant
130
+
131
+ const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
132
+ assert.equal(lines.length, 4);
133
+
134
+ const parsed = lines.map((l) => JSON.parse(l));
135
+ // 順序 (古い順): user → assistant → user → assistant
136
+ assert.deepEqual(parsed.map((o) => o.type), ['user', 'assistant', 'user', 'assistant']);
137
+ // tracer は最後の assistant のみ
138
+ const lastAssistant = parsed[3];
139
+ assert.match(lastAssistant.message.content[0].text, /\[spike-tracer: deadbeef\]$/);
140
+ // 中間の assistant には tracer が無い
141
+ const midAssistant = parsed[1];
142
+ assert.doesNotMatch(midAssistant.message.content[0].text, /spike-tracer/);
143
+ // user 行にも無い
144
+ for (const u of [parsed[0], parsed[2]]) {
145
+ assert.doesNotMatch(u.message.content[0].text, /spike-tracer/);
146
+ }
147
+ });
148
+
149
+ test('spikeInject: omits tracer entirely when tracer=null', () => {
150
+ const db = makeDb();
151
+ seedSession(db);
152
+ const target = makeTempJsonl();
153
+
154
+ const result = spikeInject({
155
+ db,
156
+ targetJsonlPath: target,
157
+ newSessionId: 'NEW',
158
+ cwd: '/repo',
159
+ version: '2.1.x',
160
+ gitBranch: 'main',
161
+ tracer: null,
162
+ });
163
+ assert.equal(result.appended, 4);
164
+ assert.equal(result.tracer, null);
165
+ assert.equal(result.tracerAppendedAt, null);
166
+
167
+ const content = readFileSync(target, 'utf8');
168
+ assert.doesNotMatch(content, /spike-tracer/);
169
+ });
170
+
171
+ test('spikeInject: chains from last uuid when target has preexisting lines', () => {
172
+ const db = makeDb();
173
+ seedSession(db);
174
+ const target = makeTempJsonl();
175
+ const preexisting = { type: 'attachment', uuid: 'pre-attachment-uuid', parentUuid: null };
176
+ writeFileSync(target, JSON.stringify(preexisting) + '\n', 'utf8');
177
+
178
+ const result = spikeInject({
179
+ db,
180
+ targetJsonlPath: target,
181
+ newSessionId: 'NEW',
182
+ cwd: '/repo',
183
+ version: '2.1.x',
184
+ gitBranch: 'main',
185
+ tracer: 'cafef00d',
186
+ });
187
+ assert.equal(result.parentUuidStart, 'pre-attachment-uuid');
188
+
189
+ const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
190
+ // 1 件目 (preexisting) は preserve、2 件目が最初の spike user
191
+ const firstSpike = JSON.parse(lines[1]);
192
+ assert.equal(firstSpike.parentUuid, 'pre-attachment-uuid');
193
+ // chain は spike 内で繋がる
194
+ const secondSpike = JSON.parse(lines[2]);
195
+ assert.equal(secondSpike.parentUuid, firstSpike.uuid);
196
+ });
197
+
198
+ test('spikeInject: defaults assistant model to a real Claude model name', () => {
199
+ const db = makeDb();
200
+ seedSession(db);
201
+ const target = makeTempJsonl();
202
+ spikeInject({
203
+ db,
204
+ targetJsonlPath: target,
205
+ newSessionId: 'NEW',
206
+ cwd: '/repo',
207
+ version: '2.1.x',
208
+ gitBranch: 'main',
209
+ tracer: 'aabbccdd',
210
+ });
211
+ const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
212
+ const firstAssistant = JSON.parse(lines[1]); // index 1 = first assistant in [user, assistant, user, assistant]
213
+ assert.equal(firstAssistant.message.model, 'claude-opus-4-7', 'Phase 0-5 retry: real model name not fake');
214
+ });
215
+
216
+ test('spikeInject: assistantModel override is honored', () => {
217
+ const db = makeDb();
218
+ seedSession(db);
219
+ const target = makeTempJsonl();
220
+ spikeInject({
221
+ db,
222
+ targetJsonlPath: target,
223
+ newSessionId: 'NEW',
224
+ cwd: '/repo',
225
+ version: '2.1.x',
226
+ gitBranch: 'main',
227
+ tracer: '11223344',
228
+ assistantModel: 'claude-overridden-model',
229
+ });
230
+ const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
231
+ const firstAssistant = JSON.parse(lines[1]);
232
+ assert.equal(firstAssistant.message.model, 'claude-overridden-model');
233
+ });
234
+
235
+ test('spikeInject: writes Claude Code-compatible shape (top-level fields)', () => {
236
+ const db = makeDb();
237
+ seedSession(db, { newSessionId: 'NEW-SESSION' });
238
+ const target = makeTempJsonl();
239
+
240
+ spikeInject({
241
+ db,
242
+ targetJsonlPath: target,
243
+ newSessionId: 'NEW-SESSION',
244
+ cwd: '/repo',
245
+ version: '2.1.145',
246
+ gitBranch: 'main',
247
+ tracer: '00000000',
248
+ });
249
+
250
+ const lines = readFileSync(target, 'utf8').split('\n').filter((l) => l.trim());
251
+ const u = JSON.parse(lines[0]);
252
+ // user turn の必須 14 フィールド (Plan §6 リバース結果より)
253
+ for (const k of [
254
+ 'parentUuid',
255
+ 'isSidechain',
256
+ 'promptId',
257
+ 'type',
258
+ 'message',
259
+ 'uuid',
260
+ 'timestamp',
261
+ 'permissionMode',
262
+ 'userType',
263
+ 'entrypoint',
264
+ 'cwd',
265
+ 'sessionId',
266
+ 'version',
267
+ 'gitBranch',
268
+ ]) {
269
+ assert.ok(k in u, `user line missing field: ${k}`);
270
+ }
271
+ assert.equal(u.type, 'user');
272
+ assert.equal(u.sessionId, 'NEW-SESSION');
273
+ assert.equal(u.version, '2.1.145');
274
+ assert.equal(u.message.role, 'user');
275
+
276
+ const a = JSON.parse(lines[1]);
277
+ // assistant turn の必須 13 フィールド
278
+ for (const k of [
279
+ 'parentUuid',
280
+ 'isSidechain',
281
+ 'message',
282
+ 'requestId',
283
+ 'type',
284
+ 'uuid',
285
+ 'timestamp',
286
+ 'userType',
287
+ 'entrypoint',
288
+ 'cwd',
289
+ 'sessionId',
290
+ 'version',
291
+ 'gitBranch',
292
+ ]) {
293
+ assert.ok(k in a, `assistant line missing field: ${k}`);
294
+ }
295
+ assert.equal(a.type, 'assistant');
296
+ assert.equal(a.message.role, 'assistant');
297
+ assert.ok(a.message.usage);
298
+ });
@@ -5,7 +5,7 @@
5
5
  * 書き手: turn-processor (Claude Stop), codex-hook (Codex Stop)
6
6
  * 読み手: token-monitor
7
7
  *
8
- * 設計判断 (docs/PUBLIC_RELEASE_PLAN.md §4.5/4.6):
8
+ * 設計判断 (docs/04_public_release_plan.md §4.5/4.6):
9
9
  * - ファイル単位分割で last-writer-wins 問題を解消
10
10
  * - updatedAt ベースで stale 判定(短命 hook process の PID には依存しない)
11
11
  * - projectPath は path.resolve → / → 末尾 / 除去 → Windows lowercase で正規化
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * VS Code の分割ターミナルなどで常時起動しておく。
11
11
  *
12
- * 設計: docs/PUBLIC_RELEASE_PLAN.md §4.5/4.6
12
+ * 設計: docs/04_public_release_plan.md §4.5/4.6
13
13
  * - 状態ファイルはセッション単位 (~/.throughline/state/<session_id>.json)
14
14
  * - setInterval (1s) + mtime 差分検知で更新を捕捉
15
15
  * - updatedAt 降順ソート、先頭行を ▶ でハイライト
@@ -54,22 +54,93 @@ export function readTranscript(transcriptPath) {
54
54
  // user / assistant エントリのみ対象
55
55
  if (entry.type !== 'user' && entry.type !== 'assistant') continue;
56
56
 
57
+ // subagent の sidechain エントリは主会話ではないので除外。現行 CC は主 transcript に
58
+ // 書かない(400 transcript 実測ゼロ件)が、将来変更への安価な防御 (docs/12 B-1)
59
+ if (entry.isSidechain === true) continue;
60
+
57
61
  const msg = entry.message;
58
62
  if (!msg || !msg.role || msg.content == null) continue;
59
63
 
60
64
  const text = extractText(msg.content);
61
65
  if (!text) continue;
62
66
 
67
+ const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
63
68
  turns.push({
64
69
  role: msg.role,
65
70
  content: text,
66
71
  turn_number: turns.length,
72
+ timestamp: Number.isNaN(ts) ? null : ts,
67
73
  });
68
74
  }
69
75
 
70
76
  return turns;
71
77
  }
72
78
 
79
+ /**
80
+ * assistant テキスト断片が API 通知(junk)かを判定する。
81
+ * junk が論理ターン群の最終断片になると、通知を本文として保存し実回答を捨てる
82
+ * ことになるため、代表選択から除外する (docs/12 B-1 refuter 修正3)。
83
+ * パターンは実測で bodies に混入した通知に限定し、prefix 固定で偽陽性を避ける。
84
+ * @param {string} text
85
+ */
86
+ export function isJunkAssistantText(text) {
87
+ if (typeof text !== 'string') return false;
88
+ return (
89
+ text.startsWith("You've hit your session limit") ||
90
+ text.startsWith("You've reached your") ||
91
+ text.startsWith('API Error')
92
+ );
93
+ }
94
+
95
+ /**
96
+ * transcript を論理ターン群に分解する。
97
+ *
98
+ * 論理ターン群 = user テキストエントリ 1 件 + それに続く assistant テキスト断片群。
99
+ * 途中割り込み(plan 拒否・AskUserQuestion 応答等)は tool_result 内に埋まり
100
+ * readTranscript には不可視のため、1 群が複数 Stop・複数断片を含むのは日常パターン。
101
+ *
102
+ * representative = 群内最後の非 junk 断片。この index が bodies の turn_number になる
103
+ * (user 行・assistant 行とも同じ turn_number で保存する現行規約と同じ)。
104
+ * 全断片が junk の群、断片ゼロの群(assistant 本文が transcript に無い B-2 ケース)は
105
+ * 返さない。
106
+ *
107
+ * @param {string} transcriptPath
108
+ * @returns {Array<{
109
+ * user: {content: string, timestamp: number|null, turn_number: number},
110
+ * fragments: Array<{index: number, content: string, timestamp: number|null}>,
111
+ * representative: {index: number, content: string, timestamp: number|null},
112
+ * }>}
113
+ */
114
+ export function getLogicalTurnGroups(transcriptPath) {
115
+ const turns = readTranscript(transcriptPath);
116
+ const raw = [];
117
+ let current = null;
118
+ for (const t of turns) {
119
+ if (t.role === 'user') {
120
+ if (current) raw.push(current);
121
+ current = { user: t, fragments: [] };
122
+ } else if (t.role === 'assistant' && current) {
123
+ current.fragments.push({ index: t.turn_number, content: t.content, timestamp: t.timestamp });
124
+ }
125
+ }
126
+ if (current) raw.push(current);
127
+
128
+ const groups = [];
129
+ for (const g of raw) {
130
+ if (g.fragments.length === 0) continue;
131
+ let representative = null;
132
+ for (let i = g.fragments.length - 1; i >= 0; i--) {
133
+ if (!isJunkAssistantText(g.fragments[i].content)) {
134
+ representative = g.fragments[i];
135
+ break;
136
+ }
137
+ }
138
+ if (!representative) continue; // 全断片 junk
139
+ groups.push({ user: g.user, fragments: g.fragments, representative });
140
+ }
141
+ return groups;
142
+ }
143
+
73
144
  /**
74
145
  * ANSI エスケープシーケンスを除去する。
75
146
  * ツール出力(特に Bash)にしばしば含まれる色コードを剥がす。
@@ -0,0 +1,131 @@
1
+ /**
2
+ * turn-backfill.mjs — transcript 全体走査による L2 回収の共通ルーチン
3
+ *
4
+ * 従来の「Stop ごとに最後の 1 ペアのみ保存」は、Stop の空振り・不発が bodies の
5
+ * 永久穴になった(実測欠落率 Desktop 27% / VSCode 41%、docs/12 §6)。
6
+ * 本ルーチンは transcript の全論理ターン群を走査し、未捕捉の完了ターンを回収する。
7
+ * turn-processor(毎 Stop)と session-start(マージ直後の前任回収)が共用する。
8
+ *
9
+ * 設計は docs/12 Workstream B-1(refuter 修正 1/3/4/5 適用済み):
10
+ * - 群レベル dedup: 群の**どの断片 index も** bodies に無い群だけ挿入する。
11
+ * 部分捕捉済み群への再挿入は「同一 user 発話の重複ペア」を量産する
12
+ * (実測: 全 DB で 110 群が該当)ため、代表断片の差し替え回収はしない。
13
+ * - 代表断片 = 群内最後の非 junk 断片(getLogicalTurnGroups 側で選択済み)。
14
+ * - created_at は transcript エントリの timestamp。now を使うと一括回収行が
15
+ * 同一ミリ秒に潰れ、created_at 順ソート(handoff-record の L2 窓・現在地アンカー)
16
+ * の会話順が tie で不定化するため。timestamp 欠損時のみ now。
17
+ * - INSERT は 1 トランザクション(fsync 1 回、turn-processor の details と同型)。
18
+ */
19
+
20
+ import { appendFileSync, mkdirSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { dirname, join } from 'node:path';
23
+ import { getLogicalTurnGroups } from './transcript-reader.mjs';
24
+
25
+ /**
26
+ * バックフィル回収実績を ~/.throughline/logs/backfill.log に 1 行 JSON で記録する。
27
+ * @param {object} entry
28
+ */
29
+ export function logBackfill(entry) {
30
+ const path = join(homedir(), '.throughline', 'logs', 'backfill.log');
31
+ try {
32
+ mkdirSync(dirname(path), { recursive: true });
33
+ appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
34
+ } catch (err) {
35
+ const msg = err instanceof Error ? err.message : 'unknown';
36
+ process.stderr.write(`[backfill-log] ${msg}\n`);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Claude Code の project-dir munging から session transcript path を決定的に導出する。
42
+ * macOS/Linux の Claude Code 規約を mirror する。Windows では呼び出し側が state file の
43
+ * transcriptPath に fallback する。
44
+ * @param {string} projectPath
45
+ * @param {string} sessionId
46
+ * @returns {string}
47
+ */
48
+ export function deriveTranscriptPath(projectPath, sessionId) {
49
+ const mungedProjectPath = `-${String(projectPath).replace(/[/.]/g, '-').replace(/^-+/, '')}`;
50
+ return join(homedir(), '.claude', 'projects', mungedProjectPath, `${sessionId}.jsonl`);
51
+ }
52
+
53
+ /**
54
+ * transcript の未捕捉完了ターンを bodies へ回収する。
55
+ *
56
+ * @param {import('node:sqlite').DatabaseSync} db
57
+ * @param {object} opts
58
+ * @param {string} opts.targetSessionId 書き込み先 session_id(merge 済みなら合流先)
59
+ * @param {string} opts.originSessionId transcript を所有する origin session_id
60
+ * @param {string|null|undefined} opts.transcriptPath
61
+ * @param {number} opts.now timestamp 欠損時の fallback epoch ms
62
+ * @returns {{groups: number, insertedTurns: number, skippedExisting: number, lastTurnNumber: number|null}}
63
+ */
64
+ export function backfillBodies(db, { targetSessionId, originSessionId, transcriptPath, now }) {
65
+ const groups = getLogicalTurnGroups(transcriptPath);
66
+ if (groups.length === 0) {
67
+ return { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null };
68
+ }
69
+
70
+ const existing = new Set(
71
+ db
72
+ .prepare('SELECT DISTINCT turn_number FROM bodies WHERE origin_session_id = ?')
73
+ .all(originSessionId)
74
+ .map((r) => r.turn_number),
75
+ );
76
+
77
+ const insertBody = db.prepare(
78
+ `INSERT OR IGNORE INTO bodies
79
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
80
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
81
+ );
82
+
83
+ let insertedTurns = 0;
84
+ let skippedExisting = 0;
85
+
86
+ db.exec('BEGIN');
87
+ try {
88
+ for (const g of groups) {
89
+ // 群のいずれかの断片 index が既に bodies にある = 部分捕捉済み群。
90
+ // 代表差し替えは重複ペアを生むので回収しない (refuter 修正1)。
91
+ if (g.fragments.some((f) => existing.has(f.index))) {
92
+ skippedExisting++;
93
+ continue;
94
+ }
95
+ const turnNumber = g.representative.index;
96
+ const assistantAt = g.representative.timestamp ?? now;
97
+ const userAt = g.user.timestamp ?? assistantAt;
98
+
99
+ insertBody.run(
100
+ targetSessionId,
101
+ originSessionId,
102
+ turnNumber,
103
+ 'user',
104
+ g.user.content,
105
+ Math.round(g.user.content.length / 4),
106
+ userAt,
107
+ );
108
+ insertBody.run(
109
+ targetSessionId,
110
+ originSessionId,
111
+ turnNumber,
112
+ 'assistant',
113
+ g.representative.content,
114
+ Math.round(g.representative.content.length / 4),
115
+ assistantAt,
116
+ );
117
+ insertedTurns++;
118
+ }
119
+ db.exec('COMMIT');
120
+ } catch (err) {
121
+ db.exec('ROLLBACK');
122
+ throw err;
123
+ }
124
+
125
+ return {
126
+ groups: groups.length,
127
+ insertedTurns,
128
+ skippedExisting,
129
+ lastTurnNumber: groups[groups.length - 1].representative.index,
130
+ };
131
+ }