throughline 0.6.3 → 0.8.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.
- package/CHANGELOG.md +101 -1
- package/README.md +86 -28
- package/bin/throughline.mjs +20 -0
- package/docs/00_overview.md +12 -0
- package/docs/02_clear_auto_handoff_plan.md +47 -13
- package/docs/04_public_release_plan.md +1 -0
- package/docs/14_observer_completed_turn_feed_plan.md +290 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +9 -3
- package/docs/adr/0002-observer-claude-completion-receipt.md +42 -0
- package/docs/adr/0003-observer-completed-chain-cursor.md +34 -0
- package/docs/adr/0004-observer-db-pair-projection.md +71 -0
- package/docs/adr/0005-observer-read-pagination.md +51 -0
- package/docs/adr/0006-observer-page-offset-proof.md +33 -0
- package/docs/adr/0007-observer-read-cli-contract.md +61 -0
- package/docs/adr/0008-observer-wait-deadline-cancel.md +81 -0
- package/docs/adr/0009-observer-integration-regression-and-docs.md +37 -0
- package/docs/adr/0010-observer-o1-phase-acceptance.md +49 -0
- package/docs/adr/0011-observer-o1-control-lane-reconciliation.md +34 -0
- package/docs/adr/0012-claude-stop-transcript-flush-barrier.md +32 -0
- package/docs/adr/0013-observer-read-busy-writer-gate.md +46 -0
- package/docs/adr/0014-two-phase-handoff-ghost-baton.md +112 -0
- package/docs/adr/0015-l1-summarizer-model-effort-ratio.md +81 -0
- package/docs/adr/0016-push-pull-recall-injection.md +93 -0
- package/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +65 -0
- package/rag/INDEX.md +4 -0
- package/src/auditor-context.mjs +92 -11
- package/src/auditor-context.test.mjs +116 -1
- package/src/baton.mjs +27 -7
- package/src/baton.test.mjs +44 -0
- package/src/body-digest.mjs +9 -0
- package/src/cli/auditor-context.test.mjs +1 -1
- package/src/cli/factory-diagnostics.mjs +1 -0
- package/src/cli/factory-diagnostics.test.mjs +6 -2
- package/src/cli/observer-read.mjs +73 -0
- package/src/cli/observer-read.test.mjs +93 -0
- package/src/cli/observer-wait.mjs +123 -0
- package/src/cli/observer-wait.test.mjs +167 -0
- package/src/cli/recall.mjs +279 -0
- package/src/cli/recall.test.mjs +269 -0
- package/src/codex-rollout-memory.mjs +13 -0
- package/src/codex-rollout-memory.test.mjs +27 -0
- package/src/codex-thread-index.mjs +1 -1
- package/src/codex-thread-index.test.mjs +18 -0
- package/src/completed-turn-receipts.mjs +374 -0
- package/src/completed-turn-receipts.test.mjs +186 -0
- package/src/db-schema.test.mjs +9 -2
- package/src/db.mjs +20 -1
- package/src/decision-log.mjs +24 -0
- package/src/haiku-summarizer.mjs +93 -16
- package/src/haiku-summarizer.test.mjs +118 -9
- package/src/handoff-executor.mjs +161 -0
- package/src/hook-entrypoints.test.mjs +192 -12
- package/src/observer-codex-projection.test.mjs +49 -0
- package/src/observer-turn-feed.mjs +392 -0
- package/src/observer-turn-feed.test.mjs +339 -0
- package/src/observer-turn-wait.mjs +102 -0
- package/src/observer-turn-wait.test.mjs +122 -0
- package/src/pending-handoff.mjs +96 -0
- package/src/pending-handoff.test.mjs +107 -0
- package/src/prompt-submit.mjs +46 -1
- package/src/resume-context.mjs +337 -60
- package/src/resume-context.test.mjs +228 -1
- package/src/runtime-error-store.mjs +2 -1
- package/src/runtime-error-store.test.mjs +1 -1
- package/src/session-start.mjs +70 -233
- package/src/transcript-reader.mjs +32 -0
- package/src/turn-backfill.mjs +3 -2
- package/src/turn-backfill.test.mjs +10 -4
- package/src/turn-processor.mjs +90 -1
- package/src/turn-processor.test.mjs +142 -0
- package/src/windows-acl-test-helper.mjs +2 -2
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { registerPendingHandoff, consumePendingHandoff } from './pending-handoff.mjs';
|
|
5
|
+
|
|
6
|
+
function makeDb() {
|
|
7
|
+
const db = new DatabaseSync(':memory:');
|
|
8
|
+
db.exec(`
|
|
9
|
+
CREATE TABLE pending_handoffs (
|
|
10
|
+
session_id TEXT PRIMARY KEY,
|
|
11
|
+
project_path TEXT NOT NULL,
|
|
12
|
+
source TEXT,
|
|
13
|
+
auto_predecessor_id TEXT,
|
|
14
|
+
created_at INTEGER NOT NULL
|
|
15
|
+
);
|
|
16
|
+
`);
|
|
17
|
+
return db;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('registerPendingHandoff: inserts an intent row', () => {
|
|
21
|
+
const db = makeDb();
|
|
22
|
+
registerPendingHandoff(db, {
|
|
23
|
+
sessionId: 'S1',
|
|
24
|
+
projectPath: '/proj',
|
|
25
|
+
source: 'startup',
|
|
26
|
+
now: 1000,
|
|
27
|
+
});
|
|
28
|
+
const row = db.prepare('SELECT * FROM pending_handoffs').get();
|
|
29
|
+
assert.equal(row.session_id, 'S1');
|
|
30
|
+
assert.equal(row.project_path, '/proj');
|
|
31
|
+
assert.equal(row.source, 'startup');
|
|
32
|
+
assert.equal(row.auto_predecessor_id, null);
|
|
33
|
+
assert.equal(row.created_at, 1000);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('registerPendingHandoff: re-registration (resume) updates source and created_at', () => {
|
|
37
|
+
const db = makeDb();
|
|
38
|
+
registerPendingHandoff(db, {
|
|
39
|
+
sessionId: 'S1',
|
|
40
|
+
projectPath: '/proj',
|
|
41
|
+
source: 'startup',
|
|
42
|
+
now: 1000,
|
|
43
|
+
});
|
|
44
|
+
registerPendingHandoff(db, {
|
|
45
|
+
sessionId: 'S1',
|
|
46
|
+
projectPath: '/proj',
|
|
47
|
+
source: 'resume',
|
|
48
|
+
now: 5000,
|
|
49
|
+
});
|
|
50
|
+
const rows = db.prepare('SELECT * FROM pending_handoffs').all();
|
|
51
|
+
assert.equal(rows.length, 1);
|
|
52
|
+
assert.equal(rows[0].source, 'resume');
|
|
53
|
+
assert.equal(rows[0].created_at, 5000);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('registerPendingHandoff: stores frozen auto predecessor for source=clear', () => {
|
|
57
|
+
const db = makeDb();
|
|
58
|
+
registerPendingHandoff(db, {
|
|
59
|
+
sessionId: 'S1',
|
|
60
|
+
projectPath: '/proj',
|
|
61
|
+
source: 'clear',
|
|
62
|
+
autoPredecessorId: 'PRED',
|
|
63
|
+
now: 1000,
|
|
64
|
+
});
|
|
65
|
+
const row = db.prepare('SELECT * FROM pending_handoffs').get();
|
|
66
|
+
assert.equal(row.auto_predecessor_id, 'PRED');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('consumePendingHandoff: returns the row once and deletes it', () => {
|
|
70
|
+
const db = makeDb();
|
|
71
|
+
registerPendingHandoff(db, {
|
|
72
|
+
sessionId: 'S1',
|
|
73
|
+
projectPath: '/proj',
|
|
74
|
+
source: 'clear',
|
|
75
|
+
autoPredecessorId: 'PRED',
|
|
76
|
+
now: 1234,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const first = consumePendingHandoff(db, { sessionId: 'S1' });
|
|
80
|
+
assert.deepEqual(first, {
|
|
81
|
+
sessionId: 'S1',
|
|
82
|
+
projectPath: '/proj',
|
|
83
|
+
source: 'clear',
|
|
84
|
+
autoPredecessorId: 'PRED',
|
|
85
|
+
createdAt: 1234,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const second = consumePendingHandoff(db, { sessionId: 'S1' });
|
|
89
|
+
assert.equal(second, null, 'second consumption must return null (row deleted)');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('consumePendingHandoff: returns null for an unknown session (not newborn)', () => {
|
|
93
|
+
const db = makeDb();
|
|
94
|
+
registerPendingHandoff(db, {
|
|
95
|
+
sessionId: 'S1',
|
|
96
|
+
projectPath: '/proj',
|
|
97
|
+
source: 'startup',
|
|
98
|
+
now: 1000,
|
|
99
|
+
});
|
|
100
|
+
const result = consumePendingHandoff(db, { sessionId: 'OTHER' });
|
|
101
|
+
assert.equal(result, null);
|
|
102
|
+
assert.equal(
|
|
103
|
+
db.prepare('SELECT COUNT(*) AS c FROM pending_handoffs').get().c,
|
|
104
|
+
1,
|
|
105
|
+
'other sessions must not consume S1 pending row',
|
|
106
|
+
);
|
|
107
|
+
});
|
package/src/prompt-submit.mjs
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* UserPromptSubmit hook — /tl & /clear
|
|
3
|
+
* UserPromptSubmit hook — 二相ハンドオフ第二相 + /tl & /clear バトン書き込み + Phase 0-5 spike
|
|
4
4
|
*
|
|
5
5
|
* stdin: { session_id, cwd, prompt, transcript_path, hook_event_name, ... }
|
|
6
6
|
*
|
|
7
7
|
* 動作:
|
|
8
|
+
* - **二相ハンドオフ第二相 (ADR 0014)**: このセッションの pending intent
|
|
9
|
+
* (SessionStart が登録) が残っていれば、それを消費して baton path 優先 →
|
|
10
|
+
* auto path の順で前任を merge し、予算内 resume context を stdout 注入する。
|
|
11
|
+
* プロンプト到達 = セッション実在の証明であり、transcript を生成しない幽霊
|
|
12
|
+
* SessionStart はここに到達できない (= バトン・記憶を奪えない)。
|
|
13
|
+
* 注入がこの hook に移ったため、SessionStart 側の注入は廃止済み
|
|
14
|
+
* (旧「二重注入回避」制約はこの構成では発生しない)。
|
|
8
15
|
* - prompt が /tl (単独 or /tl ... 形式) で始まっていればバトンを書き込んで終了
|
|
9
16
|
* - prompt が /clear (単独 or /clear ... 形式) で始まっていれば、現セッションの
|
|
10
17
|
* session_id をバトンに書き込んで終了。
|
|
@@ -27,6 +34,8 @@
|
|
|
27
34
|
|
|
28
35
|
import { getDb } from './db.mjs';
|
|
29
36
|
import { writeBaton } from './baton.mjs';
|
|
37
|
+
import { executeFirstPromptHandoff } from './handoff-executor.mjs';
|
|
38
|
+
import { logDecision } from './decision-log.mjs';
|
|
30
39
|
import { ensureMonitorTaskFile } from './vscode-task.mjs';
|
|
31
40
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
32
41
|
import { join, dirname } from 'node:path';
|
|
@@ -146,6 +155,42 @@ export async function run() {
|
|
|
146
155
|
process.stderr.write(`[vscode-task] ${msg}\n`);
|
|
147
156
|
}
|
|
148
157
|
|
|
158
|
+
// 二相ハンドオフの第二相 (ADR 0014): このプロンプトが newborn セッションの
|
|
159
|
+
// 初回プロンプトなら、pending intent を消費して merge + 注入をここで行う。
|
|
160
|
+
// プロンプト到達 = セッション実在の証明。幽霊 SessionStart はここに来られない。
|
|
161
|
+
// /tl・/clear のバトン書き込みより先に実行する (初回プロンプトが /tl でも、
|
|
162
|
+
// 引き継ぎを受けてから自分のバトンを書く順序になり、自己バトン食いが起きない)。
|
|
163
|
+
if (session_id) {
|
|
164
|
+
const db = getDb();
|
|
165
|
+
const projectPath = cwd ?? process.cwd();
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
const handoff = executeFirstPromptHandoff(db, {
|
|
168
|
+
sessionId: session_id,
|
|
169
|
+
projectPath,
|
|
170
|
+
now,
|
|
171
|
+
});
|
|
172
|
+
if (handoff.attempted) {
|
|
173
|
+
if (handoff.injectionText) {
|
|
174
|
+
process.stdout.write(handoff.injectionText + '\n');
|
|
175
|
+
}
|
|
176
|
+
logDecision({
|
|
177
|
+
ts: new Date(now).toISOString(),
|
|
178
|
+
phase: 'prompt-submit',
|
|
179
|
+
session_id,
|
|
180
|
+
project_path: projectPath,
|
|
181
|
+
pending_created_at: handoff.pendingCreatedAt,
|
|
182
|
+
triggered_path: handoff.triggeredPath,
|
|
183
|
+
baton_session_id: handoff.baton?.sessionId ?? null,
|
|
184
|
+
baton_age_ms: handoff.baton?.ageMs ?? null,
|
|
185
|
+
baton_skip_reason: handoff.baton?.skipReason ?? null,
|
|
186
|
+
merged: handoff.mergeResult.merged,
|
|
187
|
+
merge_skip_reason: handoff.mergeResult.skipReason ?? null,
|
|
188
|
+
predecessor_id: handoff.mergeResult.predecessorId ?? null,
|
|
189
|
+
injection: handoff.injectionStats,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
149
194
|
const tlMatch = isBatonCommand(prompt);
|
|
150
195
|
const clearMatch = !tlMatch && isClearCommand(prompt);
|
|
151
196
|
|
package/src/resume-context.mjs
CHANGED
|
@@ -83,22 +83,17 @@ function pickLatestExchange(recentBodies) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
86
|
+
* 注入セクションを構造化して組み立てる(内部共通)。
|
|
87
|
+
* buildResumeContext (無制限) と buildBudgetedResumeContext (予算付き) が共有する。
|
|
88
88
|
*
|
|
89
|
-
* @
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* }} params
|
|
96
|
-
* @returns {string | null}
|
|
89
|
+
* @returns {{
|
|
90
|
+
* header: string,
|
|
91
|
+
* anchorLines: string[],
|
|
92
|
+
* l1Lines: string[],
|
|
93
|
+
* l2Lines: { text: string, time: string }[],
|
|
94
|
+
* } | null}
|
|
97
95
|
*/
|
|
98
|
-
|
|
99
|
-
db,
|
|
100
|
-
{ sessionId, isInheritance, excludeOriginId = null, inflightMemo: _ignoredMemo = null },
|
|
101
|
-
) {
|
|
96
|
+
function buildResumeSections(db, { sessionId, isInheritance, excludeOriginId = null }) {
|
|
102
97
|
const record = buildHandoffRecord(db, {
|
|
103
98
|
sessionId,
|
|
104
99
|
isInheritance,
|
|
@@ -108,15 +103,14 @@ export function buildResumeContext(
|
|
|
108
103
|
|
|
109
104
|
const turnCount = record.stats.preservedContextRows;
|
|
110
105
|
const header = isInheritance ? RESUME_HEADER_TEMPLATE(turnCount) : NORMAL_HEADER;
|
|
111
|
-
const lines = [header];
|
|
112
106
|
|
|
113
107
|
const l3ByTurn = groupL3ByTurn(record.references.l3);
|
|
114
108
|
|
|
115
109
|
// 現在地アンカー: 引き継ぎ時のみ、最新 user / assistant turn をヘッダ直下に再掲する。
|
|
116
110
|
// L2 末尾アンカーだけだと、長い L2 で注意が前半に固着して話の流れを取り違える事例があった。
|
|
111
|
+
const anchorLines = [];
|
|
117
112
|
if (isInheritance && record.memory.recentBodies.length > 0) {
|
|
118
113
|
const { latestUser, latestAssistant } = pickLatestExchange(record.memory.recentBodies);
|
|
119
|
-
const anchorLines = [];
|
|
120
114
|
if (latestUser) {
|
|
121
115
|
anchorLines.push(
|
|
122
116
|
`**最新ユーザー指示** [${latestUser.time}]: ${truncateForAnchor(latestUser.text)}`,
|
|
@@ -127,62 +121,345 @@ export function buildResumeContext(
|
|
|
127
121
|
`**直前のアシスタント** [${latestAssistant.time}]: ${truncateForAnchor(latestAssistant.text)}`,
|
|
128
122
|
);
|
|
129
123
|
}
|
|
130
|
-
if (anchorLines.length > 0) {
|
|
131
|
-
lines.push('');
|
|
132
|
-
lines.push('### 現在地 (直前のやりとり)');
|
|
133
|
-
lines.push(...anchorLines);
|
|
134
|
-
}
|
|
135
124
|
}
|
|
136
125
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const key = `${r.originSessionId}\x00${r.turnNumber}`;
|
|
126
|
+
const l1Lines = [];
|
|
127
|
+
for (const r of record.memory.l1Summaries) {
|
|
128
|
+
if (!r.summary || r.summary === '(no content)') continue;
|
|
129
|
+
const summary = r.summary.replace(/\n+/g, ' ').trim();
|
|
130
|
+
const key = `${r.originSessionId}\x00${r.turnNumber}`;
|
|
143
131
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
132
|
+
// body 時刻が引けた行だけ詳細呼び出しを案内する。引けない場合は
|
|
133
|
+
// `[skeleton 時刻]` のままだと throughline detail が解決しないので suffix を出さない。
|
|
134
|
+
const displayTime = r.bodyTime ?? r.time;
|
|
135
|
+
const partCounts = l3ByTurn.get(key)?.partCounts ?? new Map();
|
|
136
|
+
const suffix = r.bodyTime != null
|
|
137
|
+
? buildPartsSummary(partCounts, { includeBody: true })
|
|
138
|
+
: '';
|
|
151
139
|
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
if (l1Lines.length > 0) {
|
|
155
|
-
lines.push('');
|
|
156
|
-
lines.push('### それ以前の要約 (L1)');
|
|
157
|
-
lines.push(...l1Lines);
|
|
158
|
-
}
|
|
140
|
+
l1Lines.push(`[${displayTime}] ${summary}${suffix}`);
|
|
159
141
|
}
|
|
160
142
|
|
|
161
|
-
|
|
143
|
+
// ターン内の最終 role 行 (通常 user→assistant 順なら assistant) にだけ suffix を出す。
|
|
144
|
+
// L3 (思考 / ツール / hook 出力 / 画像) は turn_number 単位でしか紐付いていないので
|
|
145
|
+
// 同じターンの user 行と assistant 行の両方に貼ると同じ内容が二度出て紛らわしい。
|
|
146
|
+
const l2Lines = [];
|
|
147
|
+
const lastIdxPerTurn = new Map();
|
|
148
|
+
for (let i = 0; i < record.memory.recentBodies.length; i += 1) {
|
|
149
|
+
const r = record.memory.recentBodies[i];
|
|
150
|
+
if (!r.text) continue;
|
|
151
|
+
const key = `${r.originSessionId}\x00${r.turnNumber}`;
|
|
152
|
+
lastIdxPerTurn.set(key, i);
|
|
153
|
+
}
|
|
154
|
+
for (let i = 0; i < record.memory.recentBodies.length; i += 1) {
|
|
155
|
+
const r = record.memory.recentBodies[i];
|
|
156
|
+
if (!r.text) continue;
|
|
157
|
+
const key = `${r.originSessionId}\x00${r.turnNumber}`;
|
|
158
|
+
const isLastOfTurn = lastIdxPerTurn.get(key) === i;
|
|
159
|
+
const partCounts = isLastOfTurn ? (l3ByTurn.get(key)?.partCounts ?? new Map()) : new Map();
|
|
160
|
+
const suffix = buildPartsSummary(partCounts);
|
|
161
|
+
l2Lines.push({
|
|
162
|
+
text: `[${r.time}] [${r.role}]: ${r.text}${suffix}`,
|
|
163
|
+
time: r.time,
|
|
164
|
+
role: r.role,
|
|
165
|
+
turnKey: key,
|
|
166
|
+
createdAt: r.createdAt,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { header, anchorLines, l1Lines, l2Lines };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function joinSections({ header, anchorLines, l1Lines, l2Lines }, { l1Note = null, l2Note = null } = {}) {
|
|
174
|
+
const lines = [header];
|
|
175
|
+
if (anchorLines.length > 0) {
|
|
176
|
+
lines.push('');
|
|
177
|
+
lines.push('### 現在地 (直前のやりとり)');
|
|
178
|
+
lines.push(...anchorLines);
|
|
179
|
+
}
|
|
180
|
+
if (l1Lines.length > 0 || l1Note) {
|
|
181
|
+
lines.push('');
|
|
182
|
+
lines.push('### それ以前の要約 (L1)');
|
|
183
|
+
if (l1Note) lines.push(l1Note);
|
|
184
|
+
lines.push(...l1Lines);
|
|
185
|
+
}
|
|
186
|
+
if (l2Lines.length > 0 || l2Note) {
|
|
162
187
|
lines.push('');
|
|
163
188
|
lines.push('### 直前の対話 (L2 / active work thread, 古い順)');
|
|
189
|
+
if (l2Note) lines.push(l2Note);
|
|
190
|
+
lines.push(...l2Lines.map((l) => l.text));
|
|
191
|
+
}
|
|
192
|
+
return lines.join('\n');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* L1 + L2 注入テキストを組み立てる(サイズ無制限)。L3 は本文ではなく
|
|
197
|
+
* 各 L1 / L2 行末尾の inline hint として付与する。
|
|
198
|
+
*
|
|
199
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
200
|
+
* @param {{
|
|
201
|
+
* sessionId: string,
|
|
202
|
+
* isInheritance: boolean,
|
|
203
|
+
* excludeOriginId?: string | null,
|
|
204
|
+
* inflightMemo?: string | null,
|
|
205
|
+
* }} params
|
|
206
|
+
* @returns {string | null}
|
|
207
|
+
*/
|
|
208
|
+
export function buildResumeContext(
|
|
209
|
+
db,
|
|
210
|
+
{ sessionId, isInheritance, excludeOriginId = null, inflightMemo: _ignoredMemo = null },
|
|
211
|
+
) {
|
|
212
|
+
const sections = buildResumeSections(db, { sessionId, isInheritance, excludeOriginId });
|
|
213
|
+
if (!sections) return null;
|
|
214
|
+
return joinSections(sections);
|
|
215
|
+
}
|
|
164
216
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
217
|
+
/**
|
|
218
|
+
* hook stdout 注入の予算上限(文字数)。
|
|
219
|
+
*
|
|
220
|
+
* 実測 (2026-07-17, Claude Code 2.1.211 / ADR 0014):
|
|
221
|
+
* SessionStart / UserPromptSubmit の hook stdout は約 10,000 字を超えると
|
|
222
|
+
* file 化され、モデル可視は `<persisted-output>`(ファイルパス + 先頭 2KB preview)
|
|
223
|
+
* だけに劣化する。9,501 字は inline 通過、15,286 字は file 化を確認。
|
|
224
|
+
* 全 transcript 実測では >10k の注入 12 件が 12 件とも劣化していた
|
|
225
|
+
* (v2.1.195 / 2026-06-28 以降)。安全側マージンとして 9,500 に設定。
|
|
226
|
+
*/
|
|
227
|
+
export const INJECTION_BUDGET_CHARS = 9_500;
|
|
228
|
+
|
|
229
|
+
// 案内セクション(### さらに前の記憶)の予約分。ヘッダ・アンカーと同格の固定部として
|
|
230
|
+
// 予算計算の最初に差し引き、どれだけ逼迫しても落とさない(無条件表示)。
|
|
231
|
+
// 実文言はテンプレート固定 + 動的値(件数・時刻範囲・session id・ISO 境界)なので
|
|
232
|
+
// この上限内に収まる(session id ~42 字 + ISO 24 字 + 本文 2 行で最大 ~520 字を実測見積もり)。
|
|
233
|
+
const GUIDANCE_RESERVE = 600;
|
|
234
|
+
// 最新 L2 ターンが単体で予算を超える場合に、切り詰めてでも入れる最小の残余
|
|
235
|
+
const MIN_TRUNCATED_L2_CHARS = 400;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 20 ターン窓の外側(それより古い側)のターン統計。
|
|
239
|
+
* 案内セクションの `recall --l1` 行(全 M ターン / 要約済み K / 時刻範囲)に使う。
|
|
240
|
+
*/
|
|
241
|
+
function loadOlderTurnStats(db, { sessionId, excludeOriginId, windowKeys }) {
|
|
242
|
+
const hasExclude = Boolean(excludeOriginId);
|
|
243
|
+
const turnQuery = hasExclude
|
|
244
|
+
? `SELECT origin_session_id, turn_number, MIN(created_at) AS min_ca
|
|
245
|
+
FROM bodies
|
|
246
|
+
WHERE session_id = ? AND origin_session_id != ?
|
|
247
|
+
GROUP BY origin_session_id, turn_number`
|
|
248
|
+
: `SELECT origin_session_id, turn_number, MIN(created_at) AS min_ca
|
|
249
|
+
FROM bodies
|
|
250
|
+
WHERE session_id = ?
|
|
251
|
+
GROUP BY origin_session_id, turn_number`;
|
|
252
|
+
const allTurns = hasExclude
|
|
253
|
+
? db.prepare(turnQuery).all(sessionId, excludeOriginId)
|
|
254
|
+
: db.prepare(turnQuery).all(sessionId);
|
|
255
|
+
|
|
256
|
+
const older = allTurns.filter(
|
|
257
|
+
(r) => !windowKeys.has(`${r.origin_session_id}\x00${r.turn_number}`),
|
|
258
|
+
);
|
|
259
|
+
if (older.length === 0) {
|
|
260
|
+
return { total: 0, summarized: 0, minMs: null, maxMs: null };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const skelQuery = hasExclude
|
|
264
|
+
? `SELECT DISTINCT origin_session_id, turn_number FROM skeletons
|
|
265
|
+
WHERE session_id = ? AND origin_session_id != ?`
|
|
266
|
+
: `SELECT DISTINCT origin_session_id, turn_number FROM skeletons
|
|
267
|
+
WHERE session_id = ?`;
|
|
268
|
+
const skelKeys = new Set(
|
|
269
|
+
(hasExclude
|
|
270
|
+
? db.prepare(skelQuery).all(sessionId, excludeOriginId)
|
|
271
|
+
: db.prepare(skelQuery).all(sessionId)
|
|
272
|
+
).map((r) => `${r.origin_session_id}\x00${r.turn_number}`),
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
let minMs = Infinity;
|
|
276
|
+
let maxMs = -Infinity;
|
|
277
|
+
let summarized = 0;
|
|
278
|
+
for (const r of older) {
|
|
279
|
+
if (r.min_ca < minMs) minMs = r.min_ca;
|
|
280
|
+
if (r.min_ca > maxMs) maxMs = r.min_ca;
|
|
281
|
+
if (skelKeys.has(`${r.origin_session_id}\x00${r.turn_number}`)) summarized += 1;
|
|
282
|
+
}
|
|
283
|
+
return { total: older.length, summarized, minMs, maxMs };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function formatClock(unixMs) {
|
|
287
|
+
const d = new Date(unixMs);
|
|
288
|
+
const hh = String(d.getHours()).padStart(2, '0');
|
|
289
|
+
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
290
|
+
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
291
|
+
return `${hh}:${mm}:${ss}`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 案内セクション(無条件表示)。表示用の HH:MM:SS と機械用の境界(ISO 8601 ms / 件数 /
|
|
296
|
+
* session id)を分離し、機械用は全部コマンド引数へ焼き込む。recall 側は再計算しない。
|
|
297
|
+
*/
|
|
298
|
+
function buildGuidanceLines({ sessionId, boundaryMs, remainingTurns, remainingRange, older }) {
|
|
299
|
+
const lines = ['### さらに前の記憶(必要な時だけ取得)'];
|
|
300
|
+
const boundaryIso = boundaryMs != null ? new Date(boundaryMs).toISOString() : null;
|
|
301
|
+
|
|
302
|
+
if (remainingTurns > 0) {
|
|
303
|
+
lines.push(
|
|
304
|
+
`- これより前の続き${remainingTurns}ターン (${remainingRange}) の会話全文: ` +
|
|
305
|
+
`\`throughline recall --l2 --session ${sessionId} --before ${boundaryIso} --last ${remainingTurns}\` を実行`,
|
|
306
|
+
);
|
|
307
|
+
} else {
|
|
308
|
+
lines.push('- これより前の続き: なし(直近の会話は全て上の L2 セクションに注入済み)');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (older.total > 0) {
|
|
312
|
+
const range = `${formatClock(older.minMs)}〜${formatClock(older.maxMs)}`;
|
|
313
|
+
lines.push(
|
|
314
|
+
`- それ以前の全${older.total}ターン(要約済み ${older.summarized} / 未要約 ${older.total - older.summarized})の一覧 (${range}): ` +
|
|
315
|
+
`\`throughline recall --l1 --session ${sessionId} --before ${boundaryIso} --skip ${remainingTurns}\` を実行` +
|
|
316
|
+
'(気になるターンは `throughline detail <時刻>` で全文・ツール入出力まで掘れる)',
|
|
317
|
+
);
|
|
318
|
+
} else {
|
|
319
|
+
lines.push('- それ以前のターン: なし');
|
|
320
|
+
}
|
|
321
|
+
return lines;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 予算付き注入テキスト。構成 (オーナー裁定 2026-07-18):
|
|
326
|
+
* 1. ヘッダ + 現在地アンカー(常に全文)
|
|
327
|
+
* 2. 案内セクション(無条件表示。予約 GUIDANCE_RESERVE を先に差し引く)
|
|
328
|
+
* 3. L2 本文: 新しい順に**丸ごと入るターンだけ**詰めて古い順に出力
|
|
329
|
+
* (ターン単位の原子。固定 N で予算を遊ばせず、断片も詰めない。
|
|
330
|
+
* ターン境界の自然な端数は許容)
|
|
331
|
+
* L1 は注入しない(窓の残りは recall --l2、それより古い側は recall --l1 が担う)。
|
|
332
|
+
* 最新ターンが単体で予算を超える場合だけ、切り詰めてでも入れる
|
|
333
|
+
* (現在地の文脈をアンカー 600 字より厚く確保するため)。
|
|
334
|
+
*
|
|
335
|
+
* @returns {{
|
|
336
|
+
* text: string,
|
|
337
|
+
* totalChars: number,
|
|
338
|
+
* injectedL2Turns: number,
|
|
339
|
+
* remainingL2Turns: number,
|
|
340
|
+
* olderTurns: number,
|
|
341
|
+
* olderSummarized: number,
|
|
342
|
+
* truncatedNewestL2: boolean,
|
|
343
|
+
* } | null}
|
|
344
|
+
*/
|
|
345
|
+
export function buildBudgetedResumeContext(
|
|
346
|
+
db,
|
|
347
|
+
{ sessionId, isInheritance, excludeOriginId = null, maxChars = INJECTION_BUDGET_CHARS },
|
|
348
|
+
) {
|
|
349
|
+
const sections = buildResumeSections(db, { sessionId, isInheritance, excludeOriginId });
|
|
350
|
+
if (!sections) return null;
|
|
351
|
+
|
|
352
|
+
const lineCost = (s) => s.length + 1; // join('\n') 分
|
|
353
|
+
|
|
354
|
+
// L2 行をターン単位のグループにまとめる(元の古い順を保つ)
|
|
355
|
+
const turns = [];
|
|
356
|
+
const turnIndex = new Map();
|
|
357
|
+
for (const line of sections.l2Lines) {
|
|
358
|
+
let group = turnIndex.get(line.turnKey);
|
|
359
|
+
if (!group) {
|
|
360
|
+
group = { turnKey: line.turnKey, lines: [], minCreatedAt: Infinity, maxCreatedAt: -Infinity };
|
|
361
|
+
turnIndex.set(line.turnKey, group);
|
|
362
|
+
turns.push(group);
|
|
174
363
|
}
|
|
364
|
+
group.lines.push(line);
|
|
365
|
+
if (line.createdAt < group.minCreatedAt) group.minCreatedAt = line.createdAt;
|
|
366
|
+
if (line.createdAt > group.maxCreatedAt) group.maxCreatedAt = line.createdAt;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// 固定部 (ヘッダ + アンカー + セクション見出し + 案内予約) のコスト
|
|
370
|
+
const fixedCost =
|
|
371
|
+
lineCost(sections.header) +
|
|
372
|
+
(sections.anchorLines.length > 0
|
|
373
|
+
? lineCost('') + lineCost('### 現在地 (直前のやりとり)') +
|
|
374
|
+
sections.anchorLines.reduce((a, l) => a + lineCost(l), 0)
|
|
375
|
+
: 0) +
|
|
376
|
+
lineCost('') + lineCost('### 直前の対話 (L2 / active work thread, 古い順)');
|
|
175
377
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
378
|
+
let remaining = maxChars - fixedCost - GUIDANCE_RESERVE;
|
|
379
|
+
|
|
380
|
+
// L2: 新しい順にターンを丸ごと採用して古い順で出力
|
|
381
|
+
const keptTurns = [];
|
|
382
|
+
let truncatedNewestL2 = false;
|
|
383
|
+
for (let i = turns.length - 1; i >= 0; i -= 1) {
|
|
384
|
+
const turn = turns[i];
|
|
385
|
+
const cost = turn.lines.reduce((a, l) => a + lineCost(l.text), 0);
|
|
386
|
+
if (remaining - cost >= 0) {
|
|
387
|
+
keptTurns.unshift(turn);
|
|
388
|
+
remaining -= cost;
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
// 最新ターンが単体で入らない場合だけ、最新行を切り詰めて確保する
|
|
392
|
+
if (keptTurns.length === 0 && remaining >= MIN_TRUNCATED_L2_CHARS) {
|
|
393
|
+
const newestLine = turn.lines[turn.lines.length - 1];
|
|
394
|
+
const marker = ` …(予算超過で切詰め; 全文: throughline detail ${newestLine.time})`;
|
|
395
|
+
const keep = remaining - marker.length - 1;
|
|
396
|
+
keptTurns.unshift({
|
|
397
|
+
...turn,
|
|
398
|
+
lines: [{ ...newestLine, text: newestLine.text.slice(0, keep) + marker }],
|
|
399
|
+
});
|
|
400
|
+
remaining = 0;
|
|
401
|
+
truncatedNewestL2 = true;
|
|
184
402
|
}
|
|
403
|
+
break; // ターン原子: 入らないターンが出たらそこで打ち切り (歯抜けを作らない)
|
|
185
404
|
}
|
|
186
405
|
|
|
187
|
-
|
|
406
|
+
const injectedL2Turns = keptTurns.length;
|
|
407
|
+
const remainingL2Turns = turns.length - injectedL2Turns;
|
|
408
|
+
|
|
409
|
+
// pull 境界: 実際に注入できた最古ターンの min(created_at)。strict less-than で
|
|
410
|
+
// 「それより古い側」が recall --l2 の担当になる。1 ターンも入らなかった場合は
|
|
411
|
+
// 最新ターンの max(created_at)+1 を境界にして窓全体を pull 側に渡す。
|
|
412
|
+
let boundaryMs = null;
|
|
413
|
+
if (injectedL2Turns > 0) {
|
|
414
|
+
boundaryMs = keptTurns[0].minCreatedAt;
|
|
415
|
+
} else if (turns.length > 0) {
|
|
416
|
+
boundaryMs = turns[turns.length - 1].maxCreatedAt + 1;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
let remainingRange = null;
|
|
420
|
+
if (remainingL2Turns > 0) {
|
|
421
|
+
const remainingTurnsList = turns.slice(0, remainingL2Turns);
|
|
422
|
+
remainingRange = `${formatClock(remainingTurnsList[0].minCreatedAt)}〜${formatClock(remainingTurnsList[remainingTurnsList.length - 1].maxCreatedAt)}`;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const older = loadOlderTurnStats(db, {
|
|
426
|
+
sessionId,
|
|
427
|
+
excludeOriginId,
|
|
428
|
+
windowKeys: new Set(turns.map((t) => t.turnKey)),
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const guidanceLines = buildGuidanceLines({
|
|
432
|
+
sessionId,
|
|
433
|
+
boundaryMs,
|
|
434
|
+
remainingTurns: remainingL2Turns,
|
|
435
|
+
remainingRange,
|
|
436
|
+
older,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const lines = [sections.header];
|
|
440
|
+
if (sections.anchorLines.length > 0) {
|
|
441
|
+
lines.push('');
|
|
442
|
+
lines.push('### 現在地 (直前のやりとり)');
|
|
443
|
+
lines.push(...sections.anchorLines);
|
|
444
|
+
}
|
|
445
|
+
lines.push('');
|
|
446
|
+
lines.push(...guidanceLines);
|
|
447
|
+
if (keptTurns.length > 0) {
|
|
448
|
+
lines.push('');
|
|
449
|
+
lines.push('### 直前の対話 (L2 / active work thread, 古い順)');
|
|
450
|
+
for (const turn of keptTurns) {
|
|
451
|
+
lines.push(...turn.lines.map((l) => l.text));
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const text = lines.join('\n');
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
text,
|
|
458
|
+
totalChars: text.length,
|
|
459
|
+
injectedL2Turns,
|
|
460
|
+
remainingL2Turns,
|
|
461
|
+
olderTurns: older.total,
|
|
462
|
+
olderSummarized: older.summarized,
|
|
463
|
+
truncatedNewestL2,
|
|
464
|
+
};
|
|
188
465
|
}
|