@ijfw/memory-server 1.5.0 → 1.5.3
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-memorize +14 -7
- package/fixtures/team/book.json +6 -6
- package/fixtures/team/business.json +146 -20
- package/fixtures/team/content.json +6 -6
- package/fixtures/team/design.json +148 -20
- package/fixtures/team/mixed.json +206 -27
- package/fixtures/team/research.json +146 -20
- package/fixtures/team/software.json +148 -20
- package/package.json +8 -4
- package/src/brain/budget-guard.js +86 -0
- package/src/brain/citation-resolver.js +41 -0
- package/src/brain/context-injection.js +69 -0
- package/src/brain/discovery.js +83 -0
- package/src/brain/dream-pipeline.js +324 -0
- package/src/brain/dump-ingest.js +88 -0
- package/src/brain/entity-collapse.js +28 -0
- package/src/brain/export.js +112 -0
- package/src/brain/extractors/index.js +24 -0
- package/src/brain/extractors/markdown.js +27 -0
- package/src/brain/extractors/pdf.js +31 -0
- package/src/brain/extractors/transcript.js +38 -0
- package/src/brain/first-run-scan.js +61 -0
- package/src/brain/index.js +1 -0
- package/src/brain/layout-sentinel.js +29 -0
- package/src/brain/migrate-facts-internal-once.js +87 -0
- package/src/brain/path-guard.js +103 -0
- package/src/brain/paths.js +26 -0
- package/src/brain/promotion-suggester.js +41 -0
- package/src/brain/stub-detector.js +33 -0
- package/src/brain/tiered-llm.js +83 -0
- package/src/brain/wiki-compiler.js +144 -0
- package/src/brain/wiki-sentinels.js +45 -0
- package/src/brain/wiki-templates.js +94 -0
- package/src/cross-orchestrator-cli.js +336 -150
- package/src/cross-orchestrator.js +52 -3
- package/src/dashboard-server.js +1 -1
- package/src/dispatch/extension.js +1 -1
- package/src/dream/runner.mjs +21 -0
- package/src/extension-registry.js +2 -2
- package/src/handlers/brain-handler.js +319 -0
- package/src/hardware-signer.js +4 -2
- package/src/lib/ui-review-runner.js +48 -7
- package/src/memory/auto-linker.js +121 -2
- package/src/memory/benchmark.js +4 -3
- package/src/memory/layout-migrations/001-visible-layer.js +131 -0
- package/src/memory/layout-migrations/index.js +50 -0
- package/src/memory/migration-runner.js +37 -3
- package/src/memory/migrations/009-obsidian-backfill.js +50 -0
- package/src/memory/obsidian-parser.js +65 -2
- package/src/memory/reader.js +2 -1
- package/src/memory/search.js +190 -41
- package/src/memory/temporal.js +40 -1
- package/src/orchestrator/agents-md-blackboard.js +114 -1
- package/src/orchestrator/debug-trident-trigger.js +374 -0
- package/src/orchestrator/discipline-selector.js +276 -0
- package/src/orchestrator/merge-block-aware.js +15 -5
- package/src/orchestrator/post-done-runner.js +36 -8
- package/src/orchestrator/state-sdk.js +216 -10
- package/src/orchestrator/subagent-telemetry.js +19 -0
- package/src/orchestrator/wave-state.js +38 -0
- package/src/override-resolver.js +5 -3
- package/src/recovery/code-fixer.js +311 -6
- package/src/runtime-mediator.js +0 -1
- package/src/server.js +486 -132
- package/src/swarm-config.js +30 -22
- package/src/team/domain-templates/business.json +4 -1
- package/src/team/domain-templates/research.json +4 -1
- package/src/team/generator.js +162 -0
- package/src/update-apply.js +1 -1
- package/src/dashboard-charts.js +0 -239
- package/src/orchestrator/runtime-loop.js +0 -430
package/src/memory/search.js
CHANGED
|
@@ -31,6 +31,13 @@ import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
|
31
31
|
import { dirname, join, resolve, normalize, isAbsolute } from 'node:path';
|
|
32
32
|
|
|
33
33
|
import { expandQuery } from '../compute/synonyms.js';
|
|
34
|
+
import { loadMigrations } from './migration-runner.js';
|
|
35
|
+
// v1.5.1 R4-H2 — auto-index rows must flow through indexEntry so the
|
|
36
|
+
// v1.5.0 memory-moat (M1 Obsidian indexing + M2 A-Mem auto-linking) fires
|
|
37
|
+
// for warm-tier rebuilds, not just the benchmark harness. obsidian-parser
|
|
38
|
+
// is imported directly so M1 runs synchronously inside the same txn batch.
|
|
39
|
+
import { indexObsidianRelations } from './obsidian-parser.js';
|
|
40
|
+
import { autoLink } from './auto-linker.js';
|
|
34
41
|
|
|
35
42
|
const MAX_RESULTS = 50;
|
|
36
43
|
const SNIPPET_HALF = 60;
|
|
@@ -50,30 +57,16 @@ try {
|
|
|
50
57
|
}
|
|
51
58
|
|
|
52
59
|
// Resolve migration modules synchronously at module load via top-level
|
|
53
|
-
// await. Replayed inside searchMemory's sync path.
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const v6 = await import('./migrations/006-obsidian-graph.js');
|
|
64
|
-
const v7 = await import('./migrations/007-skill-telemetry.js');
|
|
65
|
-
const v8 = await import('./migrations/008-write-provenance.js');
|
|
66
|
-
return [
|
|
67
|
-
{ version: v1.VERSION, description: v1.DESCRIPTION, up: v1.up },
|
|
68
|
-
{ version: v2.VERSION, description: v2.DESCRIPTION, up: v2.up },
|
|
69
|
-
{ version: v3.VERSION, description: v3.DESCRIPTION, up: v3.up },
|
|
70
|
-
{ version: v4.VERSION, description: v4.DESCRIPTION, up: v4.up },
|
|
71
|
-
{ version: v5.VERSION, description: v5.DESCRIPTION, up: v5.up },
|
|
72
|
-
{ version: v6.VERSION, description: v6.DESCRIPTION, up: v6.up },
|
|
73
|
-
{ version: v7.VERSION, description: v7.DESCRIPTION, up: v7.up },
|
|
74
|
-
{ version: v8.VERSION, description: v8.DESCRIPTION, up: v8.up },
|
|
75
|
-
].sort((a, b) => a.version - b.version);
|
|
76
|
-
}
|
|
60
|
+
// await. Replayed inside searchMemory's sync path.
|
|
61
|
+
//
|
|
62
|
+
// v1.5.1 W3.B: discovery is delegated to memory/migration-runner.js
|
|
63
|
+
// (readdirSync over ./migrations/) so a single source of truth governs
|
|
64
|
+
// which migrations search.js knows about. Prior to this, search.js
|
|
65
|
+
// carried its OWN hardcoded list -- the v1.5.0 INT.7 hotfix patched
|
|
66
|
+
// the symptom (006/007/008 missing); this kills the dual-registry bug
|
|
67
|
+
// class outright. Drop migration 009 into ./migrations/, and search.js
|
|
68
|
+
// will pick it up automatically.
|
|
69
|
+
const MEMORY_MIGRATIONS = await loadMigrations();
|
|
77
70
|
|
|
78
71
|
function highestMigrationVersion() {
|
|
79
72
|
if (!MEMORY_MIGRATIONS.length) return 0;
|
|
@@ -220,12 +213,20 @@ function runMemoryMigrationsSync(db, currentVersion, targetVersion) {
|
|
|
220
213
|
|
|
221
214
|
function autoIndex(db, files) {
|
|
222
215
|
let n = 0;
|
|
216
|
+
// v1.5.1 R4-H2 — capture the rowid of every inserted entry so the
|
|
217
|
+
// memory-moat aux indexing (M1 Obsidian relations, M2 auto-link) can run
|
|
218
|
+
// over the warm-tier rebuild, not just the benchmark harness. The bulk
|
|
219
|
+
// INSERT stays in one transaction for FTS write performance; M1/M2 run
|
|
220
|
+
// AFTER commit so a parse/link failure can never abort the rebuild.
|
|
221
|
+
const inserted = [];
|
|
223
222
|
const txfn = db.transaction((batch) => {
|
|
224
223
|
const stmt = db.prepare(
|
|
225
224
|
'INSERT INTO memory_entries (body, source, session_id, created_at) VALUES (?, ?, ?, ?)'
|
|
226
225
|
);
|
|
227
226
|
for (const item of batch) {
|
|
228
|
-
stmt.run(item.body, item.source, null, item.created_at);
|
|
227
|
+
const info = stmt.run(item.body, item.source, null, item.created_at);
|
|
228
|
+
const id = info && info.lastInsertRowid != null ? Number(info.lastInsertRowid) : null;
|
|
229
|
+
inserted.push({ id, body: item.body });
|
|
229
230
|
n++;
|
|
230
231
|
}
|
|
231
232
|
});
|
|
@@ -242,6 +243,26 @@ function autoIndex(db, files) {
|
|
|
242
243
|
}
|
|
243
244
|
if (batch.length === 0) return 0;
|
|
244
245
|
try { txfn.immediate(batch); } catch { /* one bad batch should not abort the search */ }
|
|
246
|
+
|
|
247
|
+
// v1.5.1 R4-H2 — M1: Obsidian wikilink/tag/meta indexing into
|
|
248
|
+
// memory_links/_tags/_meta. Synchronous + idempotent (indexObsidianRelations
|
|
249
|
+
// clears prior rows for the id before re-inserting). Best-effort: a missing
|
|
250
|
+
// migration-006 schema or a parse failure must never break the search path.
|
|
251
|
+
// M2: A-Mem auto-linking — fire-and-forget, env-gated (IJFW_AUTOLINK_OFF),
|
|
252
|
+
// budget-capped (IJFW_AUTOLINK_BUDGET_USD); returns skipped cleanly when no
|
|
253
|
+
// API key, so a bulk rebuild without credentials does no LLM work.
|
|
254
|
+
for (const row of inserted) {
|
|
255
|
+
if (row.id == null) continue;
|
|
256
|
+
try {
|
|
257
|
+
indexObsidianRelations(db, String(row.id), row.body);
|
|
258
|
+
} catch { /* M1 best-effort -- never abort the search */ }
|
|
259
|
+
try {
|
|
260
|
+
const p = autoLink(db, { id: row.id, body: row.body });
|
|
261
|
+
if (p && typeof p.catch === 'function') p.catch(() => {});
|
|
262
|
+
// expose for tests that want deterministic completion
|
|
263
|
+
autoIndex.__lastAutoLinkPromise = p;
|
|
264
|
+
} catch { /* M2 dispatch best-effort */ }
|
|
265
|
+
}
|
|
245
266
|
return n;
|
|
246
267
|
}
|
|
247
268
|
|
|
@@ -325,6 +346,108 @@ function rowCount(db) {
|
|
|
325
346
|
}
|
|
326
347
|
}
|
|
327
348
|
|
|
349
|
+
// --- Structured provenance helpers -----------------------------------------
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Convert a raw FTS row + fileBySource map to a structured provenance object.
|
|
353
|
+
* Used when opts.format === 'structured'.
|
|
354
|
+
*
|
|
355
|
+
* Fields that aren't computed in the existing pipeline are returned as
|
|
356
|
+
* null / 0 rather than introducing new compute work (Task 28 spec).
|
|
357
|
+
*
|
|
358
|
+
* @param {object} row - raw DB row from searchFts5
|
|
359
|
+
* @param {Map} fileBySource
|
|
360
|
+
* @param {string} rawQuery - original user query (for whyMatched extraction)
|
|
361
|
+
* @param {object} db - open DB handle (for backlink count query)
|
|
362
|
+
* @returns {object}
|
|
363
|
+
*/
|
|
364
|
+
function ftsRowToStructured(row, fileBySource, rawQuery, db) {
|
|
365
|
+
const src = row.source || '';
|
|
366
|
+
const meta = fileBySource.get(src) || null;
|
|
367
|
+
const source = (meta && meta.path) || src;
|
|
368
|
+
const text = String(row.body || '');
|
|
369
|
+
const snip = text.slice(0, 200).replace(/\s+/g, ' ').trim();
|
|
370
|
+
|
|
371
|
+
// confidence: bm25 rank is negative (more negative = better). Convert to 0..1.
|
|
372
|
+
// rank returned from searchFts5 can be 0 or negative; we use the same
|
|
373
|
+
// score formula as ftsRowToResult (100 - rank) but normalise to 0..1 by
|
|
374
|
+
// clamping to [0, 100] and dividing.
|
|
375
|
+
const rawRank = Number(row.rank || 0);
|
|
376
|
+
const scoreRaw = 100 - rawRank; // same as ftsRowToResult
|
|
377
|
+
const confidence = Math.min(1, Math.max(0, scoreRaw / 100));
|
|
378
|
+
|
|
379
|
+
// ageDays: created_at is unix ms
|
|
380
|
+
const createdAt = Number(row.created_at || 0);
|
|
381
|
+
const ageDays = createdAt > 0
|
|
382
|
+
? Math.max(0, (Date.now() - createdAt) / 86400000)
|
|
383
|
+
: 0;
|
|
384
|
+
|
|
385
|
+
// decayFactor: not yet computed in pipeline — return null per spec
|
|
386
|
+
const decayFactor = null;
|
|
387
|
+
|
|
388
|
+
// whyMatched: tokenise the raw query into distinct non-trivial terms
|
|
389
|
+
const whyMatched = rawQuery
|
|
390
|
+
.trim()
|
|
391
|
+
.split(/\s+/)
|
|
392
|
+
.map(t => t.replace(/['"*()]/g, '').toLowerCase())
|
|
393
|
+
.filter(t => t.length > 0);
|
|
394
|
+
|
|
395
|
+
// backlinkCount: count rows in memory_links where to_target matches source
|
|
396
|
+
let backlinkCount = 0;
|
|
397
|
+
if (db && row.id != null) {
|
|
398
|
+
try {
|
|
399
|
+
const idStr = String(row.id);
|
|
400
|
+
const r = db.prepare(
|
|
401
|
+
'SELECT COUNT(*) AS n FROM memory_links WHERE to_target = ?'
|
|
402
|
+
).get(idStr);
|
|
403
|
+
backlinkCount = r ? Number(r.n) : 0;
|
|
404
|
+
} catch { /* memory_links may not exist in older dbs */ }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
source,
|
|
409
|
+
anchor: null,
|
|
410
|
+
snippet: snip,
|
|
411
|
+
confidence,
|
|
412
|
+
ageDays,
|
|
413
|
+
decayFactor,
|
|
414
|
+
whyMatched,
|
|
415
|
+
backlinkCount,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Convert a hot-linear result to a structured provenance object.
|
|
421
|
+
* Linear results lack a DB row id so backlinkCount is always 0.
|
|
422
|
+
*
|
|
423
|
+
* @param {object} result - from searchLinear
|
|
424
|
+
* @param {string} rawQuery
|
|
425
|
+
* @returns {object}
|
|
426
|
+
*/
|
|
427
|
+
function linearResultToStructured(result, rawQuery) {
|
|
428
|
+
const scoreRaw = Number(result.score || 0);
|
|
429
|
+
// Hot-linear score is titleMatches*3 + bodyMatches; normalise loosely to 0..1
|
|
430
|
+
// by capping at 50 matches (arbitrary but safe)
|
|
431
|
+
const confidence = Math.min(1, scoreRaw / 50);
|
|
432
|
+
|
|
433
|
+
const whyMatched = rawQuery
|
|
434
|
+
.trim()
|
|
435
|
+
.split(/\s+/)
|
|
436
|
+
.map(t => t.replace(/['"*()]/g, '').toLowerCase())
|
|
437
|
+
.filter(t => t.length > 0);
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
source: result.path || result.relpath || '',
|
|
441
|
+
anchor: null,
|
|
442
|
+
snippet: result.snippet || '',
|
|
443
|
+
confidence,
|
|
444
|
+
ageDays: 0,
|
|
445
|
+
decayFactor: null,
|
|
446
|
+
whyMatched,
|
|
447
|
+
backlinkCount: 0,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
328
451
|
// --- Public API -------------------------------------------------------------
|
|
329
452
|
|
|
330
453
|
/**
|
|
@@ -344,30 +467,41 @@ function rowCount(db) {
|
|
|
344
467
|
* the hot-linear fallback is unfiltered (D1 does not yet write tier
|
|
345
468
|
* metadata into the markdown surface).
|
|
346
469
|
*
|
|
470
|
+
* format option (Task 28 — structured provenance):
|
|
471
|
+
* opts.format === 'structured' returns an Array of provenance objects:
|
|
472
|
+
* [{source, anchor, snippet, confidence, ageDays, decayFactor,
|
|
473
|
+
* whyMatched, backlinkCount}]
|
|
474
|
+
* Default (no format) returns Array<{path,relpath,title,snippet,score,
|
|
475
|
+
* tier_semantic}> — byte-identical to pre-Task-28 behaviour.
|
|
476
|
+
*
|
|
347
477
|
* @param {string} q
|
|
348
478
|
* @param {Array<{path,relpath,title,preview}>} files
|
|
349
479
|
* @param {number} limit
|
|
350
480
|
* @param {object|undefined} options
|
|
351
|
-
* @returns {Array<{path,relpath,title,snippet,score,tier_semantic}>}
|
|
481
|
+
* @returns {Array<{path,relpath,title,snippet,score,tier_semantic}>|Array<provenance>}
|
|
352
482
|
*/
|
|
353
483
|
export function searchMemory(q, files, limit = MAX_RESULTS, options) {
|
|
354
484
|
if (!q || !q.trim() || !files || files.length === 0) return [];
|
|
355
485
|
|
|
356
|
-
// Normalise options. Allow undefined / { tier_semantic, include_stale
|
|
357
|
-
// a bare string (treated as the tier_semantic value) for
|
|
358
|
-
// sites. include_stale defaults to false -- D4 GA-B2
|
|
486
|
+
// Normalise options. Allow undefined / { tier_semantic, include_stale,
|
|
487
|
+
// format } / a bare string (treated as the tier_semantic value) for
|
|
488
|
+
// ergonomic call sites. include_stale defaults to false -- D4 GA-B2
|
|
489
|
+
// retrieval guard. format === 'structured' enables Task-28 provenance.
|
|
359
490
|
let tier_semantic;
|
|
360
491
|
let include_stale = false;
|
|
492
|
+
let format;
|
|
361
493
|
if (typeof options === 'string') {
|
|
362
494
|
tier_semantic = options;
|
|
363
495
|
} else if (options && typeof options === 'object') {
|
|
364
496
|
tier_semantic = options.tier_semantic;
|
|
365
497
|
include_stale = options.include_stale === true;
|
|
498
|
+
format = options.format;
|
|
366
499
|
}
|
|
367
500
|
|
|
368
501
|
const { expanded, synonym_matches, applied } = expandQuery(q);
|
|
369
502
|
|
|
370
503
|
let warmHits = null;
|
|
504
|
+
let warmRawRows = null; // preserved for structured format (Task 28)
|
|
371
505
|
let warmEmpty = false;
|
|
372
506
|
let db = null;
|
|
373
507
|
|
|
@@ -403,6 +537,11 @@ export function searchMemory(q, files, limit = MAX_RESULTS, options) {
|
|
|
403
537
|
if (f.relpath) fileBySource.set(f.relpath, f);
|
|
404
538
|
if (f.path) fileBySource.set(f.path, f);
|
|
405
539
|
}
|
|
540
|
+
if (format === 'structured') {
|
|
541
|
+
// Task 28: map raw rows to provenance objects while db is still open
|
|
542
|
+
// (backlinkCount query needs the handle)
|
|
543
|
+
warmRawRows = rows.map(r => ftsRowToStructured(r, fileBySource, q, db));
|
|
544
|
+
}
|
|
406
545
|
warmHits = rows.map(r => ftsRowToResult(r, fileBySource));
|
|
407
546
|
} else {
|
|
408
547
|
warmEmpty = true;
|
|
@@ -411,31 +550,41 @@ export function searchMemory(q, files, limit = MAX_RESULTS, options) {
|
|
|
411
550
|
}
|
|
412
551
|
} catch {
|
|
413
552
|
warmHits = null;
|
|
553
|
+
warmRawRows = null;
|
|
414
554
|
} finally {
|
|
415
555
|
if (db) { try { db.close(); } catch { /* ignore */ } }
|
|
416
556
|
}
|
|
417
557
|
|
|
418
558
|
let results;
|
|
419
559
|
if (warmHits && warmHits.length > 0) {
|
|
420
|
-
results =
|
|
560
|
+
results = format === 'structured'
|
|
561
|
+
? warmRawRows.slice(0, limit)
|
|
562
|
+
: warmHits.slice(0, limit);
|
|
421
563
|
} else if (tier_semantic) {
|
|
422
564
|
// Tier filter active and warm tier has no matches -- the hot-linear
|
|
423
565
|
// tier doesn't carry tier metadata so it can't honour the filter.
|
|
424
566
|
// Returning [] here keeps the contract honest ("only matching tier").
|
|
425
567
|
results = [];
|
|
426
568
|
} else {
|
|
427
|
-
|
|
569
|
+
const linearResults = searchLinear(q, files, limit);
|
|
570
|
+
results = format === 'structured'
|
|
571
|
+
? linearResults.map(r => linearResultToStructured(r, q))
|
|
572
|
+
: linearResults;
|
|
428
573
|
}
|
|
429
574
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
575
|
+
// Structured results are plain arrays — no non-enumerable decorations needed.
|
|
576
|
+
// Legacy path: attach non-enumerable metadata as before (byte-identical).
|
|
577
|
+
if (format !== 'structured') {
|
|
578
|
+
Object.defineProperty(results, 'synonym_matches', {
|
|
579
|
+
value: applied ? synonym_matches : {},
|
|
580
|
+
enumerable: false,
|
|
581
|
+
});
|
|
582
|
+
Object.defineProperty(results, 'tier', {
|
|
583
|
+
value: warmHits && warmHits.length > 0
|
|
584
|
+
? 'warm-fts5'
|
|
585
|
+
: (warmEmpty ? 'hot-linear-empty-fts5' : 'hot-linear'),
|
|
586
|
+
enumerable: false,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
440
589
|
return results;
|
|
441
590
|
}
|
package/src/memory/temporal.js
CHANGED
|
@@ -333,7 +333,13 @@ export function storeFactBitemporal(db, fact, now) {
|
|
|
333
333
|
const factId = insertFact(db, f, t);
|
|
334
334
|
return { invalidated, factId };
|
|
335
335
|
});
|
|
336
|
-
|
|
336
|
+
// F2.7: .immediate() issues BEGIN IMMEDIATE — see brain-handler conflict.resolve.
|
|
337
|
+
// Sister writers (conflict.resolve) hold IMMEDIATE locks; if we ran DEFERRED here
|
|
338
|
+
// we would hit SQLITE_BUSY when upgrading SHARED→RESERVED on the first write
|
|
339
|
+
// inside the txn body and the user's memory write would silently drop (or
|
|
340
|
+
// throw SQLITE_BUSY at the caller). Lock-mode alignment with sister writers
|
|
341
|
+
// is what makes the cross-connection contract honest.
|
|
342
|
+
const r = txn.immediate(fact, ts);
|
|
337
343
|
return { invalidated: r.invalidated, factId: r.factId, deduped: false };
|
|
338
344
|
}
|
|
339
345
|
|
|
@@ -513,6 +519,38 @@ export function applyDecayToFacts(rows, now, options = {}) {
|
|
|
513
519
|
});
|
|
514
520
|
}
|
|
515
521
|
|
|
522
|
+
/**
|
|
523
|
+
* getHistoryWindow -- bounded slice of facts about (subject[, predicate]) for
|
|
524
|
+
* the wiki compiler's "history" section. Returns at most `limit` rows ordered
|
|
525
|
+
* by valid_from DESC. When rollupOlder is true and rows.length === limit,
|
|
526
|
+
* also returns an `older` rollup of facts beyond the window so the wiki page
|
|
527
|
+
* can show "Older: 55 events between 2024-03-01 and 2025-06-30" without
|
|
528
|
+
* bloating the page.
|
|
529
|
+
*
|
|
530
|
+
* Trident F-B2 protection: prevents page-bloat for hot subjects with hundreds
|
|
531
|
+
* of facts.
|
|
532
|
+
*/
|
|
533
|
+
export function getHistoryWindow(db, subject, predicate, { limit = 50, since = null, rollupOlder = true } = {}) {
|
|
534
|
+
const params = [subject];
|
|
535
|
+
let where = 'subject = ?';
|
|
536
|
+
if (predicate != null) { where += ' AND predicate = ?'; params.push(predicate); }
|
|
537
|
+
if (since) { where += ' AND valid_from >= ?'; params.push(since); }
|
|
538
|
+
const rows = db.prepare(
|
|
539
|
+
`SELECT id, predicate, object, valid_from, valid_to, memory_id, source, confidence
|
|
540
|
+
FROM facts WHERE ${where} ORDER BY valid_from DESC LIMIT ?`
|
|
541
|
+
).all(...params, limit);
|
|
542
|
+
let older = null;
|
|
543
|
+
if (rollupOlder && rows.length === limit) {
|
|
544
|
+
const earliest = rows[rows.length - 1].valid_from;
|
|
545
|
+
const r = db.prepare(
|
|
546
|
+
`SELECT COUNT(*) AS count, MIN(valid_from) AS fromIso, MAX(valid_from) AS toIso
|
|
547
|
+
FROM facts WHERE ${where} AND valid_from < ?`
|
|
548
|
+
).get(...params, earliest);
|
|
549
|
+
if (r.count > 0) older = r;
|
|
550
|
+
}
|
|
551
|
+
return { rows, older };
|
|
552
|
+
}
|
|
553
|
+
|
|
516
554
|
export default {
|
|
517
555
|
openTemporalDb,
|
|
518
556
|
openTemporalDbSync,
|
|
@@ -524,6 +562,7 @@ export default {
|
|
|
524
562
|
getHistory,
|
|
525
563
|
getAllFactsWithWindows,
|
|
526
564
|
applyDecayToFacts,
|
|
565
|
+
getHistoryWindow,
|
|
527
566
|
DECAY_HALFLIFE_DAYS,
|
|
528
567
|
DECAY_HALFLIFE_SESSION_DAYS,
|
|
529
568
|
};
|
|
@@ -46,12 +46,17 @@
|
|
|
46
46
|
* AGENTS.md hiccup — see `wave-state.js#checkpointWave`).
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
|
-
import { join } from 'node:path';
|
|
49
|
+
import { join, resolve as pathResolve, dirname as pathDirname } from 'node:path';
|
|
50
50
|
|
|
51
51
|
import { withFsLock, lockPathFor } from '../fs-lock.js';
|
|
52
52
|
import { readWaveState } from './wave-state.js';
|
|
53
53
|
import { mergeFile, MergeBlockAwareError } from './merge-block-aware.js';
|
|
54
54
|
import { query } from './state-sdk.js';
|
|
55
|
+
import {
|
|
56
|
+
selectDisciplineTemplate,
|
|
57
|
+
detectProjectTypeFromRepo,
|
|
58
|
+
} from './discipline-selector.js';
|
|
59
|
+
import { validateSafeRepoPath } from '../brain/path-guard.js';
|
|
55
60
|
|
|
56
61
|
/**
|
|
57
62
|
* Render the BLACKBOARD marker-block payload from a wave's STATE.md
|
|
@@ -93,8 +98,21 @@ export async function populateBlackboardBlock(waveId, projectRoot) {
|
|
|
93
98
|
const state = await readWaveState(waveId, projectRoot);
|
|
94
99
|
if (!state) return { ok: false, reason: 'no-state' };
|
|
95
100
|
|
|
101
|
+
// Defense-in-depth: refuse to operate at a filesystem root (e.g. '/' on
|
|
102
|
+
// POSIX, 'C:\\' on Windows). validateSafeRepoPath alone accepts these
|
|
103
|
+
// because '/AGENTS.md' is technically "inside" '/'; OS permissions would
|
|
104
|
+
// then catch the actual write, but the failure mode would be 'merge-error'
|
|
105
|
+
// not 'unsafe-path'. We want a clean structured rejection upstream of any
|
|
106
|
+
// I/O attempt. Test at integration/test-discipline-integration.js:189.
|
|
107
|
+
const resolvedRoot = pathResolve(projectRoot || '.');
|
|
108
|
+
if (resolvedRoot === pathDirname(resolvedRoot)) {
|
|
109
|
+
return { ok: false, reason: 'unsafe-path', error: 'projectRoot is a filesystem root' };
|
|
110
|
+
}
|
|
111
|
+
|
|
96
112
|
const payload = renderBlackboardPayload(waveId, state);
|
|
97
113
|
const agentsMdPath = join(projectRoot, 'AGENTS.md');
|
|
114
|
+
const guard = validateSafeRepoPath(projectRoot, agentsMdPath);
|
|
115
|
+
if (!guard.ok) return { ok: false, reason: 'unsafe-path', error: guard.error };
|
|
98
116
|
const lockPath = lockPathFor(agentsMdPath);
|
|
99
117
|
|
|
100
118
|
let mergeResult;
|
|
@@ -150,3 +168,98 @@ export async function populateBlackboardBlock(waveId, projectRoot) {
|
|
|
150
168
|
|
|
151
169
|
return { ok: true };
|
|
152
170
|
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Populate the DISCIPLINE marker block in `<projectRoot>/AGENTS.md` with the
|
|
174
|
+
* project-appropriate discipline template body. Held under the §3 #8 AGENTS.md
|
|
175
|
+
* lock; in-process (no spawn). Best-effort SDK event emit after lock release.
|
|
176
|
+
*
|
|
177
|
+
* If `projectType` is not supplied, `detectProjectTypeFromRepo(projectRoot)`
|
|
178
|
+
* is called to infer it. For `unknown` / `mixed` types the DISCIPLINE block
|
|
179
|
+
* is written with an empty body (marker present, body empty) -- this is the
|
|
180
|
+
* correct state, not an error.
|
|
181
|
+
*
|
|
182
|
+
* Return shapes:
|
|
183
|
+
* `{ ok: true }` -- wrote AGENTS.md
|
|
184
|
+
* `{ ok: false, reason: 'merge-error', error }` -- merger threw
|
|
185
|
+
* `{ ok: false, reason: 'template-missing', error }` -- template file absent
|
|
186
|
+
*
|
|
187
|
+
* @param {string} projectRoot
|
|
188
|
+
* @param {string} [projectType] optional; inferred when absent
|
|
189
|
+
* @param {{waveId?: string}} [opts] optional; waveId defaults to 'system'
|
|
190
|
+
* @returns {Promise<{ok: boolean, reason?: string, error?: string}>}
|
|
191
|
+
*/
|
|
192
|
+
export async function populateDisciplineBlock(projectRoot, projectType, opts = {}) {
|
|
193
|
+
const waveId = opts.waveId || 'system';
|
|
194
|
+
const type = projectType !== undefined && projectType !== null
|
|
195
|
+
? String(projectType)
|
|
196
|
+
: detectProjectTypeFromRepo(projectRoot);
|
|
197
|
+
|
|
198
|
+
let content;
|
|
199
|
+
try {
|
|
200
|
+
content = selectDisciplineTemplate(type);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
reason: 'template-missing',
|
|
205
|
+
error: String(err.message || err),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Defense-in-depth: same filesystem-root rejection as populateBlackboardBlock.
|
|
210
|
+
const resolvedRoot = pathResolve(projectRoot || '.');
|
|
211
|
+
if (resolvedRoot === pathDirname(resolvedRoot)) {
|
|
212
|
+
return { ok: false, reason: 'unsafe-path', error: 'projectRoot is a filesystem root' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const agentsMdPath = join(projectRoot, 'AGENTS.md');
|
|
216
|
+
const guard = validateSafeRepoPath(projectRoot, agentsMdPath);
|
|
217
|
+
if (!guard.ok) return { ok: false, reason: 'unsafe-path', error: guard.error };
|
|
218
|
+
const lockPath = lockPathFor(agentsMdPath);
|
|
219
|
+
|
|
220
|
+
let mergeResult;
|
|
221
|
+
let mergeError = null;
|
|
222
|
+
try {
|
|
223
|
+
mergeResult = await withFsLock(lockPath, async () => mergeFile(
|
|
224
|
+
agentsMdPath,
|
|
225
|
+
[{ block: 'DISCIPLINE', content }],
|
|
226
|
+
));
|
|
227
|
+
} catch (err) {
|
|
228
|
+
mergeError = err;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (mergeError) {
|
|
232
|
+
const code = mergeError instanceof MergeBlockAwareError ? mergeError.code : null;
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
reason: code === 'ERR_TEMPLATE_MISSING' ? 'template-missing' : 'merge-error',
|
|
236
|
+
error: String(mergeError.message || mergeError),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// SDK-routed observability event -- fire-and-forget AFTER lock release.
|
|
241
|
+
// Mirrors the agents-md.blackboard.set emit pattern above.
|
|
242
|
+
try {
|
|
243
|
+
await query('event.emit', {
|
|
244
|
+
subagentId: 'parent',
|
|
245
|
+
waveId,
|
|
246
|
+
eventType: 'agents-md.discipline.set',
|
|
247
|
+
data: {
|
|
248
|
+
path: mergeResult?.path ?? agentsMdPath,
|
|
249
|
+
bytes: mergeResult?.bytes ?? 0,
|
|
250
|
+
seeded: !!mergeResult?.seeded,
|
|
251
|
+
project_type: type,
|
|
252
|
+
},
|
|
253
|
+
dedupKey: `agents-md.discipline.set:${waveId}:${type}`,
|
|
254
|
+
}, { projectRoot, subagentId: 'parent' });
|
|
255
|
+
} catch {
|
|
256
|
+
// Observability is best-effort; never demote a successful AGENTS.md
|
|
257
|
+
// rewrite because the event tap had a hiccup.
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Forward noop flag from the short-circuit so callers can detect idempotent
|
|
261
|
+
// calls (5B-L2-05). When mergeResult.noop is true the file was unchanged.
|
|
262
|
+
const result = { ok: true };
|
|
263
|
+
if (mergeResult?.noop) result.noop = true;
|
|
264
|
+
return result;
|
|
265
|
+
}
|