throughline 0.7.0 → 0.8.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.
@@ -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
  }
@@ -634,32 +634,95 @@ test('INJECTION_BUDGET_CHARS stays under the measured 10k persisted-output limit
634
634
  assert.ok(INJECTION_BUDGET_CHARS <= 9_501, '実測 inline 通過上限 9,501 字以下であること');
635
635
  });
636
636
 
637
- test('budgeted: under budget output matches the unbudgeted renderer, nothing dropped', () => {
637
+ test('budgeted: under budget keeps all L2 turns, no L1 section, guidance always present', () => {
638
638
  const db = makeDb();
639
639
  insertBody(db, { session: 'new', origin: 'old', turn: 1, role: 'user', text: 'short question', createdAt: 1000 });
640
640
  insertBody(db, { session: 'new', origin: 'old', turn: 1, role: 'assistant', text: 'short answer', createdAt: 1100 });
641
641
 
642
- const full = buildResumeContext(db, { sessionId: 'new', isInheritance: true });
643
642
  const budgeted = buildBudgetedResumeContext(db, { sessionId: 'new', isInheritance: true });
644
643
 
645
644
  assert.ok(budgeted);
646
- assert.equal(budgeted.text, full);
647
- assert.equal(budgeted.droppedL1Rows, 0);
648
- assert.equal(budgeted.droppedL2Rows, 0);
645
+ assert.equal(budgeted.injectedL2Turns, 1);
646
+ assert.equal(budgeted.remainingL2Turns, 0);
647
+ assert.equal(budgeted.olderTurns, 0);
649
648
  assert.equal(budgeted.truncatedNewestL2, false);
650
649
  assert.ok(budgeted.totalChars <= INJECTION_BUDGET_CHARS);
650
+ // 案内セクションは無条件表示 (データ無し側も「なし」と明示)
651
+ assert.ok(budgeted.text.includes('### さらに前の記憶(必要な時だけ取得)'));
652
+ assert.match(budgeted.text, /これより前の続き: なし/);
653
+ assert.match(budgeted.text, /それ以前のターン: なし/);
654
+ // L1 セクションは注入しない
655
+ assert.ok(!budgeted.text.includes('### それ以前の要約 (L1)'));
651
656
  });
652
657
 
653
- test('budgeted: drops oldest L2 rows first, keeps newest, and stays within maxChars', () => {
658
+ test('budgeted: packs whole turns newest-first, bakes --before/--last/--session into guidance', () => {
654
659
  const db = makeDb();
655
- // 各 ~800 字 × 10 = 本文だけで ~8,000 字 → maxChars 4000 で古い行が落ちる
660
+ // 各 ~800 字 × 10 ターン = 本文だけで ~8,000 字 → maxChars 5000 で古いターンが落ちる
656
661
  for (let turn = 1; turn <= 10; turn += 1) {
662
+ insertBody(db, {
663
+ session: 'new',
664
+ origin: 'old',
665
+ turn,
666
+ role: 'user',
667
+ text: `q-${String(turn).padStart(2, '0')} question`,
668
+ createdAt: 1000 + turn * 100,
669
+ });
657
670
  insertBody(db, {
658
671
  session: 'new',
659
672
  origin: 'old',
660
673
  turn,
661
674
  role: 'assistant',
662
675
  text: `turn-${String(turn).padStart(2, '0')} ` + 'x'.repeat(800),
676
+ createdAt: 1050 + turn * 100,
677
+ });
678
+ }
679
+
680
+ const budgeted = buildBudgetedResumeContext(db, {
681
+ sessionId: 'new',
682
+ isInheritance: true,
683
+ maxChars: 5000,
684
+ });
685
+
686
+ assert.ok(budgeted);
687
+ assert.ok(budgeted.totalChars <= 5000, `totalChars ${budgeted.totalChars} must fit budget`);
688
+ assert.ok(budgeted.injectedL2Turns > 0, 'newest turns must be injected');
689
+ assert.ok(budgeted.remainingL2Turns > 0, 'some old turns must be left for pull');
690
+ assert.equal(budgeted.injectedL2Turns + budgeted.remainingL2Turns, 10);
691
+ assert.ok(budgeted.text.includes('turn-10'), 'newest L2 turn must survive');
692
+ assert.ok(!budgeted.text.includes('turn-01 '), 'oldest L2 turn must be left for pull');
693
+
694
+ // ターン原子性: 注入されたターンは user 行と assistant 行が揃っている
695
+ const oldestInjectedTurn = 10 - budgeted.injectedL2Turns + 1;
696
+ const tag = String(oldestInjectedTurn).padStart(2, '0');
697
+ assert.ok(budgeted.text.includes(`q-${tag} question`), 'user row of injected turn must be present');
698
+ assert.ok(budgeted.text.includes(`turn-${tag} `), 'assistant row of injected turn must be present');
699
+
700
+ // 案内: --before は実注入最古ターンの min(created_at) の ISO ms、--last は残り件数
701
+ const boundaryMs = 1000 + oldestInjectedTurn * 100; // 最古注入ターンの user 行時刻
702
+ const iso = new Date(boundaryMs).toISOString().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
703
+ assert.match(
704
+ budgeted.text,
705
+ new RegExp(
706
+ `これより前の続き${budgeted.remainingL2Turns}ターン .*` +
707
+ '`throughline recall --l2 --session new ' +
708
+ `--before ${iso} --last ${budgeted.remainingL2Turns}\``,
709
+ ),
710
+ 'guidance must bake session, ISO ms boundary, and remaining count',
711
+ );
712
+ // 全 10 ターンとも窓内なので、窓より古い側は正直に「なし」と明示される
713
+ assert.match(budgeted.text, /それ以前のターン: なし/);
714
+ });
715
+
716
+ test('budgeted: --l1 guidance bakes the same boundary and the --l2 skip count', () => {
717
+ const db = makeDb();
718
+ // 窓 (20) + 窓外 5 ターン。予算を絞って窓内にも pull 残りを作る
719
+ for (let turn = 1; turn <= 25; turn += 1) {
720
+ insertBody(db, {
721
+ session: 'new',
722
+ origin: 'old',
723
+ turn,
724
+ role: 'assistant',
725
+ text: `turn-${String(turn).padStart(2, '0')} ` + 'x'.repeat(700),
663
726
  createdAt: 1000 + turn * 100,
664
727
  });
665
728
  }
@@ -671,22 +734,25 @@ test('budgeted: drops oldest L2 rows first, keeps newest, and stays within maxCh
671
734
  });
672
735
 
673
736
  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 で引けない)
737
+ assert.ok(budgeted.remainingL2Turns > 0);
738
+ assert.equal(budgeted.olderTurns, 5);
739
+ const oldestInjectedTurn = 25 - budgeted.injectedL2Turns + 1;
740
+ const boundaryMs = 1000 + oldestInjectedTurn * 100;
741
+ const iso = new Date(boundaryMs).toISOString().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
742
+ assert.match(
743
+ budgeted.text,
744
+ new RegExp(`--l2 --session new --before ${iso} --last ${budgeted.remainingL2Turns}`),
745
+ );
681
746
  assert.match(
682
747
  budgeted.text,
683
- /省略分 \(新しい順\): (\[\d{2}:\d{2}:\d{2} assistant\][ ]?)+/,
684
- 'dropped rows must be listed with [time role] refs for throughline detail',
748
+ new RegExp(`--l1 --session new --before ${iso} --skip ${budgeted.remainingL2Turns}`),
749
+ '--l1 guidance must bake the same boundary and skip count',
685
750
  );
686
751
  });
687
752
 
688
- test('budgeted: very many dropped rows fold into ほかN行 keeping the note bounded', () => {
753
+ test('budgeted: window turns beyond budget never fall into a blank band (guidance covers them)', () => {
689
754
  const db = makeDb();
755
+ // 40 ターン: 窓は最新 20 ターン、そこからさらに予算落ちが出る構成
690
756
  for (let turn = 1; turn <= 40; turn += 1) {
691
757
  insertBody(db, {
692
758
  session: 'new',
@@ -706,7 +772,35 @@ test('budgeted: very many dropped rows fold into ほかN行 keeping the note bou
706
772
 
707
773
  assert.ok(budgeted);
708
774
  assert.ok(budgeted.totalChars <= 4000);
709
- assert.match(budgeted.text, /ほか\d+行/, 'overflow refs must fold into a count');
775
+ // 20 ターンのうち注入できなかった分は全部 recall --l2 の担当として案内される
776
+ assert.equal(budgeted.injectedL2Turns + budgeted.remainingL2Turns, 20);
777
+ // 窓より古い 20 ターンは --l1 側 (未要約でも件数として見える)
778
+ assert.equal(budgeted.olderTurns, 20);
779
+ assert.match(budgeted.text, /それ以前の全20ターン(要約済み 0 \/ 未要約 20)/);
780
+ });
781
+
782
+ test('budgeted: older summarized/unsummarized counts are honest in the guidance', () => {
783
+ const db = makeDb();
784
+ for (let turn = 1; turn <= 25; turn += 1) {
785
+ insertBody(db, {
786
+ session: 'new',
787
+ origin: 'old',
788
+ turn,
789
+ role: 'assistant',
790
+ text: `turn-${turn}`,
791
+ createdAt: 1000 + turn * 1000,
792
+ });
793
+ }
794
+ // 窓 (最新 20 ターン = turn 6..25) より古い turn 1..5 のうち 2 件だけ要約済み
795
+ insertSkeleton(db, { session: 'new', origin: 'old', turn: 1, role: 'assistant', summary: 's1', createdAt: 90_000 });
796
+ insertSkeleton(db, { session: 'new', origin: 'old', turn: 2, role: 'assistant', summary: 's2', createdAt: 90_001 });
797
+
798
+ const budgeted = buildBudgetedResumeContext(db, { sessionId: 'new', isInheritance: true });
799
+
800
+ assert.ok(budgeted);
801
+ assert.equal(budgeted.olderTurns, 5);
802
+ assert.equal(budgeted.olderSummarized, 2);
803
+ assert.match(budgeted.text, /それ以前の全5ターン(要約済み 2 \/ 未要約 3)/);
710
804
  });
711
805
 
712
806
  test('budgeted: a single oversized newest L2 row is truncated with a detail pointer', () => {
@@ -25,7 +25,8 @@ export const RUNTIME_ERROR_DIAGNOSTIC = '[throughline:runtime-errors] store_unav
25
25
  const DEFAULT_SNAPSHOT_LIMIT = 256;
26
26
  const BEST_EFFORT_TIMEOUT_MS = 750;
27
27
  const WINDOWS_BEST_EFFORT_TIMEOUT_MS = 5_000;
28
- const WINDOWS_ACL_TIMEOUT_MS = 3_000;
28
+ // CI実測でPowerShellコールドスタートが3.0〜3.2秒に達しflakeしたため15秒 (run 29586852389 / 29628634501)
29
+ const WINDOWS_ACL_TIMEOUT_MS = 15_000;
29
30
  const RESOLUTION_REASONS = new Set(['manual', 'recovered']);
30
31
  const PRIVATE_DIRECTORY_CAPABILITY = Symbol('throughline.private-directory');
31
32
 
@@ -101,7 +101,7 @@ test('runtime error store: one Windows mutation spends ACL processes only on dis
101
101
 
102
102
  assert.equal(observeRuntimeError({ code: 'HOOK_CODEX_FAILED' }, options).status, 'recorded');
103
103
  assert.equal(calls.length, 4, 'directory apply, existing lock/store verify, and replacement store apply are distinct');
104
- assert.ok(calls.every((call) => call.options.timeout === 3_000));
104
+ assert.ok(calls.every((call) => call.options.timeout === 15_000));
105
105
  });
106
106
 
107
107
  test('runtime error store: Windows temporary ACL failure leaves the previous atomic store intact', (t) => {
@@ -157,9 +157,10 @@ test('publishCapturedClaudeCompletionReceipt: L2 capture済みpairをL1/L3より
157
157
  assert.deepEqual(second, first, 'Stop retry must return the original receipt');
158
158
  assert.equal(first.completed_at, 1234);
159
159
  if (process.platform !== 'win32') {
160
- // POSIX permission 契約。Windows stat mode は 0o666 系で chmod 契約を
161
- // 表現できない (receipt store Windows private 化は runtime-error-store の
162
- // ACL 方式に倣う Observer 側の未着手課題)。
160
+ // POSIX permission 契約 (0700/0600) の表現形式検査。Windows の秘匿は stat mode
161
+ // ではなく owner-only ACL で担保され、writeCompletedTurnReceipt 内部の
162
+ // applyAndVerifyWindowsAcl が書き込みのたびに apply + verify して失敗を throw
163
+ // するため、win32 ではこのテストが receipt を作れた時点で ACL 検証済み。
163
164
  assert.equal(statSync(join(root, 'state')).mode & 0o777, 0o700);
164
165
  assert.equal(statSync(storePath).mode & 0o777, 0o600);
165
166
  }
@@ -17,7 +17,7 @@ if($isDir){[System.IO.Directory]::SetAccessControl($target,$acl)}else{[System.IO
17
17
  '-NoProfile', '-NonInteractive', '-Command', script,
18
18
  ], {
19
19
  encoding: 'utf8',
20
- timeout: 3_000,
20
+ timeout: 15_000,
21
21
  windowsHide: true,
22
22
  env: {
23
23
  ...process.env,
@@ -46,7 +46,7 @@ if($rule.IdentityReference.Value -ne $sid -or $rule.AccessControlType -ne 'Allow
46
46
  '-NoProfile', '-NonInteractive', '-Command', script,
47
47
  ], {
48
48
  encoding: 'utf8',
49
- timeout: 3_000,
49
+ timeout: 15_000,
50
50
  windowsHide: true,
51
51
  env: {
52
52
  ...process.env,