@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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Cross-platform atomic I/O helpers. Zero deps. POSIX + Windows NTFS.
|
|
2
|
+
//
|
|
3
|
+
// writeAtomic(path, data, opts) -- write to <path>.tmp.<rand>, fsync (POSIX),
|
|
4
|
+
// rename to final. Atomic on same volume on POSIX + NTFS (since Vista).
|
|
5
|
+
// Network FS / FAT32 -- logs warning, writes non-atomically; tolerable.
|
|
6
|
+
//
|
|
7
|
+
// readSafe(path, validator?) -- never throws. Returns {ok, data, error}.
|
|
8
|
+
// If validator(data) returns falsy/throws, returned as no-data path.
|
|
9
|
+
//
|
|
10
|
+
// withLock(lockPath, fn, opts) -- exclusive PID-file lock via O_EXCL.
|
|
11
|
+
// 5s default wait with 50ms retry. On timeout returns {status:'locked', pid}.
|
|
12
|
+
|
|
13
|
+
import { writeFileSync, openSync, closeSync, fsyncSync, renameSync, readFileSync,
|
|
14
|
+
existsSync, mkdirSync, unlinkSync, statSync, chmodSync } from 'node:fs';
|
|
15
|
+
import { dirname, resolve as pathResolve } from 'node:path';
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
import { platform } from 'node:os';
|
|
18
|
+
|
|
19
|
+
const IS_WIN = platform() === 'win32';
|
|
20
|
+
|
|
21
|
+
export function writeAtomic(targetPath, data, opts = {}) {
|
|
22
|
+
const { mode = 0o600, ensureDir = true, fsyncDir = !IS_WIN } = opts;
|
|
23
|
+
const abs = pathResolve(targetPath);
|
|
24
|
+
const dir = dirname(abs);
|
|
25
|
+
|
|
26
|
+
if (ensureDir && !existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
27
|
+
|
|
28
|
+
// Refuse symlinks at target -- supply-chain hygiene per v3 sec 1
|
|
29
|
+
if (existsSync(abs)) {
|
|
30
|
+
try {
|
|
31
|
+
const st = statSync(abs);
|
|
32
|
+
if (st.isSymbolicLink && st.isSymbolicLink()) {
|
|
33
|
+
throw new Error(`refusing to overwrite symlink at ${abs}`);
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (e.message.startsWith('refusing')) throw e;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const tmp = `${abs}.tmp.${randomBytes(8).toString('hex')}`;
|
|
41
|
+
const buf = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
|
|
42
|
+
|
|
43
|
+
let fd;
|
|
44
|
+
try {
|
|
45
|
+
fd = openSync(tmp, 'w', mode);
|
|
46
|
+
writeFileSync(fd, buf);
|
|
47
|
+
if (!IS_WIN) {
|
|
48
|
+
try { fsyncSync(fd); } catch { /* fsync may fail on some FS, tolerable */ }
|
|
49
|
+
}
|
|
50
|
+
closeSync(fd);
|
|
51
|
+
fd = null;
|
|
52
|
+
|
|
53
|
+
renameSync(tmp, abs);
|
|
54
|
+
|
|
55
|
+
if (fsyncDir) {
|
|
56
|
+
try {
|
|
57
|
+
const dfd = openSync(dir, 'r');
|
|
58
|
+
try { fsyncSync(dfd); } catch { /* dir fsync best-effort */ }
|
|
59
|
+
closeSync(dfd);
|
|
60
|
+
} catch { /* dir fsync optional */ }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try { chmodSync(abs, mode); } catch { /* perms best-effort, hot-path validates */ }
|
|
64
|
+
} catch (e) {
|
|
65
|
+
if (fd != null) try { closeSync(fd); } catch { /* */ }
|
|
66
|
+
try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* */ }
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { ok: true, path: abs, bytes: Buffer.byteLength(buf) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readSafe(targetPath, validator) {
|
|
74
|
+
try {
|
|
75
|
+
if (!existsSync(targetPath)) return { ok: false, error: 'enoent' };
|
|
76
|
+
const raw = readFileSync(targetPath, 'utf8');
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return { ok: false, error: 'parse', message: e.message };
|
|
82
|
+
}
|
|
83
|
+
if (typeof validator === 'function') {
|
|
84
|
+
try {
|
|
85
|
+
const ok = validator(parsed);
|
|
86
|
+
if (!ok) return { ok: false, error: 'invalid', data: parsed };
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return { ok: false, error: 'validator', message: e.message, data: parsed };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { ok: true, data: parsed };
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return { ok: false, error: 'io', message: e.message };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function withLock(lockPath, fn, opts = {}) {
|
|
98
|
+
const { timeoutMs = 5000, retryMs = 50 } = opts;
|
|
99
|
+
const abs = pathResolve(lockPath);
|
|
100
|
+
const dir = dirname(abs);
|
|
101
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
102
|
+
|
|
103
|
+
const start = Date.now();
|
|
104
|
+
let acquired = false;
|
|
105
|
+
let heldByPid = null;
|
|
106
|
+
|
|
107
|
+
while (Date.now() - start < timeoutMs) {
|
|
108
|
+
try {
|
|
109
|
+
const fd = openSync(abs, 'wx', 0o600);
|
|
110
|
+
try { writeFileSync(fd, String(process.pid)); } catch { /* */ }
|
|
111
|
+
closeSync(fd);
|
|
112
|
+
acquired = true;
|
|
113
|
+
break;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
if (e.code === 'EEXIST') {
|
|
116
|
+
try {
|
|
117
|
+
const pidStr = readFileSync(abs, 'utf8').trim();
|
|
118
|
+
heldByPid = Number(pidStr) || null;
|
|
119
|
+
// Stale-lock detection -- if PID isn't running, reclaim
|
|
120
|
+
if (heldByPid && !pidAlive(heldByPid)) {
|
|
121
|
+
try { unlinkSync(abs); } catch { /* */ }
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
} catch { /* */ }
|
|
125
|
+
const waited = Date.now() - start;
|
|
126
|
+
const remaining = timeoutMs - waited;
|
|
127
|
+
if (remaining <= 0) break;
|
|
128
|
+
// Synchronous sleep via Atomics
|
|
129
|
+
sleepSync(Math.min(retryMs, remaining));
|
|
130
|
+
} else {
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!acquired) return { status: 'locked', pid: heldByPid };
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const result = fn();
|
|
140
|
+
return { status: 'ok', result };
|
|
141
|
+
} finally {
|
|
142
|
+
try { unlinkSync(abs); } catch { /* */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function pidAlive(pid) {
|
|
147
|
+
try {
|
|
148
|
+
process.kill(pid, 0);
|
|
149
|
+
return true;
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return e.code === 'EPERM';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sleepSync(ms) {
|
|
156
|
+
const sab = new SharedArrayBuffer(4);
|
|
157
|
+
const view = new Int32Array(sab);
|
|
158
|
+
Atomics.wait(view, 0, 0, ms);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Log rotation -- caller of writeAtomic for log files invokes this first.
|
|
162
|
+
// Each log capped at 1MB; rotate to <name>.log.1, delete <name>.log.2.
|
|
163
|
+
export function rotateLogIfNeeded(logPath, maxBytes = 1024 * 1024) {
|
|
164
|
+
try {
|
|
165
|
+
if (!existsSync(logPath)) return false;
|
|
166
|
+
const st = statSync(logPath);
|
|
167
|
+
if (st.size < maxBytes) return false;
|
|
168
|
+
const rot1 = `${logPath}.1`;
|
|
169
|
+
const rot2 = `${logPath}.2`;
|
|
170
|
+
try { if (existsSync(rot2)) unlinkSync(rot2); } catch { /* */ }
|
|
171
|
+
try { if (existsSync(rot1)) renameSync(rot1, rot2); } catch { /* */ }
|
|
172
|
+
try { renameSync(logPath, rot1); } catch { /* */ }
|
|
173
|
+
return true;
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// URL redactor -- strip query strings before logging per v3 sec 15
|
|
180
|
+
export function redactUrl(s) {
|
|
181
|
+
if (typeof s !== 'string') return s;
|
|
182
|
+
return s.replace(/(https?:\/\/[^\s?#]+)\?[^\s]*/g, '$1?<redacted>');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ANSI-strip for safe console output of fetched content (CHANGELOG etc).
|
|
186
|
+
// Strips ESC sequences + bell + non-printable controls except \n \t.
|
|
187
|
+
export function stripAnsi(s) {
|
|
188
|
+
if (typeof s !== 'string') return '';
|
|
189
|
+
// ESC sequences (CSI + OSC + simple ESC + 8-bit CSI)
|
|
190
|
+
// eslint-disable-next-line no-control-regex
|
|
191
|
+
let out = s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
|
|
192
|
+
// eslint-disable-next-line no-control-regex
|
|
193
|
+
.replace(/\x1b\][^\x07]*\x07/g, '')
|
|
194
|
+
// eslint-disable-next-line no-control-regex
|
|
195
|
+
.replace(/\x1b[@-Z\\-_]/g, '')
|
|
196
|
+
// eslint-disable-next-line no-control-regex
|
|
197
|
+
.replace(/\x9b[0-9;?]*[a-zA-Z]/g, '')
|
|
198
|
+
// eslint-disable-next-line no-control-regex
|
|
199
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
|
200
|
+
return out;
|
|
201
|
+
}
|
package/src/lib/cache.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Lightweight TTL cache with mtime-based invalidation. Zero deps.
|
|
2
|
+
//
|
|
3
|
+
// ttlCache(key, ttlMs, mtimeKeyFn, computeFn) -> value
|
|
4
|
+
// mtimeKeyFn is a FUNCTION called lazily -- only invoked when TTL has elapsed or cache is cold.
|
|
5
|
+
// Cache hits within TTL return immediately without invoking mtimeKeyFn or computeFn.
|
|
6
|
+
// mtimeKey: a string-quantizable invalidator (e.g. "<latest mtime>:<file count>").
|
|
7
|
+
// If mtimeKey changes after TTL expiry, cache is busted; if unchanged, cachedAt is refreshed.
|
|
8
|
+
|
|
9
|
+
const store = new Map(); // key -> { value, cachedAt, mtimeKey }
|
|
10
|
+
|
|
11
|
+
export function ttlCache(key, ttlMs, mtimeKeyFn, computeFn) {
|
|
12
|
+
if (process.env.IJFW_DASHBOARD_NO_CACHE === '1') return computeFn();
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
const entry = store.get(key);
|
|
15
|
+
if (entry && (now - entry.cachedAt) < ttlMs) {
|
|
16
|
+
// Inside TTL -- fast path: no fs walk, no invalidator check.
|
|
17
|
+
return entry.value;
|
|
18
|
+
}
|
|
19
|
+
// TTL expired or cache miss -- now invoke the invalidator lazily.
|
|
20
|
+
const mtimeKey = mtimeKeyFn();
|
|
21
|
+
if (entry && entry.mtimeKey === mtimeKey) {
|
|
22
|
+
// Same data -- just refresh cachedAt, skip recompute.
|
|
23
|
+
entry.cachedAt = now;
|
|
24
|
+
return entry.value;
|
|
25
|
+
}
|
|
26
|
+
const value = computeFn();
|
|
27
|
+
store.set(key, { value, cachedAt: now, mtimeKey });
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function clearCache() {
|
|
32
|
+
store.clear();
|
|
33
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// npm view helper -- regex-validated version string fetch with retry + log.
|
|
2
|
+
// Used by both the SessionStart bg check and ijfw_update_check MCP tool.
|
|
3
|
+
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { rotateLogIfNeeded, redactUrl } from './atomic-io.js';
|
|
9
|
+
|
|
10
|
+
const VERSION_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
|
|
11
|
+
const PKG = '@ijfw/install';
|
|
12
|
+
|
|
13
|
+
export async function npmView(pkg = PKG, opts = {}) {
|
|
14
|
+
const { retries = 2, timeoutMs = 10_000, backoffMs = 2000 } = opts;
|
|
15
|
+
let lastErr = null;
|
|
16
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
17
|
+
const res = spawnSync('npm', ['view', pkg, 'version', '--json'], {
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
timeout: timeoutMs,
|
|
20
|
+
// shell:true on Windows so npm.cmd resolves; harmless on POSIX.
|
|
21
|
+
shell: process.platform === 'win32',
|
|
22
|
+
});
|
|
23
|
+
if (res.error) {
|
|
24
|
+
lastErr = `spawn-${res.error.code || 'ERR'}: ${(res.error.message || String(res.error)).slice(0, 120)}`;
|
|
25
|
+
logFailure(pkg, lastErr);
|
|
26
|
+
if (attempt < retries && isTransient(res.error.code)) {
|
|
27
|
+
await sleep(backoffMs);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
return { ok: false, error: 'spawn', message: lastErr };
|
|
31
|
+
}
|
|
32
|
+
if (res.signal) {
|
|
33
|
+
lastErr = `killed by ${res.signal} (likely network timeout)`;
|
|
34
|
+
logFailure(pkg, lastErr);
|
|
35
|
+
if (attempt < retries) {
|
|
36
|
+
await sleep(backoffMs);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
return { ok: false, error: 'signal', message: lastErr };
|
|
40
|
+
}
|
|
41
|
+
if (res.status !== 0) {
|
|
42
|
+
lastErr = (res.stderr || '').trim() || `npm view exited ${res.status} with no stderr`;
|
|
43
|
+
logFailure(pkg, lastErr);
|
|
44
|
+
if (attempt < retries) {
|
|
45
|
+
await sleep(backoffMs);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
return { ok: false, error: 'exit', message: lastErr };
|
|
49
|
+
}
|
|
50
|
+
const raw = (res.stdout || '').trim().replace(/^"|"$/g, '');
|
|
51
|
+
if (!VERSION_RE.test(raw)) {
|
|
52
|
+
logFailure(pkg, `malformed: ${raw.slice(0, 80)}`);
|
|
53
|
+
return { ok: false, error: 'malformed', message: raw.slice(0, 80) };
|
|
54
|
+
}
|
|
55
|
+
return { ok: true, version: raw };
|
|
56
|
+
}
|
|
57
|
+
return { ok: false, error: 'exhausted', message: lastErr };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isTransient(code) {
|
|
61
|
+
return code === 'ENOTFOUND' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ECONNREFUSED';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sleep(ms) {
|
|
65
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function isVersionStringValid(s) {
|
|
69
|
+
return typeof s === 'string' && VERSION_RE.test(s);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function compareSemver(a, b) {
|
|
73
|
+
// Returns -1 if a<b, 0 if eq, 1 if a>b. Pre-release sorts < release.
|
|
74
|
+
const parse = v => {
|
|
75
|
+
const [main, pre] = String(v).split('-', 2);
|
|
76
|
+
const nums = main.split('.').map(n => parseInt(n, 10) || 0);
|
|
77
|
+
while (nums.length < 3) nums.push(0);
|
|
78
|
+
return { nums, pre: pre || null };
|
|
79
|
+
};
|
|
80
|
+
const A = parse(a); const B = parse(b);
|
|
81
|
+
for (let i = 0; i < 3; i++) {
|
|
82
|
+
if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1;
|
|
83
|
+
}
|
|
84
|
+
if (A.pre === B.pre) return 0;
|
|
85
|
+
if (A.pre && !B.pre) return -1;
|
|
86
|
+
if (!A.pre && B.pre) return 1;
|
|
87
|
+
return A.pre < B.pre ? -1 : 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function logRoot() {
|
|
91
|
+
const r = process.env.IJFW_HOME || join(homedir(), '.ijfw');
|
|
92
|
+
const dir = join(r, 'logs');
|
|
93
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
94
|
+
return dir;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function logFailure(pkg, message) {
|
|
98
|
+
try {
|
|
99
|
+
const path = join(logRoot(), 'update-check.log');
|
|
100
|
+
rotateLogIfNeeded(path);
|
|
101
|
+
const line = `${new Date().toISOString()} ${pkg} ${redactUrl(String(message)).slice(0, 200)}\n`;
|
|
102
|
+
appendFileSync(path, line, { mode: 0o600 });
|
|
103
|
+
} catch { /* logging is best-effort */ }
|
|
104
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Shared status-card composer -- one source of truth for the per-turn
|
|
2
|
+
// "[ijfw] context: X% | 1.1.6 available" line that Codex Stop + Gemini
|
|
3
|
+
// AfterAgent + (eventually) other-platform hooks all surface.
|
|
4
|
+
//
|
|
5
|
+
// Inputs:
|
|
6
|
+
// contextPct number 0..100 (optional) -- estimated context-used percentage
|
|
7
|
+
// If null, the bar is omitted; only the update nudge surfaces.
|
|
8
|
+
// ijfwHome string (optional) -- override for ~/.ijfw (testing)
|
|
9
|
+
//
|
|
10
|
+
// Output:
|
|
11
|
+
// string -- single-line card, or '' when nothing useful to surface.
|
|
12
|
+
// Caller is responsible for embedding into the platform-specific envelope
|
|
13
|
+
// (systemMessage / additionalContext / etc.).
|
|
14
|
+
|
|
15
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
|
|
19
|
+
function readJsonSafe(p) {
|
|
20
|
+
try { if (!existsSync(p)) return null; return JSON.parse(readFileSync(p, 'utf8')); }
|
|
21
|
+
catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cmpSemver(a, b) {
|
|
25
|
+
const parse = v => {
|
|
26
|
+
const [main, pre] = String(v).split('-', 2);
|
|
27
|
+
const nums = main.split('.').map(n => parseInt(n, 10) || 0);
|
|
28
|
+
while (nums.length < 3) nums.push(0);
|
|
29
|
+
return { nums, pre: pre || null };
|
|
30
|
+
};
|
|
31
|
+
const A = parse(a); const B = parse(b);
|
|
32
|
+
for (let i = 0; i < 3; i++) {
|
|
33
|
+
if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1;
|
|
34
|
+
}
|
|
35
|
+
if (A.pre === B.pre) return 0;
|
|
36
|
+
if (A.pre && !B.pre) return -1;
|
|
37
|
+
if (!A.pre && B.pre) return 1;
|
|
38
|
+
return A.pre < B.pre ? -1 : 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function composeStatusCard(opts = {}) {
|
|
42
|
+
const root = opts.ijfwHome || process.env.IJFW_HOME || join(homedir(), '.ijfw');
|
|
43
|
+
const state = readJsonSafe(join(root, 'state.json')) || {};
|
|
44
|
+
const cache = readJsonSafe(join(root, 'cache', 'update-check.json')) || {};
|
|
45
|
+
const settings = readJsonSafe(join(root, 'settings.json')) || {};
|
|
46
|
+
|
|
47
|
+
// Update segment with re-entrancy guard (matches statusline + prelude logic)
|
|
48
|
+
let updateSeg = '';
|
|
49
|
+
if (cache.last_latest_seen) {
|
|
50
|
+
const installed = state.installed_version || '0.0.0';
|
|
51
|
+
const lastApplied = state.last_applied_version;
|
|
52
|
+
const stillBehind = cmpSemver(installed, cache.last_latest_seen) < 0;
|
|
53
|
+
const reentrancyOk = !lastApplied || cmpSemver(lastApplied, cache.last_latest_seen) < 0;
|
|
54
|
+
if (stillBehind && reentrancyOk) {
|
|
55
|
+
updateSeg = `${cache.last_latest_seen} available`;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Context segment (only when we have a number)
|
|
60
|
+
let ctxSeg = '';
|
|
61
|
+
const pct = opts.contextPct;
|
|
62
|
+
if (typeof pct === 'number' && isFinite(pct) && pct >= 0 && pct <= 100) {
|
|
63
|
+
const cb = settings.context_bar || {};
|
|
64
|
+
const style = cb.style || 'left';
|
|
65
|
+
const used = Math.round(pct);
|
|
66
|
+
const remaining = Math.max(0, 100 - used);
|
|
67
|
+
ctxSeg =
|
|
68
|
+
style === 'runway' ? `${remaining}% runway` :
|
|
69
|
+
style === 'classic' ? `${used}% used` :
|
|
70
|
+
`${remaining}% left`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!updateSeg && !ctxSeg) return '';
|
|
74
|
+
const parts = [];
|
|
75
|
+
if (ctxSeg) parts.push(`context: ${ctxSeg}`);
|
|
76
|
+
if (updateSeg) parts.push(`update: ${updateSeg}`);
|
|
77
|
+
return `[ijfw] ${parts.join(' | ')}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Estimate context % from a transcript file's byte size.
|
|
81
|
+
// Heuristic shared with Codex's PreCompact workaround in session-end.sh.
|
|
82
|
+
// - Default model context window: 200_000 tokens (Claude Sonnet 4.6 ballpark).
|
|
83
|
+
// - Token estimate: bytes / 3.5 (rough English heuristic).
|
|
84
|
+
// - Returns null when transcript missing or empty.
|
|
85
|
+
export function estimateContextPctFromTranscript(transcriptPath, opts = {}) {
|
|
86
|
+
const { modelContextTokens = 200_000, bytesPerToken = 3.5 } = opts;
|
|
87
|
+
try {
|
|
88
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return null;
|
|
89
|
+
const sz = statSync(transcriptPath).size;
|
|
90
|
+
if (sz <= 0) return null;
|
|
91
|
+
const tokens = sz / bytesPerToken;
|
|
92
|
+
const pct = Math.min(99, Math.max(0, (tokens / modelContextTokens) * 100));
|
|
93
|
+
return pct;
|
|
94
|
+
} catch { return null; }
|
|
95
|
+
}
|
package/src/lib/token.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Crypto-random confirmation token issuance for MCP update flow.
|
|
2
|
+
// Token is short (32 hex chars = 128 bits) and human-typeable.
|
|
3
|
+
// Stored server-side at ~/.ijfw/run/<session>/update-token.json with expiry.
|
|
4
|
+
|
|
5
|
+
import { randomBytes } from 'node:crypto';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { writeAtomic, readSafe } from './atomic-io.js';
|
|
9
|
+
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 min
|
|
12
|
+
|
|
13
|
+
export function generateToken() {
|
|
14
|
+
return randomBytes(16).toString('hex');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sessionDir(sessionId = 'default') {
|
|
18
|
+
// Sanitize -- per v3 sec 2: /^[a-zA-Z0-9_-]{8,64}$/, fallback to 'default'
|
|
19
|
+
const safe = /^[a-zA-Z0-9_-]{8,64}$/.test(sessionId) ? sessionId : 'default-session';
|
|
20
|
+
const root = process.env.IJFW_HOME || join(homedir(), '.ijfw');
|
|
21
|
+
const dir = join(root, 'run', safe);
|
|
22
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function issueToken(sessionId, targetVersion, ttlMs = DEFAULT_TTL_MS) {
|
|
27
|
+
const token = generateToken();
|
|
28
|
+
const expiresAt = Date.now() + ttlMs;
|
|
29
|
+
const dir = sessionDir(sessionId);
|
|
30
|
+
const path = join(dir, 'update-token.json');
|
|
31
|
+
writeAtomic(path, {
|
|
32
|
+
schema_version: 1,
|
|
33
|
+
token,
|
|
34
|
+
target_version: targetVersion,
|
|
35
|
+
issued_at: Date.now(),
|
|
36
|
+
expires_at: expiresAt,
|
|
37
|
+
consumed: false,
|
|
38
|
+
});
|
|
39
|
+
return { token, expires_at: expiresAt, path };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateToken(sessionId, candidateToken) {
|
|
43
|
+
const dir = sessionDir(sessionId);
|
|
44
|
+
const path = join(dir, 'update-token.json');
|
|
45
|
+
const r = readSafe(path, d => d && typeof d.token === 'string');
|
|
46
|
+
if (!r.ok) return { ok: false, error: 'no-token' };
|
|
47
|
+
const t = r.data;
|
|
48
|
+
if (t.consumed) return { ok: false, error: 'already-consumed' };
|
|
49
|
+
if (Date.now() > t.expires_at) return { ok: false, error: 'expired' };
|
|
50
|
+
if (t.token !== candidateToken) return { ok: false, error: 'mismatch' };
|
|
51
|
+
return { ok: true, target_version: t.target_version };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function consumeToken(sessionId) {
|
|
55
|
+
const dir = sessionDir(sessionId);
|
|
56
|
+
const path = join(dir, 'update-token.json');
|
|
57
|
+
const r = readSafe(path);
|
|
58
|
+
if (!r.ok) return false;
|
|
59
|
+
writeAtomic(path, { ...r.data, consumed: true, consumed_at: Date.now() });
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function writePendingSentinel(sessionId, targetVersion, token) {
|
|
64
|
+
const dir = sessionDir(sessionId);
|
|
65
|
+
const path = join(dir, 'update-pending.json');
|
|
66
|
+
writeAtomic(path, {
|
|
67
|
+
schema_version: 1,
|
|
68
|
+
target_version: targetVersion,
|
|
69
|
+
token,
|
|
70
|
+
issued_at: Date.now(),
|
|
71
|
+
});
|
|
72
|
+
return path;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function readPendingSentinel(sessionId) {
|
|
76
|
+
const dir = sessionDir(sessionId);
|
|
77
|
+
const path = join(dir, 'update-pending.json');
|
|
78
|
+
return readSafe(path, d => d && typeof d.token === 'string');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function clearPendingSentinel(sessionId) {
|
|
82
|
+
const dir = sessionDir(sessionId);
|
|
83
|
+
const path = join(dir, 'update-pending.json');
|
|
84
|
+
try { if (existsSync(path)) unlinkSync(path); return true; } catch { return false; }
|
|
85
|
+
}
|