@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.
- package/bin/ijfw +27 -0
- package/bin/ijfw-dashboard +180 -0
- package/bin/ijfw-dispatch-plan +41 -0
- package/bin/ijfw-memorize +273 -0
- package/bin/ijfw-memory +51 -0
- package/fixtures/demo-target.js +28 -0
- package/package.json +53 -0
- package/src/api-client.js +190 -0
- package/src/audit-roster.js +315 -0
- package/src/caps.js +37 -0
- package/src/cold-scan-runner.mjs +37 -0
- package/src/compute/edges.js +155 -0
- package/src/compute/extract.js +560 -0
- package/src/compute/fts5.js +420 -0
- package/src/compute/graph-auto-index.js +191 -0
- package/src/compute/graph-lock.js +114 -0
- package/src/compute/index.js +18 -0
- package/src/compute/migration-runner.js +116 -0
- package/src/compute/migrations/001-initial.js +23 -0
- package/src/compute/migrations/002-porter-stemming-source.js +139 -0
- package/src/compute/migrations/003-tier-semantic.js +69 -0
- package/src/compute/migrations/004-kg-tables.js +83 -0
- package/src/compute/migrations/005-stale-candidate.js +72 -0
- package/src/compute/python-resolver.js +106 -0
- package/src/compute/runner-vm.js +185 -0
- package/src/compute/runner.js +416 -0
- package/src/compute/sandbox-detect.js +122 -0
- package/src/compute/sandbox-linux.js +164 -0
- package/src/compute/sandbox-macos.js +167 -0
- package/src/compute/sandbox-windows.js +63 -0
- package/src/compute/schema.sql +118 -0
- package/src/compute/staleness.js +239 -0
- package/src/compute/synonyms.js +367 -0
- package/src/compute/traverse.js +180 -0
- package/src/cost/aggregator.js +229 -0
- package/src/cost/pricing.js +134 -0
- package/src/cost/readers/claude.js +179 -0
- package/src/cost/readers/codex.js +131 -0
- package/src/cost/readers/gemini.js +111 -0
- package/src/cost/savings.js +243 -0
- package/src/cross-dispatcher.js +437 -0
- package/src/cross-orchestrator-cli.js +1885 -0
- package/src/cross-orchestrator.js +598 -0
- package/src/cross-project-search.js +114 -0
- package/src/dashboard-client.html +1180 -0
- package/src/dashboard-server.js +895 -0
- package/src/design-companion.js +81 -0
- package/src/dispatch/colon-syntax.js +732 -0
- package/src/dispatch-planner.js +235 -0
- package/src/dream/cooldown.js +105 -0
- package/src/dream/runner.mjs +373 -0
- package/src/dream/staleness-wiring.js +195 -0
- package/src/feedback-detector.js +57 -0
- package/src/hero-line.js +115 -0
- package/src/importers/claude-mem.js +152 -0
- package/src/importers/cli.js +311 -0
- package/src/importers/common.js +84 -0
- package/src/importers/discover.js +235 -0
- package/src/importers/rtk.js +107 -0
- package/src/intent-router.js +221 -0
- package/src/lib/atomic-io.js +201 -0
- package/src/lib/cache.js +33 -0
- package/src/lib/npm-view.js +104 -0
- package/src/lib/status-card.js +95 -0
- package/src/lib/token.js +85 -0
- package/src/memory/fts5.js +349 -0
- package/src/memory/migration-runner.js +116 -0
- package/src/memory/migrations/001-fts5-init.js +26 -0
- package/src/memory/migrations/002-tier-semantic.js +60 -0
- package/src/memory/migrations/003-stale-candidate.js +60 -0
- package/src/memory/reader.js +300 -0
- package/src/memory/recall-counter.js +76 -0
- package/src/memory/schema.sql +79 -0
- package/src/memory/search.js +431 -0
- package/src/memory/staleness.js +237 -0
- package/src/memory/tier-promotion.js +377 -0
- package/src/memory/tokenize.js +63 -0
- package/src/project-type-detector.js +866 -0
- package/src/prompt-check.js +171 -0
- package/src/ralph-allowlist.js +88 -0
- package/src/receipts.js +129 -0
- package/src/redactor.js +107 -0
- package/src/sandbox.js +275 -0
- package/src/sanitizer.js +69 -0
- package/src/scan-resume.js +167 -0
- package/src/schema.js +82 -0
- package/src/search-bm25.js +108 -0
- package/src/server.js +1414 -0
- package/src/swarm-config.js +80 -0
- package/src/trident/dispatch.js +211 -0
- package/src/trident/lens-health.js +253 -0
- package/src/update-apply.js +79 -0
- package/src/update-check.js +136 -0
- package/src/vectors.js +178 -0
- package/templates/design/bento-grid.md +84 -0
- package/templates/design/brutalist-luxe.md +82 -0
- package/templates/design/cinematic-dark.md +82 -0
- package/templates/design/data-dense-dashboard.md +88 -0
- package/templates/design/editorial-warm.md +81 -0
- package/templates/design/glassmorphic.md +84 -0
- package/templates/design/magazine-editorial.md +84 -0
- package/templates/design/maximalist-vibrant.md +85 -0
- package/templates/design/neo-swiss-tech.md +85 -0
- package/templates/design/swiss-minimal.md +80 -0
- package/templates/design/terminal-native.md +83 -0
- package/templates/design/warm-organic.md +84 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,1414 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IJFW Memory Server -- Cross-platform MCP memory for AI coding agents
|
|
5
|
+
* By Sean Donahoe | "It Just Fucking Works"
|
|
6
|
+
*
|
|
7
|
+
* 4 tools: recall, store, search, status
|
|
8
|
+
* Storage: append-only markdown (hot layer, zero dependencies)
|
|
9
|
+
* Protocol: MCP over stdio (JSON-RPC 2.0)
|
|
10
|
+
*
|
|
11
|
+
* Hardened against: prompt injection via stored content, cross-project worming,
|
|
12
|
+
* non-atomic writes, silent storage failures, Windows path traversal.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createInterface } from 'readline';
|
|
16
|
+
import {
|
|
17
|
+
existsSync, mkdirSync, readFileSync, writeFileSync,
|
|
18
|
+
appendFileSync, readdirSync, statSync, renameSync, unlinkSync,
|
|
19
|
+
openSync, closeSync, fsyncSync, realpathSync
|
|
20
|
+
} from 'fs';
|
|
21
|
+
import { join, resolve, isAbsolute, normalize, basename, dirname } from 'path';
|
|
22
|
+
import { homedir } from 'os';
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
24
|
+
import { createHash, randomBytes } from 'crypto';
|
|
25
|
+
|
|
26
|
+
// Read version dynamically from package.json so bumps don't require a code change.
|
|
27
|
+
const __pkg_dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const PKG_VERSION = (() => {
|
|
29
|
+
try { return JSON.parse(readFileSync(join(__pkg_dirname, '..', 'package.json'), 'utf8')).version; }
|
|
30
|
+
catch { return 'unknown'; }
|
|
31
|
+
})();
|
|
32
|
+
import { checkPrompt } from './prompt-check.js';
|
|
33
|
+
import { applyCaps, CAP_CONTENT } from './caps.js';
|
|
34
|
+
import { ensureSchemaHeader, SCHEMA_HEADER } from './schema.js';
|
|
35
|
+
import { searchCorpus } from './search-bm25.js';
|
|
36
|
+
import { crossProjectSearch } from './cross-project-search.js';
|
|
37
|
+
// R2-E -- single source of truth for markdown/HTML/control-char defanger.
|
|
38
|
+
import { sanitizeContent } from './sanitizer.js';
|
|
39
|
+
// 1.1.6: update tools (cap 8 -> 10) -- token-issuance + OOB terminal confirm.
|
|
40
|
+
// Per CLAUDE.md policy: future growth triggers retirement review, not raise.
|
|
41
|
+
import { ijfwUpdateCheck, TOOL_DEF as UPDATE_CHECK_TOOL } from './update-check.js';
|
|
42
|
+
import { ijfwUpdateApply, TOOL_DEF as UPDATE_APPLY_TOOL } from './update-apply.js';
|
|
43
|
+
// ijfw_run: sandbox large command output to disk, return terse summary to context.
|
|
44
|
+
import { runCommand, detectDomain, summarize, writeToSandbox, readFromSandbox, purgeSandboxOld, stripAnsi } from './sandbox.js';
|
|
45
|
+
// W1B (1.3.0-alpha) -- colon-syntax dispatcher. Extends ijfw_run + ijfw_memory_search
|
|
46
|
+
// with compute:/index:/detect: sub-commands without registering new MCP tools.
|
|
47
|
+
import { parseColonCommand, dispatchRun, dispatchSearch } from './dispatch/colon-syntax.js';
|
|
48
|
+
const SANDBOX_DIR = join(process.env.HOME || homedir(), '.ijfw', 'session-sandbox');
|
|
49
|
+
|
|
50
|
+
// --- Constants ---
|
|
51
|
+
const SCHEMA_VERSION = 1;
|
|
52
|
+
const MAX_STORE_LENGTH = CAP_CONTENT;
|
|
53
|
+
const MAX_TAGS = 20;
|
|
54
|
+
const MAX_TAG_LEN = 50;
|
|
55
|
+
const MAX_SEARCH_RESULTS = 20;
|
|
56
|
+
const MAX_FILE_READ = 5_000_000; // 5MB -- large enough that unbounded growth doesn't hit during normal lifetime
|
|
57
|
+
const VALID_MEMORY_TYPES = ['decision', 'observation', 'pattern', 'handoff', 'preference'];
|
|
58
|
+
|
|
59
|
+
// --- Project root resolution (path-traversal-safe; cross-platform) ---
|
|
60
|
+
// Strategy:
|
|
61
|
+
// 1. IJFW_PROJECT_DIR env (explicit) -- validated for traversal, used as-is.
|
|
62
|
+
// 2. CLAUDE_PROJECT_DIR env (set by Claude Code when project known) -- same validation.
|
|
63
|
+
// 3. process.cwd() -- used ONLY if writable. Claude Code sometimes spawns
|
|
64
|
+
// MCP servers in directories the user can't write to (/, /tmp).
|
|
65
|
+
// 4. os.homedir() -- final fallback. Always writable for the user.
|
|
66
|
+
//
|
|
67
|
+
// Picking a writable root at startup eliminates the EACCES-on-mkdir failure
|
|
68
|
+
// mode that corrupts the MCP stdio handshake (any stderr byte during init
|
|
69
|
+
// can make the client mark the server as failed).
|
|
70
|
+
function validatePath(raw) {
|
|
71
|
+
if (!raw) return null;
|
|
72
|
+
const resolved = resolve(raw);
|
|
73
|
+
const normalized = normalize(resolved);
|
|
74
|
+
if (!isAbsolute(normalized)) return null;
|
|
75
|
+
const parts = normalized.split(/[\\/]+/);
|
|
76
|
+
if (parts.includes('..')) return null;
|
|
77
|
+
return normalized;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isWritable(dir) {
|
|
81
|
+
try {
|
|
82
|
+
if (!existsSync(dir)) {
|
|
83
|
+
// Try to create it; if that works it's writable.
|
|
84
|
+
mkdirSync(dir, { recursive: true });
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
// Exists -- probe with a tmp file.
|
|
88
|
+
const probe = join(dir, `.ijfw-probe-${process.pid}-${Date.now()}`);
|
|
89
|
+
writeFileSync(probe, '');
|
|
90
|
+
unlinkSync(probe);
|
|
91
|
+
return true;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function safeProjectDir() {
|
|
98
|
+
// 1. Explicit IJFW_PROJECT_DIR wins (user or installer set it deliberately).
|
|
99
|
+
const fromIjfw = validatePath(process.env.IJFW_PROJECT_DIR);
|
|
100
|
+
if (fromIjfw && isWritable(fromIjfw)) return fromIjfw;
|
|
101
|
+
|
|
102
|
+
// 2. CLAUDE_PROJECT_DIR (set by some Claude Code versions).
|
|
103
|
+
const fromClaude = validatePath(process.env.CLAUDE_PROJECT_DIR);
|
|
104
|
+
if (fromClaude && isWritable(fromClaude)) return fromClaude;
|
|
105
|
+
|
|
106
|
+
// 3. CWD if writable -- normal case for shell-invoked use and Claude Code
|
|
107
|
+
// sessions rooted in a project.
|
|
108
|
+
const cwd = process.cwd();
|
|
109
|
+
if (isWritable(cwd)) return cwd;
|
|
110
|
+
|
|
111
|
+
// 4. HOME fallback -- always writable for the user. Memory becomes
|
|
112
|
+
// user-global but we stay alive instead of crashing.
|
|
113
|
+
return homedir();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const PROJECT_DIR = safeProjectDir();
|
|
117
|
+
const PROJECT_HASH = createHash('sha256').update(PROJECT_DIR).digest('hex').slice(0, 12);
|
|
118
|
+
const IJFW_DIR = join(PROJECT_DIR, '.ijfw');
|
|
119
|
+
const MEMORY_DIR = join(IJFW_DIR, 'memory');
|
|
120
|
+
const SESSIONS_DIR = join(IJFW_DIR, 'sessions');
|
|
121
|
+
const GLOBAL_DIR = join(homedir(), '.ijfw', 'memory');
|
|
122
|
+
// Legacy single-file location (pre-Phase 2). Still read for backward compat
|
|
123
|
+
// but new writes go to the faceted structure.
|
|
124
|
+
const LEGACY_GLOBAL_FILE = join(GLOBAL_DIR, 'global-knowledge.md');
|
|
125
|
+
// Faceted global memory (Phase 2). Each file is bounded, human-readable, git-friendly.
|
|
126
|
+
const GLOBAL_FACETS_DIR = join(GLOBAL_DIR, 'global');
|
|
127
|
+
const GLOBAL_FACETS = ['preferences', 'patterns', 'stack', 'anti-patterns', 'lessons'];
|
|
128
|
+
const DEFAULT_FACET = 'preferences';
|
|
129
|
+
// Phase 3: cross-project registry. Session-start hooks append one line per
|
|
130
|
+
// known IJFW project. Used by search(scope:'all') and recall(from_project:X).
|
|
131
|
+
const REGISTRY_FILE = join(homedir(), '.ijfw', 'registry.md');
|
|
132
|
+
// Phase 3 #8: team memory tier. Project-local, faceted, committed alongside
|
|
133
|
+
// personal memory but distinguished as shared decisions/patterns/stack/members.
|
|
134
|
+
// Precedence: team > personal > global. Empty by default -- no behavior change
|
|
135
|
+
// until user creates .ijfw/team/<facet>.md (commits it for teammates).
|
|
136
|
+
const TEAM_DIR_NAME = 'team';
|
|
137
|
+
const TEAM_FACETS = ['decisions', 'patterns', 'stack', 'members'];
|
|
138
|
+
|
|
139
|
+
// Claude Code's native auto-memory lives at ~/.claude/projects/<encoded>/memory/
|
|
140
|
+
// where <encoded> is the project path with `/` → `-`. IJFW reads these files
|
|
141
|
+
// and surfaces them via MCP so all platforms (not just Claude) see the same
|
|
142
|
+
// memories -- no fighting Claude's native "Remember X" handler.
|
|
143
|
+
const NATIVE_CLAUDE_DIR = join(
|
|
144
|
+
homedir(), '.claude', 'projects',
|
|
145
|
+
PROJECT_DIR.replace(/\//g, '-'),
|
|
146
|
+
'memory'
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// --- Bootstrap directories ---
|
|
150
|
+
// Project dirs are required; global is best-effort (HOME may be read-only on CI).
|
|
151
|
+
// Failures here do NOT write to stderr during startup -- any stderr byte during
|
|
152
|
+
// MCP handshake can make strict clients (incl. Claude Code) mark the server
|
|
153
|
+
// as failed. Subsequent store/read calls surface structured errors instead.
|
|
154
|
+
try {
|
|
155
|
+
[MEMORY_DIR, SESSIONS_DIR].forEach(dir => {
|
|
156
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
157
|
+
});
|
|
158
|
+
} catch { /* handleStore/recall surface structured errors on first use */ }
|
|
159
|
+
try {
|
|
160
|
+
if (!existsSync(GLOBAL_DIR)) mkdirSync(GLOBAL_DIR, { recursive: true });
|
|
161
|
+
} catch { /* handleStore reports on attempted write */ }
|
|
162
|
+
|
|
163
|
+
// R2-E -- sanitizeContent moved to mcp-server/src/sanitizer.js so MCP stores
|
|
164
|
+
// and auto-memorize stores share a single implementation. Imported above.
|
|
165
|
+
|
|
166
|
+
// --- Atomic write (write to .tmp, fsync, rename) ---
|
|
167
|
+
//
|
|
168
|
+
// Eliminates partial-write corruption on crash and makes concurrent writers
|
|
169
|
+
// from two server instances on the same project safe at the file level
|
|
170
|
+
// (last writer wins atomically, no interleaved bytes).
|
|
171
|
+
function atomicWrite(filepath, content) {
|
|
172
|
+
const tmp = `${filepath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
|
|
173
|
+
let fd;
|
|
174
|
+
try {
|
|
175
|
+
fd = openSync(tmp, 'w');
|
|
176
|
+
writeFileSync(fd, content, 'utf-8');
|
|
177
|
+
fsyncSync(fd);
|
|
178
|
+
closeSync(fd);
|
|
179
|
+
fd = null;
|
|
180
|
+
renameSync(tmp, filepath);
|
|
181
|
+
return { ok: true };
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (fd != null) { try { closeSync(fd); } catch {} }
|
|
184
|
+
try { unlinkSync(tmp); } catch {}
|
|
185
|
+
return { ok: false, code: err.code || 'EUNKNOWN', message: err.message };
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- Read with explicit error reporting ---
|
|
190
|
+
//
|
|
191
|
+
// Returns { ok: true, content } on success including empty file.
|
|
192
|
+
// Returns { ok: false, reason } so callers can distinguish "absent" from
|
|
193
|
+
// "permission denied" / "too big" / "I/O error" -- silent null was the
|
|
194
|
+
// previous root of multiple bugs.
|
|
195
|
+
function readMarkdownFile(filepath) {
|
|
196
|
+
if (!existsSync(filepath)) return { ok: false, reason: 'absent' };
|
|
197
|
+
let stats;
|
|
198
|
+
try {
|
|
199
|
+
stats = statSync(filepath);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
return { ok: false, reason: err.code || 'stat-failed' };
|
|
202
|
+
}
|
|
203
|
+
if (stats.size > MAX_FILE_READ) return { ok: false, reason: 'too-large', size: stats.size };
|
|
204
|
+
try {
|
|
205
|
+
return { ok: true, content: readFileSync(filepath, 'utf-8') };
|
|
206
|
+
} catch (err) {
|
|
207
|
+
return { ok: false, reason: err.code || 'read-failed' };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Convenience wrapper: returns string ('' if absent or unreadable) for the
|
|
212
|
+
// recall hot-path where we just need text. Logs unexpected failures.
|
|
213
|
+
function readOr(filepath, fallback = '') {
|
|
214
|
+
const r = readMarkdownFile(filepath);
|
|
215
|
+
if (r.ok) return r.content;
|
|
216
|
+
if (r.reason !== 'absent') {
|
|
217
|
+
process.stderr.write(`IJFW: read ${basename(filepath)}: ${r.reason}\n`);
|
|
218
|
+
}
|
|
219
|
+
return fallback;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// --- Append helper (atomic for entries < PIPE_BUF; append-only growth) ---
|
|
223
|
+
//
|
|
224
|
+
// We rely on POSIX O_APPEND atomicity for entries under 4KB. Sanitized
|
|
225
|
+
// entries are bounded at MAX_STORE_LENGTH=5000 chars, but the entry header
|
|
226
|
+
// keeps each *line* well under 4KB after sanitization (single-line collapse).
|
|
227
|
+
function appendLine(filepath, line) {
|
|
228
|
+
try {
|
|
229
|
+
if (!existsSync(filepath)) {
|
|
230
|
+
// First write seeds the schema header (audit R1). Best-effort atomic.
|
|
231
|
+
const seed = `${SCHEMA_HEADER}\n# ${basename(filepath, '.md')}\n${line}\n`;
|
|
232
|
+
const r = atomicWrite(filepath, seed);
|
|
233
|
+
if (!r.ok) return r;
|
|
234
|
+
return { ok: true };
|
|
235
|
+
}
|
|
236
|
+
// Existing file: migrate if it predates the schema header.
|
|
237
|
+
try { ensureSchemaHeader(filepath); } catch { /* best-effort; append still runs */ }
|
|
238
|
+
appendFileSync(filepath, line + '\n');
|
|
239
|
+
return { ok: true };
|
|
240
|
+
} catch (err) {
|
|
241
|
+
return { ok: false, code: err.code || 'EUNKNOWN', message: err.message };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --- Recall observation emitter ---
|
|
246
|
+
// Appends a lightweight "memory-recall" entry to ~/.ijfw/observations.jsonl
|
|
247
|
+
// so the dashboard recall-counter can track per-file recall frequency.
|
|
248
|
+
// Best-effort: never throws, never blocks the recall response.
|
|
249
|
+
function emitRecallObservation({ context_hint, from_project } = {}) {
|
|
250
|
+
try {
|
|
251
|
+
const obsPath = join(homedir(), '.ijfw', 'observations.jsonl');
|
|
252
|
+
// Derive a plausible file_path from context_hint if it looks like a filename
|
|
253
|
+
const fp = (context_hint && context_hint.includes('.md'))
|
|
254
|
+
? join(GLOBAL_DIR, context_hint)
|
|
255
|
+
: null;
|
|
256
|
+
const obs = {
|
|
257
|
+
type: 'memory-recall',
|
|
258
|
+
ts: new Date().toISOString(),
|
|
259
|
+
tool_name: 'ijfw_memory_recall',
|
|
260
|
+
context_hint: context_hint || null,
|
|
261
|
+
file_path: fp,
|
|
262
|
+
from_project: from_project || null,
|
|
263
|
+
platform: 'mcp',
|
|
264
|
+
};
|
|
265
|
+
appendFileSync(obsPath, JSON.stringify(obs) + '\n');
|
|
266
|
+
} catch { /* best-effort */ }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// --- Storage helpers ---
|
|
270
|
+
function appendToJournal(entry) {
|
|
271
|
+
const journalPath = join(MEMORY_DIR, 'project-journal.md');
|
|
272
|
+
const ts = new Date().toISOString();
|
|
273
|
+
const line = `- [${ts}] ${entry}`;
|
|
274
|
+
return appendLine(journalPath, line);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Structured append for decisions/patterns -- produces a richer frontmatter block
|
|
278
|
+
// similar to Claude's native auto-memory format: YAML frontmatter plus a body with
|
|
279
|
+
// Why / How-to-apply sections. This is the format users retrieve well from.
|
|
280
|
+
function appendStructuredToKnowledge({ type, summary, content, why, howToApply, tags }) {
|
|
281
|
+
const filepath = join(MEMORY_DIR, 'knowledge.md');
|
|
282
|
+
const ts = new Date().toISOString();
|
|
283
|
+
const tagLine = tags && tags.length ? tags.join(', ') : '';
|
|
284
|
+
const block = [
|
|
285
|
+
'',
|
|
286
|
+
'---',
|
|
287
|
+
`type: ${type}`,
|
|
288
|
+
`summary: ${summary}`,
|
|
289
|
+
`stored: ${ts}`,
|
|
290
|
+
tagLine ? `tags: [${tagLine}]` : '',
|
|
291
|
+
'---',
|
|
292
|
+
content,
|
|
293
|
+
why ? `\n**Why:** ${why}` : '',
|
|
294
|
+
howToApply ? `\n**How to apply:** ${howToApply}` : '',
|
|
295
|
+
''
|
|
296
|
+
].filter(l => l !== '').join('\n') + '\n';
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
if (!existsSync(filepath)) {
|
|
300
|
+
const seed = `${SCHEMA_HEADER}\n# Knowledge Base\n${block}`;
|
|
301
|
+
return atomicWrite(filepath, seed);
|
|
302
|
+
}
|
|
303
|
+
try { ensureSchemaHeader(filepath); } catch { /* best-effort */ }
|
|
304
|
+
appendFileSync(filepath, block);
|
|
305
|
+
return { ok: true };
|
|
306
|
+
} catch (err) {
|
|
307
|
+
return { ok: false, code: err.code || 'EUNKNOWN', message: err.message };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Per-project namespacing prevents cross-project worming. A poisoned preference
|
|
312
|
+
// stored from project A is namespaced to A's hash, so project B never reads it
|
|
313
|
+
// as if it were its own preference.
|
|
314
|
+
//
|
|
315
|
+
// Phase 2: writes go to faceted files. facet is inferred from tags when present
|
|
316
|
+
// (tag matches facet name → that facet; else preferences). Legacy global file is
|
|
317
|
+
// read but not written -- future migration can merge it into facets.
|
|
318
|
+
function appendToGlobalPrefs(entry, tags = []) {
|
|
319
|
+
try {
|
|
320
|
+
if (!existsSync(GLOBAL_FACETS_DIR)) mkdirSync(GLOBAL_FACETS_DIR, { recursive: true });
|
|
321
|
+
} catch { /* best-effort -- if HOME is RO we can't write global */ }
|
|
322
|
+
const facet = GLOBAL_FACETS.find(f => tags.some(t => t.toLowerCase() === f)) || DEFAULT_FACET;
|
|
323
|
+
const namespaced = `[ns:${PROJECT_HASH}] ${entry}`;
|
|
324
|
+
return appendLine(join(GLOBAL_FACETS_DIR, `${facet}.md`), namespaced);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function readKnowledgeBase() {
|
|
328
|
+
return readOr(join(MEMORY_DIR, 'knowledge.md'));
|
|
329
|
+
}
|
|
330
|
+
function readHandoff() {
|
|
331
|
+
return readOr(join(MEMORY_DIR, 'handoff.md'));
|
|
332
|
+
}
|
|
333
|
+
// Read Claude Code native auto-memory for this project. Returns concatenated
|
|
334
|
+
// sanitized content of all project_*.md files (skipping MEMORY.md index).
|
|
335
|
+
// This lets IJFW surface Claude-native memories to other platforms that don't
|
|
336
|
+
// have an equivalent built-in system.
|
|
337
|
+
function readNativeClaudeMemory() {
|
|
338
|
+
try {
|
|
339
|
+
if (!existsSync(NATIVE_CLAUDE_DIR)) return '';
|
|
340
|
+
const files = readdirSync(NATIVE_CLAUDE_DIR)
|
|
341
|
+
.filter(f => f.endsWith('.md') && f !== 'MEMORY.md')
|
|
342
|
+
.sort();
|
|
343
|
+
const parts = [];
|
|
344
|
+
for (const f of files) {
|
|
345
|
+
const r = readMarkdownFile(join(NATIVE_CLAUDE_DIR, f));
|
|
346
|
+
if (!r.ok) continue;
|
|
347
|
+
// Strip YAML frontmatter for brevity in prelude -- keep the body that
|
|
348
|
+
// already includes the **Why:** / **How to apply:** sections.
|
|
349
|
+
const body = r.content.replace(/^---[\s\S]*?---\n/, '').trim();
|
|
350
|
+
if (body) parts.push(body);
|
|
351
|
+
}
|
|
352
|
+
return parts.join('\n\n---\n\n');
|
|
353
|
+
} catch {
|
|
354
|
+
return '';
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Phase 3 #8: team memory -- shared, project-local, committed. Read-only here.
|
|
359
|
+
// Faceted (decisions/patterns/stack/members) for parity with global tier;
|
|
360
|
+
// each facet is a plain markdown file that teammates edit via PR.
|
|
361
|
+
function readTeamKnowledge() {
|
|
362
|
+
const teamDir = join(IJFW_DIR, TEAM_DIR_NAME);
|
|
363
|
+
if (!existsSync(teamDir)) return '';
|
|
364
|
+
const out = [];
|
|
365
|
+
for (const facet of TEAM_FACETS) {
|
|
366
|
+
const raw = readOr(join(teamDir, `${facet}.md`));
|
|
367
|
+
if (raw) out.push(`### ${facet} (team)\n${raw}`);
|
|
368
|
+
}
|
|
369
|
+
return out.join('\n\n');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Global prefs are filtered to entries matching this project's namespace OR
|
|
373
|
+
// entries with no namespace (legacy/manual entries). Cross-project prefs are
|
|
374
|
+
// not exposed by default. Phase 2: reads both faceted files and legacy flat.
|
|
375
|
+
function readGlobalKnowledge() {
|
|
376
|
+
const sources = [];
|
|
377
|
+
// Faceted files (Phase 2)
|
|
378
|
+
if (existsSync(GLOBAL_FACETS_DIR)) {
|
|
379
|
+
for (const facet of GLOBAL_FACETS) {
|
|
380
|
+
const p = join(GLOBAL_FACETS_DIR, `${facet}.md`);
|
|
381
|
+
const raw = readOr(p);
|
|
382
|
+
if (raw) sources.push(`### ${facet}\n${raw}`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Legacy single-file (pre-Phase 2) -- still surface if present, unfaceted
|
|
386
|
+
const legacy = readOr(LEGACY_GLOBAL_FILE);
|
|
387
|
+
if (legacy) sources.push(`### legacy\n${legacy}`);
|
|
388
|
+
|
|
389
|
+
if (sources.length === 0) return '';
|
|
390
|
+
|
|
391
|
+
// Filter to entries matching this project's namespace (or unnamespaced).
|
|
392
|
+
return sources.map(section =>
|
|
393
|
+
section.split('\n').filter(line => {
|
|
394
|
+
if (!line.startsWith('[ns:')) return true;
|
|
395
|
+
return line.startsWith(`[ns:${PROJECT_HASH}]`);
|
|
396
|
+
}).join('\n')
|
|
397
|
+
).join('\n\n');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function getSessionCount() {
|
|
401
|
+
try {
|
|
402
|
+
if (!existsSync(SESSIONS_DIR)) return 0;
|
|
403
|
+
return readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.md')).length;
|
|
404
|
+
} catch {
|
|
405
|
+
return 0;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function getDecisionCount() {
|
|
410
|
+
const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
|
|
411
|
+
if (!journal) return 0;
|
|
412
|
+
// Match only journal entry lines (we now prefix with - [timestamp]) -- not
|
|
413
|
+
// arbitrary list bullets that might appear in seeded content.
|
|
414
|
+
return (journal.match(/^- \[\d{4}-\d{2}-\d{2}T/gm) || []).length;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function getRecentJournalEntries(count = 5) {
|
|
418
|
+
const journal = readOr(join(MEMORY_DIR, 'project-journal.md'));
|
|
419
|
+
if (!journal) return '';
|
|
420
|
+
const entries = journal.split('\n').filter(l => /^- \[\d{4}-/.test(l));
|
|
421
|
+
return entries.slice(-count).join('\n');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// --- Cross-project registry (Phase 3) ---
|
|
425
|
+
//
|
|
426
|
+
// Registry lines look like: <abs-path> | <sha256-12> | <first-seen-iso>
|
|
427
|
+
// Returns [{path, hash, iso}]. Skips malformed lines; excludes current project.
|
|
428
|
+
function readRegistry({ includeCurrent = false } = {}) {
|
|
429
|
+
const r = readMarkdownFile(REGISTRY_FILE);
|
|
430
|
+
if (!r.ok) return [];
|
|
431
|
+
const out = [];
|
|
432
|
+
for (const line of r.content.split('\n')) {
|
|
433
|
+
const parts = line.split('|').map(s => s.trim());
|
|
434
|
+
if (parts.length < 3) continue;
|
|
435
|
+
const [path, hash, iso] = parts;
|
|
436
|
+
if (!path || !isAbsolute(path)) continue;
|
|
437
|
+
if (!includeCurrent && path === PROJECT_DIR) continue;
|
|
438
|
+
out.push({ path, hash, iso });
|
|
439
|
+
}
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Resolve a from_project arg (path OR 12-char hash) to a registry entry.
|
|
444
|
+
function resolveProject(spec) {
|
|
445
|
+
if (!spec || typeof spec !== 'string') return null;
|
|
446
|
+
const all = readRegistry({ includeCurrent: true });
|
|
447
|
+
const trimmed = spec.trim();
|
|
448
|
+
// Try absolute path first, then hash, then basename suffix match.
|
|
449
|
+
return all.find(e => e.path === trimmed)
|
|
450
|
+
|| all.find(e => e.hash === trimmed)
|
|
451
|
+
|| all.find(e => basename(e.path) === trimmed)
|
|
452
|
+
|| null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Read this-project-shape memory for an arbitrary project root. Mirrors the
|
|
456
|
+
// sources the local search uses, but isolated to that project's directory.
|
|
457
|
+
function readProjectMemory(projectPath) {
|
|
458
|
+
const memDir = join(projectPath, '.ijfw', 'memory');
|
|
459
|
+
return {
|
|
460
|
+
knowledge: readOr(join(memDir, 'knowledge.md')),
|
|
461
|
+
journal: readOr(join(memDir, 'project-journal.md')),
|
|
462
|
+
handoff: readOr(join(memDir, 'handoff.md'))
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function searchAcrossProjects(query, limit) {
|
|
467
|
+
const queryLower = String(query).toLowerCase();
|
|
468
|
+
const keywords = queryLower.split(/\s+/).filter(w => w.length > 2);
|
|
469
|
+
if (keywords.length === 0) return [];
|
|
470
|
+
|
|
471
|
+
const results = [];
|
|
472
|
+
for (const entry of readRegistry()) {
|
|
473
|
+
const tag = basename(entry.path);
|
|
474
|
+
const mem = readProjectMemory(entry.path);
|
|
475
|
+
for (const [src, content] of Object.entries(mem)) {
|
|
476
|
+
if (!content) continue;
|
|
477
|
+
const lines = content.split('\n');
|
|
478
|
+
for (let i = 0; i < lines.length; i++) {
|
|
479
|
+
const line = lines[i];
|
|
480
|
+
if (line.trim().length === 0) continue;
|
|
481
|
+
const score = keywords.filter(k => line.toLowerCase().includes(k)).length;
|
|
482
|
+
if (score > 0) {
|
|
483
|
+
results.push({
|
|
484
|
+
source: `${src}@${tag}`,
|
|
485
|
+
line: i + 1,
|
|
486
|
+
content: `[project:${tag}] ${line.trim().substring(0, 200)}`,
|
|
487
|
+
score
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
results.sort((a, b) => b.score - a.score);
|
|
494
|
+
return results.slice(0, limit);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// --- Search ---
|
|
498
|
+
// P5.1 / H4 -- BM25 ranking over line-level docs. Source tags and line
|
|
499
|
+
// numbers preserved so callers get the same output shape; scoring is
|
|
500
|
+
// BM25 (IDF + TF + length-normalized) with per-source boost. Team tier
|
|
501
|
+
// ranks first via a score bump for ties.
|
|
502
|
+
function searchMemory(query, limit = 10, scope = 'project') {
|
|
503
|
+
limit = Math.min(Math.max(1, limit | 0), MAX_SEARCH_RESULTS);
|
|
504
|
+
if (scope === 'all') return searchAcrossProjects(query, limit);
|
|
505
|
+
|
|
506
|
+
const sources = [
|
|
507
|
+
{ name: 'team', content: readTeamKnowledge(), boost: 1.25 },
|
|
508
|
+
{ name: 'knowledge', content: readKnowledgeBase(), boost: 1.15 },
|
|
509
|
+
{ name: 'journal', content: readOr(join(MEMORY_DIR, 'project-journal.md')), boost: 1.0 },
|
|
510
|
+
{ name: 'handoff', content: readHandoff(), boost: 1.1 },
|
|
511
|
+
{ name: 'global', content: readGlobalKnowledge(), boost: 0.95 },
|
|
512
|
+
{ name: 'claude-native', content: readNativeClaudeMemory(), boost: 0.95 },
|
|
513
|
+
];
|
|
514
|
+
|
|
515
|
+
const docs = [];
|
|
516
|
+
const meta = new Map();
|
|
517
|
+
for (const src of sources) {
|
|
518
|
+
if (!src.content) continue;
|
|
519
|
+
const lines = src.content.split('\n');
|
|
520
|
+
for (let i = 0; i < lines.length; i++) {
|
|
521
|
+
const line = lines[i];
|
|
522
|
+
if (line.trim().length === 0) continue;
|
|
523
|
+
const id = `${src.name}:${i + 1}`;
|
|
524
|
+
docs.push({ id, text: line });
|
|
525
|
+
meta.set(id, { source: src.name, line: i + 1, boost: src.boost });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (docs.length === 0) return [];
|
|
529
|
+
|
|
530
|
+
const ranked = searchCorpus(query, docs, { limit: limit * 3 });
|
|
531
|
+
if (ranked.length === 0) return [];
|
|
532
|
+
|
|
533
|
+
const boosted = ranked.map(r => {
|
|
534
|
+
const m = meta.get(r.id);
|
|
535
|
+
return {
|
|
536
|
+
source: m.source,
|
|
537
|
+
line: m.line,
|
|
538
|
+
content: (r.snippet || '').substring(0, 200),
|
|
539
|
+
score: r.score * (m.boost || 1),
|
|
540
|
+
};
|
|
541
|
+
});
|
|
542
|
+
boosted.sort((a, b) => b.score - a.score);
|
|
543
|
+
return boosted.slice(0, limit);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// --- DESIGN picker (1.2.0 Phase 5) ---
|
|
547
|
+
// MCP-only delivery of the 12-template design catalog for OpenCode / Qwen
|
|
548
|
+
// Code / Kimi Code / OpenClaw / Aider. No new tool -- served via existing
|
|
549
|
+
// ijfw_memory_recall using context_hint colon-syntax:
|
|
550
|
+
// 'design_template' -> catalog of 12 names + descriptions
|
|
551
|
+
// 'design_template:<name>' -> full template body
|
|
552
|
+
const DESIGN_TEMPLATES_DIR = join(__pkg_dirname, '..', 'templates', 'design');
|
|
553
|
+
const DESIGN_TEMPLATE_NAME_RE = /^[a-z][a-z0-9-]{0,40}$/;
|
|
554
|
+
const DESIGN_TEMPLATE_CATALOG = [
|
|
555
|
+
['bento-grid', 'Modular card grid with varied sizes; Apple/Notion-style product pages.'],
|
|
556
|
+
['brutalist-luxe', 'Raw concrete textures + luxury editorial restraint; fashion and architecture brands.'],
|
|
557
|
+
['cinematic-dark', 'Film-grade dark UI with dramatic contrast; streaming, media, portfolio.'],
|
|
558
|
+
['data-dense-dashboard', 'Monitoring/BI layout optimized for information density.'],
|
|
559
|
+
['editorial-warm', 'Magazine feel in warm off-white; newsletters, blogs, long-form content.'],
|
|
560
|
+
['glassmorphic', 'Frosted translucency with soft blur; creative SaaS and fintech premium.'],
|
|
561
|
+
['magazine-editorial', 'Print-magazine hierarchy with bold display type; publishing, agency work.'],
|
|
562
|
+
['maximalist-vibrant', 'Saturated palettes, bold pattern, high energy; consumer lifestyle brands.'],
|
|
563
|
+
['neo-swiss-tech', 'Updated Swiss style with accent color and modern sans; dev tools, SaaS.'],
|
|
564
|
+
['swiss-minimal', 'Classical Swiss typographic school; developer-facing and documentation sites.'],
|
|
565
|
+
['terminal-native', 'Monospaced terminal aesthetic; CLIs, infra tools, hacker-brand products.'],
|
|
566
|
+
['warm-organic', 'Soft curves and earthy tones; wellness, sustainable brands, lifestyle.'],
|
|
567
|
+
];
|
|
568
|
+
|
|
569
|
+
function designCatalogText() {
|
|
570
|
+
const lines = DESIGN_TEMPLATE_CATALOG.map(([n, d]) => `- ${n} -- ${d}`);
|
|
571
|
+
lines.push('');
|
|
572
|
+
lines.push('Pick one with: ijfw_memory_recall({context_hint: "design_template:<name>"}).');
|
|
573
|
+
return `# DESIGN templates (12)\n${lines.join('\n')}`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function handleDesignTemplate(hint) {
|
|
577
|
+
// Catalog mode: bare 'design_template'.
|
|
578
|
+
if (hint === 'design_template') {
|
|
579
|
+
return { text: designCatalogText() };
|
|
580
|
+
}
|
|
581
|
+
// Body mode: 'design_template:<name>'.
|
|
582
|
+
const name = hint.slice('design_template:'.length);
|
|
583
|
+
const catalogNames = DESIGN_TEMPLATE_CATALOG.map(([n]) => n).join(', ');
|
|
584
|
+
if (!DESIGN_TEMPLATE_NAME_RE.test(name)) {
|
|
585
|
+
return { text: `Unknown template: ${name}. Catalog: ${catalogNames}`, isError: true };
|
|
586
|
+
}
|
|
587
|
+
const file = join(DESIGN_TEMPLATES_DIR, `${name}.md`);
|
|
588
|
+
// Defence-in-depth: realpath both sides so a symlink inside templates/design/
|
|
589
|
+
// can't escape the directory. Regex already blocks raw `..`, NUL, URL-encoded
|
|
590
|
+
// traversal; this closes the symlink hole caught by codex Round-4 audit.
|
|
591
|
+
try {
|
|
592
|
+
const baseReal = realpathSync.native(DESIGN_TEMPLATES_DIR);
|
|
593
|
+
const resolvedReal = realpathSync.native(file);
|
|
594
|
+
const expected = join(baseReal, `${name}.md`);
|
|
595
|
+
if (resolvedReal !== expected) {
|
|
596
|
+
return { text: `Unknown template: ${name}. Catalog: ${catalogNames}`, isError: true };
|
|
597
|
+
}
|
|
598
|
+
const body = readFileSync(resolvedReal, 'utf8');
|
|
599
|
+
return { text: body };
|
|
600
|
+
} catch {
|
|
601
|
+
return { text: `Unknown template: ${name}. Catalog: ${catalogNames}`, isError: true };
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// --- MCP Tool Definitions ---
|
|
606
|
+
const TOOLS = [
|
|
607
|
+
{
|
|
608
|
+
name: 'ijfw_memory_recall',
|
|
609
|
+
description: 'Wake up with project context intact -- past decisions, handoff state, and knowledge base in one call. Use at session start or when you need to remember why something was built a certain way. Pass from_project to pull from a different IJFW project by basename (simplest), 12-char hash, or absolute path. Also accepts context_hint "design_template" (12-template catalog) or "design_template:<name>" (full template body) for the DESIGN picker.',
|
|
610
|
+
inputSchema: {
|
|
611
|
+
type: 'object',
|
|
612
|
+
properties: {
|
|
613
|
+
context_hint: {
|
|
614
|
+
type: 'string',
|
|
615
|
+
description: 'What context is needed: "session_start" for wake-up injection, "handoff" for last session state, "decisions" for recent decisions, "design_template" for the 12-template DESIGN picker catalog, "design_template:<name>" for a specific template body, or a natural language query.'
|
|
616
|
+
},
|
|
617
|
+
detail_level: {
|
|
618
|
+
type: 'string',
|
|
619
|
+
enum: ['summary', 'standard', 'full'],
|
|
620
|
+
description: 'Level of detail. Summary: ~200 tokens. Standard: recent context. Full: everything.'
|
|
621
|
+
},
|
|
622
|
+
from_project: {
|
|
623
|
+
type: 'string',
|
|
624
|
+
description: 'Optional. Pull from a different IJFW project by absolute path, 12-char hash, or basename. Project must exist in the registry (~/.ijfw/registry.md).'
|
|
625
|
+
}
|
|
626
|
+
},
|
|
627
|
+
required: ['context_hint']
|
|
628
|
+
}
|
|
629
|
+
},
|
|
630
|
+
{
|
|
631
|
+
name: 'ijfw_memory_store',
|
|
632
|
+
description: 'Persist a decision, observation, or session state so it survives context resets. For decisions and patterns, add summary/why/how_to_apply for a richer knowledge-base entry. Returns isError on storage failure.',
|
|
633
|
+
inputSchema: {
|
|
634
|
+
type: 'object',
|
|
635
|
+
properties: {
|
|
636
|
+
content: { type: 'string', description: 'Full statement of what to remember. Max 5000 chars. Sanitised on storage.' },
|
|
637
|
+
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.' },
|
|
638
|
+
summary: { type: 'string', description: 'Optional 1-line summary (≤80 chars). Used as the frontmatter name for decisions/patterns.' },
|
|
639
|
+
why: { type: 'string', description: 'Optional rationale -- why this decision was made. Populates the Why section in the knowledge base entry.' },
|
|
640
|
+
how_to_apply: { type: 'string', description: 'Optional guidance -- when and how to apply this. Populates the How-to-apply section.' },
|
|
641
|
+
tags: { type: 'array', items: { type: 'string' }, description: 'Up to 20 tags, 50 chars each.' }
|
|
642
|
+
},
|
|
643
|
+
required: ['content', 'type']
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
{
|
|
647
|
+
name: 'ijfw_memory_search',
|
|
648
|
+
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.',
|
|
649
|
+
inputSchema: {
|
|
650
|
+
type: 'object',
|
|
651
|
+
properties: {
|
|
652
|
+
query: { type: 'string', description: 'Natural language search query. Not required when scope is "sandbox".' },
|
|
653
|
+
limit: { type: 'number', description: 'Max results (default 10, max 20).' },
|
|
654
|
+
scope: { type: 'string', enum: ['project', 'all', 'sandbox'], description: 'project (default) = this project only. all = every known IJFW project on this machine. sandbox = retrieve sandboxed ijfw_run output.' },
|
|
655
|
+
label: { type: 'string', description: 'Sandbox entry label to retrieve. Only used when scope is "sandbox". Omit to list all available entries.' }
|
|
656
|
+
},
|
|
657
|
+
required: []
|
|
658
|
+
}
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
name: 'ijfw_memory_prelude',
|
|
662
|
+
description: 'CALL THIS AT SESSION START. Returns all relevant project memory in one pass -- knowledge base, handoff state, recent activity. Eliminates the need to grep/search/recall separately. Call once at the start of a session before answering the user.',
|
|
663
|
+
inputSchema: {
|
|
664
|
+
type: 'object',
|
|
665
|
+
properties: {
|
|
666
|
+
detail_level: {
|
|
667
|
+
type: 'string',
|
|
668
|
+
enum: ['summary', 'standard', 'full'],
|
|
669
|
+
description: 'summary ≈ 200 tokens (defaults). standard ≈ 500 tokens. full = everything available.'
|
|
670
|
+
}
|
|
671
|
+
},
|
|
672
|
+
required: []
|
|
673
|
+
}
|
|
674
|
+
},
|
|
675
|
+
{
|
|
676
|
+
name: 'ijfw_prompt_check',
|
|
677
|
+
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.',
|
|
678
|
+
inputSchema: {
|
|
679
|
+
type: 'object',
|
|
680
|
+
properties: {
|
|
681
|
+
prompt: { type: 'string', description: 'The full user prompt text.' }
|
|
682
|
+
},
|
|
683
|
+
required: ['prompt']
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
name: 'ijfw_metrics',
|
|
688
|
+
description: 'See tokens/spend, model routing mix, and session totals -- the receipts behind your IJFW sessions. Aggregates from .ijfw/metrics/sessions.jsonl. Tolerates mixed v1/v2 lines.',
|
|
689
|
+
inputSchema: {
|
|
690
|
+
type: 'object',
|
|
691
|
+
properties: {
|
|
692
|
+
period: { type: 'string', enum: ['today', '7d', '30d', 'all'], description: 'Time window (default 7d).' },
|
|
693
|
+
metric: { type: 'string', enum: ['tokens', 'cost', 'sessions', 'routing'], description: 'Which metric to render (default tokens).' }
|
|
694
|
+
},
|
|
695
|
+
required: []
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
{
|
|
699
|
+
name: 'ijfw_cross_project_search',
|
|
700
|
+
description: 'BM25-ranked search across every IJFW project ever opened on this machine. Results tagged [project:<basename>] with line numbers + snippets. Use when you need to recall how a similar problem was solved in another project. Reads ~/.ijfw/registry.md as the source of truth.',
|
|
701
|
+
inputSchema: {
|
|
702
|
+
type: 'object',
|
|
703
|
+
properties: {
|
|
704
|
+
pattern: { type: 'string', description: 'Search query. Supports plain words and "quoted phrases". Use BM25 relevance ranking.' },
|
|
705
|
+
limit: { type: 'number', description: 'Max results (default 10, max 50).' }
|
|
706
|
+
},
|
|
707
|
+
required: ['pattern']
|
|
708
|
+
}
|
|
709
|
+
},
|
|
710
|
+
UPDATE_CHECK_TOOL,
|
|
711
|
+
UPDATE_APPLY_TOOL,
|
|
712
|
+
{
|
|
713
|
+
name: 'ijfw_run',
|
|
714
|
+
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.',
|
|
715
|
+
inputSchema: {
|
|
716
|
+
type: 'object',
|
|
717
|
+
properties: {
|
|
718
|
+
command: { type: 'string', description: 'Shell command to run' },
|
|
719
|
+
label: { type: 'string', description: 'Optional label for sandbox retrieval. Auto-generated if omitted.' },
|
|
720
|
+
cwd: { type: 'string', description: 'Working directory. Defaults to process.cwd().' },
|
|
721
|
+
},
|
|
722
|
+
required: ['command'],
|
|
723
|
+
},
|
|
724
|
+
}
|
|
725
|
+
];
|
|
726
|
+
|
|
727
|
+
// --- Tool Handlers ---
|
|
728
|
+
|
|
729
|
+
function handleRecall({ context_hint, detail_level = 'standard', from_project }) {
|
|
730
|
+
// Cross-project explicit pull. We bypass current-project sources and read
|
|
731
|
+
// the target project's knowledge/handoff/journal directly. Search queries
|
|
732
|
+
// are routed through searchAcrossProjects via scope:'all' on the search tool;
|
|
733
|
+
// recall here is for "give me everything from X."
|
|
734
|
+
if (from_project) {
|
|
735
|
+
const target = resolveProject(from_project);
|
|
736
|
+
if (!target) {
|
|
737
|
+
return { text: `No registered IJFW project matches: ${from_project}`, isError: true };
|
|
738
|
+
}
|
|
739
|
+
const mem = readProjectMemory(target.path);
|
|
740
|
+
const tag = basename(target.path);
|
|
741
|
+
const out = [];
|
|
742
|
+
if (mem.knowledge) out.push(`## Knowledge [${tag}]\n${mem.knowledge}`);
|
|
743
|
+
if (mem.handoff) out.push(`## Handoff [${tag}]\n${mem.handoff}`);
|
|
744
|
+
if (mem.journal && (context_hint === 'decisions' || detail_level === 'full')) {
|
|
745
|
+
out.push(`## Journal [${tag}]\n${mem.journal}`);
|
|
746
|
+
}
|
|
747
|
+
return { text: out.join('\n\n') || `No memory found in project: ${tag}` };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// 1.2.0 Phase 5: DESIGN picker -- MCP-only delivery of the 12-template
|
|
751
|
+
// catalog to platforms without a skills tree (OpenCode/Qwen/Kimi/OpenClaw)
|
|
752
|
+
// or with MCP-less rules (Aider + CONVENTIONS.md).
|
|
753
|
+
if (typeof context_hint === 'string'
|
|
754
|
+
&& (context_hint === 'design_template' || context_hint.startsWith('design_template:'))) {
|
|
755
|
+
return handleDesignTemplate(context_hint);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const parts = [];
|
|
759
|
+
|
|
760
|
+
if (context_hint === 'session_start' || detail_level === 'summary') {
|
|
761
|
+
const knowledge = readKnowledgeBase();
|
|
762
|
+
const handoff = readHandoff();
|
|
763
|
+
const global = readGlobalKnowledge();
|
|
764
|
+
|
|
765
|
+
if (knowledge) parts.push(`## Knowledge\n${knowledge.split('\n').slice(0, 20).join('\n')}`);
|
|
766
|
+
if (handoff) parts.push(`## Last Session\n${handoff.split('\n').slice(0, 15).join('\n')}`);
|
|
767
|
+
if (global) parts.push(`## Preferences\n${global.split('\n').slice(0, 10).join('\n')}`);
|
|
768
|
+
|
|
769
|
+
return { text: parts.join('\n\n') || 'First session on this project. No memory stored yet.' };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (context_hint === 'handoff') {
|
|
773
|
+
return { text: readHandoff() || 'No handoff from previous session.' };
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
if (context_hint === 'decisions') {
|
|
777
|
+
return { text: getRecentJournalEntries(10) || 'No decisions recorded yet.' };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const results = searchMemory(context_hint);
|
|
781
|
+
if (results.length === 0) return { text: `No memories matching: ${context_hint}` };
|
|
782
|
+
return { text: results.map(r => `[${r.source}] ${r.content}`).join('\n') };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function handleStore({ content, type, tags = [], summary, why, how_to_apply }) {
|
|
786
|
+
// --- Input Validation ---
|
|
787
|
+
if (!content || typeof content !== 'string') {
|
|
788
|
+
return { text: 'content is required and must be a string.', isError: true };
|
|
789
|
+
}
|
|
790
|
+
if (content.length > MAX_STORE_LENGTH) {
|
|
791
|
+
return { text: `content exceeds ${MAX_STORE_LENGTH} character limit (got ${content.length}). Summarize and retry.`, isError: true };
|
|
792
|
+
}
|
|
793
|
+
if (!VALID_MEMORY_TYPES.includes(type)) {
|
|
794
|
+
return { text: `type must be one of: ${VALID_MEMORY_TYPES.join(', ')}`, isError: true };
|
|
795
|
+
}
|
|
796
|
+
if (!Array.isArray(tags)) tags = [];
|
|
797
|
+
// S2 -- tag whitelist. Rejects path-traversal / null bytes / punctuation
|
|
798
|
+
// in tag values that are later used as grep arguments or filenames.
|
|
799
|
+
tags = tags
|
|
800
|
+
.filter(t => typeof t === 'string')
|
|
801
|
+
.slice(0, MAX_TAGS)
|
|
802
|
+
.map(t => sanitizeContent(t).substring(0, MAX_TAG_LEN))
|
|
803
|
+
.map(t => t.replace(/[^a-zA-Z0-9_-]/g, ''))
|
|
804
|
+
.filter(t => t.length > 0);
|
|
805
|
+
|
|
806
|
+
// Enforce per-field caps before sanitize (audit S1). content is rejected
|
|
807
|
+
// above at the MAX_STORE_LENGTH gate so callers aren't silently truncated.
|
|
808
|
+
// why/how/summary are truncated rather than rejected so structured stores
|
|
809
|
+
// never silently drop the whole entry over one long field.
|
|
810
|
+
const capped = applyCaps({ summary, why, how_to_apply });
|
|
811
|
+
summary = capped.summary;
|
|
812
|
+
why = capped.why;
|
|
813
|
+
how_to_apply = capped.how_to_apply;
|
|
814
|
+
|
|
815
|
+
// Sanitize ALL text fields -- never store raw user/agent text in markdown
|
|
816
|
+
// that gets re-injected into a future LLM context.
|
|
817
|
+
const safeContent = sanitizeContent(content);
|
|
818
|
+
if (!safeContent) {
|
|
819
|
+
return { text: 'content was empty after sanitisation (only control/format chars).', isError: true };
|
|
820
|
+
}
|
|
821
|
+
const safeSummary = summary ? sanitizeContent(summary).substring(0, 120) : '';
|
|
822
|
+
const safeWhy = why ? sanitizeContent(why) : '';
|
|
823
|
+
const safeHow = how_to_apply ? sanitizeContent(how_to_apply) : '';
|
|
824
|
+
|
|
825
|
+
const tagStr = tags.length > 0 ? ` [${tags.join(', ')}]` : '';
|
|
826
|
+
const journalEntry = `**${type}**${tagStr}: ${safeSummary || safeContent.substring(0, 200)}`;
|
|
827
|
+
|
|
828
|
+
// 1. Always append to journal (one-line timeline). Hard failure → report.
|
|
829
|
+
const journalResult = appendToJournal(journalEntry);
|
|
830
|
+
if (!journalResult.ok) {
|
|
831
|
+
return { text: `Memory journal is not writable (${journalResult.code}) -- check .ijfw/ directory permissions and retry.`, isError: true };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// 2. Type-specific secondary writes. Each tracked so we report partial
|
|
835
|
+
// success accurately rather than lying about "stored."
|
|
836
|
+
const failures = [];
|
|
837
|
+
|
|
838
|
+
if (type === 'decision' || type === 'pattern') {
|
|
839
|
+
// Richer frontmatter block for retrieval-quality entries.
|
|
840
|
+
const r = appendStructuredToKnowledge({
|
|
841
|
+
type,
|
|
842
|
+
summary: safeSummary || safeContent.substring(0, 80),
|
|
843
|
+
content: safeContent,
|
|
844
|
+
why: safeWhy,
|
|
845
|
+
howToApply: safeHow,
|
|
846
|
+
tags
|
|
847
|
+
});
|
|
848
|
+
if (!r.ok) failures.push(`knowledge base (${r.code})`);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (type === 'preference') {
|
|
852
|
+
const r = appendToGlobalPrefs(`**preference**${tagStr}: ${safeContent}`, tags);
|
|
853
|
+
if (!r.ok) failures.push(`global preferences (${r.code})`);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (type === 'handoff') {
|
|
857
|
+
const handoffPath = join(MEMORY_DIR, 'handoff.md');
|
|
858
|
+
const prior = readMarkdownFile(handoffPath);
|
|
859
|
+
if (prior.ok && prior.content.trim()) {
|
|
860
|
+
appendToJournal(`prior-handoff-archived: ${sanitizeContent(prior.content).substring(0, 500)}`);
|
|
861
|
+
}
|
|
862
|
+
const r = atomicWrite(handoffPath, safeContent + '\n');
|
|
863
|
+
if (!r.ok) failures.push(`handoff (${r.code})`);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (failures.length > 0) {
|
|
867
|
+
return {
|
|
868
|
+
text: `Stored ${type} to journal. Secondary writes failed: ${failures.join(', ')}`,
|
|
869
|
+
isError: true
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
return { text: `Stored ${type}${tagStr}` };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// Universal first-turn recall -- call once at session start to hydrate context.
|
|
877
|
+
// Returns a compact, structured block that agents on any platform can ingest
|
|
878
|
+
// without cascading into multiple exploratory tool calls.
|
|
879
|
+
// 1.1.6 update-nudge composer for cross-platform prelude parity.
|
|
880
|
+
// Reads ~/.ijfw/state.json + ~/.ijfw/cache/update-check.json. Returns the
|
|
881
|
+
// terse nudge line, or '' when up-to-date / re-entrancy / no cache.
|
|
882
|
+
function composeUpdateNudge() {
|
|
883
|
+
try {
|
|
884
|
+
const root = process.env.IJFW_HOME || join(homedir(), '.ijfw');
|
|
885
|
+
let state, cache;
|
|
886
|
+
try { state = JSON.parse(readFileSync(join(root, 'state.json'), 'utf8')); }
|
|
887
|
+
catch { state = {}; }
|
|
888
|
+
try { cache = JSON.parse(readFileSync(join(root, 'cache', 'update-check.json'), 'utf8')); }
|
|
889
|
+
catch { cache = {}; }
|
|
890
|
+
if (!cache.last_latest_seen) return '';
|
|
891
|
+
const installed = state.installed_version || '0.0.0';
|
|
892
|
+
const lastApplied = state.last_applied_version;
|
|
893
|
+
// Re-entrancy: if we just applied this version, don't nudge
|
|
894
|
+
if (lastApplied && cmpSemverPrelude(lastApplied, cache.last_latest_seen) >= 0) return '';
|
|
895
|
+
if (cmpSemverPrelude(installed, cache.last_latest_seen) >= 0) return '';
|
|
896
|
+
return `## IJFW update available\n` +
|
|
897
|
+
`Installed: v${installed} -- latest: v${cache.last_latest_seen}.\n` +
|
|
898
|
+
`Run 'ijfw update' in your TERMINAL to upgrade. ` +
|
|
899
|
+
`(I cannot run this for you -- the MCP path is air-gapped from code execution.)`;
|
|
900
|
+
} catch { return ''; }
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function cmpSemverPrelude(a, b) {
|
|
904
|
+
const parse = v => {
|
|
905
|
+
const [main, pre] = String(v).split('-', 2);
|
|
906
|
+
const nums = main.split('.').map(n => parseInt(n, 10) || 0);
|
|
907
|
+
while (nums.length < 3) nums.push(0);
|
|
908
|
+
return { nums, pre: pre || null };
|
|
909
|
+
};
|
|
910
|
+
const A = parse(a); const B = parse(b);
|
|
911
|
+
for (let i = 0; i < 3; i++) {
|
|
912
|
+
if (A.nums[i] !== B.nums[i]) return A.nums[i] < B.nums[i] ? -1 : 1;
|
|
913
|
+
}
|
|
914
|
+
if (A.pre === B.pre) return 0;
|
|
915
|
+
if (A.pre && !B.pre) return -1;
|
|
916
|
+
if (!A.pre && B.pre) return 1;
|
|
917
|
+
return A.pre < B.pre ? -1 : 1;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function handlePrelude({ detail_level = 'summary' } = {}) {
|
|
921
|
+
const KB_LINES = detail_level === 'full' ? 200 : detail_level === 'standard' ? 80 : 40;
|
|
922
|
+
const HO_LINES = detail_level === 'full' ? 80 : detail_level === 'standard' ? 30 : 15;
|
|
923
|
+
const JN_LINES = detail_level === 'full' ? 20 : detail_level === 'standard' ? 10 : 5;
|
|
924
|
+
|
|
925
|
+
const TM_LINES = detail_level === 'full' ? 200 : detail_level === 'standard' ? 60 : 20;
|
|
926
|
+
|
|
927
|
+
const parts = ['<ijfw-memory>'];
|
|
928
|
+
parts.push('Project memory hydrated. Treat as background context -- no further recall needed unless the user asks something not covered here.');
|
|
929
|
+
parts.push('');
|
|
930
|
+
|
|
931
|
+
// 1.1.6: surface update availability for cross-platform parity.
|
|
932
|
+
// Claude Code shows this in statusLine; Codex/Gemini/Cursor/Windsurf/
|
|
933
|
+
// Copilot/Hermes/Wayland surface it here in the first-turn prelude.
|
|
934
|
+
// Re-entrancy: suppressed when last_applied_version >= last_latest_seen.
|
|
935
|
+
const updateNudge = composeUpdateNudge();
|
|
936
|
+
if (updateNudge) parts.push(updateNudge, '');
|
|
937
|
+
|
|
938
|
+
// 1.2.0 Phase 5: surface the DESIGN picker to platforms without a skills tree.
|
|
939
|
+
// Skip when the project already has a DESIGN.md (contract exists; no picker).
|
|
940
|
+
try {
|
|
941
|
+
if (!existsSync(join(PROJECT_DIR, 'DESIGN.md'))) {
|
|
942
|
+
const names = DESIGN_TEMPLATE_CATALOG.map(([n]) => n);
|
|
943
|
+
parts.push('## Design picker');
|
|
944
|
+
parts.push('No DESIGN.md in project. 12 curated templates available:');
|
|
945
|
+
parts.push(names.slice(0, 5).join(', ') + ',');
|
|
946
|
+
parts.push(names.slice(5, 10).join(', ') + ',');
|
|
947
|
+
parts.push(names.slice(10).join(', ') + '.');
|
|
948
|
+
parts.push('');
|
|
949
|
+
parts.push('Pick one: ijfw_memory_recall({context_hint: "design_template:<name>"}).');
|
|
950
|
+
parts.push('Full catalog with descriptions: ijfw_memory_recall({context_hint: "design_template"}).');
|
|
951
|
+
parts.push('');
|
|
952
|
+
}
|
|
953
|
+
} catch { /* cwd unreadable -- skip picker block */ }
|
|
954
|
+
|
|
955
|
+
// Team knowledge first -- shared decisions/patterns/stack rank above personal.
|
|
956
|
+
const team = readTeamKnowledge();
|
|
957
|
+
if (team) {
|
|
958
|
+
const body = team.split('\n').slice(0, TM_LINES).join('\n').trim();
|
|
959
|
+
if (body) parts.push('## Team knowledge', body, '');
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const knowledge = readKnowledgeBase();
|
|
963
|
+
if (knowledge) {
|
|
964
|
+
const body = knowledge.split('\n')
|
|
965
|
+
.filter(l => !l.startsWith('<!-- ijfw'))
|
|
966
|
+
.filter(l => !/^#[^#]/.test(l))
|
|
967
|
+
.slice(0, KB_LINES)
|
|
968
|
+
.join('\n')
|
|
969
|
+
.trim();
|
|
970
|
+
if (body) parts.push('## Knowledge base', body, '');
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Claude Code's native auto-memory -- Claude's own skill writes here on
|
|
974
|
+
// "Remember X". Surfacing it via IJFW makes those memories available to
|
|
975
|
+
// Codex/Gemini/Cursor too, fulfilling the cross-platform promise without
|
|
976
|
+
// fighting Claude's native handler.
|
|
977
|
+
const nativeMem = readNativeClaudeMemory();
|
|
978
|
+
if (nativeMem) {
|
|
979
|
+
const body = nativeMem.split('\n').slice(0, KB_LINES).join('\n').trim();
|
|
980
|
+
if (body) parts.push('## Claude-native project memory', body, '');
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const handoff = readHandoff();
|
|
984
|
+
if (handoff) {
|
|
985
|
+
const body = handoff.split('\n')
|
|
986
|
+
.filter(l => !l.startsWith('<!-- ijfw'))
|
|
987
|
+
.slice(0, HO_LINES)
|
|
988
|
+
.join('\n')
|
|
989
|
+
.trim();
|
|
990
|
+
if (body) parts.push('## Last session handoff', body, '');
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
const recent = getRecentJournalEntries(JN_LINES);
|
|
994
|
+
if (recent) parts.push('## Recent activity', recent, '');
|
|
995
|
+
|
|
996
|
+
const global = readGlobalKnowledge();
|
|
997
|
+
if (global) {
|
|
998
|
+
const body = global.split('\n').slice(0, 10).join('\n').trim();
|
|
999
|
+
if (body) parts.push('## Project preferences', body, '');
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
parts.push('</ijfw-memory>');
|
|
1003
|
+
|
|
1004
|
+
const text = parts.join('\n');
|
|
1005
|
+
if (text.length < 60) {
|
|
1006
|
+
return { text: 'Fresh project -- no memory stored yet. Proceed normally.' };
|
|
1007
|
+
}
|
|
1008
|
+
return { text };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function handleSearch({ query, limit = 10, scope = 'project', label }) {
|
|
1012
|
+
if (scope === 'sandbox') {
|
|
1013
|
+
if (label) {
|
|
1014
|
+
const content = readFromSandbox(label);
|
|
1015
|
+
if (content === null) return { text: `Sandbox entry not found: ${label}` };
|
|
1016
|
+
return { text: content };
|
|
1017
|
+
}
|
|
1018
|
+
// List available sandbox entries.
|
|
1019
|
+
if (!existsSync(SANDBOX_DIR)) return { text: 'No sandbox entries found.' };
|
|
1020
|
+
let files;
|
|
1021
|
+
try { files = readdirSync(SANDBOX_DIR).filter(f => f.endsWith('.json')); }
|
|
1022
|
+
catch { return { text: 'No sandbox entries found.' }; }
|
|
1023
|
+
if (files.length === 0) return { text: 'No sandbox entries found.' };
|
|
1024
|
+
const entries = [];
|
|
1025
|
+
for (const f of files) {
|
|
1026
|
+
try {
|
|
1027
|
+
const meta = JSON.parse(readFileSync(join(SANDBOX_DIR, f), 'utf8'));
|
|
1028
|
+
entries.push(`${meta.label} | ${meta.command} | exit=${meta.exitCode} | ${meta.lines} lines | ${meta.timestamp}`);
|
|
1029
|
+
} catch { /* skip malformed */ }
|
|
1030
|
+
}
|
|
1031
|
+
return { text: entries.length > 0 ? entries.join('\n') : 'No sandbox entries found.' };
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
if (!query || typeof query !== 'string') {
|
|
1035
|
+
return { text: 'query is required and must be a string.', isError: true };
|
|
1036
|
+
}
|
|
1037
|
+
if (query.length > 500) query = query.substring(0, 500);
|
|
1038
|
+
if (scope !== 'project' && scope !== 'all') scope = 'project';
|
|
1039
|
+
const results = searchMemory(query, limit, scope);
|
|
1040
|
+
if (results.length === 0) {
|
|
1041
|
+
const where = scope === 'all' ? ' across all projects' : '';
|
|
1042
|
+
return { text: `No results for: "${query}"${where}` };
|
|
1043
|
+
}
|
|
1044
|
+
return { text: results.map(r => `[${r.source}:L${r.line}] ${r.content}`).join('\n') };
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Phase 12 / Wave 12B (R1): BM25-ranked cross-project search. Distinct from
|
|
1048
|
+
// handleSearch(scope:'all') which is a naive keyword-count scan retained for
|
|
1049
|
+
// backward compat. This handler is the canonical cross-project path.
|
|
1050
|
+
function handleCrossProjectSearch({ pattern, limit = 10 } = {}) {
|
|
1051
|
+
if (!pattern || typeof pattern !== 'string') {
|
|
1052
|
+
return { text: 'pattern is required and must be a string.', isError: true };
|
|
1053
|
+
}
|
|
1054
|
+
if (pattern.length > 500) pattern = pattern.substring(0, 500);
|
|
1055
|
+
const projects = readRegistry();
|
|
1056
|
+
if (projects.length === 0) {
|
|
1057
|
+
return { text: 'No other IJFW projects on record. Open one more project to enable cross-project search.' };
|
|
1058
|
+
}
|
|
1059
|
+
const hits = crossProjectSearch(pattern, projects, readProjectMemory, { limit });
|
|
1060
|
+
if (hits.length === 0) {
|
|
1061
|
+
return { text: `No matches for "${pattern}" across ${projects.length} project${projects.length === 1 ? '' : 's'}.` };
|
|
1062
|
+
}
|
|
1063
|
+
const body = hits.map(h => `[${h.source}:L${h.line}] (score ${h.score}) ${h.snippet}`).join('\n');
|
|
1064
|
+
return { text: body };
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Phase 3 #6: aggregate session metrics. Reads .ijfw/metrics/sessions.jsonl,
|
|
1068
|
+
// tolerates v1 lines (treats missing token/cost fields as 0), groups by day,
|
|
1069
|
+
// renders compact text. Positive-framed zero-state when no sessions logged yet.
|
|
1070
|
+
function handleMetrics({ period = '7d', metric = 'tokens' } = {}) {
|
|
1071
|
+
const file = join(IJFW_DIR, 'metrics', 'sessions.jsonl');
|
|
1072
|
+
const r = readMarkdownFile(file);
|
|
1073
|
+
if (!r.ok) {
|
|
1074
|
+
return { text: 'Ready to track -- run a session and metrics will populate here.' };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const lines = r.content.split('\n').filter(l => l.trim());
|
|
1078
|
+
const rows = [];
|
|
1079
|
+
for (const line of lines) {
|
|
1080
|
+
try { rows.push(JSON.parse(line)); } catch { /* skip malformed line */ }
|
|
1081
|
+
}
|
|
1082
|
+
if (rows.length === 0) {
|
|
1083
|
+
return { text: 'Ready to track -- run a session and metrics will populate here.' };
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Window filter (UTC day comparison via ISO prefix).
|
|
1087
|
+
const now = Date.now();
|
|
1088
|
+
const cutoff = period === 'today' ? now - 24 * 3600e3
|
|
1089
|
+
: period === '7d' ? now - 7 * 24 * 3600e3
|
|
1090
|
+
: period === '30d' ? now - 30 * 24 * 3600e3
|
|
1091
|
+
: 0;
|
|
1092
|
+
const within = rows.filter(row => {
|
|
1093
|
+
if (!row.timestamp) return false;
|
|
1094
|
+
const t = Date.parse(row.timestamp);
|
|
1095
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
1096
|
+
});
|
|
1097
|
+
if (within.length === 0) {
|
|
1098
|
+
return { text: `Window ${period}: no sessions yet. Earlier history available -- try period: 'all'.` };
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
if (metric === 'sessions') {
|
|
1102
|
+
const handoffs = within.filter(r => r.handoff).length;
|
|
1103
|
+
const memEntries = within.reduce((s, r) => s + (r.memory_stores || 0), 0);
|
|
1104
|
+
return { text: [
|
|
1105
|
+
`Sessions in ${period}: ${within.length}`,
|
|
1106
|
+
`Handoffs preserved: ${handoffs} (${Math.round(100 * handoffs / within.length)}%)`,
|
|
1107
|
+
`Memory entries logged: ${memEntries}`
|
|
1108
|
+
].join('\n') };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
if (metric === 'routing') {
|
|
1112
|
+
const counts = {};
|
|
1113
|
+
for (const r of within) counts[r.routing || 'native'] = (counts[r.routing || 'native'] || 0) + 1;
|
|
1114
|
+
return { text: ['Routing mix:'].concat(
|
|
1115
|
+
Object.entries(counts).map(([k, v]) => ` ${k}: ${v}`)
|
|
1116
|
+
).join('\n') };
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// Group by UTC day for tokens / cost.
|
|
1120
|
+
const byDay = {};
|
|
1121
|
+
for (const row of within) {
|
|
1122
|
+
const day = String(row.timestamp).slice(0, 10);
|
|
1123
|
+
byDay[day] = byDay[day] || { in: 0, out: 0, cr: 0, cc: 0, cost: 0, n: 0 };
|
|
1124
|
+
byDay[day].in += row.input_tokens || 0;
|
|
1125
|
+
byDay[day].out += row.output_tokens || 0;
|
|
1126
|
+
byDay[day].cr += row.cache_read_tokens || 0;
|
|
1127
|
+
byDay[day].cc += row.cache_creation_tokens || 0;
|
|
1128
|
+
byDay[day].cost += row.cost_usd || 0;
|
|
1129
|
+
byDay[day].n += 1;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const days = Object.keys(byDay).sort();
|
|
1133
|
+
if (metric === 'cost') {
|
|
1134
|
+
const total = days.reduce((s, d) => s + byDay[d].cost, 0);
|
|
1135
|
+
const lines = ['Day | sessions | cost (USD)'];
|
|
1136
|
+
for (const d of days) lines.push(`${d} | ${String(byDay[d].n).padStart(8)} | $${byDay[d].cost.toFixed(4)}`);
|
|
1137
|
+
lines.push(`Total: $${total.toFixed(4)} across ${within.length} session(s) -- clean session-ends only.`);
|
|
1138
|
+
return { text: lines.join('\n') };
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// tokens (default)
|
|
1142
|
+
const totals = days.reduce((acc, d) => {
|
|
1143
|
+
acc.in += byDay[d].in; acc.out += byDay[d].out; acc.cr += byDay[d].cr; acc.cc += byDay[d].cc;
|
|
1144
|
+
return acc;
|
|
1145
|
+
}, { in: 0, out: 0, cr: 0, cc: 0 });
|
|
1146
|
+
const out = ['Day | sessions | input | output | cache-read'];
|
|
1147
|
+
for (const d of days) {
|
|
1148
|
+
const r = byDay[d];
|
|
1149
|
+
out.push(`${d} | ${String(r.n).padStart(8)} | ${r.in.toLocaleString().padStart(7)} | ${r.out.toLocaleString().padStart(7)} | ${r.cr.toLocaleString().padStart(10)}`);
|
|
1150
|
+
}
|
|
1151
|
+
out.push(`Total: ${(totals.in + totals.out).toLocaleString()} tokens (${totals.in.toLocaleString()} in / ${totals.out.toLocaleString()} out / ${totals.cr.toLocaleString()} cache-read).`);
|
|
1152
|
+
return { text: out.join('\n') };
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function handleStatus() {
|
|
1156
|
+
const sessionCount = getSessionCount();
|
|
1157
|
+
const decisionCount = getDecisionCount();
|
|
1158
|
+
const hasKnowledge = existsSync(join(MEMORY_DIR, 'knowledge.md'));
|
|
1159
|
+
const hasHandoff = existsSync(join(MEMORY_DIR, 'handoff.md'));
|
|
1160
|
+
const hasGlobal = readGlobalKnowledge().trim().length > 0;
|
|
1161
|
+
|
|
1162
|
+
const parts = [];
|
|
1163
|
+
if (hasKnowledge) {
|
|
1164
|
+
const kb = readKnowledgeBase();
|
|
1165
|
+
const kbLines = kb.split('\n').filter(l => l.trim().startsWith('**')).length;
|
|
1166
|
+
parts.push(`Knowledge: ${kbLines} entries`);
|
|
1167
|
+
}
|
|
1168
|
+
if (sessionCount > 0 || decisionCount > 0) {
|
|
1169
|
+
parts.push(`History: ${sessionCount} sessions, ${decisionCount} decisions`);
|
|
1170
|
+
}
|
|
1171
|
+
if (hasHandoff) {
|
|
1172
|
+
const handoff = readHandoff();
|
|
1173
|
+
const statusLine = handoff.split('\n').find(l => l.trim().length > 0 && !l.startsWith('<!--') && !l.startsWith('#'));
|
|
1174
|
+
if (statusLine) parts.push(`Last: ${statusLine.trim().substring(0, 150)}`);
|
|
1175
|
+
}
|
|
1176
|
+
if (hasGlobal) parts.push('Project preferences loaded');
|
|
1177
|
+
|
|
1178
|
+
return { text: parts.join('\n') || 'Fresh project -- no memory yet.' };
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// --- MCP Protocol Handler (JSON-RPC 2.0 over stdio) ---
|
|
1182
|
+
|
|
1183
|
+
function createResponse(id, result) {
|
|
1184
|
+
return JSON.stringify({ jsonrpc: '2.0', id, result });
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function createError(id, code, message) {
|
|
1188
|
+
return JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } });
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
function handleMessage(msg) {
|
|
1192
|
+
const { method, params, id } = msg;
|
|
1193
|
+
|
|
1194
|
+
switch (method) {
|
|
1195
|
+
case 'initialize':
|
|
1196
|
+
return createResponse(id, {
|
|
1197
|
+
protocolVersion: '2024-11-05',
|
|
1198
|
+
capabilities: { tools: {}, resources: {}, prompts: {} },
|
|
1199
|
+
serverInfo: { name: 'ijfw-memory', version: PKG_VERSION, schemaVersion: SCHEMA_VERSION }
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
case 'notifications/initialized':
|
|
1203
|
+
case 'notifications/cancelled':
|
|
1204
|
+
return null;
|
|
1205
|
+
|
|
1206
|
+
case 'tools/list':
|
|
1207
|
+
return createResponse(id, { tools: TOOLS });
|
|
1208
|
+
|
|
1209
|
+
case 'tools/call': {
|
|
1210
|
+
const { name, arguments: args } = params || {};
|
|
1211
|
+
// ijfw_run is async; wrap the entire case in a Promise so the caller
|
|
1212
|
+
// can await it. All other cases resolve synchronously via Promise.resolve().
|
|
1213
|
+
return (async () => {
|
|
1214
|
+
let result;
|
|
1215
|
+
try {
|
|
1216
|
+
switch (name) {
|
|
1217
|
+
case 'ijfw_update_check': {
|
|
1218
|
+
const r = await ijfwUpdateCheck(args || {});
|
|
1219
|
+
result = { text: JSON.stringify(r, null, 2), isError: !!(r && r.error) };
|
|
1220
|
+
break;
|
|
1221
|
+
}
|
|
1222
|
+
case 'ijfw_update_apply': {
|
|
1223
|
+
const r = ijfwUpdateApply(args || {});
|
|
1224
|
+
result = { text: JSON.stringify(r, null, 2), isError: r && r.status === 'error' };
|
|
1225
|
+
break;
|
|
1226
|
+
}
|
|
1227
|
+
case 'ijfw_memory_recall':
|
|
1228
|
+
result = handleRecall(args || {});
|
|
1229
|
+
emitRecallObservation(args || {});
|
|
1230
|
+
break;
|
|
1231
|
+
case 'ijfw_memory_store':
|
|
1232
|
+
result = handleStore(args || {});
|
|
1233
|
+
break;
|
|
1234
|
+
case 'ijfw_memory_search': {
|
|
1235
|
+
// W1B (1.3.0-alpha): colon-syntax dispatch. compute:<query> hits
|
|
1236
|
+
// the per-project FTS5 db; anything else falls through to the
|
|
1237
|
+
// legacy keyword search.
|
|
1238
|
+
const searchArgs = args || {};
|
|
1239
|
+
const parsedQuery = typeof searchArgs.query === 'string'
|
|
1240
|
+
? parseColonCommand(searchArgs.query)
|
|
1241
|
+
: null;
|
|
1242
|
+
if (parsedQuery && (parsedQuery.namespace === 'compute' || parsedQuery.namespace === 'graph')) {
|
|
1243
|
+
const dispatched = await dispatchSearch(parsedQuery, {
|
|
1244
|
+
projectRoot: searchArgs.projectRoot,
|
|
1245
|
+
limit: searchArgs.limit,
|
|
1246
|
+
});
|
|
1247
|
+
if (dispatched !== null) {
|
|
1248
|
+
result = {
|
|
1249
|
+
text: JSON.stringify(dispatched, null, 2),
|
|
1250
|
+
isError: dispatched.ok === false,
|
|
1251
|
+
};
|
|
1252
|
+
break;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
result = handleSearch(searchArgs);
|
|
1256
|
+
break;
|
|
1257
|
+
}
|
|
1258
|
+
case 'ijfw_memory_status':
|
|
1259
|
+
result = handleStatus();
|
|
1260
|
+
break;
|
|
1261
|
+
case 'ijfw_memory_prelude':
|
|
1262
|
+
result = handlePrelude(args || {});
|
|
1263
|
+
break;
|
|
1264
|
+
case 'ijfw_metrics':
|
|
1265
|
+
result = handleMetrics(args || {});
|
|
1266
|
+
break;
|
|
1267
|
+
case 'ijfw_cross_project_search':
|
|
1268
|
+
result = handleCrossProjectSearch(args || {});
|
|
1269
|
+
break;
|
|
1270
|
+
case 'ijfw_prompt_check': {
|
|
1271
|
+
const pc = checkPrompt((args && args.prompt) || '');
|
|
1272
|
+
const text = pc.vague
|
|
1273
|
+
? `vague: yes\nsignals: ${pc.signals.join(', ')}\nsuggestion: ${pc.suggestion}`
|
|
1274
|
+
: `vague: no${pc.bypass_reason ? ` (bypass: ${pc.bypass_reason})` : pc.signals.length ? ` (signals: ${pc.signals.join(', ')} -- below threshold)` : ''}`;
|
|
1275
|
+
result = { text };
|
|
1276
|
+
break;
|
|
1277
|
+
}
|
|
1278
|
+
case 'ijfw_run': {
|
|
1279
|
+
purgeSandboxOld();
|
|
1280
|
+
const { command, label: userLabel, cwd } = args || {};
|
|
1281
|
+
if (!command || typeof command !== 'string') {
|
|
1282
|
+
result = { text: 'command is required and must be a string.', isError: true };
|
|
1283
|
+
break;
|
|
1284
|
+
}
|
|
1285
|
+
// W1B (1.3.0-alpha): colon-syntax dispatch. compute:python /
|
|
1286
|
+
// compute:js / index:<source> / detect:project_type ride this
|
|
1287
|
+
// tool surface so we don't grow tool count past 10.
|
|
1288
|
+
// L2: trust dispatcher's null contract -- the redundant
|
|
1289
|
+
// namespace tuple here would drift if dispatch adds new ones.
|
|
1290
|
+
// dispatchRun() returns null for unrecognized namespaces and a
|
|
1291
|
+
// result object for owned ones.
|
|
1292
|
+
const parsedRun = parseColonCommand(command);
|
|
1293
|
+
if (parsedRun) {
|
|
1294
|
+
const dispatched = await dispatchRun(parsedRun, {
|
|
1295
|
+
projectRoot: cwd,
|
|
1296
|
+
sessionId: process.env.IJFW_SESSION_ID,
|
|
1297
|
+
});
|
|
1298
|
+
if (dispatched !== null) {
|
|
1299
|
+
result = {
|
|
1300
|
+
text: JSON.stringify(dispatched, null, 2),
|
|
1301
|
+
isError: dispatched.ok === false,
|
|
1302
|
+
};
|
|
1303
|
+
break;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
const runResult = await runCommand(command, { cwd });
|
|
1307
|
+
const { stdout, exitCode, durationMs, lines, bytes, timedOut } = runResult;
|
|
1308
|
+
|
|
1309
|
+
const INLINE_LINES = 40;
|
|
1310
|
+
const INLINE_BYTES = 50 * 1024;
|
|
1311
|
+
|
|
1312
|
+
if (lines <= INLINE_LINES && bytes <= INLINE_BYTES && !timedOut) {
|
|
1313
|
+
result = { text: stdout || '(no output)', isError: exitCode !== 0 };
|
|
1314
|
+
break;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
const stripped = stripAnsi(stdout);
|
|
1318
|
+
const domain = detectDomain(stripped);
|
|
1319
|
+
const label = userLabel || `run-${Date.now()}`;
|
|
1320
|
+
const summary = summarize(stripped, domain, command, exitCode, durationMs);
|
|
1321
|
+
writeToSandbox(label, command, stripped, { exitCode, lines, bytes });
|
|
1322
|
+
|
|
1323
|
+
result = {
|
|
1324
|
+
text: summary + '\n\nFull output sandboxed. Retrieve: ijfw_memory_search({ scope: "sandbox", label: "' + label + '" })',
|
|
1325
|
+
isError: exitCode !== 0,
|
|
1326
|
+
};
|
|
1327
|
+
break;
|
|
1328
|
+
}
|
|
1329
|
+
default:
|
|
1330
|
+
return createError(id, -32601, `Unknown tool: ${name}`);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// Handlers now return {text, isError?}. Forward both to the MCP client
|
|
1334
|
+
// so failures aren't silently labelled as success.
|
|
1335
|
+
return createResponse(id, {
|
|
1336
|
+
content: [{ type: 'text', text: String(result.text) }],
|
|
1337
|
+
isError: result.isError === true
|
|
1338
|
+
});
|
|
1339
|
+
} catch (err) {
|
|
1340
|
+
return createResponse(id, {
|
|
1341
|
+
content: [{ type: 'text', text: `Internal error: ${err.message}` }],
|
|
1342
|
+
isError: true
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
})(); // end async IIFE for tools/call
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
case 'resources/list':
|
|
1349
|
+
return createResponse(id, { resources: [] });
|
|
1350
|
+
case 'resources/read':
|
|
1351
|
+
return createError(id, -32601, 'No resources available');
|
|
1352
|
+
case 'resources/templates/list':
|
|
1353
|
+
return createResponse(id, { resourceTemplates: [] });
|
|
1354
|
+
case 'prompts/list':
|
|
1355
|
+
return createResponse(id, { prompts: [] });
|
|
1356
|
+
case 'prompts/get':
|
|
1357
|
+
return createError(id, -32601, 'No prompts available');
|
|
1358
|
+
case 'ping':
|
|
1359
|
+
return createResponse(id, {});
|
|
1360
|
+
|
|
1361
|
+
default:
|
|
1362
|
+
if (id) return createError(id, -32601, `Method not found: ${method}`);
|
|
1363
|
+
return null;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// --- stdio Transport ---
|
|
1368
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
1369
|
+
|
|
1370
|
+
rl.on('line', (line) => {
|
|
1371
|
+
if (!line.trim()) return;
|
|
1372
|
+
let msg;
|
|
1373
|
+
try {
|
|
1374
|
+
msg = JSON.parse(line);
|
|
1375
|
+
} catch {
|
|
1376
|
+
process.stdout.write(JSON.stringify({
|
|
1377
|
+
jsonrpc: '2.0', id: null,
|
|
1378
|
+
error: { code: -32700, message: 'Parse error' }
|
|
1379
|
+
}) + '\n');
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
try {
|
|
1383
|
+
const response = handleMessage(msg);
|
|
1384
|
+
if (response && typeof response.then === 'function') {
|
|
1385
|
+
response.then(r => { if (r) process.stdout.write(r + '\n'); }).catch(err => {
|
|
1386
|
+
process.stdout.write(JSON.stringify({
|
|
1387
|
+
jsonrpc: '2.0',
|
|
1388
|
+
id: msg && msg.id ? msg.id : null,
|
|
1389
|
+
error: { code: -32603, message: `Internal error: ${err.message}` }
|
|
1390
|
+
}) + '\n');
|
|
1391
|
+
});
|
|
1392
|
+
} else if (response) {
|
|
1393
|
+
process.stdout.write(response + '\n');
|
|
1394
|
+
}
|
|
1395
|
+
} catch (err) {
|
|
1396
|
+
process.stdout.write(JSON.stringify({
|
|
1397
|
+
jsonrpc: '2.0',
|
|
1398
|
+
id: msg && msg.id ? msg.id : null,
|
|
1399
|
+
error: { code: -32603, message: `Internal error: ${err.message}` }
|
|
1400
|
+
}) + '\n');
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
process.on('SIGINT', () => process.exit(0));
|
|
1405
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
1406
|
+
process.on('uncaughtException', (err) => {
|
|
1407
|
+
process.stderr.write(`IJFW: uncaught: ${err.stack || err.message}\n`);
|
|
1408
|
+
});
|
|
1409
|
+
process.on('unhandledRejection', (err) => {
|
|
1410
|
+
process.stderr.write(`IJFW: unhandled rejection: ${err}\n`);
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
// Export for tests (Node ESM allows this -- only consumed when imported, not on stdio run)
|
|
1414
|
+
export { sanitizeContent, atomicWrite, readMarkdownFile, PROJECT_HASH };
|