@hicaru/pi-rlm 0.3.15 → 0.3.17
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/README.md +96 -70
- package/README.ru.md +86 -59
- package/README.zh-CN.md +95 -65
- package/package.json +5 -5
- package/src/bridge/add-context.ts +1 -1
- package/src/bridge/handlers/await.ts +13 -22
- package/src/bridge/handlers/completion.ts +27 -5
- package/src/bridge/handlers/emitting.ts +2 -2
- package/src/bridge/handlers/llm-query.ts +46 -68
- package/src/bridge/handlers/rlm-query.ts +14 -84
- package/src/bridge/handlers/task-registry.ts +22 -17
- package/src/bridge/handlers/types.ts +8 -6
- package/src/bridge/model.ts +21 -4
- package/src/commands/rlm-llm.ts +1 -10
- package/src/commands/rlm-rlm.ts +1 -8
- package/src/config/defaults.ts +31 -12
- package/src/config/settings.ts +47 -33
- package/src/config/skillstate.ts +465 -0
- package/src/context/md-cache.ts +1 -1
- package/src/context/merge.ts +1 -1
- package/src/context/namespace.ts +2 -2
- package/src/context/refresh.ts +1 -1
- package/src/context/source-dir.ts +21 -11
- package/src/context/source-doc.ts +1 -1
- package/src/context/source-git.ts +3 -15
- package/src/context/source-text.ts +1 -1
- package/src/context/walk.ts +6 -14
- package/src/core/budget.ts +107 -21
- package/src/core/compaction.ts +44 -1
- package/src/core/engine.ts +192 -94
- package/src/core/iteration.ts +1 -1
- package/src/core/ledger.ts +10 -13
- package/src/core/limits.ts +1 -1
- package/src/core/model-registry.ts +1 -1
- package/src/core/resource-limits.ts +1 -1
- package/src/core/root-context.ts +126 -0
- package/src/core/root-digest.ts +213 -0
- package/src/core/root-state.ts +240 -0
- package/src/core/run-state.ts +577 -0
- package/src/core/types.ts +56 -12
- package/src/index.ts +167 -36
- package/src/mode/llm-model.ts +13 -1
- package/src/mode/native-guards.ts +0 -6
- package/src/mode/rlm-mode.ts +34 -11
- package/src/mode/subagent.ts +5 -5
- package/src/prompts/glossary.ts +41 -25
- package/src/prompts/native.ts +1 -3
- package/src/prompts/system.ts +12 -4
- package/src/prompts/user.ts +17 -0
- package/src/sandbox/context-file.ts +1 -1
- package/src/sandbox/interrupts.ts +25 -31
- package/src/sandbox/protocol.ts +14 -20
- package/src/sandbox/py/__pycache__/guards.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/retrieval.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/scaffold.cpython-314.pyc +0 -0
- package/src/sandbox/py/__pycache__/worker.cpython-314.pyc +0 -0
- package/src/sandbox/py/guards.py +1 -1
- package/src/sandbox/py/retrieval.py +4 -1
- package/src/sandbox/py/scaffold.py +24 -31
- package/src/sandbox/py/worker.py +3 -1
- package/src/sandbox/sandbox-manager.ts +2 -2
- package/src/sandbox/sandbox.ts +35 -5
- package/src/text/agent-text.ts +58 -0
- package/src/text/parsing.ts +35 -3
- package/src/text/preview.ts +3 -0
- package/src/text/repl-output.ts +1 -1
- package/src/tool/background-tasks.ts +1 -1
- package/src/tool/repl-render.ts +1 -1
- package/src/tool/repl-result.ts +1 -1
- package/src/tool/repl-tool.ts +50 -26
- package/src/tool/rlm-tool.ts +4 -5
- package/src/tool/subcall-render.ts +1 -1
- package/src/tool/subcall-store.ts +2 -2
- package/src/tool/tool-utils.ts +5 -5
- package/src/ui/config-panel.ts +39 -0
- package/src/ui/intro.ts +1 -1
- package/src/ui/modal/timeline-store.ts +1 -1
- package/src/ui/model-picker/drilldown.ts +1 -1
- package/src/ui/model-picker/levels.ts +1 -1
- package/src/ui/panel/run-registry.ts +1 -1
- package/src/ui/tree/tree-rows.ts +1 -1
- package/src/ui/tree/tree-widget.ts +1 -1
- package/src/util/bm25.ts +97 -0
- package/src/util/concurrency.ts +1 -1
- package/src/util/errors.ts +1 -1
- package/src/util/retry.ts +22 -7
- package/src/util/state-merge.ts +34 -0
- package/src/util/throttle.ts +1 -1
- package/src/util/type-guards.ts +6 -0
- package/src/core/memory.ts +0 -589
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root Σ (WS-3) — per-LLM-call A_t assembly for the native Pi session, via the `context`
|
|
3
|
+
* event (which feeds Pi's transformContext → applied on EVERY provider call).
|
|
4
|
+
*
|
|
5
|
+
* Pi hands handlers a structuredClone of the outgoing messages; the LAST returned array
|
|
6
|
+
* wins and the disk transcript is never touched — context is the query channel, the
|
|
7
|
+
* session log stays the archive (LLM-memory-survey thesis). Both transforms mutate the
|
|
8
|
+
* clone in place (new message objects land in the same array slots; splice uses the same
|
|
9
|
+
* array) to honor the zero-extra-allocations rule.
|
|
10
|
+
*
|
|
11
|
+
* - `elideStalePayloads` — discard semantics (paper §5.3): tool payloads older than the
|
|
12
|
+
* keep window become head+tail previews; the full bytes remain in the session log.
|
|
13
|
+
* - `spliceSigmaSnapshot` — the fresh Σ snapshot rides immediately before the last user
|
|
14
|
+
* message, exactly one instance (previous ones are removed — idempotent per call).
|
|
15
|
+
*
|
|
16
|
+
* Both are pure with respect to the transcript and never throw on weird shapes.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ContextEvent } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import type { RunState } from "./run-state.ts";
|
|
21
|
+
import { runStateRootBlock } from "./run-state.ts";
|
|
22
|
+
import { truncateOutput } from "../text/parsing.ts";
|
|
23
|
+
import { textContentOf } from "../text/agent-text.ts";
|
|
24
|
+
|
|
25
|
+
/** The message union Pi passes through the context event (indexed — host evolution safe). */
|
|
26
|
+
export type RootMessage = ContextEvent["messages"][number];
|
|
27
|
+
|
|
28
|
+
export interface ElideOptions {
|
|
29
|
+
readonly keepTurns: number;
|
|
30
|
+
readonly elideChars: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ELIDE_MARK = "chars elided — full result in session log";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* WS-3a: elide stale tool payloads. The newest `keepTurns` assistant turns and the final
|
|
37
|
+
* user message stay verbatim; older toolResult content over `elideChars` becomes a
|
|
38
|
+
* head+tail preview (same truncation shape as repl stdout). `role:"custom"` messages with
|
|
39
|
+
* `customType: "rlm-sigma"` are immune (WS-3b owns them). Mutates the array in place;
|
|
40
|
+
* returns the number of messages elided (telemetry), for zero-cost counters at the seam.
|
|
41
|
+
*/
|
|
42
|
+
export function elideStalePayloads(messages: RootMessage[], opts: ElideOptions): number {
|
|
43
|
+
const keepTurns = Math.max(0, Math.floor(opts.keepTurns));
|
|
44
|
+
if (keepTurns === 0 || messages.length === 0) return 0;
|
|
45
|
+
|
|
46
|
+
// Index of the assistant message that opens the keepTurns-th-from-last turn — everything
|
|
47
|
+
// from there on is the protected tail (same walk as core/compaction.ts elideOldToolPayloads).
|
|
48
|
+
let tailStart = -1;
|
|
49
|
+
let seen = 0;
|
|
50
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
51
|
+
if (messages[i]?.role === "assistant") {
|
|
52
|
+
seen += 1;
|
|
53
|
+
if (seen >= keepTurns) {
|
|
54
|
+
tailStart = i;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (tailStart <= 0) return 0; // fewer turns than the window — nothing to elide
|
|
60
|
+
|
|
61
|
+
const lastUser = lastIndexOfRole(messages, "user");
|
|
62
|
+
let elided = 0;
|
|
63
|
+
for (let i = 0; i < tailStart; i++) {
|
|
64
|
+
const m = messages[i];
|
|
65
|
+
if (m === undefined || m.role !== "toolResult") continue;
|
|
66
|
+
if (i === lastUser) continue; // paranoia: the final user message is never touched
|
|
67
|
+
const total = totalTextLength(m);
|
|
68
|
+
if (total <= opts.elideChars) continue;
|
|
69
|
+
messages[i] = {
|
|
70
|
+
...m,
|
|
71
|
+
content: [{ type: "text", text: previewToolText(m, opts.elideChars) }],
|
|
72
|
+
} as RootMessage;
|
|
73
|
+
elided += 1;
|
|
74
|
+
}
|
|
75
|
+
return elided;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** WS-3b: exactly one live Σ snapshot, immediately before the LAST user message. */
|
|
79
|
+
export function spliceSigmaSnapshot(
|
|
80
|
+
messages: RootMessage[],
|
|
81
|
+
state: RunState,
|
|
82
|
+
rectifyHint: string | undefined,
|
|
83
|
+
): void {
|
|
84
|
+
const block = runStateRootBlock(state);
|
|
85
|
+
const text = rectifyHint === undefined ? block : `${block}\n${rectifyHint}`;
|
|
86
|
+
// Remove any previous instance (only one lives at a time — idempotent across calls).
|
|
87
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
88
|
+
const m = messages[i];
|
|
89
|
+
if (isSigmaSnapshot(m)) messages.splice(i, 1);
|
|
90
|
+
}
|
|
91
|
+
const at = lastIndexOfRole(messages, "user");
|
|
92
|
+
const sigma: RootMessage = {
|
|
93
|
+
role: "custom",
|
|
94
|
+
customType: "rlm-sigma",
|
|
95
|
+
content: text,
|
|
96
|
+
display: false,
|
|
97
|
+
details: { kind: "sigma-snapshot", updatedAt: state.updatedAt },
|
|
98
|
+
timestamp: Date.now(),
|
|
99
|
+
} as RootMessage;
|
|
100
|
+
if (at < 0) {
|
|
101
|
+
messages.push(sigma); // no user message (degenerate) — append; still exactly one
|
|
102
|
+
} else {
|
|
103
|
+
messages.splice(at, 0, sigma);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isSigmaSnapshot(m: RootMessage | undefined): boolean {
|
|
108
|
+
return m !== undefined && m.role === "custom" && (m as { customType?: unknown }).customType === "rlm-sigma";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function lastIndexOfRole(messages: readonly RootMessage[], role: "user"): number {
|
|
112
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
113
|
+
if (messages[i]?.role === role) return i;
|
|
114
|
+
}
|
|
115
|
+
return -1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function totalTextLength(message: RootMessage): number {
|
|
119
|
+
return textContentOf((message as { content?: unknown }).content).length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function previewToolText(message: RootMessage, elideChars: number): string {
|
|
123
|
+
const text = textContentOf((message as { content?: unknown }).content);
|
|
124
|
+
// The preview REPLACES the payload, so `elideChars` budgets the WHOLE head+tail result.
|
|
125
|
+
return truncateOutput(text, Math.max(100, elideChars), ELIDE_MARK);
|
|
126
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root Σ (WS-2) — deterministic root compaction for the Pi harness orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* When the native session compacts, this replaces Pi's LLM prose summarizer with a
|
|
5
|
+
* structural digest: the same no-LLM distillation shape the engine proven-uses at its
|
|
6
|
+
* budget hard-state (core/budget.ts:distillTrajectory), adapted to host AgentMessages.
|
|
7
|
+
* Zero summary tokens; compaction becomes reproducible. Paper framing: the digest is a
|
|
8
|
+
* Σ-style sufficient statistic (§3.1) — sections are ordered by continuity value, and the
|
|
9
|
+
* verbatim tail after `firstKeptEntryId` stays byte-identical (§5.3 observation override:
|
|
10
|
+
* fresh tool results outrank the digest).
|
|
11
|
+
*
|
|
12
|
+
* Cut-point semantics: Pi's preparation already picked `firstKeptEntryId` via its
|
|
13
|
+
* keepRecentTokens walk. We only ever TIGHTEN (move the cut later, shrinking the verbatim
|
|
14
|
+
* tail toward `rootDigestKeepRecentChars`) — never extend — and fold every message the
|
|
15
|
+
* tighter cut displaces into the digest inputs, so nothing is dropped unaccounted. Cuts
|
|
16
|
+
* land on entry boundaries Pi itself treats as safe (the walk is char-budgeted over whole
|
|
17
|
+
* entries, mirroring pi-docs/compaction.md's accumulate-and-cut).
|
|
18
|
+
*
|
|
19
|
+
* Failure semantics: pure function, no model call, no throw paths — the index.ts handler
|
|
20
|
+
* still wraps it fail-soft (host contract: handlers must not throw) and returns undefined
|
|
21
|
+
* so Pi falls back to its own summarizer.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { CompactionResult, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import type { RlmConfig } from "./types.ts";
|
|
26
|
+
import { DEFAULT_NEXT_STEP, FINDINGS_MAX, FINDINGS_MIN_CHARS, NEXT_STEP_RE, STATE_MAX, truncateMid } from "./budget.ts";
|
|
27
|
+
import { estimateMessageTokens } from "../text/tokens.ts";
|
|
28
|
+
import { agentMessageText } from "../text/agent-text.ts";
|
|
29
|
+
import { isRecord } from "../util/type-guards.ts";
|
|
30
|
+
import { ROOT_DIGEST_HEADER, ROOT_DIGEST_SECTIONS } from "../prompts/glossary.ts";
|
|
31
|
+
|
|
32
|
+
/** Marker persisted on the session's compaction entry — tests and soaks assert on it. */
|
|
33
|
+
export interface RootDigestDetails {
|
|
34
|
+
readonly kind: "root-digest";
|
|
35
|
+
readonly version: 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Structural slice of SkillStore the digest needs — tests inject stand-ins without a store. */
|
|
39
|
+
export interface DigestFactSource {
|
|
40
|
+
sliceForPrompt(query: string, budgetTokens: number, minScore: number): string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `SessionBeforeCompactEvent["preparation"]` slice — indexed on the event shape upstream. */
|
|
44
|
+
export interface RootDigestPreparation {
|
|
45
|
+
readonly firstKeptEntryId: string;
|
|
46
|
+
readonly messagesToSummarize: readonly unknown[];
|
|
47
|
+
readonly isSplitTurn: boolean;
|
|
48
|
+
readonly tokensBefore: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RootDigestArgs {
|
|
52
|
+
readonly preparation: RootDigestPreparation;
|
|
53
|
+
/** Chronological session entries (event.branchEntries) — the tighten walk reads these. */
|
|
54
|
+
readonly branchEntries: readonly SessionEntry[];
|
|
55
|
+
readonly config: Pick<RlmConfig, "rootDigestKeepRecentChars" | "rootDigestMaxChars" | "skillStateMinScore">;
|
|
56
|
+
readonly store: DigestFactSource | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Section caps come from budget.ts (ONE source — N2); only digest-local shapes live here. */
|
|
60
|
+
const BULLET_CHARS = 400;
|
|
61
|
+
const TASK_FRACTION = 0.3;
|
|
62
|
+
const FACTS_FRACTION = 4; // facts budget = maxChars / FACTS_FRACTION, tokens at 4 chars/token
|
|
63
|
+
|
|
64
|
+
/** AgentMessage-shaped projection of one session entry (host sessionEntryToContextMessages shape). */
|
|
65
|
+
function entryMessages(entry: SessionEntry): readonly unknown[] {
|
|
66
|
+
if (!isRecord(entry)) return [];
|
|
67
|
+
if (entry.type === "message" && isRecord(entry.message)) return [entry.message];
|
|
68
|
+
if (entry.type === "custom_message") return [{ role: "custom", content: entry.content }];
|
|
69
|
+
if (entry.type === "compaction" && typeof entry.summary === "string") {
|
|
70
|
+
return [{ role: "compactionSummary", summary: entry.summary }];
|
|
71
|
+
}
|
|
72
|
+
if (entry.type === "branch_summary" && typeof entry.summary === "string") {
|
|
73
|
+
return [{ role: "branchSummary", summary: entry.summary }];
|
|
74
|
+
}
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function entryChars(entry: SessionEntry): number {
|
|
79
|
+
let total = 0;
|
|
80
|
+
for (const message of entryMessages(entry)) total += agentMessageText(message).length + 8;
|
|
81
|
+
return total;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The ONE tighten walk: index of the first entry kept verbatim (≥ piCut). Walks backward
|
|
85
|
+
* accumulating entry chars until the budget is met; when the whole tail fits under the
|
|
86
|
+
* budget, Pi's boundary stands — we never EXTEND the digest span past Pi's cut.
|
|
87
|
+
*/
|
|
88
|
+
function tightenedCut(entries: readonly SessionEntry[], piCut: number, keepChars: number): number {
|
|
89
|
+
let acc = 0;
|
|
90
|
+
for (let i = entries.length - 1; i >= piCut; i--) {
|
|
91
|
+
acc += entryChars(entries[i]);
|
|
92
|
+
if (acc >= keepChars) return i;
|
|
93
|
+
}
|
|
94
|
+
return piCut;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** [Task] — the LAST user prompt in the span (the ask still in force when compaction hits). */
|
|
98
|
+
function taskSection(messages: readonly unknown[]): string {
|
|
99
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
100
|
+
const m = messages[i];
|
|
101
|
+
if (isRecord(m) && m.role === "user") {
|
|
102
|
+
const text = agentMessageText(m).trim();
|
|
103
|
+
if (text !== "") return text;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** [Findings] — newest-first substantive assistant blobs, capped, then chronological. */
|
|
110
|
+
function findingsSection(messages: readonly unknown[]): readonly string[] {
|
|
111
|
+
const findings: string[] = [];
|
|
112
|
+
for (let i = messages.length - 1; i >= 0 && findings.length < FINDINGS_MAX; i--) {
|
|
113
|
+
const m = messages[i];
|
|
114
|
+
if (!isRecord(m) || m.role !== "assistant") continue;
|
|
115
|
+
const text = agentMessageText(m).trim();
|
|
116
|
+
if (text.length > FINDINGS_MIN_CHARS) findings.push(text);
|
|
117
|
+
}
|
|
118
|
+
findings.reverse();
|
|
119
|
+
return findings;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** [State] — newest-first toolResult first-lines (the last observed machinery state). */
|
|
123
|
+
function stateSection(messages: readonly unknown[]): readonly string[] {
|
|
124
|
+
const states: string[] = [];
|
|
125
|
+
for (let i = messages.length - 1; i >= 0 && states.length < STATE_MAX; i--) {
|
|
126
|
+
const m = messages[i];
|
|
127
|
+
if (!isRecord(m) || m.role !== "toolResult") continue;
|
|
128
|
+
const text = agentMessageText(m).trim();
|
|
129
|
+
if (text === "") continue;
|
|
130
|
+
const tool = typeof m.toolName === "string" ? m.toolName : "tool";
|
|
131
|
+
states.push(`${tool}: ${text.split("\n", 1)[0] ?? text}`);
|
|
132
|
+
}
|
|
133
|
+
states.reverse();
|
|
134
|
+
return states;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the digest compaction. Returns undefined when Pi should keep its own path:
|
|
139
|
+
* split turns (our flat summary would double-count the turn prefix) or an empty span.
|
|
140
|
+
*
|
|
141
|
+
* `tokensBeforeRecomputed` is our own estimateMessageTokens pass over the same span —
|
|
142
|
+
* V1 soak probe: the host consumes `compaction.tokensBefore` (Pi's own preparation number)
|
|
143
|
+
* as-is; one trace line carries both so the first real /compact confirms whether the host
|
|
144
|
+
* ever diverges (status-line truth check) without touching the persisted CompactionResult.
|
|
145
|
+
*/
|
|
146
|
+
export function buildRootDigestCompaction(
|
|
147
|
+
args: RootDigestArgs,
|
|
148
|
+
): {
|
|
149
|
+
readonly compaction: CompactionResult<RootDigestDetails>;
|
|
150
|
+
readonly tokensBeforeRecomputed: number;
|
|
151
|
+
} | undefined {
|
|
152
|
+
const prep = args.preparation;
|
|
153
|
+
if (prep.isSplitTurn) return undefined;
|
|
154
|
+
|
|
155
|
+
const piCut = args.branchEntries.findIndex((entry) => entry.id === prep.firstKeptEntryId);
|
|
156
|
+
const cut = piCut < 0 ? -1 : tightenedCut(args.branchEntries, piCut, args.config.rootDigestKeepRecentChars);
|
|
157
|
+
|
|
158
|
+
const messages: unknown[] = [...prep.messagesToSummarize];
|
|
159
|
+
if (cut > piCut && piCut >= 0) {
|
|
160
|
+
for (let i = piCut; i < cut; i++) messages.push(...entryMessages(args.branchEntries[i]));
|
|
161
|
+
}
|
|
162
|
+
if (messages.length === 0) return undefined;
|
|
163
|
+
|
|
164
|
+
const max = Math.max(200, args.config.rootDigestMaxChars);
|
|
165
|
+
const task = truncateMid(taskSection(messages), Math.floor(max * TASK_FRACTION));
|
|
166
|
+
const findings = findingsSection(messages).map((f) => truncateMid(f, BULLET_CHARS));
|
|
167
|
+
const states = stateSection(messages);
|
|
168
|
+
// Next-step probe mirrors distillTrajectory: newest-first scan, chronological render.
|
|
169
|
+
const next = findings.find((f) => NEXT_STEP_RE.test(f)) ?? DEFAULT_NEXT_STEP;
|
|
170
|
+
const facts = args.store === undefined
|
|
171
|
+
? ""
|
|
172
|
+
: args.store.sliceForPrompt(
|
|
173
|
+
task,
|
|
174
|
+
Math.max(1, Math.floor(max / FACTS_FRACTION / 4)),
|
|
175
|
+
args.config.skillStateMinScore,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const render = (factsText: string, stateLines: readonly string[], findingLines: readonly string[]): string => {
|
|
179
|
+
const blocks: string[] = [ROOT_DIGEST_HEADER];
|
|
180
|
+
const section = (label: string, body: string): void => {
|
|
181
|
+
if (body.trim() !== "") blocks.push(`[${label}] ${body}`);
|
|
182
|
+
};
|
|
183
|
+
section(ROOT_DIGEST_SECTIONS.task, task);
|
|
184
|
+
section(ROOT_DIGEST_SECTIONS.findings, findingLines.map((f) => `- ${f}`).join("\n"));
|
|
185
|
+
section(ROOT_DIGEST_SECTIONS.state, stateLines.map((s) => `- ${s}`).join("\n"));
|
|
186
|
+
section(ROOT_DIGEST_SECTIONS.next, next);
|
|
187
|
+
section(ROOT_DIGEST_SECTIONS.facts, factsText);
|
|
188
|
+
return blocks.join("\n\n");
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Over-cap drop order: [Project facts] → [State] → [Findings]; [Task]/[Next] survive to a
|
|
192
|
+
// final truncate — task continuity beats trivia (paper §3.1 sufficient statistic).
|
|
193
|
+
let summary = render(facts, states, findings);
|
|
194
|
+
if (summary.length > max) summary = render("", states, findings);
|
|
195
|
+
if (summary.length > max) summary = render("", [], findings);
|
|
196
|
+
if (summary.length > max) {
|
|
197
|
+
summary = render("", [], findings.slice(0, Math.max(1, Math.floor(findings.length / 2))));
|
|
198
|
+
}
|
|
199
|
+
summary = truncateMid(summary, max);
|
|
200
|
+
|
|
201
|
+
const boundary = cut >= 0 ? args.branchEntries[cut] : undefined;
|
|
202
|
+
return {
|
|
203
|
+
compaction: {
|
|
204
|
+
summary,
|
|
205
|
+
firstKeptEntryId: (isRecord(boundary) && typeof boundary.id === "string" ? boundary.id : undefined)
|
|
206
|
+
?? prep.firstKeptEntryId,
|
|
207
|
+
tokensBefore: prep.tokensBefore,
|
|
208
|
+
details: { kind: "root-digest", version: 1 },
|
|
209
|
+
},
|
|
210
|
+
// Same CHAR_PER_TOKEN=4 (+8/message) math as the engine budgets — comparable numbers.
|
|
211
|
+
tokensBeforeRecomputed: estimateMessageTokens(messages.map((m) => ({ content: agentMessageText(m) }))),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root Σ (WS-3b/WS-4) — RootStateTracker: a digest-level Σ_t for the NATIVE Pi session.
|
|
3
|
+
*
|
|
4
|
+
* This is NOT the engine's RunStateTracker (core/engine turn loop); it is the root
|
|
5
|
+
* orchestrator's sufficient statistic, living in the extension closure next to
|
|
6
|
+
* controller/sandboxManager. It is runtime-derived — tool outcomes, engine-run mirrors,
|
|
7
|
+
* the user's latest prompt — zero model cooperation required (paper §5.3: observation
|
|
8
|
+
* override; §5.7: small models must not be the state's author by default). The optional
|
|
9
|
+
* fence protocol (enableRootStateFences, default OFF) is the only model-proposed input and
|
|
10
|
+
* rides the SAME V(ΔΣ_t,Σ_t) validator + retry/degrade ladder as engine runs.
|
|
11
|
+
*
|
|
12
|
+
* Caps/dedup/serialization are the engine's own machinery: RUN_STATE_LIMITS, dedupStrings,
|
|
13
|
+
* enforceCaps, applyPatch — reused, never re-implemented here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
applyPatch,
|
|
18
|
+
dedupStrings,
|
|
19
|
+
enforceCaps,
|
|
20
|
+
freshRunState,
|
|
21
|
+
malformedFenceProblem,
|
|
22
|
+
patchErrorText,
|
|
23
|
+
RUN_STATE_LIMITS,
|
|
24
|
+
statePatchObservation,
|
|
25
|
+
type ApproachOutcome,
|
|
26
|
+
type MutableState,
|
|
27
|
+
type PatchError,
|
|
28
|
+
type RunState,
|
|
29
|
+
} from "./run-state.ts";
|
|
30
|
+
import type { Result } from "../util/errors.ts";
|
|
31
|
+
import type { StateFenceResult } from "../text/parsing.ts";
|
|
32
|
+
|
|
33
|
+
/** Consecutive failed outcomes on one key before the rectify hint fires (MAS2 Eq. 5 parity). */
|
|
34
|
+
const RECTIFY_FAILURE_THRESHOLD = 2;
|
|
35
|
+
/** Task restatement cap — mirrors run-state.ts TASK_MAX_CHARS (kept in sync by comment). */
|
|
36
|
+
const ROOT_TASK_MAX_CHARS = 200;
|
|
37
|
+
|
|
38
|
+
/** Root Σ mode — same discriminated union shape as the engine's (active | degraded). */
|
|
39
|
+
type RootStateMode =
|
|
40
|
+
| { readonly kind: "active"; readonly retries: number }
|
|
41
|
+
| { readonly kind: "degraded"; readonly reason: string };
|
|
42
|
+
|
|
43
|
+
export class RootStateTracker {
|
|
44
|
+
private draft: MutableState;
|
|
45
|
+
private mode: RootStateMode = { kind: "active", retries: 0 };
|
|
46
|
+
private opCounter = 0;
|
|
47
|
+
private dirtyFlag = false;
|
|
48
|
+
private readonly failures = new Map<string, number>();
|
|
49
|
+
private pendingObservation: string | undefined;
|
|
50
|
+
private readonly retryMax: number;
|
|
51
|
+
|
|
52
|
+
private constructor(task: string, retryMax: number) {
|
|
53
|
+
const fresh = freshRunState(task);
|
|
54
|
+
this.draft = {
|
|
55
|
+
task: fresh.task,
|
|
56
|
+
nextStep: fresh.nextStep,
|
|
57
|
+
updatedAt: fresh.updatedAt,
|
|
58
|
+
findings: [...fresh.findings],
|
|
59
|
+
verifiedFacts: [...fresh.verifiedFacts],
|
|
60
|
+
openQuestions: [...fresh.openQuestions],
|
|
61
|
+
testedApproaches: { ...fresh.testedApproaches },
|
|
62
|
+
artifacts: { ...fresh.artifacts },
|
|
63
|
+
};
|
|
64
|
+
this.retryMax = Math.max(0, Math.floor(retryMax));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
static fresh(task: string, retryMax = 2): RootStateTracker {
|
|
68
|
+
return new RootStateTracker(task.trim().slice(0, ROOT_TASK_MAX_CHARS), retryMax);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** True when nothing worth splicing has accumulated — the context transform no-ops. */
|
|
72
|
+
get isEmpty(): boolean {
|
|
73
|
+
return (
|
|
74
|
+
this.draft.findings.length === 0 &&
|
|
75
|
+
this.draft.verifiedFacts.length === 0 &&
|
|
76
|
+
Object.keys(this.draft.testedApproaches).length === 0 &&
|
|
77
|
+
this.draft.nextStep === ""
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get dirty(): boolean {
|
|
82
|
+
return this.dirtyFlag;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
snapshot(): RunState {
|
|
86
|
+
const capped = enforceCaps(this.draft);
|
|
87
|
+
// enforceCaps never fails in practice; the fallback keeps the tracker fail-soft anyway.
|
|
88
|
+
return capped.ok ? capped.value : freshRunState(this.draft.task);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
noteFinding(text: string): void {
|
|
92
|
+
const trimmed = text.trim();
|
|
93
|
+
if (trimmed === "") return;
|
|
94
|
+
this.draft.findings = dedupStrings([...this.draft.findings, trimmed]).slice(-RUN_STATE_LIMITS.findings);
|
|
95
|
+
this.touch();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
noteFact(text: string): void {
|
|
99
|
+
const trimmed = text.trim();
|
|
100
|
+
if (trimmed === "") return;
|
|
101
|
+
this.draft.verifiedFacts = dedupStrings([...this.draft.verifiedFacts, trimmed])
|
|
102
|
+
.slice(-RUN_STATE_LIMITS.verifiedFacts);
|
|
103
|
+
this.touch();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
noteOutcome(key: string, outcome: ApproachOutcome): void {
|
|
107
|
+
const k = key.trim();
|
|
108
|
+
if (k === "") return;
|
|
109
|
+
this.draft.testedApproaches[k] = outcome;
|
|
110
|
+
if (outcome.status === "failed") {
|
|
111
|
+
this.failures.set(k, (this.failures.get(k) ?? 0) + 1);
|
|
112
|
+
} else {
|
|
113
|
+
this.failures.delete(k);
|
|
114
|
+
}
|
|
115
|
+
this.touch();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Tool-outcome feed (WS-4 v1 source): failures become approach outcomes, verbatim. */
|
|
119
|
+
observeToolResult(toolName: string, isError: boolean, reasonFirstLine: string): void {
|
|
120
|
+
if (isError) {
|
|
121
|
+
this.noteOutcome(`tool:${toolName}`, { status: "failed", reason: reasonFirstLine });
|
|
122
|
+
} else {
|
|
123
|
+
// A success clears the tool's failure streak — the state reflects the newest truth.
|
|
124
|
+
if (this.draft.testedApproaches[`tool:${toolName}`] !== undefined) {
|
|
125
|
+
this.noteOutcome(`tool:${toolName}`, { status: "succeeded", evidence: reasonFirstLine });
|
|
126
|
+
} else {
|
|
127
|
+
this.failures.delete(`tool:${toolName}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The user's latest ask IS the next step by definition (WS-4 v1 source). Empty is a no-op. */
|
|
133
|
+
setNextStep(text: string): void {
|
|
134
|
+
const trimmed = text.trim();
|
|
135
|
+
if (trimmed === "") return;
|
|
136
|
+
this.draft.nextStep = trimmed.slice(0, 300);
|
|
137
|
+
this.touch();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* WS-4 mirror: absorb a finished engine run's Σ (same object the SkillState harvest
|
|
142
|
+
* consumes — one source, two sinks). Findings/facts dedup through the shared key; the
|
|
143
|
+
* engine's approach outcomes ride along under their own keys.
|
|
144
|
+
*/
|
|
145
|
+
absorbEngineState(engine: RunState): void {
|
|
146
|
+
this.draft.findings = dedupStrings([...this.draft.findings, ...engine.findings])
|
|
147
|
+
.slice(-RUN_STATE_LIMITS.findings);
|
|
148
|
+
this.draft.verifiedFacts = dedupStrings([...this.draft.verifiedFacts, ...engine.verifiedFacts])
|
|
149
|
+
.slice(-RUN_STATE_LIMITS.verifiedFacts);
|
|
150
|
+
for (const [key, outcome] of Object.entries(engine.testedApproaches)) {
|
|
151
|
+
this.draft.testedApproaches[key] = outcome;
|
|
152
|
+
}
|
|
153
|
+
if (this.draft.task === "") this.draft.task = engine.task;
|
|
154
|
+
this.touch();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* WS-4.2 (default OFF): model-proposed ΔΣ_t fences through the ONE validator. EXACT ladder
|
|
159
|
+
* parity with the engine's `applyStatePatches` (N3): every fence is processed — accepted
|
|
160
|
+
* deltas land sequentially, ALL problems accumulate into ONE observation (error-as-
|
|
161
|
+
* observation), and only past `runStateRetryMax` total rejections the tracker degrades and
|
|
162
|
+
* fences stop being applied. Wording delegates to run-state.ts (N1) — one source.
|
|
163
|
+
*/
|
|
164
|
+
applyFences(fences: readonly StateFenceResult[]): void {
|
|
165
|
+
if (this.mode.kind !== "active" || fences.length === 0) return;
|
|
166
|
+
let state = this.snapshot();
|
|
167
|
+
const problems: string[] = [];
|
|
168
|
+
for (const fence of fences) {
|
|
169
|
+
if (!fence.ok) {
|
|
170
|
+
problems.push(malformedFenceProblem(fence.error));
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const next: Result<RunState, PatchError> = applyPatch(state, fence.value, ++this.opCounter);
|
|
174
|
+
if (next.ok) {
|
|
175
|
+
state = next.value;
|
|
176
|
+
} else {
|
|
177
|
+
problems.push(patchErrorText(next.error));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (problems.length === 0) {
|
|
181
|
+
this.mode = { kind: "active", retries: 0 };
|
|
182
|
+
this.pendingObservation = undefined;
|
|
183
|
+
this.draft = this.toMutable(state);
|
|
184
|
+
this.touch();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const retries = this.mode.retries + problems.length;
|
|
188
|
+
this.pendingObservation = statePatchObservation(problems);
|
|
189
|
+
// Accepted deltas in a partially-failing batch still land — engine parity: real work is
|
|
190
|
+
// never rolled back just because a sibling fence was malformed.
|
|
191
|
+
this.draft = this.toMutable(state);
|
|
192
|
+
this.touch();
|
|
193
|
+
if (retries > this.retryMax) {
|
|
194
|
+
this.mode = { kind: "degraded", reason: `state-patch retry cap exceeded (${retries} rejected)` };
|
|
195
|
+
} else {
|
|
196
|
+
this.mode = { kind: "active", retries };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Consume (and clear) the pending fence-rejection observation, if any. */
|
|
201
|
+
takePendingObservation(): string | undefined {
|
|
202
|
+
const observation = this.pendingObservation;
|
|
203
|
+
this.pendingObservation = undefined;
|
|
204
|
+
return observation;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* WS-4 rectify parity (budget.ts:rectify ideas, deterministic): after N consecutive
|
|
209
|
+
* failed outcomes on one key, suggest the local fix — never a model switch.
|
|
210
|
+
*/
|
|
211
|
+
rectifyHint(): string | undefined {
|
|
212
|
+
if (this.mode.kind !== "active") return undefined;
|
|
213
|
+
for (const [key, count] of this.failures) {
|
|
214
|
+
if (count >= RECTIFY_FAILURE_THRESHOLD) {
|
|
215
|
+
return `[rectify] '${key}' failed ${count}× consecutively — narrow the approach ` +
|
|
216
|
+
"(different path/tool/slice) instead of retrying blind (MAS2 Eq. 5).";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private toMutable(state: RunState): MutableState {
|
|
223
|
+
return {
|
|
224
|
+
task: state.task,
|
|
225
|
+
nextStep: state.nextStep,
|
|
226
|
+
updatedAt: state.updatedAt,
|
|
227
|
+
findings: [...state.findings],
|
|
228
|
+
verifiedFacts: [...state.verifiedFacts],
|
|
229
|
+
openQuestions: [...state.openQuestions],
|
|
230
|
+
testedApproaches: { ...state.testedApproaches },
|
|
231
|
+
artifacts: { ...state.artifacts },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private touch(): void {
|
|
236
|
+
this.opCounter += 1;
|
|
237
|
+
this.draft.updatedAt = this.opCounter;
|
|
238
|
+
this.dirtyFlag = true;
|
|
239
|
+
}
|
|
240
|
+
}
|