throughline 0.6.2 → 0.7.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 +79 -4
- package/README.md +77 -25
- package/bin/throughline.mjs +14 -0
- package/docs/00_overview.md +12 -0
- package/docs/02_clear_auto_handoff_plan.md +39 -13
- package/docs/04_public_release_plan.md +2 -1
- package/docs/13_native_factory_diagnostics_plan.md +4 -2
- package/docs/14_observer_completed_turn_feed_plan.md +290 -0
- package/docs/BUGHUB_RUNTIME_ERROR_STORE_PLAN.md +31 -4
- 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/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +48 -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 +5 -2
- package/src/cli/factory-diagnostics.test.mjs +31 -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/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 +373 -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/factory-diagnostics.mjs +0 -1
- package/src/factory-diagnostics.test.mjs +18 -0
- package/src/haiku-summarizer.mjs +93 -16
- package/src/haiku-summarizer.test.mjs +118 -9
- package/src/handoff-executor.mjs +159 -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 +226 -62
- package/src/resume-context.test.mjs +134 -1
- package/src/runtime-error-store.mjs +74 -32
- package/src/runtime-error-store.test.mjs +51 -3
- 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 +141 -0
- package/src/windows-acl-test-helper.mjs +29 -0
|
@@ -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,232 @@ 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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
l1Lines.push(`[${displayTime}] ${summary}${suffix}`);
|
|
153
|
-
}
|
|
154
|
-
if (l1Lines.length > 0) {
|
|
155
|
-
lines.push('');
|
|
156
|
-
lines.push('### それ以前の要約 (L1)');
|
|
157
|
-
lines.push(...l1Lines);
|
|
158
|
-
}
|
|
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}`;
|
|
131
|
+
|
|
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
|
+
: '';
|
|
139
|
+
|
|
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({ text: `[${r.time}] [${r.role}]: ${r.text}${suffix}`, time: r.time, role: r.role });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { header, anchorLines, l1Lines, l2Lines };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function joinSections({ header, anchorLines, l1Lines, l2Lines }, { l1Note = null, l2Note = null } = {}) {
|
|
168
|
+
const lines = [header];
|
|
169
|
+
if (anchorLines.length > 0) {
|
|
170
|
+
lines.push('');
|
|
171
|
+
lines.push('### 現在地 (直前のやりとり)');
|
|
172
|
+
lines.push(...anchorLines);
|
|
173
|
+
}
|
|
174
|
+
if (l1Lines.length > 0 || l1Note) {
|
|
175
|
+
lines.push('');
|
|
176
|
+
lines.push('### それ以前の要約 (L1)');
|
|
177
|
+
if (l1Note) lines.push(l1Note);
|
|
178
|
+
lines.push(...l1Lines);
|
|
179
|
+
}
|
|
180
|
+
if (l2Lines.length > 0 || l2Note) {
|
|
162
181
|
lines.push('');
|
|
163
182
|
lines.push('### 直前の対話 (L2 / active work thread, 古い順)');
|
|
183
|
+
if (l2Note) lines.push(l2Note);
|
|
184
|
+
lines.push(...l2Lines.map((l) => l.text));
|
|
185
|
+
}
|
|
186
|
+
return lines.join('\n');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* L1 + L2 注入テキストを組み立てる(サイズ無制限)。L3 は本文ではなく
|
|
191
|
+
* 各 L1 / L2 行末尾の inline hint として付与する。
|
|
192
|
+
*
|
|
193
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
194
|
+
* @param {{
|
|
195
|
+
* sessionId: string,
|
|
196
|
+
* isInheritance: boolean,
|
|
197
|
+
* excludeOriginId?: string | null,
|
|
198
|
+
* inflightMemo?: string | null,
|
|
199
|
+
* }} params
|
|
200
|
+
* @returns {string | null}
|
|
201
|
+
*/
|
|
202
|
+
export function buildResumeContext(
|
|
203
|
+
db,
|
|
204
|
+
{ sessionId, isInheritance, excludeOriginId = null, inflightMemo: _ignoredMemo = null },
|
|
205
|
+
) {
|
|
206
|
+
const sections = buildResumeSections(db, { sessionId, isInheritance, excludeOriginId });
|
|
207
|
+
if (!sections) return null;
|
|
208
|
+
return joinSections(sections);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* hook stdout 注入の予算上限(文字数)。
|
|
213
|
+
*
|
|
214
|
+
* 実測 (2026-07-17, Claude Code 2.1.211 / ADR 0014):
|
|
215
|
+
* SessionStart / UserPromptSubmit の hook stdout は約 10,000 字を超えると
|
|
216
|
+
* file 化され、モデル可視は `<persisted-output>`(ファイルパス + 先頭 2KB preview)
|
|
217
|
+
* だけに劣化する。9,501 字は inline 通過、15,286 字は file 化を確認。
|
|
218
|
+
* 全 transcript 実測では >10k の注入 12 件が 12 件とも劣化していた
|
|
219
|
+
* (v2.1.195 / 2026-06-28 以降)。安全側マージンとして 9,500 に設定。
|
|
220
|
+
*/
|
|
221
|
+
export const INJECTION_BUDGET_CHARS = 9_500;
|
|
164
222
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
223
|
+
// 予算超過時に注入へ入れる省略告知の予約分(この分を先に差し引いてから詰める)。
|
|
224
|
+
// 告知には省略した L2 行の [時刻 role] 参照リストを含める — 窓内の L2 行にはまだ
|
|
225
|
+
// L1 要約が無いことがあり、時刻が無いと `throughline detail` で取り出せなくなる
|
|
226
|
+
// (= 「要約せず削る。ただし参照は必ず残す」コンセプトの担保)。
|
|
227
|
+
const OMISSION_NOTE_RESERVE = 500;
|
|
228
|
+
// l2Note 単体の上限 (RESERVE から l1Note ぶんの余裕を引いた値)
|
|
229
|
+
const L2_NOTE_MAX_CHARS = 430;
|
|
230
|
+
// 最新 L2 行が単体で予算を超える場合に、切り詰めてでも入れる最小の残余
|
|
231
|
+
const MIN_TRUNCATED_L2_CHARS = 400;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 予算付き注入テキスト。優先順位:
|
|
235
|
+
* 1. ヘッダ + 現在地アンカー(常に全文)
|
|
236
|
+
* 2. L1 要約(新しい順に予算まで。落とした分は告知行)
|
|
237
|
+
* 3. L2 本文(新しい順に予算まで詰めて古い順に出力。落とした分は告知行 +
|
|
238
|
+
* L1 / `throughline detail` への誘導)
|
|
239
|
+
* 予算内に一切 L2 が入らない場合でも、最新 L2 行だけは切り詰めて入れる
|
|
240
|
+
* (現在地の文脈をアンカー 600 字より厚く確保するため)。
|
|
241
|
+
*
|
|
242
|
+
* @returns {{
|
|
243
|
+
* text: string,
|
|
244
|
+
* totalChars: number,
|
|
245
|
+
* droppedL1Rows: number,
|
|
246
|
+
* droppedL2Rows: number,
|
|
247
|
+
* truncatedNewestL2: boolean,
|
|
248
|
+
* } | null}
|
|
249
|
+
*/
|
|
250
|
+
export function buildBudgetedResumeContext(
|
|
251
|
+
db,
|
|
252
|
+
{ sessionId, isInheritance, excludeOriginId = null, maxChars = INJECTION_BUDGET_CHARS },
|
|
253
|
+
) {
|
|
254
|
+
const sections = buildResumeSections(db, { sessionId, isInheritance, excludeOriginId });
|
|
255
|
+
if (!sections) return null;
|
|
256
|
+
|
|
257
|
+
const lineCost = (s) => s.length + 1; // join('\n') 分
|
|
258
|
+
|
|
259
|
+
// 固定部 (ヘッダ + アンカー) のコスト。セクション見出し・空行も概算に含める
|
|
260
|
+
const fixedCost =
|
|
261
|
+
lineCost(sections.header) +
|
|
262
|
+
(sections.anchorLines.length > 0
|
|
263
|
+
? lineCost('') + lineCost('### 現在地 (直前のやりとり)') +
|
|
264
|
+
sections.anchorLines.reduce((a, l) => a + lineCost(l), 0)
|
|
265
|
+
: 0) +
|
|
266
|
+
lineCost('') + lineCost('### それ以前の要約 (L1)') +
|
|
267
|
+
lineCost('') + lineCost('### 直前の対話 (L2 / active work thread, 古い順)');
|
|
268
|
+
|
|
269
|
+
let remaining = maxChars - fixedCost - OMISSION_NOTE_RESERVE;
|
|
270
|
+
|
|
271
|
+
// L1: 新しい順 (配列末尾) に採用して古い順で出力
|
|
272
|
+
const keptL1 = [];
|
|
273
|
+
let droppedL1Rows = 0;
|
|
274
|
+
for (let i = sections.l1Lines.length - 1; i >= 0; i -= 1) {
|
|
275
|
+
const cost = lineCost(sections.l1Lines[i]);
|
|
276
|
+
if (remaining - cost >= 0) {
|
|
277
|
+
keptL1.unshift(sections.l1Lines[i]);
|
|
278
|
+
remaining -= cost;
|
|
279
|
+
} else {
|
|
280
|
+
droppedL1Rows = i + 1;
|
|
281
|
+
break;
|
|
174
282
|
}
|
|
283
|
+
}
|
|
175
284
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
285
|
+
// L2: 新しい順に採用して古い順で出力
|
|
286
|
+
const keptL2 = [];
|
|
287
|
+
let droppedL2Rows = 0;
|
|
288
|
+
let truncatedNewestL2 = false;
|
|
289
|
+
for (let i = sections.l2Lines.length - 1; i >= 0; i -= 1) {
|
|
290
|
+
const line = sections.l2Lines[i];
|
|
291
|
+
const cost = lineCost(line.text);
|
|
292
|
+
if (remaining - cost >= 0) {
|
|
293
|
+
keptL2.unshift(line);
|
|
294
|
+
remaining -= cost;
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
// 最新行が単体で入らない場合だけ、切り詰めて確保する
|
|
298
|
+
if (keptL2.length === 0 && remaining >= MIN_TRUNCATED_L2_CHARS) {
|
|
299
|
+
const marker = ` …(予算超過で切詰め; 全文: throughline detail ${line.time})`;
|
|
300
|
+
const keep = remaining - marker.length - 1;
|
|
301
|
+
keptL2.unshift({ ...line, text: line.text.slice(0, keep) + marker });
|
|
302
|
+
remaining = 0;
|
|
303
|
+
truncatedNewestL2 = true;
|
|
304
|
+
droppedL2Rows = i; // これより古い行は全部落ちる
|
|
305
|
+
break;
|
|
184
306
|
}
|
|
307
|
+
droppedL2Rows = i + 1;
|
|
308
|
+
break;
|
|
185
309
|
}
|
|
186
310
|
|
|
187
|
-
|
|
311
|
+
const l1Note =
|
|
312
|
+
droppedL1Rows > 0
|
|
313
|
+
? `(注入予算 ${maxChars} 字超過のため古い L1 を ${droppedL1Rows} 行省略)`
|
|
314
|
+
: null;
|
|
315
|
+
|
|
316
|
+
// 省略 L2 行の取り出し手がかり: [時刻 role] を新しい側 (文脈に近い側) から
|
|
317
|
+
// L2_NOTE_MAX_CHARS に収まるだけ列挙し、残りは「ほか N 行」に畳む。
|
|
318
|
+
let l2Note = null;
|
|
319
|
+
if (droppedL2Rows > 0) {
|
|
320
|
+
const base =
|
|
321
|
+
`(注入予算 ${maxChars} 字超過のため古い L2 を ${droppedL2Rows} 行省略。` +
|
|
322
|
+
'各行の全文は `throughline detail 時刻` で取得可。省略分 (新しい順): ';
|
|
323
|
+
const closing = ')';
|
|
324
|
+
const dropped = sections.l2Lines.slice(0, droppedL2Rows);
|
|
325
|
+
const refs = [];
|
|
326
|
+
let used = base.length + closing.length;
|
|
327
|
+
for (let i = dropped.length - 1; i >= 0; i -= 1) {
|
|
328
|
+
const ref = `[${dropped[i].time} ${dropped[i].role}]`;
|
|
329
|
+
const foldTail = i > 0 ? ` ほか${i}行`.length : 0;
|
|
330
|
+
if (used + ref.length + 1 + foldTail > L2_NOTE_MAX_CHARS) {
|
|
331
|
+
refs.push(`ほか${i + 1}行`);
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
refs.push(ref);
|
|
335
|
+
used += ref.length + 1;
|
|
336
|
+
}
|
|
337
|
+
l2Note = base + refs.join(' ') + closing;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const text = joinSections(
|
|
341
|
+
{ header: sections.header, anchorLines: sections.anchorLines, l1Lines: keptL1, l2Lines: keptL2 },
|
|
342
|
+
{ l1Note, l2Note },
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
text,
|
|
347
|
+
totalChars: text.length,
|
|
348
|
+
droppedL1Rows,
|
|
349
|
+
droppedL2Rows,
|
|
350
|
+
truncatedNewestL2,
|
|
351
|
+
};
|
|
188
352
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildResumeContext,
|
|
6
|
+
buildBudgetedResumeContext,
|
|
7
|
+
INJECTION_BUDGET_CHARS,
|
|
8
|
+
} from './resume-context.mjs';
|
|
5
9
|
|
|
6
10
|
function makeDb() {
|
|
7
11
|
const db = new DatabaseSync(':memory:');
|
|
@@ -623,3 +627,132 @@ test('buildResumeContext: ignores inflightMemo (kept only for signature compatib
|
|
|
623
627
|
assert.ok(text);
|
|
624
628
|
assert.ok(!text.includes('**Next**: keep going'));
|
|
625
629
|
});
|
|
630
|
+
|
|
631
|
+
// ---- buildBudgetedResumeContext (ADR 0014: hook stdout の 10k file 化対策) ----
|
|
632
|
+
|
|
633
|
+
test('INJECTION_BUDGET_CHARS stays under the measured 10k persisted-output limit', () => {
|
|
634
|
+
assert.ok(INJECTION_BUDGET_CHARS <= 9_501, '実測 inline 通過上限 9,501 字以下であること');
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
test('budgeted: under budget output matches the unbudgeted renderer, nothing dropped', () => {
|
|
638
|
+
const db = makeDb();
|
|
639
|
+
insertBody(db, { session: 'new', origin: 'old', turn: 1, role: 'user', text: 'short question', createdAt: 1000 });
|
|
640
|
+
insertBody(db, { session: 'new', origin: 'old', turn: 1, role: 'assistant', text: 'short answer', createdAt: 1100 });
|
|
641
|
+
|
|
642
|
+
const full = buildResumeContext(db, { sessionId: 'new', isInheritance: true });
|
|
643
|
+
const budgeted = buildBudgetedResumeContext(db, { sessionId: 'new', isInheritance: true });
|
|
644
|
+
|
|
645
|
+
assert.ok(budgeted);
|
|
646
|
+
assert.equal(budgeted.text, full);
|
|
647
|
+
assert.equal(budgeted.droppedL1Rows, 0);
|
|
648
|
+
assert.equal(budgeted.droppedL2Rows, 0);
|
|
649
|
+
assert.equal(budgeted.truncatedNewestL2, false);
|
|
650
|
+
assert.ok(budgeted.totalChars <= INJECTION_BUDGET_CHARS);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test('budgeted: drops oldest L2 rows first, keeps newest, and stays within maxChars', () => {
|
|
654
|
+
const db = makeDb();
|
|
655
|
+
// 各 ~800 字 × 10 行 = 本文だけで ~8,000 字 → maxChars 4000 で古い行が落ちる
|
|
656
|
+
for (let turn = 1; turn <= 10; turn += 1) {
|
|
657
|
+
insertBody(db, {
|
|
658
|
+
session: 'new',
|
|
659
|
+
origin: 'old',
|
|
660
|
+
turn,
|
|
661
|
+
role: 'assistant',
|
|
662
|
+
text: `turn-${String(turn).padStart(2, '0')} ` + 'x'.repeat(800),
|
|
663
|
+
createdAt: 1000 + turn * 100,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const budgeted = buildBudgetedResumeContext(db, {
|
|
668
|
+
sessionId: 'new',
|
|
669
|
+
isInheritance: true,
|
|
670
|
+
maxChars: 4000,
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
assert.ok(budgeted);
|
|
674
|
+
assert.ok(budgeted.totalChars <= 4000, `totalChars ${budgeted.totalChars} must fit budget`);
|
|
675
|
+
assert.ok(budgeted.droppedL2Rows > 0, 'some old L2 rows must be dropped');
|
|
676
|
+
assert.ok(budgeted.text.includes('turn-10'), 'newest L2 row must survive');
|
|
677
|
+
assert.ok(!budgeted.text.includes('turn-01 '), 'oldest L2 row must be dropped');
|
|
678
|
+
assert.match(budgeted.text, /古い L2 を \d+ 行省略/, 'omission must be announced, not silent');
|
|
679
|
+
// 「削るときは取り出すための参照を必ず残す」: 落とした行の [時刻 role] リストが
|
|
680
|
+
// 告知に載っていること (窓内の行にはまだ L1 が無く、時刻が無いと detail で引けない)
|
|
681
|
+
assert.match(
|
|
682
|
+
budgeted.text,
|
|
683
|
+
/省略分 \(新しい順\): (\[\d{2}:\d{2}:\d{2} assistant\][ ]?)+/,
|
|
684
|
+
'dropped rows must be listed with [time role] refs for throughline detail',
|
|
685
|
+
);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
test('budgeted: very many dropped rows fold into ほかN行 keeping the note bounded', () => {
|
|
689
|
+
const db = makeDb();
|
|
690
|
+
for (let turn = 1; turn <= 40; turn += 1) {
|
|
691
|
+
insertBody(db, {
|
|
692
|
+
session: 'new',
|
|
693
|
+
origin: 'old',
|
|
694
|
+
turn,
|
|
695
|
+
role: 'assistant',
|
|
696
|
+
text: `turn-${String(turn).padStart(2, '0')} ` + 'x'.repeat(700),
|
|
697
|
+
createdAt: 1000 + turn * 1000,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const budgeted = buildBudgetedResumeContext(db, {
|
|
702
|
+
sessionId: 'new',
|
|
703
|
+
isInheritance: true,
|
|
704
|
+
maxChars: 4000,
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
assert.ok(budgeted);
|
|
708
|
+
assert.ok(budgeted.totalChars <= 4000);
|
|
709
|
+
assert.match(budgeted.text, /ほか\d+行/, 'overflow refs must fold into a count');
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test('budgeted: a single oversized newest L2 row is truncated with a detail pointer', () => {
|
|
713
|
+
const db = makeDb();
|
|
714
|
+
insertBody(db, {
|
|
715
|
+
session: 'new',
|
|
716
|
+
origin: 'old',
|
|
717
|
+
turn: 1,
|
|
718
|
+
role: 'assistant',
|
|
719
|
+
text: 'HEAD-MARKER ' + 'y'.repeat(20_000),
|
|
720
|
+
createdAt: 1000,
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
const budgeted = buildBudgetedResumeContext(db, {
|
|
724
|
+
sessionId: 'new',
|
|
725
|
+
isInheritance: true,
|
|
726
|
+
maxChars: 4000,
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
assert.ok(budgeted);
|
|
730
|
+
assert.ok(budgeted.totalChars <= 4000);
|
|
731
|
+
assert.equal(budgeted.truncatedNewestL2, true);
|
|
732
|
+
assert.ok(budgeted.text.includes('HEAD-MARKER'), 'the head of the newest row must survive');
|
|
733
|
+
assert.match(budgeted.text, /全文: throughline detail /, 'truncation must point to detail command');
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test('budgeted: header and anchor always survive even under pressure', () => {
|
|
737
|
+
const db = makeDb();
|
|
738
|
+
for (let turn = 1; turn <= 5; turn += 1) {
|
|
739
|
+
insertBody(db, {
|
|
740
|
+
session: 'new',
|
|
741
|
+
origin: 'old',
|
|
742
|
+
turn,
|
|
743
|
+
role: 'assistant',
|
|
744
|
+
text: 'z'.repeat(3000),
|
|
745
|
+
createdAt: 1000 + turn,
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const budgeted = buildBudgetedResumeContext(db, {
|
|
750
|
+
sessionId: 'new',
|
|
751
|
+
isInheritance: true,
|
|
752
|
+
maxChars: 4000,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
assert.ok(budgeted);
|
|
756
|
+
assert.match(budgeted.text, /^## Throughline: 直前スレッドの継続応答用コンテキスト/);
|
|
757
|
+
assert.ok(budgeted.text.includes('### 現在地 (直前のやりとり)'));
|
|
758
|
+
});
|