@ijfw/memory-server 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/bin/ijfw +27 -0
  2. package/bin/ijfw-dashboard +180 -0
  3. package/bin/ijfw-dispatch-plan +41 -0
  4. package/bin/ijfw-memorize +273 -0
  5. package/bin/ijfw-memory +51 -0
  6. package/fixtures/demo-target.js +28 -0
  7. package/package.json +53 -0
  8. package/src/api-client.js +190 -0
  9. package/src/audit-roster.js +315 -0
  10. package/src/caps.js +37 -0
  11. package/src/cold-scan-runner.mjs +37 -0
  12. package/src/compute/edges.js +155 -0
  13. package/src/compute/extract.js +560 -0
  14. package/src/compute/fts5.js +420 -0
  15. package/src/compute/graph-auto-index.js +191 -0
  16. package/src/compute/graph-lock.js +114 -0
  17. package/src/compute/index.js +18 -0
  18. package/src/compute/migration-runner.js +116 -0
  19. package/src/compute/migrations/001-initial.js +23 -0
  20. package/src/compute/migrations/002-porter-stemming-source.js +139 -0
  21. package/src/compute/migrations/003-tier-semantic.js +69 -0
  22. package/src/compute/migrations/004-kg-tables.js +83 -0
  23. package/src/compute/migrations/005-stale-candidate.js +72 -0
  24. package/src/compute/python-resolver.js +106 -0
  25. package/src/compute/runner-vm.js +185 -0
  26. package/src/compute/runner.js +416 -0
  27. package/src/compute/sandbox-detect.js +122 -0
  28. package/src/compute/sandbox-linux.js +164 -0
  29. package/src/compute/sandbox-macos.js +167 -0
  30. package/src/compute/sandbox-windows.js +63 -0
  31. package/src/compute/schema.sql +118 -0
  32. package/src/compute/staleness.js +239 -0
  33. package/src/compute/synonyms.js +367 -0
  34. package/src/compute/traverse.js +180 -0
  35. package/src/cost/aggregator.js +229 -0
  36. package/src/cost/pricing.js +134 -0
  37. package/src/cost/readers/claude.js +179 -0
  38. package/src/cost/readers/codex.js +131 -0
  39. package/src/cost/readers/gemini.js +111 -0
  40. package/src/cost/savings.js +243 -0
  41. package/src/cross-dispatcher.js +437 -0
  42. package/src/cross-orchestrator-cli.js +1885 -0
  43. package/src/cross-orchestrator.js +598 -0
  44. package/src/cross-project-search.js +114 -0
  45. package/src/dashboard-client.html +1180 -0
  46. package/src/dashboard-server.js +895 -0
  47. package/src/design-companion.js +81 -0
  48. package/src/dispatch/colon-syntax.js +732 -0
  49. package/src/dispatch-planner.js +235 -0
  50. package/src/dream/cooldown.js +105 -0
  51. package/src/dream/runner.mjs +373 -0
  52. package/src/dream/staleness-wiring.js +195 -0
  53. package/src/feedback-detector.js +57 -0
  54. package/src/hero-line.js +115 -0
  55. package/src/importers/claude-mem.js +152 -0
  56. package/src/importers/cli.js +311 -0
  57. package/src/importers/common.js +84 -0
  58. package/src/importers/discover.js +235 -0
  59. package/src/importers/rtk.js +107 -0
  60. package/src/intent-router.js +221 -0
  61. package/src/lib/atomic-io.js +201 -0
  62. package/src/lib/cache.js +33 -0
  63. package/src/lib/npm-view.js +104 -0
  64. package/src/lib/status-card.js +95 -0
  65. package/src/lib/token.js +85 -0
  66. package/src/memory/fts5.js +349 -0
  67. package/src/memory/migration-runner.js +116 -0
  68. package/src/memory/migrations/001-fts5-init.js +26 -0
  69. package/src/memory/migrations/002-tier-semantic.js +60 -0
  70. package/src/memory/migrations/003-stale-candidate.js +60 -0
  71. package/src/memory/reader.js +300 -0
  72. package/src/memory/recall-counter.js +76 -0
  73. package/src/memory/schema.sql +79 -0
  74. package/src/memory/search.js +431 -0
  75. package/src/memory/staleness.js +237 -0
  76. package/src/memory/tier-promotion.js +377 -0
  77. package/src/memory/tokenize.js +63 -0
  78. package/src/project-type-detector.js +866 -0
  79. package/src/prompt-check.js +171 -0
  80. package/src/ralph-allowlist.js +88 -0
  81. package/src/receipts.js +129 -0
  82. package/src/redactor.js +107 -0
  83. package/src/sandbox.js +275 -0
  84. package/src/sanitizer.js +69 -0
  85. package/src/scan-resume.js +167 -0
  86. package/src/schema.js +82 -0
  87. package/src/search-bm25.js +108 -0
  88. package/src/server.js +1414 -0
  89. package/src/swarm-config.js +80 -0
  90. package/src/trident/dispatch.js +211 -0
  91. package/src/trident/lens-health.js +253 -0
  92. package/src/update-apply.js +79 -0
  93. package/src/update-check.js +136 -0
  94. package/src/vectors.js +178 -0
  95. package/templates/design/bento-grid.md +84 -0
  96. package/templates/design/brutalist-luxe.md +82 -0
  97. package/templates/design/cinematic-dark.md +82 -0
  98. package/templates/design/data-dense-dashboard.md +88 -0
  99. package/templates/design/editorial-warm.md +81 -0
  100. package/templates/design/glassmorphic.md +84 -0
  101. package/templates/design/magazine-editorial.md +84 -0
  102. package/templates/design/maximalist-vibrant.md +85 -0
  103. package/templates/design/neo-swiss-tech.md +85 -0
  104. package/templates/design/swiss-minimal.md +80 -0
  105. package/templates/design/terminal-native.md +83 -0
  106. package/templates/design/warm-organic.md +84 -0
@@ -0,0 +1,190 @@
1
+ // api-client.js -- API-key fallback for cross-audit/research/critique.
2
+ //
3
+ // Uses Node 19+ native fetch (Undici). Zero external deps.
4
+ // Each provider gets its own request builder; the caller treats the
5
+ // returned text like CLI stdout.
6
+
7
+ import { getTemplate } from './cross-dispatcher.js';
8
+
9
+ const DEFAULT_TIMEOUT_MS = 30_000;
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Provider request builders
13
+ // ---------------------------------------------------------------------------
14
+
15
+ // Optional `endpoint` argument lets OpenAI-compatible providers (Qwen via
16
+ // DashScope, Together, Groq, etc.) reuse the same request/response shape
17
+ // while pointing at their own URL. When omitted, falls back to OpenAI's
18
+ // canonical chat-completions endpoint.
19
+ function buildOpenAI(system, user, model, key, timeoutMs, endpoint) {
20
+ return {
21
+ url: endpoint || 'https://api.openai.com/v1/chat/completions',
22
+ options: {
23
+ method: 'POST',
24
+ headers: {
25
+ 'Content-Type': 'application/json',
26
+ 'Authorization': `Bearer ${key}`,
27
+ },
28
+ body: JSON.stringify({
29
+ model,
30
+ messages: [
31
+ { role: 'system', content: system },
32
+ { role: 'user', content: user },
33
+ ],
34
+ }),
35
+ signal: AbortSignal.timeout(timeoutMs),
36
+ },
37
+ };
38
+ }
39
+
40
+ function buildGemini(system, user, model, key, timeoutMs, endpoint) {
41
+ // 1.2.5: defensive guard against missing endpoint (B3.1) -- the roster entry
42
+ // always supplies one for provider:'google', but the runtime guard means a
43
+ // misconfigured fallback fails with a clear error instead of TypeError.
44
+ if (!endpoint || typeof endpoint !== 'string') {
45
+ throw new Error('buildGemini: apiFallback.endpoint is required for provider="google"');
46
+ }
47
+ // 1.2.5: drop the redundant ?key= URL parameter (B3.2). Auth flows entirely
48
+ // through the x-goog-api-key header below; the URL form was redundant +
49
+ // slightly leakier (logs / proxies can capture URLs more easily than headers).
50
+ const url = endpoint.replace('{model}', model);
51
+ return {
52
+ url,
53
+ options: {
54
+ method: 'POST',
55
+ headers: {
56
+ 'Content-Type': 'application/json',
57
+ 'x-goog-api-key': key,
58
+ },
59
+ body: JSON.stringify({
60
+ systemInstruction: { parts: [{ text: system }] },
61
+ contents: [{ role: 'user', parts: [{ text: user }] }],
62
+ }),
63
+ signal: AbortSignal.timeout(timeoutMs),
64
+ },
65
+ };
66
+ }
67
+
68
+ // Sonnet 4.5 prompt-caching threshold: 1024 tokens (rough: chars / 4).
69
+ const CACHE_TOKEN_THRESHOLD = 1024;
70
+
71
+ function buildAnthropic(system, user, model, key, timeoutMs) {
72
+ const promptChars = system.length + user.length;
73
+ const estimatedTokens = Math.floor(promptChars / 4);
74
+ const cacheEligible = estimatedTokens >= CACHE_TOKEN_THRESHOLD;
75
+
76
+ const systemBlock = cacheEligible
77
+ ? [{ type: 'text', text: system, cache_control: { type: 'ephemeral' } }]
78
+ : system;
79
+
80
+ return {
81
+ url: 'https://api.anthropic.com/v1/messages',
82
+ options: {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Content-Type': 'application/json',
86
+ 'x-api-key': key,
87
+ 'anthropic-version': '2023-06-01',
88
+ 'anthropic-beta': 'prompt-caching-2024-07-31',
89
+ },
90
+ body: JSON.stringify({
91
+ model,
92
+ max_tokens: 4096,
93
+ system: systemBlock,
94
+ messages: [{ role: 'user', content: user }],
95
+ }),
96
+ signal: AbortSignal.timeout(timeoutMs),
97
+ },
98
+ _cacheEligible: cacheEligible,
99
+ };
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Text extractor -- normalises the three provider response shapes
104
+ // ---------------------------------------------------------------------------
105
+
106
+ function extractText(provider, json) {
107
+ // openai-compat (Qwen via DashScope, Together, Groq, etc.) reuses the
108
+ // OpenAI chat-completions response shape, so the extractor is shared.
109
+ if (provider === 'openai' || provider === 'openai-compat') {
110
+ return json?.choices?.[0]?.message?.content ?? '';
111
+ }
112
+ if (provider === 'google') {
113
+ return json?.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
114
+ }
115
+ if (provider === 'anthropic') {
116
+ const block = (json?.content ?? []).find(b => b.type === 'text');
117
+ return block?.text ?? '';
118
+ }
119
+ return '';
120
+ }
121
+
122
+ function extractCacheStats(json, cacheEligible) {
123
+ if (!cacheEligible) {
124
+ return {
125
+ cache_eligible: false,
126
+ cache_eligible_reason: 'prompt < 1024 tokens',
127
+ };
128
+ }
129
+ const usage = json?.usage ?? {};
130
+ return {
131
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
132
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
133
+ cache_eligible: true,
134
+ };
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Main export
139
+ // ---------------------------------------------------------------------------
140
+
141
+ // runViaApi(pick, mode, angle, target, env, timeoutMs?, abortSignal?)
142
+ // Returns { status: 'ok', raw, model } or { status: 'failed', error, model }.
143
+ export async function runViaApi(pick, mode, angle, target, env = process.env, timeoutMs = DEFAULT_TIMEOUT_MS, abortSignal = null) {
144
+ const fb = pick.apiFallback;
145
+ if (!fb) return { status: 'failed', error: 'no API fallback configured', model: '' };
146
+
147
+ const key = env[fb.authEnv];
148
+ if (!key) return { status: 'failed', error: `${fb.authEnv} not set`, model: fb.model };
149
+
150
+ const { system, format } = getTemplate(mode, angle);
151
+ const user = `${format}\n\n## Target\n\n${target}`;
152
+
153
+ // Combine caller abort signal with our per-call timeout signal.
154
+ const timeoutSig = AbortSignal.timeout(timeoutMs);
155
+ const combinedSignal = abortSignal ? AbortSignal.any([timeoutSig, abortSignal]) : timeoutSig;
156
+
157
+ let req;
158
+ if (fb.provider === 'openai') {
159
+ req = buildOpenAI(system, user, fb.model, key, timeoutMs);
160
+ } else if (fb.provider === 'openai-compat') {
161
+ // OpenAI-compatible endpoints (Qwen via DashScope, Together, Groq, etc.)
162
+ // share the chat-completions request/response shape and only differ on URL.
163
+ req = buildOpenAI(system, user, fb.model, key, timeoutMs, fb.endpoint);
164
+ } else if (fb.provider === 'google') {
165
+ req = buildGemini(system, user, fb.model, key, timeoutMs, fb.endpoint);
166
+ } else if (fb.provider === 'anthropic') {
167
+ req = buildAnthropic(system, user, fb.model, key, timeoutMs);
168
+ } else {
169
+ return { status: 'failed', error: `unknown provider: ${fb.provider}`, model: fb.model };
170
+ }
171
+ // Override signal to use the combined abort signal.
172
+ req.options.signal = combinedSignal;
173
+
174
+ try {
175
+ const res = await fetch(req.url, req.options);
176
+ if (!res.ok) {
177
+ const body = await res.text().catch(() => '');
178
+ return { status: 'failed', error: `HTTP ${res.status}: ${body.slice(0, 300)}`, model: fb.model };
179
+ }
180
+ const json = await res.json();
181
+ const raw = extractText(fb.provider, json);
182
+ if (fb.provider === 'anthropic') {
183
+ const cache_stats = extractCacheStats(json, req._cacheEligible);
184
+ return { status: 'ok', raw, model: fb.model, cache_stats };
185
+ }
186
+ return { status: 'ok', raw, model: fb.model };
187
+ } catch (err) {
188
+ return { status: 'failed', error: err.message ?? String(err), model: fb.model };
189
+ }
190
+ }
@@ -0,0 +1,315 @@
1
+ // --- Cross-audit roster (P5 followup) ---
2
+ //
3
+ // Who can we ask for a second opinion? This module knows the roster of
4
+ // audit-capable CLI tools, fingerprints the currently-running caller via
5
+ // env vars, AND probes whether each CLI is actually installed on PATH.
6
+ //
7
+ // Donahoe principle: never trust a single AI; run through (at least) three.
8
+ // Caller is one. We aim to suggest two reviewers -- the Trident.
9
+ //
10
+ // Detection is conservative: we'd rather show all options than silently
11
+ // exclude a valid one. If we genuinely can't tell who's calling, nothing
12
+ // gets filtered as "self."
13
+
14
+ import { spawnSync } from 'node:child_process';
15
+
16
+ export const ROSTER = [
17
+ {
18
+ id: 'codex',
19
+ family: 'openai',
20
+ model: '',
21
+ name: 'Codex CLI',
22
+ invoke: 'codex exec --skip-git-repo-check --sandbox read-only -c approval_policy="never" -c mcp_servers.ijfw-memory.enabled=false -',
23
+ note: 'Different training lineage; fast on review tasks. The - flag reads prompt from stdin. --skip-git-repo-check bypasses the trusted-directory gate added in codex-cli 0.118.0. --sandbox read-only blocks file WRITES on the host (verified Codex 0.122.0: `echo > /tmp/x` returns `operation not permitted`); it does NOT block shell exec or subprocess launching -- a `read-only` sandbox can still run `ls`, `curl`, or `gemini`. The defense against codex going meta and shelling out to other auditors is the prompt-layer "Operating constraints" block in cross-dispatcher.js buildRequest, not the sandbox flag. The model layer additionally refuses to read explicitly-secret files like ~/.ssh/id_rsa or ~/.codex/auth.json even when prompt-injected to do so. The visibility surface in cross-orchestrator-cli.js cmdCross catches any residual silent failure. approval_policy="never" auto-approves without an interactive prompt. mcp_servers.ijfw-memory.enabled=false disables IJFW MCP for this session because Codex in `codex exec` mode under a non-bypass sandbox auto-cancels MCP tool calls -- the cancellation noise wastes tokens and the audit does not need IJFW memory recall (the brief contains the full target inline).',
24
+ // CODEX_SESSION_ID is set by codex itself when running INSIDE a codex
25
+ // session; CODEX_HOME is a config-path env var that's set whenever codex
26
+ // is *installed* (not just when active), so checking it here would falsely
27
+ // self-exclude codex from every Trident run on machines that have codex
28
+ // installed but where the caller is something else (Claude Code, Cursor,
29
+ // etc.). Surface noted by carrmjw during the qwen roster review (#11).
30
+ detect: (env) => Boolean(env.CODEX_SESSION_ID) || /codex/i.test(env._ || ''),
31
+ apiFallback: { provider: 'openai', model: 'gpt-4o-mini', authEnv: 'OPENAI_API_KEY', endpoint: 'https://api.openai.com/v1/chat/completions' },
32
+ },
33
+ {
34
+ id: 'gemini',
35
+ family: 'google',
36
+ model: '',
37
+ name: 'Gemini CLI',
38
+ invoke: 'gemini',
39
+ note: 'Strong on security + architectural patterns. Auto-detects piped stdin for headless mode.',
40
+ detect: (env) => Boolean(env.GEMINI_CLI || env.GOOGLE_CLOUD_PROJECT_GEMINI) || /gemini-cli/i.test(env._ || ''),
41
+ apiFallback: { provider: 'google', model: 'gemini-2.0-flash', authEnv: 'GEMINI_API_KEY', endpoint: 'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent' },
42
+ },
43
+ {
44
+ id: 'qwen',
45
+ family: 'oss',
46
+ model: '',
47
+ name: 'Qwen Code',
48
+ invoke: 'qwen -p',
49
+ note: 'Apache-2.0 weights (Qwen3-Coder-480B-A35B), agentic-tuned (~67% SWE-Bench Verified). Fork of gemini-cli; supports qwen-oauth (free Coding Plan tier), plus openai/anthropic/gemini auth-types via `qwen auth`. Diversity value for Trident: third independent training lineage outside openai/google.',
50
+ detect: (env) => Boolean(env.QWEN_SESSION) || /(?:^|\W)qwen(?:\W|$)/i.test(env._ || ''),
51
+ apiFallback: { provider: 'openai-compat', model: 'qwen3-coder-plus', authEnv: 'DASHSCOPE_API_KEY', endpoint: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/chat/completions' },
52
+ },
53
+ {
54
+ id: 'deepseek',
55
+ family: 'oss',
56
+ model: '',
57
+ name: 'DeepSeek',
58
+ invoke: 'deepseek',
59
+ note: 'DeepSeek-V4 (Chinese open-source lineage, MIT-licensed weights). Distinct training data and posttraining recipe from openai/google/anthropic, which is exactly what the Trident wants for adversarial review. No first-party canonical CLI -- multiple third-party CLIs exist; API path via DeepSeek Platform is the load-bearing one for this entry. Pricing is among the cheapest of any reasoning-capable model on the roster.',
60
+ detect: () => false,
61
+ apiFallback: { provider: 'openai-compat', model: 'deepseek-v4-pro', authEnv: 'DEEPSEEK_API_KEY', endpoint: 'https://api.deepseek.com/v1/chat/completions' },
62
+ },
63
+ {
64
+ id: 'kimi',
65
+ family: 'oss',
66
+ model: '',
67
+ name: 'Kimi (Moonshot)',
68
+ invoke: 'kimi',
69
+ note: 'Moonshot AI Kimi K2 series (Chinese open-source lineage, separate from DeepSeek). Long-context strength makes it useful for whole-file or whole-module audits where context window matters. OpenAI-compatible API via platform.moonshot.ai. Detection is left at false because no canonical session env var ships with Kimi today -- prefer double-coverage over false self-exclusion.',
70
+ detect: () => false,
71
+ apiFallback: { provider: 'openai-compat', model: 'kimi-k2.6', authEnv: 'MOONSHOT_API_KEY', endpoint: 'https://api.moonshot.ai/v1/chat/completions' },
72
+ },
73
+ {
74
+ id: 'opencode',
75
+ family: 'oss',
76
+ model: '',
77
+ name: 'opencode',
78
+ invoke: 'opencode',
79
+ note: 'OSS / local-friendly; good when privacy matters.',
80
+ detect: (env) => Boolean(env.OPENCODE_SESSION || env.OPENCODE_HOME),
81
+ apiFallback: null,
82
+ },
83
+ {
84
+ id: 'aider',
85
+ family: 'oss',
86
+ model: '',
87
+ name: 'Aider',
88
+ invoke: 'aider --message',
89
+ note: 'Code-focused peer; terse + diff-aware.',
90
+ detect: (env) => Boolean(env.AIDER_SESSION) || /aider/i.test(env._ || ''),
91
+ apiFallback: null,
92
+ },
93
+ {
94
+ id: 'copilot',
95
+ family: 'openai',
96
+ model: '',
97
+ name: 'Copilot CLI',
98
+ invoke: 'gh copilot suggest',
99
+ note: 'Convenient if gh CLI is already authenticated.',
100
+ detect: (env) => Boolean(env.GH_COPILOT_TOKEN || env.COPILOT_CLI_SESSION),
101
+ apiFallback: null,
102
+ },
103
+ {
104
+ id: 'claude',
105
+ family: 'anthropic',
106
+ model: '',
107
+ name: 'Claude Code',
108
+ invoke: 'claude -p',
109
+ note: 'Anthropic; useful when you want a second Claude pass in a fresh session.',
110
+ detect: (env) => Boolean(env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT || env.CLAUDE_PLUGIN_ROOT),
111
+ apiFallback: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', authEnv: 'ANTHROPIC_API_KEY', endpoint: 'https://api.anthropic.com/v1/messages' },
112
+ },
113
+ ];
114
+
115
+ // Returns the id of the current caller, or null if unknown.
116
+ export function detectSelf(env = process.env) {
117
+ for (const entry of ROSTER) {
118
+ try { if (entry.detect(env)) return entry.id; } catch { /* ignore */ }
119
+ }
120
+ return null;
121
+ }
122
+
123
+ // Probe whether the auditor's CLI is on PATH. Cached per process.
124
+ // Exported so tests can prime the cache for deterministic behavior.
125
+ export const _installedCache = new Map();
126
+ export function isInstalled(id) {
127
+ if (_installedCache.has(id)) return _installedCache.get(id);
128
+ const entry = ROSTER.find(e => e.id === id);
129
+ if (!entry) return false;
130
+ // First word of invoke is the binary; the rest are args.
131
+ const bin = entry.invoke.split(/\s+/)[0];
132
+ // POSIX `command -v` is the portable existence check; bash builtin form
133
+ // works reliably across macOS + Linux. spawnSync exit code = 0 → present.
134
+ const r = spawnSync('bash', ['-lc', `command -v ${JSON.stringify(bin)} >/dev/null 2>&1`], { timeout: 2000 });
135
+ const installed = r.status === 0;
136
+ _installedCache.set(id, installed);
137
+ return installed;
138
+ }
139
+
140
+ // Check reachability: CLI (PATH probe) and/or API (env key present).
141
+ // Returns { cli: bool, api: bool, any: bool }. Does not touch isInstalled signature.
142
+ export function isReachable(id, env = process.env) {
143
+ const entry = ROSTER.find(e => e.id === id);
144
+ if (!entry) return { cli: false, api: false, any: false };
145
+ const cli = isInstalled(id);
146
+ const api = Boolean(entry.apiFallback && env[entry.apiFallback.authEnv]);
147
+ return { cli, api, any: cli || api };
148
+ }
149
+
150
+ // Returns roster entries with isSelf + installed flags resolved.
151
+ export function rosterWithStatus(env = process.env) {
152
+ const self = detectSelf(env);
153
+ return ROSTER.map(e => ({ ...e, isSelf: e.id === self, installed: isInstalled(e.id) }));
154
+ }
155
+
156
+ // The Trident: pick up to N (default 2) installed, non-self auditors in
157
+ // roster priority order. Returns { picks: [], missing: [], note: string }.
158
+ // - picks: chosen auditor entries, ready to invoke
159
+ // - missing: roster entries we'd have liked but aren't installed
160
+ // - note: human-readable advisory (Donahoe trident reminder when only 1)
161
+ export function pickAuditors({ count = 2, env = process.env, only = null, strategy = 'priority' } = {}) {
162
+ const all = rosterWithStatus(env);
163
+ // Annotate a pick with preferredSource:'api' when reachable only via API key.
164
+ function annotatePick(e) {
165
+ const reach = isReachable(e.id, env);
166
+ if (!reach.cli && reach.api) return { ...e, preferredSource: 'api' };
167
+ return e;
168
+ }
169
+
170
+ if (only) {
171
+ const ids = String(only).split(/[ ,]+/).map(s => s.toLowerCase()).filter(Boolean);
172
+ const picks = ids.map(id => all.find(e => e.id === id)).filter(Boolean);
173
+ // Self-exclusion applies to --with too -- the Trident's value is
174
+ // multi-lineage diversity, so requesting the caller's own family ID
175
+ // collapses to a single-source review. Surface it instead of running it.
176
+ const selfPicks = picks.filter(e => e.isSelf);
177
+ const nonSelfPicks = picks.filter(e => !e.isSelf);
178
+ const reachablePicks = nonSelfPicks.filter(e => isReachable(e.id, env).any);
179
+ const unreachable = nonSelfPicks.filter(e => !isReachable(e.id, env).any);
180
+ const missing = [...unreachable, ...selfPicks];
181
+ const noteParts = [];
182
+ if (unreachable.length) noteParts.push(`Requested but not reachable: ${unreachable.map(e => e.id).join(', ')}.`);
183
+ if (selfPicks.length) noteParts.push(`Skipped self-audit on ${selfPicks.map(e => e.id).join(', ')} -- pass a different auditor for cross-lineage review.`);
184
+ return {
185
+ picks: reachablePicks.map(annotatePick),
186
+ missing,
187
+ note: noteParts.join(' '),
188
+ };
189
+ }
190
+
191
+ if (strategy === 'diversity') {
192
+ const selfId = detectSelf(env);
193
+ const selfEntry = ROSTER.find(e => e.id === selfId);
194
+ const callerFamily = selfEntry ? selfEntry.family : null;
195
+
196
+ // Reachable (CLI or API) non-self entries, grouped by family
197
+ const eligible = all.filter(e => !e.isSelf && isReachable(e.id, env).any);
198
+ const byFamily = (fam) => eligible.filter(e => e.family === fam);
199
+
200
+ const TARGET_FAMILIES = ['openai', 'google'];
201
+ const picks = [];
202
+ const picked = new Set();
203
+ const missing = [];
204
+ const nudges = [];
205
+
206
+ for (const fam of TARGET_FAMILIES) {
207
+ if (fam === callerFamily) {
208
+ // Caller is in this family -- pick next-best family (oss, or other non-self)
209
+ const backfill = eligible.find(e => !picked.has(e.id) && e.family !== callerFamily);
210
+ if (backfill) {
211
+ picks.push(annotatePick(backfill));
212
+ picked.add(backfill.id);
213
+ nudges.push(`No ${fam}-family auditor outside caller -- using ${backfill.id} (${backfill.family}) as stand-in. Install a ${fam === 'openai' ? 'google' : 'openai'}-family auditor for full Trident diversity.`);
214
+ } else {
215
+ missing.push({ family: fam, reason: `no reachable auditor in family ${fam}` });
216
+ }
217
+ continue;
218
+ }
219
+ const candidates = byFamily(fam);
220
+ if (candidates.length > 0) {
221
+ const pick = candidates.find(e => !picked.has(e.id));
222
+ if (pick) {
223
+ picks.push(annotatePick(pick));
224
+ picked.add(pick.id);
225
+ } else {
226
+ // All family members already picked -- leave slot missing
227
+ missing.push({ family: fam, reason: `all reachable auditors in family ${fam} already selected` });
228
+ }
229
+ } else {
230
+ // No reachable member of this family -- backfill from oss or any remaining non-self
231
+ const backfill = eligible.find(e => !picked.has(e.id) && e.family !== callerFamily && !TARGET_FAMILIES.includes(e.family));
232
+ missing.push({ family: fam, reason: `no reachable auditor in family ${fam}` });
233
+ if (backfill) {
234
+ picks.push(annotatePick(backfill));
235
+ picked.add(backfill.id);
236
+ nudges.push(`No ${fam}-family auditor reachable -- using ${backfill.id} (${backfill.family}) as stand-in. Install gemini (google) or codex/copilot (openai) for full Trident lineage diversity.`);
237
+ }
238
+ }
239
+ }
240
+
241
+ // If we still have fewer than 2 picks, backfill from any remaining eligible
242
+ if (picks.length < 2) {
243
+ for (const e of eligible) {
244
+ if (picks.length >= 2) break;
245
+ if (!picked.has(e.id)) {
246
+ picks.push(annotatePick(e));
247
+ picked.add(e.id);
248
+ }
249
+ }
250
+ }
251
+
252
+ const baseNote = picks.length === 0
253
+ ? 'No external auditors reachable. Install codex, gemini, opencode, aider, or copilot (or set OPENAI_API_KEY / GEMINI_API_KEY) to use cross-audit.'
254
+ : picks.length < 2
255
+ ? `Donahoe Trident principle: cross-audit works best with two top-tier AIs reviewing alongside the caller. Only ${picks.length} reachable (${picks.map(e => e.id).join(', ')}); install another to triangulate findings.`
256
+ : '';
257
+ const note = [baseNote, ...nudges].filter(Boolean).join(' ');
258
+ return { picks, missing, note };
259
+ }
260
+
261
+ // Default: priority strategy
262
+ const eligible = all.filter(e => !e.isSelf && isReachable(e.id, env).any);
263
+ const picks = eligible.slice(0, count).map(annotatePick);
264
+ const wantMore = count - picks.length;
265
+ let note = '';
266
+ if (picks.length === 0) {
267
+ note = 'No external auditors reachable. Install codex, gemini, opencode, aider, or copilot (or set OPENAI_API_KEY / GEMINI_API_KEY) to use cross-audit.';
268
+ } else if (picks.length < count) {
269
+ note = `Donahoe Trident principle: cross-audit works best with two top-tier AIs reviewing alongside the caller. Only ${picks.length} reachable (${picks.map(e => e.id).join(', ')}); ${wantMore} short. Install another to triangulate findings -- single-reviewer audits miss what overlap would catch.`;
270
+ }
271
+ return { picks, missing: all.filter(e => !e.isSelf && !isReachable(e.id, env).any), note };
272
+ }
273
+
274
+ // Returns roster entries, marking self and filtering when requested.
275
+ // { excludeSelf: bool, only: string | null }
276
+ export function rosterFor({ excludeSelf = true, only = null, env = process.env } = {}) {
277
+ const self = detectSelf(env);
278
+ let list = ROSTER.map(e => ({ ...e, isSelf: e.id === self }));
279
+ if (only) {
280
+ const match = list.find(e => e.id === only.toLowerCase());
281
+ return match ? [match] : [];
282
+ }
283
+ if (excludeSelf && self) list = list.filter(e => !e.isSelf);
284
+ return list;
285
+ }
286
+
287
+ // Pick the top default auditor: highest-priority non-self entry that is
288
+ // actually reachable via CLI or API key. Returning a non-reachable pick used
289
+ // to give callers a misleading "ready" answer that fell over on first invoke.
290
+ export function defaultAuditor(env = process.env) {
291
+ const list = rosterFor({ excludeSelf: true, env });
292
+ const reachable = list.find(e => isReachable(e.id, env).any);
293
+ return reachable || list[0] || null;
294
+ }
295
+
296
+ // Pretty-print the roster for user consumption. Now shows install status
297
+ // and self marker so the user sees instantly what's actionable.
298
+ export function formatRoster(env = process.env) {
299
+ const self = detectSelf(env);
300
+ const all = rosterWithStatus(env);
301
+ const lines = [];
302
+ for (const e of all) {
303
+ // 'ready' covers CLI-installed AND API-only-reachable (e.g., user has
304
+ // OPENAI_API_KEY set but no codex binary on PATH). Previously this only
305
+ // checked installed and would suggest "install" even when the API path
306
+ // would have worked.
307
+ const reach = isReachable(e.id, env);
308
+ const role = e.isSelf ? 'self ' : (reach.any ? 'ready ' : 'install ');
309
+ lines.push(` ${e.id.padEnd(9)} ${role}-- ${e.name} (${e.invoke}) -- ${e.note}`);
310
+ }
311
+ const header = self
312
+ ? `Detected caller: ${self}. Roster (ready = installed + non-self):`
313
+ : `Caller unknown -- full roster:`;
314
+ return header + '\n' + lines.join('\n');
315
+ }
package/src/caps.js ADDED
@@ -0,0 +1,37 @@
1
+ // --- Storage caps (audit S1) ---
2
+ // Prevents a single rogue call from bloating knowledge files to MB-scale
3
+ // which would poison every future session-start read. Truncation uses a
4
+ // visible marker so callers (and users inspecting memory) can tell data
5
+ // was cut.
6
+ //
7
+ // Truncation is codepoint-aware: slicing by UTF-16 code units would cut
8
+ // a surrogate pair mid-char and produce a dangling surrogate. We iterate
9
+ // by codepoint via `Array.from` (equivalent to [...s]) to stay correct
10
+ // on emoji, CJK, and combining sequences.
11
+
12
+ export const CAP_CONTENT = 4096;
13
+ export const CAP_WHY = 1024;
14
+ export const CAP_HOW = 1024;
15
+ export const CAP_SUMMARY = 120;
16
+
17
+ const MARKER = '…[truncated]';
18
+
19
+ function cap(s, limit) {
20
+ if (typeof s !== 'string' || !s) return '';
21
+ if (s.length <= limit) return s;
22
+ // s.length is in UTF-16 code units -- codepoint count is <= that. If the
23
+ // codepoint count is under the limit, return unchanged (no slice needed).
24
+ const codepoints = Array.from(s);
25
+ if (codepoints.length <= limit) return s;
26
+ const keep = Math.max(0, limit - MARKER.length);
27
+ return codepoints.slice(0, keep).join('') + MARKER;
28
+ }
29
+
30
+ export function applyCaps({ content, summary, why, how_to_apply } = {}) {
31
+ return {
32
+ content: cap(content, CAP_CONTENT),
33
+ summary: cap(summary, CAP_SUMMARY),
34
+ why: cap(why, CAP_WHY),
35
+ how_to_apply: cap(how_to_apply, CAP_HOW),
36
+ };
37
+ }
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // IJFW v1.3.0 Alpha -- A3 cold-scan runner.
3
+ //
4
+ // Tiny CLI adapter so the installer + hooks can spawn the detector as a
5
+ // detached child. Reads --project-root, runs detect() + writeProjectType(),
6
+ // exits 0 on completion. Any throw is swallowed so a broken scan never
7
+ // surfaces a non-zero exit to the parent installer / hook.
8
+ //
9
+ // Usage:
10
+ // node cold-scan-runner.mjs --project-root <path> [--no-c9] [--max-files N]
11
+
12
+ import { detect, writeProjectType } from './project-type-detector.js';
13
+
14
+ const argv = process.argv.slice(2);
15
+ const opts = { projectRoot: null, c9Available: true, maxFiles: null };
16
+ for (let i = 0; i < argv.length; i++) {
17
+ const a = argv[i];
18
+ if (a === '--project-root') opts.projectRoot = argv[++i];
19
+ else if (a === '--no-c9') opts.c9Available = false;
20
+ else if (a === '--max-files') opts.maxFiles = Number(argv[++i]);
21
+ }
22
+
23
+ if (!opts.projectRoot) process.exit(0);
24
+
25
+ try {
26
+ const result = detect(opts.projectRoot, {
27
+ c9Available: opts.c9Available,
28
+ maxFiles: opts.maxFiles || undefined,
29
+ sessionId: process.env.IJFW_SESSION_ID || null,
30
+ });
31
+ writeProjectType(opts.projectRoot, result);
32
+ process.exit(0);
33
+ } catch {
34
+ // Cold scan is best-effort. The hoist treats a missing project.type as
35
+ // "silent skip", so failing here only delays detection to the next session.
36
+ process.exit(0);
37
+ }