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.
- package/CHANGELOG.md +40 -1
- package/README.md +13 -4
- 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 +93 -0
- package/package.json +1 -1
- package/rag/01-hooks/hook-stdout-10k-persisted-output.md +17 -0
- 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/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
|
@@ -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,
|