throughline 0.7.0 → 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.
@@ -0,0 +1,269 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { DatabaseSync } from 'node:sqlite';
9
+ import { parseRecallArgs, renderRecallL2, renderRecallL1 } from './recall.mjs';
10
+
11
+ const BIN = fileURLToPath(new URL('../../bin/throughline.mjs', import.meta.url));
12
+
13
+ function makeDb(path = ':memory:') {
14
+ const db = new DatabaseSync(path);
15
+ db.exec(`
16
+ CREATE TABLE skeletons (
17
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
18
+ session_id TEXT NOT NULL,
19
+ origin_session_id TEXT,
20
+ turn_number INTEGER NOT NULL,
21
+ role TEXT NOT NULL,
22
+ summary TEXT NOT NULL,
23
+ created_at INTEGER NOT NULL
24
+ );
25
+ CREATE TABLE bodies (
26
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
27
+ session_id TEXT NOT NULL,
28
+ origin_session_id TEXT NOT NULL,
29
+ turn_number INTEGER NOT NULL,
30
+ role TEXT NOT NULL,
31
+ text TEXT NOT NULL,
32
+ token_count INTEGER,
33
+ created_at INTEGER NOT NULL
34
+ );
35
+ CREATE TABLE details (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ session_id TEXT NOT NULL,
38
+ origin_session_id TEXT,
39
+ turn_number INTEGER,
40
+ tool_name TEXT NOT NULL,
41
+ input_text TEXT,
42
+ output_text TEXT,
43
+ token_count INTEGER NOT NULL DEFAULT 0,
44
+ created_at INTEGER NOT NULL,
45
+ kind TEXT,
46
+ source_id TEXT
47
+ );
48
+ `);
49
+ return db;
50
+ }
51
+
52
+ function insertBody(db, { session, origin, turn, role, text, createdAt }) {
53
+ db.prepare(
54
+ `INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
55
+ VALUES (?, ?, ?, ?, ?, 1, ?)`,
56
+ ).run(session, origin, turn, role, text, createdAt);
57
+ }
58
+
59
+ function insertSkeleton(db, { session, origin, turn, role, summary, createdAt }) {
60
+ db.prepare(
61
+ `INSERT INTO skeletons (session_id, origin_session_id, turn_number, role, summary, created_at)
62
+ VALUES (?, ?, ?, ?, ?, ?)`,
63
+ ).run(session, origin, turn, role, summary, createdAt);
64
+ }
65
+
66
+ // day: 2026-07-17T23:50:00Z 起点の ms を返す (深夜跨ぎテスト用)
67
+ const BASE = Date.parse('2026-07-17T23:50:00.000Z');
68
+
69
+ function seedTurns(db, { session = 's1', origin = 'o1', turns = 10, stepMs = 60_000 } = {}) {
70
+ for (let t = 1; t <= turns; t += 1) {
71
+ insertBody(db, {
72
+ session, origin, turn: t, role: 'user',
73
+ text: `q-${String(t).padStart(2, '0')}`, createdAt: BASE + t * stepMs,
74
+ });
75
+ insertBody(db, {
76
+ session, origin, turn: t, role: 'assistant',
77
+ text: `a-${String(t).padStart(2, '0')}`, createdAt: BASE + t * stepMs + 1_000,
78
+ });
79
+ }
80
+ }
81
+
82
+ test('parseRecallArgs: mode/session/before are required, --l2 requires --last', () => {
83
+ assert.throws(() => parseRecallArgs([]), /--l2 または --l1/);
84
+ assert.throws(() => parseRecallArgs(['--l2', '--session', 's', '--last', '3']), /--before は必須/);
85
+ assert.throws(() => parseRecallArgs(['--l2', '--before', '2026-07-18T00:00:00Z', '--last', '3']), /--session は必須/);
86
+ assert.throws(
87
+ () => parseRecallArgs(['--l2', '--session', 's', '--before', '2026-07-18T00:00:00Z']),
88
+ /--last <N> が必須/,
89
+ );
90
+ assert.throws(() => parseRecallArgs(['--l2', '--l1']), /同時に指定できません/);
91
+ assert.throws(
92
+ () => parseRecallArgs(['--l2', '--session', 's', '--before', '14:02:11', '--last', '3']),
93
+ /解釈できません/,
94
+ 'HH:MM:SS 単独は日付が無く受け付けない (深夜跨ぎで壊れるため ISO 必須)',
95
+ );
96
+ const ok = parseRecallArgs([
97
+ '--l1', '--session', 's', '--before', '2026-07-18T00:00:00.482Z', '--skip', '7',
98
+ ]);
99
+ assert.equal(ok.mode, 'l1');
100
+ assert.equal(ok.beforeMs, Date.parse('2026-07-18T00:00:00.482Z'));
101
+ assert.equal(ok.skip, 7);
102
+ });
103
+
104
+ test('recall --l2: strict less-than ms boundary, newest-first selection, oldest-first output', () => {
105
+ const db = makeDb();
106
+ seedTurns(db, { turns: 10 });
107
+ // 境界 = turn 8 の min(created_at) → turn 7 以前だけが対象 (turn 8 自身は含まない)
108
+ const boundary = BASE + 8 * 60_000;
109
+
110
+ const { text, turnCount } = renderRecallL2(db, { sessionId: 's1', beforeMs: boundary, last: 3 });
111
+
112
+ assert.equal(turnCount, 3);
113
+ assert.ok(!text.includes('a-08'), 'boundary turn itself must be excluded (strict less-than)');
114
+ assert.ok(text.includes('q-05') && text.includes('a-07'), 'turns 5..7 must be returned');
115
+ assert.ok(!text.includes('q-04 '), 'turns older than --last window are not returned');
116
+ // 古い順: q-05 が a-07 より先
117
+ assert.ok(text.indexOf('q-05') < text.indexOf('a-07'));
118
+ });
119
+
120
+ test('recall --l2: same-second sibling rows are split exactly by ms boundary (no gap, no overlap)', () => {
121
+ const db = makeDb();
122
+ const sec = Date.parse('2026-07-18T01:02:03.000Z');
123
+ // 同一秒内に 2 ターン (ms 差のみ)
124
+ insertBody(db, { session: 's1', origin: 'o1', turn: 1, role: 'assistant', text: 'ms-100', createdAt: sec + 100 });
125
+ insertBody(db, { session: 's1', origin: 'o1', turn: 2, role: 'assistant', text: 'ms-482', createdAt: sec + 482 });
126
+
127
+ const { text } = renderRecallL2(db, { sessionId: 's1', beforeMs: sec + 482, last: 10 });
128
+ assert.ok(text.includes('ms-100'), 'older same-second row must be included');
129
+ assert.ok(!text.includes('ms-482'), 'boundary row itself must be excluded');
130
+ });
131
+
132
+ test('recall --l2: ISO boundary works across midnight (no today-anchored resolution)', () => {
133
+ const db = makeDb();
134
+ seedTurns(db, { turns: 10 }); // BASE=23:50Z 起点、turn 10 は翌日 00:30Z 台
135
+ const boundary = BASE + 10 * 60_000; // 日付跨ぎ後の turn 10 の min
136
+
137
+ const { text, turnCount } = renderRecallL2(db, { sessionId: 's1', beforeMs: boundary, last: 20 });
138
+ assert.equal(turnCount, 9, 'all 9 turns before the post-midnight boundary must be found');
139
+ assert.ok(text.includes('q-01'));
140
+ });
141
+
142
+ test('recall --l2: results do not shift when the new session appends turns (no window recomputation)', () => {
143
+ const db = makeDb();
144
+ seedTurns(db, { turns: 10 });
145
+ const boundary = BASE + 8 * 60_000;
146
+ const before = renderRecallL2(db, { sessionId: 's1', beforeMs: boundary, last: 3 });
147
+
148
+ // 新セッションのターンが同じ session_id へ大量に追記されても (merge 後の走行)、
149
+ // recall の結果は焼き込まれた境界だけで決まり不変
150
+ for (let t = 100; t < 130; t += 1) {
151
+ insertBody(db, {
152
+ session: 's1', origin: 'newborn', turn: t, role: 'assistant',
153
+ text: `new-${t}`, createdAt: BASE + t * 60_000,
154
+ });
155
+ }
156
+ const after = renderRecallL2(db, { sessionId: 's1', beforeMs: boundary, last: 3 });
157
+ assert.equal(after.text, before.text);
158
+ });
159
+
160
+ test('recall --l2: fewer rows than --last is announced, not silent', () => {
161
+ const db = makeDb();
162
+ seedTurns(db, { turns: 3 });
163
+ const { text, turnCount } = renderRecallL2(db, {
164
+ sessionId: 's1', beforeMs: BASE + 3 * 60_000, last: 13,
165
+ });
166
+ assert.equal(turnCount, 2);
167
+ assert.match(text, /--last 13 のうち DB に存在するのは 2 ターンのみ/);
168
+ });
169
+
170
+ test('recall --l2: other sessions are not mixed in', () => {
171
+ const db = makeDb();
172
+ seedTurns(db, { turns: 3 });
173
+ insertBody(db, {
174
+ session: 'codex:zzz', origin: 'codex:zzz', turn: 1, role: 'assistant',
175
+ text: 'codex-row', createdAt: BASE + 60_000,
176
+ });
177
+ const { text } = renderRecallL2(db, { sessionId: 's1', beforeMs: BASE + 10 * 60_000, last: 20 });
178
+ assert.ok(!text.includes('codex-row'));
179
+ });
180
+
181
+ test('recall --l2: L3 suffix appears on the last row of each turn', () => {
182
+ const db = makeDb();
183
+ seedTurns(db, { turns: 3 });
184
+ db.prepare(
185
+ `INSERT INTO details (session_id, origin_session_id, turn_number, tool_name, input_text, output_text, token_count, created_at, kind, source_id)
186
+ VALUES ('s1', 'o1', 1, 'Bash', 'ls', 'ok', 1, ?, 'tool_input', 'tu-1')`,
187
+ ).run(BASE + 60_000 + 500);
188
+ const { text } = renderRecallL2(db, { sessionId: 's1', beforeMs: BASE + 10 * 60_000, last: 20 });
189
+ assert.match(text, /a-01 \(詳細:Bash\)/);
190
+ });
191
+
192
+ test('recall --l1: skip jumps over the --l2 range; summarized and unsummarized turns are both listed', () => {
193
+ const db = makeDb();
194
+ seedTurns(db, { turns: 10 });
195
+ // turn 1, 2 だけ要約済み
196
+ insertSkeleton(db, { session: 's1', origin: 'o1', turn: 1, role: 'assistant', summary: 'sum-1', createdAt: BASE });
197
+ insertSkeleton(db, { session: 's1', origin: 'o1', turn: 2, role: 'assistant', summary: 'sum-2', createdAt: BASE });
198
+
199
+ // 境界 = turn 8 の min。--skip 3 で turn 7..5 を飛ばし、turn 4 以前が対象
200
+ const boundary = BASE + 8 * 60_000;
201
+ const { text, turnCount, summarizedCount } = renderRecallL1(db, {
202
+ sessionId: 's1', beforeMs: boundary, skip: 3,
203
+ });
204
+
205
+ assert.equal(turnCount, 4, 'turns 1..4 must be listed');
206
+ assert.equal(summarizedCount, 2);
207
+ assert.match(text, /全4ターン \/ 要約済み 2/);
208
+ assert.ok(text.includes('sum-1') && text.includes('sum-2'));
209
+ assert.match(text, /\(未要約\) 全文: throughline detail \d{2}:\d{2}:\d{2}/);
210
+ assert.ok(!text.includes('q-05'), 'skipped turns (--l2 range) must not appear');
211
+ // 古い順: sum-1 が未要約 turn 4 より先
212
+ assert.ok(text.indexOf('sum-1') < text.indexOf('(未要約)'));
213
+ });
214
+
215
+ test('recall --l1: empty result is explicit', () => {
216
+ const db = makeDb();
217
+ seedTurns(db, { turns: 2 });
218
+ const { text, turnCount } = renderRecallL1(db, {
219
+ sessionId: 's1', beforeMs: BASE + 2 * 60_000, skip: 5,
220
+ });
221
+ assert.equal(turnCount, 0);
222
+ assert.match(text, /該当ターンなし/);
223
+ });
224
+
225
+ test('recall CLI: read-only contract — missing DB is an explicit error and is not created', () => {
226
+ const dir = mkdtempSync(join(tmpdir(), 'tl-recall-'));
227
+ const dbPath = join(dir, 'nope', 'throughline.db');
228
+ try {
229
+ let failed = false;
230
+ try {
231
+ execFileSync(
232
+ process.execPath,
233
+ [BIN, 'recall', '--l2', '--session', 's1',
234
+ '--before', '2026-07-18T00:00:00Z', '--last', '3', '--db', dbPath],
235
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
236
+ );
237
+ } catch (err) {
238
+ failed = true;
239
+ assert.match(String(err.stderr), /DB がありません/);
240
+ }
241
+ assert.ok(failed, 'missing DB must exit non-zero');
242
+ assert.ok(!existsSync(dbPath), 'recall must not create the DB');
243
+ } finally {
244
+ rmSync(dir, { recursive: true, force: true });
245
+ }
246
+ });
247
+
248
+ test('recall CLI: end-to-end via bin dispatcher against a real DB file', () => {
249
+ const dir = mkdtempSync(join(tmpdir(), 'tl-recall-'));
250
+ const dbPath = join(dir, 'throughline.db');
251
+ try {
252
+ const db = makeDb(dbPath);
253
+ seedTurns(db, { turns: 5 });
254
+ db.close();
255
+
256
+ const boundaryIso = new Date(BASE + 4 * 60_000).toISOString();
257
+ const out = execFileSync(
258
+ process.execPath,
259
+ [BIN, 'recall', '--l2', '--session', 's1',
260
+ '--before', boundaryIso, '--last', '2', '--db', dbPath],
261
+ { encoding: 'utf8' },
262
+ );
263
+ assert.match(out, /## Throughline recall \(L2\): 2ターン/);
264
+ assert.ok(out.includes('a-03') && out.includes('q-02'));
265
+ assert.ok(!out.includes('a-04'), 'boundary turn must be excluded');
266
+ } finally {
267
+ rmSync(dir, { recursive: true, force: true });
268
+ }
269
+ });
@@ -20,7 +20,8 @@ export const COMPLETED_TURN_RECEIPT_SCHEMA_VERSION = '1.0';
20
20
  export const COMPLETED_TURN_RECEIPT_LIMIT = 256;
21
21
 
22
22
  const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.completed-turn-receipt-directory');
23
- const WINDOWS_ACL_TIMEOUT_MS = 3_000;
23
+ // CI実測でPowerShellコールドスタートが3.0〜3.2秒に達しflakeしたため15秒 (run 29586852389 / 29628634501)
24
+ const WINDOWS_ACL_TIMEOUT_MS = 15_000;
24
25
 
25
26
  export function defaultCompletedTurnReceiptStorePath(projectSha256, env = process.env) {
26
27
  if (!isSha256(projectSha256)) throw new TypeError('projectSha256 must be a SHA-256 digest');
@@ -140,8 +140,10 @@ export function executeFirstPromptHandoff(db, { sessionId, projectPath, now = Da
140
140
  injectionText = budgeted.text;
141
141
  injectionStats = {
142
142
  total_chars: budgeted.totalChars,
143
- dropped_l1_rows: budgeted.droppedL1Rows,
144
- dropped_l2_rows: budgeted.droppedL2Rows,
143
+ injected_l2_turns: budgeted.injectedL2Turns,
144
+ remaining_l2_turns: budgeted.remainingL2Turns,
145
+ older_turns: budgeted.olderTurns,
146
+ older_summarized: budgeted.olderSummarized,
145
147
  truncated_newest_l2: budgeted.truncatedNewestL2,
146
148
  };
147
149
  }
@@ -158,7 +158,13 @@ function buildResumeSections(db, { sessionId, isInheritance, excludeOriginId = n
158
158
  const isLastOfTurn = lastIdxPerTurn.get(key) === i;
159
159
  const partCounts = isLastOfTurn ? (l3ByTurn.get(key)?.partCounts ?? new Map()) : new Map();
160
160
  const suffix = buildPartsSummary(partCounts);
161
- l2Lines.push({ text: `[${r.time}] [${r.role}]: ${r.text}${suffix}`, time: r.time, role: r.role });
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
+ });
162
168
  }
163
169
 
164
170
  return { header, anchorLines, l1Lines, l2Lines };
@@ -220,30 +226,119 @@ export function buildResumeContext(
220
226
  */
221
227
  export const INJECTION_BUDGET_CHARS = 9_500;
222
228
 
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 行が単体で予算を超える場合に、切り詰めてでも入れる最小の残余
229
+ // 案内セクション(### さらに前の記憶)の予約分。ヘッダ・アンカーと同格の固定部として
230
+ // 予算計算の最初に差し引き、どれだけ逼迫しても落とさない(無条件表示)。
231
+ // 実文言はテンプレート固定 + 動的値(件数・時刻範囲・session id・ISO 境界)なので
232
+ // この上限内に収まる(session id ~42 字 + ISO 24 字 + 本文 2 行で最大 ~520 字を実測見積もり)。
233
+ const GUIDANCE_RESERVE = 600;
234
+ // 最新 L2 ターンが単体で予算を超える場合に、切り詰めてでも入れる最小の残余
231
235
  const MIN_TRUNCATED_L2_CHARS = 400;
232
236
 
233
237
  /**
234
- * 予算付き注入テキスト。優先順位:
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):
235
326
  * 1. ヘッダ + 現在地アンカー(常に全文)
236
- * 2. L1 要約(新しい順に予算まで。落とした分は告知行)
237
- * 3. L2 本文(新しい順に予算まで詰めて古い順に出力。落とした分は告知行 +
238
- * L1 / `throughline detail` への誘導)
239
- * 予算内に一切 L2 が入らない場合でも、最新 L2 行だけは切り詰めて入れる
240
- * (現在地の文脈をアンカー 600 字より厚く確保するため)。
327
+ * 2. 案内セクション(無条件表示。予約 GUIDANCE_RESERVE を先に差し引く)
328
+ * 3. L2 本文: 新しい順に**丸ごと入るターンだけ**詰めて古い順に出力
329
+ * (ターン単位の原子。固定 N で予算を遊ばせず、断片も詰めない。
330
+ * ターン境界の自然な端数は許容)
331
+ * L1 は注入しない(窓の残りは recall --l2、それより古い側は recall --l1 が担う)。
332
+ * 最新ターンが単体で予算を超える場合だけ、切り詰めてでも入れる
333
+ * (現在地の文脈をアンカー 600 字より厚く確保するため)。
241
334
  *
242
335
  * @returns {{
243
336
  * text: string,
244
337
  * totalChars: number,
245
- * droppedL1Rows: number,
246
- * droppedL2Rows: number,
338
+ * injectedL2Turns: number,
339
+ * remainingL2Turns: number,
340
+ * olderTurns: number,
341
+ * olderSummarized: number,
247
342
  * truncatedNewestL2: boolean,
248
343
  * } | null}
249
344
  */
@@ -256,97 +351,115 @@ export function buildBudgetedResumeContext(
256
351
 
257
352
  const lineCost = (s) => s.length + 1; // join('\n') 分
258
353
 
259
- // 固定部 (ヘッダ + アンカー) のコスト。セクション見出し・空行も概算に含める
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);
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
+ // 固定部 (ヘッダ + アンカー + セクション見出し + 案内予約) のコスト
260
370
  const fixedCost =
261
371
  lineCost(sections.header) +
262
372
  (sections.anchorLines.length > 0
263
373
  ? lineCost('') + lineCost('### 現在地 (直前のやりとり)') +
264
374
  sections.anchorLines.reduce((a, l) => a + lineCost(l), 0)
265
375
  : 0) +
266
- lineCost('') + lineCost('### それ以前の要約 (L1)') +
267
376
  lineCost('') + lineCost('### 直前の対話 (L2 / active work thread, 古い順)');
268
377
 
269
- let remaining = maxChars - fixedCost - OMISSION_NOTE_RESERVE;
378
+ let remaining = maxChars - fixedCost - GUIDANCE_RESERVE;
270
379
 
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;
282
- }
283
- }
284
-
285
- // L2: 新しい順に採用して古い順で出力
286
- const keptL2 = [];
287
- let droppedL2Rows = 0;
380
+ // L2: 新しい順にターンを丸ごと採用して古い順で出力
381
+ const keptTurns = [];
288
382
  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);
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);
292
386
  if (remaining - cost >= 0) {
293
- keptL2.unshift(line);
387
+ keptTurns.unshift(turn);
294
388
  remaining -= cost;
295
389
  continue;
296
390
  }
297
- // 最新行が単体で入らない場合だけ、切り詰めて確保する
298
- if (keptL2.length === 0 && remaining >= MIN_TRUNCATED_L2_CHARS) {
299
- const marker = ` …(予算超過で切詰め; 全文: throughline detail ${line.time})`;
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})`;
300
395
  const keep = remaining - marker.length - 1;
301
- keptL2.unshift({ ...line, text: line.text.slice(0, keep) + marker });
396
+ keptTurns.unshift({
397
+ ...turn,
398
+ lines: [{ ...newestLine, text: newestLine.text.slice(0, keep) + marker }],
399
+ });
302
400
  remaining = 0;
303
401
  truncatedNewestL2 = true;
304
- droppedL2Rows = i; // これより古い行は全部落ちる
305
- break;
306
402
  }
307
- droppedL2Rows = i + 1;
308
- break;
403
+ break; // ターン原子: 入らないターンが出たらそこで打ち切り (歯抜けを作らない)
309
404
  }
310
405
 
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;
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;
338
417
  }
339
418
 
340
- const text = joinSections(
341
- { header: sections.header, anchorLines: sections.anchorLines, l1Lines: keptL1, l2Lines: keptL2 },
342
- { l1Note, l2Note },
343
- );
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');
344
455
 
345
456
  return {
346
457
  text,
347
458
  totalChars: text.length,
348
- droppedL1Rows,
349
- droppedL2Rows,
459
+ injectedL2Turns,
460
+ remainingL2Turns,
461
+ olderTurns: older.total,
462
+ olderSummarized: older.summarized,
350
463
  truncatedNewestL2,
351
464
  };
352
465
  }