throughline 0.5.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codex-sidecar.yml +5 -0
- package/CHANGELOG.md +60 -2
- package/README.ja.md +37 -21
- package/README.md +79 -29
- package/bin/throughline.mjs +10 -0
- package/docs/00_overview.md +34 -0
- package/docs/{L1_L2_L3_REDESIGN.md → 01_l1_l2_l3_redesign.md} +3 -3
- package/docs/{THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md → 02_clear_auto_handoff_plan.md} +6 -6
- package/docs/{INHERITANCE_ON_CLEAR_ONLY.md → 03_inheritance_on_clear_only.md} +3 -3
- package/docs/{PUBLIC_RELEASE_PLAN.md → 04_public_release_plan.md} +3 -3
- package/docs/{THROUGHLINE_CODEX_FIRST_ROADMAP.md → 05_codex_first_roadmap.md} +9 -9
- package/docs/{THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md → 06_codex_trim_rollback_fix_plan.md} +6 -6
- package/docs/{THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md → 07_codex_trim_implementation_plan.md} +10 -10
- package/docs/{THROUGHLINE_CODEX_DUAL_SUPPORT.md → 08_codex_dual_support.md} +8 -8
- package/docs/{throughline-rollback-context-trim-insight.md → 09_rollback_context_trim_insight.md} +5 -5
- package/docs/{THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md → 10_transcript_injection_plan.md} +6 -6
- package/docs/{THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md → 11_codex_monitor_implementation_plan.md} +1 -1
- package/docs/12_desktop_clear_handoff_plan.md +215 -0
- package/docs/adr/0001-claude-primary-codex-adapter.md +22 -0
- package/docs/archive/README.md +3 -3
- package/docs/archive/THROUGHLINE_NEXT_STEPS.md +3 -3
- package/package.json +2 -1
- package/rag/01-hooks/raw/session-end-reasons.md +21 -0
- package/{docs/RAG → rag}/INDEX.md +20 -16
- package/src/auditor-context.mjs +330 -0
- package/src/auditor-context.test.mjs +296 -0
- package/src/baton.mjs +2 -2
- package/src/cli/auditor-context.mjs +141 -0
- package/src/cli/auditor-context.test.mjs +148 -0
- package/src/cli/codex-hook.mjs +20 -0
- package/src/codex-thread-index.mjs +11 -2
- package/src/db.mjs +2 -2
- package/src/hook-entrypoints.test.mjs +102 -0
- package/src/package-files.test.mjs +1 -0
- package/src/phase0-spotter-contract.test.mjs +280 -0
- package/src/prompt-submit.mjs +2 -2
- package/src/resume-context.mjs +1 -1
- package/src/session-merger.mjs +1 -1
- package/src/session-start.mjs +62 -3
- package/src/spike-transcript-writer.mjs +1 -1
- package/src/state-file.mjs +1 -1
- package/src/token-monitor.mjs +1 -1
- package/src/transcript-reader.mjs +71 -0
- package/src/turn-backfill.mjs +131 -0
- package/src/turn-backfill.test.mjs +213 -0
- package/src/turn-processor.mjs +28 -40
- /package/docs/{throughline-codex-trim-rollback-incident-report.md → audit-2026-05/codex-trim-rollback-incident-report.md} +0 -0
- /package/{docs/RAG/_raw/01-hooks → rag/01-hooks/raw}/hooks-reference-extract.md +0 -0
- /package/{docs/RAG/_raw/02-messages-api → rag/02-messages-api/raw}/messages-api-extract.md +0 -0
- /package/{docs/RAG/_raw/03-settings → rag/03-settings/raw}/sessions-extract.md +0 -0
- /package/{docs/RAG/_raw/04-skills → rag/04-skills/raw}/initialUserMessage-investigation.md +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir, homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { backfillBodies, deriveTranscriptPath } from './turn-backfill.mjs';
|
|
8
|
+
|
|
9
|
+
function makeDb() {
|
|
10
|
+
const db = new DatabaseSync(':memory:');
|
|
11
|
+
db.exec(`
|
|
12
|
+
CREATE TABLE bodies (
|
|
13
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
14
|
+
session_id TEXT NOT NULL,
|
|
15
|
+
origin_session_id TEXT NOT NULL,
|
|
16
|
+
turn_number INTEGER NOT NULL,
|
|
17
|
+
role TEXT NOT NULL,
|
|
18
|
+
text TEXT NOT NULL,
|
|
19
|
+
token_count INTEGER,
|
|
20
|
+
created_at INTEGER NOT NULL,
|
|
21
|
+
UNIQUE(session_id, origin_session_id, turn_number, role)
|
|
22
|
+
);
|
|
23
|
+
`);
|
|
24
|
+
return db;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function withData(entries, fn) {
|
|
28
|
+
const dir = mkdtempSync(join(tmpdir(), 'tl-backfill-'));
|
|
29
|
+
const path = join(dir, 'data.jsonl');
|
|
30
|
+
writeFileSync(path, entries.map((entry) => JSON.stringify(entry)).join('\n'), 'utf8');
|
|
31
|
+
try {
|
|
32
|
+
return fn(path);
|
|
33
|
+
} finally {
|
|
34
|
+
rmSync(dir, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function entry(role, text, timestamp, extra = {}) {
|
|
39
|
+
return {
|
|
40
|
+
type: role,
|
|
41
|
+
...(timestamp === undefined ? {} : { timestamp }),
|
|
42
|
+
...extra,
|
|
43
|
+
message: { role, content: [{ type: 'text', text }] },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
test('backfillBodies: multi-fragment group uses last fragment index for both body rows', () => {
|
|
48
|
+
withData(
|
|
49
|
+
[
|
|
50
|
+
entry('user', 'question one'),
|
|
51
|
+
entry('assistant', 'first fragment'),
|
|
52
|
+
entry('assistant', 'last fragment'),
|
|
53
|
+
entry('user', 'question two'),
|
|
54
|
+
entry('assistant', 'second answer'),
|
|
55
|
+
],
|
|
56
|
+
(path) => {
|
|
57
|
+
const db = makeDb();
|
|
58
|
+
const result = backfillBodies(db, {
|
|
59
|
+
targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
|
|
60
|
+
});
|
|
61
|
+
assert.deepEqual(result, { groups: 2, insertedTurns: 2, skippedExisting: 0, lastTurnNumber: 4 });
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
db.prepare('SELECT turn_number, role, text FROM bodies ORDER BY turn_number, role').all().map((row) => ({ ...row })),
|
|
64
|
+
[
|
|
65
|
+
{ turn_number: 2, role: 'assistant', text: 'last fragment' },
|
|
66
|
+
{ turn_number: 2, role: 'user', text: 'question one' },
|
|
67
|
+
{ turn_number: 4, role: 'assistant', text: 'second answer' },
|
|
68
|
+
{ turn_number: 4, role: 'user', text: 'question two' },
|
|
69
|
+
],
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('backfillBodies: junk final fragment falls back and all-junk group is dropped', () => {
|
|
76
|
+
withData(
|
|
77
|
+
[
|
|
78
|
+
entry('user', 'keep this'),
|
|
79
|
+
entry('assistant', 'real answer'),
|
|
80
|
+
entry('assistant', "You've hit your session limit. Please try again later."),
|
|
81
|
+
entry('user', 'drop this'),
|
|
82
|
+
entry('assistant', 'API Error: unavailable'),
|
|
83
|
+
],
|
|
84
|
+
(path) => {
|
|
85
|
+
const db = makeDb();
|
|
86
|
+
const result = backfillBodies(db, {
|
|
87
|
+
targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
|
|
88
|
+
});
|
|
89
|
+
assert.equal(result.groups, 1);
|
|
90
|
+
assert.equal(result.insertedTurns, 1);
|
|
91
|
+
assert.deepEqual(
|
|
92
|
+
db.prepare("SELECT turn_number, text FROM bodies WHERE role = 'assistant'").all().map((row) => ({ ...row })),
|
|
93
|
+
[{ turn_number: 1, text: 'real answer' }],
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('backfillBodies: user-only group is dropped', () => {
|
|
100
|
+
withData([entry('user', 'unanswered')], (path) => {
|
|
101
|
+
const db = makeDb();
|
|
102
|
+
assert.deepEqual(
|
|
103
|
+
backfillBodies(db, {
|
|
104
|
+
targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
|
|
105
|
+
}),
|
|
106
|
+
{ groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null },
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('backfillBodies: a single pre-seeded fragment skips its whole group while other groups insert', () => {
|
|
112
|
+
withData(
|
|
113
|
+
[
|
|
114
|
+
entry('user', 'first question'),
|
|
115
|
+
entry('assistant', 'first fragment'),
|
|
116
|
+
entry('assistant', 'last first fragment'),
|
|
117
|
+
entry('user', 'second question'),
|
|
118
|
+
entry('assistant', 'second answer'),
|
|
119
|
+
],
|
|
120
|
+
(path) => {
|
|
121
|
+
const db = makeDb();
|
|
122
|
+
db.prepare(
|
|
123
|
+
`INSERT INTO bodies
|
|
124
|
+
(session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
125
|
+
VALUES ('existing-target', 'origin', 1, 'assistant', 'existing fragment', 1, 1)`,
|
|
126
|
+
).run();
|
|
127
|
+
const result = backfillBodies(db, {
|
|
128
|
+
targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
|
|
129
|
+
});
|
|
130
|
+
assert.equal(result.insertedTurns, 1);
|
|
131
|
+
assert.equal(result.skippedExisting, 1);
|
|
132
|
+
assert.equal(
|
|
133
|
+
db.prepare("SELECT COUNT(*) AS count FROM bodies WHERE text = 'last first fragment'").get().count,
|
|
134
|
+
0,
|
|
135
|
+
);
|
|
136
|
+
assert.equal(
|
|
137
|
+
db.prepare("SELECT COUNT(*) AS count FROM bodies WHERE turn_number = 4").get().count,
|
|
138
|
+
2,
|
|
139
|
+
);
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('backfillBodies: second run is idempotent', () => {
|
|
145
|
+
withData([entry('user', 'question'), entry('assistant', 'answer')], (path) => {
|
|
146
|
+
const db = makeDb();
|
|
147
|
+
backfillBodies(db, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999 });
|
|
148
|
+
assert.equal(
|
|
149
|
+
backfillBodies(db, {
|
|
150
|
+
targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
|
|
151
|
+
}).insertedTurns,
|
|
152
|
+
0,
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('backfillBodies: transcript timestamps are retained and absent timestamps use now', () => {
|
|
158
|
+
withData(
|
|
159
|
+
[
|
|
160
|
+
entry('user', 'dated question', '2026-01-02T03:04:05.000Z'),
|
|
161
|
+
entry('assistant', 'dated answer', '2026-01-02T03:04:06.000Z'),
|
|
162
|
+
entry('user', 'undated question'),
|
|
163
|
+
entry('assistant', 'undated answer'),
|
|
164
|
+
],
|
|
165
|
+
(path) => {
|
|
166
|
+
const db = makeDb();
|
|
167
|
+
backfillBodies(db, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 777 });
|
|
168
|
+
assert.deepEqual(
|
|
169
|
+
db.prepare('SELECT turn_number, role, created_at FROM bodies ORDER BY turn_number, role').all().map((row) => ({ ...row })),
|
|
170
|
+
[
|
|
171
|
+
{ turn_number: 1, role: 'assistant', created_at: Date.parse('2026-01-02T03:04:06.000Z') },
|
|
172
|
+
{ turn_number: 1, role: 'user', created_at: Date.parse('2026-01-02T03:04:05.000Z') },
|
|
173
|
+
{ turn_number: 3, role: 'assistant', created_at: 777 },
|
|
174
|
+
{ turn_number: 3, role: 'user', created_at: 777 },
|
|
175
|
+
],
|
|
176
|
+
);
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('backfillBodies: sidechain entries and missing or empty paths produce no groups', () => {
|
|
182
|
+
withData(
|
|
183
|
+
[entry('user', 'side question', undefined, { isSidechain: true }), entry('assistant', 'side answer', undefined, { isSidechain: true })],
|
|
184
|
+
(path) => {
|
|
185
|
+
const db = makeDb();
|
|
186
|
+
assert.equal(
|
|
187
|
+
backfillBodies(db, {
|
|
188
|
+
targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999,
|
|
189
|
+
}).groups,
|
|
190
|
+
0,
|
|
191
|
+
);
|
|
192
|
+
assert.deepEqual(
|
|
193
|
+
backfillBodies(db, {
|
|
194
|
+
targetSessionId: 'target', originSessionId: 'origin', transcriptPath: null, now: 999,
|
|
195
|
+
}),
|
|
196
|
+
{ groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null },
|
|
197
|
+
);
|
|
198
|
+
assert.deepEqual(
|
|
199
|
+
backfillBodies(db, {
|
|
200
|
+
targetSessionId: 'target', originSessionId: 'origin', transcriptPath: '', now: 999,
|
|
201
|
+
}),
|
|
202
|
+
{ groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null },
|
|
203
|
+
);
|
|
204
|
+
},
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('deriveTranscriptPath munges slash and dot characters with one leading dash', () => {
|
|
209
|
+
assert.equal(
|
|
210
|
+
deriveTranscriptPath('/Users/example/Developer/Through.line', 'session-id'),
|
|
211
|
+
join(homedir(), '.claude', 'projects', '-Users-example-Developer-Through-line', 'session-id.jsonl'),
|
|
212
|
+
);
|
|
213
|
+
});
|
package/src/turn-processor.mjs
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* (Haiku 要約用の claude -p subprocess 内で自分自身の Stop hook として起動された場合)
|
|
9
9
|
* 1. resolveMergeTarget で「実書き込み先 (target) / origin」を解決
|
|
10
10
|
* (input session が別セッションに合流済みなら合流先に書く)
|
|
11
|
-
* 2.
|
|
12
|
-
*
|
|
11
|
+
* 2. transcript 全体を論理ターン群で走査し、未捕捉の完了ターンを bodies へ
|
|
12
|
+
* 一括回収する (turn-backfill.mjs、docs/12 B-1)。回収実績は backfill.log に記録
|
|
13
|
+
* 3. (旧: 最後の 1 ペアのみ保存 — Stop 空振りで永久穴が生じたため全走査化)
|
|
13
14
|
* 4. 【遅延要約】target 配下の bodies ターン数 (distinct origin×turn) が
|
|
14
15
|
* WINDOW (=20) を超えていたら、最古の未要約ターンを 1 件だけ
|
|
15
16
|
* Haiku 4.5 で要約 → skeletons (L1) に INSERT。
|
|
@@ -31,11 +32,11 @@
|
|
|
31
32
|
|
|
32
33
|
import { getDb } from './db.mjs';
|
|
33
34
|
import {
|
|
34
|
-
getLastTurnPair,
|
|
35
35
|
readRawEntries,
|
|
36
36
|
sliceCurrentTurnEntries,
|
|
37
37
|
extractDetailBlocks,
|
|
38
38
|
} from './transcript-reader.mjs';
|
|
39
|
+
import { backfillBodies, logBackfill } from './turn-backfill.mjs';
|
|
39
40
|
import { resolveMergeTarget } from './session-merger.mjs';
|
|
40
41
|
import { writeSessionState } from './state-file.mjs';
|
|
41
42
|
import { summarizeToL1 } from './haiku-summarizer.mjs';
|
|
@@ -160,46 +161,33 @@ export async function run() {
|
|
|
160
161
|
db.prepare('UPDATE sessions SET updated_at = ? WHERE session_id = ?').run(now, target);
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
// L2 = transcript 全体を論理ターン群で走査し、未捕捉の完了ターンを一括回収する。
|
|
165
|
+
// 従来の「最後の 1 ペアのみ保存」は Stop の空振り・不発が永久穴になった (docs/12 B-1)。
|
|
166
|
+
// user / assistant は「1 往復 = 1 ターン」として同じ turn_number(= 代表 assistant
|
|
167
|
+
// 断片の index)でペアリングされ、bodies と skeletons が同じ turn_number で突合できる。
|
|
168
|
+
const backfill = backfillBodies(db, {
|
|
169
|
+
targetSessionId: target,
|
|
170
|
+
originSessionId: origin,
|
|
171
|
+
transcriptPath: transcript_path,
|
|
172
|
+
now,
|
|
173
|
+
});
|
|
174
|
+
logBackfill({
|
|
175
|
+
ts: new Date(now).toISOString(),
|
|
176
|
+
hook: 'stop',
|
|
177
|
+
session_id,
|
|
178
|
+
target,
|
|
179
|
+
origin,
|
|
180
|
+
transcript_path: transcript_path ?? null,
|
|
181
|
+
groups: backfill.groups,
|
|
182
|
+
inserted_turns: backfill.insertedTurns,
|
|
183
|
+
skipped_existing: backfill.skippedExisting,
|
|
184
|
+
});
|
|
185
|
+
if (backfill.lastTurnNumber === null) {
|
|
186
|
+
// /clear 直後などでトランスクリプトに完了ターンが無い場合は何もしない
|
|
167
187
|
process.exit(0);
|
|
168
188
|
}
|
|
169
189
|
|
|
170
|
-
const turnNumber =
|
|
171
|
-
|
|
172
|
-
// L2 = bodies に user / assistant を個別行で保存
|
|
173
|
-
const insertBody = db.prepare(
|
|
174
|
-
`INSERT OR IGNORE INTO bodies
|
|
175
|
-
(session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
176
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
177
|
-
);
|
|
178
|
-
// user / assistant を「1 往復 = 1 ターン」として扱うため、同じ turn_number
|
|
179
|
-
// (= assistant 側の turn_number)でペアリングして保存する。
|
|
180
|
-
// これにより bodies と skeletons が同じ turn_number で突合できる。
|
|
181
|
-
if (userTurn?.content) {
|
|
182
|
-
insertBody.run(
|
|
183
|
-
target,
|
|
184
|
-
origin,
|
|
185
|
-
turnNumber,
|
|
186
|
-
'user',
|
|
187
|
-
userTurn.content,
|
|
188
|
-
Math.round(userTurn.content.length / 4),
|
|
189
|
-
now,
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
if (assistantTurn?.content) {
|
|
193
|
-
insertBody.run(
|
|
194
|
-
target,
|
|
195
|
-
origin,
|
|
196
|
-
turnNumber,
|
|
197
|
-
'assistant',
|
|
198
|
-
assistantTurn.content,
|
|
199
|
-
Math.round(assistantTurn.content.length / 4),
|
|
200
|
-
now,
|
|
201
|
-
);
|
|
202
|
-
}
|
|
190
|
+
const turnNumber = backfill.lastTurnNumber;
|
|
203
191
|
|
|
204
192
|
// L1 = 遅延要約。target 配下の bodies ターン数 (distinct origin×turn) が
|
|
205
193
|
// WINDOW を超えていたら、最古の未要約ターンを 1 件だけ要約する。
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|