@ijfw/memory-server 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/bin/ijfw +27 -0
- package/bin/ijfw-dashboard +180 -0
- package/bin/ijfw-dispatch-plan +41 -0
- package/bin/ijfw-memorize +273 -0
- package/bin/ijfw-memory +51 -0
- package/fixtures/demo-target.js +28 -0
- package/package.json +53 -0
- package/src/api-client.js +190 -0
- package/src/audit-roster.js +315 -0
- package/src/caps.js +37 -0
- package/src/cold-scan-runner.mjs +37 -0
- package/src/compute/edges.js +155 -0
- package/src/compute/extract.js +560 -0
- package/src/compute/fts5.js +420 -0
- package/src/compute/graph-auto-index.js +191 -0
- package/src/compute/graph-lock.js +114 -0
- package/src/compute/index.js +18 -0
- package/src/compute/migration-runner.js +116 -0
- package/src/compute/migrations/001-initial.js +23 -0
- package/src/compute/migrations/002-porter-stemming-source.js +139 -0
- package/src/compute/migrations/003-tier-semantic.js +69 -0
- package/src/compute/migrations/004-kg-tables.js +83 -0
- package/src/compute/migrations/005-stale-candidate.js +72 -0
- package/src/compute/python-resolver.js +106 -0
- package/src/compute/runner-vm.js +185 -0
- package/src/compute/runner.js +416 -0
- package/src/compute/sandbox-detect.js +122 -0
- package/src/compute/sandbox-linux.js +164 -0
- package/src/compute/sandbox-macos.js +167 -0
- package/src/compute/sandbox-windows.js +63 -0
- package/src/compute/schema.sql +118 -0
- package/src/compute/staleness.js +239 -0
- package/src/compute/synonyms.js +367 -0
- package/src/compute/traverse.js +180 -0
- package/src/cost/aggregator.js +229 -0
- package/src/cost/pricing.js +134 -0
- package/src/cost/readers/claude.js +179 -0
- package/src/cost/readers/codex.js +131 -0
- package/src/cost/readers/gemini.js +111 -0
- package/src/cost/savings.js +243 -0
- package/src/cross-dispatcher.js +437 -0
- package/src/cross-orchestrator-cli.js +1885 -0
- package/src/cross-orchestrator.js +598 -0
- package/src/cross-project-search.js +114 -0
- package/src/dashboard-client.html +1180 -0
- package/src/dashboard-server.js +895 -0
- package/src/design-companion.js +81 -0
- package/src/dispatch/colon-syntax.js +732 -0
- package/src/dispatch-planner.js +235 -0
- package/src/dream/cooldown.js +105 -0
- package/src/dream/runner.mjs +373 -0
- package/src/dream/staleness-wiring.js +195 -0
- package/src/feedback-detector.js +57 -0
- package/src/hero-line.js +115 -0
- package/src/importers/claude-mem.js +152 -0
- package/src/importers/cli.js +311 -0
- package/src/importers/common.js +84 -0
- package/src/importers/discover.js +235 -0
- package/src/importers/rtk.js +107 -0
- package/src/intent-router.js +221 -0
- package/src/lib/atomic-io.js +201 -0
- package/src/lib/cache.js +33 -0
- package/src/lib/npm-view.js +104 -0
- package/src/lib/status-card.js +95 -0
- package/src/lib/token.js +85 -0
- package/src/memory/fts5.js +349 -0
- package/src/memory/migration-runner.js +116 -0
- package/src/memory/migrations/001-fts5-init.js +26 -0
- package/src/memory/migrations/002-tier-semantic.js +60 -0
- package/src/memory/migrations/003-stale-candidate.js +60 -0
- package/src/memory/reader.js +300 -0
- package/src/memory/recall-counter.js +76 -0
- package/src/memory/schema.sql +79 -0
- package/src/memory/search.js +431 -0
- package/src/memory/staleness.js +237 -0
- package/src/memory/tier-promotion.js +377 -0
- package/src/memory/tokenize.js +63 -0
- package/src/project-type-detector.js +866 -0
- package/src/prompt-check.js +171 -0
- package/src/ralph-allowlist.js +88 -0
- package/src/receipts.js +129 -0
- package/src/redactor.js +107 -0
- package/src/sandbox.js +275 -0
- package/src/sanitizer.js +69 -0
- package/src/scan-resume.js +167 -0
- package/src/schema.js +82 -0
- package/src/search-bm25.js +108 -0
- package/src/server.js +1414 -0
- package/src/swarm-config.js +80 -0
- package/src/trident/dispatch.js +211 -0
- package/src/trident/lens-health.js +253 -0
- package/src/update-apply.js +79 -0
- package/src/update-check.js +136 -0
- package/src/vectors.js +178 -0
- package/templates/design/bento-grid.md +84 -0
- package/templates/design/brutalist-luxe.md +82 -0
- package/templates/design/cinematic-dark.md +82 -0
- package/templates/design/data-dense-dashboard.md +88 -0
- package/templates/design/editorial-warm.md +81 -0
- package/templates/design/glassmorphic.md +84 -0
- package/templates/design/magazine-editorial.md +84 -0
- package/templates/design/maximalist-vibrant.md +85 -0
- package/templates/design/neo-swiss-tech.md +85 -0
- package/templates/design/swiss-minimal.md +80 -0
- package/templates/design/terminal-native.md +83 -0
- package/templates/design/warm-organic.md +84 -0
package/src/sandbox.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sandbox.js -- ijfw_run sandbox module
|
|
3
|
+
* Runs shell commands, caps output, summarizes for LLM context, spills full
|
|
4
|
+
* output to disk so the context window isn't flooded.
|
|
5
|
+
*
|
|
6
|
+
* Zero external dependencies -- Node.js built-ins only.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, statSync, unlinkSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
import { homedir } from 'os';
|
|
13
|
+
|
|
14
|
+
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
15
|
+
const MAX_LINES = 50_000;
|
|
16
|
+
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
17
|
+
|
|
18
|
+
// Strip ANSI escape sequences.
|
|
19
|
+
function stripAnsi(s) {
|
|
20
|
+
return s.replace(/\x1b\[[0-9;]*[mGKHFJK]/g, ''); // eslint-disable-line no-control-regex
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Sanitize a label string for use as a filename.
|
|
24
|
+
function sanitizeLabel(label) {
|
|
25
|
+
return String(label).replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 128);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* runCommand(command, opts) → { stdout, exitCode, signal, durationMs, lines, bytes, timedOut }
|
|
30
|
+
*
|
|
31
|
+
* Spawns command in a shell. Merges stdout+stderr in arrival order, capped at
|
|
32
|
+
* MAX_BYTES / MAX_LINES. Kills the process after TIMEOUT_MS.
|
|
33
|
+
*/
|
|
34
|
+
export function runCommand(command, opts = {}) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const cwd = opts.cwd || process.cwd();
|
|
37
|
+
const start = Date.now();
|
|
38
|
+
let timedOut = false;
|
|
39
|
+
let totalBytes = 0;
|
|
40
|
+
let capped = false;
|
|
41
|
+
const chunks = [];
|
|
42
|
+
|
|
43
|
+
const child = spawn(command, [], {
|
|
44
|
+
shell: true,
|
|
45
|
+
cwd,
|
|
46
|
+
env: process.env,
|
|
47
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
timedOut = true;
|
|
52
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
53
|
+
}, TIMEOUT_MS);
|
|
54
|
+
|
|
55
|
+
function onData(chunk) {
|
|
56
|
+
if (capped) return;
|
|
57
|
+
const remaining = MAX_BYTES - totalBytes;
|
|
58
|
+
if (chunk.length >= remaining) {
|
|
59
|
+
chunks.push(chunk.slice(0, remaining));
|
|
60
|
+
totalBytes += remaining;
|
|
61
|
+
capped = true;
|
|
62
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
63
|
+
} else {
|
|
64
|
+
chunks.push(chunk);
|
|
65
|
+
totalBytes += chunk.length;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
child.stdout.on('data', onData);
|
|
70
|
+
child.stderr.on('data', onData);
|
|
71
|
+
|
|
72
|
+
child.on('close', (code, signal) => {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
75
|
+
const lines = raw.split('\n').length;
|
|
76
|
+
const truncLines = Math.min(lines, MAX_LINES);
|
|
77
|
+
// If over MAX_LINES, trim to MAX_LINES worth of lines
|
|
78
|
+
const stdout = lines > MAX_LINES
|
|
79
|
+
? raw.split('\n').slice(0, MAX_LINES).join('\n')
|
|
80
|
+
: raw;
|
|
81
|
+
const bytes = Buffer.byteLength(stdout, 'utf8');
|
|
82
|
+
resolve({
|
|
83
|
+
stdout,
|
|
84
|
+
exitCode: code,
|
|
85
|
+
signal,
|
|
86
|
+
durationMs: Date.now() - start,
|
|
87
|
+
lines: truncLines,
|
|
88
|
+
bytes,
|
|
89
|
+
timedOut,
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
child.on('error', (err) => {
|
|
94
|
+
clearTimeout(timer);
|
|
95
|
+
resolve({
|
|
96
|
+
stdout: `spawn error: ${err.message}`,
|
|
97
|
+
exitCode: 1,
|
|
98
|
+
signal: null,
|
|
99
|
+
durationMs: Date.now() - start,
|
|
100
|
+
lines: 1,
|
|
101
|
+
bytes: 0,
|
|
102
|
+
timedOut: false,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* detectDomain(output) → 'test' | 'build' | 'grep' | 'log' | 'raw'
|
|
110
|
+
* Heuristics on ANSI-stripped output.
|
|
111
|
+
*/
|
|
112
|
+
export function detectDomain(output) {
|
|
113
|
+
const lines = output.split('\n');
|
|
114
|
+
const sample = lines.slice(0, 500); // only inspect leading lines for speed
|
|
115
|
+
|
|
116
|
+
// test: pass/fail keywords + test count or suite name
|
|
117
|
+
if (
|
|
118
|
+
/\b(PASS|FAIL|passed|failed|\u2713|\u2717|ok|not ok)\b/i.test(output) &&
|
|
119
|
+
(/\d+\s+(test|spec|suite|passing|failing|pending)/i.test(output) || /^(PASS|FAIL)\s+/m.test(output))
|
|
120
|
+
) {
|
|
121
|
+
return 'test';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// build: compiler/bundler error patterns
|
|
125
|
+
if (/error\[E\d|ERROR TS\d|SyntaxError|\b(webpack|vite|rollup|tsc|cargo)\b/i.test(output)) {
|
|
126
|
+
return 'build';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// grep: majority of lines are file:line: pattern
|
|
130
|
+
const grepLike = sample.filter(l => /^[^:]+:\d+:/.test(l)).length;
|
|
131
|
+
if (sample.length > 5 && grepLike / sample.length > 0.6) {
|
|
132
|
+
return 'grep';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// log: majority of lines start with ISO timestamp or [INFO]/[ERROR]/[WARN]
|
|
136
|
+
const logLike = sample.filter(l =>
|
|
137
|
+
/^\d{4}-\d{2}-\d{2}[T ]/.test(l) ||
|
|
138
|
+
/^\[?(INFO|ERROR|WARN|DEBUG)\]?[\s:]/i.test(l)
|
|
139
|
+
).length;
|
|
140
|
+
if (sample.length > 5 && logLike / sample.length > 0.4) {
|
|
141
|
+
return 'log';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return 'raw';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* summarize(output, domain, command, exitCode, durationMs) → string
|
|
149
|
+
*/
|
|
150
|
+
export function summarize(output, domain, command, exitCode, durationMs) {
|
|
151
|
+
const lines = output.split('\n');
|
|
152
|
+
const lineCount = lines.length;
|
|
153
|
+
const header = `[ijfw_run] ${command} exit=${exitCode} ${durationMs}ms | ${lineCount} lines`;
|
|
154
|
+
|
|
155
|
+
const parts = [header];
|
|
156
|
+
|
|
157
|
+
if (domain === 'test') {
|
|
158
|
+
// Extract pass/fail counts
|
|
159
|
+
const countMatch = output.match(/(\d+)\s+(?:test|spec|suite)s?\s+(?:passed|passing)/i)
|
|
160
|
+
|| output.match(/Tests?:\s+(\d+)\s+passed/i);
|
|
161
|
+
const failMatch = output.match(/(\d+)\s+(?:test|spec|suite)s?\s+(?:failed|failing)/i)
|
|
162
|
+
|| output.match(/Tests?:.*?(\d+)\s+failed/i);
|
|
163
|
+
if (countMatch || failMatch) {
|
|
164
|
+
const p = countMatch ? countMatch[1] : '?';
|
|
165
|
+
const f = failMatch ? failMatch[1] : '0';
|
|
166
|
+
parts.push(`Tests: ${p} passed, ${f} failed`);
|
|
167
|
+
}
|
|
168
|
+
// Failing test names
|
|
169
|
+
const failLines = lines.filter(l => /\b(FAIL|\u2717|not ok|\u00d7 )\b/.test(l) || /^\s+●/.test(l));
|
|
170
|
+
if (failLines.length > 0) {
|
|
171
|
+
parts.push('Failures:');
|
|
172
|
+
failLines.slice(0, 10).forEach(l => parts.push(' ' + l.trim()));
|
|
173
|
+
}
|
|
174
|
+
} else if (domain === 'build') {
|
|
175
|
+
const errorLines = lines.filter(l => /\b(error|Error|ERROR)\b/.test(l));
|
|
176
|
+
parts.push(`Build errors (${errorLines.length}):`);
|
|
177
|
+
errorLines.slice(0, 20).forEach(l => parts.push(' ' + l.trim()));
|
|
178
|
+
parts.push(`exit: ${exitCode}`);
|
|
179
|
+
} else if (domain === 'grep') {
|
|
180
|
+
const paths = new Set();
|
|
181
|
+
lines.forEach(l => {
|
|
182
|
+
const m = l.match(/^([^:]+):\d+:/);
|
|
183
|
+
if (m) paths.add(m[1]);
|
|
184
|
+
});
|
|
185
|
+
parts.push(`Matches: ${lineCount} lines | ${paths.size} unique files`);
|
|
186
|
+
Array.from(paths).slice(0, 10).forEach(p => parts.push(' ' + p));
|
|
187
|
+
} else if (domain === 'log') {
|
|
188
|
+
const notable = lines.filter(l => /\b(ERROR|WARN)\b/i.test(l));
|
|
189
|
+
parts.push(`Log: ${lineCount} lines | ${notable.length} ERROR/WARN`);
|
|
190
|
+
notable.slice(0, 20).forEach(l => parts.push(' ' + l.trim()));
|
|
191
|
+
} else {
|
|
192
|
+
// raw fallback
|
|
193
|
+
const first = lines.slice(0, 15);
|
|
194
|
+
const last = lines.slice(-5);
|
|
195
|
+
const omitted = Math.max(0, lineCount - 20);
|
|
196
|
+
first.forEach(l => parts.push(l));
|
|
197
|
+
if (omitted > 0) {
|
|
198
|
+
parts.push(`... (${omitted} lines omitted) ...`);
|
|
199
|
+
last.forEach(l => parts.push(l));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Reliability tail: always append last 10 raw lines (except for raw which already includes them)
|
|
204
|
+
if (domain !== 'raw') {
|
|
205
|
+
parts.push('--- last 10 lines ---');
|
|
206
|
+
lines.slice(-10).forEach(l => parts.push(l));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return parts.join('\n');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* writeToSandbox(label, command, output, meta) → sandboxPath (the label)
|
|
214
|
+
*/
|
|
215
|
+
export function writeToSandbox(label, command, output, meta) {
|
|
216
|
+
const dir = join(process.env.HOME || homedir(), '.ijfw', 'session-sandbox');
|
|
217
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
218
|
+
|
|
219
|
+
const safeLabel = sanitizeLabel(label);
|
|
220
|
+
const txtPath = join(dir, `${safeLabel}.txt`);
|
|
221
|
+
const jsonPath = join(dir, `${safeLabel}.json`);
|
|
222
|
+
|
|
223
|
+
writeFileSync(txtPath, output, { encoding: 'utf8', mode: 0o600 });
|
|
224
|
+
writeFileSync(jsonPath, JSON.stringify({
|
|
225
|
+
label: safeLabel,
|
|
226
|
+
command,
|
|
227
|
+
timestamp: new Date().toISOString(),
|
|
228
|
+
exitCode: meta.exitCode,
|
|
229
|
+
lines: meta.lines,
|
|
230
|
+
bytes: meta.bytes,
|
|
231
|
+
}), { encoding: 'utf8', mode: 0o600 });
|
|
232
|
+
|
|
233
|
+
return safeLabel;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* purgeSandboxOld(maxAgeMs) -- deletes .txt + .json pairs older than maxAgeMs.
|
|
238
|
+
* Called on every ijfw_run invocation. Fast (stat+unlink only).
|
|
239
|
+
*/
|
|
240
|
+
export function purgeSandboxOld(maxAgeMs = 24 * 60 * 60 * 1000) {
|
|
241
|
+
const dir = join(process.env.HOME || homedir(), '.ijfw', 'session-sandbox');
|
|
242
|
+
if (!existsSync(dir)) return;
|
|
243
|
+
const now = Date.now();
|
|
244
|
+
try {
|
|
245
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.json'));
|
|
246
|
+
for (const f of files) {
|
|
247
|
+
const jsonPath = join(dir, f);
|
|
248
|
+
try {
|
|
249
|
+
const st = statSync(jsonPath);
|
|
250
|
+
if (now - st.mtimeMs > maxAgeMs) {
|
|
251
|
+
const base = f.slice(0, -5); // strip .json
|
|
252
|
+
try { unlinkSync(join(dir, `${base}.txt`)); } catch {}
|
|
253
|
+
try { unlinkSync(jsonPath); } catch {}
|
|
254
|
+
}
|
|
255
|
+
} catch { /* skip unreadable entries */ }
|
|
256
|
+
}
|
|
257
|
+
} catch { /* dir unreadable -- skip */ }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* readFromSandbox(label) → string | null
|
|
262
|
+
*/
|
|
263
|
+
export function readFromSandbox(label) {
|
|
264
|
+
const safeLabel = sanitizeLabel(label);
|
|
265
|
+
const dir = join(process.env.HOME || homedir(), '.ijfw', 'session-sandbox');
|
|
266
|
+
const txtPath = join(dir, `${safeLabel}.txt`);
|
|
267
|
+
if (!existsSync(txtPath)) return null;
|
|
268
|
+
try {
|
|
269
|
+
return readFileSync(txtPath, 'utf8');
|
|
270
|
+
} catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export { stripAnsi };
|
package/src/sanitizer.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// --- Content sanitizer (defense against prompt-injection via stored memory) ---
|
|
2
|
+
//
|
|
3
|
+
// Stored content is read back and injected into LLM context on every recall.
|
|
4
|
+
// An attacker who can write to .ijfw/memory/ (rogue dep, malicious teammate
|
|
5
|
+
// commit, compromised plugin) controls future sessions unless we neutralize
|
|
6
|
+
// the structural and semantic markdown features they could weaponize.
|
|
7
|
+
//
|
|
8
|
+
// Extracted from server.js in Phase 6 (audit finding X2) so ijfw-memorize
|
|
9
|
+
// and any other memory writer can apply the same defang before append.
|
|
10
|
+
|
|
11
|
+
export function sanitizeContent(s) {
|
|
12
|
+
if (typeof s !== 'string') return '';
|
|
13
|
+
let out = s;
|
|
14
|
+
|
|
15
|
+
// 1. Strip C0/C1 control characters (incl. NUL) except tab and newline.
|
|
16
|
+
// oxlint-disable-next-line no-control-regex -- intentional: sanitize control chars from stored content
|
|
17
|
+
out = out.replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, '');
|
|
18
|
+
|
|
19
|
+
// 2. Strip Unicode bidi/zero-width/format chars used to hide payloads.
|
|
20
|
+
// U+200B-U+200F, U+202A-U+202E, U+2066-U+2069, U+FEFF
|
|
21
|
+
out = out.replace(/[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/g, '');
|
|
22
|
+
|
|
23
|
+
// 3. Defang ANY heading prefix (1+ hashes, optional whitespace) -- entry must
|
|
24
|
+
// never produce a structural ## section that mimics a journal timestamp.
|
|
25
|
+
out = out.replace(/^[ \t]*#+[ \t]+/gm, '> ');
|
|
26
|
+
|
|
27
|
+
// 4. Defang setext-style headings (=== or --- under a line) -- strip the underline.
|
|
28
|
+
out = out.replace(/^[ \t]*[=-]{3,}[ \t]*$/gm, '');
|
|
29
|
+
|
|
30
|
+
// 5. Neutralize fenced code blocks (``` and ~~~) so attacker can't open a fence
|
|
31
|
+
// that swallows surrounding journal structure as "code".
|
|
32
|
+
out = out.replace(/^[ \t]*(```|~~~).*$/gm, '> $1');
|
|
33
|
+
|
|
34
|
+
// 6. Neutralize HTML/XML-style tags that LLMs may parse as instructions
|
|
35
|
+
// (<system>, </assistant>, <instructions>, etc.) -- escape angle brackets.
|
|
36
|
+
out = out.replace(/[<>]/g, ch => (ch === '<' ? '<' : '>'));
|
|
37
|
+
|
|
38
|
+
// 7. Collapse to single line -- multi-line stored content can't fake new
|
|
39
|
+
// journal sections. Newlines become " | " for readability.
|
|
40
|
+
out = out.replace(/\r\n?|\n/g, ' | ');
|
|
41
|
+
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function sanitizeForSandbox(s) {
|
|
46
|
+
if (typeof s !== 'string') return '';
|
|
47
|
+
let out = s;
|
|
48
|
+
|
|
49
|
+
// 1. Strip ANSI escape codes (colors, cursor movement).
|
|
50
|
+
// oxlint-disable-next-line no-control-regex -- intentional: strip ANSI from sandbox output
|
|
51
|
+
out = out.replace(/\x1b\[[0-9;]*[mGKHF]/g, '');
|
|
52
|
+
|
|
53
|
+
// 2. Strip lines starting with # (headings) -- defang prompt-injection via markdown headings.
|
|
54
|
+
out = out.replace(/^[ \t]*#+[ \t].*/gm, '');
|
|
55
|
+
|
|
56
|
+
// 3. Neutralize fenced code blocks (``` and ~~~).
|
|
57
|
+
out = out.replace(/^[ \t]*(```|~~~).*$/gm, '');
|
|
58
|
+
|
|
59
|
+
// 4. Strip <system>, <prompt>, <assistant> tag patterns (open and close).
|
|
60
|
+
out = out.replace(/<\/?(system|prompt|assistant)[^>]*>/gi, '');
|
|
61
|
+
|
|
62
|
+
// 5. Truncate any single line exceeding 2000 chars (minified JS, base64 blobs).
|
|
63
|
+
out = out
|
|
64
|
+
.split('\n')
|
|
65
|
+
.map(line => (line.length > 2000 ? line.slice(0, 200) + '...[truncated]' : line))
|
|
66
|
+
.join('\n');
|
|
67
|
+
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// IJFW v1.3.0 Alpha -- A3 cross-session checkpoint+resume helper (V3-F2).
|
|
2
|
+
//
|
|
3
|
+
// Persists scan progress at <project>/.ijfw/scan-state.json so a crash
|
|
4
|
+
// mid-walk on a 100k-file repo never re-scans from zero. State shape:
|
|
5
|
+
//
|
|
6
|
+
// {
|
|
7
|
+
// scan_id: string -- opaque id, regenerated per restart
|
|
8
|
+
// started_at: ISO8601 -- when the run began
|
|
9
|
+
// last_path_walked: string -- absolute path of last entry visited
|
|
10
|
+
// files_scanned: number
|
|
11
|
+
// total_estimate: number
|
|
12
|
+
// attempts: number -- bump on each resume; 3 caps it
|
|
13
|
+
// incomplete: bool -- false on clean finish, true on guardrail
|
|
14
|
+
// session_id: string? -- forensic only; not used for resume
|
|
15
|
+
// }
|
|
16
|
+
//
|
|
17
|
+
// shouldResume() rules (per spec):
|
|
18
|
+
// - state.incomplete must be true
|
|
19
|
+
// - state.started_at must be <24h old
|
|
20
|
+
// - state.attempts must be <3 (so a 3rd attempt triggers fresh restart;
|
|
21
|
+
// a 4th is rejected by the cap)
|
|
22
|
+
//
|
|
23
|
+
// Atomic write: tmp + rename. POSIX rename(2) is atomic on the same fs.
|
|
24
|
+
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, copyFileSync } from 'fs';
|
|
26
|
+
import { join } from 'path';
|
|
27
|
+
|
|
28
|
+
const STATE_FILE = 'scan-state.json';
|
|
29
|
+
const LOCK_FILE = 'scan-state.json.lock';
|
|
30
|
+
const STALENESS_MS = 24 * 60 * 60 * 1000; // 24h
|
|
31
|
+
const ATTEMPT_CAP = 3;
|
|
32
|
+
// P3-M6: stale-lock reclamation -- a lock older than this and with a dead
|
|
33
|
+
// owner PID is reclaimable. Mirrors lock.sh's STALE_AGE_SECONDS=60.
|
|
34
|
+
const LOCK_STALE_MS = 60 * 1000;
|
|
35
|
+
|
|
36
|
+
function statePath(projectRoot) {
|
|
37
|
+
return join(String(projectRoot), '.ijfw', STATE_FILE);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function loadScanState(projectRoot) {
|
|
41
|
+
const path = statePath(projectRoot);
|
|
42
|
+
if (!existsSync(path)) return null;
|
|
43
|
+
try {
|
|
44
|
+
const raw = readFileSync(path, 'utf8');
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
47
|
+
} catch { /* fall through */ }
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function writeScanState(projectRoot, state) {
|
|
52
|
+
const dir = join(String(projectRoot), '.ijfw');
|
|
53
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
54
|
+
const finalPath = statePath(projectRoot);
|
|
55
|
+
const tmpPath = `${finalPath}.tmp.${process.pid}.${Date.now()}`;
|
|
56
|
+
const safe = {
|
|
57
|
+
scan_id: String(state.scan_id || ''),
|
|
58
|
+
started_at: String(state.started_at || new Date().toISOString()),
|
|
59
|
+
last_path_walked: String(state.last_path_walked || ''),
|
|
60
|
+
files_scanned: Number.isFinite(state.files_scanned) ? state.files_scanned : 0,
|
|
61
|
+
total_estimate: Number.isFinite(state.total_estimate) ? state.total_estimate : 0,
|
|
62
|
+
attempts: Number.isFinite(state.attempts) ? state.attempts : 1,
|
|
63
|
+
incomplete: state.incomplete !== false,
|
|
64
|
+
session_id: state.session_id || null,
|
|
65
|
+
};
|
|
66
|
+
// P3-H3: persist accumulated partial walk state (counters, fingerprint
|
|
67
|
+
// sample, manifest hits, dir hits, ext totals, pattern hits) so resume
|
|
68
|
+
// continues to add to it rather than restarting at zero.
|
|
69
|
+
if (state.partial && typeof state.partial === 'object') {
|
|
70
|
+
safe.partial = state.partial;
|
|
71
|
+
}
|
|
72
|
+
writeFileSync(tmpPath, JSON.stringify(safe, null, 2) + '\n', 'utf8');
|
|
73
|
+
// P3-H5: cross-mount symlink layouts raise EXDEV from rename; copy +
|
|
74
|
+
// unlink keeps the write durable on dotfile setups where .ijfw/ lives
|
|
75
|
+
// on a different filesystem than the temp file.
|
|
76
|
+
try {
|
|
77
|
+
renameSync(tmpPath, finalPath);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
if (!err || err.code !== 'EXDEV') throw err;
|
|
80
|
+
try {
|
|
81
|
+
copyFileSync(tmpPath, finalPath);
|
|
82
|
+
} finally {
|
|
83
|
+
try { unlinkSync(tmpPath); } catch { /* best-effort */ }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return finalPath;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// P3-M6: TOCTOU lock helpers for scan-state.json. Mirrors the noclobber
|
|
90
|
+
// CAS pattern used by lock.sh -- exclusive create, PID + epoch_ms inside,
|
|
91
|
+
// stale-lock reclamation on dead owners or age > LOCK_STALE_MS.
|
|
92
|
+
function lockPath(projectRoot) {
|
|
93
|
+
return join(String(projectRoot), '.ijfw', LOCK_FILE);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isPidAlive(pid) {
|
|
97
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
98
|
+
try {
|
|
99
|
+
process.kill(pid, 0);
|
|
100
|
+
return true;
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err && err.code === 'EPERM') return true;
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function reclaimIfStale(lp) {
|
|
108
|
+
if (!existsSync(lp)) return;
|
|
109
|
+
let raw;
|
|
110
|
+
try { raw = readFileSync(lp, 'utf8'); } catch { return; }
|
|
111
|
+
const lines = String(raw).split(/\r?\n/);
|
|
112
|
+
const pid = Number(lines[0]);
|
|
113
|
+
const ts = Number(lines[1]);
|
|
114
|
+
const ageOk = Number.isFinite(ts) && (Date.now() - ts) <= LOCK_STALE_MS;
|
|
115
|
+
if (isPidAlive(pid) && ageOk) return;
|
|
116
|
+
try { unlinkSync(lp); } catch { /* best-effort */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* acquireScanLock(projectRoot) -> { released } | null
|
|
121
|
+
* Returns null when another live writer holds the lock; the caller MUST
|
|
122
|
+
* skip its write rather than racing. On success, callers must invoke
|
|
123
|
+
* the returned `released` function (or implicitly release on process exit).
|
|
124
|
+
*/
|
|
125
|
+
export function acquireScanLock(projectRoot) {
|
|
126
|
+
const dir = join(String(projectRoot), '.ijfw');
|
|
127
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
128
|
+
const lp = lockPath(projectRoot);
|
|
129
|
+
reclaimIfStale(lp);
|
|
130
|
+
const payload = String(process.pid) + '\n' + String(Date.now()) + '\n';
|
|
131
|
+
try {
|
|
132
|
+
writeFileSync(lp, payload, { encoding: 'utf8', flag: 'wx' });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err && err.code === 'EEXIST') return null;
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
let released = false;
|
|
138
|
+
return {
|
|
139
|
+
released: () => {
|
|
140
|
+
if (released) return;
|
|
141
|
+
released = true;
|
|
142
|
+
try { unlinkSync(lp); } catch { /* best-effort */ }
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function shouldResume(state) {
|
|
148
|
+
if (!state || typeof state !== 'object') return false;
|
|
149
|
+
if (state.incomplete !== true) return false;
|
|
150
|
+
if (!state.started_at || typeof state.started_at !== 'string') return false;
|
|
151
|
+
const startedMs = Date.parse(state.started_at);
|
|
152
|
+
if (!Number.isFinite(startedMs)) return false;
|
|
153
|
+
const ageMs = Date.now() - startedMs;
|
|
154
|
+
if (ageMs > STALENESS_MS) return false;
|
|
155
|
+
const attempts = Number.isFinite(state.attempts) ? state.attempts : 0;
|
|
156
|
+
if (attempts >= ATTEMPT_CAP) return false;
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function clearScanState(projectRoot) {
|
|
161
|
+
const path = statePath(projectRoot);
|
|
162
|
+
if (existsSync(path)) {
|
|
163
|
+
try { unlinkSync(path); } catch { /* best-effort */ }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export const __test = { STALENESS_MS, ATTEMPT_CAP, LOCK_STALE_MS, statePath, lockPath };
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// --- Memory schema versioning (audit R1) ---
|
|
2
|
+
// Changes to memory file structure bump this constant. Readers auto-migrate
|
|
3
|
+
// legacy files on next touch (prepend-only, no data loss). Gives us room
|
|
4
|
+
// to evolve the on-disk format in future waves without silent corruption.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
7
|
+
import { join, dirname, basename } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export const MEMORY_SCHEMA = 'v1';
|
|
10
|
+
export const SCHEMA_HEADER = `<!-- ijfw-schema: ${MEMORY_SCHEMA} -->`;
|
|
11
|
+
export const LEGACY_HEADER_RE = /^<!-- ijfw[- ]schema[:\s][^>]*-->/;
|
|
12
|
+
|
|
13
|
+
export function ensureSchemaHeader(filepath) {
|
|
14
|
+
if (!existsSync(filepath)) {
|
|
15
|
+
writeFileSync(filepath, SCHEMA_HEADER + '\n\n');
|
|
16
|
+
return 'created';
|
|
17
|
+
}
|
|
18
|
+
const cur = readFileSync(filepath, 'utf-8');
|
|
19
|
+
if (cur.startsWith(SCHEMA_HEADER)) return 'current';
|
|
20
|
+
writeFileSync(filepath, SCHEMA_HEADER + '\n\n' + cur);
|
|
21
|
+
return 'migrated';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// W3.2 / ST4 -- corruption recovery.
|
|
25
|
+
// If a memory file is non-zero but fails a structure sanity check,
|
|
26
|
+
// quarantine it to <name>.corrupt.<ts> and seed a fresh file. Returns
|
|
27
|
+
// 'ok' | 'recovered' | 'created'. Called before any read that treats
|
|
28
|
+
// the file as canonical (knowledge.md, handoff.md, project-journal.md).
|
|
29
|
+
//
|
|
30
|
+
// Sanity: file must parse as UTF-8 and either (a) start with an ijfw
|
|
31
|
+
// schema header or (b) be plain markdown with a leading `#` heading.
|
|
32
|
+
// Otherwise we treat it as corrupt and recover. Conservative by design:
|
|
33
|
+
// false positives cost a rename, not data.
|
|
34
|
+
export function recoverIfCorrupt(filepath) {
|
|
35
|
+
if (!existsSync(filepath)) return 'ok';
|
|
36
|
+
let cur;
|
|
37
|
+
try {
|
|
38
|
+
cur = readFileSync(filepath, 'utf-8');
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return quarantine(filepath, cur, `read-failed:${e.code || e.message}`);
|
|
41
|
+
}
|
|
42
|
+
if (!cur) return 'ok'; // empty file is fine
|
|
43
|
+
// Well-formed: schema header OR legacy header OR markdown heading.
|
|
44
|
+
if (cur.startsWith(SCHEMA_HEADER)) return 'ok';
|
|
45
|
+
if (LEGACY_HEADER_RE.test(cur)) return 'ok';
|
|
46
|
+
if (/^\s*#/.test(cur)) return 'ok';
|
|
47
|
+
// Binary-ish? Look for high ratio of non-printable bytes.
|
|
48
|
+
const sample = cur.slice(0, 2048);
|
|
49
|
+
// oxlint-disable-next-line no-control-regex -- intentional: binary corruption detection
|
|
50
|
+
const bad = (sample.match(/[\u0000-\u0008\u000E-\u001F]/g) || []).length;
|
|
51
|
+
if (bad / Math.max(1, sample.length) > 0.02) {
|
|
52
|
+
return quarantine(filepath, cur, 'binary-content');
|
|
53
|
+
}
|
|
54
|
+
// Default: treat as ok -- preserve user's plain-text content even without
|
|
55
|
+
// structure markers. We only recover on true corruption.
|
|
56
|
+
return 'ok';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function quarantine(filepath, content, reason) {
|
|
60
|
+
try {
|
|
61
|
+
const ts = Date.now();
|
|
62
|
+
const quarantinePath = join(dirname(filepath), `${basename(filepath)}.corrupt.${ts}`);
|
|
63
|
+
if (content != null) {
|
|
64
|
+
writeFileSync(quarantinePath, `<!-- ijfw-quarantine reason=${reason} at=${new Date(ts).toISOString()} -->\n\n${content}`);
|
|
65
|
+
} else {
|
|
66
|
+
// Y4 -- renameSync can transiently fail on Windows when an indexer or
|
|
67
|
+
// AV holds a short lock on the file. Retry a few times with 50ms spacing.
|
|
68
|
+
let renamed = false;
|
|
69
|
+
for (let i = 0; i < 4 && !renamed; i++) {
|
|
70
|
+
try { renameSync(filepath, quarantinePath); renamed = true; }
|
|
71
|
+
catch {
|
|
72
|
+
const wait = Date.now() + 50;
|
|
73
|
+
while (Date.now() < wait) { /* busy-wait; sync path */ }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
writeFileSync(filepath, SCHEMA_HEADER + '\n\n');
|
|
78
|
+
return 'recovered';
|
|
79
|
+
} catch {
|
|
80
|
+
return 'ok'; // never block a caller over recovery failure
|
|
81
|
+
}
|
|
82
|
+
}
|