@iowarp/clio-coder 0.3.2 → 0.3.4
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 +269 -458
- package/CONTRIBUTING.md +1 -1
- package/README.md +3 -3
- package/dist/{acp-BIYHVZIM.js → acp-S5R4RR5B.js} +7 -6
- package/dist/{agents-YT6SSRIT.js → agents-P6DMMVZY.js} +24 -21
- package/dist/assets/codewiki.json +1 -1
- package/dist/{auth-5TWEIYDN.js → auth-2XCZLPKS.js} +12 -8
- package/dist/{chunk-GGXXDWE4.js → chunk-22NAGB7X.js} +2 -2
- package/dist/{chunk-WMSVI4G2.js → chunk-2LZI5CAG.js} +133 -13
- package/dist/{chunk-OAO4GE4M.js → chunk-2TZWSW76.js} +2 -2
- package/dist/{chunk-OOJYHWRB.js → chunk-34475P3I.js} +2 -2
- package/dist/{chunk-WVO7V2QY.js → chunk-35MKKU5R.js} +4 -4
- package/dist/{chunk-LBNRH5WM.js → chunk-3HZ5RWN2.js} +5 -5
- package/dist/{chunk-AGYYIBLL.js → chunk-3JLKSKD7.js} +2 -2
- package/dist/{chunk-MBS4V7ZP.js → chunk-4JUF2NNX.js} +7 -7
- package/dist/{chunk-ZDOOVTXZ.js → chunk-4OC57DA6.js} +27 -4
- package/dist/chunk-5M54SPOL.js +926 -0
- package/dist/{chunk-STBPMHSX.js → chunk-7RXG6QRZ.js} +51 -11
- package/dist/{chunk-77VKQEHF.js → chunk-A2GZF7DC.js} +5 -5
- package/dist/{chunk-A3CYT5EX.js → chunk-AD2SYQYC.js} +55 -2
- package/dist/chunk-AOCYTWAV.js +449 -0
- package/dist/chunk-BEY543CS.js +258 -0
- package/dist/{chunk-6N5PTWMY.js → chunk-BP4OYD6A.js} +32 -13
- package/dist/chunk-BPGS2WCQ.js +612 -0
- package/dist/{chunk-J5HN4RYU.js → chunk-BRXQQJFP.js} +8 -8
- package/dist/chunk-CFGTUFWB.js +67 -0
- package/dist/{chunk-CBCAPZAA.js → chunk-E25LMLRW.js} +2 -2
- package/dist/{chunk-G2DE3C7R.js → chunk-EDRHSCIE.js} +4 -4
- package/dist/{chunk-4KLWL3UC.js → chunk-EFADSJET.js} +2 -2
- package/dist/{chunk-M6SHUN7Q.js → chunk-FO5ZOVUY.js} +2 -2
- package/dist/chunk-FYYLNIL5.js +313 -0
- package/dist/{chunk-EPVUXGXG.js → chunk-HV5X7OR2.js} +14 -12
- package/dist/{chunk-TZTZS7QK.js → chunk-HXG4IURW.js} +5 -3
- package/dist/{chunk-IGLFWIYI.js → chunk-K6WL7QZT.js} +3 -3
- package/dist/chunk-K7VKOLQQ.js +15 -0
- package/dist/{chunk-BMEMKKIT.js → chunk-KOHPCX4K.js} +2 -2
- package/dist/{chunk-V4RXGQ5Q.js → chunk-KRPY7NTG.js} +10 -7
- package/dist/chunk-LL4KHSZI.js +22 -0
- package/dist/{chunk-KJ5LWLOE.js → chunk-MEQ45TQ4.js} +15 -9
- package/dist/{chunk-5UUP6MWO.js → chunk-MV3K5QF2.js} +5 -436
- package/dist/{chunk-AO4RKG4M.js → chunk-N4CZJQRK.js} +5 -5
- package/dist/{chunk-ARBGF5F7.js → chunk-NILBFAPG.js} +14 -8
- package/dist/chunk-OZNBF4L3.js +23 -0
- package/dist/{verify-G6V4D2G7.js → chunk-PCZJO5TI.js} +127 -42
- package/dist/chunk-QQK64KLB.js +1360 -0
- package/dist/{chunk-6EJV5X2W.js → chunk-QQL5RT5M.js} +979 -1619
- package/dist/{chunk-LZSJBIVT.js → chunk-QWU7ZBO7.js} +70 -720
- package/dist/{chunk-2EHAIA3X.js → chunk-RD5U66HV.js} +3 -3
- package/dist/{chunk-OKGUZO2U.js → chunk-SPULKLCF.js} +4 -3
- package/dist/{chunk-OQ33BKR3.js → chunk-TTNYS3EA.js} +3 -60
- package/dist/chunk-TW3WDMVS.js +677 -0
- package/dist/chunk-TZSKNMZG.js +434 -0
- package/dist/{chunk-7MNJORFF.js → chunk-UL3WSD3F.js} +6 -1
- package/dist/{chunk-7EYHLWU7.js → chunk-UZHIZC5S.js} +7 -7
- package/dist/{chunk-QTYWRVRA.js → chunk-VAWWTKDP.js} +8 -8
- package/dist/{chunk-X75S7HFS.js → chunk-VEZEGCGW.js} +214 -20
- package/dist/{chunk-OHHN2SO4.js → chunk-VMNQ6OZA.js} +98 -202
- package/dist/chunk-VSNATDE6.js +122 -0
- package/dist/chunk-W6GROXXM.js +69 -0
- package/dist/chunk-WPQLXFOZ.js +375 -0
- package/dist/{chunk-ORBHGJC5.js → chunk-WR67VIZY.js} +3 -3
- package/dist/{chunk-3ZXDFGR5.js → chunk-X6COSD2O.js} +5 -5
- package/dist/chunk-ZGVHUX3M.js +66 -0
- package/dist/{chunk-MAW544W2.js → chunk-ZWMF7253.js} +4 -4
- package/dist/{chunk-MQSRRFWA.js → chunk-ZYKPLLNQ.js} +563 -546
- package/dist/cli/index.js +27 -23
- package/dist/{clio-4LY5K2AC.js → clio-J5JIOIDS.js} +7 -6
- package/dist/{code-nav-7AX6FYE6.js → code-nav-AXCXSBHX.js} +5 -3
- package/dist/{config-GTLUW2PR.js → config-OEBMIN2U.js} +37 -27
- package/dist/{configure-R6A64DHX.js → configure-PUQOSIXQ.js} +16 -13
- package/dist/{context-5VKGUVJJ.js → context-EKDCKUUZ.js} +82 -7
- package/dist/{context-RW5HC47S.js → context-MGSE4Z2T.js} +33 -23
- package/dist/{context-JFZEJ7W5.js → context-URSXPBCK.js} +17 -9
- package/dist/{context-clear-6ZHBAZZT.js → context-clear-KDAJRNUK.js} +33 -23
- package/dist/context-working-set-SBKMPPI2.js +1552 -0
- package/dist/{dispatch-runner-VKBRCWQC.js → dispatch-runner-MSWN72NK.js} +43 -29
- package/dist/{doctor-KI767GSN.js → doctor-7BSE27PJ.js} +10 -10
- package/dist/{eval-XSSNATB4.js → eval-IZGDOO4H.js} +9 -8
- package/dist/{evidence-UA6AWDQQ.js → evidence-SR7WXB5B.js} +51 -23
- package/dist/{evolve-QNTFGV6Z.js → evolve-K7VE2CBX.js} +30 -20
- package/dist/{fleet-Q7UOMUSG.js → fleet-7XMJNQNF.js} +48 -38
- package/dist/{fleet-preflight-DDN536IT.js → fleet-preflight-AQNAH644.js} +3 -3
- package/dist/{init-WBB65ZHQ.js → init-JGNPAYXT.js} +41 -31
- package/dist/{memory-MD3O64RI.js → memory-4ALKDJ4Q.js} +32 -22
- package/dist/{models-BZU34YWD.js → models-ZMMLFJNN.js} +22 -19
- package/dist/{monitor-MEQA5C3I.js → monitor-2F3T5KHP.js} +55 -43
- package/dist/{orchestrator-CGFKEP27.js → orchestrator-ORHT43JB.js} +2507 -1896
- package/dist/{reset-L2FQEE3E.js → reset-NXGTYNUO.js} +4 -3
- package/dist/{run-IV4Q6RLN.js → run-RF4WJGMT.js} +51 -41
- package/dist/{share-S5BZQC5I.js → share-UT3W6E4M.js} +5 -4
- package/dist/{skills-LQEKRDTN.js → skills-PSACKC5Q.js} +2 -2
- package/dist/{skills-eval-3DC4HEWS.js → skills-eval-WJSI55RZ.js} +34 -24
- package/dist/{targets-C4SSGQOB.js → targets-PIIRAOYS.js} +23 -20
- package/dist/{terminal-lease-IT5JW2NR.js → terminal-lease-ULWXWNVY.js} +5 -4
- package/dist/{upgrade-7TT7SQ3G.js → upgrade-346TZ6AV.js} +18 -17
- package/dist/{usage-GV4PKT3M.js → usage-6KKXR32N.js} +34 -24
- package/dist/verifiers-4UUM6TEE.js +1214 -0
- package/dist/verify-X5HDROLA.js +25 -0
- package/dist/{wiki-generate-DQF6Z66B.js → wiki-generate-7STOCIFZ.js} +42 -31
- package/dist/worker/entry.js +33 -24
- package/docs/README.md +8 -7
- package/docs/acp.md +1 -1
- package/docs/alcf-provider.md +1 -1
- package/docs/architecture.md +2 -2
- package/docs/artifact-versions.md +1 -1
- package/docs/built-in-agents.md +1 -1
- package/docs/capacity-and-scheduling.md +1 -1
- package/docs/commands-and-modes.md +53 -21
- package/docs/config-knobs-audit.md +1 -2
- package/docs/configuration-and-targets.md +15 -1
- package/docs/context-engine.md +64 -12
- package/docs/context-working-set.md +194 -0
- package/docs/development-pipeline.md +1 -1
- package/docs/documentation-coverage.md +5 -5
- package/docs/documentation-guide.md +6 -5
- package/docs/environment-variables.md +2 -1
- package/docs/eval-runner.md +1 -1
- package/docs/evals-internal.md +14 -1
- package/docs/evidence-and-memory.md +74 -2
- package/docs/evolution.md +1 -1
- package/docs/exit-codes-and-output.md +1 -1
- package/docs/extensions-and-sharing.md +2 -2
- package/docs/fleet-dispatch.md +22 -7
- package/docs/glossary.md +21 -1
- package/docs/installation-and-lifecycle.md +6 -6
- package/docs/middleware-and-components.md +1 -1
- package/docs/model-catalog.md +7 -9
- package/docs/observability.md +4 -4
- package/docs/performance-methodology.md +2 -2
- package/docs/proactive-memory.md +1 -1
- package/docs/prompt-envelope-and-tools.md +4 -4
- package/docs/provider-adapter-cookbook.md +1 -1
- package/docs/release-cut-checklist.md +35 -35
- package/docs/safety-model.md +23 -4
- package/docs/scientific-validation.md +21 -3
- package/docs/session-lifecycle.md +3 -3
- package/docs/skills-marketplace.md +1 -1
- package/docs/tool-usage.md +79 -12
- package/docs/trace-store.md +1 -1
- package/docs/troubleshooting.md +1 -1
- package/docs/tui-design.md +2 -2
- package/docs/worker-dispatch-mechanics.md +11 -1
- package/package.json +8 -11
- package/skills/meta/clio-test/SKILL.md +20 -17
- package/skills/meta/clio-test/evals.md +3 -3
- package/skills/meta/clio-test/references/harness.md +35 -6
- package/skills/meta/clio-test/references/test-map.md +20 -10
- package/skills/registry.yaml +2 -2
- package/skills/skill-marketplace.json +1 -1
- package/src/cli/context-working-set.ts +513 -0
- package/src/cli/context.ts +8 -0
- package/src/cli/evidence.ts +20 -2
- package/src/cli/index.ts +4 -0
- package/src/cli/verifiers.ts +325 -0
- package/src/core/bash-exec.ts +39 -14
- package/src/core/bus-events.ts +19 -4
- package/src/core/config.ts +54 -0
- package/src/core/defaults.ts +50 -3
- package/src/core/git-commit-attribution.ts +46 -21
- package/src/core/verification-scripts.ts +6 -0
- package/src/domains/agents/builtins/verifier.md +3 -0
- package/src/domains/config/classify.ts +1 -0
- package/src/domains/config/keybindings.ts +3 -3
- package/src/domains/context/working-set/contract.ts +161 -0
- package/src/domains/context/working-set/defaults.ts +28 -0
- package/src/domains/context/working-set/engine.ts +203 -0
- package/src/domains/context/working-set/fold.ts +62 -0
- package/src/domains/context/working-set/horizon.ts +38 -0
- package/src/domains/context/working-set/marker.ts +103 -0
- package/src/domains/context/working-set/path-index.ts +436 -0
- package/src/domains/context/working-set/payload.ts +152 -0
- package/src/domains/context/working-set/policies/age-horizon.ts +55 -0
- package/src/domains/context/working-set/policies/index.ts +21 -0
- package/src/domains/context/working-set/policies/structural.ts +160 -0
- package/src/domains/context/working-set/project.ts +132 -0
- package/src/domains/context/working-set/protect.ts +109 -0
- package/src/domains/context/working-set/recall.ts +177 -0
- package/src/domains/context/working-set/replay/controls.ts +112 -0
- package/src/domains/context/working-set/replay/load-clio.ts +199 -0
- package/src/domains/context/working-set/replay/metrics.ts +185 -0
- package/src/domains/context/working-set/replay/reference-graph.ts +79 -0
- package/src/domains/context/working-set/replay/report.ts +139 -0
- package/src/domains/context/working-set/replay/runner.ts +325 -0
- package/src/domains/context/working-set/replay/synthetic.ts +422 -0
- package/src/domains/context/working-set/replay/trace.ts +21 -0
- package/src/domains/context/working-set/visible.ts +54 -0
- package/src/domains/evidence/build.ts +112 -45
- package/src/domains/evidence/eval.ts +24 -7
- package/src/domains/evidence/index.ts +53 -0
- package/src/domains/evidence/ordering.ts +12 -0
- package/src/domains/evidence/run-trust.ts +221 -0
- package/src/domains/evidence/store.ts +46 -6
- package/src/domains/evidence/trust-status.ts +854 -0
- package/src/domains/evidence/types.ts +26 -0
- package/src/domains/middleware/memory-intervention.ts +3 -0
- package/src/domains/middleware/stalled-turn.ts +165 -4
- package/src/domains/safety/autonomy.ts +1 -1
- package/src/domains/safety/default-path-policy.ts +8 -0
- package/src/domains/safety/finish-contract.ts +4 -3
- package/src/domains/safety/policy-engine.ts +48 -6
- package/src/domains/session/compaction/compact.ts +23 -1
- package/src/domains/session/compaction/cut-point.ts +2 -0
- package/src/domains/session/compaction/tokens.ts +16 -1
- package/src/domains/session/context-ledger.ts +2 -0
- package/src/domains/session/entries.ts +107 -1
- package/src/domains/session/manager.ts +9 -2
- package/src/domains/session/migrations/index.ts +22 -3
- package/src/engine/acp/server.ts +3 -0
- package/src/engine/agent.ts +18 -1
- package/src/engine/session.ts +9 -3
- package/src/entry/orchestrator.ts +16 -4
- package/src/interactive/chat-loop-messages.ts +18 -6
- package/src/interactive/chat-panel.ts +571 -244
- package/src/interactive/chat-renderer.ts +79 -39
- package/src/interactive/context-meter.ts +10 -0
- package/src/interactive/context-overlay.ts +81 -6
- package/src/interactive/context-recall-command.ts +110 -0
- package/src/interactive/editor-submit.ts +26 -1
- package/src/interactive/footer/widgets.ts +22 -20
- package/src/interactive/footer-panel.ts +6 -1
- package/src/interactive/interactive-application.ts +2 -0
- package/src/interactive/interactive-event-projection.ts +12 -0
- package/src/interactive/interactive-slash-runtime.ts +49 -8
- package/src/interactive/model-session-replay.ts +21 -0
- package/src/interactive/overlay-general-openers.ts +6 -0
- package/src/interactive/overlay-session-lifecycle.ts +8 -4
- package/src/interactive/overlays/ask-user.ts +146 -24
- package/src/interactive/renderers/tool-execution.ts +167 -56
- package/src/interactive/session-transcript.ts +2 -2
- package/src/interactive/slash-commands.ts +29 -2
- package/src/interactive/status/index.ts +12 -1
- package/src/interactive/status/reasoning.ts +87 -0
- package/src/interactive/status/summary.ts +13 -2
- package/src/interactive/transcript-detail.ts +120 -0
- package/src/interactive/turn-context.ts +238 -88
- package/src/interactive/turn-middleware.ts +6 -6
- package/src/tools/agent-tools.ts +11 -4
- package/src/tools/bash.ts +144 -82
- package/src/tools/builtin-tool-catalog.ts +18 -6
- package/src/tools/context/index.ts +105 -3
- package/src/tools/context/surface.ts +3 -2
- package/src/tools/core-bootstrap.ts +21 -0
- package/src/tools/dispatch-runner.ts +9 -7
- package/src/tools/monitor.ts +28 -20
- package/src/tools/presentation.ts +107 -0
- package/src/tools/registry.ts +65 -7
- package/src/tools/result-disposition.ts +550 -0
- package/src/tools/result-shaping.ts +262 -19
- package/src/tools/safe-exec.ts +2 -0
- package/src/tools/verify/authoring.ts +1119 -0
- package/src/tools/verify/catalog.ts +346 -0
- package/src/tools/verify/index.ts +13 -3
- package/src/tools/verify/scripts.ts +135 -37
- package/src/tools/verify/surface.ts +9 -5
- package/src/tools/worker-evidence.ts +35 -12
- package/dist/chunk-MNA4JGU4.js +0 -255
- package/dist/chunk-SRF2PJNW.js +0 -184
- package/dist/chunk-T6YILFSB.js +0 -80
- package/dist/chunk-VAKQQHWR.js +0 -434
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact recall by ref.
|
|
3
|
+
*
|
|
4
|
+
* A `contextEviction` entry removes a tool-result body from the projection
|
|
5
|
+
* and leaves a marker naming the ref. Recall is the reverse move: given a ref
|
|
6
|
+
* on the active path whose key the fold still lists as evicted, hand back the
|
|
7
|
+
* original body byte-exact and describe the `contextRecall` entry the caller
|
|
8
|
+
* appends. The ref stays evicted in the fold: the body rides the recall tool
|
|
9
|
+
* result at the tail of the working set, so the marker and the prefix cache
|
|
10
|
+
* are untouched and a repeat recall is the churn signal. Pure over entries:
|
|
11
|
+
* nothing here reads the session, writes the ledger, or calls a model.
|
|
12
|
+
*
|
|
13
|
+
* The body is read through the same `payload.ts` readers the projection and
|
|
14
|
+
* the marker use, so what recall returns is exactly what the model saw before
|
|
15
|
+
* eviction. No truncation happens here; the observation envelope applies the
|
|
16
|
+
* per-turn caps.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ceilChars } from "../../session/context-accounting.js";
|
|
20
|
+
import type { MessageEntry, SessionEntry } from "../../session/entries.js";
|
|
21
|
+
import { filterEntriesToActivePath } from "../../session/tree/active-path.js";
|
|
22
|
+
import {
|
|
23
|
+
type ContextRecallFields,
|
|
24
|
+
EMPTY_WORKING_SET_VIEW,
|
|
25
|
+
type RecallError,
|
|
26
|
+
type RecallResult,
|
|
27
|
+
type RecallTrigger,
|
|
28
|
+
type WorkingSetView,
|
|
29
|
+
} from "./contract.js";
|
|
30
|
+
import { parseRefKey, refKey } from "./fold.js";
|
|
31
|
+
import { callPathsByToolCallId } from "./path-index.js";
|
|
32
|
+
import { offloadPathOf, primaryPathOf, toolResultPayload, toolResultText } from "./payload.js";
|
|
33
|
+
|
|
34
|
+
export type RecallOutcome = { ok: true; result: RecallResult } | { ok: false; error: RecallError };
|
|
35
|
+
|
|
36
|
+
function isThinkingEntry(entry: SessionEntry): boolean {
|
|
37
|
+
return entry.kind === "message" && entry.role === "assistant";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isToolResultEntry(entry: SessionEntry): entry is MessageEntry {
|
|
41
|
+
return entry.kind === "message" && entry.role === "tool_result";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveRecall(
|
|
45
|
+
entries: ReadonlyArray<SessionEntry>,
|
|
46
|
+
view: WorkingSetView,
|
|
47
|
+
ref: string,
|
|
48
|
+
activeLeafTurnId?: string,
|
|
49
|
+
): RecallOutcome {
|
|
50
|
+
const parsed = parseRefKey(ref);
|
|
51
|
+
if (parsed === null) return { ok: false, error: { kind: "invalid_ref", ref } };
|
|
52
|
+
const key = refKey(parsed);
|
|
53
|
+
const active = filterEntriesToActivePath(entries, activeLeafTurnId);
|
|
54
|
+
const entry = active.find((candidate) => candidate.turnId === key);
|
|
55
|
+
if (entry === undefined) {
|
|
56
|
+
return { ok: false, error: { kind: "not_on_active_path", ref: key } };
|
|
57
|
+
}
|
|
58
|
+
// Thinking leaves the working set without a marker and is not recallable
|
|
59
|
+
// in this slice; `recallErrorMessage` names that case from the entry.
|
|
60
|
+
if (isThinkingEntry(entry) || !view.evicted.has(key) || !isToolResultEntry(entry)) {
|
|
61
|
+
return { ok: false, error: { kind: "not_evicted", ref: key } };
|
|
62
|
+
}
|
|
63
|
+
const payload = toolResultPayload(entry.payload);
|
|
64
|
+
const body = toolResultText(payload.result);
|
|
65
|
+
const offloadPath = offloadPathOf(payload);
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
result: {
|
|
69
|
+
ref: parsed,
|
|
70
|
+
entry,
|
|
71
|
+
body,
|
|
72
|
+
tokens: ceilChars(body.length),
|
|
73
|
+
...(offloadPath !== undefined ? { offloadPath } : {}),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The turn a `contextRecall` entry parents onto: the newest message on the
|
|
80
|
+
* active path. Every recall caller needs this and they must agree, because a
|
|
81
|
+
* record anchored anywhere else folds onto the wrong branch and a `/tree`
|
|
82
|
+
* switch would then show a recall the branch never made.
|
|
83
|
+
*/
|
|
84
|
+
export function recallParentTurnId(entries: ReadonlyArray<SessionEntry>, activeLeafTurnId?: string): string | null {
|
|
85
|
+
const active = filterEntriesToActivePath(entries, activeLeafTurnId);
|
|
86
|
+
for (let i = active.length - 1; i >= 0; i -= 1) {
|
|
87
|
+
const candidate = active[i];
|
|
88
|
+
if (candidate?.kind === "message") return candidate.turnId;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildRecallFields(
|
|
94
|
+
result: RecallResult,
|
|
95
|
+
meta: { trigger: RecallTrigger; toolCallId?: string },
|
|
96
|
+
): ContextRecallFields {
|
|
97
|
+
return {
|
|
98
|
+
kind: "contextRecall",
|
|
99
|
+
ref: { entry: result.ref.entry },
|
|
100
|
+
trigger: meta.trigger,
|
|
101
|
+
tokensReadmitted: result.tokens,
|
|
102
|
+
...(meta.toolCallId !== undefined ? { toolCallId: meta.toolCallId } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Refs listed in a recall failure before the list is cut with an ellipsis. */
|
|
107
|
+
const MAX_LISTED_REFS = 8;
|
|
108
|
+
|
|
109
|
+
export interface RecallableRefListing {
|
|
110
|
+
/** Already-rendered `ref (tool path)` rows, bounded for prompt use. */
|
|
111
|
+
refs: string[];
|
|
112
|
+
/** Recallable refs omitted after the bounded prefix. */
|
|
113
|
+
remaining: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The refs a recall can actually bring back, so the next call can name one of
|
|
118
|
+
* them. Thinking refs are evicted too but are not recallable, so listing them
|
|
119
|
+
* would hand the caller a ref that fails for a different reason. A guessed
|
|
120
|
+
* "nearest" ref was tried first and dropped: over time-ordered ids a prefix
|
|
121
|
+
* match names an unrelated result, and the listing is what helps.
|
|
122
|
+
*/
|
|
123
|
+
export function recallableRefListing(entries: ReadonlyArray<SessionEntry>, view: WorkingSetView): RecallableRefListing {
|
|
124
|
+
const byTurnId = new Map<string, SessionEntry>();
|
|
125
|
+
for (const entry of entries) byTurnId.set(entry.turnId, entry);
|
|
126
|
+
const callPaths = callPathsByToolCallId(entries);
|
|
127
|
+
const refs: string[] = [];
|
|
128
|
+
for (const key of view.evicted.keys()) {
|
|
129
|
+
const entry = byTurnId.get(key);
|
|
130
|
+
if (entry === undefined || !isToolResultEntry(entry)) continue;
|
|
131
|
+
// After a summary compaction the markers before the cut are gone from
|
|
132
|
+
// the working set, so the listing is the only place the caller learns
|
|
133
|
+
// what a ref was. Tool and path are what it needs to pick one.
|
|
134
|
+
const payload = toolResultPayload(entry.payload);
|
|
135
|
+
const toolCallId = typeof payload.obj.toolCallId === "string" ? payload.obj.toolCallId : undefined;
|
|
136
|
+
const path = primaryPathOf(payload) ?? (toolCallId === undefined ? undefined : callPaths.get(toolCallId));
|
|
137
|
+
refs.push(`${key} (${payload.toolName}${path === undefined ? "" : ` ${path}`})`);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
refs: refs.slice(0, MAX_LISTED_REFS),
|
|
141
|
+
remaining: Math.max(0, refs.length - MAX_LISTED_REFS),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function recallableRefMessage(entries: ReadonlyArray<SessionEntry>, view: WorkingSetView): string {
|
|
146
|
+
const listing = recallableRefListing(entries, view);
|
|
147
|
+
if (listing.refs.length === 0) return "No recallable refs on the active path.";
|
|
148
|
+
const more = listing.remaining > 0 ? `, and ${listing.remaining} more` : "";
|
|
149
|
+
return `Recallable refs on the active path: ${listing.refs.join(", ")}${more}.`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One-line operator/model-facing message for a recall failure. Says why an
|
|
154
|
+
* assistant turn is refused instead of calling it "not evicted", and ends with
|
|
155
|
+
* the refs that can be recalled. `entries` is the active path the view was
|
|
156
|
+
* folded over; without it the listing is empty.
|
|
157
|
+
*/
|
|
158
|
+
export function recallErrorMessage(
|
|
159
|
+
error: RecallError,
|
|
160
|
+
entries: ReadonlyArray<SessionEntry> = [],
|
|
161
|
+
view: WorkingSetView = EMPTY_WORKING_SET_VIEW,
|
|
162
|
+
): string {
|
|
163
|
+
const listing = ` ${recallableRefMessage(entries, view)}`;
|
|
164
|
+
switch (error.kind) {
|
|
165
|
+
case "invalid_ref":
|
|
166
|
+
return `recall ref must be a single turnId without whitespace; got '${error.ref}'.`;
|
|
167
|
+
case "not_on_active_path":
|
|
168
|
+
return `ref ${error.ref} is not on the active path of this session (unknown or on an abandoned branch).${listing}`;
|
|
169
|
+
case "not_evicted": {
|
|
170
|
+
const entry = entries.find((candidate) => candidate.turnId === error.ref);
|
|
171
|
+
if (entry !== undefined && isThinkingEntry(entry)) {
|
|
172
|
+
return `ref ${error.ref} is an assistant turn; thinking is not recallable.${listing}`;
|
|
173
|
+
}
|
|
174
|
+
return `ref ${error.ref} is not evicted; its content is already in context.${listing}`;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { SessionEntry } from "../../../session/entries.js";
|
|
2
|
+
import type { EvictionCandidate, PolicyInput, WorkingSetPolicy, WorkingSetPolicyId } from "../contract.js";
|
|
3
|
+
import { tokensFreedByEviction } from "../engine.js";
|
|
4
|
+
import { protectionCutoffIndex } from "../horizon.js";
|
|
5
|
+
import { buildPathIndex, callPathsByToolCallId } from "../path-index.js";
|
|
6
|
+
import { isProtected } from "../protect.js";
|
|
7
|
+
import type { ReferenceGraph } from "./reference-graph.js";
|
|
8
|
+
import { countReplayTurns } from "./trace.js";
|
|
9
|
+
|
|
10
|
+
/** Replay-only diagnostic surface; it does not widen the live policy contract. */
|
|
11
|
+
export interface ReplayCandidatePoolPolicy extends WorkingSetPolicy {
|
|
12
|
+
/** Number of currently usable candidates before target-based truncation. */
|
|
13
|
+
replayCandidateCount(input: PolicyInput): number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function controlId(id: string): WorkingSetPolicyId {
|
|
17
|
+
return id as WorkingSetPolicyId;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function eligibleToolResults(input: PolicyInput): SessionEntry[] {
|
|
21
|
+
const cutoff = protectionCutoffIndex(input.entries, input.settings.protectLastTurns);
|
|
22
|
+
const index = buildPathIndex(input.entries, { cwd: input.cwd });
|
|
23
|
+
const out: SessionEntry[] = [];
|
|
24
|
+
for (let entryIndex = cutoff - 1; entryIndex >= 0; entryIndex -= 1) {
|
|
25
|
+
const entry = input.entries[entryIndex];
|
|
26
|
+
if (entry?.kind !== "message" || entry.role !== "tool_result") continue;
|
|
27
|
+
if (input.view.evicted.has(entry.turnId)) continue;
|
|
28
|
+
if (isProtected(entry, { entryIndex, cutoffIndex: cutoff, input, index })) continue;
|
|
29
|
+
out.push(entry);
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function takeToTarget(input: PolicyInput, entries: ReadonlyArray<SessionEntry>): EvictionCandidate[] {
|
|
35
|
+
let tokensNeeded = Math.max(0, input.pressure.tokens - input.pressure.target * input.pressure.contextWindow);
|
|
36
|
+
if (tokensNeeded <= 0) return [];
|
|
37
|
+
const selected: EvictionCandidate[] = [];
|
|
38
|
+
const callPaths = callPathsByToolCallId(input.entries);
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
const candidate: EvictionCandidate = { ref: { entry: entry.turnId }, reason: "age_horizon" };
|
|
41
|
+
selected.push(candidate);
|
|
42
|
+
tokensNeeded -= tokensFreedByEviction(input.estimateTokens, entry, candidate, callPaths);
|
|
43
|
+
if (tokensNeeded <= 0) break;
|
|
44
|
+
}
|
|
45
|
+
return selected;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function makeOraclePolicy(graph: ReferenceGraph): ReplayCandidatePoolPolicy {
|
|
49
|
+
let lastInput: PolicyInput | null = null;
|
|
50
|
+
let lastCandidateCount = 0;
|
|
51
|
+
const safeEntries = (input: PolicyInput): SessionEntry[] => {
|
|
52
|
+
const currentTurn = countReplayTurns(input.entries) + 1;
|
|
53
|
+
return eligibleToolResults(input).filter((entry) => {
|
|
54
|
+
const futureTurns = graph.futureTurnsOf.get(entry.turnId) ?? [];
|
|
55
|
+
return futureTurns.every((turn) => turn < currentTurn);
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
id: controlId("oracle"),
|
|
60
|
+
select(input): ReadonlyArray<EvictionCandidate> {
|
|
61
|
+
// Replay calls before the next turn-start entry is appended. A reference
|
|
62
|
+
// in that next turn is therefore still future from the model's view.
|
|
63
|
+
const safe = safeEntries(input);
|
|
64
|
+
lastInput = input;
|
|
65
|
+
lastCandidateCount = safe.length;
|
|
66
|
+
return takeToTarget(input, safe);
|
|
67
|
+
},
|
|
68
|
+
replayCandidateCount(input): number {
|
|
69
|
+
return input === lastInput ? lastCandidateCount : safeEntries(input).length;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function mulberry32(seed: number): () => number {
|
|
75
|
+
let value = seed >>> 0;
|
|
76
|
+
return () => {
|
|
77
|
+
value = (value + 0x6d2b79f5) >>> 0;
|
|
78
|
+
let next = value;
|
|
79
|
+
next = Math.imul(next ^ (next >>> 15), next | 1);
|
|
80
|
+
next ^= next + Math.imul(next ^ (next >>> 7), next | 61);
|
|
81
|
+
return ((next ^ (next >>> 14)) >>> 0) / 4_294_967_296;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function makeRandomPolicy(seed: number): ReplayCandidatePoolPolicy {
|
|
86
|
+
let lastInput: PolicyInput | null = null;
|
|
87
|
+
let lastCandidateCount = 0;
|
|
88
|
+
return {
|
|
89
|
+
id: controlId("random"),
|
|
90
|
+
select(input): ReadonlyArray<EvictionCandidate> {
|
|
91
|
+
const entries = [...eligibleToolResults(input)];
|
|
92
|
+
lastInput = input;
|
|
93
|
+
lastCandidateCount = entries.length;
|
|
94
|
+
const random = mulberry32(seed);
|
|
95
|
+
for (let index = entries.length - 1; index > 0; index -= 1) {
|
|
96
|
+
const swap = Math.floor(random() * (index + 1));
|
|
97
|
+
const value = entries[index];
|
|
98
|
+
entries[index] = entries[swap] as SessionEntry;
|
|
99
|
+
entries[swap] = value as SessionEntry;
|
|
100
|
+
}
|
|
101
|
+
return takeToTarget(input, entries);
|
|
102
|
+
},
|
|
103
|
+
replayCandidateCount(input): number {
|
|
104
|
+
return input === lastInput ? lastCandidateCount : eligibleToolResults(input).length;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const nonePolicy: WorkingSetPolicy = {
|
|
110
|
+
id: controlId("none"),
|
|
111
|
+
select: () => [],
|
|
112
|
+
};
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { parseSessionEntries } from "../../../session/archive-readers.js";
|
|
5
|
+
import { isSessionHeader, type SessionEntry } from "../../../session/entries.js";
|
|
6
|
+
import { filterEntriesToActivePath } from "../../../session/tree/active-path.js";
|
|
7
|
+
import { buildPathIndex } from "../path-index.js";
|
|
8
|
+
import { buildReferenceGraph } from "./reference-graph.js";
|
|
9
|
+
import { countReplayTurns, type Trace } from "./trace.js";
|
|
10
|
+
|
|
11
|
+
export interface ReplayFilterOptions {
|
|
12
|
+
minTurns?: number;
|
|
13
|
+
minToolResults?: number;
|
|
14
|
+
requireFileReread?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LoadClioTraceOptions {
|
|
18
|
+
/** False is the CLI's --no-filter behavior. */
|
|
19
|
+
filter?: false | ReplayFilterOptions;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ReplayLoadCascade {
|
|
23
|
+
found: number;
|
|
24
|
+
unreadable: number;
|
|
25
|
+
filtered: Record<string, number>;
|
|
26
|
+
kept: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_FILTER = {
|
|
30
|
+
minTurns: 8,
|
|
31
|
+
minToolResults: 8,
|
|
32
|
+
requireFileReread: true,
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
async function collectLedgerFiles(input: string, out: Set<string>): Promise<void> {
|
|
36
|
+
const path = resolve(input);
|
|
37
|
+
let facts: Awaited<ReturnType<typeof stat>>;
|
|
38
|
+
try {
|
|
39
|
+
facts = await stat(path);
|
|
40
|
+
} catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (facts.isFile()) {
|
|
44
|
+
// Directory discovery is intentionally limited to current.jsonl below;
|
|
45
|
+
// an explicit JSONL path may also be a frozen synthetic fixture.
|
|
46
|
+
if (path.endsWith(".jsonl")) out.add(path);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!facts.isDirectory()) return;
|
|
50
|
+
|
|
51
|
+
const direct = join(path, "current.jsonl");
|
|
52
|
+
try {
|
|
53
|
+
if ((await stat(direct)).isFile()) {
|
|
54
|
+
out.add(direct);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// A sessions root or cwd-hash directory has no direct ledger.
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let children: Dirent[];
|
|
62
|
+
try {
|
|
63
|
+
children = await readdir(path, { withFileTypes: true });
|
|
64
|
+
} catch {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
68
|
+
if (!child.isDirectory()) continue;
|
|
69
|
+
await collectLedgerFiles(join(path, child.name), out);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sessionFacts(raw: string, source: string): { id: string; cwd: string | null } {
|
|
74
|
+
const fallback = { id: basename(dirname(source)), cwd: null };
|
|
75
|
+
for (const line of raw.split("\n")) {
|
|
76
|
+
if (line.trim().length === 0) continue;
|
|
77
|
+
try {
|
|
78
|
+
const value = JSON.parse(line) as unknown;
|
|
79
|
+
if (isSessionHeader(value)) return { id: value.id, cwd: value.cwd.length > 0 ? value.cwd : null };
|
|
80
|
+
} catch {
|
|
81
|
+
return fallback;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return fallback;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function pinnedLeafTurnId(source: string): Promise<string | undefined> {
|
|
88
|
+
try {
|
|
89
|
+
const raw = await readFile(join(dirname(source), "meta.json"), "utf8");
|
|
90
|
+
const value = JSON.parse(raw) as unknown;
|
|
91
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
92
|
+
const pinned = (value as Record<string, unknown>).pinnedLeafTurnId;
|
|
93
|
+
return typeof pinned === "string" && pinned.length > 0 ? pinned : undefined;
|
|
94
|
+
} catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function cleanActiveEntries(entries: ReadonlyArray<SessionEntry>, activeLeafTurnId?: string): SessionEntry[] {
|
|
100
|
+
return filterEntriesToActivePath(entries, activeLeafTurnId).filter(
|
|
101
|
+
(entry) => entry.kind !== "contextEviction" && entry.kind !== "contextRecall",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function toolResultCount(entries: ReadonlyArray<SessionEntry>): number {
|
|
106
|
+
let count = 0;
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (entry.kind === "message" && entry.role === "tool_result") count += 1;
|
|
109
|
+
}
|
|
110
|
+
return count;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Load only Clio ledgers. Inputs may be a current.jsonl file, one session
|
|
115
|
+
* directory, a cwd-hash directory, or the sessions root.
|
|
116
|
+
*/
|
|
117
|
+
export async function loadClioTraces(
|
|
118
|
+
paths: ReadonlyArray<string>,
|
|
119
|
+
options: LoadClioTraceOptions = {},
|
|
120
|
+
): Promise<{ traces: Trace[]; cascade: ReplayLoadCascade }> {
|
|
121
|
+
const discovered = new Set<string>();
|
|
122
|
+
let missingInputs = 0;
|
|
123
|
+
for (const path of paths) {
|
|
124
|
+
const before = discovered.size;
|
|
125
|
+
await collectLedgerFiles(path, discovered);
|
|
126
|
+
if (discovered.size === before) {
|
|
127
|
+
try {
|
|
128
|
+
await stat(resolve(path));
|
|
129
|
+
} catch {
|
|
130
|
+
missingInputs += 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const filtered: Record<string, number> = {
|
|
136
|
+
turns_lt_8: 0,
|
|
137
|
+
tool_results_lt_8: 0,
|
|
138
|
+
no_file_reread: 0,
|
|
139
|
+
};
|
|
140
|
+
const cascade: ReplayLoadCascade = {
|
|
141
|
+
found: discovered.size,
|
|
142
|
+
unreadable: missingInputs,
|
|
143
|
+
filtered,
|
|
144
|
+
kept: 0,
|
|
145
|
+
};
|
|
146
|
+
const traces: Trace[] = [];
|
|
147
|
+
const filter =
|
|
148
|
+
options.filter === false
|
|
149
|
+
? null
|
|
150
|
+
: {
|
|
151
|
+
minTurns: options.filter?.minTurns ?? DEFAULT_FILTER.minTurns,
|
|
152
|
+
minToolResults: options.filter?.minToolResults ?? DEFAULT_FILTER.minToolResults,
|
|
153
|
+
requireFileReread: options.filter?.requireFileReread ?? DEFAULT_FILTER.requireFileReread,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
for (const source of [...discovered].sort((a, b) => a.localeCompare(b))) {
|
|
157
|
+
let raw: string;
|
|
158
|
+
try {
|
|
159
|
+
raw = await readFile(source, "utf8");
|
|
160
|
+
} catch {
|
|
161
|
+
cascade.unreadable += 1;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const parsed = parseSessionEntries(raw, source);
|
|
165
|
+
if (parsed.errors.length > 0) {
|
|
166
|
+
cascade.unreadable += 1;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const entries = cleanActiveEntries(parsed.entries, await pinnedLeafTurnId(source));
|
|
170
|
+
const facts = sessionFacts(raw, source);
|
|
171
|
+
const trace: Trace = {
|
|
172
|
+
id: facts.id,
|
|
173
|
+
source,
|
|
174
|
+
cwd: facts.cwd,
|
|
175
|
+
entries,
|
|
176
|
+
turnCount: countReplayTurns(entries),
|
|
177
|
+
};
|
|
178
|
+
if (filter !== null) {
|
|
179
|
+
if (trace.turnCount < filter.minTurns) {
|
|
180
|
+
filtered.turns_lt_8 = (filtered.turns_lt_8 ?? 0) + 1;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (toolResultCount(entries) < filter.minToolResults) {
|
|
184
|
+
filtered.tool_results_lt_8 = (filtered.tool_results_lt_8 ?? 0) + 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (filter.requireFileReread) {
|
|
188
|
+
const graph = buildReferenceGraph(trace, buildPathIndex(entries, { cwd: trace.cwd }));
|
|
189
|
+
if (!graph.edges.some((edge) => edge.kind === "file_reread")) {
|
|
190
|
+
filtered.no_file_reread = (filtered.no_file_reread ?? 0) + 1;
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
traces.push(trace);
|
|
196
|
+
}
|
|
197
|
+
cascade.kept = traces.length;
|
|
198
|
+
return { traces, cascade };
|
|
199
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { covers, type PathIndex, type PathObservation } from "../path-index.js";
|
|
2
|
+
import type { ReferenceGraph } from "./reference-graph.js";
|
|
3
|
+
import type { ReplayTraceResult } from "./runner.js";
|
|
4
|
+
import type { Trace } from "./trace.js";
|
|
5
|
+
|
|
6
|
+
export interface ReplayMetrics {
|
|
7
|
+
traces: number;
|
|
8
|
+
retention: number;
|
|
9
|
+
/** Retention after crediting a surviving newer read that covers the original range. */
|
|
10
|
+
retentionCovered: number;
|
|
11
|
+
retentionAt10: number;
|
|
12
|
+
evictionPrecision: number;
|
|
13
|
+
tokensEvicted: number;
|
|
14
|
+
/** Tokens freed by evicting items a later turn referenced again: the re-discovery bill a perfect recall would pay. */
|
|
15
|
+
recallTokens: number;
|
|
16
|
+
/** Sum over events of the projected tokens after the earliest evicted position: exact-prefix cache re-prefill cost. */
|
|
17
|
+
coldPrefixTokens: number;
|
|
18
|
+
evictionEvents: number;
|
|
19
|
+
/** Fraction of applied events that exhausted the policy's usable candidates. */
|
|
20
|
+
saturatedEvents: number;
|
|
21
|
+
turnsToFirstSummary: number | null;
|
|
22
|
+
/** Summary compactions the modeled summary stage applied; what a policy exists to make rare. */
|
|
23
|
+
summaries: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ReplayMeasurement {
|
|
27
|
+
trace: Trace;
|
|
28
|
+
index: PathIndex;
|
|
29
|
+
graph: ReferenceGraph;
|
|
30
|
+
replay: ReplayTraceResult;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReplayMetricAggregate {
|
|
34
|
+
/** Arithmetic mean of the per-trace metrics; `traces` is the sample size. */
|
|
35
|
+
mean: ReplayMetrics;
|
|
36
|
+
/** Number of traces contributing to the nullable `turnsToFirstSummary` mean. */
|
|
37
|
+
turnsToFirstSummaryCount: number;
|
|
38
|
+
/** Headline pair-level retention pooled across every critical future reference. */
|
|
39
|
+
pooledRetention: number;
|
|
40
|
+
pooledRetentionCovered: number;
|
|
41
|
+
pooledRetentionAt10: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface MeasuredTrace {
|
|
45
|
+
metrics: ReplayMetrics;
|
|
46
|
+
pairs: number;
|
|
47
|
+
retainedPairs: number;
|
|
48
|
+
coveredRetainedPairs: number;
|
|
49
|
+
pairsAt10: number;
|
|
50
|
+
retainedPairsAt10: number;
|
|
51
|
+
saturatedEventCount: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function safeFraction(numerator: number, denominator: number, empty: number): number {
|
|
55
|
+
return denominator === 0 ? empty : numerator / denominator;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function survivesThrough(
|
|
59
|
+
observation: PathObservation,
|
|
60
|
+
referenceTurn: number,
|
|
61
|
+
evictedAtTurn: ReadonlyMap<string, number>,
|
|
62
|
+
): boolean {
|
|
63
|
+
const evictedAt = evictedAtTurn.get(observation.ref.entry);
|
|
64
|
+
return evictedAt === undefined || evictedAt > referenceTurn;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasSurvivingCoveringRead(input: ReplayMeasurement, ref: string, referenceTurn: number): boolean {
|
|
68
|
+
const original = input.index.byRef.get(ref);
|
|
69
|
+
if (original?.op !== "read" || original.path.length === 0) return false;
|
|
70
|
+
return (input.index.byPath.get(original.path) ?? []).some(
|
|
71
|
+
(later) =>
|
|
72
|
+
later.op === "read" &&
|
|
73
|
+
!later.isError &&
|
|
74
|
+
later.entryIndex > original.entryIndex &&
|
|
75
|
+
later.turnIndex < referenceTurn &&
|
|
76
|
+
covers(later.range, original.range) &&
|
|
77
|
+
survivesThrough(later, referenceTurn, input.replay.evictedAtTurn),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function measure(input: ReplayMeasurement): MeasuredTrace {
|
|
82
|
+
let pairs = 0;
|
|
83
|
+
let retainedPairs = 0;
|
|
84
|
+
let coveredRetainedPairs = 0;
|
|
85
|
+
let pairsAt10 = 0;
|
|
86
|
+
let retainedPairsAt10 = 0;
|
|
87
|
+
for (const [ref, futureTurns] of input.graph.futureTurnsOf) {
|
|
88
|
+
const observationTurn = input.index.byRef.get(ref)?.turnIndex;
|
|
89
|
+
const evictedAt = input.replay.evictedAtTurn.get(ref);
|
|
90
|
+
for (const referenceTurn of futureTurns) {
|
|
91
|
+
pairs += 1;
|
|
92
|
+
const retained = evictedAt === undefined || evictedAt > referenceTurn;
|
|
93
|
+
if (retained) retainedPairs += 1;
|
|
94
|
+
if (retained || hasSurvivingCoveringRead(input, ref, referenceTurn)) coveredRetainedPairs += 1;
|
|
95
|
+
if (observationTurn !== undefined && referenceTurn - observationTurn <= 10) {
|
|
96
|
+
pairsAt10 += 1;
|
|
97
|
+
if (retained) retainedPairsAt10 += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Precision is the share of evicted items never referenced again; the
|
|
103
|
+
// complement (items the session came back to) is what live churn would
|
|
104
|
+
// count as recalls, so it is not reported as a second column.
|
|
105
|
+
let evictedItems = 0;
|
|
106
|
+
let safelyEvictedItems = 0;
|
|
107
|
+
let tokensEvicted = 0;
|
|
108
|
+
let recallTokens = 0;
|
|
109
|
+
let coldPrefixTokens = 0;
|
|
110
|
+
let saturatedEventCount = 0;
|
|
111
|
+
for (const event of input.replay.events) {
|
|
112
|
+
if (event.saturated) saturatedEventCount += 1;
|
|
113
|
+
coldPrefixTokens += event.coldPrefixTokens;
|
|
114
|
+
for (const item of event.items) {
|
|
115
|
+
evictedItems += 1;
|
|
116
|
+
tokensEvicted += item.tokensFreed;
|
|
117
|
+
const future = input.graph.futureTurnsOf.get(item.ref.entry) ?? [];
|
|
118
|
+
if (!future.some((turn) => turn > event.turnIndex)) safelyEvictedItems += 1;
|
|
119
|
+
else recallTokens += item.tokensFreed;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
metrics: {
|
|
125
|
+
traces: 1,
|
|
126
|
+
retention: safeFraction(retainedPairs, pairs, 1),
|
|
127
|
+
retentionCovered: safeFraction(coveredRetainedPairs, pairs, 1),
|
|
128
|
+
retentionAt10: safeFraction(retainedPairsAt10, pairsAt10, 1),
|
|
129
|
+
evictionPrecision: safeFraction(safelyEvictedItems, evictedItems, 1),
|
|
130
|
+
tokensEvicted,
|
|
131
|
+
recallTokens,
|
|
132
|
+
coldPrefixTokens,
|
|
133
|
+
evictionEvents: input.replay.events.length,
|
|
134
|
+
saturatedEvents: safeFraction(saturatedEventCount, input.replay.events.length, 0),
|
|
135
|
+
turnsToFirstSummary: input.replay.turnsToFirstSummary,
|
|
136
|
+
summaries: input.replay.summaries,
|
|
137
|
+
},
|
|
138
|
+
pairs,
|
|
139
|
+
retainedPairs,
|
|
140
|
+
coveredRetainedPairs,
|
|
141
|
+
pairsAt10,
|
|
142
|
+
retainedPairsAt10,
|
|
143
|
+
saturatedEventCount,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function measureReplayTrace(input: ReplayMeasurement): ReplayMetrics {
|
|
148
|
+
return measure(input).metrics;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function mean(values: ReadonlyArray<number>): number {
|
|
152
|
+
return values.length === 0 ? 0 : values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function aggregateReplayMetrics(inputs: ReadonlyArray<ReplayMeasurement>): ReplayMetricAggregate {
|
|
156
|
+
const measured = inputs.map(measure);
|
|
157
|
+
const firstSummaries = measured
|
|
158
|
+
.map((entry) => entry.metrics.turnsToFirstSummary)
|
|
159
|
+
.filter((value): value is number => value !== null);
|
|
160
|
+
const sum = (field: "pairs" | "retainedPairs" | "coveredRetainedPairs" | "pairsAt10" | "retainedPairsAt10"): number =>
|
|
161
|
+
measured.reduce((total, entry) => total + entry[field], 0);
|
|
162
|
+
const totalEvents = measured.reduce((total, entry) => total + entry.metrics.evictionEvents, 0);
|
|
163
|
+
const saturatedEvents = measured.reduce((total, entry) => total + entry.saturatedEventCount, 0);
|
|
164
|
+
return {
|
|
165
|
+
mean: {
|
|
166
|
+
traces: measured.length,
|
|
167
|
+
retention: measured.length === 0 ? 1 : mean(measured.map((entry) => entry.metrics.retention)),
|
|
168
|
+
retentionCovered: measured.length === 0 ? 1 : mean(measured.map((entry) => entry.metrics.retentionCovered)),
|
|
169
|
+
retentionAt10: measured.length === 0 ? 1 : mean(measured.map((entry) => entry.metrics.retentionAt10)),
|
|
170
|
+
evictionPrecision: measured.length === 0 ? 1 : mean(measured.map((entry) => entry.metrics.evictionPrecision)),
|
|
171
|
+
tokensEvicted: mean(measured.map((entry) => entry.metrics.tokensEvicted)),
|
|
172
|
+
recallTokens: mean(measured.map((entry) => entry.metrics.recallTokens)),
|
|
173
|
+
coldPrefixTokens: mean(measured.map((entry) => entry.metrics.coldPrefixTokens)),
|
|
174
|
+
evictionEvents: mean(measured.map((entry) => entry.metrics.evictionEvents)),
|
|
175
|
+
// Event-pooled: zero-event traces must not dilute the saturation rate.
|
|
176
|
+
saturatedEvents: safeFraction(saturatedEvents, totalEvents, 0),
|
|
177
|
+
turnsToFirstSummary: firstSummaries.length === 0 ? null : mean(firstSummaries),
|
|
178
|
+
summaries: mean(measured.map((entry) => entry.metrics.summaries)),
|
|
179
|
+
},
|
|
180
|
+
turnsToFirstSummaryCount: firstSummaries.length,
|
|
181
|
+
pooledRetention: safeFraction(sum("retainedPairs"), sum("pairs"), 1),
|
|
182
|
+
pooledRetentionCovered: safeFraction(sum("coveredRetainedPairs"), sum("pairs"), 1),
|
|
183
|
+
pooledRetentionAt10: safeFraction(sum("retainedPairsAt10"), sum("pairsAt10"), 1),
|
|
184
|
+
};
|
|
185
|
+
}
|