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.
- package/CHANGELOG.md +50 -1
- package/README.ja.md +74 -42
- package/README.md +40 -25
- package/bin/throughline.mjs +6 -0
- package/docs/02_clear_auto_handoff_plan.md +8 -0
- package/docs/adr/0016-push-pull-recall-injection.md +105 -0
- package/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +17 -0
- package/src/cli/factory-diagnostics.test.mjs +10 -1
- package/src/cli/recall.mjs +279 -0
- package/src/cli/recall.test.mjs +269 -0
- package/src/completed-turn-receipts.mjs +2 -1
- package/src/factory-diagnostics.mjs +3 -1
- package/src/factory-diagnostics.test.mjs +17 -0
- package/src/handoff-executor.mjs +4 -2
- package/src/resume-context.mjs +195 -82
- package/src/resume-context.test.mjs +112 -18
- package/src/runtime-error-store.mjs +2 -1
- package/src/runtime-error-store.test.mjs +1 -1
- package/src/turn-processor.test.mjs +4 -3
- package/src/windows-acl-test-helper.mjs +2 -2
package/src/resume-context.mjs
CHANGED
|
@@ -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({
|
|
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
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
const
|
|
228
|
-
//
|
|
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.
|
|
237
|
-
* 3. L2
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
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
|
-
*
|
|
246
|
-
*
|
|
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 -
|
|
378
|
+
let remaining = maxChars - fixedCost - GUIDANCE_RESERVE;
|
|
270
379
|
|
|
271
|
-
//
|
|
272
|
-
const
|
|
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 =
|
|
290
|
-
const
|
|
291
|
-
const cost = lineCost(
|
|
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
|
-
|
|
387
|
+
keptTurns.unshift(turn);
|
|
294
388
|
remaining -= cost;
|
|
295
389
|
continue;
|
|
296
390
|
}
|
|
297
|
-
//
|
|
298
|
-
if (
|
|
299
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
break;
|
|
403
|
+
break; // ターン原子: 入らないターンが出たらそこで打ち切り (歯抜けを作らない)
|
|
309
404
|
}
|
|
310
405
|
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
|
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.
|
|
647
|
-
assert.equal(budgeted.
|
|
648
|
-
assert.equal(budgeted.
|
|
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:
|
|
658
|
+
test('budgeted: packs whole turns newest-first, bakes --before/--last/--session into guidance', () => {
|
|
654
659
|
const db = makeDb();
|
|
655
|
-
// 各 ~800 字 × 10
|
|
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.
|
|
675
|
-
assert.
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
|
|
684
|
-
'
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 ===
|
|
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
|
|
161
|
-
//
|
|
162
|
-
//
|
|
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:
|
|
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:
|
|
49
|
+
timeout: 15_000,
|
|
50
50
|
windowsHide: true,
|
|
51
51
|
env: {
|
|
52
52
|
...process.env,
|