@ijfw/memory-server 1.5.6 → 1.6.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-dashboard +20 -1
- package/package.json +4 -3
- package/src/audit-roster.js +89 -12
- package/src/brain/tiered-llm.js +57 -7
- package/src/cross-orchestrator-cli.js +344 -4
- package/src/cross-project-search.js +39 -1
- package/src/dashboard-server.js +7 -1
- package/src/dream/runner.mjs +560 -8
- package/src/handlers/brain-handler.js +101 -1
- package/src/importers/discover.js +1 -1
- package/src/memory/bench-metrics.js +289 -0
- package/src/memory/benchmark.js +1 -1
- package/src/memory/search.js +53 -1
- package/src/orchestrator/plan-checker.js +1 -1
- package/src/profile/audit.js +671 -0
- package/src/profile/capture.js +871 -0
- package/src/profile/derive-dialectic.js +242 -0
- package/src/profile/derive-heuristic.js +733 -0
- package/src/profile/derive.js +156 -0
- package/src/profile/egress.js +306 -0
- package/src/profile/eval/build-real-probes.mjs +197 -0
- package/src/profile/eval/corpus-from-reddit.mjs +166 -0
- package/src/profile/eval/corpus-from-reddit.test.mjs +121 -0
- package/src/profile/eval/corpus-from-transcripts.mjs +264 -0
- package/src/profile/eval/gate-b-behavior.mjs +420 -0
- package/src/profile/eval/gate-b-decision-run.mjs +171 -0
- package/src/profile/eval/gate-b-decision-run.test.mjs +141 -0
- package/src/profile/eval/gate-b-run.mjs +417 -0
- package/src/profile/eval/gate-b-run.test.mjs +204 -0
- package/src/profile/eval/gate-c-capture.mjs +323 -0
- package/src/profile/eval/harness.mjs +551 -0
- package/src/profile/eval/instrument-validation.mjs +248 -0
- package/src/profile/eval/instrument-validation.test.mjs +125 -0
- package/src/profile/eval/multi-subject-harness.mjs +106 -0
- package/src/profile/eval/multi-subject-harness.test.mjs +99 -0
- package/src/profile/eval/personas.test.mjs +83 -0
- package/src/profile/eval/plumbing.test.mjs +69 -0
- package/src/profile/eval/prereg.mjs +130 -0
- package/src/profile/eval/prereg.test.mjs +78 -0
- package/src/profile/eval/real-corpus.test.mjs +103 -0
- package/src/profile/eval/real-personas.mjs +109 -0
- package/src/profile/eval/run-real-corpus-concurrent.mjs +407 -0
- package/src/profile/eval/run-real-corpus.mjs +358 -0
- package/src/profile/eval/slug-quality.mjs +464 -0
- package/src/profile/eval/stylometry-features.js +85 -0
- package/src/profile/eval/stylometry-reference.js +16 -0
- package/src/profile/eval/stylometry.js +224 -0
- package/src/profile/eval/stylometry.test.mjs +103 -0
- package/src/profile/eval/synthetic-personas.js +91 -0
- package/src/profile/eval/verifier-features.mjs +170 -0
- package/src/profile/eval/verifier-logreg.mjs +74 -0
- package/src/profile/eval/verifier-pair.mjs +122 -0
- package/src/profile/eval/verifier-reference.mjs +68 -0
- package/src/profile/eval/verifier-scorer.mjs +30 -0
- package/src/profile/eval/wrong-target-control.mjs +168 -0
- package/src/profile/eval/wrong-target-control.test.mjs +124 -0
- package/src/profile/exemplar-capture.js +232 -0
- package/src/profile/exemplar-retrieve.js +138 -0
- package/src/profile/exemplar-store.js +314 -0
- package/src/profile/lock.js +64 -0
- package/src/profile/merge.js +624 -0
- package/src/profile/path-policy.js +213 -0
- package/src/profile/precision-stamp.mjs +151 -0
- package/src/profile/render-brief.js +717 -0
- package/src/profile/schema.js +244 -0
- package/src/profile/sensitivity.js +249 -0
- package/src/profile/serve.js +345 -0
- package/src/profile/store.js +261 -0
- package/src/profile/telemetry.js +289 -0
- package/src/recovery/checkpoint.js +7 -1
- package/src/server.js +185 -14
- package/src/.registry-meta-key.pem +0 -3
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* profile/telemetry.js — Cross-system profile bus, S3 (repeat-correction proof).
|
|
3
|
+
*
|
|
4
|
+
* The NO-JUDGE behavioral metric (design spec §"The honest bar", claim 2):
|
|
5
|
+
* "Repeat-correction-rate drop — how often you re-issue the SAME correction,
|
|
6
|
+
* bucketed by session age. A working system bends the curve down (3× in week 1
|
|
7
|
+
* -> 0× by week 4). The most honest single number."
|
|
8
|
+
*
|
|
9
|
+
* This module records, per preference SLUG, every time the user RE-ISSUES a
|
|
10
|
+
* correction that the profile should already have learned, and computes the drop
|
|
11
|
+
* curve across session-age buckets. If injecting a learned preference works, the
|
|
12
|
+
* user stops repeating themselves and the curve bends toward zero.
|
|
13
|
+
*
|
|
14
|
+
* STORE: an append-only JSON-lines ledger `recorrections.log` (sibling of the
|
|
15
|
+
* profile, under the same dir). One object per line:
|
|
16
|
+
* { ts, slug, session, host, age_days? }
|
|
17
|
+
* `ts` is the event time; `age_days` (optional) is the age of the slug at the
|
|
18
|
+
* time of the re-correction — i.e. days since the slug was first learned — and
|
|
19
|
+
* is what we bucket on. When absent, callers can supply a `learnedAt` map to
|
|
20
|
+
* `dropCurve`/`bucketByAge` to derive it from `ts`.
|
|
21
|
+
*
|
|
22
|
+
* The COMPUTE is pure and IO-free (bucketByAge / dropCurve operate on plain
|
|
23
|
+
* arrays) so the metric is unit-testable without touching disk; the append/read
|
|
24
|
+
* helpers mirror egress.js discipline (O_NOFOLLOW, symlink-guarded, size-capped).
|
|
25
|
+
*
|
|
26
|
+
* Zero deps, Node built-ins only. NO LLM calls.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
openSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
fsyncSync,
|
|
33
|
+
closeSync,
|
|
34
|
+
readFileSync,
|
|
35
|
+
existsSync,
|
|
36
|
+
mkdirSync,
|
|
37
|
+
lstatSync,
|
|
38
|
+
constants as fsConstants,
|
|
39
|
+
} from 'node:fs';
|
|
40
|
+
import { join } from 'node:path';
|
|
41
|
+
|
|
42
|
+
import { profileDir } from './store.js';
|
|
43
|
+
|
|
44
|
+
const RECORRECTIONS_FILE = 'recorrections.log';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read-size cap (mirrors egress.js): the ledger is one tiny JSON line per
|
|
48
|
+
* re-correction; a file past this is a corrupt/hand-edited artifact — refuse to
|
|
49
|
+
* slurp it whole rather than OOM. 8 MiB is far above any realistic ledger.
|
|
50
|
+
*/
|
|
51
|
+
const MAX_RECORRECTIONS_BYTES = 8 * 1024 * 1024;
|
|
52
|
+
|
|
53
|
+
/** Default bucket width in days (a "week" bucket). */
|
|
54
|
+
export const DEFAULT_BUCKET_DAYS = 7;
|
|
55
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
56
|
+
|
|
57
|
+
export function recorrectionsLogPath() {
|
|
58
|
+
return join(profileDir(), RECORRECTIONS_FILE);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ensureDir(dir) {
|
|
62
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** True iff `p` exists AND is a symlink (refuse to read/write through links). */
|
|
66
|
+
function isSymlink(p) {
|
|
67
|
+
try {
|
|
68
|
+
return lstatSync(p).isSymbolicLink();
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// IO — append-only ledger (mirrors egress.js).
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* recordRecorrection(entry) -> { ok, entry?, code?, message? }. Appends ONE JSON
|
|
80
|
+
* line { ts, slug, session, host, age_days? }. `ts` defaults to now (ISO).
|
|
81
|
+
* Never throws — telemetry must never break the host path; a logging failure is
|
|
82
|
+
* surfaced via the return shape, not an exception. `slug` is required (a
|
|
83
|
+
* re-correction with no slug is meaningless and is rejected with EBADSLUG).
|
|
84
|
+
*
|
|
85
|
+
* @param {{ slug?:string, session?:string, host?:string, ts?:string, age_days?:number }} entry
|
|
86
|
+
*/
|
|
87
|
+
export function recordRecorrection(entry = {}) {
|
|
88
|
+
const slug = typeof entry.slug === 'string' ? entry.slug : '';
|
|
89
|
+
if (!slug) return { ok: false, code: 'EBADSLUG', message: 'recordRecorrection: slug is required' };
|
|
90
|
+
|
|
91
|
+
const target = recorrectionsLogPath();
|
|
92
|
+
if (isSymlink(target)) {
|
|
93
|
+
return { ok: false, code: 'ERECORR_SYMLINK', message: `refusing symlinked telemetry log: ${target}` };
|
|
94
|
+
}
|
|
95
|
+
const rec = {
|
|
96
|
+
ts: typeof entry.ts === 'string' && entry.ts ? entry.ts : new Date().toISOString(),
|
|
97
|
+
slug,
|
|
98
|
+
session: typeof entry.session === 'string' ? entry.session : null,
|
|
99
|
+
host: typeof entry.host === 'string' ? entry.host : null,
|
|
100
|
+
};
|
|
101
|
+
if (Number.isFinite(entry.age_days)) rec.age_days = Number(entry.age_days);
|
|
102
|
+
|
|
103
|
+
let fd = null;
|
|
104
|
+
try {
|
|
105
|
+
ensureDir(profileDir());
|
|
106
|
+
// O_NOFOLLOW refuses a symlinked target at the kernel (anti-TOCTOU); O_APPEND
|
|
107
|
+
// keeps append atomicity; O_CREAT|0o600 creates owner-only if absent.
|
|
108
|
+
fd = openSync(
|
|
109
|
+
target,
|
|
110
|
+
fsConstants.O_WRONLY | fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_NOFOLLOW,
|
|
111
|
+
0o600,
|
|
112
|
+
);
|
|
113
|
+
writeFileSync(fd, `${JSON.stringify(rec)}\n`, { encoding: 'utf8' });
|
|
114
|
+
fsyncSync(fd);
|
|
115
|
+
return { ok: true, entry: rec };
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (err && err.code === 'ELOOP') {
|
|
118
|
+
return { ok: false, code: 'ERECORR_SYMLINK', message: `refusing symlinked telemetry log: ${target}` };
|
|
119
|
+
}
|
|
120
|
+
return { ok: false, code: err.code || 'ERECORR_WRITE', message: err.message };
|
|
121
|
+
} finally {
|
|
122
|
+
if (fd != null) { try { closeSync(fd); } catch {} }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* readRecorrections() -> { ok, events:[...] }. Reads + parses every JSON line. A
|
|
128
|
+
* missing log -> empty list. Unparseable lines are skipped (append-only audit
|
|
129
|
+
* surface; one bad line must not poison the whole read).
|
|
130
|
+
*/
|
|
131
|
+
export function readRecorrections() {
|
|
132
|
+
const target = recorrectionsLogPath();
|
|
133
|
+
if (isSymlink(target)) return { ok: false, code: 'ERECORR_SYMLINK', events: [] };
|
|
134
|
+
if (!existsSync(target)) return { ok: true, events: [] };
|
|
135
|
+
try {
|
|
136
|
+
const st = lstatSync(target);
|
|
137
|
+
if (st.isFile() && st.size > MAX_RECORRECTIONS_BYTES) {
|
|
138
|
+
return { ok: false, code: 'ERECORR_TOOBIG', message: `telemetry log exceeds ${MAX_RECORRECTIONS_BYTES} bytes`, events: [] };
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
// fall through to read
|
|
142
|
+
}
|
|
143
|
+
let raw;
|
|
144
|
+
try {
|
|
145
|
+
raw = readFileSync(target, 'utf8');
|
|
146
|
+
} catch (err) {
|
|
147
|
+
return { ok: false, code: err.code || 'ERECORR_READ', message: err.message, events: [] };
|
|
148
|
+
}
|
|
149
|
+
const events = [];
|
|
150
|
+
for (const line of raw.split('\n')) {
|
|
151
|
+
const s = line.trim();
|
|
152
|
+
if (!s) continue;
|
|
153
|
+
try {
|
|
154
|
+
events.push(JSON.parse(s));
|
|
155
|
+
} catch {
|
|
156
|
+
// skip a corrupt line — best-effort audit read.
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { ok: true, events };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// COMPUTE — pure, IO-free. Bucket re-corrections by session-age + drop curve.
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve the AGE (in days) of a re-correction event:
|
|
168
|
+
* - prefer an explicit `event.age_days` (the recorder already knew the slug's
|
|
169
|
+
* age at re-correction time),
|
|
170
|
+
* - else derive it from `learnedAt[slug]` -> `event.ts` (days since the slug
|
|
171
|
+
* was first learned),
|
|
172
|
+
* - else null (cannot be bucketed).
|
|
173
|
+
*/
|
|
174
|
+
function ageDaysFor(event, learnedAt) {
|
|
175
|
+
if (Number.isFinite(event.age_days)) return Math.max(0, Number(event.age_days));
|
|
176
|
+
const learned = learnedAt && learnedAt[event.slug];
|
|
177
|
+
if (learned && typeof event.ts === 'string') {
|
|
178
|
+
const l = Date.parse(learned);
|
|
179
|
+
const t = Date.parse(event.ts);
|
|
180
|
+
if (Number.isFinite(l) && Number.isFinite(t)) {
|
|
181
|
+
return Math.max(0, (t - l) / DAY_MS);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The bucket INDEX an age falls into (0 = first window). */
|
|
188
|
+
function bucketIndex(ageDays, bucketDays) {
|
|
189
|
+
return Math.floor(ageDays / bucketDays);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* bucketByAge(events, opts?) -> { perSlug, totals, bucketDays }.
|
|
194
|
+
*
|
|
195
|
+
* Buckets re-correction events by SESSION-AGE (how old the slug was when the user
|
|
196
|
+
* re-issued the correction). Returns, per slug, an array of counts indexed by
|
|
197
|
+
* age bucket, plus a `totals` array summed across all slugs. Events that can't
|
|
198
|
+
* be aged (no age_days and no learnedAt entry) are tallied into `undated` and
|
|
199
|
+
* excluded from the buckets — never silently dropped.
|
|
200
|
+
*
|
|
201
|
+
* @param {Array} events re-correction events { slug, ts, age_days? }
|
|
202
|
+
* @param {{ bucketDays?:number, learnedAt?:Record<string,string>, maxBuckets?:number }} opts
|
|
203
|
+
*/
|
|
204
|
+
export function bucketByAge(events = [], opts = {}) {
|
|
205
|
+
const bucketDays = Number.isFinite(opts.bucketDays) && opts.bucketDays > 0 ? opts.bucketDays : DEFAULT_BUCKET_DAYS;
|
|
206
|
+
const learnedAt = opts.learnedAt || null;
|
|
207
|
+
const list = Array.isArray(events) ? events : [];
|
|
208
|
+
|
|
209
|
+
const perSlug = {};
|
|
210
|
+
let undated = 0;
|
|
211
|
+
let maxIdx = -1;
|
|
212
|
+
|
|
213
|
+
for (const ev of list) {
|
|
214
|
+
if (!ev || typeof ev.slug !== 'string' || !ev.slug) continue;
|
|
215
|
+
const age = ageDaysFor(ev, learnedAt);
|
|
216
|
+
if (age === null) { undated += 1; continue; }
|
|
217
|
+
const idx = bucketIndex(age, bucketDays);
|
|
218
|
+
if (Number.isFinite(opts.maxBuckets) && opts.maxBuckets > 0 && idx >= opts.maxBuckets) continue;
|
|
219
|
+
if (!perSlug[ev.slug]) perSlug[ev.slug] = [];
|
|
220
|
+
perSlug[ev.slug][idx] = (perSlug[ev.slug][idx] || 0) + 1;
|
|
221
|
+
if (idx > maxIdx) maxIdx = idx;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Normalize ragged arrays to a common length (fill holes with 0) so the curve
|
|
225
|
+
// is dense and comparable across slugs.
|
|
226
|
+
const length = maxIdx + 1;
|
|
227
|
+
const totals = Array.from({ length }, () => 0);
|
|
228
|
+
for (const slug of Object.keys(perSlug)) {
|
|
229
|
+
const arr = perSlug[slug];
|
|
230
|
+
for (let i = 0; i < length; i += 1) {
|
|
231
|
+
const v = Number(arr[i]) || 0;
|
|
232
|
+
arr[i] = v;
|
|
233
|
+
totals[i] += v;
|
|
234
|
+
}
|
|
235
|
+
perSlug[slug] = arr;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return { perSlug, totals, undated, bucketDays };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* dropCurve(events, opts?) -> { perSlug, overall, bucketDays }.
|
|
243
|
+
*
|
|
244
|
+
* The headline metric. For each slug (and overall), reports the bucketed counts
|
|
245
|
+
* and a DROP RATIO comparing an early window to a late window:
|
|
246
|
+
* drop = (early - late) / early in [0,1] (1.0 = re-corrections vanished)
|
|
247
|
+
* `early` defaults to bucket 0 (week 1); `late` defaults to the LAST non-empty
|
|
248
|
+
* bucket. A slug with re-corrections in week 1 and none by week 4 yields
|
|
249
|
+
* drop = 1.0 — the curve bent all the way down. When `early` is 0 the drop is
|
|
250
|
+
* null (no baseline to improve on). `trend` is 'down' | 'flat' | 'up'.
|
|
251
|
+
*
|
|
252
|
+
* @param {Array} events
|
|
253
|
+
* @param {{ bucketDays?:number, learnedAt?:Record<string,string>, earlyBucket?:number, lateBucket?:number, maxBuckets?:number }} opts
|
|
254
|
+
*/
|
|
255
|
+
export function dropCurve(events = [], opts = {}) {
|
|
256
|
+
const { perSlug, totals, undated, bucketDays } = bucketByAge(events, opts);
|
|
257
|
+
|
|
258
|
+
const summarize = (counts) => {
|
|
259
|
+
const c = Array.isArray(counts) ? counts.map((x) => Number(x) || 0) : [];
|
|
260
|
+
const earlyIdx = Number.isFinite(opts.earlyBucket) ? opts.earlyBucket : 0;
|
|
261
|
+
let lateIdx;
|
|
262
|
+
if (Number.isFinite(opts.lateBucket)) {
|
|
263
|
+
lateIdx = opts.lateBucket;
|
|
264
|
+
} else {
|
|
265
|
+
// last non-empty bucket; if all-empty, fall back to the last index.
|
|
266
|
+
lateIdx = c.length - 1;
|
|
267
|
+
for (let i = c.length - 1; i >= 0; i -= 1) { if (c[i] > 0) { lateIdx = i; break; } }
|
|
268
|
+
}
|
|
269
|
+
const early = Number(c[earlyIdx]) || 0;
|
|
270
|
+
const late = Number(c[lateIdx]) || 0;
|
|
271
|
+
let drop = null;
|
|
272
|
+
if (early > 0) drop = (early - late) / early;
|
|
273
|
+
let trend = 'flat';
|
|
274
|
+
if (late < early) trend = 'down';
|
|
275
|
+
else if (late > early) trend = 'up';
|
|
276
|
+
const total = c.reduce((a, b) => a + b, 0);
|
|
277
|
+
return { counts: c, early, late, earlyBucket: earlyIdx, lateBucket: lateIdx, drop, trend, total };
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const perSlugOut = {};
|
|
281
|
+
for (const slug of Object.keys(perSlug)) perSlugOut[slug] = summarize(perSlug[slug]);
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
perSlug: perSlugOut,
|
|
285
|
+
overall: summarize(totals),
|
|
286
|
+
undated,
|
|
287
|
+
bucketDays,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -50,7 +50,13 @@ export function recoveryStatus(projectRoot = process.cwd()) {
|
|
|
50
50
|
const team = readTeamAssembly(projectRoot);
|
|
51
51
|
const plan = buildSwarmPlan(projectRoot);
|
|
52
52
|
const tasks = blackboard.tasks.data.tasks || [];
|
|
53
|
-
|
|
53
|
+
// readLatest() returns a wrapper { code, data } — unwrap to the bare
|
|
54
|
+
// checkpoint object (or null) so consumers can read `.id`/`.ts` directly.
|
|
55
|
+
// Previously the wrapper leaked through, so `ijfw recover status` printed
|
|
56
|
+
// "Latest checkpoint: undefined" even when latest.json existed and held a
|
|
57
|
+
// valid checkpoint (latest.id was on latest.data.id, never latest.id).
|
|
58
|
+
const latestRead = readLatest(paths.latest);
|
|
59
|
+
const latest = latestRead.code === 'ok' ? latestRead.data : null;
|
|
54
60
|
return {
|
|
55
61
|
ok: true,
|
|
56
62
|
latest,
|
package/src/server.js
CHANGED
|
@@ -326,6 +326,21 @@ function safeProjectDir() {
|
|
|
326
326
|
const PROJECT_DIR = safeProjectDir();
|
|
327
327
|
const PROJECT_HASH = createHash('sha256').update(PROJECT_DIR).digest('hex').slice(0, 12);
|
|
328
328
|
const IJFW_DIR = join(PROJECT_DIR, '.ijfw');
|
|
329
|
+
|
|
330
|
+
// Tenant isolation (P4): the current project's tenant gates cross-project search
|
|
331
|
+
// so one tenant's memory never surfaces in another's session. Source of truth:
|
|
332
|
+
// IJFW_TENANT env, else `<project>/.ijfw/tenant` (first non-empty line), else
|
|
333
|
+
// 'default'. Default==default => no behavior change until a user opts in.
|
|
334
|
+
function currentTenant() {
|
|
335
|
+
const env = (process.env.IJFW_TENANT || '').trim();
|
|
336
|
+
if (env) return env;
|
|
337
|
+
try {
|
|
338
|
+
const raw = readFileSync(join(IJFW_DIR, 'tenant'), 'utf8');
|
|
339
|
+
const line = raw.split('\n').map((s) => s.trim()).find((s) => s.length > 0);
|
|
340
|
+
if (line) return line;
|
|
341
|
+
} catch { /* no tenant declared */ }
|
|
342
|
+
return 'default';
|
|
343
|
+
}
|
|
329
344
|
// REPO_ROOT is the parent of .ijfw/ — required by resolveBrainPaths.
|
|
330
345
|
const REPO_ROOT = dirname(IJFW_DIR);
|
|
331
346
|
// paths() re-reads the layout sentinel on every call so a long-running server
|
|
@@ -871,7 +886,7 @@ async function searchMemory(query, limit = 10, scope = 'project', opts = {}) {
|
|
|
871
886
|
// crossProjectSearch, not the legacy naive keyword-count scan.
|
|
872
887
|
const projects = readRegistry();
|
|
873
888
|
if (projects.length === 0) return [];
|
|
874
|
-
return crossProjectSearch(query, projects, readProjectMemory, { limit });
|
|
889
|
+
return crossProjectSearch(query, projects, readProjectMemory, { limit, tenant: currentTenant() });
|
|
875
890
|
}
|
|
876
891
|
|
|
877
892
|
const sources = [
|
|
@@ -1215,7 +1230,20 @@ const TOOLS = [
|
|
|
1215
1230
|
|
|
1216
1231
|
// --- Tool Handlers ---
|
|
1217
1232
|
|
|
1218
|
-
|
|
1233
|
+
// v1.6.0 functional-smoke fix — recall is genuinely async: its catch-all
|
|
1234
|
+
// natural-language branch calls `searchMemory` (async: opens the embedding-cache
|
|
1235
|
+
// db + runs the optional cold-tier vector rerank). The prior signature was
|
|
1236
|
+
// synchronous and the branch did `const results = searchMemory(...)` WITHOUT
|
|
1237
|
+
// awaiting, so `results` was a Promise: `results.length === 0` was
|
|
1238
|
+
// `undefined === 0` (false) and `results.map(...)` threw
|
|
1239
|
+
// "results.map is not a function" — making EVERY free-text recall
|
|
1240
|
+
// (exact-keyword AND natural-language) fail at runtime while every unit/route
|
|
1241
|
+
// test still passed (they only exercise the reserved context_hint branches:
|
|
1242
|
+
// session_start / handoff / decisions / facts / design_template, which return
|
|
1243
|
+
// synchronously). Making the function async + awaiting the search closes the
|
|
1244
|
+
// flagship-recall silent break. The single dispatch site (tools/call) already
|
|
1245
|
+
// runs inside an async IIFE, so awaiting the handler is free.
|
|
1246
|
+
async function handleRecall({ context_hint, detail_level = 'standard', from_project }) {
|
|
1219
1247
|
// Cross-project explicit pull. We bypass current-project sources and read
|
|
1220
1248
|
// the target project's knowledge/handoff/journal directly. Search queries
|
|
1221
1249
|
// are routed through crossProjectSearch (BM25) via scope:'all' on the
|
|
@@ -1357,7 +1385,7 @@ function handleRecall({ context_hint, detail_level = 'standard', from_project })
|
|
|
1357
1385
|
}
|
|
1358
1386
|
}
|
|
1359
1387
|
|
|
1360
|
-
const results = searchMemory(context_hint);
|
|
1388
|
+
const results = await searchMemory(context_hint);
|
|
1361
1389
|
if (results.length === 0) return { text: `No memories matching: ${context_hint}` };
|
|
1362
1390
|
return { text: results.map(r => `[${r.source}] ${r.content}`).join('\n') };
|
|
1363
1391
|
}
|
|
@@ -1949,7 +1977,7 @@ function handleCrossProjectSearch({ pattern, limit = 10 } = {}) {
|
|
|
1949
1977
|
if (projects.length === 0) {
|
|
1950
1978
|
return { text: 'No other IJFW projects on record. Open one more project to enable cross-project search.' };
|
|
1951
1979
|
}
|
|
1952
|
-
const hits = crossProjectSearch(pattern, projects, readProjectMemory, { limit });
|
|
1980
|
+
const hits = crossProjectSearch(pattern, projects, readProjectMemory, { limit, tenant: currentTenant() });
|
|
1953
1981
|
if (hits.length === 0) {
|
|
1954
1982
|
return { text: `No matches for "${pattern}" across ${projects.length} project${projects.length === 1 ? '' : 's'}.` };
|
|
1955
1983
|
}
|
|
@@ -1988,7 +2016,12 @@ function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
|
|
|
1988
2016
|
return Number.isFinite(t) && t >= cutoff;
|
|
1989
2017
|
});
|
|
1990
2018
|
if (within.length === 0) {
|
|
1991
|
-
|
|
2019
|
+
// Don't suggest 'all' when the caller is already on 'all' (the rows simply
|
|
2020
|
+
// carry no parseable timestamp in that case).
|
|
2021
|
+
const hint = period === 'all'
|
|
2022
|
+
? ` ${rows.length} record(s) on disk but none carry a parseable timestamp.`
|
|
2023
|
+
: ` Earlier history available -- try period: 'all'.`;
|
|
2024
|
+
return { text: `Window ${period}: no sessions in range.${hint}` };
|
|
1992
2025
|
}
|
|
1993
2026
|
|
|
1994
2027
|
if (metric === 'sessions') {
|
|
@@ -2059,12 +2092,58 @@ function handleMessage(msg) {
|
|
|
2059
2092
|
const { method, params, id } = msg;
|
|
2060
2093
|
|
|
2061
2094
|
switch (method) {
|
|
2062
|
-
case 'initialize':
|
|
2063
|
-
|
|
2095
|
+
case 'initialize': {
|
|
2096
|
+
// v1.6.0 PERSONALIZATION S1 — OPTIONAL `instructions` field. The MCP
|
|
2097
|
+
// `instructions` string is surfaced by hosts as ambient context the moment
|
|
2098
|
+
// they connect, so it is the cross-tool "it followed me" surface: the same
|
|
2099
|
+
// observed-pattern brief the Resource path serves, delivered without an
|
|
2100
|
+
// explicit read. It is GATED EXACTLY like the passive Resource read
|
|
2101
|
+
// (resources/read above): only when settings.profile.inject === 'on' AND
|
|
2102
|
+
// IJFW_PROFILE_KILL is not engaged, and ALWAYS forceLowOnly (a passive
|
|
2103
|
+
// injection carries no per-read consent, so LOW sensitivity only — the
|
|
2104
|
+
// STYLE + EXPERTISE bands, never preference slugs). Best-effort: any error
|
|
2105
|
+
// omits `instructions` and returns the plain initialize result — the field
|
|
2106
|
+
// is strictly additive and must NEVER break protocol handshake.
|
|
2107
|
+
const base = {
|
|
2064
2108
|
protocolVersion: '2024-11-05',
|
|
2065
2109
|
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
2066
2110
|
serverInfo: { name: 'ijfw-memory', version: PKG_VERSION, schemaVersion: SCHEMA_VERSION }
|
|
2067
|
-
}
|
|
2111
|
+
};
|
|
2112
|
+
return (async () => {
|
|
2113
|
+
try {
|
|
2114
|
+
// INJECT GATE — identical resolution to resources/read so the consent
|
|
2115
|
+
// semantics are the same across every passive-injection surface.
|
|
2116
|
+
let injectOn = false;
|
|
2117
|
+
try {
|
|
2118
|
+
const fs = await import('node:fs');
|
|
2119
|
+
const path = await import('node:path');
|
|
2120
|
+
const home = process.env.IJFW_HOME || path.join(process.env.HOME || '', '.ijfw');
|
|
2121
|
+
const s = JSON.parse(fs.readFileSync(path.join(home, 'settings.json'), 'utf8'));
|
|
2122
|
+
injectOn = s && s.profile && s.profile.inject === 'on';
|
|
2123
|
+
} catch { injectOn = false; }
|
|
2124
|
+
const kill = String(process.env.IJFW_PROFILE_KILL || '').trim().toLowerCase();
|
|
2125
|
+
const killed = kill !== '' && kill !== '0' && kill !== 'false' && kill !== 'no' && kill !== 'off';
|
|
2126
|
+
if (!injectOn || killed) {
|
|
2127
|
+
return createResponse(id, base);
|
|
2128
|
+
}
|
|
2129
|
+
const { profileBrief } = await import('./profile/serve.js');
|
|
2130
|
+
// forceLowOnly: passive injection => LOW-sensitivity ONLY, always —
|
|
2131
|
+
// no env flag / allowlist entry can elevate a handshake-time brief.
|
|
2132
|
+
const r = profileBrief({
|
|
2133
|
+
context: { host: 'mcp-initialize' },
|
|
2134
|
+
env: process.env,
|
|
2135
|
+
forceLowOnly: true,
|
|
2136
|
+
});
|
|
2137
|
+
const instructions = r && typeof r.brief === 'string' ? r.brief : '';
|
|
2138
|
+
// Empty brief (cold start / nothing earned) => omit the field entirely
|
|
2139
|
+
// so the handshake stays byte-identical to the un-personalized server.
|
|
2140
|
+
if (instructions) base.instructions = instructions;
|
|
2141
|
+
return createResponse(id, base);
|
|
2142
|
+
} catch {
|
|
2143
|
+
return createResponse(id, base);
|
|
2144
|
+
}
|
|
2145
|
+
})();
|
|
2146
|
+
}
|
|
2068
2147
|
|
|
2069
2148
|
case 'notifications/initialized':
|
|
2070
2149
|
case 'notifications/cancelled':
|
|
@@ -2164,8 +2243,22 @@ function handleMessage(msg) {
|
|
|
2164
2243
|
// surface `isError: true` so the orchestrator-LLM treats it as a
|
|
2165
2244
|
// hard stop rather than an advisory note (mirrors the prior
|
|
2166
2245
|
// ijfw_subagent_post_done `block: true` contract).
|
|
2246
|
+
//
|
|
2247
|
+
// Functional-smoke fix (workflow-state dig): `isError` must also
|
|
2248
|
+
// track the SDK's primary `ok` contract, not just `refused`. Every
|
|
2249
|
+
// query() result carries `ok` (contract §7); a verb can return a
|
|
2250
|
+
// genuine `{ ok:false }` WITHOUT `refused` (e.g. roster.synthesize
|
|
2251
|
+
// on an unknown domain -> `{ ok:false, reason:'domain-template-
|
|
2252
|
+
// missing' }`). Keying `isError` on `refused` alone reported those
|
|
2253
|
+
// real failures to the orchestrator-LLM as SUCCESS — the same
|
|
2254
|
+
// silent-swallow class as the memory-recall regression, and a
|
|
2255
|
+
// cross-surface inconsistency with the CLI (cli-run.js exits
|
|
2256
|
+
// non-zero on any `ok:false`). Widen the trigger to `ok === false
|
|
2257
|
+
// || refused` so the MCP surface, the CLI surface, and the `ok`
|
|
2258
|
+
// contract all agree.
|
|
2167
2259
|
const refused = r && r.refused === true;
|
|
2168
|
-
|
|
2260
|
+
const failed = !r || r.ok === false;
|
|
2261
|
+
result = { text: JSON.stringify(r, null, 2), isError: failed || refused };
|
|
2169
2262
|
} catch (err) {
|
|
2170
2263
|
const msg = err && err.message ? err.message : String(err);
|
|
2171
2264
|
result = { text: JSON.stringify({ ok: false, error: msg }), isError: true };
|
|
@@ -2289,7 +2382,7 @@ function handleMessage(msg) {
|
|
|
2289
2382
|
break;
|
|
2290
2383
|
}
|
|
2291
2384
|
case 'ijfw_memory_recall':
|
|
2292
|
-
result = handleRecall(args || {});
|
|
2385
|
+
result = await handleRecall(args || {});
|
|
2293
2386
|
emitRecallObservation(args || {});
|
|
2294
2387
|
break;
|
|
2295
2388
|
case 'ijfw_memory_store':
|
|
@@ -2377,7 +2470,15 @@ function handleMessage(msg) {
|
|
|
2377
2470
|
const INLINE_BYTES = 50 * 1024;
|
|
2378
2471
|
|
|
2379
2472
|
if (lines <= INLINE_LINES && bytes <= INLINE_BYTES && !timedOut) {
|
|
2380
|
-
|
|
2473
|
+
// On failure, annotate so the human sees WHY even when the command
|
|
2474
|
+
// produced no output (e.g. `exit 7` with empty stdout/stderr). The
|
|
2475
|
+
// isError flag already signals failure to the client; this surfaces
|
|
2476
|
+
// the exit code in the text too.
|
|
2477
|
+
const body = stdout || '(no output)';
|
|
2478
|
+
result = {
|
|
2479
|
+
text: exitCode !== 0 ? `${body}\n[ijfw_run] command exited ${exitCode}` : body,
|
|
2480
|
+
isError: exitCode !== 0,
|
|
2481
|
+
};
|
|
2381
2482
|
break;
|
|
2382
2483
|
}
|
|
2383
2484
|
|
|
@@ -2413,9 +2514,79 @@ function handleMessage(msg) {
|
|
|
2413
2514
|
}
|
|
2414
2515
|
|
|
2415
2516
|
case 'resources/list':
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2517
|
+
// PHASE P4.6 — expose the user-global profile brief as an MCP Resource
|
|
2518
|
+
// for PASSIVE injection. A host that supports resource subscription can
|
|
2519
|
+
// read this without an explicit tool call, surfacing the observed-pattern
|
|
2520
|
+
// brief into context. cacheScope:'session' marks it host-session-cacheable
|
|
2521
|
+
// (the brief changes only at SessionEnd re-derivation). The read path is
|
|
2522
|
+
// ZERO-LLM (serve.js moat).
|
|
2523
|
+
return createResponse(id, {
|
|
2524
|
+
resources: [
|
|
2525
|
+
{
|
|
2526
|
+
uri: 'ijfw://profile/brief',
|
|
2527
|
+
name: 'IJFW user profile brief',
|
|
2528
|
+
description: 'Observed cross-system interaction patterns (informative, not directive). Sensitivity-gated, redaction-honored, zero-LLM.',
|
|
2529
|
+
mimeType: 'text/markdown',
|
|
2530
|
+
cacheScope: 'session',
|
|
2531
|
+
},
|
|
2532
|
+
],
|
|
2533
|
+
});
|
|
2534
|
+
case 'resources/read': {
|
|
2535
|
+
const uri = params && params.uri;
|
|
2536
|
+
if (uri === 'ijfw://profile/brief') {
|
|
2537
|
+
// ZERO-LLM read: profileBrief reads the store, composes via render-brief
|
|
2538
|
+
// (sensitivity + redaction + kill-switch enforced), and logs egress.
|
|
2539
|
+
// Cold start -> empty brief, never an error. Wrapped in an async IIFE
|
|
2540
|
+
// (handleMessage is sync but may return a Promise — same pattern as
|
|
2541
|
+
// tools/call) because serve.js is dynamically imported.
|
|
2542
|
+
return (async () => {
|
|
2543
|
+
try {
|
|
2544
|
+
// v1.6.0 INJECT GATE — a passive Resource read is an injection, so it
|
|
2545
|
+
// honors the same profile.inject consent the hooks use. Only inject
|
|
2546
|
+
// when the resolved setting is "on" (IJFW_PROFILE_KILL forces off;
|
|
2547
|
+
// "ask"/"off" serve an empty brief). Reading settings here keeps the
|
|
2548
|
+
// gate consistent across the hook path and the MCP-resource path.
|
|
2549
|
+
let injectOn = false;
|
|
2550
|
+
try {
|
|
2551
|
+
const fs = await import('node:fs');
|
|
2552
|
+
const path = await import('node:path');
|
|
2553
|
+
const home = process.env.IJFW_HOME || path.join(process.env.HOME || '', '.ijfw');
|
|
2554
|
+
const s = JSON.parse(fs.readFileSync(path.join(home, 'settings.json'), 'utf8'));
|
|
2555
|
+
injectOn = s && s.profile && s.profile.inject === 'on';
|
|
2556
|
+
} catch { injectOn = false; }
|
|
2557
|
+
const kill = String(process.env.IJFW_PROFILE_KILL || '').trim().toLowerCase();
|
|
2558
|
+
const killed = kill !== '' && kill !== '0' && kill !== 'false' && kill !== 'no' && kill !== 'off';
|
|
2559
|
+
if (!injectOn || killed) {
|
|
2560
|
+
return createResponse(id, {
|
|
2561
|
+
contents: [{ uri, mimeType: 'text/markdown', text: '' }],
|
|
2562
|
+
});
|
|
2563
|
+
}
|
|
2564
|
+
const { profileBrief } = await import('./profile/serve.js');
|
|
2565
|
+
// MED-3: a passive MCP Resource read can carry NO per-read user
|
|
2566
|
+
// consent, so it must serve LOW-sensitivity ONLY — always. We force
|
|
2567
|
+
// it here (forceLowOnly) so no env flag / allowlist entry can ever
|
|
2568
|
+
// elevate a passive injection past the low tier.
|
|
2569
|
+
const r = profileBrief({
|
|
2570
|
+
context: { host: 'mcp-resource' },
|
|
2571
|
+
env: process.env,
|
|
2572
|
+
forceLowOnly: true,
|
|
2573
|
+
});
|
|
2574
|
+
return createResponse(id, {
|
|
2575
|
+
contents: [
|
|
2576
|
+
{
|
|
2577
|
+
uri,
|
|
2578
|
+
mimeType: 'text/markdown',
|
|
2579
|
+
text: String(r && r.brief ? r.brief : ''),
|
|
2580
|
+
},
|
|
2581
|
+
],
|
|
2582
|
+
});
|
|
2583
|
+
} catch (err) {
|
|
2584
|
+
return createError(id, -32603, `profile brief read failed: ${err.message}`);
|
|
2585
|
+
}
|
|
2586
|
+
})();
|
|
2587
|
+
}
|
|
2588
|
+
return createError(id, -32601, `Unknown resource: ${uri || '(none)'}`);
|
|
2589
|
+
}
|
|
2419
2590
|
case 'resources/templates/list':
|
|
2420
2591
|
return createResponse(id, { resourceTemplates: [] });
|
|
2421
2592
|
case 'prompts/list':
|