@hicaru/pi-rlm 0.3.16 → 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 +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 +28 -12
- package/src/config/settings.ts +41 -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 +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 +51 -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/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/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
package/src/config/settings.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Api, Model, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
|
7
7
|
import type { RlmConfig } from "../core/types.ts";
|
|
8
8
|
import { DEFAULT_CONFIG } from "./defaults.ts";
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
interface PersistedSettings {
|
|
11
11
|
readonly config: Partial<RlmConfig>;
|
|
12
12
|
/** "provider/id" of the pinned sub-LLM, or undefined for "cheapest (auto)".
|
|
13
13
|
* `null` = explicit "cheapest" clear (omit key on disk). */
|
|
@@ -104,8 +104,7 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
104
104
|
if (subSystemPrompt !== undefined) out.subSystemPrompt = subSystemPrompt;
|
|
105
105
|
const sandboxInitTimeoutMs = validateNumber(r.sandboxInitTimeoutMs, 100);
|
|
106
106
|
if (sandboxInitTimeoutMs !== undefined) out.sandboxInitTimeoutMs = sandboxInitTimeoutMs;
|
|
107
|
-
|
|
108
|
-
const contextLoader = validateBoolean(r.contextLoader) ?? validateBoolean(r.libraryLoader);
|
|
107
|
+
const contextLoader = validateBoolean(r.contextLoader);
|
|
109
108
|
if (contextLoader !== undefined) out.contextLoader = contextLoader;
|
|
110
109
|
const autoSeedCwd = validateBoolean(r.autoSeedCwd);
|
|
111
110
|
if (autoSeedCwd !== undefined) out.autoSeedCwd = autoSeedCwd;
|
|
@@ -127,18 +126,6 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
127
126
|
if (enableLedger !== undefined) out.enableLedger = enableLedger;
|
|
128
127
|
const rlmBudget = validateNumber(r.rlmBudget, 0);
|
|
129
128
|
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
129
|
// v5 provider concurrency caps: { provider: minConcurrent }
|
|
143
130
|
if (typeof r.providerMaxConcurrent === "object" && r.providerMaxConcurrent !== null) {
|
|
144
131
|
const caps: Record<string, number> = {};
|
|
@@ -148,11 +135,43 @@ export function validateConfig(raw: unknown): Partial<RlmConfig> {
|
|
|
148
135
|
}
|
|
149
136
|
if (Object.keys(caps).length > 0) out.providerMaxConcurrent = Object.freeze(caps);
|
|
150
137
|
}
|
|
151
|
-
// v5 child surface doctrine
|
|
152
|
-
if (r.childSurface === "delegation" || r.childSurface === "legacy") out.childSurface = r.childSurface;
|
|
153
138
|
// Verification-discipline nudge (default OFF).
|
|
154
139
|
const enableVerificationNudge = validateBoolean(r.enableVerificationNudge);
|
|
155
140
|
if (enableVerificationNudge !== undefined) out.enableVerificationNudge = enableVerificationNudge;
|
|
141
|
+
// SKILL.state integration (Workstreams A–F)
|
|
142
|
+
const enableRunState = validateBoolean(r.enableRunState);
|
|
143
|
+
if (enableRunState !== undefined) out.enableRunState = enableRunState;
|
|
144
|
+
const runStateRetryMax = validateNumber(r.runStateRetryMax, 0);
|
|
145
|
+
if (runStateRetryMax !== undefined) out.runStateRetryMax = runStateRetryMax;
|
|
146
|
+
const enableSkillState = validateBoolean(r.enableSkillState);
|
|
147
|
+
if (enableSkillState !== undefined) out.enableSkillState = enableSkillState;
|
|
148
|
+
const enableSkillStateDistill = validateBoolean(r.enableSkillStateDistill);
|
|
149
|
+
if (enableSkillStateDistill !== undefined) out.enableSkillStateDistill = enableSkillStateDistill;
|
|
150
|
+
const skillStateMaxTokens = validateNumber(r.skillStateMaxTokens, 50);
|
|
151
|
+
if (skillStateMaxTokens !== undefined) out.skillStateMaxTokens = skillStateMaxTokens;
|
|
152
|
+
const skillStateLeafTokens = validateNumber(r.skillStateLeafTokens, 0);
|
|
153
|
+
if (skillStateLeafTokens !== undefined) out.skillStateLeafTokens = skillStateLeafTokens;
|
|
154
|
+
const skillStateMinScore = validateNumber(r.skillStateMinScore, 0);
|
|
155
|
+
if (skillStateMinScore !== undefined) out.skillStateMinScore = skillStateMinScore;
|
|
156
|
+
const skillStateNotesPerProject = validateNumber(r.skillStateNotesPerProject, 1);
|
|
157
|
+
if (skillStateNotesPerProject !== undefined) out.skillStateNotesPerProject = skillStateNotesPerProject;
|
|
158
|
+
// Root Σ integration (WS-2..WS-4)
|
|
159
|
+
const enableRootDigestCompaction = validateBoolean(r.enableRootDigestCompaction);
|
|
160
|
+
if (enableRootDigestCompaction !== undefined) out.enableRootDigestCompaction = enableRootDigestCompaction;
|
|
161
|
+
const rootDigestKeepRecentChars = validateNumber(r.rootDigestKeepRecentChars, 200);
|
|
162
|
+
if (rootDigestKeepRecentChars !== undefined) out.rootDigestKeepRecentChars = rootDigestKeepRecentChars;
|
|
163
|
+
const rootDigestMaxChars = validateNumber(r.rootDigestMaxChars, 200);
|
|
164
|
+
if (rootDigestMaxChars !== undefined) out.rootDigestMaxChars = rootDigestMaxChars;
|
|
165
|
+
const enableRootContextTransform = validateBoolean(r.enableRootContextTransform);
|
|
166
|
+
if (enableRootContextTransform !== undefined) out.enableRootContextTransform = enableRootContextTransform;
|
|
167
|
+
const rootContextKeepTurns = validateNumber(r.rootContextKeepTurns, 0);
|
|
168
|
+
if (rootContextKeepTurns !== undefined) out.rootContextKeepTurns = rootContextKeepTurns;
|
|
169
|
+
const rootContextElideChars = validateNumber(r.rootContextElideChars, 100);
|
|
170
|
+
if (rootContextElideChars !== undefined) out.rootContextElideChars = rootContextElideChars;
|
|
171
|
+
const rootContextSnapshot = validateBoolean(r.rootContextSnapshot);
|
|
172
|
+
if (rootContextSnapshot !== undefined) out.rootContextSnapshot = rootContextSnapshot;
|
|
173
|
+
const enableRootStateFences = validateBoolean(r.enableRootStateFences);
|
|
174
|
+
if (enableRootStateFences !== undefined) out.enableRootStateFences = enableRootStateFences;
|
|
156
175
|
if (typeof r.subSampling === "object" && r.subSampling !== null) {
|
|
157
176
|
const ss = r.subSampling as Record<string, unknown>;
|
|
158
177
|
const sampling: { maxTokens?: number; temperature?: number; reasoning?: ThinkingLevel } = {};
|
|
@@ -185,8 +204,7 @@ export async function loadSettings(): Promise<PersistedSettings> {
|
|
|
185
204
|
const r = raw as Record<string, unknown>;
|
|
186
205
|
return {
|
|
187
206
|
config: validateConfig(r.config),
|
|
188
|
-
|
|
189
|
-
llm: validateString(r.llm) ?? validateString(r.worker),
|
|
207
|
+
llm: validateString(r.llm),
|
|
190
208
|
rlm: validateString(r.rlm),
|
|
191
209
|
};
|
|
192
210
|
} catch {
|
|
@@ -242,16 +260,8 @@ export function modelRef(model: Model<Api> | undefined): string | undefined {
|
|
|
242
260
|
return model ? `${model.provider}/${model.id}` : undefined;
|
|
243
261
|
}
|
|
244
262
|
|
|
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;
|
|
263
|
+
/** Single "provider/id" projection for sub-call node labels — shared by the llm and rlm
|
|
264
|
+
* bridges so sub-call trees label their nodes identically. */
|
|
265
|
+
export function modelLabelOf(model: Model<Api>): string {
|
|
266
|
+
return modelRef(model) ?? model.id;
|
|
257
267
|
}
|
|
@@ -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;
|
|
@@ -29,7 +29,7 @@ import { applyPathPrefix, contextSourceId, pathPrefixFor } from "./namespace.ts"
|
|
|
29
29
|
/** Conversions are libuv-threadpool bound (default pool 4); 8 keeps the pool busy without thrash. */
|
|
30
30
|
const CONVERT_CONCURRENCY = 8;
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
interface PackDirResult {
|
|
33
33
|
readonly files: readonly ContextFile[];
|
|
34
34
|
readonly chars: number;
|
|
35
35
|
readonly documents: number;
|
|
@@ -37,6 +37,25 @@ export interface PackDirResult {
|
|
|
37
37
|
readonly skipped: readonly SkippedFile[];
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** DRY: the ONE PackDirResult → SourceResult flattening — shared by the local-dir source and
|
|
41
|
+
* the git-clone source (which wraps it in Result). Never inline a second copy. */
|
|
42
|
+
export function packToSourceResult(
|
|
43
|
+
packed: PackDirResult,
|
|
44
|
+
sourceId: string,
|
|
45
|
+
pathPrefix: string,
|
|
46
|
+
): SourceResult {
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
payload: packed.files,
|
|
49
|
+
files: packed.files.length,
|
|
50
|
+
chars: packed.chars,
|
|
51
|
+
sourceId,
|
|
52
|
+
pathPrefix,
|
|
53
|
+
documents: packed.documents,
|
|
54
|
+
converted: packed.converted,
|
|
55
|
+
skipped: packed.skipped,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
40
59
|
/**
|
|
41
60
|
* Walk `dir` and produce ContextFiles. Resolves the anydoc handle once per source
|
|
42
61
|
* (not per file) and threads it into the per-file router.
|
|
@@ -153,14 +172,5 @@ export async function sourceDir(
|
|
|
153
172
|
// Explicit pathPrefix (including "") wins; otherwise derive ctx/<id>/.
|
|
154
173
|
const pathPrefix = opts.pathPrefix !== undefined ? opts.pathPrefix : pathPrefixFor(sourceId);
|
|
155
174
|
const packed = await packDirectory(dir, pathPrefix, opts.signal);
|
|
156
|
-
return
|
|
157
|
-
payload: packed.files,
|
|
158
|
-
files: packed.files.length,
|
|
159
|
-
chars: packed.chars,
|
|
160
|
-
sourceId,
|
|
161
|
-
pathPrefix,
|
|
162
|
-
documents: packed.documents,
|
|
163
|
-
converted: packed.converted,
|
|
164
|
-
skipped: packed.skipped,
|
|
165
|
-
});
|
|
175
|
+
return packToSourceResult(packed, sourceId, pathPrefix);
|
|
166
176
|
}
|