@worca/app 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// The durable agent memory store: ~/.worca-cc/memory/{global,projects/<key>}/<name>.md
|
|
2
|
+
// (agent-memory-design.md §2–§3). This module is the ONE reader/writer of that
|
|
3
|
+
// layout; the run mount (memory-sync.mjs), the Ask tools and the HTTP API all go
|
|
4
|
+
// through it. fs posture mirrors run-context.mjs: ENOENT is a normal, silent
|
|
5
|
+
// outcome; a REAL read error is reported through `onError` and skipped; nothing
|
|
6
|
+
// here throws on a missing source. Every store write is preceded by a snapshot.
|
|
7
|
+
import { readFile, writeFile, readdir, mkdir, rm, rename, cp, realpath } from 'node:fs/promises';
|
|
8
|
+
import { join, resolve, relative, isAbsolute, sep } from 'node:path';
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { worcaHome } from './projects.mjs';
|
|
11
|
+
import { isValidSkillName } from './skills.mjs';
|
|
12
|
+
import { parseFrontmatter, stripFrontmatter } from './frontmatter.mjs';
|
|
13
|
+
|
|
14
|
+
export const MEMORY_DIR = 'memory';
|
|
15
|
+
export const HOOK_MAX_CHARS = 160;
|
|
16
|
+
export const SNAPSHOT_KEEP = 20;
|
|
17
|
+
export const GLOBAL_SCOPE = Object.freeze({ kind: 'global' });
|
|
18
|
+
|
|
19
|
+
/** Every refusal this module raises: ENAME (unusable name), ECASE (case twin), ETOOBIG (over
|
|
20
|
+
* the hard cap), EFULL (the scope holds caps.maxFilesPerScope files and this is a new one),
|
|
21
|
+
* ENOSCOPE (no such snapshot), EESCAPE (the scope dir resolves outside the store root).
|
|
22
|
+
* Callers branch on `code`; syncBack turns them into per-file rejections instead of aborting. */
|
|
23
|
+
export class MemoryError extends Error {
|
|
24
|
+
constructor(code, message) { super(message); this.name = 'MemoryError'; this.code = code; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function projectScope(projectKey) { return { kind: 'project', projectKey: String(projectKey) }; }
|
|
28
|
+
|
|
29
|
+
/** `<worcaHome>/memory` — read fresh per call (WORCA_HOME / settings root may change). */
|
|
30
|
+
export function memoryRoot() { return join(worcaHome(), MEMORY_DIR); }
|
|
31
|
+
|
|
32
|
+
/** 'global' | 'projects/<projectKey>'. Throws on a malformed scope (a programming error, never user input). */
|
|
33
|
+
export function scopeKey(scope) {
|
|
34
|
+
if (scope?.kind === 'global') return 'global';
|
|
35
|
+
if (scope?.kind === 'project') {
|
|
36
|
+
if (!isValidSkillName(scope.projectKey)) throw new Error(`memory scope: invalid projectKey ${JSON.stringify(scope?.projectKey)}`);
|
|
37
|
+
return `projects/${scope.projectKey}`;
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`memory scope: unknown kind ${JSON.stringify(scope?.kind)} (expected global|project)`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function scopeDir(root, scope) {
|
|
43
|
+
return scope?.kind === 'global' ? join(root, 'global') : join(root, 'projects', scopeKey(scope).slice('projects/'.length));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const WIN_RESERVED_RE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i; // Win32 device names — reserved with ANY extension
|
|
47
|
+
/** A memory NAME is a filename stem used as a path segment: skills.mjs' class, minus a
|
|
48
|
+
* trailing `.md`; never dot-leading (a hidden file is invisible to every lister), never
|
|
49
|
+
* dot-trailing (Win32 strips it) and never a Windows device name. */
|
|
50
|
+
export function isValidMemoryName(name) {
|
|
51
|
+
return isValidSkillName(name) && !name.startsWith('.') && !name.endsWith('.') && !/\.md$/i.test(name) && !WIN_RESERVED_RE.test(name.split('.')[0]); // the STEM before the first dot: Win32 reserves `nul.rules.md` too
|
|
52
|
+
}
|
|
53
|
+
/** The ONE human wording of that rule. The /api/memory routes answer `invalid memory name — ${MEMORY_NAME_HELP}`
|
|
54
|
+
* and ui/public/memory-view.mjs declares the same literal for the editor's client-side refusal, so the
|
|
55
|
+
* message a user sees never depends on which side refused (test/api-memory.test.mjs compares them). */
|
|
56
|
+
export const MEMORY_NAME_HELP = 'letters, digits, ".", "_" and "-" only, no extension, no leading or trailing dot';
|
|
57
|
+
|
|
58
|
+
// Built from char codes so the SOURCE carries no escape sequence (see the plan's
|
|
59
|
+
// escape-safety rule): C0 + DEL, C1 (incl. U+0085 NEL), U+2028 and U+2029.
|
|
60
|
+
const C = String.fromCharCode;
|
|
61
|
+
const LINE_BREAKERS = new RegExp(`[${C(0)}-${C(31)}${C(127)}-${C(159)}${C(0x2028)}${C(0x2029)}]`, 'g');
|
|
62
|
+
const CONTEXT_TAG_RE = /\[\/?worca context\]/gi;
|
|
63
|
+
|
|
64
|
+
/** One line, always. Same neutralisation as ask/prompt.mjs' private flatten (kept local: this module must not pull the Ask catalog). */
|
|
65
|
+
export function flattenLine(s) {
|
|
66
|
+
return String(s ?? '').replace(LINE_BREAKERS, ' ').replace(CONTEXT_TAG_RE, '(worca context)');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const clipHook = (s, n = HOOK_MAX_CHARS) => { const t = flattenLine(s).trim(); return t.length > n ? t.slice(0, n) : t; };
|
|
70
|
+
const splitPaths = (s) => String(s ?? '').split(',').map((p) => p.trim()).filter(Boolean);
|
|
71
|
+
const KNOWN_KEYS = ['name', 'description', 'paths', 'source', 'updated'];
|
|
72
|
+
const EXTRA_KEY_RE = /^[A-Za-z][A-Za-z0-9_-]*$/; // frontmatter.mjs' KEY_LINE_RE class: anything else cannot be re-parsed
|
|
73
|
+
|
|
74
|
+
/** @returns {{meta:{name:string,description:string,paths:string[],source:string,updated:string,extra:Record<string,string>}, body:string, hasFrontmatter:boolean}} */
|
|
75
|
+
export function parseMemoryFile(text) {
|
|
76
|
+
const s = typeof text === 'string' ? text : '';
|
|
77
|
+
const fm = parseFrontmatter(s);
|
|
78
|
+
const empty = { name: '', description: '', paths: [], source: '', updated: '', extra: {} };
|
|
79
|
+
if (!fm) return { meta: empty, body: s, hasFrontmatter: false };
|
|
80
|
+
const extra = {};
|
|
81
|
+
for (const k of Object.keys(fm.fields).sort()) if (!KNOWN_KEYS.includes(k)) extra[k] = fm.fields[k];
|
|
82
|
+
return {
|
|
83
|
+
meta: {
|
|
84
|
+
name: fm.fields.name || '', description: fm.fields.description || '', paths: splitPaths(fm.fields.paths),
|
|
85
|
+
source: fm.fields.source || '', updated: fm.fields.updated || '', extra,
|
|
86
|
+
},
|
|
87
|
+
body: stripFrontmatter(s),
|
|
88
|
+
hasFrontmatter: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Known keys in a fixed order, then `extra` sorted; empty values are omitted; the body always ends with one newline. */
|
|
93
|
+
export function renderMemoryFile(meta, body) {
|
|
94
|
+
if (!isValidMemoryName(meta?.name)) throw new MemoryError('ENAME', `memory: cannot render a file without a valid name (${JSON.stringify(meta?.name)})`);
|
|
95
|
+
const lines = ['---'];
|
|
96
|
+
if (meta.name) lines.push(`name: ${flattenLine(meta.name)}`);
|
|
97
|
+
if (meta.description) lines.push(`description: ${flattenLine(meta.description)}`);
|
|
98
|
+
if (meta.paths?.length) lines.push(`paths: ${meta.paths.map(flattenLine).join(', ')}`);
|
|
99
|
+
if (meta.source) lines.push(`source: ${flattenLine(meta.source)}`);
|
|
100
|
+
if (meta.updated) lines.push(`updated: ${flattenLine(meta.updated)}`);
|
|
101
|
+
// An extra key the reader could not parse back would silently vanish on the next
|
|
102
|
+
// round-trip (or worse, swallow the line after it) — drop it at the render.
|
|
103
|
+
for (const k of Object.keys(meta.extra || {}).sort()) if (EXTRA_KEY_RE.test(k) && !KNOWN_KEYS.includes(k)) lines.push(`${k}: ${flattenLine(meta.extra[k])}`);
|
|
104
|
+
lines.push('---');
|
|
105
|
+
const b = String(body ?? '');
|
|
106
|
+
return `${lines.join('\n')}\n${b.endsWith('\n') ? b : `${b}\n`}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** First non-empty body line that is not a fence/rule (`---`), minus a markdown heading marker. */
|
|
110
|
+
const deriveHook = (body, n = HOOK_MAX_CHARS) =>
|
|
111
|
+
clipHook((String(body ?? '').split(/\r?\n/).find((l) => l.trim() && !/^-{3,}\s*$/.test(l)) || '').replace(/^#+\s*/, ''), n);
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Normalise a file the way every WRITER does (§2 "sync-back repairs frontmatter"):
|
|
115
|
+
* the name is forced to the filename stem, a missing hook is derived from the body,
|
|
116
|
+
* a long hook is clipped, paths and extra keys are kept, and `source`/`updated` are
|
|
117
|
+
* ALWAYS overwritten with the writer's values — provenance is worca's, never the
|
|
118
|
+
* agent's (a file arriving with `source: user` from a run is a lie). `changed` says
|
|
119
|
+
* whether the rendered text differs from the input; callers only ever repair files
|
|
120
|
+
* whose hash moved, so an untouched file is never rewritten. `hookMaxChars` is the
|
|
121
|
+
* caller's cap: writeMemory and syncBack MUST pass the same one or the two repairs
|
|
122
|
+
* of one file would render different bytes and the baseline hash would never settle.
|
|
123
|
+
*/
|
|
124
|
+
export function repairMemoryFile(text, { name, source, now, hookMaxChars = HOOK_MAX_CHARS }) {
|
|
125
|
+
const input = String(text ?? '');
|
|
126
|
+
const { meta, body } = parseMemoryFile(input);
|
|
127
|
+
const next = {
|
|
128
|
+
name, description: clipHook(meta.description, hookMaxChars) || deriveHook(body, hookMaxChars), paths: meta.paths,
|
|
129
|
+
source, updated: now, extra: meta.extra,
|
|
130
|
+
};
|
|
131
|
+
const out = renderMemoryFile(next, body);
|
|
132
|
+
return { text: out, meta: next, changed: out !== input };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
136
|
+
export const hashText = (text) => createHash('sha1').update(String(text ?? ''), 'utf8').digest('hex');
|
|
137
|
+
const isAbsent = (err) => err && (err.code === 'ENOENT' || err.code === 'ENOTDIR');
|
|
138
|
+
const bytesOf = (s) => Buffer.byteLength(String(s ?? ''), 'utf8');
|
|
139
|
+
/** `yyyymmdd-hhmmss` from an ISO timestamp — no colons (Windows), sorts chronologically. */
|
|
140
|
+
const stamp = (iso) => String(iso).replace(/[-:T]/g, '').slice(0, 15).replace(/^(\d{8})(\d{6}).*$/, '$1-$2');
|
|
141
|
+
const sourceSlug = (source) => String(source || 'unknown').replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'unknown';
|
|
142
|
+
async function readdirMaybe(dir, onError) {
|
|
143
|
+
try { return await readdir(dir, { withFileTypes: true }); }
|
|
144
|
+
catch (err) { if (!isAbsent(err)) onError?.(dir, err); return []; }
|
|
145
|
+
}
|
|
146
|
+
async function readTextMaybe(p, onError) {
|
|
147
|
+
try { return await readFile(p, 'utf8'); }
|
|
148
|
+
catch (err) { if (!isAbsent(err)) onError?.(p, err); return null; }
|
|
149
|
+
}
|
|
150
|
+
let tmpSeq = 0; // two writers in one process (parallel executions, several live runs) must never share a temp name
|
|
151
|
+
/** Atomic text write: temp file beside the target + rename (POSIX and Windows). */
|
|
152
|
+
async function writeAtomic(p, text) {
|
|
153
|
+
await mkdir(resolve(p, '..'), { recursive: true });
|
|
154
|
+
const tmp = `${p}.tmp-${process.pid}-${Date.now()}-${++tmpSeq}`;
|
|
155
|
+
try { await writeFile(tmp, text, 'utf8'); await rename(tmp, p); }
|
|
156
|
+
catch (err) { await rm(tmp, { force: true }).catch(() => {}); throw err; }
|
|
157
|
+
}
|
|
158
|
+
/** The scope dir, created and PROVEN to live under the store root. A scope dir that is a
|
|
159
|
+
* symlink or junction out of the root would carry writeAtomic's temp file + rename (and
|
|
160
|
+
* removeMemory's rm, restoreSnapshot's rm/cp) elsewhere — spec §2/§13, the realpath
|
|
161
|
+
* re-check P1's amendment A5 deferred. `mkdir` first: a fresh store has no root yet.
|
|
162
|
+
* `relative` on the REAL paths handles the macOS /tmp → /private/tmp alias and (on Windows,
|
|
163
|
+
* where path.relative is case-insensitive) a drive-letter case difference.
|
|
164
|
+
* Two accepted edges: a linked `projects/` PARENT has the empty scope dir created at its target
|
|
165
|
+
* before the refusal (nothing is ever written there), and because this runs before writeMemory's
|
|
166
|
+
* cap checks a refused ETOOBIG/EFULL write can leave an empty scope dir behind (listMemory: []). */
|
|
167
|
+
async function writableScopeDir(root, scope) {
|
|
168
|
+
const dir = scopeDir(root, scope);
|
|
169
|
+
await mkdir(dir, { recursive: true });
|
|
170
|
+
const [realRoot, realDir] = await Promise.all([realpath(root), realpath(dir)]);
|
|
171
|
+
const rel = relative(realRoot, realDir);
|
|
172
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
173
|
+
throw new MemoryError('EESCAPE', `memory: ${dir} resolves to ${realDir}, outside the memory store — refusing to write`);
|
|
174
|
+
}
|
|
175
|
+
return dir;
|
|
176
|
+
}
|
|
177
|
+
function assertName(name) {
|
|
178
|
+
if (!isValidMemoryName(name)) throw new MemoryError('ENAME', `memory: invalid name ${JSON.stringify(name)} — letters, digits, ".", "_" and "-" only, no extension`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── listing / reading ────────────────────────────────────────────────────────
|
|
182
|
+
/** @param {string} dir any directory holding <name>.md files (a store scope dir OR a run-mount dir) */
|
|
183
|
+
export async function listMemoryDir(dir, { onError } = {}) {
|
|
184
|
+
const out = [];
|
|
185
|
+
for (const e of await readdirMaybe(dir, onError)) {
|
|
186
|
+
if (!e.isFile() || !e.name.endsWith('.md')) continue;
|
|
187
|
+
const name = e.name.slice(0, -3);
|
|
188
|
+
const path = join(dir, e.name);
|
|
189
|
+
if (!isValidMemoryName(name)) { onError?.(path, new MemoryError('ENAME', `memory: skipped ${e.name} (invalid name)`)); continue; }
|
|
190
|
+
const text = await readTextMaybe(path, onError);
|
|
191
|
+
if (text === null) continue;
|
|
192
|
+
const { meta, body, hasFrontmatter } = parseMemoryFile(text);
|
|
193
|
+
out.push({
|
|
194
|
+
// §2: a broken/missing fence is still SERVED — the hook falls back to the first non-empty body line.
|
|
195
|
+
name, description: meta.description || deriveHook(body), paths: meta.paths, source: meta.source, updated: meta.updated,
|
|
196
|
+
bytes: bytesOf(text), hasFrontmatter, hash: hashText(text),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
200
|
+
}
|
|
201
|
+
export function listMemory(root, scope, opts) { return listMemoryDir(scopeDir(root, scope), opts); }
|
|
202
|
+
|
|
203
|
+
export async function readMemory(root, scope, name) {
|
|
204
|
+
assertName(name);
|
|
205
|
+
const text = await readTextMaybe(join(scopeDir(root, scope), `${name}.md`));
|
|
206
|
+
if (text === null) return null;
|
|
207
|
+
const { meta, body } = parseMemoryFile(text);
|
|
208
|
+
return { text, meta, body };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── .state counters ──────────────────────────────────────────────────────────
|
|
212
|
+
const stateFile = (root, scope) => join(root, '.state', `${scopeKey(scope)}.json`);
|
|
213
|
+
const EMPTY_STATE = Object.freeze({ writesSinceDefrag: 0, lastWriteAt: null, lastDefragAt: null, lastDefragRunId: null });
|
|
214
|
+
export async function readScopeState(root, scope) {
|
|
215
|
+
const text = await readTextMaybe(stateFile(root, scope));
|
|
216
|
+
if (text === null) return { ...EMPTY_STATE };
|
|
217
|
+
try { const j = JSON.parse(text); return { ...EMPTY_STATE, ...(j && typeof j === 'object' ? j : {}) }; }
|
|
218
|
+
catch { return { ...EMPTY_STATE }; }
|
|
219
|
+
}
|
|
220
|
+
export async function bumpScopeState(root, scope, patch) {
|
|
221
|
+
const next = { ...(await readScopeState(root, scope)), ...patch };
|
|
222
|
+
await writeAtomic(stateFile(root, scope), `${JSON.stringify(next, null, 2)}\n`);
|
|
223
|
+
return next;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── snapshots (.history/<scopeKey>/<yyyymmdd-hhmmss>-<source>[-NN]/) ──────────
|
|
227
|
+
const historyDir = (root, scope) => join(root, '.history', scopeKey(scope));
|
|
228
|
+
export async function listSnapshots(root, scope) {
|
|
229
|
+
const dir = historyDir(root, scope);
|
|
230
|
+
const ids = (await readdirMaybe(dir)).filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
231
|
+
const out = [];
|
|
232
|
+
for (const id of ids) {
|
|
233
|
+
const files = (await readdirMaybe(join(dir, id))).filter((e) => e.isFile() && e.name.endsWith('.md')).map((e) => e.name).sort();
|
|
234
|
+
out.push({ id, dir: join(dir, id), files });
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
/** A snapshot (and a restore) is complete over the files the scope SERVES, or it fails. A
|
|
239
|
+
* junk-named file (a hand-dropped `my notes.md`) is not one of them: it is skipped like
|
|
240
|
+
* every lister skips it, because failing here would make the whole scope unwritable — one
|
|
241
|
+
* such file would reject every agent's write to it, blaming a file the agent never wrote. */
|
|
242
|
+
const throwUnlessJunk = (p, err) => { if (err?.code !== 'ENAME') throw err; };
|
|
243
|
+
/** Copy the scope's current *.md files into a new snapshot dir; prune to `keep`. No-op when the scope has no files. */
|
|
244
|
+
export async function snapshotScope(root, scope, { source, now, keep = SNAPSHOT_KEEP } = {}) {
|
|
245
|
+
const entries = await listMemory(root, scope, { onError: throwUnlessJunk });
|
|
246
|
+
if (!entries.length) return null;
|
|
247
|
+
const base = `${stamp(now || new Date().toISOString())}-${sourceSlug(source)}`;
|
|
248
|
+
// Same-second, same-source snapshots get -02, -03, … AFTER the highest existing
|
|
249
|
+
// suffix; zero-padded so the ring's lexicographic sort stays chronological
|
|
250
|
+
// (an unpadded -10 would sort before -2 and be pruned as the "oldest").
|
|
251
|
+
const existing = (await listSnapshots(root, scope)).map((s) => s.id)
|
|
252
|
+
.filter((x) => x === base || (x.startsWith(`${base}-`) && /^\d+$/.test(x.slice(base.length + 1))));
|
|
253
|
+
const max = existing.reduce((m, x) => Math.max(m, x === base ? 1 : Number(x.slice(base.length + 1))), 0);
|
|
254
|
+
const id = max === 0 ? base : `${base}-${String(max + 1).padStart(2, '0')}`;
|
|
255
|
+
const dest = join(historyDir(root, scope), id);
|
|
256
|
+
await mkdir(dest, { recursive: true });
|
|
257
|
+
for (const e of entries) await cp(join(scopeDir(root, scope), `${e.name}.md`), join(dest, `${e.name}.md`));
|
|
258
|
+
const all = await listSnapshots(root, scope);
|
|
259
|
+
for (const s of all.slice(0, Math.max(0, all.length - keep))) await rm(s.dir, { recursive: true, force: true });
|
|
260
|
+
return id;
|
|
261
|
+
}
|
|
262
|
+
/** Snapshot first (undo of the undo), then make the scope's files exactly the snapshot's. */
|
|
263
|
+
export async function restoreSnapshot(root, scope, id, { source, now } = {}) {
|
|
264
|
+
if (!isValidSkillName(id)) throw new MemoryError('ENAME', `memory: invalid snapshot id ${JSON.stringify(id)}`);
|
|
265
|
+
const snap = (await listSnapshots(root, scope)).find((s) => s.id === id);
|
|
266
|
+
if (!snap) throw new MemoryError('ENOSCOPE', `memory: no snapshot ${id}`);
|
|
267
|
+
const dir = await writableScopeDir(root, scope); // BEFORE the pre-restore snapshot: never read or copy through a link
|
|
268
|
+
await snapshotScope(root, scope, { source, now });
|
|
269
|
+
for (const e of await listMemory(root, scope, { onError: throwUnlessJunk })) await rm(join(dir, `${e.name}.md`), { force: true });
|
|
270
|
+
for (const f of snap.files) await cp(join(snap.dir, f), join(dir, f));
|
|
271
|
+
await bumpScopeState(root, scope, { lastWriteAt: now || new Date().toISOString() });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── writing ──────────────────────────────────────────────────────────────────
|
|
275
|
+
/** Case-folded uniqueness: 'Testing' next to 'testing' is one file on macOS/Windows and two on
|
|
276
|
+
* Linux — refuse both ways. Returns how many valid-named `.md` files the dir holds (the class
|
|
277
|
+
* listMemory serves and syncBack counts), so writeMemory needs no second readdir for EFULL.
|
|
278
|
+
* The `isFile()` + `isValidMemoryName` filters align the twin check with listMemoryDir's served
|
|
279
|
+
* class: a DIRECTORY named `Testing.md`, or a junk-named `my Notes.md`, no longer collides and
|
|
280
|
+
* no longer counts toward the cap. */
|
|
281
|
+
async function assertNoCaseTwin(dir, name) {
|
|
282
|
+
const lower = name.toLowerCase();
|
|
283
|
+
let count = 0;
|
|
284
|
+
for (const e of await readdirMaybe(dir)) {
|
|
285
|
+
if (!e.isFile() || !e.name.endsWith('.md')) continue;
|
|
286
|
+
const stem = e.name.slice(0, -3);
|
|
287
|
+
if (!isValidMemoryName(stem)) continue;
|
|
288
|
+
count++;
|
|
289
|
+
if (stem !== name && stem.toLowerCase() === lower) throw new MemoryError('ECASE', `memory: "${name}" collides with existing "${stem}" (names differ only by case)`);
|
|
290
|
+
}
|
|
291
|
+
return count;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Write one memory file: validate, repair frontmatter (name forced, hook derived/clipped,
|
|
295
|
+
* source/updated stamped), enforce the HARD cap, snapshot the scope, write atomically,
|
|
296
|
+
* bump the counters. Rejects with a MemoryError BEFORE touching the disk.
|
|
297
|
+
* `snapshot`: `true` (snapshot this scope now), `false` (the caller took one) or an async
|
|
298
|
+
* function called at the snapshot point — syncBack passes a once-per-scope closure so a
|
|
299
|
+
* sync of N files takes ONE snapshot of the pre-sync scope, not N.
|
|
300
|
+
*/
|
|
301
|
+
export async function writeMemory(root, scope, name, text, { source, now, caps, onError, snapshot = true } = {}) {
|
|
302
|
+
assertName(name);
|
|
303
|
+
const dir = await writableScopeDir(root, scope);
|
|
304
|
+
const existing = await assertNoCaseTwin(dir, name);
|
|
305
|
+
const when = now || new Date().toISOString();
|
|
306
|
+
const repaired = repairMemoryFile(String(text ?? ''), { name, source: source || 'user', now: when, hookMaxChars: caps?.hookMaxChars });
|
|
307
|
+
const bytes = bytesOf(repaired.text);
|
|
308
|
+
const hard = caps?.hardBytesPerFile;
|
|
309
|
+
if (hard && bytes > hard) throw new MemoryError('ETOOBIG', `memory: "${name}" is ${bytes} bytes, over the ${hard}-byte cap`);
|
|
310
|
+
const target = join(dir, `${name}.md`);
|
|
311
|
+
const before = await readTextMaybe(target, onError);
|
|
312
|
+
// A NEW file past the per-scope cap is refused before the snapshot (spec §2 caps; P2 amendment
|
|
313
|
+
// B12). syncBack pre-checks the same count and its reason text is identical minus the prefix.
|
|
314
|
+
if (before === null && caps?.maxFilesPerScope && existing >= caps.maxFilesPerScope) {
|
|
315
|
+
throw new MemoryError('EFULL', `memory: scope is full (${caps.maxFilesPerScope} files)`);
|
|
316
|
+
}
|
|
317
|
+
if (typeof snapshot === 'function') await snapshot();
|
|
318
|
+
else if (snapshot !== false) await snapshotScope(root, scope, { source, now: when });
|
|
319
|
+
await writeAtomic(target, repaired.text);
|
|
320
|
+
const st = await readScopeState(root, scope);
|
|
321
|
+
await bumpScopeState(root, scope, { writesSinceDefrag: st.writesSinceDefrag + 1, lastWriteAt: when });
|
|
322
|
+
return { created: before === null, bytes, meta: repaired.meta, changed: before !== repaired.text };
|
|
323
|
+
}
|
|
324
|
+
export async function removeMemory(root, scope, name, { source, now, snapshot = true } = {}) {
|
|
325
|
+
assertName(name);
|
|
326
|
+
// Exact-cased existence: on macOS/Windows `readTextMaybe` would happily open testing.md through
|
|
327
|
+
// "Testing" and the rm below would delete a file the caller never named. readdir is the truth.
|
|
328
|
+
const names = (await readdirMaybe(scopeDir(root, scope))).filter((e) => e.isFile()).map((e) => e.name);
|
|
329
|
+
if (!names.includes(`${name}.md`)) return false;
|
|
330
|
+
const target = join(scopeDir(root, scope), `${name}.md`);
|
|
331
|
+
await writableScopeDir(root, scope); // a no-op remove above never created a dir; a real one is proven in-root
|
|
332
|
+
const when = now || new Date().toISOString();
|
|
333
|
+
if (typeof snapshot === 'function') await snapshot();
|
|
334
|
+
else if (snapshot !== false) await snapshotScope(root, scope, { source, now: when });
|
|
335
|
+
await rm(target, { force: true });
|
|
336
|
+
const st = await readScopeState(root, scope);
|
|
337
|
+
await bumpScopeState(root, scope, { writesSinceDefrag: st.writesSinceDefrag + 1, lastWriteAt: when });
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── the agent-facing pointer block (§4.2, native-rules revision) ─────
|
|
342
|
+
// The bodies reach the agent through Claude Code's own `.claude/rules` loader (the
|
|
343
|
+
// mount lives inside every spawn's cwd — memory-sync.mjs MEMORY_RULES_REL); this block
|
|
344
|
+
// only says WHERE memory lives and WHAT belongs there — the WRITE TRIGGER, the worth-a-file
|
|
345
|
+
// categories and the anti-list are what decide whether a run records anything at all, so they
|
|
346
|
+
// live in the intro rather than in each agent body. One `Label — /abs/dir:` line per
|
|
347
|
+
// mounted scope: claude-runner.mjs memoryDirsFromPrompt reads exactly those lines, so the
|
|
348
|
+
// intro must stay ONE line and nothing may follow the dir lines inside the block.
|
|
349
|
+
export const MEMORY_BLOCK_HEADING = '## Worca memory';
|
|
350
|
+
export const MEMORY_BLOCK_INTRO =
|
|
351
|
+
'Durable rules, preferences and traps kept across runs and chats. Claude Code loads them into your context ' +
|
|
352
|
+
'from the memory directories below (a file with `paths` loads when you read a matching file), so never search ' +
|
|
353
|
+
'for them (the built-in Explore and Plan sub-agents do not load them — read the files there if you are one). ' +
|
|
354
|
+
'WRITE TRIGGER: write a file there only when what you learned (a) cost you a cycle, or would have cost the next agent one, ' +
|
|
355
|
+
'or (b) contradicted what you assumed when you started — AND will still be true next month. Worth a file: ' +
|
|
356
|
+
'a trap (behaves differently from how it reads); a verification recipe (the exact command / env var / ' +
|
|
357
|
+
'fixture-regeneration step that proves work here is correct); an invariant or contract the code depends ' +
|
|
358
|
+
'on but never states; a settled user decision with its reason, including approaches ruled out and why; a ' +
|
|
359
|
+
'defect class a review had to flag that will recur here. Never: run summaries or progress notes, restatements ' +
|
|
360
|
+
'of code or structure you can read, one-off task facts, anything already in CLAUDE.md / ' +
|
|
361
|
+
'CONTRIBUTING / README, machine paths or secrets, unverified guesses. Budget: at most 1–2 files per run; ' +
|
|
362
|
+
'prefer EDITING an existing file over adding one; give `paths` whenever the rule is file-specific; keep a file ' +
|
|
363
|
+
'under ~8 KB. One topic per file (`<topic>.md`); ' +
|
|
364
|
+
'keep the frontmatter: `name` (the filename stem), `description` (one line: when it is worth reading), ' +
|
|
365
|
+
'optional `paths` (comma-separated globs); worca stamps `source` and `updated`. To remove a file, empty it.';
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* @param {Array<{label:string, dir:string}>} sections one per mounted scope, in mount order
|
|
369
|
+
* @returns {string} the block with one trailing newline; byte-stable for identical input
|
|
370
|
+
*/
|
|
371
|
+
export function renderMemoryBlock(sections) {
|
|
372
|
+
const lines = [MEMORY_BLOCK_HEADING, MEMORY_BLOCK_INTRO];
|
|
373
|
+
for (const s of sections || []) lines.push(`${flattenLine(s.label)} — ${s.dir}:`);
|
|
374
|
+
return `${lines.join('\n')}\n`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── health (§8) ──────────────────────────────────────────────────────────────
|
|
378
|
+
export const MEMORY_LEVELS = Object.freeze(['fresh', 'ok', 'due', 'overdue']);
|
|
379
|
+
const DEFAULT_DEFRAG = Object.freeze({ writes: 10, files: 30, bytesPct: 60, alwaysOnBytes: 16384 });
|
|
380
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
381
|
+
const names = (list) => `${list.slice(0, 3).map((e) => `${e.name}.md`).join(', ')}${list.length > 3 ? ', …' : ''}`;
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Pure. `entries` are listMemory rows, `state` the scope's .state counters, `caps` memoryCaps()
|
|
385
|
+
* (a caps object without `defrag` falls back to the defaults 10 / 30 / 60 % / 16 384).
|
|
386
|
+
* fresh = no files. overdue = writes at 2× the threshold, any file over the hard cap, or the
|
|
387
|
+
* always-on bytes at 2× their threshold. due = writes ≥ threshold, files ≥ threshold, bytes ≥
|
|
388
|
+
* bytesPct % of maxFilesPerScope × soft cap, any oversized (soft) or fence-less file, or the
|
|
389
|
+
* always-on bytes ≥ `defrag.alwaysOnBytes`.
|
|
390
|
+
* Native rules: every file WITHOUT `paths` is loaded into every agent's context at launch —
|
|
391
|
+
* `alwaysOnBytes` is that cost (the figure the old 4 KB index cap used to bound). A path-scoped
|
|
392
|
+
* file costs nothing until a matching file is read, so it is excluded from it.
|
|
393
|
+
*/
|
|
394
|
+
export function memoryHealth(entries, state, caps) {
|
|
395
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
396
|
+
const st = { ...EMPTY_STATE, ...(state && typeof state === 'object' ? state : {}) };
|
|
397
|
+
const T = { ...DEFAULT_DEFRAG, ...(caps?.defrag && typeof caps.defrag === 'object' ? caps.defrag : {}) };
|
|
398
|
+
const soft = caps?.softBytesPerFile ?? 8192;
|
|
399
|
+
const hard = caps?.hardBytesPerFile ?? 32768;
|
|
400
|
+
const maxFiles = caps?.maxFilesPerScope ?? 50;
|
|
401
|
+
const size = (e) => Number(e.bytes) || 0;
|
|
402
|
+
const files = list.length;
|
|
403
|
+
const bytes = list.reduce((n, e) => n + size(e), 0);
|
|
404
|
+
const oversized = list.filter((e) => size(e) > soft);
|
|
405
|
+
const overHard = list.filter((e) => size(e) > hard);
|
|
406
|
+
const invalid = list.filter((e) => e.hasFrontmatter === false);
|
|
407
|
+
const alwaysOn = list.filter((e) => !(Array.isArray(e.paths) && e.paths.length));
|
|
408
|
+
const alwaysOnBytes = alwaysOn.reduce((n, e) => n + size(e), 0);
|
|
409
|
+
const budget = maxFiles * soft;
|
|
410
|
+
// Spec §8's threshold is `bytes ≥ bytesPct % of budget`: compare integers, never a rounded
|
|
411
|
+
// percentage (Math.round would turn 59.5 % into a 60 % "due"). `pct` is for the message only.
|
|
412
|
+
const overBudget = budget > 0 && bytes * 100 >= T.bytesPct * budget;
|
|
413
|
+
const pct = budget > 0 ? Math.floor((bytes * 100) / budget) : 0;
|
|
414
|
+
const writes = Number(st.writesSinceDefrag) || 0;
|
|
415
|
+
const reasons = [];
|
|
416
|
+
if (files > 0) {
|
|
417
|
+
if (writes >= T.writes) reasons.push(`${plural(writes, 'memory write')} since the last defragment (due at ${T.writes})`);
|
|
418
|
+
if (files >= T.files) reasons.push(`${plural(files, 'file')} in this scope (due at ${T.files})`);
|
|
419
|
+
if (overBudget) reasons.push(`${pct}% of the scope's byte budget in use (due at ${T.bytesPct}%)`);
|
|
420
|
+
if (oversized.length) reasons.push(`${plural(oversized.length, 'file')} over the ${soft}-byte soft cap: ${names(oversized)}`);
|
|
421
|
+
if (overHard.length) reasons.push(`${plural(overHard.length, 'file')} over the ${hard}-byte hard cap — runs cannot update them: ${names(overHard)}`);
|
|
422
|
+
if (invalid.length) reasons.push(`${plural(invalid.length, 'file')} without frontmatter — added by hand? worca still serves them; a defragment rewrites them: ${names(invalid)}`);
|
|
423
|
+
if (alwaysOnBytes >= T.alwaysOnBytes) reasons.push(`${alwaysOnBytes} bytes of memory load into the context of every agent that mounts this scope (${plural(alwaysOn.length, 'file')} without paths; due at ${T.alwaysOnBytes})`);
|
|
424
|
+
}
|
|
425
|
+
const level = files === 0 ? 'fresh'
|
|
426
|
+
: (writes >= 2 * T.writes || overHard.length || alwaysOnBytes >= 2 * T.alwaysOnBytes) ? 'overdue'
|
|
427
|
+
: reasons.length ? 'due' : 'ok';
|
|
428
|
+
return {
|
|
429
|
+
files, bytes, oversized: oversized.length, overHard: overHard.length, invalidFrontmatter: invalid.length,
|
|
430
|
+
alwaysOnBytes, alwaysOnFiles: alwaysOn.length,
|
|
431
|
+
writesSinceDefrag: writes, lastWriteAt: st.lastWriteAt, lastDefragAt: st.lastDefragAt, lastDefragRunId: st.lastDefragRunId,
|
|
432
|
+
level, reasons,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Everything a scope view or route needs in one read: the listing, the counters and the health. */
|
|
437
|
+
export async function memoryScopeReport(root, scope, caps, { onError } = {}) {
|
|
438
|
+
const entries = await listMemory(root, scope, { onError });
|
|
439
|
+
const state = await readScopeState(root, scope);
|
|
440
|
+
return { scope: scopeKey(scope), entries, state, health: memoryHealth(entries, state, caps) };
|
|
441
|
+
}
|