@ijfw/memory-server 1.5.1 → 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/package.json +6 -5
- 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 +132 -5
- package/src/cross-orchestrator.js +2 -2
- package/src/dashboard-server.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/memory/auto-linker.js +5 -1
- 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 +31 -2
- package/src/memory/obsidian-parser.js +3 -1
- package/src/memory/reader.js +2 -1
- package/src/memory/search.js +144 -16
- package/src/memory/temporal.js +40 -1
- package/src/orchestrator/agents-md-blackboard.js +114 -1
- package/src/orchestrator/discipline-selector.js +276 -0
- package/src/orchestrator/merge-block-aware.js +15 -5
- package/src/orchestrator/state-sdk.js +42 -4
- package/src/orchestrator/wave-state.js +38 -0
- package/src/recovery/code-fixer.js +1 -1
- package/src/server.js +290 -75
- package/src/update-apply.js +1 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// IJFW v1.5.2 -- brain promotion suggester.
|
|
2
|
+
//
|
|
3
|
+
// Walks per-project db paths and surfaces (subject, predicate, object) triples
|
|
4
|
+
// that appear across >= minProjects (default 3) projects. Promotion is ALWAYS
|
|
5
|
+
// operator-confirmed -- this module only surfaces candidates.
|
|
6
|
+
|
|
7
|
+
import Database from 'better-sqlite3';
|
|
8
|
+
|
|
9
|
+
export function suggestPromotions({ projectDbs, minProjects = 3 } = {}) {
|
|
10
|
+
if (!Array.isArray(projectDbs) || projectDbs.length === 0) return [];
|
|
11
|
+
const tally = new Map();
|
|
12
|
+
for (const entry of projectDbs) {
|
|
13
|
+
const { name, dbPath } = entry;
|
|
14
|
+
let db;
|
|
15
|
+
try { db = new Database(dbPath, { readonly: true, fileMustExist: true }); }
|
|
16
|
+
catch { continue; }
|
|
17
|
+
try {
|
|
18
|
+
const rows = db.prepare(`
|
|
19
|
+
SELECT DISTINCT subject, predicate, object
|
|
20
|
+
FROM facts
|
|
21
|
+
WHERE valid_to IS NULL
|
|
22
|
+
`).all();
|
|
23
|
+
for (const r of rows) {
|
|
24
|
+
const key = `${r.subject || ''}|${r.predicate || ''}|${r.object || ''}`;
|
|
25
|
+
if (!tally.has(key)) tally.set(key, new Set());
|
|
26
|
+
tally.get(key).add(name);
|
|
27
|
+
}
|
|
28
|
+
} finally { try { db.close(); } catch {} }
|
|
29
|
+
}
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const [key, projects] of tally) {
|
|
32
|
+
if (projects.size < minProjects) continue;
|
|
33
|
+
const [subject, predicate, object] = key.split('|');
|
|
34
|
+
out.push({
|
|
35
|
+
subject, predicate, object,
|
|
36
|
+
projects: [...projects].sort(),
|
|
37
|
+
confidence: projects.size >= 5 ? 'high' : 'medium',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return out.sort((a, b) => b.projects.length - a.projects.length);
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// IJFW v1.5.2 -- brain stub detector.
|
|
2
|
+
//
|
|
3
|
+
// A "stub" is a wikilink target with N+ incoming references but no actual
|
|
4
|
+
// wiki page at ijfw/wiki/<type>s/<slug>.md. Surfaces gaps the operator may
|
|
5
|
+
// want to fill. Checks both visible ijfw/ and legacy .ijfw/ paths during
|
|
6
|
+
// the v1->v2 transition.
|
|
7
|
+
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const WIKI_TYPES = ['concepts', 'entities', 'decisions', 'milestones'];
|
|
12
|
+
|
|
13
|
+
function pageExistsAnywhere(repoRoot, target) {
|
|
14
|
+
for (const t of WIKI_TYPES) {
|
|
15
|
+
if (existsSync(join(repoRoot, 'ijfw', 'wiki', t, `${target}.md`))) return true;
|
|
16
|
+
if (existsSync(join(repoRoot, '.ijfw', 'wiki', t, `${target}.md`))) return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function detectStubs(db, repoRoot, { minIncomingLinks = 3 } = {}) {
|
|
22
|
+
const rows = db.prepare(`
|
|
23
|
+
SELECT to_target AS target, COUNT(*) AS incomingLinks
|
|
24
|
+
FROM memory_links
|
|
25
|
+
WHERE to_target IS NOT NULL AND to_target != ''
|
|
26
|
+
GROUP BY to_target
|
|
27
|
+
HAVING COUNT(*) >= ?
|
|
28
|
+
`).all(minIncomingLinks);
|
|
29
|
+
return rows
|
|
30
|
+
.filter((r) => !pageExistsAnywhere(repoRoot, r.target))
|
|
31
|
+
.map((r) => ({ target: r.target, incomingLinks: r.incomingLinks }))
|
|
32
|
+
.sort((a, b) => b.incomingLinks - a.incomingLinks);
|
|
33
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// IJFW v1.5.2 -- brain tiered LLM router.
|
|
2
|
+
//
|
|
3
|
+
// Two tiers used by the dream-cycle pipeline:
|
|
4
|
+
// - extract: cheap, high-volume fact extraction (default: claude-haiku-4-5-20251001)
|
|
5
|
+
// - synth: mid-tier reconciliation / page synthesis (default: claude-sonnet-4-6)
|
|
6
|
+
//
|
|
7
|
+
// When IJFW_BRAIN_LOCAL_URL is set, we try the local endpoint first (Ollama-
|
|
8
|
+
// compatible /api/generate). On any local-call error, we fall back to the
|
|
9
|
+
// Anthropic API. This makes "local-first" cost discipline opt-in via env.
|
|
10
|
+
//
|
|
11
|
+
// Tests inject custom callers via opts._callers to avoid hitting real APIs.
|
|
12
|
+
|
|
13
|
+
const DEFAULTS = {
|
|
14
|
+
extract: 'claude-haiku-4-5-20251001',
|
|
15
|
+
synth: 'claude-sonnet-4-6',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MAX_TOKENS = {
|
|
19
|
+
extract: 512,
|
|
20
|
+
synth: 1500,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function resolveTierModel(tier, env = process.env) {
|
|
24
|
+
if (tier === 'extract') return env.IJFW_BRAIN_EXTRACT_MODEL || DEFAULTS.extract;
|
|
25
|
+
if (tier === 'synth') return env.IJFW_BRAIN_SYNTH_MODEL || DEFAULTS.synth;
|
|
26
|
+
throw new Error(`tiered-llm: unknown tier '${tier}'`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function defaultCallers() {
|
|
30
|
+
return {
|
|
31
|
+
async local({ url, model, prompt, maxTokens }) {
|
|
32
|
+
// Ollama-compatible /api/generate -- streamless single-response mode.
|
|
33
|
+
const res = await fetch(url.replace(/\/$/, '') + '/api/generate', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'Content-Type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ model, prompt, stream: false, options: { num_predict: maxTokens } }),
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) throw new Error(`local LLM HTTP ${res.status}`);
|
|
39
|
+
const data = await res.json();
|
|
40
|
+
return { text: data.response || '', usage: { input: data.prompt_eval_count, output: data.eval_count }, model, via: 'local' };
|
|
41
|
+
},
|
|
42
|
+
async anthropic({ model, prompt, maxTokens, apiKey }) {
|
|
43
|
+
if (!apiKey) throw new Error('tiered-llm: ANTHROPIC_API_KEY (or IJFW_BRAIN_API_KEY) required for Anthropic fallback');
|
|
44
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
'x-api-key': apiKey,
|
|
49
|
+
'anthropic-version': '2023-06-01',
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
model,
|
|
53
|
+
max_tokens: maxTokens,
|
|
54
|
+
messages: [{ role: 'user', content: prompt }],
|
|
55
|
+
}),
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) throw new Error(`Anthropic HTTP ${res.status}`);
|
|
58
|
+
const data = await res.json();
|
|
59
|
+
const text = (data.content || []).map((b) => b.text || '').join('');
|
|
60
|
+
return { text, usage: data.usage, model, via: 'anthropic' };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function callTiered(tier, prompt, opts = {}) {
|
|
66
|
+
const env = opts.env || process.env;
|
|
67
|
+
const model = resolveTierModel(tier, env);
|
|
68
|
+
const maxTokens = opts.maxTokens || DEFAULT_MAX_TOKENS[tier] || 512;
|
|
69
|
+
const callers = opts._callers || defaultCallers();
|
|
70
|
+
if (env.IJFW_BRAIN_LOCAL_URL) {
|
|
71
|
+
try {
|
|
72
|
+
return await callers.local({ url: env.IJFW_BRAIN_LOCAL_URL, model, prompt, maxTokens });
|
|
73
|
+
} catch {
|
|
74
|
+
// fall through to Anthropic
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return callers.anthropic({
|
|
78
|
+
model,
|
|
79
|
+
prompt,
|
|
80
|
+
maxTokens,
|
|
81
|
+
apiKey: env.IJFW_BRAIN_API_KEY || env.ANTHROPIC_API_KEY,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// IJFW v1.5.2 -- brain wiki compiler (Trident F-B1 + F-F2 enforcement).
|
|
2
|
+
//
|
|
3
|
+
// compileWikiPage(db, { repoRoot, type, subject }) renders the page from
|
|
4
|
+
// structured facts + history + backlinks + sources, runs the result through
|
|
5
|
+
// resolveCitations(), and ATOMICALLY writes (.tmp + rename) only if every
|
|
6
|
+
// citation resolves. Returns {ok:false, unresolved[]} when any cite is
|
|
7
|
+
// dangling -- this is the hallucination defense.
|
|
8
|
+
//
|
|
9
|
+
// Wiki path: <ijfw|.ijfw>/wiki/<type>s/<slug>.md per the layout sentinel.
|
|
10
|
+
// Slug is the subject lowercased + non-alphanum -> '-'.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, openSync, closeSync, unlinkSync, statSync } from 'node:fs';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
const STALE_LOCK_MS = 60_000; // 60s — locks older than this are assumed dead-process orphans
|
|
16
|
+
import { resolveBrainPaths } from './paths.js';
|
|
17
|
+
import { applyTemplate } from './wiki-templates.js';
|
|
18
|
+
import { resolveCitations } from './citation-resolver.js';
|
|
19
|
+
import { getHistoryWindow } from '../memory/temporal.js';
|
|
20
|
+
import { validateSafeRepoPath } from './path-guard.js';
|
|
21
|
+
|
|
22
|
+
export function slugify(s) {
|
|
23
|
+
return String(s || '').toLowerCase().trim()
|
|
24
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
25
|
+
.replace(/^-+|-+$/g, '') || 'untitled';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pluralType(type) {
|
|
29
|
+
// Templates store under <type>s/ -- e.g. type='entity' -> 'entities'.
|
|
30
|
+
if (type === 'entity') return 'entities';
|
|
31
|
+
if (type === 'concept') return 'concepts';
|
|
32
|
+
if (type === 'decision') return 'decisions';
|
|
33
|
+
if (type === 'milestone') return 'milestones';
|
|
34
|
+
return `${type}s`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function queryFacts(db, subject) {
|
|
38
|
+
try {
|
|
39
|
+
return db.prepare(
|
|
40
|
+
'SELECT id, predicate, object, valid_from, valid_to, memory_id, source, confidence FROM facts WHERE subject = ? AND valid_to IS NULL ORDER BY id DESC'
|
|
41
|
+
).all(subject);
|
|
42
|
+
} catch { return []; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function queryBacklinks(db, target) {
|
|
46
|
+
try {
|
|
47
|
+
return db.prepare(
|
|
48
|
+
`SELECT to_target AS target, COUNT(*) AS count
|
|
49
|
+
FROM memory_links
|
|
50
|
+
WHERE to_target IS NOT NULL AND to_target = ?
|
|
51
|
+
GROUP BY to_target`
|
|
52
|
+
).all(target);
|
|
53
|
+
} catch { return []; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function querySources(db, subject) {
|
|
57
|
+
// Top-5 memory entries whose body mentions the subject.
|
|
58
|
+
try {
|
|
59
|
+
const rows = db.prepare(
|
|
60
|
+
`SELECT path, kind, COUNT(*) AS mentions
|
|
61
|
+
FROM memory_entries
|
|
62
|
+
WHERE body LIKE ?
|
|
63
|
+
GROUP BY path, kind
|
|
64
|
+
ORDER BY mentions DESC
|
|
65
|
+
LIMIT 5`
|
|
66
|
+
).all(`%${subject}%`);
|
|
67
|
+
return rows.map((r) => ({ path: r.path, kind: r.kind, mentions: r.mentions }));
|
|
68
|
+
} catch { return []; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function compileWikiPage(db, { repoRoot, type, subject } = {}) {
|
|
72
|
+
if (!subject) return { ok: false, error: 'missing-subject' };
|
|
73
|
+
const paths = resolveBrainPaths(repoRoot);
|
|
74
|
+
const slug = slugify(subject);
|
|
75
|
+
const pageDir = join(paths.wikiDir, pluralType(type));
|
|
76
|
+
const pagePath = join(pageDir, `${slug}.md`);
|
|
77
|
+
|
|
78
|
+
// F-LENS2-05: enforce containment + reserved-name on the compile target.
|
|
79
|
+
// A symlinked wiki dir or a maliciously-crafted subject (slugify removes
|
|
80
|
+
// most danger, but defense-in-depth) could otherwise direct the atomic
|
|
81
|
+
// rename outside the repo. mkdirSync(pageDir,…) below runs only if guard
|
|
82
|
+
// passes so we never even create a parent directory outside repoRoot.
|
|
83
|
+
const guard = validateSafeRepoPath(repoRoot, pagePath);
|
|
84
|
+
if (!guard.ok) return guard;
|
|
85
|
+
|
|
86
|
+
// F8: per-page advisory lock prevents two concurrent compiles for the
|
|
87
|
+
// same subject from interleaving (both read existing → both render →
|
|
88
|
+
// both rename → operator NOTES from the intermediate state could be
|
|
89
|
+
// lost). EEXIST-based exclusive open is portable + atomic.
|
|
90
|
+
mkdirSync(pageDir, { recursive: true });
|
|
91
|
+
const lockPath = pagePath + '.lock';
|
|
92
|
+
let lockFd;
|
|
93
|
+
try {
|
|
94
|
+
lockFd = openSync(lockPath, 'wx');
|
|
95
|
+
} catch (e) {
|
|
96
|
+
if (e.code === 'EEXIST') {
|
|
97
|
+
// FLAG-8: stale-lock recovery. If a prior compile process was SIGKILL'd
|
|
98
|
+
// between acquire and finally, the lockfile orphans and every subsequent
|
|
99
|
+
// compile fails. Check the lockfile's age; if older than STALE_LOCK_MS,
|
|
100
|
+
// assume it's a dead-process orphan and reclaim.
|
|
101
|
+
let stale = false;
|
|
102
|
+
try {
|
|
103
|
+
const age = Date.now() - statSync(lockPath).mtimeMs;
|
|
104
|
+
if (age > STALE_LOCK_MS) stale = true;
|
|
105
|
+
} catch { /* lockfile vanished while we checked — race won by us */ stale = true; }
|
|
106
|
+
if (stale) {
|
|
107
|
+
try { unlinkSync(lockPath); } catch {}
|
|
108
|
+
try { lockFd = openSync(lockPath, 'wx'); }
|
|
109
|
+
catch (e2) { return { ok: false, error: 'page-locked-by-concurrent-compile', pagePath, staleReclaimFailed: e2.code }; }
|
|
110
|
+
} else {
|
|
111
|
+
return { ok: false, error: 'page-locked-by-concurrent-compile', pagePath };
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
throw e;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
// Read existing AFTER acquiring the lock so we see the freshest committed
|
|
120
|
+
// state (no race with another compile that just landed its rename).
|
|
121
|
+
const existing = existsSync(pagePath) ? readFileSync(pagePath, 'utf8') : '';
|
|
122
|
+
const facts = queryFacts(db, subject);
|
|
123
|
+
const history = getHistoryWindow(db, subject, null, { limit: 50 });
|
|
124
|
+
const backlinks = queryBacklinks(db, slug);
|
|
125
|
+
const sources = querySources(db, subject);
|
|
126
|
+
|
|
127
|
+
const candidate = applyTemplate(type, existing, { subject, facts, history, backlinks, sources });
|
|
128
|
+
|
|
129
|
+
const verdict = resolveCitations(db, candidate);
|
|
130
|
+
if (!verdict.ok) {
|
|
131
|
+
return { ok: false, error: 'unresolved-citations', unresolved: verdict.unresolved, pagePath };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Atomic write
|
|
135
|
+
const tmp = pagePath + '.tmp';
|
|
136
|
+
writeFileSync(tmp, candidate);
|
|
137
|
+
renameSync(tmp, pagePath);
|
|
138
|
+
|
|
139
|
+
return { ok: true, pagePath, factsCount: facts.length, historyRows: history.rows.length };
|
|
140
|
+
} finally {
|
|
141
|
+
try { closeSync(lockFd); } catch {}
|
|
142
|
+
try { unlinkSync(lockPath); } catch {}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// IJFW v1.5.2 -- brain wiki section sentinels (Trident F-F2).
|
|
2
|
+
//
|
|
3
|
+
// Wiki pages carry LLM-managed regions delimited by HTML comments:
|
|
4
|
+
// <!-- ijfw:auto:begin section="current-state" -->
|
|
5
|
+
// ...AUTO content (replaced by the compiler)...
|
|
6
|
+
// <!-- ijfw:auto:end section="current-state" -->
|
|
7
|
+
//
|
|
8
|
+
// Everything OUTSIDE these blocks is operator-owned ("NOTES") and the
|
|
9
|
+
// compiler MUST preserve it verbatim. extractSections() returns the AUTO
|
|
10
|
+
// regions; replaceSection() swaps a named region's body while preserving
|
|
11
|
+
// the NOTES content and all other AUTO regions unchanged.
|
|
12
|
+
|
|
13
|
+
const SECTION_RE = /<!--\s*ijfw:auto:begin\s+section="([^"]+)"\s*-->([\s\S]*?)<!--\s*ijfw:auto:end\s+section="\1"\s*-->/g;
|
|
14
|
+
|
|
15
|
+
export function extractSections(md) {
|
|
16
|
+
if (typeof md !== 'string') return [];
|
|
17
|
+
const out = [];
|
|
18
|
+
for (const m of md.matchAll(SECTION_RE)) {
|
|
19
|
+
out.push({ name: m[1], body: m[2] });
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function escapeRe(s) {
|
|
25
|
+
return s.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeBlock(name, body) {
|
|
29
|
+
return `<!-- ijfw:auto:begin section="${name}" -->\n${body}\n<!-- ijfw:auto:end section="${name}" -->`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function replaceSection(md, name, newBody) {
|
|
33
|
+
if (typeof md !== 'string') md = '';
|
|
34
|
+
const escaped = escapeRe(name);
|
|
35
|
+
const targetRe = new RegExp(
|
|
36
|
+
`<!--\\s*ijfw:auto:begin\\s+section="${escaped}"\\s*-->[\\s\\S]*?<!--\\s*ijfw:auto:end\\s+section="${escaped}"\\s*-->`
|
|
37
|
+
);
|
|
38
|
+
const replacement = makeBlock(name, newBody);
|
|
39
|
+
if (targetRe.test(md)) {
|
|
40
|
+
return md.replace(targetRe, replacement);
|
|
41
|
+
}
|
|
42
|
+
// Section absent -- append it. If md ends without trailing newline, add one.
|
|
43
|
+
const sep = md.length === 0 || md.endsWith('\n') ? '' : '\n';
|
|
44
|
+
return md + sep + '\n' + replacement + '\n';
|
|
45
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// IJFW v1.5.2 -- brain wiki page templates.
|
|
2
|
+
//
|
|
3
|
+
// applyTemplate(type, existingMd, { subject, facts, history, backlinks, sources })
|
|
4
|
+
// returns markdown with 4 AUTO sections (current-state, history, backlinks,
|
|
5
|
+
// sources) wrapped in ijfw:auto sentinels. Operator-owned NOTES regions OUTSIDE
|
|
6
|
+
// the sentinels are preserved verbatim by the underlying replaceSection().
|
|
7
|
+
//
|
|
8
|
+
// Per Trident F-B2, the history section is windowed (consumer passes
|
|
9
|
+
// {rows, older} from getHistoryWindow); when older is present we append a
|
|
10
|
+
// rollup line ("Older: 55 events between A and B") instead of pasting more rows.
|
|
11
|
+
|
|
12
|
+
import { replaceSection } from './wiki-sentinels.js';
|
|
13
|
+
|
|
14
|
+
function isoDate(s) {
|
|
15
|
+
if (!s) return '';
|
|
16
|
+
try {
|
|
17
|
+
const d = new Date(s);
|
|
18
|
+
if (Number.isNaN(d.getTime())) return s;
|
|
19
|
+
return d.toISOString().slice(0, 10);
|
|
20
|
+
} catch { return s; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function renderCurrentState({ subject, facts }) {
|
|
24
|
+
if (!facts || facts.length === 0) {
|
|
25
|
+
return `_No facts about **${subject}** yet._`;
|
|
26
|
+
}
|
|
27
|
+
const lines = [];
|
|
28
|
+
for (const f of facts) {
|
|
29
|
+
const cite = f.id != null ? ` [fact:${f.id}]` : '';
|
|
30
|
+
const memCite = f.memory_id != null ? ` [mem:${f.memory_id}]` : '';
|
|
31
|
+
lines.push(`- ${f.predicate || '(no predicate)'}: **${f.object ?? ''}**${cite}${memCite}`);
|
|
32
|
+
}
|
|
33
|
+
return lines.join('\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderHistory({ history }) {
|
|
37
|
+
if (!history) return '_No history available._';
|
|
38
|
+
const { rows = [], older = null } = history;
|
|
39
|
+
if (rows.length === 0) return '_No history available._';
|
|
40
|
+
const lines = [];
|
|
41
|
+
for (const r of rows) {
|
|
42
|
+
const date = isoDate(r.valid_from);
|
|
43
|
+
const closed = r.valid_to ? ` (closed ${isoDate(r.valid_to)})` : '';
|
|
44
|
+
const memCite = r.memory_id != null ? ` [mem:${r.memory_id}]` : '';
|
|
45
|
+
const factCite = r.id != null ? ` [fact:${r.id}]` : '';
|
|
46
|
+
lines.push(`- ${date}: ${r.predicate || '(no predicate)'} = **${r.object ?? ''}**${closed}${factCite}${memCite}`);
|
|
47
|
+
}
|
|
48
|
+
if (older) {
|
|
49
|
+
lines.push('');
|
|
50
|
+
lines.push(`_Older: ${older.count} event${older.count === 1 ? '' : 's'} between ${isoDate(older.fromIso)} and ${isoDate(older.toIso)}._`);
|
|
51
|
+
}
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderBacklinks({ backlinks }) {
|
|
56
|
+
if (!backlinks || backlinks.length === 0) return '_No backlinks._';
|
|
57
|
+
const lines = [];
|
|
58
|
+
for (const b of backlinks) {
|
|
59
|
+
const target = b.target || b.to_target || '';
|
|
60
|
+
const n = b.count ?? b.incomingLinks ?? 1;
|
|
61
|
+
if (!target) continue;
|
|
62
|
+
lines.push(`- [[${target}]] (${n} link${n === 1 ? '' : 's'})`);
|
|
63
|
+
}
|
|
64
|
+
return lines.length ? lines.join('\n') : '_No backlinks._';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function renderSources({ sources }) {
|
|
68
|
+
if (!sources || sources.length === 0) return '_No sources._';
|
|
69
|
+
const top = sources.slice(0, 5);
|
|
70
|
+
const lines = [];
|
|
71
|
+
for (const s of top) {
|
|
72
|
+
const path = s.path || '(no path)';
|
|
73
|
+
const kind = s.kind ? ` (${s.kind})` : '';
|
|
74
|
+
const mentions = s.mentions != null ? ` — ${s.mentions} mention${s.mentions === 1 ? '' : 's'}` : '';
|
|
75
|
+
lines.push(`- \`${path}\`${kind}${mentions}`);
|
|
76
|
+
}
|
|
77
|
+
return lines.join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function applyTemplate(type, existingMd, data) {
|
|
81
|
+
let md = existingMd || '';
|
|
82
|
+
md = replaceSection(md, 'current-state', renderCurrentState(data));
|
|
83
|
+
md = replaceSection(md, 'history', renderHistory(data));
|
|
84
|
+
md = replaceSection(md, 'backlinks', renderBacklinks(data));
|
|
85
|
+
md = replaceSection(md, 'sources', renderSources(data));
|
|
86
|
+
return md;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export {
|
|
90
|
+
renderCurrentState as _renderCurrentState,
|
|
91
|
+
renderHistory as _renderHistory,
|
|
92
|
+
renderBacklinks as _renderBacklinks,
|
|
93
|
+
renderSources as _renderSources,
|
|
94
|
+
};
|
|
@@ -410,6 +410,13 @@ function parseArgsInner(args) {
|
|
|
410
410
|
return { cmd: 'extension', sub: args[1] || 'list', rest: args.slice(2) };
|
|
411
411
|
}
|
|
412
412
|
|
|
413
|
+
if (args[0] === 'env') {
|
|
414
|
+
// v1.5.2 F6: `ijfw env` lists every IJFW_* env var with current value,
|
|
415
|
+
// default, and one-line description. Closes the configuration-sprawl
|
|
416
|
+
// discoverability gap surfaced in the v1.5.2 cross-audit.
|
|
417
|
+
return { cmd: 'env' };
|
|
418
|
+
}
|
|
419
|
+
|
|
413
420
|
if (args[0] === 'swarm') {
|
|
414
421
|
return { cmd: 'swarm', sub: args[1] || 'status' };
|
|
415
422
|
}
|
|
@@ -500,6 +507,40 @@ function parseArgsInner(args) {
|
|
|
500
507
|
// Commands
|
|
501
508
|
// ---------------------------------------------------------------------------
|
|
502
509
|
|
|
510
|
+
// v1.5.2 F6: every IJFW_* env var the brain + memory subsystems read at runtime.
|
|
511
|
+
// Order: most-likely-to-set first. `default` is the documented fallback when
|
|
512
|
+
// the var is unset; `description` is one short line for `ijfw env` output.
|
|
513
|
+
const IJFW_ENV_VARS = [
|
|
514
|
+
// Brain (v1.5.2)
|
|
515
|
+
{ name: 'IJFW_DREAM_BUDGET_USD', default: '0.50', description: 'Per-cycle USD cap for dream-cycle LLM extraction.' },
|
|
516
|
+
{ name: 'IJFW_DREAM_BUDGET_DAY_USD', default: '5.00', description: 'Per-day USD cap; cycle stops when reached.' },
|
|
517
|
+
{ name: 'IJFW_BRAIN_LOCAL_URL', default: '(unset)', description: 'Ollama-compatible local LLM endpoint to try first.' },
|
|
518
|
+
{ name: 'IJFW_BRAIN_EXTRACT_MODEL', default: 'claude-haiku-4-5-20251001', description: 'Cheap-tier model id for fact extraction.' },
|
|
519
|
+
{ name: 'IJFW_BRAIN_SYNTH_MODEL', default: 'claude-sonnet-4-6', description: 'Mid-tier model id for reconciliation + page synthesis.' },
|
|
520
|
+
{ name: 'IJFW_BRAIN_API_KEY', default: '(falls back to ANTHROPIC_API_KEY)', description: 'API key for the synth-tier Anthropic call.' },
|
|
521
|
+
{ name: 'IJFW_BRAIN_INJECT', default: 'never', description: '"auto"|"always" appends top-N wiki pages to handlePrelude.' },
|
|
522
|
+
// Memory-moat (v1.5.0 — A-Mem auto-linker)
|
|
523
|
+
{ name: 'IJFW_AUTOLINK_OFF', default: '(unset)', description: 'Set to disable the A-Mem auto-linker entirely.' },
|
|
524
|
+
{ name: 'IJFW_AUTOLINK_BUDGET_USD', default: '(unbounded if unset)', description: 'Per-write USD cap for auto-linker LLM calls.' },
|
|
525
|
+
{ name: 'IJFW_AUTOLINK_BACKFILL', default: '(unset)', description: 'Set to "1" to opt into M2 backfill during `memory reindex --m2`.' },
|
|
526
|
+
];
|
|
527
|
+
|
|
528
|
+
export function cmdEnv() {
|
|
529
|
+
const widthName = Math.max(...IJFW_ENV_VARS.map((v) => v.name.length)) + 2;
|
|
530
|
+
console.log('IJFW environment variables\n');
|
|
531
|
+
for (const v of IJFW_ENV_VARS) {
|
|
532
|
+
const current = process.env[v.name];
|
|
533
|
+
const shownValue = current !== undefined && current !== '' ? current : '(unset)';
|
|
534
|
+
const isOverridden = current !== undefined && current !== '';
|
|
535
|
+
const tag = isOverridden ? ' [SET]' : '';
|
|
536
|
+
console.log(` ${v.name.padEnd(widthName)} = ${shownValue}${tag}`);
|
|
537
|
+
console.log(` ${' '.repeat(widthName)} default: ${v.default}`);
|
|
538
|
+
console.log(` ${' '.repeat(widthName)} ${v.description}`);
|
|
539
|
+
console.log('');
|
|
540
|
+
}
|
|
541
|
+
console.log('Tip: variables tagged [SET] override their defaults. Unset values show the documented default in effect.');
|
|
542
|
+
}
|
|
543
|
+
|
|
503
544
|
function printMemoryHelp() {
|
|
504
545
|
console.log(`
|
|
505
546
|
ijfw memory -- project memory namespace
|
|
@@ -516,6 +557,7 @@ Usage:
|
|
|
516
557
|
|
|
517
558
|
Related:
|
|
518
559
|
ijfw recover [status|latest] Inspect checkpoints and recovery state.
|
|
560
|
+
ijfw env List every IJFW_* env var, current value, default, description.
|
|
519
561
|
ijfw --help Top-level user-facing commands.
|
|
520
562
|
ijfw commands Full command surface (all verbs).
|
|
521
563
|
`.trim());
|
|
@@ -1037,7 +1079,7 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
|
|
|
1037
1079
|
const chunks = buildChunkedTargets(absPath, rawTarget);
|
|
1038
1080
|
console.log('');
|
|
1039
1081
|
console.log(`--chunk: splitting ${rawTarget} into ${chunks.length} chunks (≈${(CHUNKER_DEFAULTS.chunkSize / 1024).toFixed(0)} KB each, ${(CHUNKER_DEFAULTS.overlap / 1024).toFixed(0)} KB overlap).`);
|
|
1040
|
-
console.log(`Trident dispatches: ${chunks.length}
|
|
1082
|
+
console.log(`Trident dispatches: ${chunks.length} x per-chunk audit. Cost scales linearly.`);
|
|
1041
1083
|
|
|
1042
1084
|
const perChunkResults = [];
|
|
1043
1085
|
const auditorIds = new Set();
|
|
@@ -1072,7 +1114,7 @@ async function cmdCross({ mode, target, only, confirm, expand, chunk }) {
|
|
|
1072
1114
|
}
|
|
1073
1115
|
for (const f of merged) {
|
|
1074
1116
|
const sev = (f.severity || 'note').toUpperCase();
|
|
1075
|
-
const cluster = f.clusterSize > 1 ? ` [
|
|
1117
|
+
const cluster = f.clusterSize > 1 ? ` [x${f.clusterSize}]` : '';
|
|
1076
1118
|
const tgt = f.target ? ` ${f.target} —` : '';
|
|
1077
1119
|
// v1.5.0 wire-W4: widen field fallback to cover description/issue/
|
|
1078
1120
|
// detail/note/summary keys auditors emit. Closes the r19 "(no detail)"
|
|
@@ -2395,6 +2437,8 @@ if (isMainModule) {
|
|
|
2395
2437
|
cmdMemoryCheckpoint(parsed.label);
|
|
2396
2438
|
} else if (parsed.cmd === 'memory-reindex') {
|
|
2397
2439
|
cmdMemoryReindex(parsed).catch(err => { console.error(err.message); process.exit(1); });
|
|
2440
|
+
} else if (parsed.cmd === 'env') {
|
|
2441
|
+
cmdEnv();
|
|
2398
2442
|
} else if (parsed.cmd === 'recover') {
|
|
2399
2443
|
cmdRecover(parsed.sub);
|
|
2400
2444
|
} else {
|
|
@@ -2417,10 +2461,19 @@ function repoRootFromCli() {
|
|
|
2417
2461
|
return join(here, '..', '..');
|
|
2418
2462
|
}
|
|
2419
2463
|
function findCliAsset(...rel) {
|
|
2464
|
+
// F-C-4 (Lens 3): probe XDG_DATA_HOME and XDG_CONFIG_HOME in addition to
|
|
2465
|
+
// ~/.ijfw and IJFW_HOME. Users who installed via a distro-aware packager
|
|
2466
|
+
// (e.g. dotfiles repo, nix, distro RPM, or any wrapper that honours XDG
|
|
2467
|
+
// base-dir spec) land their ijfw tree under $XDG_DATA_HOME/ijfw rather
|
|
2468
|
+
// than ~/.ijfw, and were silently invisible to the doctor fallback.
|
|
2469
|
+
const xdgData = process.env.XDG_DATA_HOME;
|
|
2470
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME;
|
|
2420
2471
|
const candidates = [
|
|
2421
2472
|
join(repoRootFromCli(), ...rel),
|
|
2422
2473
|
process.env.IJFW_HOME ? join(process.env.IJFW_HOME, ...rel) : null,
|
|
2423
2474
|
join(homedir(), '.ijfw', ...rel),
|
|
2475
|
+
xdgData ? join(xdgData, 'ijfw', ...rel) : null,
|
|
2476
|
+
xdgConfig ? join(xdgConfig, 'ijfw', ...rel) : null,
|
|
2424
2477
|
].filter(Boolean);
|
|
2425
2478
|
return candidates.find(p => existsSync(p)) || null;
|
|
2426
2479
|
}
|
|
@@ -2919,6 +2972,42 @@ function cmdCodex(sub) {
|
|
|
2919
2972
|
process.exit(1);
|
|
2920
2973
|
}
|
|
2921
2974
|
|
|
2975
|
+
// F4.1/F4.2: resolve the canonical IJFW version with explicit source labelling
|
|
2976
|
+
// and a corrupt-vs-missing distinction. Exported so the codex-doctor tests can
|
|
2977
|
+
// exercise the fallback matrix without spawning the CLI against synthetic
|
|
2978
|
+
// repo trees. Inputs are absolute paths (or null) so callers can stub them.
|
|
2979
|
+
//
|
|
2980
|
+
// Returns: { canonicalVersion, canonicalSource, canonicalParseError } where
|
|
2981
|
+
// canonicalVersion : string | null (first source whose JSON parses)
|
|
2982
|
+
// canonicalSource : 'installer' | 'mcp-server' | null
|
|
2983
|
+
// canonicalParseError : string | null (only set if installer pkg corrupt)
|
|
2984
|
+
export function resolveCanonicalVersion({ installerPkg, selfPkg } = {}) {
|
|
2985
|
+
let canonicalVersion = null;
|
|
2986
|
+
let canonicalSource = null;
|
|
2987
|
+
let canonicalParseError = null;
|
|
2988
|
+
for (const [src, p] of [['installer', installerPkg], ['mcp-server', selfPkg]]) {
|
|
2989
|
+
if (!p || !existsSync(p)) continue;
|
|
2990
|
+
let raw;
|
|
2991
|
+
try {
|
|
2992
|
+
raw = readFileSync(p, 'utf8');
|
|
2993
|
+
} catch { continue; }
|
|
2994
|
+
try {
|
|
2995
|
+
canonicalVersion = JSON.parse(raw).version;
|
|
2996
|
+
canonicalSource = src;
|
|
2997
|
+
break;
|
|
2998
|
+
} catch (e) {
|
|
2999
|
+
// F-C-2 (Lens 3): capture parse error from whichever source was attempted
|
|
3000
|
+
// and label the source. Previously only installer parse errors surfaced,
|
|
3001
|
+
// so a corrupt mcp-server/package.json (e.g. partial pnpm install) caused
|
|
3002
|
+
// the doctor to render misleading "install @ijfw/install" fix text.
|
|
3003
|
+
if (canonicalParseError == null) {
|
|
3004
|
+
canonicalParseError = `${src}: ${e.message}`;
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
}
|
|
3008
|
+
return { canonicalVersion, canonicalSource, canonicalParseError };
|
|
3009
|
+
}
|
|
3010
|
+
|
|
2922
3011
|
function codexDoctor(projectRoot) {
|
|
2923
3012
|
const root = resolve(projectRoot);
|
|
2924
3013
|
const checks = [];
|
|
@@ -2946,12 +3035,50 @@ function codexDoctor(projectRoot) {
|
|
|
2946
3035
|
const agentsMd = join(root, 'AGENTS.md');
|
|
2947
3036
|
|
|
2948
3037
|
const plugin = readJsonFile(pluginPath);
|
|
3038
|
+
// v1.5.2.1: read the canonical version dynamically from
|
|
3039
|
+
// installer/package.json instead of comparing against a hardcoded literal.
|
|
3040
|
+
// The previous hardcoded '1.3.2' check drifted out of sync on every
|
|
3041
|
+
// release (latest observed: project at 1.5.1, doctor still expecting
|
|
3042
|
+
// 1.3.2 → false-positive failures on every fresh install). Reading from
|
|
3043
|
+
// installer/package.json keeps the doctor honest as long as that file
|
|
3044
|
+
// and the codex plugin.json are bumped together at ship-gate.
|
|
3045
|
+
//
|
|
3046
|
+
// F4.1: standalone @ijfw/memory-server installs do not ship installer/.
|
|
3047
|
+
// F4.3: regressing against the established findCliAsset() convention. Reuse it.
|
|
3048
|
+
// F4.2: differentiate ENOENT (missing) from SyntaxError (corrupt).
|
|
3049
|
+
const { canonicalVersion, canonicalSource, canonicalParseError } =
|
|
3050
|
+
resolveCanonicalVersion({
|
|
3051
|
+
installerPkg: findCliAsset('installer', 'package.json'),
|
|
3052
|
+
// F-C-1 (Lens 3): IJFW_HOME fallback was previously installer-only; make
|
|
3053
|
+
// mcp-server resolution symmetric so a custom-checkout user with IJFW_HOME
|
|
3054
|
+
// pointed at a partial tree also gets a working fallback.
|
|
3055
|
+
selfPkg: findCliAsset('mcp-server', 'package.json')
|
|
3056
|
+
?? join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'),
|
|
3057
|
+
});
|
|
2949
3058
|
checks.push({
|
|
2950
3059
|
name: 'plugin metadata',
|
|
2951
|
-
ok: plugin
|
|
3060
|
+
ok: !!plugin && !!canonicalVersion && plugin.version === canonicalVersion,
|
|
2952
3061
|
required: true,
|
|
2953
|
-
message: plugin
|
|
2954
|
-
|
|
3062
|
+
message: plugin
|
|
3063
|
+
? (canonicalVersion
|
|
3064
|
+
? `version ${plugin.version}${plugin.version === canonicalVersion ? '' : ` (expected ${canonicalVersion} per ${canonicalSource}/package.json)`}`
|
|
3065
|
+
: canonicalParseError
|
|
3066
|
+
? `version ${plugin.version} (canonical source corrupt: ${canonicalParseError})`
|
|
3067
|
+
: `version ${plugin.version} (canonical version unreadable -- install @ijfw/install or set IJFW_HOME)`)
|
|
3068
|
+
: 'missing plugin.json',
|
|
3069
|
+
// F-C-9 (Lens 3): if plugin.json itself is missing, the fix text needs
|
|
3070
|
+
// to point at restoring plugin.json, NOT at the canonical-version dance.
|
|
3071
|
+
// canonicalParseError is now source-labelled by F-C-2 ("installer: ..."
|
|
3072
|
+
// or "mcp-server: ..."), so derive the specific package.json to restore.
|
|
3073
|
+
fix: !plugin
|
|
3074
|
+
? `restore codex/.codex-plugin/plugin.json (try \`git checkout codex/.codex-plugin/plugin.json\`)`
|
|
3075
|
+
: canonicalVersion
|
|
3076
|
+
? (plugin.version !== canonicalVersion
|
|
3077
|
+
? `update codex/.codex-plugin/plugin.json version to ${canonicalVersion}`
|
|
3078
|
+
: null)
|
|
3079
|
+
: canonicalParseError
|
|
3080
|
+
? `restore canonical source (try \`git checkout ${canonicalParseError.split(':')[0]}/package.json\`)`
|
|
3081
|
+
: `install @ijfw/install (npm i -g @ijfw/install), or set IJFW_HOME=<ijfw-repo-checkout>`,
|
|
2955
3082
|
});
|
|
2956
3083
|
|
|
2957
3084
|
const hooks = readJsonFile(hooksPath);
|
|
@@ -1084,7 +1084,7 @@ const DEFAULT_LENSES = ['codex', 'gemini', 'claude'];
|
|
|
1084
1084
|
|
|
1085
1085
|
// v1.5.0 audit-H4.1 — hard upper bound on convergence iterations. A caller
|
|
1086
1086
|
// asking for 100 rounds would burn 100 rounds of full Trident dispatch (~3
|
|
1087
|
-
// auditors
|
|
1087
|
+
// auditors x ~90s = ~4.5h per cycle on cold start). 10 is well above the
|
|
1088
1088
|
// observed empirical ceiling — the convergence loop almost always settles in
|
|
1089
1089
|
// 2-3 iters; >5 is a smell, >10 is a misuse. Anything above the cap is
|
|
1090
1090
|
// silently clamped to MAX_CONVERGE_ITERATIONS + emits a single dedup'd warning.
|
|
@@ -1176,7 +1176,7 @@ function buildCycleSummary(iteration, prior) {
|
|
|
1176
1176
|
// projectRoot string (passed through to dispatch)
|
|
1177
1177
|
// totalTimeoutMs v1.5.0 audit-MED-trident-M6 — cumulative wall-clock cap.
|
|
1178
1178
|
// When set, an AbortController fires at the deadline and
|
|
1179
|
-
// cancels remaining iterations. 3 iters
|
|
1179
|
+
// cancels remaining iterations. 3 iters x 3 lenses x 90s =
|
|
1180
1180
|
// 270s worst case without a cap; this lets a caller say
|
|
1181
1181
|
// "no more than 4 minutes for the whole convergence".
|
|
1182
1182
|
// Defaults to env IJFW_AUDIT_CONVERGE_TOTAL_TIMEOUT_SEC.
|