@ijfw/memory-server 1.4.4 → 1.5.1
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/fixtures/truncation-corpus/_generate-corpus.js +367 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
- package/package.json +6 -3
- package/src/active-extension-writer.js +144 -64
- package/src/api-client.js +43 -5
- package/src/audit-roster.js +80 -5
- package/src/blackboard.js +298 -6
- package/src/cli-run.js +33 -5
- package/src/codex-agents.js +96 -5
- package/src/cost/aggregator.js +39 -9
- package/src/cost/pricing.js +57 -0
- package/src/cost/readers/gemini.js +1 -1
- package/src/cross-audit-chunker.js +189 -0
- package/src/cross-dispatcher.js +124 -21
- package/src/cross-orchestrator-cli.js +754 -159
- package/src/cross-orchestrator.js +1065 -17
- package/src/cross-project-search.js +195 -9
- package/src/dashboard-client-waves.html +304 -0
- package/src/dashboard-client.html +5 -1
- package/src/dashboard-server.js +73 -0
- package/src/deploy-alerts.js +150 -0
- package/src/design/iframe-bridge.js +242 -0
- package/src/design-companion.js +144 -0
- package/src/dispatch/checkpoint-cli.js +97 -0
- package/src/dispatch/colon-syntax.js +81 -1
- package/src/dispatch/extension.js +26 -2
- package/src/dispatch/registry-cli.js +4 -1
- package/src/dispatch/wave-cli.js +201 -6
- package/src/dispatch/worktree-cli.js +40 -0
- package/src/dispatch-planner.js +97 -2
- package/src/dream/runner.mjs +47 -11
- package/src/dream/stage-runner.js +40 -0
- package/src/dream/state-file.js +102 -0
- package/src/extension-installer.js +70 -24
- package/src/extension-quota-tracker.js +4 -2
- package/src/extension-registry.js +289 -35
- package/src/feedback-detector.js +26 -0
- package/src/fs-lock.js +259 -7
- package/src/gate-result.js +95 -1
- package/src/hardware-signer.js +4 -2
- package/src/hero-line.js +86 -5
- package/src/intent-router.js +35 -0
- package/src/lib/a11y-contract.js +117 -0
- package/src/lib/atomic-io.js +29 -8
- package/src/lib/cache-keepalive.js +150 -0
- package/src/lib/jsonl-rotation.js +104 -0
- package/src/lib/lighthouse-pillar.js +121 -0
- package/src/lib/llm-call.js +121 -0
- package/src/lib/playwright-baseline.js +205 -0
- package/src/lib/rekor-bridge.js +221 -0
- package/src/lib/repo-map.js +392 -0
- package/src/lib/shasum-verify.js +164 -0
- package/src/lib/sketches-gc.js +132 -0
- package/src/lib/tmp-suffix.js +62 -0
- package/src/lib/ui-review-runner.js +595 -0
- package/src/lib/uispec-drift.js +301 -0
- package/src/lib/uispec-intake.js +381 -0
- package/src/lib/worktree-guards.js +118 -0
- package/src/lib/worktree-recovery.js +100 -0
- package/src/memory/auto-linker.js +267 -0
- package/src/memory/benchmark.js +498 -0
- package/src/memory/dedup.js +126 -0
- package/src/memory/embedding-cache.js +136 -0
- package/src/memory/fact-extractor.js +168 -0
- package/src/memory/fts5.js +65 -1
- package/src/memory/migration-runner.js +6 -1
- package/src/memory/migrations/004-bitemporal.js +91 -0
- package/src/memory/migrations/005-vector-cache.js +61 -0
- package/src/memory/migrations/006-obsidian-graph.js +46 -0
- package/src/memory/migrations/007-skill-telemetry.js +24 -0
- package/src/memory/migrations/008-write-provenance.js +41 -0
- package/src/memory/migrations/009-obsidian-backfill.js +50 -0
- package/src/memory/obsidian-parser.js +152 -0
- package/src/memory/query-dataview.js +86 -0
- package/src/memory/search.js +46 -15
- package/src/memory/temporal.js +529 -0
- package/src/memory/tokenize.js +10 -0
- package/src/memory-facts-handler.js +37 -0
- package/src/memory-feedback.js +260 -2
- package/src/model-refresh.js +292 -0
- package/src/observability/cost-anomaly.js +166 -0
- package/src/observability/evaluator-checkpoint-contract.js +117 -0
- package/src/observability/trace-id.js +163 -0
- package/src/orchestrator/agents-md-blackboard.js +152 -0
- package/src/orchestrator/checkpoint-contract.md +140 -0
- package/src/orchestrator/debug-trident-trigger.js +374 -0
- package/src/orchestrator/debug-trident.js +570 -0
- package/src/orchestrator/merge-block-aware.js +350 -0
- package/src/orchestrator/plan-checker.js +475 -0
- package/src/orchestrator/post-done-runner.js +277 -0
- package/src/orchestrator/review.js +38 -3
- package/src/orchestrator/skill-telemetry-sink.js +29 -0
- package/src/orchestrator/skill-telemetry.js +37 -0
- package/src/orchestrator/state-events.js +459 -0
- package/src/orchestrator/state-sdk.js +1932 -0
- package/src/orchestrator/status-protocol.js +84 -17
- package/src/orchestrator/subagent-telemetry.js +471 -0
- package/src/orchestrator/termination.js +160 -0
- package/src/orchestrator/verification-gate.js +200 -16
- package/src/orchestrator/wave-state.js +332 -23
- package/src/orchestrator/worktree-provision.js +77 -0
- package/src/override-resolver.js +5 -3
- package/src/override-use-registry.js +111 -5
- package/src/receipts.js +36 -4
- package/src/recovery/checkpoint.js +56 -3
- package/src/recovery/code-fixer.js +961 -0
- package/src/recovery/truncation.js +317 -0
- package/src/redactor.js +75 -6
- package/src/runtime-mediator.js +15 -1
- package/src/sanitizer.js +10 -0
- package/src/search-hybrid.js +139 -0
- package/src/server.js +795 -112
- package/src/swarm/worktree.js +27 -4
- package/src/swarm-config.js +102 -17
- package/src/team/domain-templates/book.json +51 -0
- package/src/team/domain-templates/business.json +44 -0
- package/src/team/domain-templates/content.json +50 -0
- package/src/team/domain-templates/design.json +44 -0
- package/src/team/domain-templates/research.json +44 -0
- package/src/team/domain-templates/software.json +40 -0
- package/src/team/generator.js +440 -3
- package/src/team/modify.js +203 -0
- package/src/team/schemas.js +48 -0
- package/src/update-apply.js +19 -3
- package/src/dashboard-charts.js +0 -239
package/src/server.js
CHANGED
|
@@ -33,9 +33,33 @@ import { checkPrompt } from './prompt-check.js';
|
|
|
33
33
|
import { applyCaps, CAP_CONTENT } from './caps.js';
|
|
34
34
|
import { ensureSchemaHeader, SCHEMA_HEADER } from './schema.js';
|
|
35
35
|
import { searchCorpus } from './search-bm25.js';
|
|
36
|
+
// r17 (cold-tier wire-up): hybrid BM25+vector ranking when IJFW_VECTORS=on AND
|
|
37
|
+
// @xenova/transformers is installed. Silent BM25-only fallback otherwise.
|
|
38
|
+
// Lives in its own module so tests can drive the rerank helper without
|
|
39
|
+
// importing server.js (whose stdio bootstrap would hang the test runner).
|
|
40
|
+
import { maybeRerankWithVectors } from './search-hybrid.js';
|
|
36
41
|
import { crossProjectSearch } from './cross-project-search.js';
|
|
37
42
|
// R2-E -- single source of truth for markdown/HTML/control-char defanger.
|
|
38
43
|
import { sanitizeContent } from './sanitizer.js';
|
|
44
|
+
// v1.5.1 R4-H3 — secret redaction on the direct ijfw_store write path.
|
|
45
|
+
// The redactor is already wired into FTS5 ingest + auto-memorize; the
|
|
46
|
+
// direct MCP store was the one bypass, so a secret pasted into an
|
|
47
|
+
// ijfw_store call could land in .ijfw/memory/*.md cleartext.
|
|
48
|
+
import { redactSecrets } from './redactor.js';
|
|
49
|
+
// H5.5 / H5.6 — ingest-time fact extraction + semantic dedup. Closes
|
|
50
|
+
// memory-engine.md competitor gaps (mem0/Zep extract facts; Graphiti dedups).
|
|
51
|
+
// Both are pure-JS, zero-LLM, deterministic.
|
|
52
|
+
import { extractFacts, factToJsonl } from './memory/fact-extractor.js';
|
|
53
|
+
import { findNearDuplicate, readDedupConfig } from './memory/dedup.js';
|
|
54
|
+
// v1.5.0 audit H5.4 — Graphiti-style bi-temporal validity. Lets storing a
|
|
55
|
+
// contradictory fact close the prior's valid_to instead of accumulating.
|
|
56
|
+
import {
|
|
57
|
+
openTemporalDbSync,
|
|
58
|
+
storeFactBitemporal,
|
|
59
|
+
getValidAt as temporalGetValidAt,
|
|
60
|
+
getHistory as temporalGetHistory,
|
|
61
|
+
getAllFactsWithWindows as temporalGetAllFactsWithWindows,
|
|
62
|
+
} from './memory/temporal.js';
|
|
39
63
|
// 1.1.6: update tools (cap 8 -> 10) -- token-issuance + OOB terminal confirm.
|
|
40
64
|
// Per CLAUDE.md policy: future growth triggers retirement review, not raise.
|
|
41
65
|
import { ijfwUpdateCheck, TOOL_DEF as UPDATE_CHECK_TOOL } from './update-check.js';
|
|
@@ -394,8 +418,11 @@ function readOr(filepath, fallback = '') {
|
|
|
394
418
|
// --- Append helper (atomic for entries < PIPE_BUF; append-only growth) ---
|
|
395
419
|
//
|
|
396
420
|
// We rely on POSIX O_APPEND atomicity for entries under 4KB. Sanitized
|
|
397
|
-
// entries are bounded at MAX_STORE_LENGTH=
|
|
398
|
-
// keeps each *line* well under 4KB after sanitization
|
|
421
|
+
// entries are bounded at MAX_STORE_LENGTH=4096 chars (CAP_CONTENT in caps.js),
|
|
422
|
+
// but the entry header keeps each *line* well under 4KB after sanitization
|
|
423
|
+
// (single-line collapse). Audit MED #8 / F-COR-1: doc-vs-code parity fix --
|
|
424
|
+
// the prior text said 5000, which had drifted from the actual cap.
|
|
425
|
+
|
|
399
426
|
function appendLine(filepath, line) {
|
|
400
427
|
try {
|
|
401
428
|
if (!existsSync(filepath)) {
|
|
@@ -569,28 +596,110 @@ function readGlobalKnowledge() {
|
|
|
569
596
|
).join('\n\n');
|
|
570
597
|
}
|
|
571
598
|
|
|
572
|
-
function
|
|
599
|
+
function getRecentJournalEntries(count = 5) {
|
|
600
|
+
const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
|
|
601
|
+
if (!journal) return '';
|
|
602
|
+
const entries = journal.split('\n').filter(l => /^- \[\d{4}-/.test(l));
|
|
603
|
+
return entries.slice(-count).join('\n');
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// H5.6 — dedup needs `recents` shaped as { id, content } for findNearDuplicate.
|
|
607
|
+
// We synthesize id from the timestamp (project-journal entries are unique by ts
|
|
608
|
+
// down to ms; collisions would only happen on near-simultaneous writes which
|
|
609
|
+
// our atomic-append + fs flush ordering already serialise).
|
|
610
|
+
function getRecentMemoriesForDedup(limit = 50) {
|
|
611
|
+
const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
|
|
612
|
+
if (!journal) return [];
|
|
613
|
+
const lines = journal.split('\n').filter(l => /^- \[\d{4}-/.test(l));
|
|
614
|
+
// Most-recent-last in the file → reverse so findNearDuplicate sees newest first.
|
|
615
|
+
const slice = lines.slice(-limit).reverse();
|
|
616
|
+
return slice.map(line => {
|
|
617
|
+
// Format: "- [<iso>] <body>" → { id: iso, content: body }
|
|
618
|
+
const m = line.match(/^- \[([^\]]+)\]\s*(.*)$/);
|
|
619
|
+
if (!m) return null;
|
|
620
|
+
return { id: m[1], content: m[2] };
|
|
621
|
+
}).filter(Boolean);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// H5.5 — sidecar file for structured facts (one JSON object per line).
|
|
625
|
+
// Append-only; consumed by handleRecall({context_hint:'facts'}).
|
|
626
|
+
const FACTS_FILE = join(MEMORY_DIR, 'facts.jsonl');
|
|
627
|
+
|
|
628
|
+
// Stable short id for joining a fact back to its journal entry. We don't have
|
|
629
|
+
// a uuid; the journal-line text itself + ts is unique enough for cross-ref.
|
|
630
|
+
function factMemoryIdFor(journalEntryText) {
|
|
631
|
+
return 'm-' + createHash('sha256')
|
|
632
|
+
.update(String(journalEntryText) + ':' + Date.now())
|
|
633
|
+
.digest('hex')
|
|
634
|
+
.slice(0, 10);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function appendFactsToSidecar(facts, meta) {
|
|
638
|
+
if (!Array.isArray(facts) || facts.length === 0) return { ok: true, written: 0 };
|
|
573
639
|
try {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
640
|
+
const lines = facts.map(f => factToJsonl(f, meta)).join('\n') + '\n';
|
|
641
|
+
appendFileSync(FACTS_FILE, lines);
|
|
642
|
+
return { ok: true, written: facts.length };
|
|
643
|
+
} catch (err) {
|
|
644
|
+
// Non-fatal: facts are augmentation, not source-of-truth. Journal already
|
|
645
|
+
// captured the raw memory.
|
|
646
|
+
return { ok: false, code: err.code || 'EUNKNOWN', message: err.message };
|
|
578
647
|
}
|
|
579
648
|
}
|
|
580
649
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
650
|
+
// v1.5.0 audit H5.4 — sibling SQL store for bi-temporal facts. Lives next to
|
|
651
|
+
// facts.jsonl so the JSONL sidecar (append-only timeline log) and the SQL
|
|
652
|
+
// table (queryable point-in-time view) stay co-located. Lazy-opened on first
|
|
653
|
+
// use so a project that never stores a memory never pays the better-sqlite3
|
|
654
|
+
// load cost.
|
|
655
|
+
const FACTS_DB_FILE = join(MEMORY_DIR, 'facts.db');
|
|
656
|
+
let _factsDbHandle = null;
|
|
657
|
+
function getFactsDb() {
|
|
658
|
+
if (_factsDbHandle) return _factsDbHandle;
|
|
659
|
+
try {
|
|
660
|
+
_factsDbHandle = openTemporalDbSync(FACTS_DB_FILE);
|
|
661
|
+
return _factsDbHandle;
|
|
662
|
+
} catch (err) {
|
|
663
|
+
// Non-fatal. JSONL sidecar still gets written; we just lose the bi-temporal
|
|
664
|
+
// SQL view for this process. Surface via stderr so operators see the
|
|
665
|
+
// degradation but the user-facing store result still says "ok".
|
|
666
|
+
try {
|
|
667
|
+
process.stderr.write(`[ijfw temporal] facts.db unavailable (${err.code || err.message}); SQL fact view degraded\n`);
|
|
668
|
+
} catch { /* stderr may be detached */ }
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
587
671
|
}
|
|
588
672
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
673
|
+
// writeFactsBitemporal -- for each extracted fact, close any prior currently-
|
|
674
|
+
// valid fact with the same (subject, predicate) but different object, then
|
|
675
|
+
// insert the new fact. Wrapped in a per-fact transaction inside temporal.js's
|
|
676
|
+
// storeFactBitemporal helper. Best-effort: a SQL failure logs to stderr but
|
|
677
|
+
// never breaks the journal-or-JSONL path.
|
|
678
|
+
function writeFactsBitemporal(facts, meta) {
|
|
679
|
+
if (!Array.isArray(facts) || facts.length === 0) return { ok: true, written: 0, invalidated: 0 };
|
|
680
|
+
const db = getFactsDb();
|
|
681
|
+
if (!db) return { ok: false, code: 'ENOFACTSDB', written: 0, invalidated: 0 };
|
|
682
|
+
let written = 0;
|
|
683
|
+
let invalidated = 0;
|
|
684
|
+
for (const f of facts) {
|
|
685
|
+
try {
|
|
686
|
+
const r = storeFactBitemporal(db, {
|
|
687
|
+
subject: f.subject,
|
|
688
|
+
predicate: f.predicate,
|
|
689
|
+
object: f.object,
|
|
690
|
+
confidence: f.confidence,
|
|
691
|
+
memory_id: meta && meta.memory_id,
|
|
692
|
+
source: meta && meta.source,
|
|
693
|
+
}, meta && meta.ts);
|
|
694
|
+
invalidated += r.invalidated;
|
|
695
|
+
if (!r.deduped) written += 1;
|
|
696
|
+
} catch (err) {
|
|
697
|
+
try {
|
|
698
|
+
process.stderr.write(`[ijfw temporal] storeFactBitemporal failed: ${err.message}\n`);
|
|
699
|
+
} catch { /* stderr may be detached */ }
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return { ok: true, written, invalidated };
|
|
594
703
|
}
|
|
595
704
|
|
|
596
705
|
// --- Cross-project registry (Phase 3) ---
|
|
@@ -635,55 +744,81 @@ function readProjectMemory(projectPath) {
|
|
|
635
744
|
};
|
|
636
745
|
}
|
|
637
746
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
for (const entry of readRegistry()) {
|
|
645
|
-
const tag = basename(entry.path);
|
|
646
|
-
const mem = readProjectMemory(entry.path);
|
|
647
|
-
for (const [src, content] of Object.entries(mem)) {
|
|
648
|
-
if (!content) continue;
|
|
649
|
-
const lines = content.split('\n');
|
|
650
|
-
for (let i = 0; i < lines.length; i++) {
|
|
651
|
-
const line = lines[i];
|
|
652
|
-
if (line.trim().length === 0) continue;
|
|
653
|
-
const score = keywords.filter(k => line.toLowerCase().includes(k)).length;
|
|
654
|
-
if (score > 0) {
|
|
655
|
-
results.push({
|
|
656
|
-
source: `${src}@${tag}`,
|
|
657
|
-
line: i + 1,
|
|
658
|
-
content: `[project:${tag}] ${line.trim().substring(0, 200)}`,
|
|
659
|
-
score
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
results.sort((a, b) => b.score - a.score);
|
|
666
|
-
return results.slice(0, limit);
|
|
667
|
-
}
|
|
747
|
+
// v1.5.1 H1.5 (audit memory-engine.md F-FUN-4): the legacy naive-keyword-count
|
|
748
|
+
// `searchAcrossProjects` was removed. `scope:'all'` now routes to the BM25-
|
|
749
|
+
// ranked `crossProjectSearch` in cross-project-search.js (which already
|
|
750
|
+
// returns the same `{ source, line, content, score }` shape via the
|
|
751
|
+
// `[project:<name>]` content prefix). Two parallel cross-project surfaces
|
|
752
|
+
// existed; the worse one was the default. Now there is one.
|
|
668
753
|
|
|
669
754
|
// --- Search ---
|
|
670
755
|
// P5.1 / H4 -- BM25 ranking over line-level docs. Source tags and line
|
|
671
756
|
// numbers preserved so callers get the same output shape; scoring is
|
|
672
757
|
// BM25 (IDF + TF + length-normalized) with per-source boost. Team tier
|
|
673
758
|
// ranks first via a score bump for ties.
|
|
674
|
-
|
|
759
|
+
//
|
|
760
|
+
// r17 (cold-tier wire-up): when IJFW_VECTORS=on AND @xenova/transformers is
|
|
761
|
+
// installed AND the model is loadable, the BM25 top-K is reranked via cosine
|
|
762
|
+
// similarity over the snippet text (blended weights wBm25=0.6, wVec=0.4 from
|
|
763
|
+
// vectors.js defaults). Async because embedder load + embed() are async. The
|
|
764
|
+
// `opts.embedder` parameter lets tests inject a mock embedder without
|
|
765
|
+
// installing @xenova/transformers.
|
|
766
|
+
|
|
767
|
+
// v1.5.0 wire-W1.C — lazy memory.db handle for the embedding cache.
|
|
768
|
+
// Opens once per process and reuses thereafter. Returns null when the
|
|
769
|
+
// project has no .ijfw/index/memory.db (e.g. fresh checkout that never
|
|
770
|
+
// stored a memory) so the rerank still falls back to live embed.
|
|
771
|
+
let _memoryDbForRerank = null;
|
|
772
|
+
async function getMemoryDbForRerank() {
|
|
773
|
+
if (_memoryDbForRerank) return _memoryDbForRerank;
|
|
774
|
+
try {
|
|
775
|
+
const { openDb } = await import('./memory/fts5.js');
|
|
776
|
+
_memoryDbForRerank = await openDb(PROJECT_DIR);
|
|
777
|
+
return _memoryDbForRerank;
|
|
778
|
+
} catch {
|
|
779
|
+
_memoryDbForRerank = null;
|
|
780
|
+
return null;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async function searchMemory(query, limit = 10, scope = 'project', opts = {}) {
|
|
675
785
|
limit = Math.min(Math.max(1, limit | 0), MAX_SEARCH_RESULTS);
|
|
676
|
-
if (scope === 'all')
|
|
786
|
+
if (scope === 'all') {
|
|
787
|
+
// v1.5.1 H1.5 (audit memory-engine.md F-FUN-4): use BM25-ranked
|
|
788
|
+
// crossProjectSearch, not the legacy naive keyword-count scan.
|
|
789
|
+
const projects = readRegistry();
|
|
790
|
+
if (projects.length === 0) return [];
|
|
791
|
+
return crossProjectSearch(query, projects, readProjectMemory, { limit });
|
|
792
|
+
}
|
|
677
793
|
|
|
678
794
|
const sources = [
|
|
679
|
-
{ name: 'team', content: readTeamKnowledge(), boost: 1.25 },
|
|
680
|
-
{ name: 'knowledge', content: readKnowledgeBase(), boost: 1.15 },
|
|
681
|
-
{ name: 'journal', content: readOr(join(MEMORY_DIR, 'project-journal.md')), boost: 1.0 },
|
|
682
|
-
{ name: 'handoff', content: readHandoff(), boost: 1.1 },
|
|
683
|
-
{ name: 'global', content: readGlobalKnowledge(), boost: 0.95 },
|
|
684
|
-
{ name: 'claude-native', content: readNativeClaudeMemory(), boost: 0.95 },
|
|
795
|
+
{ name: 'team', content: readTeamKnowledge(), boost: 1.25, path: join(MEMORY_DIR, 'team-knowledge.md') },
|
|
796
|
+
{ name: 'knowledge', content: readKnowledgeBase(), boost: 1.15, path: join(MEMORY_DIR, 'knowledge.md') },
|
|
797
|
+
{ name: 'journal', content: readOr(join(MEMORY_DIR, 'project-journal.md')), boost: 1.0, path: join(MEMORY_DIR, 'project-journal.md') },
|
|
798
|
+
{ name: 'handoff', content: readHandoff(), boost: 1.1, path: join(MEMORY_DIR, 'handoff.md') },
|
|
799
|
+
{ name: 'global', content: readGlobalKnowledge(), boost: 0.95, path: null },
|
|
800
|
+
{ name: 'claude-native', content: readNativeClaudeMemory(), boost: 0.95, path: null },
|
|
685
801
|
];
|
|
686
802
|
|
|
803
|
+
// v1.5.0 audit MED #12 (memory-engine.md F-FUN-5): recency decay on the
|
|
804
|
+
// boosted map. Each source file has an mtime; per-result age (days since
|
|
805
|
+
// mtime) feeds Math.exp(-ageDays / 90), so a 90-day-old source decays
|
|
806
|
+
// by ~1/e (0.37) and a 1-year-old source by ~0.018. Fresh entries
|
|
807
|
+
// (< 1 day) stay essentially unchanged (~0.99). Sources without a file
|
|
808
|
+
// (global, claude-native) get no decay (multiplier 1.0).
|
|
809
|
+
const RECENCY_HALFLIFE_DAYS = 90;
|
|
810
|
+
const nowMs = Date.now();
|
|
811
|
+
const sourceDecay = new Map();
|
|
812
|
+
for (const src of sources) {
|
|
813
|
+
if (!src.path) { sourceDecay.set(src.name, 1); continue; }
|
|
814
|
+
let ageDays = 0;
|
|
815
|
+
try {
|
|
816
|
+
const st = statSync(src.path);
|
|
817
|
+
ageDays = Math.max(0, (nowMs - st.mtimeMs) / 86400000);
|
|
818
|
+
} catch { ageDays = 0; }
|
|
819
|
+
sourceDecay.set(src.name, Math.exp(-ageDays / RECENCY_HALFLIFE_DAYS));
|
|
820
|
+
}
|
|
821
|
+
|
|
687
822
|
const docs = [];
|
|
688
823
|
const meta = new Map();
|
|
689
824
|
for (const src of sources) {
|
|
@@ -702,19 +837,46 @@ function searchMemory(query, limit = 10, scope = 'project') {
|
|
|
702
837
|
const ranked = searchCorpus(query, docs, { limit: limit * 3 });
|
|
703
838
|
if (ranked.length === 0) return [];
|
|
704
839
|
|
|
705
|
-
|
|
840
|
+
// r17: cold-tier hybrid rerank. Pure no-op when vectors disabled OR
|
|
841
|
+
// embedder unavailable. Never throws into the caller.
|
|
842
|
+
//
|
|
843
|
+
// v1.5.0 wire-W1.C: when the caller didn't supply a db handle but the
|
|
844
|
+
// memory.db exists for this project, open it lazily + thread through so
|
|
845
|
+
// the embedding cache backs the rerank. The default modelId mirrors
|
|
846
|
+
// vectors.js DEFAULT_MODEL so first-call writes match cache reads from
|
|
847
|
+
// the same process on later calls.
|
|
848
|
+
//
|
|
849
|
+
// r20-MED fix: previously the lazy-open was SKIPPED whenever opts.embedder
|
|
850
|
+
// was supplied (intended as a test-seam guard). That meant any caller
|
|
851
|
+
// passing a custom embedder (e.g. an HTTP-backed one) lost the cache.
|
|
852
|
+
// Now: always lazy-open when !opts.db. Tests that want to disable the
|
|
853
|
+
// cache pass opts.db = null explicitly.
|
|
854
|
+
const rerankOpts = { ...opts };
|
|
855
|
+
if (!rerankOpts.db && rerankOpts.db !== null) {
|
|
856
|
+
try {
|
|
857
|
+
rerankOpts.db = await getMemoryDbForRerank();
|
|
858
|
+
} catch { /* memory db unavailable -- skip cache, fall back to live embed */ }
|
|
859
|
+
}
|
|
860
|
+
if (!rerankOpts.modelId) {
|
|
861
|
+
rerankOpts.modelId = process.env.IJFW_VECTORS_MODEL || 'Xenova/all-MiniLM-L6-v2';
|
|
862
|
+
}
|
|
863
|
+
const reranked = await maybeRerankWithVectors(query, ranked, rerankOpts);
|
|
864
|
+
|
|
865
|
+
const boosted = reranked.map(r => {
|
|
706
866
|
const m = meta.get(r.id);
|
|
867
|
+
const decay = sourceDecay.get(m.source) ?? 1;
|
|
707
868
|
return {
|
|
708
869
|
source: m.source,
|
|
709
870
|
line: m.line,
|
|
710
871
|
content: (r.snippet || '').substring(0, 200),
|
|
711
|
-
score: r.score * (m.boost || 1),
|
|
872
|
+
score: r.score * (m.boost || 1) * decay,
|
|
712
873
|
};
|
|
713
874
|
});
|
|
714
875
|
boosted.sort((a, b) => b.score - a.score);
|
|
715
876
|
return boosted.slice(0, limit);
|
|
716
877
|
}
|
|
717
878
|
|
|
879
|
+
|
|
718
880
|
// --- DESIGN picker (1.2.0 Phase 5) ---
|
|
719
881
|
// MCP-only delivery of the 12-template design catalog for OpenCode / Qwen
|
|
720
882
|
// Code / Kimi Code / OpenClaw / Aider. No new tool -- served via existing
|
|
@@ -805,7 +967,7 @@ const TOOLS = [
|
|
|
805
967
|
inputSchema: {
|
|
806
968
|
type: 'object',
|
|
807
969
|
properties: {
|
|
808
|
-
content: { type: 'string', description: 'Full statement of what to remember. Max
|
|
970
|
+
content: { type: 'string', description: 'Full statement of what to remember. Max 4096 chars. Sanitised on storage.' },
|
|
809
971
|
type: { type: 'string', enum: VALID_MEMORY_TYPES, description: 'Memory tier: decision or pattern -> knowledge base (frontmatter). handoff -> overwrites handoff.md. preference -> project-namespaced global. observation -> journal only.' },
|
|
810
972
|
summary: { type: 'string', description: 'Optional 1-line summary (≤80 chars). Used as the frontmatter name for decisions/patterns.' },
|
|
811
973
|
why: { type: 'string', description: 'Optional rationale -- why this decision was made. Populates the Why section in the knowledge base entry.' },
|
|
@@ -817,7 +979,7 @@ const TOOLS = [
|
|
|
817
979
|
},
|
|
818
980
|
{
|
|
819
981
|
name: 'ijfw_memory_search',
|
|
820
|
-
description: 'Keyword search across memory sources. Up to 20 results. Scope defaults to current project; pass scope:"all" to search across every IJFW project ever opened on this machine (results tagged [project:<name>]). Pass scope:"sandbox" to retrieve sandboxed ijfw_run output -- include label to get the full output of a specific run, or omit label to list all available sandbox entries.',
|
|
982
|
+
description: 'Keyword search across memory sources. Up to 20 results. Scope defaults to current project; pass scope:"all" to search across every IJFW project ever opened on this machine (results tagged [project:<name>]). Pass scope:"sandbox" to retrieve sandboxed ijfw_run output -- include label to get the full output of a specific run, or omit label to list all available sandbox entries. The query field also accepts colon-namespaced commands: "compute:<query>" hits the per-project FTS5 index, "graph:<query>" routes through the knowledge-graph search.',
|
|
821
983
|
inputSchema: {
|
|
822
984
|
type: 'object',
|
|
823
985
|
properties: {
|
|
@@ -844,6 +1006,21 @@ const TOOLS = [
|
|
|
844
1006
|
required: []
|
|
845
1007
|
}
|
|
846
1008
|
},
|
|
1009
|
+
{
|
|
1010
|
+
// v1.5.0 M5 (INT.6) -- bi-temporal facts MCP surface.
|
|
1011
|
+
name: 'ijfw_memory_facts',
|
|
1012
|
+
description: 'Query the bi-temporal facts table (subject/predicate/object timeline with valid_from / valid_to). Default: current-valid rows only. Pass history=true for full timeline; pass valid_at=<ISO-8601> for point-in-time. Subject + predicate are required.',
|
|
1013
|
+
inputSchema: {
|
|
1014
|
+
type: 'object',
|
|
1015
|
+
properties: {
|
|
1016
|
+
subject: { type: 'string', description: 'Fact subject (e.g. "v1.5.0").' },
|
|
1017
|
+
predicate: { type: 'string', description: 'Fact predicate (e.g. "ship_date").' },
|
|
1018
|
+
valid_at: { type: 'string', description: 'Optional ISO-8601 timestamp. Returns rows whose validity window covers this instant.' },
|
|
1019
|
+
history: { type: 'boolean', description: 'If true, return all rows (current + invalidated) ordered DESC by valid_from.' }
|
|
1020
|
+
},
|
|
1021
|
+
required: ['subject', 'predicate']
|
|
1022
|
+
}
|
|
1023
|
+
},
|
|
847
1024
|
{
|
|
848
1025
|
name: 'ijfw_prompt_check',
|
|
849
1026
|
description: 'Call on the first turn when the user prompt is short (<30 tokens) or likely vague. Returns whether the prompt is under-specified and a sharpening suggestion. Deterministic regex detector -- no LLM call. Use for Codex/Cursor/Windsurf/Copilot/Gemini where pre-prompt hooks are not available.',
|
|
@@ -861,8 +1038,8 @@ const TOOLS = [
|
|
|
861
1038
|
inputSchema: {
|
|
862
1039
|
type: 'object',
|
|
863
1040
|
properties: {
|
|
864
|
-
period: { type: 'string', enum: ['today', '7d', '30d', 'all'], description: 'Time window (default 7d).' },
|
|
865
|
-
metric: { type: 'string', enum: ['tokens', 'cost', 'sessions', 'routing'], description: 'Which metric to render (default tokens).' }
|
|
1041
|
+
period: { type: 'string', enum: ['today', '7d', '30d', 'all'], default: '7d', description: 'Time window (default 7d).' },
|
|
1042
|
+
metric: { type: 'string', enum: ['tokens', 'cost', 'sessions', 'routing'], default: 'tokens', description: 'Which metric to render (default tokens).' }
|
|
866
1043
|
},
|
|
867
1044
|
required: []
|
|
868
1045
|
}
|
|
@@ -883,7 +1060,7 @@ const TOOLS = [
|
|
|
883
1060
|
UPDATE_APPLY_TOOL,
|
|
884
1061
|
{
|
|
885
1062
|
name: 'ijfw_run',
|
|
886
|
-
description: 'Run a shell command. For commands likely to produce large output (builds, test suites, grep -r, log tails), use this instead of Bash -- full output is sandboxed to disk and a smart summary is returned to context. For git/nav/quick ops, use Bash directly.',
|
|
1063
|
+
description: 'Run a shell command. For commands likely to produce large output (builds, test suites, grep -r, log tails), use this instead of Bash -- full output is sandboxed to disk and a smart summary is returned to context. For git/nav/quick ops, use Bash directly. Also accepts colon-namespaced commands instead of a shell line: "compute:python", "compute:js", "index:<source>", "detect:project_type".',
|
|
887
1064
|
inputSchema: {
|
|
888
1065
|
type: 'object',
|
|
889
1066
|
properties: {
|
|
@@ -893,6 +1070,47 @@ const TOOLS = [
|
|
|
893
1070
|
},
|
|
894
1071
|
required: ['command'],
|
|
895
1072
|
},
|
|
1073
|
+
},
|
|
1074
|
+
{
|
|
1075
|
+
// v1.5.0 T13: ijfw_state — single MCP face for the state-SDK verb facade.
|
|
1076
|
+
// Absorbs the retired ijfw_subagent_post_done tool (post-done IS a state
|
|
1077
|
+
// transition → reachable as the `subagent.post-done` verb). All 20 frozen
|
|
1078
|
+
// verbs from STATE-SDK-CONTRACT §7 are reachable through this one tool,
|
|
1079
|
+
// keeping the MCP cap at 13/13. The same `query(verb, payload, ctx)` core
|
|
1080
|
+
// is also exposed as a JS import and a CLI colon-namespace (`ijfw state:<verb>`).
|
|
1081
|
+
name: 'ijfw_state',
|
|
1082
|
+
description: 'State-SDK verb facade — invoke any of the 20 frozen verbs over the canonical physical state files. The 20 verbs: workflow.get, workflow.set-phase, wave.get, wave.advance, wave.record-task, phase.plan-check, phase.complete, subagent.dispatch, subagent.checkpoint, subagent.post-done, event.emit, telemetry.record, roster.synthesize, roster.record, extension.set-active, decision.add, blocker.add, blocker.resolve, state.replay, state.validate. Single MCP face for the state-SDK; subagent.post-done is the verb that absorbed the retired ijfw_subagent_post_done tool. Returns the verb result with `ok` + `verbId` + verb-specific fields (see STATE-SDK-CONTRACT §7).',
|
|
1083
|
+
inputSchema: {
|
|
1084
|
+
type: 'object',
|
|
1085
|
+
properties: {
|
|
1086
|
+
verb: { type: 'string', description: 'Verb name from the frozen 20-verb registry (e.g. "workflow.get", "wave.advance", "subagent.post-done", "state.validate").' },
|
|
1087
|
+
payload: { type: 'object', description: 'Verb-specific payload (see STATE-SDK-CONTRACT §7 for each verb signature). Defaults to {} when omitted.' },
|
|
1088
|
+
projectRoot: { type: 'string', description: 'Project root for ctx (defaults to process.cwd()).' },
|
|
1089
|
+
subagentId: { type: 'string', description: 'Subagent id stamped on event/telemetry records (defaults to "parent").' },
|
|
1090
|
+
homeDir: { type: 'string', description: 'Home dir override for the homedir-scope active-extension file (defaults to process.env.HOME / USERPROFILE / os.homedir()).' },
|
|
1091
|
+
},
|
|
1092
|
+
required: ['verb'],
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
1095
|
+
{
|
|
1096
|
+
// v1.5.0-major W12-C N03: Trident-as-a-service. Multi-lens consensus
|
|
1097
|
+
// convergence (lock-in #47 — canonical Phase E). Dispatches all 3 lenses
|
|
1098
|
+
// (codex/gemini/claude by default) in parallel; if verdicts diverge,
|
|
1099
|
+
// re-runs with a CYCLE_SUMMARY of the disagreement until consensus or
|
|
1100
|
+
// maxIterations (default 3). Stall breaker halts on byte-identical
|
|
1101
|
+
// iterations. Slot 12 of the 13/13 tool cap.
|
|
1102
|
+
name: 'ijfw_cross_audit_converge',
|
|
1103
|
+
description: 'Multi-lens Trident audit with consensus convergence loop. Dispatches codex/gemini/claude in parallel against a commit range, detects verdict divergence, and re-runs with a cycle summary until consensus or maxIterations. Returns {verdict, iterations, findings, divergence?, stalled?}. Verdict: PASS / CONDITIONAL / FAIL / consensus_failed / UNREACHABLE.',
|
|
1104
|
+
inputSchema: {
|
|
1105
|
+
type: 'object',
|
|
1106
|
+
properties: {
|
|
1107
|
+
commitRange: { type: 'string', description: 'Git commit range to audit (e.g. "HEAD~1..HEAD", "main..feature/x"). Required.' },
|
|
1108
|
+
maxIterations: { type: 'number', minimum: 1, maximum: 10, description: 'Max convergence iterations (default 3, capped at 10). 1 → single-shot (fallback mode).' },
|
|
1109
|
+
lenses: { type: 'array', items: { type: 'string' }, description: 'Lens ids to dispatch (default ["codex","gemini","claude"]).' },
|
|
1110
|
+
autoFix: { type: 'boolean', description: 'v1.5.1 (T27) — opt-in consensus auto-fix. When true, after a non-PASS convergence the consensus code-fixer AUTOMATICALLY MODIFIES CODE: it runs an atomic per-finding fix loop (one revertable git commit per fix) over HIGH findings that 2+ lenses agreed on. SAFETY BOUNDS: the fixer can only write files inside the audited project root (path-containment guard refuses out-of-root paths) and touches at most 10 distinct files per run (change cap — beyond it it stops and reports rather than mass-rewriting). Logic bugs are deferred to humans, never auto-patched. Results surface on result.autoFix without changing the verdict. Default false — the audit is read-only unless you explicitly opt in.' },
|
|
1111
|
+
},
|
|
1112
|
+
required: ['commitRange'],
|
|
1113
|
+
},
|
|
896
1114
|
}
|
|
897
1115
|
];
|
|
898
1116
|
|
|
@@ -901,8 +1119,8 @@ const TOOLS = [
|
|
|
901
1119
|
function handleRecall({ context_hint, detail_level = 'standard', from_project }) {
|
|
902
1120
|
// Cross-project explicit pull. We bypass current-project sources and read
|
|
903
1121
|
// the target project's knowledge/handoff/journal directly. Search queries
|
|
904
|
-
// are routed through
|
|
905
|
-
// recall here is for "give me everything from X."
|
|
1122
|
+
// are routed through crossProjectSearch (BM25) via scope:'all' on the
|
|
1123
|
+
// search tool; recall here is for "give me everything from X."
|
|
906
1124
|
if (from_project) {
|
|
907
1125
|
const target = resolveProject(from_project);
|
|
908
1126
|
if (!target) {
|
|
@@ -949,11 +1167,163 @@ function handleRecall({ context_hint, detail_level = 'standard', from_project })
|
|
|
949
1167
|
return { text: getRecentJournalEntries(10) || 'No decisions recorded yet.' };
|
|
950
1168
|
}
|
|
951
1169
|
|
|
1170
|
+
// H5.5 / v1.5.0 H5.4 — structured facts feed.
|
|
1171
|
+
// context_hint === 'facts' -> only currently-valid facts (SQL
|
|
1172
|
+
// getValidAt(now)); fallback to raw
|
|
1173
|
+
// facts.jsonl if the SQL table is
|
|
1174
|
+
// empty/unavailable (back-compat for
|
|
1175
|
+
// pre-H5.4 installations).
|
|
1176
|
+
// context_hint === 'facts:history' -> full timeline including invalidated
|
|
1177
|
+
// rows (with their valid_from/valid_to
|
|
1178
|
+
// windows). If the hint is followed by
|
|
1179
|
+
// ":subject/predicate" we narrow to
|
|
1180
|
+
// that pair; otherwise return every
|
|
1181
|
+
// row.
|
|
1182
|
+
if (context_hint === 'facts' || (typeof context_hint === 'string' && context_hint.startsWith('facts:history'))) {
|
|
1183
|
+
const isHistory = typeof context_hint === 'string' && context_hint.startsWith('facts:history');
|
|
1184
|
+
try {
|
|
1185
|
+
const db = getFactsDb();
|
|
1186
|
+
if (db) {
|
|
1187
|
+
if (isHistory) {
|
|
1188
|
+
// Optional ":subject/predicate" narrow-down. Spec: "if subject+
|
|
1189
|
+
// predicate keys can be inferred from the recall query; else return
|
|
1190
|
+
// all facts with their validity windows".
|
|
1191
|
+
const tail = context_hint.slice('facts:history'.length).replace(/^:/, '').trim();
|
|
1192
|
+
let rows;
|
|
1193
|
+
if (tail) {
|
|
1194
|
+
const [subj, pred] = tail.split('/').map(s => s && s.trim()).filter(Boolean);
|
|
1195
|
+
if (subj && pred) {
|
|
1196
|
+
rows = temporalGetHistory(db, subj, pred);
|
|
1197
|
+
} else {
|
|
1198
|
+
rows = temporalGetAllFactsWithWindows(db);
|
|
1199
|
+
}
|
|
1200
|
+
} else {
|
|
1201
|
+
rows = temporalGetAllFactsWithWindows(db);
|
|
1202
|
+
}
|
|
1203
|
+
if (!rows || rows.length === 0) {
|
|
1204
|
+
return { text: 'No structured facts extracted yet. Store memories with key:value lines, "X uses Y", or "decided to ..." phrases to populate.' };
|
|
1205
|
+
}
|
|
1206
|
+
const lines = rows.map(r => JSON.stringify({
|
|
1207
|
+
subject: r.subject,
|
|
1208
|
+
predicate: r.predicate,
|
|
1209
|
+
object: r.object,
|
|
1210
|
+
confidence: r.confidence,
|
|
1211
|
+
valid_from: r.valid_from,
|
|
1212
|
+
valid_to: r.valid_to,
|
|
1213
|
+
memory_id: r.memory_id,
|
|
1214
|
+
source: r.source,
|
|
1215
|
+
}));
|
|
1216
|
+
if (detail_level === 'summary') {
|
|
1217
|
+
const tailLines = lines.slice(-5).join('\n');
|
|
1218
|
+
return { text: `## Fact history (${lines.length} rows)\n${tailLines}` };
|
|
1219
|
+
}
|
|
1220
|
+
return { text: `## Fact history (${lines.length} rows)\n${lines.join('\n')}` };
|
|
1221
|
+
}
|
|
1222
|
+
// Default: currently-valid facts only.
|
|
1223
|
+
const rows = temporalGetValidAt(db, new Date().toISOString());
|
|
1224
|
+
if (rows && rows.length > 0) {
|
|
1225
|
+
const lines = rows.map(r => JSON.stringify({
|
|
1226
|
+
subject: r.subject,
|
|
1227
|
+
predicate: r.predicate,
|
|
1228
|
+
object: r.object,
|
|
1229
|
+
confidence: r.confidence,
|
|
1230
|
+
valid_from: r.valid_from,
|
|
1231
|
+
memory_id: r.memory_id,
|
|
1232
|
+
source: r.source,
|
|
1233
|
+
}));
|
|
1234
|
+
if (detail_level === 'summary') {
|
|
1235
|
+
const tail = lines.slice(-5).join('\n');
|
|
1236
|
+
return { text: `## Currently-valid facts (${lines.length})\n${tail}` };
|
|
1237
|
+
}
|
|
1238
|
+
return { text: `## Currently-valid facts (${lines.length})\n${lines.join('\n')}` };
|
|
1239
|
+
}
|
|
1240
|
+
// SQL table empty -- fall through to JSONL back-compat path below.
|
|
1241
|
+
}
|
|
1242
|
+
if (!existsSync(FACTS_FILE)) {
|
|
1243
|
+
return { text: 'No structured facts extracted yet. Store memories with key:value lines, "X uses Y", or "decided to ..." phrases to populate.' };
|
|
1244
|
+
}
|
|
1245
|
+
const raw = readFileSync(FACTS_FILE, 'utf8');
|
|
1246
|
+
// detail_level === 'summary' → just count + a sample tail. Keeps token
|
|
1247
|
+
// usage bounded for session-start hydration.
|
|
1248
|
+
if (detail_level === 'summary') {
|
|
1249
|
+
const lines = raw.split('\n').filter(Boolean);
|
|
1250
|
+
const tail = lines.slice(-5).join('\n');
|
|
1251
|
+
return { text: `## Structured facts (${lines.length} total)\n${tail}` };
|
|
1252
|
+
}
|
|
1253
|
+
return { text: raw || 'No structured facts extracted yet.' };
|
|
1254
|
+
} catch (err) {
|
|
1255
|
+
return { text: `Facts feed unreadable: ${err.code || err.message}`, isError: true };
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
952
1259
|
const results = searchMemory(context_hint);
|
|
953
1260
|
if (results.length === 0) return { text: `No memories matching: ${context_hint}` };
|
|
954
1261
|
return { text: results.map(r => `[${r.source}] ${r.content}`).join('\n') };
|
|
955
1262
|
}
|
|
956
1263
|
|
|
1264
|
+
// v1.5.1 R4-H2 — wire the v1.5.0 memory-moat to the real write path.
|
|
1265
|
+
//
|
|
1266
|
+
// M1 (Obsidian wikilink/tag/meta indexing -> memory_links/_tags/_meta) and
|
|
1267
|
+
// M2 (A-Mem auto-linking) only fire inside memory/fts5.js#indexEntry. But the
|
|
1268
|
+
// production memory writers never called indexEntry: handleStore wrote the
|
|
1269
|
+
// markdown journal only, and search.js#autoIndex did a raw INSERT. So the
|
|
1270
|
+
// memory-moat's flagship — "memory that learns about you" — ran ONLY in the
|
|
1271
|
+
// benchmark harness. This helper routes a real ijfw_store through indexEntry,
|
|
1272
|
+
// which in one atomic INSERT also fires M1 (synchronous, idempotent) + M2
|
|
1273
|
+
// (fire-and-forget, env-gated via IJFW_AUTOLINK_OFF, budget-capped).
|
|
1274
|
+
//
|
|
1275
|
+
// Best-effort + fire-and-forget: handleStore stays synchronous and a missing
|
|
1276
|
+
// driver / unmigrated schema / DB error never breaks the markdown-or-JSONL
|
|
1277
|
+
// store path. The journal markdown remains the source of truth (hot tier);
|
|
1278
|
+
// the FTS5 row is the warm-tier mirror. Dedup safety: handleStore previously
|
|
1279
|
+
// did NO DB INSERT at all, so this is a NEW row, not a duplicate of an
|
|
1280
|
+
// existing write. search.js#autoIndex only batch-rebuilds when the FTS table
|
|
1281
|
+
// is empty (rowCount === 0) — it will skip an already-populated table — so a
|
|
1282
|
+
// store followed by a search cannot double-index the same entry.
|
|
1283
|
+
async function indexStoredEntryToFts5({ body, source, sessionId }) {
|
|
1284
|
+
if (typeof body !== 'string' || body.length === 0) return null;
|
|
1285
|
+
const fts5Mod = await import('./memory/fts5.js');
|
|
1286
|
+
const root = process.env.IJFW_PROJECT_DIR || PROJECT_DIR;
|
|
1287
|
+
const db = await fts5Mod.openDb(root);
|
|
1288
|
+
try {
|
|
1289
|
+
// indexEntry runs the ingest scrub gate + M1 indexObsidianRelations +
|
|
1290
|
+
// M2 autoLink internally. body is already sanitised + redacted by the
|
|
1291
|
+
// handleStore caller; the scrub gate re-running over already-clean text
|
|
1292
|
+
// is idempotent.
|
|
1293
|
+
const inserted = fts5Mod.indexEntry(db, {
|
|
1294
|
+
body,
|
|
1295
|
+
source: source || 'memory_store',
|
|
1296
|
+
session_id: sessionId || null,
|
|
1297
|
+
});
|
|
1298
|
+
// indexEntry dispatches M2 autoLink + the D2 graph auto-index as
|
|
1299
|
+
// fire-and-forget promises that still hold the db handle. We own the
|
|
1300
|
+
// handle here, so we MUST let those settle before closing the db —
|
|
1301
|
+
// otherwise autoLink races into a "database connection is not open"
|
|
1302
|
+
// error. Capture the promise references SYNCHRONOUSLY right after
|
|
1303
|
+
// indexEntry returns (no await in between) so an interleaved store
|
|
1304
|
+
// can't overwrite the module-level statics before we read them. Both
|
|
1305
|
+
// promises swallow their own failures, so awaiting them never rejects.
|
|
1306
|
+
// This keeps M2 wired on the real store path without changing
|
|
1307
|
+
// handleStore's fire-and-forget contract (the caller already treats
|
|
1308
|
+
// this whole function as fire-and-forget).
|
|
1309
|
+
const autoLinkP = fts5Mod.indexEntry.__lastAutoLinkPromise;
|
|
1310
|
+
const autoIndexP = typeof fts5Mod.__getLastAutoIndexPromise === 'function'
|
|
1311
|
+
? fts5Mod.__getLastAutoIndexPromise()
|
|
1312
|
+
: null;
|
|
1313
|
+
try { await autoLinkP; } catch { /* swallowed by indexEntry */ }
|
|
1314
|
+
try { await autoIndexP; } catch { /* swallowed by indexEntry */ }
|
|
1315
|
+
return inserted;
|
|
1316
|
+
} finally {
|
|
1317
|
+
try { fts5Mod.closeDb(db); } catch { /* best-effort */ }
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// Diagnostic hook for tests — holds the most recent FTS5/M1/M2 indexing
|
|
1322
|
+
// promise fired by handleStore so end-to-end tests can await deterministic
|
|
1323
|
+
// completion before asserting on memory_links / memory_tags. Production
|
|
1324
|
+
// callers do not read this.
|
|
1325
|
+
handleStore.__lastIndexPromise = null;
|
|
1326
|
+
|
|
957
1327
|
function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
|
|
958
1328
|
// --- Input Validation ---
|
|
959
1329
|
if (!content || typeof content !== 'string') {
|
|
@@ -986,23 +1356,94 @@ function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
|
|
|
986
1356
|
|
|
987
1357
|
// Sanitize ALL text fields -- never store raw user/agent text in markdown
|
|
988
1358
|
// that gets re-injected into a future LLM context.
|
|
989
|
-
|
|
1359
|
+
//
|
|
1360
|
+
// v1.5.1 R4-H3 — secret redaction. sanitizeContent strips prompt-injection
|
|
1361
|
+
// control chars but does NOT scrub secret-shaped tokens (API keys, OAuth
|
|
1362
|
+
// secrets). Without this, a secret pasted into a direct ijfw_store call
|
|
1363
|
+
// lands in .ijfw/memory/*.md cleartext and re-injects into every future
|
|
1364
|
+
// recall. The redactor is already wired into the FTS5 ingest path
|
|
1365
|
+
// (memory/fts5.js#indexEntry) and the auto-memorize path; this closes the
|
|
1366
|
+
// direct MCP store as the one remaining bypass. Redact AFTER sanitize so
|
|
1367
|
+
// the redaction labels ([REDACTED:*]) are never themselves scrubbed.
|
|
1368
|
+
const safeContent = redactSecrets(sanitizeContent(content));
|
|
990
1369
|
if (!safeContent) {
|
|
991
1370
|
return { text: 'content was empty after sanitisation (only control/format chars).', isError: true };
|
|
992
1371
|
}
|
|
993
|
-
const safeSummary = summary ? sanitizeContent(summary).substring(0, 120) : '';
|
|
994
|
-
const safeWhy = why ? sanitizeContent(why) : '';
|
|
995
|
-
const safeHow = how_to_apply ? sanitizeContent(how_to_apply) : '';
|
|
1372
|
+
const safeSummary = summary ? redactSecrets(sanitizeContent(summary)).substring(0, 120) : '';
|
|
1373
|
+
const safeWhy = why ? redactSecrets(sanitizeContent(why)) : '';
|
|
1374
|
+
const safeHow = how_to_apply ? redactSecrets(sanitizeContent(how_to_apply)) : '';
|
|
996
1375
|
|
|
997
1376
|
const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
|
|
998
1377
|
const journalEntry = `**${type}**${tagStr}: ${safeSummary || safeContent.substring(0, 200)}`;
|
|
999
1378
|
|
|
1379
|
+
// H5.6 — Semantic dedup BEFORE append. If this memory is a near-duplicate
|
|
1380
|
+
// of one already in the last N journal entries, short-circuit and return
|
|
1381
|
+
// the existing entry's id. handoff is exempt (always overwrites a single
|
|
1382
|
+
// file by design; deduping would silently drop a handoff swap).
|
|
1383
|
+
const dedupCfg = readDedupConfig();
|
|
1384
|
+
if (dedupCfg.enabled && type !== 'handoff') {
|
|
1385
|
+
const recents = getRecentMemoriesForDedup(dedupCfg.windowSize);
|
|
1386
|
+
// Dedup against the FULL journal-entry line shape -- that's what the next
|
|
1387
|
+
// call would see in `recents`, so comparing apples to apples.
|
|
1388
|
+
const dup = findNearDuplicate(journalEntry, recents);
|
|
1389
|
+
if (dup) {
|
|
1390
|
+
// Spec: emit a stderr line so the user/agent sees the elision.
|
|
1391
|
+
try {
|
|
1392
|
+
process.stderr.write(`[ijfw memory] dedup'd similar entry; keeping prior ${dup.match.id}\n`);
|
|
1393
|
+
} catch { /* stderr may be detached in test harness */ }
|
|
1394
|
+
return {
|
|
1395
|
+
text: `Dedup'd: similar memory already exists (${dup.match.id}, similarity=${dup.similarity.toFixed(2)}). Not appended.`,
|
|
1396
|
+
deduped: true,
|
|
1397
|
+
existing_id: dup.match.id,
|
|
1398
|
+
similarity: dup.similarity,
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1000
1403
|
// 1. Always append to journal (one-line timeline). Hard failure → report.
|
|
1001
1404
|
const journalResult = appendToJournal(journalEntry);
|
|
1002
1405
|
if (!journalResult.ok) {
|
|
1003
1406
|
return { text: `Memory journal is not writable (${journalResult.code}) -- check .ijfw/ directory permissions and retry.`, isError: true };
|
|
1004
1407
|
}
|
|
1005
1408
|
|
|
1409
|
+
// v1.5.1 R4-H2 — mirror the stored entry into the FTS5 warm tier, which
|
|
1410
|
+
// also fires M1 (Obsidian indexing) + M2 (A-Mem auto-linking). Index the
|
|
1411
|
+
// full content (already sanitised + redacted above) so [[wikilinks]],
|
|
1412
|
+
// #tags and [key:: value] metadata land in memory_links/_tags/_meta and
|
|
1413
|
+
// the auto-linker sees the real body. Fire-and-forget: handleStore stays
|
|
1414
|
+
// synchronous and a DB failure never breaks the markdown store. The
|
|
1415
|
+
// promise is exposed for tests that need deterministic completion.
|
|
1416
|
+
try {
|
|
1417
|
+
handleStore.__lastIndexPromise = indexStoredEntryToFts5({
|
|
1418
|
+
body: safeContent,
|
|
1419
|
+
source: `memory_store:${type}`,
|
|
1420
|
+
sessionId: null,
|
|
1421
|
+
}).catch((e) => {
|
|
1422
|
+
try { console.error('[ijfw memory] FTS5/M1/M2 index failed:', e?.message || e); } catch { /* never throw */ }
|
|
1423
|
+
return null;
|
|
1424
|
+
});
|
|
1425
|
+
} catch (e) {
|
|
1426
|
+
handleStore.__lastIndexPromise = null;
|
|
1427
|
+
try { console.error('[ijfw memory] FTS5 index dispatch failed:', e?.message || e); } catch { /* never throw */ }
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// H5.5 — Fact extraction AFTER successful append. Best-effort: a failure
|
|
1431
|
+
// here is logged in the return text but does NOT poison the store result.
|
|
1432
|
+
// Memory-id ties facts.jsonl rows back to their journal entry.
|
|
1433
|
+
const factMeta = {
|
|
1434
|
+
ts: new Date().toISOString(),
|
|
1435
|
+
memory_id: factMemoryIdFor(journalEntry),
|
|
1436
|
+
source: `memory_store:${type}`,
|
|
1437
|
+
};
|
|
1438
|
+
const facts = extractFacts(safeContent);
|
|
1439
|
+
appendFactsToSidecar(facts, factMeta);
|
|
1440
|
+
// v1.5.0 audit H5.4 — mirror to bi-temporal SQL store. For each fact,
|
|
1441
|
+
// closes any prior currently-valid fact with the same (subject, predicate)
|
|
1442
|
+
// but different object before inserting. Same-object stores are a no-op.
|
|
1443
|
+
// Wrapped in a per-fact transaction inside temporal.js. Best-effort: any
|
|
1444
|
+
// failure is logged to stderr but never breaks the journal-or-JSONL path.
|
|
1445
|
+
writeFactsBitemporal(facts, factMeta);
|
|
1446
|
+
|
|
1006
1447
|
// 2. Type-specific secondary writes. Each tracked so we report partial
|
|
1007
1448
|
// success accurately rather than lying about "stored."
|
|
1008
1449
|
const failures = [];
|
|
@@ -1107,22 +1548,60 @@ async function handlePrelude({ detail_level = 'summary' } = {}) {
|
|
|
1107
1548
|
const updateNudge = composeUpdateNudge();
|
|
1108
1549
|
if (updateNudge) parts.push(updateNudge, '');
|
|
1109
1550
|
|
|
1551
|
+
// v1.5.0 memory-moat M3 (INT.4): surface top-K recently-successful skills
|
|
1552
|
+
// at session start. The Wayland-pattern "more-you-use-it-better-it-gets"
|
|
1553
|
+
// feedback loop: every skill execution writes to skill_telemetry via the
|
|
1554
|
+
// state-SDK telemetry.record verb (INT.3); this block reads top-5 by
|
|
1555
|
+
// success-count and surfaces them as a hint to the model. Best-effort:
|
|
1556
|
+
// an unmigrated db, empty telemetry, or read failure all skip the block
|
|
1557
|
+
// silently — never breaks the prelude.
|
|
1558
|
+
try {
|
|
1559
|
+
const { topKSuccessfulSkills } = await import('./orchestrator/skill-telemetry.js');
|
|
1560
|
+
const Database = (await import('better-sqlite3')).default;
|
|
1561
|
+
const { join: joinP } = await import('node:path');
|
|
1562
|
+
const root = process.env.IJFW_PROJECT_DIR || process.cwd();
|
|
1563
|
+
const dbPath = joinP(root, '.ijfw', 'index', 'memory.db');
|
|
1564
|
+
if (existsSync(dbPath)) {
|
|
1565
|
+
const db = new Database(dbPath, { readonly: true });
|
|
1566
|
+
try {
|
|
1567
|
+
const top = topKSuccessfulSkills(db, { k: 5 });
|
|
1568
|
+
if (top.length > 0) {
|
|
1569
|
+
const names = top.map((r) => `${r.skill_id} (${r.success_count}×)`).join(', ');
|
|
1570
|
+
parts.push(
|
|
1571
|
+
'<ijfw-recommended-skills>',
|
|
1572
|
+
`Observed success this project: ${names}`,
|
|
1573
|
+
'</ijfw-recommended-skills>',
|
|
1574
|
+
'',
|
|
1575
|
+
);
|
|
1576
|
+
}
|
|
1577
|
+
} finally {
|
|
1578
|
+
try { db.close(); } catch { /* best-effort */ }
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
} catch { /* best-effort; never block the prelude */ }
|
|
1582
|
+
|
|
1110
1583
|
// 1.2.0 Phase 5: surface the DESIGN picker to platforms without a skills tree.
|
|
1111
1584
|
// Skip when the project already has a DESIGN.md (contract exists; no picker).
|
|
1585
|
+
// Built into a standalone block so the abstention path below can re-emit it:
|
|
1586
|
+
// a fresh project with no memory AND no DESIGN.md is exactly when the picker
|
|
1587
|
+
// matters most, so it must survive the thin-memory short-circuit.
|
|
1588
|
+
let designPickerBlock = '';
|
|
1112
1589
|
try {
|
|
1113
1590
|
if (!existsSync(join(PROJECT_DIR, 'DESIGN.md'))) {
|
|
1114
1591
|
const names = DESIGN_TEMPLATE_CATALOG.map(([n]) => n);
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1592
|
+
designPickerBlock = [
|
|
1593
|
+
'## Design picker',
|
|
1594
|
+
'No DESIGN.md in project. 12 curated templates available:',
|
|
1595
|
+
names.slice(0, 5).join(', ') + ',',
|
|
1596
|
+
names.slice(5, 10).join(', ') + ',',
|
|
1597
|
+
names.slice(10).join(', ') + '.',
|
|
1598
|
+
'',
|
|
1599
|
+
'Pick one: ijfw_memory_recall({context_hint: "design_template:<name>"}).',
|
|
1600
|
+
'Full catalog with descriptions: ijfw_memory_recall({context_hint: "design_template"}).',
|
|
1601
|
+
].join('\n');
|
|
1124
1602
|
}
|
|
1125
|
-
} catch { /*
|
|
1603
|
+
} catch { /* project dir unreadable -- skip picker block */ }
|
|
1604
|
+
if (designPickerBlock) parts.push(designPickerBlock, '');
|
|
1126
1605
|
|
|
1127
1606
|
// Team knowledge first -- shared decisions/patterns/stack rank above personal.
|
|
1128
1607
|
const team = readTeamKnowledge();
|
|
@@ -1221,16 +1700,51 @@ async function handlePrelude({ detail_level = 'summary' } = {}) {
|
|
|
1221
1700
|
// Best-effort; never fail the prelude on memory-feedback issues.
|
|
1222
1701
|
}
|
|
1223
1702
|
|
|
1703
|
+
// v1.5.0 audit-MED-update-M8 (F-REL-2): surface the last-N partial-deploy
|
|
1704
|
+
// alerts so a half-deployed extension is visible at next session-start.
|
|
1705
|
+
// Wrapped in try/catch — alert read failure must NEVER fail the prelude.
|
|
1706
|
+
try {
|
|
1707
|
+
const { renderDeployAlertsForPrelude } = await import('./deploy-alerts.js');
|
|
1708
|
+
const block = await renderDeployAlertsForPrelude({ limit: 10 });
|
|
1709
|
+
if (block && typeof block === 'string' && block.length > 0) {
|
|
1710
|
+
parts.push(block);
|
|
1711
|
+
}
|
|
1712
|
+
} catch {
|
|
1713
|
+
// Best-effort; never fail the prelude on deploy-alert read issues.
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1224
1716
|
parts.push('</ijfw-memory>');
|
|
1225
1717
|
|
|
1226
1718
|
const text = parts.join('\n');
|
|
1227
1719
|
if (text.length < 60) {
|
|
1228
1720
|
return { text: 'Fresh project -- no memory stored yet. Proceed normally.' };
|
|
1229
1721
|
}
|
|
1722
|
+
|
|
1723
|
+
// v1.5.0 audit MED #11 (memory-engine.md F-FUN-7): abstention.
|
|
1724
|
+
// If memory exists but the body content is thin AND there are no recent
|
|
1725
|
+
// journal entries, surface an honest abstention so the LLM doesn't try
|
|
1726
|
+
// to over-fit a half-page of stale frontmatter to the user's prompt.
|
|
1727
|
+
// Threshold: total content chars below MIN_CONTENT_CHARS for the
|
|
1728
|
+
// knowledge/team/handoff sources combined AND zero recent journal lines.
|
|
1729
|
+
const MIN_CONTENT_CHARS = 200;
|
|
1730
|
+
const knowledgeChars = (knowledge ? knowledge.length : 0) + (team ? team.length : 0) + (handoff ? handoff.length : 0);
|
|
1731
|
+
const recentLines = recent ? recent.split('\n').filter(l => l.trim()).length : 0;
|
|
1732
|
+
if (knowledgeChars < MIN_CONTENT_CHARS && recentLines === 0) {
|
|
1733
|
+
const abstain = [
|
|
1734
|
+
'<ijfw-memory>',
|
|
1735
|
+
'Memory present but nothing relevant to your prompt -- proceed and I\'ll store any decisions you make.',
|
|
1736
|
+
];
|
|
1737
|
+
// The DESIGN picker is independent of memory richness — preserve it
|
|
1738
|
+
// through the abstention path, otherwise a fresh project never sees it.
|
|
1739
|
+
if (designPickerBlock) abstain.push('', designPickerBlock);
|
|
1740
|
+
abstain.push('</ijfw-memory>');
|
|
1741
|
+
return { text: abstain.join('\n') };
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1230
1744
|
return { text };
|
|
1231
1745
|
}
|
|
1232
1746
|
|
|
1233
|
-
function handleSearch({ query, limit = 10, scope = 'project', label }) {
|
|
1747
|
+
async function handleSearch({ query, limit = 10, scope = 'project', label }) {
|
|
1234
1748
|
if (scope === 'sandbox') {
|
|
1235
1749
|
if (label) {
|
|
1236
1750
|
const content = readFromSandbox(label);
|
|
@@ -1258,7 +1772,49 @@ function handleSearch({ query, limit = 10, scope = 'project', label }) {
|
|
|
1258
1772
|
}
|
|
1259
1773
|
if (query.length > 500) query = query.substring(0, 500);
|
|
1260
1774
|
if (scope !== 'project' && scope !== 'all') scope = 'project';
|
|
1261
|
-
|
|
1775
|
+
|
|
1776
|
+
// v1.5.0 memory-moat M1 (INT.5): "dv:" prefix routes to the declarative
|
|
1777
|
+
// Dataview-grade query mode. Returns structured rows from the FTS5 +
|
|
1778
|
+
// memory_links/_tags/_meta join populated at write time by M1.3 /
|
|
1779
|
+
// INT.1. Best-effort: errors fall through to the standard NL/FTS5 path
|
|
1780
|
+
// so the new mode never breaks existing callers.
|
|
1781
|
+
if (query.startsWith('dv:')) {
|
|
1782
|
+
try {
|
|
1783
|
+
const body = query.slice(3).trim();
|
|
1784
|
+
const dvMod = await import('./memory/query-dataview.js');
|
|
1785
|
+
const fts5Mod = await import('./memory/fts5.js');
|
|
1786
|
+
const root = process.env.IJFW_PROJECT_DIR || process.cwd();
|
|
1787
|
+
const db = await fts5Mod.openDb(root);
|
|
1788
|
+
try {
|
|
1789
|
+
const parsed = dvMod.parseDataviewQuery(body);
|
|
1790
|
+
const result = dvMod.runDataviewQuery(db, parsed);
|
|
1791
|
+
const rows = result.rows.slice(0, limit);
|
|
1792
|
+
if (rows.length === 0) {
|
|
1793
|
+
return { text: `No results for dataview query: "${body}"`, mode: 'dataview', parsed };
|
|
1794
|
+
}
|
|
1795
|
+
// Render in the existing text shape so MCP clients that expect
|
|
1796
|
+
// a single string field keep working.
|
|
1797
|
+
return {
|
|
1798
|
+
text: rows
|
|
1799
|
+
.map((r) => `[id:${r.id} source:${r.source || '?'} created:${r.created_at}] ${(r.body || '').slice(0, 200)}`)
|
|
1800
|
+
.join('\n'),
|
|
1801
|
+
mode: 'dataview',
|
|
1802
|
+
parsed,
|
|
1803
|
+
rowCount: rows.length,
|
|
1804
|
+
};
|
|
1805
|
+
} finally {
|
|
1806
|
+
try { fts5Mod.closeDb(db); } catch { /* best-effort */ }
|
|
1807
|
+
}
|
|
1808
|
+
} catch (e) {
|
|
1809
|
+
return {
|
|
1810
|
+
text: `Dataview query failed: ${e && e.message ? e.message : String(e)}`,
|
|
1811
|
+
mode: 'dataview',
|
|
1812
|
+
isError: true,
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
const results = await searchMemory(query, limit, scope);
|
|
1262
1818
|
if (results.length === 0) {
|
|
1263
1819
|
const where = scope === 'all' ? ' across all projects' : '';
|
|
1264
1820
|
return { text: `No results for: "${query}"${where}` };
|
|
@@ -1374,32 +1930,6 @@ function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
|
|
|
1374
1930
|
return { text: out.join('\n') };
|
|
1375
1931
|
}
|
|
1376
1932
|
|
|
1377
|
-
function handleStatus() {
|
|
1378
|
-
const sessionCount = getSessionCount();
|
|
1379
|
-
const decisionCount = getDecisionCount();
|
|
1380
|
-
const hasKnowledge = existsSync(join(MEMORY_DIR, 'knowledge.md'));
|
|
1381
|
-
const hasHandoff = existsSync(join(MEMORY_DIR, 'handoff.md'));
|
|
1382
|
-
const hasGlobal = readGlobalKnowledge().trim().length > 0;
|
|
1383
|
-
|
|
1384
|
-
const parts = [];
|
|
1385
|
-
if (hasKnowledge) {
|
|
1386
|
-
const kb = readKnowledgeBase();
|
|
1387
|
-
const kbLines = kb.split('\n').filter(l => l.trim().startsWith('**')).length;
|
|
1388
|
-
parts.push(`Knowledge: ${kbLines} entries`);
|
|
1389
|
-
}
|
|
1390
|
-
if (sessionCount > 0 || decisionCount > 0) {
|
|
1391
|
-
parts.push(`History: ${sessionCount} sessions, ${decisionCount} decisions`);
|
|
1392
|
-
}
|
|
1393
|
-
if (hasHandoff) {
|
|
1394
|
-
const handoff = readHandoff();
|
|
1395
|
-
const statusLine = handoff.split('\n').find(l => l.trim().length > 0 && !l.startsWith('<!--') && !l.startsWith('#'));
|
|
1396
|
-
if (statusLine) parts.push(`Last: ${statusLine.trim().substring(0, 150)}`);
|
|
1397
|
-
}
|
|
1398
|
-
if (hasGlobal) parts.push('Project preferences loaded');
|
|
1399
|
-
|
|
1400
|
-
return { text: parts.join('\n') || 'Fresh project -- no memory yet.' };
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
1933
|
// --- MCP Protocol Handler (JSON-RPC 2.0 over stdio) ---
|
|
1404
1934
|
|
|
1405
1935
|
function createResponse(id, result) {
|
|
@@ -1471,11 +2001,133 @@ function handleMessage(msg) {
|
|
|
1471
2001
|
result = { text: JSON.stringify(r, null, 2), isError: !!(r && r.error) };
|
|
1472
2002
|
break;
|
|
1473
2003
|
}
|
|
2004
|
+
case 'ijfw_state': {
|
|
2005
|
+
// v1.5.0 T13: single MCP face for the state-SDK. Routes every call
|
|
2006
|
+
// into the same `query(verb, payload, ctx)` core that the JS module
|
|
2007
|
+
// (`./orchestrator/state-sdk.js`) and the CLI colon-namespace
|
|
2008
|
+
// (`ijfw state:<verb>`) use. The retired `ijfw_subagent_post_done`
|
|
2009
|
+
// tool is now reachable as the `subagent.post-done` verb.
|
|
2010
|
+
const a = args || {};
|
|
2011
|
+
if (typeof a.verb !== 'string' || a.verb.length === 0) {
|
|
2012
|
+
result = { text: JSON.stringify({ ok: false, error: 'verb (string) is required' }), isError: true };
|
|
2013
|
+
break;
|
|
2014
|
+
}
|
|
2015
|
+
try {
|
|
2016
|
+
const { query } = await import('./orchestrator/state-sdk.js');
|
|
2017
|
+
const payload = (a.payload && typeof a.payload === 'object') ? a.payload : {};
|
|
2018
|
+
const ctx = {
|
|
2019
|
+
projectRoot: typeof a.projectRoot === 'string' && a.projectRoot.length > 0
|
|
2020
|
+
? a.projectRoot
|
|
2021
|
+
: process.cwd(),
|
|
2022
|
+
};
|
|
2023
|
+
if (typeof a.subagentId === 'string' && a.subagentId.length > 0) ctx.subagentId = a.subagentId;
|
|
2024
|
+
if (typeof a.homeDir === 'string' && a.homeDir.length > 0) ctx.homeDir = a.homeDir;
|
|
2025
|
+
const r = await query(a.verb, payload, ctx);
|
|
2026
|
+
// A verdict-fail refusal (Model 4) is the verb's correct hard-block —
|
|
2027
|
+
// surface `isError: true` so the orchestrator-LLM treats it as a
|
|
2028
|
+
// hard stop rather than an advisory note (mirrors the prior
|
|
2029
|
+
// ijfw_subagent_post_done `block: true` contract).
|
|
2030
|
+
const refused = r && r.refused === true;
|
|
2031
|
+
result = { text: JSON.stringify(r, null, 2), isError: !!refused };
|
|
2032
|
+
} catch (err) {
|
|
2033
|
+
const msg = err && err.message ? err.message : String(err);
|
|
2034
|
+
result = { text: JSON.stringify({ ok: false, error: msg }), isError: true };
|
|
2035
|
+
}
|
|
2036
|
+
break;
|
|
2037
|
+
}
|
|
1474
2038
|
case 'ijfw_update_apply': {
|
|
1475
2039
|
const r = ijfwUpdateApply(args || {});
|
|
1476
2040
|
result = { text: JSON.stringify(r, null, 2), isError: r && r.status === 'error' };
|
|
1477
2041
|
break;
|
|
1478
2042
|
}
|
|
2043
|
+
case 'ijfw_cross_audit_converge': {
|
|
2044
|
+
// v1.5.0-major W12-C N03: Trident-as-a-service.
|
|
2045
|
+
const a = args || {};
|
|
2046
|
+
if (!a.commitRange || typeof a.commitRange !== 'string') {
|
|
2047
|
+
result = { text: JSON.stringify({ error: 'commitRange (string) is required' }), isError: true };
|
|
2048
|
+
break;
|
|
2049
|
+
}
|
|
2050
|
+
const { runPhaseEConverge, defaultConvergeDispatch } = await import('./cross-orchestrator.js');
|
|
2051
|
+
try {
|
|
2052
|
+
const r = await runPhaseEConverge({
|
|
2053
|
+
commitRange: a.commitRange,
|
|
2054
|
+
maxIterations: typeof a.maxIterations === 'number' ? a.maxIterations : 3,
|
|
2055
|
+
lenses: Array.isArray(a.lenses) && a.lenses.length > 0 ? a.lenses : undefined,
|
|
2056
|
+
dispatch: defaultConvergeDispatch,
|
|
2057
|
+
projectRoot: process.cwd(),
|
|
2058
|
+
// v1.5.1 R4-H4 — opt-in consensus auto-fix (T27). Threaded
|
|
2059
|
+
// from the tool schema so the code-fixer can genuinely fire;
|
|
2060
|
+
// default false so the audit stays non-mutating unless the
|
|
2061
|
+
// caller explicitly asks for it.
|
|
2062
|
+
autoFix: a.autoFix === true,
|
|
2063
|
+
});
|
|
2064
|
+
const isErr = r.verdict === 'consensus_failed' || r.verdict === 'FAIL' || r.verdict === 'UNREACHABLE';
|
|
2065
|
+
// v1.5.1 W2.D — emit a canonical gate-result block through the
|
|
2066
|
+
// gate-result-formatter so this Trident-as-a-service surface is
|
|
2067
|
+
// consistent with the Trident gate (dispatch.js) and preflight
|
|
2068
|
+
// gates. appendGateResult guarantees the fenced block is the
|
|
2069
|
+
// LAST content emitted and is idempotent. Failure to format the
|
|
2070
|
+
// block must NOT clobber the verdict payload — observability,
|
|
2071
|
+
// not correctness.
|
|
2072
|
+
let convergeText = JSON.stringify(r, null, 2);
|
|
2073
|
+
try {
|
|
2074
|
+
const { emitGateResult } = await import('./gate-result.js');
|
|
2075
|
+
const { appendGateResult } = await import('./gate-result-formatter.js');
|
|
2076
|
+
// Map the converge verdict onto a schema-valid gate status.
|
|
2077
|
+
const VERDICT_TO_STATUS = {
|
|
2078
|
+
PASS: 'PASS',
|
|
2079
|
+
CONDITIONAL: 'CONDITIONAL',
|
|
2080
|
+
WARN: 'WARN',
|
|
2081
|
+
FLAG: 'FLAG',
|
|
2082
|
+
FAIL: 'FAIL',
|
|
2083
|
+
consensus_failed: 'FAIL',
|
|
2084
|
+
UNREACHABLE: 'FAIL',
|
|
2085
|
+
INCONCLUSIVE: 'FLAG',
|
|
2086
|
+
};
|
|
2087
|
+
const gateStatus = VERDICT_TO_STATUS[r.verdict] || 'FLAG';
|
|
2088
|
+
const block = await emitGateResult(
|
|
2089
|
+
{
|
|
2090
|
+
gate: 'cross-audit',
|
|
2091
|
+
status: gateStatus,
|
|
2092
|
+
lenses: [],
|
|
2093
|
+
affected_artifacts: [],
|
|
2094
|
+
accounting: {
|
|
2095
|
+
duration_ms:
|
|
2096
|
+
typeof r.duration_ms === 'number' ? r.duration_ms : 0,
|
|
2097
|
+
lenses_invoked: Array.isArray(a.lenses)
|
|
2098
|
+
? a.lenses.length
|
|
2099
|
+
: 0,
|
|
2100
|
+
cost_usd: null,
|
|
2101
|
+
},
|
|
2102
|
+
remediation: [],
|
|
2103
|
+
},
|
|
2104
|
+
{ projectRoot: process.cwd() },
|
|
2105
|
+
);
|
|
2106
|
+
// emitGateResult returns the fenced block as a string; the
|
|
2107
|
+
// formatter validates it back into an object before append.
|
|
2108
|
+
const parsed = JSON.parse(
|
|
2109
|
+
block.replace(/^```gate-result\n/, '').replace(/\n```$/, ''),
|
|
2110
|
+
);
|
|
2111
|
+
convergeText = appendGateResult(convergeText, parsed);
|
|
2112
|
+
} catch (gateErr) {
|
|
2113
|
+
try {
|
|
2114
|
+
const msg =
|
|
2115
|
+
gateErr && gateErr.message
|
|
2116
|
+
? gateErr.message
|
|
2117
|
+
: String(gateErr);
|
|
2118
|
+
process.stderr.write(
|
|
2119
|
+
`ijfw: cross_audit_converge gate-result emit failed: ${msg}\n`,
|
|
2120
|
+
);
|
|
2121
|
+
} catch {
|
|
2122
|
+
/* never crash the tool on a logging-channel failure */
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
result = { text: convergeText, isError: isErr };
|
|
2126
|
+
} catch (err) {
|
|
2127
|
+
result = { text: JSON.stringify({ error: err && err.message ? err.message : String(err) }), isError: true };
|
|
2128
|
+
}
|
|
2129
|
+
break;
|
|
2130
|
+
}
|
|
1479
2131
|
case 'ijfw_memory_recall':
|
|
1480
2132
|
result = handleRecall(args || {});
|
|
1481
2133
|
emitRecallObservation(args || {});
|
|
@@ -1504,15 +2156,18 @@ function handleMessage(msg) {
|
|
|
1504
2156
|
break;
|
|
1505
2157
|
}
|
|
1506
2158
|
}
|
|
1507
|
-
result = handleSearch(searchArgs);
|
|
2159
|
+
result = await handleSearch(searchArgs);
|
|
1508
2160
|
break;
|
|
1509
2161
|
}
|
|
1510
|
-
case 'ijfw_memory_status':
|
|
1511
|
-
result = handleStatus();
|
|
1512
|
-
break;
|
|
1513
2162
|
case 'ijfw_memory_prelude':
|
|
1514
2163
|
result = await handlePrelude(args || {});
|
|
1515
2164
|
break;
|
|
2165
|
+
case 'ijfw_memory_facts': {
|
|
2166
|
+
// v1.5.0 M5 (INT.6) -- surface bi-temporal facts read path.
|
|
2167
|
+
const mod = await import('./memory-facts-handler.js');
|
|
2168
|
+
result = await mod.handleMemoryFacts(args || {});
|
|
2169
|
+
break;
|
|
2170
|
+
}
|
|
1516
2171
|
case 'ijfw_metrics':
|
|
1517
2172
|
result = handleMetrics(args || {});
|
|
1518
2173
|
break;
|
|
@@ -1616,6 +2271,27 @@ function handleMessage(msg) {
|
|
|
1616
2271
|
}
|
|
1617
2272
|
}
|
|
1618
2273
|
|
|
2274
|
+
// --- B17 WebSocket revocation client (dynamic-import gate) ---
|
|
2275
|
+
// extension-registry-ws.js is dormant by default. Its docstring contract:
|
|
2276
|
+
// "Imported via `await import(...)` ONLY when `process.env.IJFW_REGISTRY_WS_URL`
|
|
2277
|
+
// is set at startup." Firing the gate here at MCP startup keeps the module out
|
|
2278
|
+
// of the import graph entirely unless the operator opts in via the env var, so
|
|
2279
|
+
// MCP startup never opens a socket for the common (unset) case. Best-effort:
|
|
2280
|
+
// a failed WS bind must never block the stdio transport.
|
|
2281
|
+
if (process.env.IJFW_REGISTRY_WS_URL || process.env.IJFW_REGISTRY_WS_SOURCE) {
|
|
2282
|
+
(async () => {
|
|
2283
|
+
try {
|
|
2284
|
+
const { initWsClient } = await import('./extension-registry-ws.js');
|
|
2285
|
+
const res = await initWsClient();
|
|
2286
|
+
if (!res.ok) {
|
|
2287
|
+
process.stderr.write(`IJFW: WS revocation client not started: ${res.error}\n`);
|
|
2288
|
+
}
|
|
2289
|
+
} catch (err) {
|
|
2290
|
+
process.stderr.write(`IJFW: WS revocation client init failed: ${err && err.message ? err.message : err}\n`);
|
|
2291
|
+
}
|
|
2292
|
+
})();
|
|
2293
|
+
}
|
|
2294
|
+
|
|
1619
2295
|
// --- stdio Transport ---
|
|
1620
2296
|
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
1621
2297
|
|
|
@@ -1665,4 +2341,11 @@ process.on('unhandledRejection', (err) => {
|
|
|
1665
2341
|
// Export for tests (Node ESM allows this -- only consumed when imported, not on stdio run)
|
|
1666
2342
|
// gatePermissionAndQuota is exported inline at its declaration above (B16/SEC-M-03)
|
|
1667
2343
|
// so test-server-quota-integration.js can drive it without spinning a server.
|
|
1668
|
-
|
|
2344
|
+
// H5.5 / H5.6 — expose handleStore/handleRecall + path/helpers so the ingest
|
|
2345
|
+
// integration test can drive the full pipeline without spawning a subprocess.
|
|
2346
|
+
export {
|
|
2347
|
+
sanitizeContent, atomicWrite, readMarkdownFile, PROJECT_HASH,
|
|
2348
|
+
handleStore, handleRecall, handleSearch, handlePrelude,
|
|
2349
|
+
MEMORY_DIR, FACTS_FILE, FACTS_DB_FILE,
|
|
2350
|
+
getFactsDb,
|
|
2351
|
+
};
|