@hicaru/pi-rlm 0.3.16 → 0.3.18
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 +0 -4
- package/README.ru.md +56 -66
- package/README.zh-CN.md +61 -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 +6 -3
- package/src/commands/rlm-llm.ts +1 -10
- package/src/commands/rlm-rlm.ts +1 -8
- package/src/config/defaults.ts +32 -12
- package/src/config/settings.ts +53 -31
- 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 +141 -84
- 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 +184 -0
- package/src/core/root-digest.ts +213 -0
- package/src/core/root-state.ts +310 -0
- package/src/core/run-state.ts +587 -0
- package/src/core/types.ts +51 -12
- package/src/index.ts +220 -39
- 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 +46 -25
- package/src/prompts/native.ts +27 -5
- package/src/prompts/system.ts +12 -4
- 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__/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/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 +21 -4
- 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 +12 -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/status.ts +39 -4
- 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
package/src/config/settings.ts
CHANGED
|
@@ -5,9 +5,10 @@ import { dirname, join } from "node:path";
|
|
|
5
5
|
import { getAgentDir, type ModelRegistry } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
7
7
|
import type { RlmConfig } from "../core/types.ts";
|
|
8
|
+
import { trace, traceEnabled } from "../util/trace.ts";
|
|
8
9
|
import { DEFAULT_CONFIG } from "./defaults.ts";
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
interface PersistedSettings {
|
|
11
12
|
readonly config: Partial<RlmConfig>;
|
|
12
13
|
/** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)".
|
|
13
14
|
* `null` = explicit "cheapest" clear (omit key on disk). */
|
|
@@ -31,6 +32,21 @@ function validateBoolean(v: unknown): boolean | undefined {
|
|
|
31
32
|
return typeof v === "boolean" ? v : undefined;
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/**
|
|
36
|
+
* R0 enforcement (/tmp/ROOT_FULL_SKILLSTATE_PLAN.md): the SKILL.state paradigm flags are
|
|
37
|
+
* operating law — they ALWAYS resolve to `true`, whatever rlm.json says. An explicit `false`
|
|
38
|
+
* is not an error (fail-soft by contract): it is traced (`skillstate.override-ignored`) and
|
|
39
|
+
* ignored, so a hostile or typo'd config loses visibly instead of crashing the load or
|
|
40
|
+
* silently unbounding the root context. Calibrations (window sizes, budgets) stay tunable —
|
|
41
|
+
* the PARADIGM is enforced, the calibrations are not.
|
|
42
|
+
*/
|
|
43
|
+
function validateEnforcedOn(value: unknown, flag: string): true {
|
|
44
|
+
if (value === false && traceEnabled) {
|
|
45
|
+
trace("skillstate.override-ignored", { flag, value });
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
function validateString(v: unknown): string | undefined {
|
|
35
51
|
return typeof v === "string" && v.trim() ? v : undefined;
|
|
36
52
|
}
|
|
@@ -104,8 +120,7 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
104
120
|
if (subSystemPrompt !== undefined) out.subSystemPrompt = subSystemPrompt;
|
|
105
121
|
const sandboxInitTimeoutMs = validateNumber(r.sandboxInitTimeoutMs, 100);
|
|
106
122
|
if (sandboxInitTimeoutMs !== undefined) out.sandboxInitTimeoutMs = sandboxInitTimeoutMs;
|
|
107
|
-
|
|
108
|
-
const contextLoader = validateBoolean(r.contextLoader) ?? validateBoolean(r.libraryLoader);
|
|
123
|
+
const contextLoader = validateBoolean(r.contextLoader);
|
|
109
124
|
if (contextLoader !== undefined) out.contextLoader = contextLoader;
|
|
110
125
|
const autoSeedCwd = validateBoolean(r.autoSeedCwd);
|
|
111
126
|
if (autoSeedCwd !== undefined) out.autoSeedCwd = autoSeedCwd;
|
|
@@ -127,18 +142,6 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
127
142
|
if (enableLedger !== undefined) out.enableLedger = enableLedger;
|
|
128
143
|
const rlmBudget = validateNumber(r.rlmBudget, 0);
|
|
129
144
|
if (rlmBudget !== undefined) out.rlmBudget = rlmBudget;
|
|
130
|
-
// v5 durable memory
|
|
131
|
-
const enableMemory = validateBoolean(r.enableMemory);
|
|
132
|
-
if (enableMemory !== undefined) out.enableMemory = enableMemory;
|
|
133
|
-
const injectNoteTokens = validateNumber(r.injectNoteTokens, 100);
|
|
134
|
-
if (injectNoteTokens !== undefined) out.injectNoteTokens = injectNoteTokens;
|
|
135
|
-
const evolveEvery = validateNumber(r.evolveEvery, 0);
|
|
136
|
-
if (evolveEvery !== undefined) out.evolveEvery = evolveEvery;
|
|
137
|
-
if (r.memoryDir === null) out.memoryDir = null;
|
|
138
|
-
else {
|
|
139
|
-
const memoryDir = validateString(r.memoryDir);
|
|
140
|
-
if (memoryDir !== undefined) out.memoryDir = memoryDir;
|
|
141
|
-
}
|
|
142
145
|
// v5 provider concurrency caps: { provider: minConcurrent }
|
|
143
146
|
if (typeof r.providerMaxConcurrent === "object" && r.providerMaxConcurrent !== null) {
|
|
144
147
|
const caps: Record<string, number> = {};
|
|
@@ -148,11 +151,39 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
148
151
|
}
|
|
149
152
|
if (Object.keys(caps).length > 0) out.providerMaxConcurrent = Object.freeze(caps);
|
|
150
153
|
}
|
|
151
|
-
// v5 child surface doctrine
|
|
152
|
-
if (r.childSurface === "delegation" || r.childSurface === "legacy") out.childSurface = r.childSurface;
|
|
153
154
|
// Verification-discipline nudge (default OFF).
|
|
154
155
|
const enableVerificationNudge = validateBoolean(r.enableVerificationNudge);
|
|
155
156
|
if (enableVerificationNudge !== undefined) out.enableVerificationNudge = enableVerificationNudge;
|
|
157
|
+
// SKILL.state integration (Workstreams A–F) — paradigm flags ENFORCED (R0); runStateRetryMax
|
|
158
|
+
// is a calibration and stays tunable.
|
|
159
|
+
out.enableRunState = validateEnforcedOn(r.enableRunState, "enableRunState");
|
|
160
|
+
const runStateRetryMax = validateNumber(r.runStateRetryMax, 0);
|
|
161
|
+
if (runStateRetryMax !== undefined) out.runStateRetryMax = runStateRetryMax;
|
|
162
|
+
out.enableSkillState = validateEnforcedOn(r.enableSkillState, "enableSkillState");
|
|
163
|
+
out.enableSkillStateDistill = validateEnforcedOn(r.enableSkillStateDistill, "enableSkillStateDistill");
|
|
164
|
+
const skillStateMaxTokens = validateNumber(r.skillStateMaxTokens, 50);
|
|
165
|
+
if (skillStateMaxTokens !== undefined) out.skillStateMaxTokens = skillStateMaxTokens;
|
|
166
|
+
const skillStateLeafTokens = validateNumber(r.skillStateLeafTokens, 0);
|
|
167
|
+
if (skillStateLeafTokens !== undefined) out.skillStateLeafTokens = skillStateLeafTokens;
|
|
168
|
+
const skillStateMinScore = validateNumber(r.skillStateMinScore, 0);
|
|
169
|
+
if (skillStateMinScore !== undefined) out.skillStateMinScore = skillStateMinScore;
|
|
170
|
+
const skillStateNotesPerProject = validateNumber(r.skillStateNotesPerProject, 1);
|
|
171
|
+
if (skillStateNotesPerProject !== undefined) out.skillStateNotesPerProject = skillStateNotesPerProject;
|
|
172
|
+
// Root Σ integration (WS-2..WS-4) — paradigm flags ENFORCED (R0); the window/byte knobs
|
|
173
|
+
// below are calibrations and stay tunable.
|
|
174
|
+
out.enableRootDigestCompaction = validateEnforcedOn(r.enableRootDigestCompaction, "enableRootDigestCompaction");
|
|
175
|
+
const rootDigestKeepRecentChars = validateNumber(r.rootDigestKeepRecentChars, 200);
|
|
176
|
+
if (rootDigestKeepRecentChars !== undefined) out.rootDigestKeepRecentChars = rootDigestKeepRecentChars;
|
|
177
|
+
const rootDigestMaxChars = validateNumber(r.rootDigestMaxChars, 200);
|
|
178
|
+
if (rootDigestMaxChars !== undefined) out.rootDigestMaxChars = rootDigestMaxChars;
|
|
179
|
+
out.enableRootContextTransform = validateEnforcedOn(r.enableRootContextTransform, "enableRootContextTransform");
|
|
180
|
+
const rootContextKeepTurns = validateNumber(r.rootContextKeepTurns, 0);
|
|
181
|
+
if (rootContextKeepTurns !== undefined) out.rootContextKeepTurns = rootContextKeepTurns;
|
|
182
|
+
const rootContextElideChars = validateNumber(r.rootContextElideChars, 100);
|
|
183
|
+
if (rootContextElideChars !== undefined) out.rootContextElideChars = rootContextElideChars;
|
|
184
|
+
const rootContextSnapshot = validateBoolean(r.rootContextSnapshot);
|
|
185
|
+
if (rootContextSnapshot !== undefined) out.rootContextSnapshot = rootContextSnapshot;
|
|
186
|
+
out.enableRootStateFences = validateEnforcedOn(r.enableRootStateFences, "enableRootStateFences");
|
|
156
187
|
if (typeof r.subSampling === "object" && r.subSampling !== null) {
|
|
157
188
|
const ss = r.subSampling as Record<string, unknown>;
|
|
158
189
|
const sampling: { maxTokens?: number; temperature?: number; reasoning?: ThinkingLevel } = {};
|
|
@@ -185,8 +216,7 @@ export async function loadSettings(): Promise<PersistedSettings> {
|
|
|
185
216
|
const r = raw as Record<string, unknown>;
|
|
186
217
|
return {
|
|
187
218
|
config: validateConfig(r.config),
|
|
188
|
-
|
|
189
|
-
llm: validateString(r.llm) ?? validateString(r.worker),
|
|
219
|
+
llm: validateString(r.llm),
|
|
190
220
|
rlm: validateString(r.rlm),
|
|
191
221
|
};
|
|
192
222
|
} catch {
|
|
@@ -242,16 +272,8 @@ export function modelRef(model: Model<Api> | undefined): string | undefined {
|
|
|
242
272
|
return model ? `${model.provider}/${model.id}` : undefined;
|
|
243
273
|
}
|
|
244
274
|
|
|
245
|
-
/**
|
|
246
|
-
*
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
*/
|
|
250
|
-
export function displayModelRef(
|
|
251
|
-
registry: ModelRegistry,
|
|
252
|
-
override: string | null,
|
|
253
|
-
fallback: Model<Api>,
|
|
254
|
-
): string {
|
|
255
|
-
const resolved = override ? (resolveModelId(registry, override) ?? fallback) : fallback;
|
|
256
|
-
return modelRef(resolved) ?? fallback.id;
|
|
275
|
+
/** Single "provider/id" projection for sub-call node labels — shared by the llm and rlm
|
|
276
|
+
* bridges so sub-call trees label their nodes identically. */
|
|
277
|
+
export function modelLabelOf(model: Model<Api>): string {
|
|
278
|
+
return modelRef(model) ?? model.id;
|
|
257
279
|
}
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workstream B — SkillState: the persistent cross-session distilled-knowledge store.
|
|
3
|
+
*
|
|
4
|
+
* One file per agent (`rlm-skillstate.json`), one section per project (cwd fingerprint). Notes
|
|
5
|
+
* are A-Mem-shaped (minus embeddings — BM25 lexical scoring only): write-time keywords/tags/
|
|
6
|
+
* contextual description, reinforcement by re-encounter (`hits`), LRU eviction with the
|
|
7
|
+
* top-hits quartile pinned.
|
|
8
|
+
*
|
|
9
|
+
* Persistence mirrors `config/settings.ts` exactly — the documented template, not copied
|
|
10
|
+
* logic: fail-soft reader narrowing `JSON.parse(…) as unknown` through a type guard, fail-soft
|
|
11
|
+
* boolean writer (`mkdir` → `writeFile` → `true | false`), frozen empty value on miss/corrupt.
|
|
12
|
+
* The three-state pin semantics of settings (`undefined` = merge-from-disk, `null` = explicit
|
|
13
|
+
* clear) do not apply here: the session-scoped SkillStore is the single writer — it hydrates
|
|
14
|
+
* the whole file, mutates its own project section, and flushes the whole file back, so other
|
|
15
|
+
* projects' sections ride along untouched.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
19
|
+
import { dirname, join, resolve } from "node:path";
|
|
20
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { bm25Rank } from "../util/bm25.ts";
|
|
22
|
+
import { deepMergeWithNullDeletion } from "../util/state-merge.ts";
|
|
23
|
+
import { formatError } from "../util/errors.ts";
|
|
24
|
+
import { isRecord } from "../util/type-guards.ts";
|
|
25
|
+
import type { RunState } from "../core/run-state.ts";
|
|
26
|
+
import type { RlmConfig } from "../core/types.ts";
|
|
27
|
+
import { skillStateLines } from "../prompts/glossary.ts";
|
|
28
|
+
|
|
29
|
+
export const SKILL_STATE_FILE = "rlm-skillstate.json";
|
|
30
|
+
|
|
31
|
+
/** A-Mem-derived note (no embedding): write-time annotation + reinforcement counter. */
|
|
32
|
+
export interface SkillNote {
|
|
33
|
+
/** Claim key — hash(project + normalized text); ledger-style dedup id. */
|
|
34
|
+
readonly id: string;
|
|
35
|
+
/** ≤240 chars, factual, path-anchored. */
|
|
36
|
+
readonly text: string;
|
|
37
|
+
/** A-Mem K_i. */
|
|
38
|
+
readonly keywords: readonly string[];
|
|
39
|
+
/** A-Mem G_i — "config" | "gotcha" | "symbol" | "recipe". */
|
|
40
|
+
readonly tags: readonly string[];
|
|
41
|
+
/** A-Mem X_i — why it matters (≤120 chars). */
|
|
42
|
+
readonly context: string;
|
|
43
|
+
/** Reinforcement count: a duplicate write bumps this instead of duplicating. */
|
|
44
|
+
readonly hits: number;
|
|
45
|
+
readonly ts: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SkillStateFile {
|
|
49
|
+
readonly version: 1;
|
|
50
|
+
/** key: cwd fingerprint (projectFingerprint of the absolute cwd). */
|
|
51
|
+
readonly projects: Readonly<Record<string, readonly SkillNote[]>>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const EMPTY_SKILL_STATE: SkillStateFile = Object.freeze({
|
|
55
|
+
version: 1,
|
|
56
|
+
projects: Object.freeze({}),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const TAGS: ReadonlySet<string> = new Set(["config", "gotcha", "symbol", "recipe"]);
|
|
60
|
+
const NOTE_MAX_CHARS = 240;
|
|
61
|
+
const CONTEXT_MAX_CHARS = 120;
|
|
62
|
+
const KEYWORDS_MAX = 6;
|
|
63
|
+
|
|
64
|
+
export function skillStatePath(dir?: string): string {
|
|
65
|
+
return join(dir ?? getAgentDir(), SKILL_STATE_FILE);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Deterministic project key — stable across sessions on the same machine. */
|
|
69
|
+
export function projectFingerprint(cwd: string): string {
|
|
70
|
+
return fnv1aHex(resolve(cwd), 0x811c9dc5).slice(0, 12);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Root Σ WS-1: the BM25 query for a root Ξ composition — the LIVE user prompt wins so
|
|
75
|
+
* mid-session harvested notes rank against what the user is actually asking; the static
|
|
76
|
+
* system-prompt slice is only the empty-prompt fallback (prior behavior).
|
|
77
|
+
*/
|
|
78
|
+
export function xiQuery(prompt: string, fallback: string): string {
|
|
79
|
+
const trimmed = prompt.trim();
|
|
80
|
+
return trimmed !== "" ? trimmed : fallback;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** FNV-1a hex; two passes for long ids. Never cryptographic — dedup keys only. */
|
|
84
|
+
function fnv1aHex(text: string, seed: number): string {
|
|
85
|
+
let hash = seed | 0;
|
|
86
|
+
for (let i = 0; i < text.length; i++) {
|
|
87
|
+
hash ^= text.charCodeAt(i);
|
|
88
|
+
hash = Math.imul(hash, 0x01000193);
|
|
89
|
+
}
|
|
90
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function noteId(project: string, text: string): string {
|
|
94
|
+
const normalized = text.trim().toLowerCase().replace(/\s+/g, " ");
|
|
95
|
+
return `${fnv1aHex(`${project}\u0000${normalized}`, 0x811c9dc5)}${fnv1aHex(normalized, 0x01000193)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeTags(tags: readonly string[] | undefined): readonly string[] {
|
|
99
|
+
if (tags === undefined) return ["symbol"];
|
|
100
|
+
const out: string[] = [];
|
|
101
|
+
for (const tag of tags) {
|
|
102
|
+
if (TAGS.has(tag) && !out.includes(tag)) out.push(tag);
|
|
103
|
+
}
|
|
104
|
+
return out.length > 0 ? out : ["symbol"];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isSkillNote(value: unknown): value is SkillNote {
|
|
108
|
+
if (!isRecord(value)) return false;
|
|
109
|
+
return (
|
|
110
|
+
typeof value.id === "string" &&
|
|
111
|
+
typeof value.text === "string" &&
|
|
112
|
+
isStringArray(value.keywords) &&
|
|
113
|
+
isStringArray(value.tags) &&
|
|
114
|
+
typeof value.context === "string" &&
|
|
115
|
+
typeof value.hits === "number" &&
|
|
116
|
+
typeof value.ts === "number"
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function isSkillStateFile(value: unknown): value is SkillStateFile {
|
|
121
|
+
if (!isRecord(value) || value.version !== 1 || !isRecord(value.projects)) return false;
|
|
122
|
+
for (const notes of Object.values(value.projects)) {
|
|
123
|
+
if (!Array.isArray(notes)) return false;
|
|
124
|
+
// Corrupt notes are dropped, not fatal (fail-soft read discipline).
|
|
125
|
+
if (!notes.every((n) => isSkillNote(n))) return false;
|
|
126
|
+
}
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isStringArray(value: unknown): value is readonly string[] {
|
|
131
|
+
return Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Fail-soft reader — mirrors settings.ts:loadSettings. Corrupt/missing ⇒ frozen empty. */
|
|
135
|
+
export async function loadSkillState(dir?: string): Promise<SkillStateFile> {
|
|
136
|
+
try {
|
|
137
|
+
const raw = JSON.parse(await readFile(skillStatePath(dir), "utf8")) as unknown;
|
|
138
|
+
if (isSkillStateFile(raw)) return raw;
|
|
139
|
+
return EMPTY_SKILL_STATE;
|
|
140
|
+
} catch {
|
|
141
|
+
return EMPTY_SKILL_STATE;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Fail-soft boolean writer — mirrors settings.ts:saveSettings. */
|
|
146
|
+
export async function saveSkillState(s: SkillStateFile, dir?: string): Promise<boolean> {
|
|
147
|
+
try {
|
|
148
|
+
const p = skillStatePath(dir);
|
|
149
|
+
await mkdir(dirname(p), { recursive: true });
|
|
150
|
+
await writeFile(p, JSON.stringify(s));
|
|
151
|
+
return true;
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Distillation: Σ → notes (the construction half of the one interface) ────────────────
|
|
158
|
+
|
|
159
|
+
/** Input note shape accepted by SkillStore.merge — ids are computed, never supplied. */
|
|
160
|
+
export interface SkillNoteInput {
|
|
161
|
+
readonly text: string;
|
|
162
|
+
readonly keywords?: readonly string[];
|
|
163
|
+
readonly tags?: readonly string[];
|
|
164
|
+
readonly context?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const PATH_TOKEN = /(?:[\w@.-]+\/)+[\w@.-]+/g;
|
|
168
|
+
const CAMEL_SPLIT = /(?<=[a-z0-9])(?=[A-Z])/;
|
|
169
|
+
|
|
170
|
+
/** Deterministic keywords: path tokens + camelCase parts + long words, deduped, ≤6. */
|
|
171
|
+
function keywordsOf(text: string): readonly string[] {
|
|
172
|
+
const out: string[] = [];
|
|
173
|
+
const push = (word: string): void => {
|
|
174
|
+
const w = word.trim();
|
|
175
|
+
if (w.length >= 3 && !out.includes(w) && out.length < KEYWORDS_MAX) out.push(w);
|
|
176
|
+
};
|
|
177
|
+
for (const match of text.matchAll(PATH_TOKEN)) push(match[0]);
|
|
178
|
+
for (const raw of text.split(/[^0-9A-Za-z]+/)) {
|
|
179
|
+
if (raw.length > 3) {
|
|
180
|
+
const parts = raw.split(CAMEL_SPLIT);
|
|
181
|
+
if (parts.length > 1) for (const part of parts) push(part.toLowerCase());
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Workstream B Hook 1 (deterministic half): harvest Σ into note inputs — zero extra tokens.
|
|
189
|
+
* verifiedFacts → "symbol" notes; successful approaches → "recipe" notes.
|
|
190
|
+
*/
|
|
191
|
+
export function notesFromRunState(state: RunState): readonly SkillNoteInput[] {
|
|
192
|
+
const notes: SkillNoteInput[] = [];
|
|
193
|
+
for (const fact of state.verifiedFacts) {
|
|
194
|
+
if (fact.trim().length < 8) continue;
|
|
195
|
+
notes.push({
|
|
196
|
+
text: fact.trim().slice(0, NOTE_MAX_CHARS),
|
|
197
|
+
keywords: keywordsOf(fact),
|
|
198
|
+
tags: ["symbol"],
|
|
199
|
+
context: state.task.slice(0, CONTEXT_MAX_CHARS),
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
for (const [key, outcome] of Object.entries(state.testedApproaches)) {
|
|
203
|
+
if (outcome.status !== "succeeded") continue;
|
|
204
|
+
const text = `${key}: ${outcome.evidence}`.slice(0, NOTE_MAX_CHARS);
|
|
205
|
+
notes.push({
|
|
206
|
+
text,
|
|
207
|
+
keywords: keywordsOf(text),
|
|
208
|
+
tags: ["recipe"],
|
|
209
|
+
context: state.task.slice(0, CONTEXT_MAX_CHARS),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return notes;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A-Mem phrasing prompt (Hook 1, LLM half — enableSkillStateDistill, default ON). */
|
|
216
|
+
export function distillPromptFor(state: RunState): string {
|
|
217
|
+
return [
|
|
218
|
+
"Distill AT MOST 6 durable, reusable project facts from this run. One per line, exactly:",
|
|
219
|
+
"text | kw1, kw2 | tag",
|
|
220
|
+
'tag ∈ {config, gotcha, symbol, recipe}. text ≤240 chars, factual, path-anchored. No preamble.',
|
|
221
|
+
"",
|
|
222
|
+
`Run task: ${state.task}`,
|
|
223
|
+
"Verified facts:",
|
|
224
|
+
...state.verifiedFacts.slice(-12).map((f) => `- ${f}`),
|
|
225
|
+
].join("\n");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Defensive parser for the distill leaf's output — anything malformed is dropped. */
|
|
229
|
+
export function parseDistilledNotes(raw: string): readonly SkillNoteInput[] {
|
|
230
|
+
const out: SkillNoteInput[] = [];
|
|
231
|
+
for (const line of raw.split("\n")) {
|
|
232
|
+
const trimmed = line.trim().replace(/^-\s*/, "");
|
|
233
|
+
if (trimmed === "") continue;
|
|
234
|
+
const parts = trimmed.split("|");
|
|
235
|
+
if (parts.length < 2) continue;
|
|
236
|
+
const text = parts[0].trim();
|
|
237
|
+
if (text.length < 8) continue;
|
|
238
|
+
const keywords = (parts[1] ?? "")
|
|
239
|
+
.split(",")
|
|
240
|
+
.map((k) => k.trim())
|
|
241
|
+
.filter((k) => k.length >= 3)
|
|
242
|
+
.slice(0, KEYWORDS_MAX);
|
|
243
|
+
const tags = (parts[2] ?? "")
|
|
244
|
+
.split(/[\s,]+/)
|
|
245
|
+
.map((t) => t.trim())
|
|
246
|
+
.filter((t) => TAGS.has(t));
|
|
247
|
+
out.push({ text: text.slice(0, NOTE_MAX_CHARS), keywords, tags: normalizeTags(tags) });
|
|
248
|
+
if (out.length >= 6) break;
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Read path: BM25 selection for Ξ, leaf grounding, and skill_search ───────────────────
|
|
254
|
+
|
|
255
|
+
function noteCorpus(note: SkillNote): string {
|
|
256
|
+
return `${note.text} ${note.keywords.join(" ")} ${note.tags.join(" ")} ${note.context}`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface SkillSearchHit {
|
|
260
|
+
readonly id: string;
|
|
261
|
+
readonly text: string;
|
|
262
|
+
readonly tags: readonly string[];
|
|
263
|
+
readonly score: number;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The session-scoped store — hydrated once at session_start, flushed at session_shutdown.
|
|
268
|
+
* All recall (Ξ block, leaf grounding, skill_search) reads through the same BM25 rank.
|
|
269
|
+
*/
|
|
270
|
+
export class SkillStore {
|
|
271
|
+
private constructor(
|
|
272
|
+
private file: SkillStateFile,
|
|
273
|
+
private readonly project: string,
|
|
274
|
+
private readonly notesPerProject: number,
|
|
275
|
+
private readonly dir: string | undefined,
|
|
276
|
+
private dirty = false,
|
|
277
|
+
) {}
|
|
278
|
+
|
|
279
|
+
static async hydrate(notesPerProject: number, dir?: string): Promise<SkillStore> {
|
|
280
|
+
return new SkillStore(
|
|
281
|
+
await loadSkillState(dir),
|
|
282
|
+
projectFingerprint(process.cwd()),
|
|
283
|
+
Math.max(1, Math.floor(notesPerProject)),
|
|
284
|
+
dir,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
get projectKey(): string {
|
|
289
|
+
return this.project;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
get noteCount(): number {
|
|
293
|
+
return this.file.projects[this.project]?.length ?? 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Test/telemetry seam — the exact on-disk shape a flush would write. */
|
|
297
|
+
snapshot(): SkillStateFile {
|
|
298
|
+
return this.file;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Workstream E: the sandbox skill_search surface. Score > 0 hits only, best first. */
|
|
302
|
+
search(query: string, k = 8): readonly SkillSearchHit[] {
|
|
303
|
+
const notes = this.file.projects[this.project] ?? [];
|
|
304
|
+
if (notes.length === 0 || query.trim() === "") return [];
|
|
305
|
+
const ranked = bm25Rank(
|
|
306
|
+
query,
|
|
307
|
+
notes.map((note) => ({ item: note, text: noteCorpus(note) })),
|
|
308
|
+
Math.max(1, Math.min(32, k)),
|
|
309
|
+
);
|
|
310
|
+
return ranked.map(({ item, score }) => ({
|
|
311
|
+
id: item.id,
|
|
312
|
+
text: item.text,
|
|
313
|
+
tags: item.tags,
|
|
314
|
+
score: Math.round(score * 100) / 100,
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Ξ body lines shared by blockFor/sliceForPrompt — packed greedily under the char budget. */
|
|
319
|
+
private pack(
|
|
320
|
+
query: string,
|
|
321
|
+
k: number,
|
|
322
|
+
budgetChars: number,
|
|
323
|
+
minScore: number,
|
|
324
|
+
): readonly string[] {
|
|
325
|
+
const notes = this.file.projects[this.project] ?? [];
|
|
326
|
+
if (notes.length === 0) return [];
|
|
327
|
+
const ranked = bm25Rank(
|
|
328
|
+
query,
|
|
329
|
+
notes.map((note) => ({ item: note, text: noteCorpus(note) })),
|
|
330
|
+
Math.min(k, notes.length),
|
|
331
|
+
);
|
|
332
|
+
const lines: string[] = [];
|
|
333
|
+
let used = 0;
|
|
334
|
+
for (const { item, score } of ranked) {
|
|
335
|
+
if (score < minScore) break; // ranked desc — the first miss ends the window
|
|
336
|
+
const line = `- (${item.tags[0] ?? "symbol"}) ${item.text}`;
|
|
337
|
+
if (used + line.length + 1 > budgetChars) continue; // too fat — try the next, smaller
|
|
338
|
+
lines.push(line);
|
|
339
|
+
used += line.length + 1;
|
|
340
|
+
}
|
|
341
|
+
return lines;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Workstream C: the full Ξ block for a root prompt (header via skillStateLines — the single
|
|
346
|
+
* wording source). "" when nothing is relevant or the store is empty.
|
|
347
|
+
*/
|
|
348
|
+
blockFor(query: string, budgetTokens: number): string {
|
|
349
|
+
const lines = this.pack(query, 24, Math.max(0, budgetTokens) * 4, Number.MIN_VALUE);
|
|
350
|
+
if (lines.length === 0) return "";
|
|
351
|
+
return skillStateLines(lines.length, lines.join("\n"));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Workstream D: grounded leaf lines. "" unless the top BM25 score clears `minScore`
|
|
356
|
+
* (below-threshold prompts stay byte-identical — grounding must never add noise).
|
|
357
|
+
*/
|
|
358
|
+
sliceForPrompt(query: string, budgetTokens: number, minScore: number): string {
|
|
359
|
+
const lines = this.pack(query, 8, Math.max(0, budgetTokens) * 4, minScore);
|
|
360
|
+
return lines.join("\n");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Dedup write: duplicate (by normalized text) bumps `hits` and refreshes `ts`; new notes
|
|
365
|
+
* insert. Per-project cap with LRU-by-ts eviction, top-hits quartile pinned.
|
|
366
|
+
*/
|
|
367
|
+
merge(inputs: readonly SkillNoteInput[]): void {
|
|
368
|
+
if (inputs.length === 0) return;
|
|
369
|
+
const existing = this.file.projects[this.project] ?? [];
|
|
370
|
+
const byId = new Map<string, SkillNote>(existing.map((note) => [note.id, note]));
|
|
371
|
+
const now = Date.now();
|
|
372
|
+
for (const input of inputs) {
|
|
373
|
+
const text = input.text.trim();
|
|
374
|
+
if (text === "") continue;
|
|
375
|
+
const id = noteId(this.project, text);
|
|
376
|
+
const prev = byId.get(id);
|
|
377
|
+
if (prev !== undefined) {
|
|
378
|
+
// Reinforcement, not duplication. The one merge (state-merge.ts) composes the update;
|
|
379
|
+
// the result is re-narrowed — a malformed merge outcome keeps the previous note.
|
|
380
|
+
const mergedKeywords = [...prev.keywords];
|
|
381
|
+
for (const keyword of input.keywords ?? []) {
|
|
382
|
+
if (!mergedKeywords.includes(keyword) && mergedKeywords.length < KEYWORDS_MAX) {
|
|
383
|
+
mergedKeywords.push(keyword);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const merged = deepMergeWithNullDeletion(prev, {
|
|
387
|
+
hits: prev.hits + 1,
|
|
388
|
+
ts: now,
|
|
389
|
+
keywords: mergedKeywords,
|
|
390
|
+
});
|
|
391
|
+
byId.set(id, isSkillNote(merged) ? merged : prev);
|
|
392
|
+
} else {
|
|
393
|
+
const note: SkillNote = {
|
|
394
|
+
id,
|
|
395
|
+
text: text.slice(0, NOTE_MAX_CHARS),
|
|
396
|
+
keywords: (input.keywords ?? []).slice(0, KEYWORDS_MAX),
|
|
397
|
+
tags: normalizeTags(input.tags),
|
|
398
|
+
context: (input.context ?? "").slice(0, CONTEXT_MAX_CHARS),
|
|
399
|
+
hits: 1,
|
|
400
|
+
ts: now,
|
|
401
|
+
};
|
|
402
|
+
byId.set(id, note);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
let notes = [...byId.values()];
|
|
406
|
+
if (notes.length > this.notesPerProject) {
|
|
407
|
+
// Pinned: top quartile by hits (ceil) — frequently-reinforced facts survive eviction.
|
|
408
|
+
const byHits = [...notes].sort((a, b) => b.hits - a.hits || b.ts - a.ts);
|
|
409
|
+
const pinned = new Set(
|
|
410
|
+
byHits.slice(0, Math.max(1, Math.ceil(byHits.length / 4))).map((note) => note.id),
|
|
411
|
+
);
|
|
412
|
+
const evictable = notes
|
|
413
|
+
.filter((note) => !pinned.has(note.id))
|
|
414
|
+
.sort((a, b) => a.ts - b.ts);
|
|
415
|
+
const evict = new Set(evictable.slice(0, notes.length - this.notesPerProject).map((note) => note.id));
|
|
416
|
+
notes = notes.filter((note) => !evict.has(note.id));
|
|
417
|
+
}
|
|
418
|
+
this.file = { version: 1, projects: { ...this.file.projects, [this.project]: notes } };
|
|
419
|
+
this.dirty = true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Flush to disk (fail-soft). Other projects' sections ride along from the hydrated file. */
|
|
423
|
+
async flush(): Promise<boolean> {
|
|
424
|
+
if (!this.dirty) return true;
|
|
425
|
+
const written = await saveSkillState(this.file, this.dir);
|
|
426
|
+
if (written) this.dirty = false;
|
|
427
|
+
return written;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ── Wiring helpers (one implementation each — both composition roots reuse these) ────────
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Workstream D leaf grounding — THE implementation complete1 delegates to via
|
|
435
|
+
* `SubcallHandlerDeps.groundLeaf`. Below-threshold ⇒ byte-identical prompt.
|
|
436
|
+
*/
|
|
437
|
+
export function groundLeafPrompt(
|
|
438
|
+
store: SkillStore,
|
|
439
|
+
config: Pick<RlmConfig, "skillStateLeafTokens" | "skillStateMinScore">,
|
|
440
|
+
prompt: string,
|
|
441
|
+
): string {
|
|
442
|
+
const slice = store.sliceForPrompt(prompt, config.skillStateLeafTokens, config.skillStateMinScore);
|
|
443
|
+
return slice === "" ? prompt : `[Project facts]\n${slice}\n\n${prompt}`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Workstream E host handler — the ONE skill_search implementation, served to both sandboxes
|
|
448
|
+
* (engine + repl tool). Unwired/disabled stores reply with an error string, never a throw.
|
|
449
|
+
*/
|
|
450
|
+
export function skillSearchHandler(
|
|
451
|
+
getStore: () => SkillStore | undefined,
|
|
452
|
+
): (query: string, k: number, depth: number) => Promise<string> {
|
|
453
|
+
return async (query, k) => {
|
|
454
|
+
const store = getStore();
|
|
455
|
+
if (store === undefined) return formatError("skill state store not configured");
|
|
456
|
+
try {
|
|
457
|
+
return JSON.stringify(store.search(query, k));
|
|
458
|
+
} catch (err: unknown) {
|
|
459
|
+
return formatError(err instanceof Error ? err.message : String(err));
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The config slice groundLeafPrompt needs — callers pass their live RlmConfig. */
|
|
465
|
+
export type GroundLeafConfig = Pick<RlmConfig, "skillStateLeafTokens" | "skillStateMinScore">;
|
package/src/context/md-cache.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
19
19
|
import { basename, join } from "node:path";
|
|
20
20
|
import { homedir } from "node:os";
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
interface FileStamp {
|
|
23
23
|
readonly size: number;
|
|
24
24
|
readonly mtimeMs: number;
|
|
25
25
|
}
|
package/src/context/merge.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "./namespace.ts";
|
|
15
15
|
import type { ContextFile } from "./types.ts";
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
interface FilteredContext {
|
|
18
18
|
readonly files: readonly ContextFile[];
|
|
19
19
|
/** Prefixes that selected zero files — the caller decides whether that is fatal. */
|
|
20
20
|
readonly unmatched: readonly string[];
|
package/src/context/namespace.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { basename, isAbsolute, resolve } from "node:path";
|
|
|
10
10
|
import { estimateTokens } from "../text/tokens.ts";
|
|
11
11
|
import { LEGACY_UNKNOWN_PREFIX, type ContextFile } from "./types.ts";
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
interface ContextNamespace {
|
|
14
14
|
readonly sourceId: string;
|
|
15
15
|
readonly pathPrefix: string;
|
|
16
16
|
}
|
|
@@ -105,7 +105,7 @@ export function namespaceContextFilesWithChars(
|
|
|
105
105
|
for (const item of payload) {
|
|
106
106
|
if (item === null || typeof item !== "object") continue;
|
|
107
107
|
const rec = item as Record<string, unknown>;
|
|
108
|
-
const content = typeof rec.content === "string" ? rec.content :
|
|
108
|
+
const content = typeof rec.content === "string" ? rec.content : "";
|
|
109
109
|
let path = typeof rec.path === "string" ? rec.path : "unknown";
|
|
110
110
|
path = applyPathPrefix(path, prefix);
|
|
111
111
|
const tokens = typeof rec.tokens === "number" && Number.isFinite(rec.tokens)
|
package/src/context/refresh.ts
CHANGED
|
@@ -69,7 +69,7 @@ export function upsertContextFile(
|
|
|
69
69
|
item !== null &&
|
|
70
70
|
typeof item === "object" &&
|
|
71
71
|
"path" in item &&
|
|
72
|
-
typeof (item
|
|
72
|
+
typeof (item).path === "string" &&
|
|
73
73
|
pathMatches((item as { path: string }).path, path, cwd)
|
|
74
74
|
) {
|
|
75
75
|
next[n++] = entry;
|