@ijfw/memory-server 1.5.5 → 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.
Files changed (72) hide show
  1. package/bin/ijfw-dashboard +20 -1
  2. package/package.json +4 -3
  3. package/src/audit-roster.js +89 -12
  4. package/src/brain/tiered-llm.js +57 -7
  5. package/src/cross-orchestrator-cli.js +344 -4
  6. package/src/cross-project-search.js +39 -1
  7. package/src/dashboard-server.js +7 -1
  8. package/src/dream/runner.mjs +560 -8
  9. package/src/handlers/brain-handler.js +101 -1
  10. package/src/importers/discover.js +1 -1
  11. package/src/memory/bench-metrics.js +289 -0
  12. package/src/memory/benchmark.js +1 -1
  13. package/src/memory/search.js +53 -1
  14. package/src/orchestrator/plan-checker.js +1 -1
  15. package/src/profile/audit.js +671 -0
  16. package/src/profile/capture.js +871 -0
  17. package/src/profile/derive-dialectic.js +242 -0
  18. package/src/profile/derive-heuristic.js +733 -0
  19. package/src/profile/derive.js +156 -0
  20. package/src/profile/egress.js +306 -0
  21. package/src/profile/eval/build-real-probes.mjs +197 -0
  22. package/src/profile/eval/corpus-from-reddit.mjs +166 -0
  23. package/src/profile/eval/corpus-from-reddit.test.mjs +121 -0
  24. package/src/profile/eval/corpus-from-transcripts.mjs +264 -0
  25. package/src/profile/eval/gate-b-behavior.mjs +420 -0
  26. package/src/profile/eval/gate-b-decision-run.mjs +171 -0
  27. package/src/profile/eval/gate-b-decision-run.test.mjs +141 -0
  28. package/src/profile/eval/gate-b-run.mjs +417 -0
  29. package/src/profile/eval/gate-b-run.test.mjs +204 -0
  30. package/src/profile/eval/gate-c-capture.mjs +323 -0
  31. package/src/profile/eval/harness.mjs +551 -0
  32. package/src/profile/eval/instrument-validation.mjs +248 -0
  33. package/src/profile/eval/instrument-validation.test.mjs +125 -0
  34. package/src/profile/eval/multi-subject-harness.mjs +106 -0
  35. package/src/profile/eval/multi-subject-harness.test.mjs +99 -0
  36. package/src/profile/eval/personas.test.mjs +83 -0
  37. package/src/profile/eval/plumbing.test.mjs +69 -0
  38. package/src/profile/eval/prereg.mjs +130 -0
  39. package/src/profile/eval/prereg.test.mjs +78 -0
  40. package/src/profile/eval/real-corpus.test.mjs +103 -0
  41. package/src/profile/eval/real-personas.mjs +109 -0
  42. package/src/profile/eval/run-real-corpus-concurrent.mjs +407 -0
  43. package/src/profile/eval/run-real-corpus.mjs +358 -0
  44. package/src/profile/eval/slug-quality.mjs +464 -0
  45. package/src/profile/eval/stylometry-features.js +85 -0
  46. package/src/profile/eval/stylometry-reference.js +16 -0
  47. package/src/profile/eval/stylometry.js +224 -0
  48. package/src/profile/eval/stylometry.test.mjs +103 -0
  49. package/src/profile/eval/synthetic-personas.js +91 -0
  50. package/src/profile/eval/verifier-features.mjs +170 -0
  51. package/src/profile/eval/verifier-logreg.mjs +74 -0
  52. package/src/profile/eval/verifier-pair.mjs +122 -0
  53. package/src/profile/eval/verifier-reference.mjs +68 -0
  54. package/src/profile/eval/verifier-scorer.mjs +30 -0
  55. package/src/profile/eval/wrong-target-control.mjs +168 -0
  56. package/src/profile/eval/wrong-target-control.test.mjs +124 -0
  57. package/src/profile/exemplar-capture.js +232 -0
  58. package/src/profile/exemplar-retrieve.js +138 -0
  59. package/src/profile/exemplar-store.js +314 -0
  60. package/src/profile/lock.js +64 -0
  61. package/src/profile/merge.js +624 -0
  62. package/src/profile/path-policy.js +213 -0
  63. package/src/profile/precision-stamp.mjs +151 -0
  64. package/src/profile/render-brief.js +717 -0
  65. package/src/profile/schema.js +244 -0
  66. package/src/profile/sensitivity.js +249 -0
  67. package/src/profile/serve.js +345 -0
  68. package/src/profile/store.js +261 -0
  69. package/src/profile/telemetry.js +289 -0
  70. package/src/recovery/checkpoint.js +7 -1
  71. package/src/server.js +185 -14
  72. 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
- const latest = readLatest(paths.latest);
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
- function handleRecall({ context_hint, detail_level = 'standard', from_project }) {
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
- return { text: `Window ${period}: no sessions yet. Earlier history available -- try period: 'all'.` };
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
- return createResponse(id, {
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
- result = { text: JSON.stringify(r, null, 2), isError: !!refused };
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
- result = { text: stdout || '(no output)', isError: exitCode !== 0 };
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
- return createResponse(id, { resources: [] });
2417
- case 'resources/read':
2418
- return createError(id, -32601, 'No resources available');
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':
@@ -1,3 +0,0 @@
1
- -----BEGIN PUBLIC KEY-----
2
- MCowBQYDK2VwAyEAL2lCdti0bYiFTGUo/hffy+NiBUBXdbDcdaDmjJS27i0=
3
- -----END PUBLIC KEY-----