@ericrisco/rsc 1.1.6 → 1.2.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 +62 -4
- package/manifest.json +834 -2
- package/package.json +3 -2
- package/schema/ecc-originality.json +16 -0
- package/schema/ecc-originality.u64 +0 -0
- package/scripts/build-manifest.js +43 -3
- package/scripts/doctor.js +47 -1
- package/scripts/install-apply.js +148 -31
- package/scripts/lib/capabilities.js +21 -0
- package/scripts/lib/manifest-file.js +4 -1
- package/scripts/originality-check.js +137 -0
- package/scripts/rsc.js +122 -14
- package/targets/agent-catalog.js +220 -0
- package/targets/agent-sources.json +48 -0
- package/targets/agents.js +61 -13
- package/targets/commands.js +212 -0
- package/targets/memory.js +276 -0
- package/targets/opencode-memory-plugin.js +65 -0
- package/targets/session-memory-adapter.mjs +117 -0
- package/targets/session-memory-core.mjs +477 -0
- package/targets/session-memory.mjs +27 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// rsc-memory:managed
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { capture, resume } from '../../.rsc/session-memory-core.mjs';
|
|
5
|
+
|
|
6
|
+
function memorySettings(cwd) {
|
|
7
|
+
try {
|
|
8
|
+
const value = JSON.parse(readFileSync(join(cwd, '.rsc.json'), 'utf8')).memory;
|
|
9
|
+
if (value === false) return { enabled: false };
|
|
10
|
+
return value && typeof value === 'object' ? value : {};
|
|
11
|
+
} catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function sessionId(input) {
|
|
17
|
+
return input?.sessionID || input?.session_id || input?.event?.properties?.info?.id
|
|
18
|
+
|| input?.event?.properties?.sessionID || null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const RscMemoryPlugin = async ({ directory, worktree }) => {
|
|
22
|
+
const cwd = worktree || directory;
|
|
23
|
+
const injected = new Set();
|
|
24
|
+
const local = () => process.env.RSC_REMOTE_AGENT !== '1' && process.env.OPENCODE_REMOTE !== '1';
|
|
25
|
+
const save = (id, event, editDelta = 0) => {
|
|
26
|
+
if (!id || !local()) return;
|
|
27
|
+
try { capture({ cwd, sessionId: id, target: 'opencode', event, editDelta, settings: memorySettings(cwd) }); } catch { /* fail open */ }
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
32
|
+
const id = sessionId(input);
|
|
33
|
+
if (!id || injected.has(id) || !local()) return;
|
|
34
|
+
injected.add(id);
|
|
35
|
+
try {
|
|
36
|
+
const config = memorySettings(cwd);
|
|
37
|
+
save(id, 'start');
|
|
38
|
+
const result = resume({ cwd, target: 'opencode', settings: config });
|
|
39
|
+
if (!result.context || !Array.isArray(output?.system)) return;
|
|
40
|
+
if (output.system.length === 0) output.system.push(result.context);
|
|
41
|
+
else output.system[0] = `${output.system[0]}\n\n${result.context}`;
|
|
42
|
+
} catch { /* fail open */ }
|
|
43
|
+
},
|
|
44
|
+
'tool.execute.after': async (input) => {
|
|
45
|
+
const tool = String(input?.tool || '').toLowerCase();
|
|
46
|
+
save(sessionId(input), 'boundary', /write|edit|patch/u.test(tool) ? 1 : 0);
|
|
47
|
+
},
|
|
48
|
+
'experimental.session.compacting': async (input, output) => {
|
|
49
|
+
const id = sessionId(input);
|
|
50
|
+
save(id, 'compact');
|
|
51
|
+
try {
|
|
52
|
+
const result = resume({ cwd, target: 'opencode', settings: memorySettings(cwd) });
|
|
53
|
+
if (result.context && Array.isArray(output?.context)) output.context.push(result.context);
|
|
54
|
+
} catch { /* fail open */ }
|
|
55
|
+
},
|
|
56
|
+
event: async ({ event }) => {
|
|
57
|
+
const id = sessionId({ event });
|
|
58
|
+
if (event?.type === 'session.created') save(id, 'start');
|
|
59
|
+
else if (event?.type === 'file.edited') save(id, 'edit', 1);
|
|
60
|
+
else if (event?.type === 'session.compacted') save(id, 'compact');
|
|
61
|
+
else if (event?.type === 'session.idle') save(id, 'turn');
|
|
62
|
+
else if (event?.type === 'session.deleted') save(id, 'sessionEnd');
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
import { capture, resume } from './session-memory-core.mjs';
|
|
5
|
+
|
|
6
|
+
const LOCAL_TARGETS = new Set(['claude', 'codex', 'cursor', 'gemini', 'opencode']);
|
|
7
|
+
|
|
8
|
+
function projectSettings(cwd) {
|
|
9
|
+
try {
|
|
10
|
+
const manifest = JSON.parse(readFileSync(join(cwd, '.rsc.json'), 'utf8'));
|
|
11
|
+
if (manifest.memory === false) return { enabled: false };
|
|
12
|
+
return manifest.memory && typeof manifest.memory === 'object' ? manifest.memory : {};
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sessionId(native, target) {
|
|
19
|
+
return native?.session_id || native?.sessionId || native?.sessionID || native?.conversation_id
|
|
20
|
+
|| native?.thread_id || `${target}-${process.ppid}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isRemote(target, native) {
|
|
24
|
+
if (target === 'cursor' && (native?.is_background_agent === true || native?.isBackgroundAgent === true)) return true;
|
|
25
|
+
return process.env.RSC_REMOTE_AGENT === '1'
|
|
26
|
+
|| process.env.CURSOR_CLOUD_AGENT === '1'
|
|
27
|
+
|| process.env.CODEX_CLOUD_AGENT === '1';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hookEventName(target, event, native) {
|
|
31
|
+
if (native?.hook_event_name) return native.hook_event_name;
|
|
32
|
+
const names = {
|
|
33
|
+
start: target === 'cursor' ? 'sessionStart' : 'SessionStart',
|
|
34
|
+
request: target === 'gemini' ? 'BeforeAgent' : 'UserPromptSubmit',
|
|
35
|
+
edit: target === 'cursor' ? 'afterFileEdit' : target === 'gemini' ? 'AfterTool' : 'PostToolUse',
|
|
36
|
+
boundary: target === 'cursor' ? 'afterShellExecution' : target === 'gemini' ? 'AfterTool' : 'PostToolUse',
|
|
37
|
+
turn: target === 'cursor' ? 'afterAgentResponse' : target === 'gemini' ? 'AfterAgent' : 'Stop',
|
|
38
|
+
compact: target === 'cursor' ? 'preCompact' : target === 'gemini' ? 'PreCompress' : 'PreCompact',
|
|
39
|
+
end: target === 'cursor' ? 'sessionEnd' : 'SessionEnd',
|
|
40
|
+
};
|
|
41
|
+
return names[event] || event;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function nativeOutput(target, eventName, context = '', notice = null, compactionHint = false) {
|
|
45
|
+
const hint = compactionHint ? 'rsc memory: consider compacting at the next phase boundary; nothing was compacted automatically.' : '';
|
|
46
|
+
const extra = [context, hint].filter(Boolean).join('\n');
|
|
47
|
+
if (!extra && !notice) return {};
|
|
48
|
+
if (target === 'cursor') return { ...(extra ? { additional_context: extra } : {}), ...(notice ? { user_message: notice } : {}) };
|
|
49
|
+
if (target === 'opencode') return { context: extra, notice };
|
|
50
|
+
return {
|
|
51
|
+
...(notice ? { systemMessage: notice } : {}),
|
|
52
|
+
...(extra ? { hookSpecificOutput: { hookEventName: eventName, additionalContext: extra } } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function contextFromNativeOutput(target, output = {}) {
|
|
57
|
+
if (target === 'cursor') return output.additional_context || '';
|
|
58
|
+
if (target === 'opencode') return output.context || '';
|
|
59
|
+
return output.hookSpecificOutput?.additionalContext || '';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function handleLifecycle({ target, event, native = {}, cwd, settings } = {}) {
|
|
63
|
+
try {
|
|
64
|
+
if (!LOCAL_TARGETS.has(target)) throw new Error(`unsupported memory target: ${target}`);
|
|
65
|
+
if (isRemote(target, native)) return { output: {}, capture: null, remote: true, degraded: false };
|
|
66
|
+
const project = resolve(cwd || native.cwd || process.env.RSC_PROJECT_CWD || process.cwd());
|
|
67
|
+
if (!existsSync(project)) throw new Error('project directory unavailable');
|
|
68
|
+
const config = settings || projectSettings(project);
|
|
69
|
+
if (config.enabled === false) return { output: {}, capture: null, remote: false, degraded: false };
|
|
70
|
+
const id = sessionId(native, target);
|
|
71
|
+
const eventName = hookEventName(target, event, native);
|
|
72
|
+
if (event === 'start') {
|
|
73
|
+
const started = capture({ cwd: project, sessionId: id, target, event: 'start', settings: config });
|
|
74
|
+
const resumed = resume({ cwd: project, target, settings: config });
|
|
75
|
+
return {
|
|
76
|
+
output: nativeOutput(target, eventName, resumed.context),
|
|
77
|
+
capture: started,
|
|
78
|
+
resume: resumed,
|
|
79
|
+
remote: false,
|
|
80
|
+
degraded: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const captureInput = {
|
|
84
|
+
cwd: project,
|
|
85
|
+
sessionId: id,
|
|
86
|
+
target,
|
|
87
|
+
event: event === 'end' ? 'sessionEnd' : event,
|
|
88
|
+
editDelta: event === 'edit' ? 1 : 0,
|
|
89
|
+
settings: config,
|
|
90
|
+
};
|
|
91
|
+
if (typeof native.cost === 'number') captureInput.cost = native.cost;
|
|
92
|
+
if (Number.isInteger(native.tool_calls)) captureInput.toolCalls = native.tool_calls;
|
|
93
|
+
const captured = capture(captureInput);
|
|
94
|
+
return {
|
|
95
|
+
output: nativeOutput(target, eventName, '', captured.notice, captured.compactionHint),
|
|
96
|
+
capture: captured,
|
|
97
|
+
remote: false,
|
|
98
|
+
degraded: false,
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return { output: {}, capture: null, remote: false, degraded: true, error: error instanceof Error ? error.message : String(error) };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function stdinJson() {
|
|
106
|
+
try {
|
|
107
|
+
const raw = readFileSync(0, 'utf8');
|
|
108
|
+
return raw.trim() ? JSON.parse(raw) : {};
|
|
109
|
+
} catch {
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
115
|
+
const result = handleLifecycle({ target: process.argv[2], event: process.argv[3], native: stdinJson() });
|
|
116
|
+
process.stdout.write(`${JSON.stringify(result.output)}\n`);
|
|
117
|
+
}
|
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync,
|
|
3
|
+
} from 'node:fs';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
|
|
8
|
+
export const SESSION_RECORD_FIELDS = Object.freeze([
|
|
9
|
+
'schemaVersion', 'sessionId', 'target', 'event', 'branch', 'worktree', 'baselineHead', 'head',
|
|
10
|
+
'files', 'commits', 'ledger', 'timestamps', 'toolCalls', 'cost', 'editCount', 'concurrent',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_MEMORY_SETTINGS = Object.freeze({
|
|
14
|
+
enabled: true,
|
|
15
|
+
metrics: true,
|
|
16
|
+
compactionHint: true,
|
|
17
|
+
editThreshold: 20,
|
|
18
|
+
retentionDays: 30,
|
|
19
|
+
contextBytes: 4096,
|
|
20
|
+
lessonThreshold: 0.7,
|
|
21
|
+
lessonLimit: 5,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const SECRET_SHAPES = [
|
|
25
|
+
/\bsk-[A-Za-z0-9_-]{12,}\b/u,
|
|
26
|
+
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/u,
|
|
27
|
+
/\bgh[opusr]_[A-Za-z0-9]{20,}\b/u,
|
|
28
|
+
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/u,
|
|
29
|
+
/\b(?:password|passwd|api[_-]?key|access[_-]?token)\s*[:=]\s*\S+/iu,
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
function secretShaped(value) {
|
|
33
|
+
return typeof value === 'string' && SECRET_SHAPES.some((pattern) => pattern.test(value));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cleanString(value, max = 1024) {
|
|
37
|
+
if (typeof value !== 'string' || secretShaped(value)) return null;
|
|
38
|
+
const clean = value.replace(/[\u0000-\u001f\u007f]/gu, '').trim();
|
|
39
|
+
return clean ? clean.slice(0, max) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cleanId(value, prefix = 'session') {
|
|
43
|
+
const clean = cleanString(value, 120);
|
|
44
|
+
if (clean && /^[A-Za-z0-9._-]+$/u.test(clean)) return clean;
|
|
45
|
+
const hash = createHash('sha256').update(String(value ?? randomUUID())).digest('hex').slice(0, 20);
|
|
46
|
+
return `${prefix}-${hash}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function git(cwd, args) {
|
|
50
|
+
try {
|
|
51
|
+
return { ok: true, out: execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 2 * 1024 * 1024 }).trim() };
|
|
52
|
+
} catch {
|
|
53
|
+
return { ok: false, out: '' };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function relativeSlash(cwd, path) {
|
|
58
|
+
return relative(cwd, path).split(sep).join('/');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ensureExcluded(cwd, root) {
|
|
62
|
+
const rel = relativeSlash(cwd, root);
|
|
63
|
+
if (!rel || rel.startsWith('../')) return false;
|
|
64
|
+
const tracked = git(cwd, ['ls-files', '--', rel]).out;
|
|
65
|
+
if (tracked) return false;
|
|
66
|
+
const gitPath = git(cwd, ['rev-parse', '--git-path', 'info/exclude']);
|
|
67
|
+
if (!gitPath.ok || !gitPath.out) return false;
|
|
68
|
+
const exclude = isAbsolute(gitPath.out) ? gitPath.out : resolve(cwd, gitPath.out);
|
|
69
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
70
|
+
const pattern = `/${rel.replace(/\/$/u, '')}/`;
|
|
71
|
+
const current = existsSync(exclude) ? readFileSync(exclude, 'utf8') : '';
|
|
72
|
+
if (!current.split('\n').includes(pattern)) {
|
|
73
|
+
const prefix = current && !current.endsWith('\n') ? '\n' : '';
|
|
74
|
+
appendFileSync(exclude, `${prefix}${pattern}\n`);
|
|
75
|
+
}
|
|
76
|
+
return git(cwd, ['check-ignore', '-q', '--no-index', `${rel}/.rsc-probe`]).ok;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function chooseMemoryRoot(cwd = process.cwd()) {
|
|
80
|
+
const project = resolve(cwd);
|
|
81
|
+
const inGit = git(project, ['rev-parse', '--is-inside-work-tree']).out === 'true';
|
|
82
|
+
if (!inGit) {
|
|
83
|
+
return { root: join(project, '.rsc', 'memory'), kind: 'local-state', reason: 'without-git', git: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const worklog = join(project, '02-DOCS', 'raw', 'worklog');
|
|
87
|
+
if (existsSync(worklog)) {
|
|
88
|
+
const tracked = git(project, ['ls-files', '--', '02-DOCS/raw/worklog']).out;
|
|
89
|
+
if (!tracked) {
|
|
90
|
+
const candidate = join(worklog, '.rsc-memory');
|
|
91
|
+
if (ensureExcluded(project, candidate)) {
|
|
92
|
+
return { root: candidate, kind: 'wiki-worklog', reason: 'untracked-worklog', git: true };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const local = join(project, '.rsc', 'memory');
|
|
98
|
+
if (ensureExcluded(project, local)) {
|
|
99
|
+
const trackedWorklog = existsSync(worklog) && Boolean(git(project, ['ls-files', '--', '02-DOCS/raw/worklog']).out);
|
|
100
|
+
return { root: local, kind: 'local-state', reason: trackedWorklog ? 'tracked-worklog' : 'no-wiki', git: true };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const gitRoot = git(project, ['rev-parse', '--git-path', 'rsc-memory']).out;
|
|
104
|
+
return {
|
|
105
|
+
root: isAbsolute(gitRoot) ? gitRoot : resolve(project, gitRoot || '.git/rsc-memory'),
|
|
106
|
+
kind: 'git-private-state', reason: 'local-state-tracked', git: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function ensureStore(root) {
|
|
111
|
+
for (const dir of ['sessions', 'anchors', 'lessons']) mkdirSync(join(root, dir), { recursive: true });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function atomicJson(path, value) {
|
|
115
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
116
|
+
const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
117
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
118
|
+
renameSync(temporary, path);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readJson(path) {
|
|
122
|
+
try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function iso(value) {
|
|
126
|
+
const parsed = new Date(value ?? Date.now());
|
|
127
|
+
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function settings(input = {}) {
|
|
131
|
+
const merged = { ...DEFAULT_MEMORY_SETTINGS };
|
|
132
|
+
for (const key of Object.keys(DEFAULT_MEMORY_SETTINGS)) {
|
|
133
|
+
if (Object.hasOwn(input, key)) merged[key] = input[key];
|
|
134
|
+
}
|
|
135
|
+
merged.retentionDays = Math.max(1, Number(merged.retentionDays) || DEFAULT_MEMORY_SETTINGS.retentionDays);
|
|
136
|
+
merged.contextBytes = Math.max(64, Number(merged.contextBytes) || DEFAULT_MEMORY_SETTINGS.contextBytes);
|
|
137
|
+
merged.lessonLimit = Math.max(0, Math.floor(Number(merged.lessonLimit) || 0));
|
|
138
|
+
merged.lessonThreshold = Math.min(1, Math.max(0, Number(merged.lessonThreshold) || 0));
|
|
139
|
+
merged.editThreshold = Math.max(1, Math.floor(Number(merged.editThreshold) || DEFAULT_MEMORY_SETTINGS.editThreshold));
|
|
140
|
+
return merged;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function parseStatus(output) {
|
|
144
|
+
const entries = output.split('\0').filter(Boolean);
|
|
145
|
+
const files = [];
|
|
146
|
+
for (let index = 0; index < entries.length; index += 1) {
|
|
147
|
+
const entry = entries[index];
|
|
148
|
+
// `git()` trims command output, so a leading space in porcelain's XY column may be
|
|
149
|
+
// absent only for the first entry. Locate the separator instead of dropping a fixed
|
|
150
|
+
// three bytes; filenames must remain byte-for-byte identifiers, never content.
|
|
151
|
+
const separator = entry.indexOf(' ');
|
|
152
|
+
const status = separator === 1 ? entry.slice(0, 1) : entry.slice(0, 2);
|
|
153
|
+
const paths = [entry.slice(separator + 1).replace(/^ +/u, '')];
|
|
154
|
+
// In porcelain -z, rename/copy records carry the destination in this entry
|
|
155
|
+
// and the source in the next NUL-delimited entry. Both are touched paths.
|
|
156
|
+
if (/[RC]/u.test(status) && entries[index + 1]) paths.push(entries[++index]);
|
|
157
|
+
for (const path of paths) {
|
|
158
|
+
const clean = cleanString(path, 1000);
|
|
159
|
+
if (clean) files.push(clean);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return files;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function fingerprintFiles(worktree, files) {
|
|
166
|
+
return Object.fromEntries(files.map((path) => {
|
|
167
|
+
const result = git(worktree, ['hash-object', '--no-filters', '--', path]);
|
|
168
|
+
return [path, result.ok ? result.out : null];
|
|
169
|
+
}));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function snapshot(cwd, baselineHead = null) {
|
|
173
|
+
const top = git(cwd, ['rev-parse', '--show-toplevel']);
|
|
174
|
+
if (!top.ok) return { git: false, branch: null, worktree: resolve(cwd), head: null, files: [], fingerprints: {}, commits: [] };
|
|
175
|
+
const worktree = resolve(top.out);
|
|
176
|
+
const branch = cleanString(git(cwd, ['symbolic-ref', '--quiet', '--short', 'HEAD']).out, 255);
|
|
177
|
+
const head = cleanString(git(cwd, ['rev-parse', '--verify', 'HEAD']).out, 64);
|
|
178
|
+
const dirty = parseStatus(git(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all']).out);
|
|
179
|
+
let committed = [];
|
|
180
|
+
let commits = [];
|
|
181
|
+
if (baselineHead && head && git(cwd, ['cat-file', '-e', `${baselineHead}^{commit}`]).ok) {
|
|
182
|
+
committed = git(cwd, ['diff', '--name-only', '-z', `${baselineHead}..${head}`]).out.split('\0').map((item) => cleanString(item, 1000)).filter(Boolean);
|
|
183
|
+
commits = git(cwd, ['log', '--format=%H', `${baselineHead}..${head}`]).out.split('\n').map((item) => cleanString(item, 64)).filter(Boolean);
|
|
184
|
+
}
|
|
185
|
+
const files = [...new Set([...dirty, ...committed])].sort();
|
|
186
|
+
return { git: true, branch, worktree, head, files, fingerprints: fingerprintFiles(worktree, dirty), commits };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function ledgerSnapshot(cwd) {
|
|
190
|
+
const roots = [
|
|
191
|
+
['spec', join(cwd, '02-DOCS', 'wiki', 'sdd', 'specs')],
|
|
192
|
+
['plan', join(cwd, '02-DOCS', 'wiki', 'sdd', 'plans')],
|
|
193
|
+
['progress', join(cwd, '02-DOCS', 'wiki', 'sdd', 'progress')],
|
|
194
|
+
];
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const [type, root] of roots) {
|
|
197
|
+
if (!existsSync(root)) continue;
|
|
198
|
+
for (const name of readdirSync(root).filter((item) => item.endsWith('.md')).sort()) {
|
|
199
|
+
const path = join(root, name);
|
|
200
|
+
let body = '';
|
|
201
|
+
try { body = readFileSync(path, 'utf8'); } catch { continue; }
|
|
202
|
+
const rel = cleanString(relativeSlash(cwd, path), 1000);
|
|
203
|
+
if (!rel) continue;
|
|
204
|
+
const status = cleanString(body.match(/^status:\s*([a-z-]+)/imu)?.[1]?.toLowerCase(), 40) || 'unknown';
|
|
205
|
+
const openItems = (body.match(/^- \[ \]/gmu) || []).length;
|
|
206
|
+
out.push({ path: rel, type, status, openItems });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return out;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sessionFiles(root) {
|
|
213
|
+
const dir = join(root, 'sessions');
|
|
214
|
+
if (!existsSync(dir)) return [];
|
|
215
|
+
return readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => join(dir, name));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function lessonFiles(root) {
|
|
219
|
+
const dir = join(root, 'lessons');
|
|
220
|
+
if (!existsSync(dir)) return [];
|
|
221
|
+
return readdirSync(dir).filter((name) => name.endsWith('.json')).map((name) => join(dir, name));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function prune(root, now, config) {
|
|
225
|
+
const cutoff = new Date(now).getTime() - config.retentionDays * 86400000;
|
|
226
|
+
for (const path of [...sessionFiles(root), ...readdirSync(join(root, 'anchors')).filter((name) => name.endsWith('.json')).map((name) => join(root, 'anchors', name))]) {
|
|
227
|
+
const value = readJson(path);
|
|
228
|
+
const updated = value?.timestamps?.updatedAt || value?.startedAt;
|
|
229
|
+
if (!updated || new Date(updated).getTime() < cutoff) rmSync(path, { force: true });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function noticeText(info) {
|
|
234
|
+
if (info.reason === 'tracked-worklog') return 'rsc memory: tracked worklog detected; using ignored local state instead.';
|
|
235
|
+
if (info.reason === 'untracked-worklog') return 'rsc memory: using the ignored wiki worklog store.';
|
|
236
|
+
if (info.reason === 'without-git') return 'rsc memory: running without git; branch and commit metadata are unavailable.';
|
|
237
|
+
if (info.reason === 'local-state-tracked') return 'rsc memory: .rsc memory was tracked; using git-private local state.';
|
|
238
|
+
return 'rsc memory: no wiki worklog found; using ignored local state.';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function consumeNotice(info) {
|
|
242
|
+
const marker = join(info.root, `.notice-${info.reason}`);
|
|
243
|
+
if (existsSync(marker)) return null;
|
|
244
|
+
writeFileSync(marker, `${iso()}\n`, { mode: 0o600 });
|
|
245
|
+
return noticeText(info);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function finiteOrNull(value, integer = false) {
|
|
249
|
+
if (value === null || value === undefined || value === '') return null;
|
|
250
|
+
const number = Number(value);
|
|
251
|
+
if (!Number.isFinite(number) || number < 0 || (integer && !Number.isInteger(number))) return null;
|
|
252
|
+
return number;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function activeConcurrent(records, sessionId, target, worktree, now) {
|
|
256
|
+
const recent = new Date(now).getTime() - 2 * 60 * 60 * 1000;
|
|
257
|
+
return records.some((record) => (record?.sessionId !== sessionId || record?.target !== target) && record?.worktree === worktree
|
|
258
|
+
&& !record?.timestamps?.completedAt && new Date(record?.timestamps?.updatedAt || 0).getTime() >= recent);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function validateSessionRecord(record) {
|
|
262
|
+
const errors = [];
|
|
263
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) return ['record: object required'];
|
|
264
|
+
for (const field of Object.keys(record)) if (!SESSION_RECORD_FIELDS.includes(field)) errors.push(`${field}: unknown field`);
|
|
265
|
+
for (const field of SESSION_RECORD_FIELDS) if (!Object.hasOwn(record, field)) errors.push(`${field}: required`);
|
|
266
|
+
if (record.schemaVersion !== 1) errors.push('schemaVersion: must be 1');
|
|
267
|
+
if (!Array.isArray(record.files) || !Array.isArray(record.commits) || !Array.isArray(record.ledger)) errors.push('files/commits/ledger: arrays required');
|
|
268
|
+
if (!record.timestamps || Object.keys(record.timestamps).some((key) => !['startedAt', 'updatedAt', 'completedAt'].includes(key))) errors.push('timestamps: closed schema required');
|
|
269
|
+
for (const item of record.ledger || []) {
|
|
270
|
+
if (!item || Object.keys(item).some((key) => !['path', 'type', 'status', 'openItems'].includes(key))) errors.push('ledger: closed schema required');
|
|
271
|
+
}
|
|
272
|
+
const walk = (value) => {
|
|
273
|
+
if (secretShaped(value)) errors.push('secret-shaped string rejected');
|
|
274
|
+
else if (Array.isArray(value)) value.forEach(walk);
|
|
275
|
+
else if (value && typeof value === 'object') Object.values(value).forEach(walk);
|
|
276
|
+
};
|
|
277
|
+
walk(record);
|
|
278
|
+
return errors;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readRecords(root) {
|
|
282
|
+
return sessionFiles(root).map(readJson).filter((record) => record && validateSessionRecord(record).length === 0);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function capture(input = {}) {
|
|
286
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
287
|
+
const config = settings(input.settings);
|
|
288
|
+
if (!config.enabled) return { record: null, path: null, notice: null, compactionHint: false };
|
|
289
|
+
const store = chooseMemoryRoot(cwd);
|
|
290
|
+
ensureStore(store.root);
|
|
291
|
+
const now = iso(input.now);
|
|
292
|
+
prune(store.root, now, config);
|
|
293
|
+
const sessionId = cleanId(input.sessionId, 'session');
|
|
294
|
+
const target = cleanId(input.target || 'unknown', 'target');
|
|
295
|
+
// Providers own their session-id namespaces. Prefixing the storage key prevents
|
|
296
|
+
// an identical id from two local assistants overwriting the other journal.
|
|
297
|
+
const storageId = `${target}--${sessionId}`;
|
|
298
|
+
const anchorPath = join(store.root, 'anchors', `${storageId}.json`);
|
|
299
|
+
const recordPath = join(store.root, 'sessions', `${storageId}.json`);
|
|
300
|
+
let anchor = readJson(anchorPath);
|
|
301
|
+
let existing = readJson(recordPath);
|
|
302
|
+
const firstSnapshot = snapshot(cwd, null);
|
|
303
|
+
if (!anchor) {
|
|
304
|
+
anchor = {
|
|
305
|
+
sessionId,
|
|
306
|
+
startedAt: now,
|
|
307
|
+
baselineHead: cleanString(input.baselineHead, 64) || firstSnapshot.head,
|
|
308
|
+
baselineFiles: firstSnapshot.files,
|
|
309
|
+
baselineFingerprints: firstSnapshot.fingerprints,
|
|
310
|
+
};
|
|
311
|
+
atomicJson(anchorPath, anchor);
|
|
312
|
+
}
|
|
313
|
+
if (input.event === 'start' && !existing) return { record: null, path: null, notice: null, compactionHint: false };
|
|
314
|
+
|
|
315
|
+
const repo = snapshot(cwd, anchor.baselineHead);
|
|
316
|
+
const editCount = Math.max(0, (existing?.editCount || 0) + (finiteOrNull(input.editDelta, true) || 0));
|
|
317
|
+
const baselineFiles = new Set(anchor.baselineFiles || []);
|
|
318
|
+
const newDirtyPath = repo.files.some((path) => !baselineFiles.has(path));
|
|
319
|
+
const baselineFingerprints = anchor.baselineFingerprints || {};
|
|
320
|
+
const dirtyChanged = Object.entries(baselineFingerprints)
|
|
321
|
+
.some(([path, fingerprint]) => repo.fingerprints[path] !== fingerprint);
|
|
322
|
+
const hasWork = Boolean(input.force === true || existing || editCount || newDirtyPath || dirtyChanged || repo.commits.length);
|
|
323
|
+
if (!hasWork) return { record: null, path: null, notice: null, compactionHint: false };
|
|
324
|
+
|
|
325
|
+
const records = readRecords(store.root);
|
|
326
|
+
const completed = ['end', 'sessionEnd', 'stop'].includes(input.event) ? now : (existing?.timestamps?.completedAt || null);
|
|
327
|
+
const cost = config.metrics
|
|
328
|
+
? (Object.hasOwn(input, 'cost') ? finiteOrNull(input.cost) : (existing?.cost ?? null))
|
|
329
|
+
: null;
|
|
330
|
+
const toolCalls = config.metrics
|
|
331
|
+
? (Object.hasOwn(input, 'toolCalls') ? finiteOrNull(input.toolCalls, true) : (existing?.toolCalls ?? null))
|
|
332
|
+
: null;
|
|
333
|
+
const record = {
|
|
334
|
+
schemaVersion: 1,
|
|
335
|
+
sessionId,
|
|
336
|
+
target: cleanString(input.target, 40) || existing?.target || 'unknown',
|
|
337
|
+
event: cleanString(input.event, 40) || 'capture',
|
|
338
|
+
branch: cleanString(repo.branch, 255),
|
|
339
|
+
worktree: cleanString(repo.worktree, 1000),
|
|
340
|
+
baselineHead: cleanString(anchor.baselineHead, 64),
|
|
341
|
+
head: cleanString(repo.head, 64),
|
|
342
|
+
files: repo.files.filter((item) => !secretShaped(item)),
|
|
343
|
+
commits: repo.commits.filter((item) => !secretShaped(item)),
|
|
344
|
+
ledger: ledgerSnapshot(cwd),
|
|
345
|
+
timestamps: { startedAt: anchor.startedAt, updatedAt: now, completedAt: completed },
|
|
346
|
+
toolCalls,
|
|
347
|
+
cost,
|
|
348
|
+
editCount,
|
|
349
|
+
concurrent: activeConcurrent(records, sessionId, target, repo.worktree, now),
|
|
350
|
+
};
|
|
351
|
+
const errors = validateSessionRecord(record);
|
|
352
|
+
if (errors.length) return { record: null, path: null, notice: null, compactionHint: false, errors };
|
|
353
|
+
atomicJson(recordPath, record);
|
|
354
|
+
existing = record;
|
|
355
|
+
return {
|
|
356
|
+
record,
|
|
357
|
+
path: recordPath,
|
|
358
|
+
notice: consumeNotice(store),
|
|
359
|
+
compactionHint: Boolean(config.compactionHint && editCount >= config.editThreshold),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function validAnchor(cwd, record) {
|
|
364
|
+
if (!record?.head) return true;
|
|
365
|
+
return git(cwd, ['cat-file', '-e', `${record.head}^{commit}`]).ok;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function selectedLessons(root, config) {
|
|
369
|
+
return lessonFiles(root).map(readJson).filter((lesson) => lesson?.approvedAt && lesson.confidence >= config.lessonThreshold
|
|
370
|
+
&& !secretShaped(lesson.text) && !secretShaped(lesson.evidence))
|
|
371
|
+
.sort((a, b) => b.confidence - a.confidence || b.approvedAt.localeCompare(a.approvedAt))
|
|
372
|
+
.slice(0, config.lessonLimit);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function truncateUtf8(text, maxBytes) {
|
|
376
|
+
if (Buffer.byteLength(text) <= maxBytes) return text;
|
|
377
|
+
let output = '';
|
|
378
|
+
for (const character of text) {
|
|
379
|
+
if (Buffer.byteLength(output + character) > maxBytes) break;
|
|
380
|
+
output += character;
|
|
381
|
+
}
|
|
382
|
+
return output;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function renderContext(record, match, lessons, config) {
|
|
386
|
+
if (!record && !lessons.length) return '';
|
|
387
|
+
const lines = record ? [
|
|
388
|
+
`[rsc local ${match} continuation]`,
|
|
389
|
+
`source: ${record.target}/${record.sessionId}`,
|
|
390
|
+
`branch: ${record.branch || 'unavailable'}`,
|
|
391
|
+
`worktree: ${record.worktree || 'unavailable'}`,
|
|
392
|
+
`head: ${record.head || 'unavailable'}`,
|
|
393
|
+
`files: ${record.files.join(', ') || 'none'}`,
|
|
394
|
+
`commits: ${record.commits.join(', ') || 'none'}`,
|
|
395
|
+
] : ['[rsc local approved lessons]'];
|
|
396
|
+
if (record?.ledger.length) lines.push(`ledger: ${record.ledger.map((item) => `${item.path}=${item.status}/${item.openItems}`).join(', ')}`);
|
|
397
|
+
if (record) lines.push(`metrics: cost=${record.cost ?? 'unknown'} toolCalls=${record.toolCalls ?? 'unknown'}`);
|
|
398
|
+
if (record?.concurrent) lines.push('parallel sessions detected; this record was not merged with them.');
|
|
399
|
+
for (const lesson of lessons) lines.push(`approved lesson (${lesson.confidence}): ${lesson.text}`);
|
|
400
|
+
return truncateUtf8(`${lines.join('\n')}\n`, config.contextBytes);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function resume(input = {}) {
|
|
404
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
405
|
+
const config = settings(input.settings);
|
|
406
|
+
if (!config.enabled) return { match: 'none', record: null, lessons: [], context: '' };
|
|
407
|
+
const store = chooseMemoryRoot(cwd);
|
|
408
|
+
ensureStore(store.root);
|
|
409
|
+
const now = iso(input.now);
|
|
410
|
+
prune(store.root, now, config);
|
|
411
|
+
const current = snapshot(cwd, null);
|
|
412
|
+
const records = readRecords(store.root).filter((record) => validAnchor(cwd, record))
|
|
413
|
+
.sort((a, b) => b.timestamps.updatedAt.localeCompare(a.timestamps.updatedAt));
|
|
414
|
+
let record = records.find((candidate) => candidate.branch === current.branch && candidate.worktree === current.worktree) || null;
|
|
415
|
+
let match = record ? 'exact' : 'none';
|
|
416
|
+
if (!record) {
|
|
417
|
+
record = records.find((candidate) => candidate.worktree === current.worktree) || records[0] || null;
|
|
418
|
+
if (record) match = 'nearby';
|
|
419
|
+
}
|
|
420
|
+
const lessons = selectedLessons(store.root, config);
|
|
421
|
+
return { match, record, lessons, context: renderContext(record, match, lessons, config) };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function learn(input = {}) {
|
|
425
|
+
if (input.approved !== true) return { saved: false, reason: 'individual explicit approval required' };
|
|
426
|
+
const text = cleanString(input.text, 1000);
|
|
427
|
+
const evidence = cleanString(input.evidence, 1000);
|
|
428
|
+
if (!text || !evidence || secretShaped(input.text) || secretShaped(input.evidence)) return { saved: false, reason: 'secret-shaped or invalid lesson' };
|
|
429
|
+
if (!['project', 'global'].includes(input.scope)) return { saved: false, reason: 'scope must be project or global' };
|
|
430
|
+
const confidence = finiteOrNull(input.confidence);
|
|
431
|
+
if (confidence === null || confidence > 1) return { saved: false, reason: 'confidence must be between 0 and 1' };
|
|
432
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
433
|
+
const store = chooseMemoryRoot(cwd);
|
|
434
|
+
ensureStore(store.root);
|
|
435
|
+
const approvedAt = iso(input.now);
|
|
436
|
+
const id = createHash('sha256').update(`${input.scope}\0${text}\0${evidence}`).digest('hex').slice(0, 20);
|
|
437
|
+
const lesson = { schemaVersion: 1, id, text, evidence, scope: input.scope, confidence, approvedAt };
|
|
438
|
+
const path = join(store.root, 'lessons', `${id}.json`);
|
|
439
|
+
atomicJson(path, lesson);
|
|
440
|
+
return { saved: true, lesson, path, notice: consumeNotice(store) };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function metricsSummary(input = {}) {
|
|
444
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
445
|
+
const config = settings(input.settings);
|
|
446
|
+
const store = chooseMemoryRoot(cwd);
|
|
447
|
+
ensureStore(store.root);
|
|
448
|
+
prune(store.root, iso(input.now), config);
|
|
449
|
+
const sessions = readRecords(store.root).map(({ sessionId, target, cost, toolCalls, timestamps }) => ({ sessionId, target, cost, toolCalls, timestamps }));
|
|
450
|
+
const unknown = {
|
|
451
|
+
cost: sessions.filter((row) => row.cost === null).length,
|
|
452
|
+
toolCalls: sessions.filter((row) => row.toolCalls === null).length,
|
|
453
|
+
};
|
|
454
|
+
const knownTotal = {
|
|
455
|
+
cost: sessions.reduce((sum, row) => sum + (row.cost ?? 0), 0),
|
|
456
|
+
toolCalls: sessions.reduce((sum, row) => sum + (row.toolCalls ?? 0), 0),
|
|
457
|
+
};
|
|
458
|
+
const total = {
|
|
459
|
+
cost: !sessions.length || unknown.cost ? null : knownTotal.cost,
|
|
460
|
+
toolCalls: !sessions.length || unknown.toolCalls ? null : knownTotal.toolCalls,
|
|
461
|
+
};
|
|
462
|
+
return { sessions, total, knownTotal, unknown };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function memoryStatus(input = {}) {
|
|
466
|
+
const cwd = resolve(input.cwd || process.cwd());
|
|
467
|
+
const store = chooseMemoryRoot(cwd);
|
|
468
|
+
ensureStore(store.root);
|
|
469
|
+
return {
|
|
470
|
+
enabled: settings(input.settings).enabled,
|
|
471
|
+
root: store.root,
|
|
472
|
+
kind: store.kind,
|
|
473
|
+
reason: store.reason,
|
|
474
|
+
sessions: readRecords(store.root).length,
|
|
475
|
+
lessons: lessonFiles(store.root).map(readJson).filter(Boolean).length,
|
|
476
|
+
};
|
|
477
|
+
}
|