@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.
Files changed (106) hide show
  1. package/bin/ijfw +27 -0
  2. package/bin/ijfw-dashboard +180 -0
  3. package/bin/ijfw-dispatch-plan +41 -0
  4. package/bin/ijfw-memorize +273 -0
  5. package/bin/ijfw-memory +51 -0
  6. package/fixtures/demo-target.js +28 -0
  7. package/package.json +53 -0
  8. package/src/api-client.js +190 -0
  9. package/src/audit-roster.js +315 -0
  10. package/src/caps.js +37 -0
  11. package/src/cold-scan-runner.mjs +37 -0
  12. package/src/compute/edges.js +155 -0
  13. package/src/compute/extract.js +560 -0
  14. package/src/compute/fts5.js +420 -0
  15. package/src/compute/graph-auto-index.js +191 -0
  16. package/src/compute/graph-lock.js +114 -0
  17. package/src/compute/index.js +18 -0
  18. package/src/compute/migration-runner.js +116 -0
  19. package/src/compute/migrations/001-initial.js +23 -0
  20. package/src/compute/migrations/002-porter-stemming-source.js +139 -0
  21. package/src/compute/migrations/003-tier-semantic.js +69 -0
  22. package/src/compute/migrations/004-kg-tables.js +83 -0
  23. package/src/compute/migrations/005-stale-candidate.js +72 -0
  24. package/src/compute/python-resolver.js +106 -0
  25. package/src/compute/runner-vm.js +185 -0
  26. package/src/compute/runner.js +416 -0
  27. package/src/compute/sandbox-detect.js +122 -0
  28. package/src/compute/sandbox-linux.js +164 -0
  29. package/src/compute/sandbox-macos.js +167 -0
  30. package/src/compute/sandbox-windows.js +63 -0
  31. package/src/compute/schema.sql +118 -0
  32. package/src/compute/staleness.js +239 -0
  33. package/src/compute/synonyms.js +367 -0
  34. package/src/compute/traverse.js +180 -0
  35. package/src/cost/aggregator.js +229 -0
  36. package/src/cost/pricing.js +134 -0
  37. package/src/cost/readers/claude.js +179 -0
  38. package/src/cost/readers/codex.js +131 -0
  39. package/src/cost/readers/gemini.js +111 -0
  40. package/src/cost/savings.js +243 -0
  41. package/src/cross-dispatcher.js +437 -0
  42. package/src/cross-orchestrator-cli.js +1885 -0
  43. package/src/cross-orchestrator.js +598 -0
  44. package/src/cross-project-search.js +114 -0
  45. package/src/dashboard-client.html +1180 -0
  46. package/src/dashboard-server.js +895 -0
  47. package/src/design-companion.js +81 -0
  48. package/src/dispatch/colon-syntax.js +732 -0
  49. package/src/dispatch-planner.js +235 -0
  50. package/src/dream/cooldown.js +105 -0
  51. package/src/dream/runner.mjs +373 -0
  52. package/src/dream/staleness-wiring.js +195 -0
  53. package/src/feedback-detector.js +57 -0
  54. package/src/hero-line.js +115 -0
  55. package/src/importers/claude-mem.js +152 -0
  56. package/src/importers/cli.js +311 -0
  57. package/src/importers/common.js +84 -0
  58. package/src/importers/discover.js +235 -0
  59. package/src/importers/rtk.js +107 -0
  60. package/src/intent-router.js +221 -0
  61. package/src/lib/atomic-io.js +201 -0
  62. package/src/lib/cache.js +33 -0
  63. package/src/lib/npm-view.js +104 -0
  64. package/src/lib/status-card.js +95 -0
  65. package/src/lib/token.js +85 -0
  66. package/src/memory/fts5.js +349 -0
  67. package/src/memory/migration-runner.js +116 -0
  68. package/src/memory/migrations/001-fts5-init.js +26 -0
  69. package/src/memory/migrations/002-tier-semantic.js +60 -0
  70. package/src/memory/migrations/003-stale-candidate.js +60 -0
  71. package/src/memory/reader.js +300 -0
  72. package/src/memory/recall-counter.js +76 -0
  73. package/src/memory/schema.sql +79 -0
  74. package/src/memory/search.js +431 -0
  75. package/src/memory/staleness.js +237 -0
  76. package/src/memory/tier-promotion.js +377 -0
  77. package/src/memory/tokenize.js +63 -0
  78. package/src/project-type-detector.js +866 -0
  79. package/src/prompt-check.js +171 -0
  80. package/src/ralph-allowlist.js +88 -0
  81. package/src/receipts.js +129 -0
  82. package/src/redactor.js +107 -0
  83. package/src/sandbox.js +275 -0
  84. package/src/sanitizer.js +69 -0
  85. package/src/scan-resume.js +167 -0
  86. package/src/schema.js +82 -0
  87. package/src/search-bm25.js +108 -0
  88. package/src/server.js +1414 -0
  89. package/src/swarm-config.js +80 -0
  90. package/src/trident/dispatch.js +211 -0
  91. package/src/trident/lens-health.js +253 -0
  92. package/src/update-apply.js +79 -0
  93. package/src/update-check.js +136 -0
  94. package/src/vectors.js +178 -0
  95. package/templates/design/bento-grid.md +84 -0
  96. package/templates/design/brutalist-luxe.md +82 -0
  97. package/templates/design/cinematic-dark.md +82 -0
  98. package/templates/design/data-dense-dashboard.md +88 -0
  99. package/templates/design/editorial-warm.md +81 -0
  100. package/templates/design/glassmorphic.md +84 -0
  101. package/templates/design/magazine-editorial.md +84 -0
  102. package/templates/design/maximalist-vibrant.md +85 -0
  103. package/templates/design/neo-swiss-tech.md +85 -0
  104. package/templates/design/swiss-minimal.md +80 -0
  105. package/templates/design/terminal-native.md +83 -0
  106. package/templates/design/warm-organic.md +84 -0
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env node
2
+ // IJFW v1.3.0 Alpha -- D3 dream-cycle runner.
3
+ //
4
+ // Replaces the legacy 5-session deferral
5
+ // (SESSION_NUM % 5 == 0 -> .ijfw/.startup-flags) with INLINE
6
+ // consolidation at SessionEnd via detached spawn. Mirrors the Phase 3
7
+ // cold-scan-runner.mjs contract: tiny CLI adapter, fire-and-forget,
8
+ // best-effort. Any throw is swallowed so a broken dream cycle never
9
+ // surfaces a non-zero exit to the parent SessionEnd hook.
10
+ //
11
+ // Sequence per run:
12
+ // 1. Cooldown check (4h via .ijfw/.dream-state.json) -- skip on hit.
13
+ // 2. Optional: invoke D1's tier-promotion module when present
14
+ // (Working -> Episodic, Episodic -> Semantic, Working -> Procedural
15
+ // per D-PILLAR-SPEC.md). Absent during D1 build window -> log and
16
+ // degrade to a "no promotion module" line.
17
+ // 3. Optional: invoke the existing project-journal -> knowledge.md
18
+ // consolidation policy (claude/commands/consolidate.md). When the
19
+ // Claude command itself runs as the consolidator (host=claude),
20
+ // runner.mjs records the dream-cycle attempt and returns; the
21
+ // heavy work happens in the slash command. For Codex/Gemini/
22
+ // Wayland/Hermes hosts, runner.mjs is the only consolidator.
23
+ // 4. Mark completion via cooldown.markCompleted() so the next
24
+ // SessionEnd within 4h is skipped.
25
+ //
26
+ // All output lands in `<repoRoot>/.ijfw/logs/dream-<timestamp>.log`.
27
+ //
28
+ // Usage:
29
+ // node runner.mjs --project-root <path> [--host <name>] [--reason <txt>]
30
+ //
31
+ // Discipline:
32
+ // - ESM, zero deps.
33
+ // - LC_ALL=C semantics (we only emit ASCII).
34
+ // - process.exit(0) on every code path -- the parent hook depends on
35
+ // a clean exit even for cooldown skips.
36
+
37
+ import { existsSync, mkdirSync, appendFileSync, readFileSync } from 'node:fs';
38
+ import { join, dirname } from 'node:path';
39
+ import { fileURLToPath, pathToFileURL } from 'node:url';
40
+ import { isOnCooldown, markCompleted } from './cooldown.js';
41
+
42
+ const __filename = fileURLToPath(import.meta.url);
43
+ const __dirname = dirname(__filename);
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // argv parsing
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const argv = process.argv.slice(2);
50
+ const opts = {
51
+ projectRoot: null,
52
+ host: 'unknown',
53
+ reason: 'session_end',
54
+ sessionId: process.env.IJFW_SESSION_ID || null,
55
+ };
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const a = argv[i];
58
+ if (a === '--project-root') opts.projectRoot = argv[++i];
59
+ else if (a === '--host') opts.host = argv[++i];
60
+ else if (a === '--reason') opts.reason = argv[++i];
61
+ else if (a === '--session-id') opts.sessionId = argv[++i];
62
+ }
63
+
64
+ if (!opts.projectRoot) process.exit(0);
65
+
66
+ const stateDir = join(opts.projectRoot, '.ijfw');
67
+ const logDir = join(stateDir, 'logs');
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // log helper -- best-effort, never throws
71
+ // ---------------------------------------------------------------------------
72
+
73
+ function isoStamp() {
74
+ return new Date().toISOString();
75
+ }
76
+
77
+ function logFilename() {
78
+ // dream-2026-05-08T10-15-32Z.log
79
+ return `dream-${isoStamp().replace(/[:]/g, '-')}.log`;
80
+ }
81
+
82
+ const LOG_PATH = join(logDir, logFilename());
83
+
84
+ function log(line) {
85
+ try {
86
+ mkdirSync(logDir, { recursive: true });
87
+ appendFileSync(LOG_PATH, `[${isoStamp()}] ${line}\n`, 'utf8');
88
+ } catch {
89
+ // never throw out of the runner
90
+ }
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Cooldown gate
95
+ // ---------------------------------------------------------------------------
96
+
97
+ if (isOnCooldown(stateDir)) {
98
+ log(`skip: cooldown active (host=${opts.host}, reason=${opts.reason})`);
99
+ process.exit(0);
100
+ }
101
+
102
+ log(`start: host=${opts.host}, reason=${opts.reason}, project=${opts.projectRoot}`);
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Step 1: D1 tier-promotion (when available)
106
+ // ---------------------------------------------------------------------------
107
+ //
108
+ // D1 (Wave 2 Agent E) lands `mcp-server/src/memory/tier-promotion.js`
109
+ // with the deterministic promotion rules from D-PILLAR-SPEC.md §1. We
110
+ // import-by-URL so a missing module is a soft skip rather than a top-
111
+ // level ESM resolve failure.
112
+ //
113
+ // Expected surface (per D-PILLAR-SPEC.md + brief):
114
+ // export async function runTierPromotion({ projectRoot, log })
115
+ // -> { promoted, superseded, skipped }
116
+ //
117
+ // Any contract drift is caught and logged; the dream cycle still
118
+ // continues to step 2.
119
+
120
+ async function runTierPromotion() {
121
+ // Resolve tier-promotion module (D1, Agent E). Search both the
122
+ // bundled mcp-server/src/memory layout and the repo-local layout so
123
+ // the runner works whether it's invoked from ~/.ijfw or the dev repo.
124
+ const candidates = [
125
+ join(__dirname, '..', 'memory', 'tier-promotion.js'),
126
+ join(opts.projectRoot, 'mcp-server', 'src', 'memory', 'tier-promotion.js'),
127
+ ];
128
+ let modulePath = null;
129
+ for (const cand of candidates) {
130
+ if (existsSync(cand)) { modulePath = cand; break; }
131
+ }
132
+ if (!modulePath) {
133
+ log('tier-promotion: module not present (D1 in flight) -- skip step');
134
+ return null;
135
+ }
136
+
137
+ // Resolve the matching fts5.js (provides openDb / closeDb).
138
+ const fts5Path = join(dirname(modulePath), 'fts5.js');
139
+ let openDb, closeDb;
140
+ try {
141
+ const fts5 = await import(pathToFileURL(fts5Path).href);
142
+ openDb = fts5.openDb;
143
+ closeDb = fts5.closeDb;
144
+ } catch (err) {
145
+ log(`tier-promotion: fts5 module not loadable (${err && err.message ? err.message : err}) -- skip step`);
146
+ return null;
147
+ }
148
+ if (typeof openDb !== 'function' || typeof closeDb !== 'function') {
149
+ log('tier-promotion: fts5 module missing openDb/closeDb -- skip step');
150
+ return null;
151
+ }
152
+
153
+ // Load the promotion module and dispatch all four promotions in
154
+ // sequence. Each promotion is wrapped individually so a single
155
+ // failure (e.g. supersession scan throws on bad data) doesn't abort
156
+ // the others.
157
+ let mod;
158
+ try {
159
+ mod = await import(pathToFileURL(modulePath).href);
160
+ } catch (err) {
161
+ log(`tier-promotion: import failed: ${err && err.message ? err.message : err}`);
162
+ return null;
163
+ }
164
+
165
+ // Two-shape contract:
166
+ // 1. New shape: `runTierPromotion({ projectRoot, log })` (preferred,
167
+ // future-proof if Agent E refactors to a single entry point).
168
+ // 2. Current shape (Agent E v1): named exports
169
+ // `promoteWorkingToEpisodic(db)` + `promoteEpisodicToSemantic(db)`
170
+ // that take a DB handle.
171
+ if (typeof mod.runTierPromotion === 'function') {
172
+ try {
173
+ const out = await mod.runTierPromotion({ projectRoot: opts.projectRoot, log });
174
+ log(`tier-promotion: ok ${JSON.stringify(out || {})}`);
175
+ return out;
176
+ } catch (err) {
177
+ log(`tier-promotion: runTierPromotion failed: ${err && err.message ? err.message : err}`);
178
+ return null;
179
+ }
180
+ }
181
+
182
+ // Per-export shape -- open DB, dispatch, close.
183
+ let db;
184
+ try {
185
+ db = await openDb(opts.projectRoot);
186
+ } catch (err) {
187
+ // No DB yet (fresh project, schema not initialised). Treat as
188
+ // skip rather than failure -- consolidation has nothing to do.
189
+ log(`tier-promotion: db open skipped (${err && err.message ? err.message : err})`);
190
+ return null;
191
+ }
192
+
193
+ const totals = { we: null, es: null };
194
+ try {
195
+ if (typeof mod.promoteWorkingToEpisodic === 'function') {
196
+ // Working->Episodic requires a session_id. When the runner was
197
+ // invoked without one (e.g. test fixture), skip the rollup with a
198
+ // clear log line rather than passing through Agent E's
199
+ // "session_id required" diagnostic on every dream cycle.
200
+ if (!opts.sessionId) {
201
+ totals.we = { skipped: 'session_id absent' };
202
+ } else {
203
+ try { totals.we = mod.promoteWorkingToEpisodic(db, { session_id: opts.sessionId }); }
204
+ catch (err) { totals.we = { error: String(err && err.message || err) }; }
205
+ }
206
+ }
207
+ if (typeof mod.promoteEpisodicToSemantic === 'function') {
208
+ try { totals.es = mod.promoteEpisodicToSemantic(db); }
209
+ catch (err) { totals.es = { error: String(err && err.message || err) }; }
210
+ }
211
+ // GA real fix-wave F4: Working->Procedural was missing from the
212
+ // dream-cycle dispatch. The function exists in tier-promotion.js
213
+ // (per D-PILLAR-SPEC §1) but the runner only fired We + Es,
214
+ // leaving the Procedural tier orphaned. Wire it here.
215
+ //
216
+ // Source signal per spec: TaskUpdate completed events with duration
217
+ // >= 5min + matching git commit window. This runner does NOT yet
218
+ // have a TaskUpdate event source (event ledger lands as part of the
219
+ // Working observation pipeline; deeper integration with TaskUpdate
220
+ // events is queued). When no source events are available, the
221
+ // promotion call is a clean no-op (returns { promoted: 0 } since
222
+ // status !== 'completed' is the early-return path inside the
223
+ // promotion function). Wiring the call here means the moment the
224
+ // event source goes live the promotion fires automatically.
225
+ //
226
+ // Pattern: the runner calls promoteWorkingToProcedural with a
227
+ // SYNTHETIC zero-shape envelope. This exercises the dispatch path,
228
+ // returns { promoted: 0 }, and lands a log line so the wiring is
229
+ // observable. When TaskUpdate events become available, callers can
230
+ // pass the real envelope and promotion will fire.
231
+ if (typeof mod.promoteWorkingToProcedural === 'function') {
232
+ try {
233
+ // Discover any pending TaskUpdate completed events. The
234
+ // event source is not yet wired into the alpha runner; lookup
235
+ // returns an empty list, in which case we exercise the
236
+ // dispatch path with a deterministic zero-shape call so the
237
+ // tier-promotion log line confirms the wiring is live.
238
+ const taskEvents = discoverTaskUpdateEvents(db);
239
+ if (taskEvents.length === 0) {
240
+ // No-op call to confirm the dispatch path exists. The
241
+ // function early-returns on status !== 'completed'.
242
+ totals.wp = mod.promoteWorkingToProcedural(db, {
243
+ status: 'noop',
244
+ task_completed_event_window_hours: 24,
245
+ });
246
+ } else {
247
+ let promotedTotal = 0;
248
+ const errors = [];
249
+ for (const ev of taskEvents) {
250
+ try {
251
+ const r = mod.promoteWorkingToProcedural(db, ev);
252
+ promotedTotal += Number(r.promoted || 0);
253
+ if (Array.isArray(r.errors)) errors.push(...r.errors);
254
+ } catch (err) {
255
+ errors.push(String(err && err.message || err));
256
+ }
257
+ }
258
+ totals.wp = { promoted: promotedTotal, errors, sources: taskEvents.length };
259
+ }
260
+ } catch (err) {
261
+ totals.wp = { error: String(err && err.message || err) };
262
+ }
263
+ }
264
+ log(`tier-promotion: working->episodic=${JSON.stringify(totals.we)} episodic->semantic=${JSON.stringify(totals.es)} working->procedural=${JSON.stringify(totals.wp)}`);
265
+
266
+ // GA-B1: Episodic->Semantic supersession fires D4 cascading staleness
267
+ // BFS over the symbol graph. Wire is best-effort -- a missing graph,
268
+ // missing entity match, or empty supersession array all skip silently.
269
+ // The runner is a SessionEnd best-effort consolidator; staleness
270
+ // propagation is value-add, not integrity-critical.
271
+ const superseded = totals.es && Array.isArray(totals.es.superseded)
272
+ ? totals.es.superseded
273
+ : [];
274
+ if (superseded.length > 0) {
275
+ try {
276
+ const wiringMod = await import('./staleness-wiring.js');
277
+ const sum = await wiringMod.propagateStaleForSupersessions({
278
+ projectRoot: opts.projectRoot,
279
+ supersededEntries: superseded,
280
+ log,
281
+ });
282
+ totals.staleness = sum;
283
+ log(`staleness-wiring: superseded=${sum.superseded_count} entities=${sum.entities_resolved} nodes=${sum.nodes_propagated} flagged=${sum.flagged_total}`);
284
+ } catch (err) {
285
+ log(`staleness-wiring: failed (${err && err.message ? err.message : err})`);
286
+ }
287
+ } else {
288
+ log('staleness-wiring: no Episodic->Semantic supersessions this cycle -- skip');
289
+ }
290
+ } finally {
291
+ try { closeDb(db); } catch { /* best effort */ }
292
+ }
293
+ return totals;
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // F4 helper: discover TaskUpdate completed events for Procedural promotion.
298
+ // ---------------------------------------------------------------------------
299
+ //
300
+ // D-PILLAR-SPEC §1 Working->Procedural promotion fires from TaskUpdate
301
+ // completed events with duration >= 5min and matching git commit window.
302
+ // The alpha runner does not yet have a dedicated TaskUpdate event source
303
+ // in the working memory ledger (events arrive via observation bodies but
304
+ // are not parsed into structured task envelopes). When the event source
305
+ // goes live, this function will return real envelopes; until then, it
306
+ // returns an empty array so the runner exercises the dispatch path
307
+ // without firing real promotions.
308
+ //
309
+ // Returns: Array<TaskUpdateEnvelope> per the contract documented in
310
+ // mcp-server/src/memory/tier-promotion.js#promoteWorkingToProcedural.
311
+ // Each envelope: { task_id, status, start_ts, end_ts, body, session_id }.
312
+
313
+ function discoverTaskUpdateEvents(_db) {
314
+ // Alpha: zero-shape source. F4 wires the dispatch path; the source
315
+ // table itself is queued (deeper TaskUpdate ledger integration).
316
+ // Returning [] keeps the call path exercised + the runner's log line
317
+ // visible without firing spurious promotions on synthetic data.
318
+ return [];
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Step 2: legacy journal -> knowledge consolidation pass
323
+ // ---------------------------------------------------------------------------
324
+ //
325
+ // Pre-D1 the canonical consolidator is the Claude /consolidate slash
326
+ // command (claude/commands/consolidate.md). For non-Claude hosts the
327
+ // runner records the attempt + writes a placeholder pattern-extraction
328
+ // summary so SessionEnd has a positive trail to point at. The actual
329
+ // scan runs against `.ijfw/memory/project-journal.md`.
330
+
331
+ function safeJournalSummary() {
332
+ try {
333
+ const journalPath = join(stateDir, 'memory', 'project-journal.md');
334
+ if (!existsSync(journalPath)) return { entries: 0, sessions: 0 };
335
+ const raw = readFileSync(journalPath, 'utf8');
336
+ const lines = raw.split('\n').filter((l) => l.startsWith('- ['));
337
+ const sessions = new Set();
338
+ for (const l of lines) {
339
+ const m = l.match(/#(\d+)/);
340
+ if (m) sessions.add(m[1]);
341
+ }
342
+ return { entries: lines.length, sessions: sessions.size };
343
+ } catch {
344
+ return { entries: 0, sessions: 0 };
345
+ }
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Driver
350
+ // ---------------------------------------------------------------------------
351
+
352
+ (async () => {
353
+ try {
354
+ const summary = safeJournalSummary();
355
+ log(`journal: ${summary.entries} entries across ${summary.sessions} sessions`);
356
+
357
+ await runTierPromotion();
358
+
359
+ const ok = markCompleted(stateDir);
360
+ log(`mark-completed: ${ok ? 'ok' : 'failed (non-fatal)'}`);
361
+ log('end: clean');
362
+ } catch (err) {
363
+ // Defensive: any unexpected throw lands in the log but never
364
+ // surfaces a non-zero exit to the parent hook.
365
+ try {
366
+ log(`end: caught ${err && err.message ? err.message : err}`);
367
+ } catch {
368
+ // give up silently
369
+ }
370
+ } finally {
371
+ process.exit(0);
372
+ }
373
+ })();
@@ -0,0 +1,195 @@
1
+ // IJFW v1.3.0 -- D3 dream-cycle staleness wiring (GA fix-wave GA-B1).
2
+ //
3
+ // Source authority: PRD-v2 section 9 Pillar D D4 + .planning/1.3.0/D-PILLAR-SPEC.md
4
+ // section 2 (cascading staleness propagation) + GA fix-wave finding GA-B1.
5
+ //
6
+ // PROBLEM (pre-fix-wave): the dream-cycle runner calls D1's
7
+ // promoteEpisodicToSemantic to fire the Episodic -> Semantic supersession,
8
+ // but then stops. propagateStale (D4) ships as a primitive with full
9
+ // grader coverage and zero production callers. End-to-end the chain
10
+ // Working -> Episodic -> Semantic (supersession) -> propagateStale
11
+ // does not fire.
12
+ //
13
+ // FIX: this module is invoked by mcp-server/src/dream/runner.mjs after
14
+ // promoteEpisodicToSemantic returns a non-empty `superseded` array. For
15
+ // each superseded Episodic body:
16
+ // 1. Re-extract entities (D2 extractEntities) from the body.
17
+ // 2. Open the compute db (where kg_nodes / kg_edges live).
18
+ // 3. Acquire .graph-write.lock for the propagation window.
19
+ // 4. For each clean entity, look up kg_nodes by (kind, name).
20
+ // 5. Call propagateStale on each found id.
21
+ // 6. Aggregate the per-supersession envelopes into a single summary.
22
+ //
23
+ // LOCK: .graph-write.lock is acquired ONCE for the whole batch so we
24
+ // don't thrash the lock file for every node lookup. propagateStale
25
+ // itself does not acquire (per its module header) -- the caller owns
26
+ // the window.
27
+ //
28
+ // FAILURE: every step is wrapped so a single bad supersession can't
29
+ // abort the dream cycle. The runner is best-effort consolidation, not
30
+ // integrity-critical work.
31
+
32
+ import { extractEntities } from '../compute/extract.js';
33
+ import { propagateStale } from '../compute/staleness.js';
34
+ import { propagateStaleMemory } from '../memory/staleness.js';
35
+ import { acquireGraphWriteLock } from '../compute/graph-lock.js';
36
+ import { openDb as openComputeDb, closeDb as closeComputeDb } from '../compute/fts5.js';
37
+ import { openDb as openMemoryDb, closeDb as closeMemoryDb } from '../memory/fts5.js';
38
+
39
+ /**
40
+ * propagateStaleForSupersessions({ projectRoot, supersededEntries, log }) -> summary
41
+ *
42
+ * Walks each superseded Episodic record:
43
+ * - extractEntities(body) -> entity list
44
+ * - filter clean entities (redacted=0)
45
+ * - for each, query kg_nodes by (kind, name) for an existing id
46
+ * - propagateStale(db, id) under a held .graph-write.lock
47
+ *
48
+ * Returns:
49
+ * {
50
+ * superseded_count: number, // input length
51
+ * entities_resolved: number, // total clean entities mapped to kg_nodes
52
+ * nodes_propagated: number, // total propagateStale invocations that fired
53
+ * flagged_total: number, // sum of envelope.flagged_count across calls
54
+ * errors: string[], // accumulated swallowed errors (for log)
55
+ * }
56
+ *
57
+ * `log(msg)` is the runner's log helper (best-effort, never throws). If
58
+ * absent, defaults to a no-op so this module is safe to call from
59
+ * non-runner contexts (e.g. unit tests).
60
+ */
61
+ export async function propagateStaleForSupersessions({ projectRoot, supersededEntries, log }) {
62
+ const summary = {
63
+ superseded_count: 0,
64
+ entities_resolved: 0,
65
+ nodes_propagated: 0,
66
+ flagged_total: 0,
67
+ flagged_compute: 0,
68
+ flagged_memory: 0,
69
+ errors: [],
70
+ };
71
+
72
+ const logger = typeof log === 'function' ? log : () => {};
73
+ const entries = Array.isArray(supersededEntries) ? supersededEntries : [];
74
+ summary.superseded_count = entries.length;
75
+ if (entries.length === 0) return summary;
76
+ if (typeof projectRoot !== 'string' || !projectRoot) {
77
+ summary.errors.push('propagateStaleForSupersessions: projectRoot required');
78
+ return summary;
79
+ }
80
+
81
+ let computeDb = null;
82
+ let memDb = null;
83
+ let lock = null;
84
+ try {
85
+ computeDb = await openComputeDb(projectRoot);
86
+ if (!hasGraphTables(computeDb)) {
87
+ logger('propagateStaleForSupersessions: kg_nodes table absent (compute migrations not run); skip');
88
+ return summary;
89
+ }
90
+ // GA real fix-wave F2: also open memory db so we can propagate to
91
+ // memory_entries.stale_candidate. Open is best-effort -- a missing
92
+ // memory db (fresh project, dream cycle running before any memory
93
+ // ingest) just means no memory rows exist yet to flag, which is
94
+ // fine. Keep going with compute-only propagation in that case.
95
+ try {
96
+ memDb = await openMemoryDb(projectRoot);
97
+ } catch (err) {
98
+ logger(`propagateStaleForSupersessions: memory db open skipped (${err && err.message ? err.message : err}); compute-only propagation`);
99
+ memDb = null;
100
+ }
101
+ lock = acquireGraphWriteLock(projectRoot, { waitMs: 5000 });
102
+ } catch (err) {
103
+ summary.errors.push(`propagateStaleForSupersessions: setup failed (${err && err.message ? err.message : err})`);
104
+ if (lock) { try { lock.released(); } catch { /* ignore */ } }
105
+ if (computeDb) { try { closeComputeDb(computeDb); } catch { /* ignore */ } }
106
+ if (memDb) { try { closeMemoryDb(memDb); } catch { /* ignore */ } }
107
+ return summary;
108
+ }
109
+
110
+ try {
111
+ const lookupNode = computeDb.prepare(
112
+ `SELECT id, redacted FROM kg_nodes WHERE kind = ? AND name = ?`
113
+ );
114
+ for (const sup of entries) {
115
+ const body = sup && typeof sup.body === 'string' ? sup.body : '';
116
+ if (!body) continue;
117
+ let entities;
118
+ try {
119
+ entities = extractEntities(body, { minMentions: 1 });
120
+ } catch (err) {
121
+ summary.errors.push(`extractEntities for episodic id=${sup.id}: ${err && err.message ? err.message : err}`);
122
+ continue;
123
+ }
124
+ if (!entities || entities.length === 0) continue;
125
+
126
+ // Map each clean entity to a kg_node id; redacted entities are
127
+ // skipped (they never seed kg_nodes per D-PILLAR-SPEC section 3).
128
+ const nodeIds = new Set();
129
+ for (const ent of entities) {
130
+ if (ent.redacted) continue;
131
+ try {
132
+ const row = lookupNode.get(ent.kind, ent.name);
133
+ if (!row) continue;
134
+ if (Number(row.redacted) === 1) continue;
135
+ nodeIds.add(Number(row.id));
136
+ } catch (err) {
137
+ summary.errors.push(`lookupNode ${ent.kind}:${ent.name}: ${err && err.message ? err.message : err}`);
138
+ }
139
+ }
140
+ summary.entities_resolved += nodeIds.size;
141
+ if (nodeIds.size === 0) continue;
142
+
143
+ // Fire propagateStale on each resolved kg_node id. Each call
144
+ // walks BFS depth_cap=2 and flags downstream observations on
145
+ // BOTH the compute store (raw + compiled) AND the memory store
146
+ // (memory_entries.stale_candidate). Pre-F2 only the compute side
147
+ // ran; the memory column existed but was never written.
148
+ for (const id of nodeIds) {
149
+ try {
150
+ const env = propagateStale(computeDb, id);
151
+ summary.nodes_propagated++;
152
+ summary.flagged_compute += Number(env.flagged_count || 0);
153
+ summary.flagged_total += Number(env.flagged_count || 0);
154
+ } catch (err) {
155
+ summary.errors.push(`propagateStale id=${id}: ${err && err.message ? err.message : err}`);
156
+ }
157
+ // GA real fix-wave F2: memory-side propagation. Walks the same
158
+ // compute kg BFS frontier but writes to memory_entries.
159
+ if (memDb) {
160
+ try {
161
+ const memEnv = propagateStaleMemory(memDb, computeDb, id);
162
+ summary.flagged_memory += Number(memEnv.flagged_count || 0);
163
+ summary.flagged_total += Number(memEnv.flagged_count || 0);
164
+ } catch (err) {
165
+ summary.errors.push(`propagateStaleMemory id=${id}: ${err && err.message ? err.message : err}`);
166
+ }
167
+ }
168
+ }
169
+ }
170
+ } finally {
171
+ if (lock) { try { lock.released(); } catch { /* best-effort */ } }
172
+ if (computeDb) { try { closeComputeDb(computeDb); } catch { /* best-effort */ } }
173
+ if (memDb) { try { closeMemoryDb(memDb); } catch { /* best-effort */ } }
174
+ }
175
+
176
+ logger(`propagateStaleForSupersessions: superseded=${summary.superseded_count} entities=${summary.entities_resolved} nodes=${summary.nodes_propagated} flagged=${summary.flagged_total} (compute=${summary.flagged_compute} memory=${summary.flagged_memory}) errors=${summary.errors.length}`);
177
+ return summary;
178
+ }
179
+
180
+ // --- helpers --------------------------------------------------------------
181
+
182
+ function hasGraphTables(db) {
183
+ try {
184
+ const row = db.prepare(
185
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='kg_nodes'`
186
+ ).get();
187
+ return !!row;
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+
193
+ export const __test = { hasGraphTables };
194
+
195
+ export default { propagateStaleForSupersessions };
@@ -0,0 +1,57 @@
1
+ // --- Feedback detector (W3.7 / H3) ---
2
+ //
3
+ // Deterministic detection of user-feedback phrases that should be promoted
4
+ // to feedback memories at session end: corrections ("don't X"), confirmations
5
+ // ("yes that was right"), and preference drifts ("keep doing Y").
6
+ //
7
+ // The session-end synthesizer (W3.9) reads these as structured signals and
8
+ // asks the LLM to generalize them into feedback entries with Why + How-to-apply.
9
+ //
10
+ // Pure regex, no LLM. High precision, low recall.
11
+
12
+ const PATTERNS = [
13
+ // Corrections -- "don't X", "stop X", "not that", "no/wrong"
14
+ { kind: 'correction', re: /\bdon'?t\s+(do|add|use|call|write|include|keep|ever)\b/i },
15
+ { kind: 'correction', re: /\bstop\s+(doing|adding|using|calling|writing|including)\b/i },
16
+ { kind: 'correction', re: /\b(?:no|not|wrong|nope)[,.!]/i },
17
+ { kind: 'correction', re: /\bdon'?t do (?:that|this|it)\b/i },
18
+
19
+ // Confirmations -- "yes that was right", "perfect", "exactly"
20
+ { kind: 'confirmation', re: /\b(?:yes|yep|yup)[,.!]?\s+(?:that|this)\s+(?:was|is)\s+(?:right|correct|good|great|perfect)\b/i },
21
+ { kind: 'confirmation', re: /\b(?:perfect|exactly|spot on|nailed it|great job|well done)\b/i },
22
+ { kind: 'confirmation', re: /\bkeep doing (?:that|this|it)\b/i },
23
+
24
+ // Preferences -- "I prefer X", "from now on X"
25
+ { kind: 'preference', re: /\bI prefer\b/i },
26
+ { kind: 'preference', re: /\bfrom now on[, ]/i },
27
+ { kind: 'preference', re: /\b(?:always|never) (?:do|use|add|include)\b/i },
28
+
29
+ // Generalization cues -- "every time", "each X"
30
+ { kind: 'rule', re: /\b(?:every time|each time|whenever|any time)\b/i },
31
+ ];
32
+
33
+ export function detectFeedback(prompt) {
34
+ if (typeof prompt !== 'string' || !prompt) return [];
35
+ const hits = [];
36
+ for (const { kind, re } of PATTERNS) {
37
+ const m = prompt.match(re);
38
+ if (m) {
39
+ hits.push({ kind, phrase: m[0].trim(), context: snippet(prompt, m.index ?? 0, 120) });
40
+ }
41
+ }
42
+ // Deduplicate by kind -- one signal per kind per prompt is enough for synthesis.
43
+ const seen = new Set();
44
+ return hits.filter(h => {
45
+ if (seen.has(h.kind)) return false;
46
+ seen.add(h.kind);
47
+ return true;
48
+ });
49
+ }
50
+
51
+ function snippet(text, start, width) {
52
+ const from = Math.max(0, start - 20);
53
+ const to = Math.min(text.length, start + width);
54
+ const prefix = from > 0 ? '…' : '';
55
+ const suffix = to < text.length ? '…' : '';
56
+ return prefix + text.slice(from, to).replace(/\s+/g, ' ').trim() + suffix;
57
+ }