claude-faf-mcp 5.7.1 → 5.7.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,5 @@
1
1
  <!-- faf: claude-faf-mcp | TypeScript | mcp-server | FAF MCP server for Claude Desktop — persistent project context, 32 tools -->
2
- <!-- faf: doc=changelog | latest=v5.7.1 | canonical=project.faf | family=FAF -->
2
+ <!-- faf: doc=changelog | latest=v5.7.2 | canonical=project.faf | family=FAF -->
3
3
 
4
4
  # Changelog
5
5
 
@@ -9,6 +9,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
9
9
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
10
10
 
11
11
 
12
+ ## [Unreleased]
13
+
14
+ **Interop now enhances your files.** The same solid, structured `.faf` data is prefixed to the top of your context files for rapid AI consumption upfront — and your Markdown stays in the instruction lane.
15
+
16
+ ### Changed
17
+
18
+ - **Non-destructive interop.** `faf_agents`, `faf_gemini`, `faf_cursor`, and `faf_sync` now inject a structured `.faf` block at the top of AGENTS.md, GEMINI.md, .cursorrules, and CLAUDE.md and preserve everything you've written below. Re-runs update the block in place (idempotent); existing faf-generated files upgrade cleanly in one pass.
19
+
20
+ ## [5.7.2] - 2026-06-11 — The Canonical Edition
21
+
22
+ ### Security
23
+ - **Path confinement on every caller-supplied `path` argument (CWE-22 / CWE-73 / CWE-200).** The shared `getProjectPath()` chokepoint (feeding the `.faf` tools) and the `faf_read` / `faf_write` file tools resolved a caller path straight into a filesystem read/write with no confinement — so an absolute path or `../` traversal could read any file the server process could read (e.g. `/etc/passwd`, `~/.ssh/id_rsa`) or write outside the project. New `safe-path.ts` confines reads to `.faf` / `.fafm` context files and general file ops to the project root (cwd + system temp; override with `FAF_ALLOWED_ROOTS`), canonicalizes through symlinks (closing the symlink bypass), and rejects traversal/absolute escapes; `callTool()` gains a central PATH-DENIED guard. Identified by the maintainers during a sibling-server audit prompted by the coordinated disclosure of the same class of issue in `grok-faf-mcp` by Zhihao Zhang (Worcester Polytechnic Institute). Adds a security regression suite (incl. symlink bypass).
24
+
12
25
  ## [5.7.1] - 2026-06-10 — The Canonical Edition
13
26
 
14
27
  Typed structured output and one-click registry install — the reliability layer made machine-readable.
package/README.md CHANGED
@@ -26,7 +26,7 @@
26
26
 
27
27
  > ⚡ **New: `/faf` prompt** — type `/faf` in Claude Desktop. It checks your project, scores it, drives it to 100%, and syncs. Relentlessly. One command.
28
28
 
29
- > 🏆 **v5.7.1 — The Canonical Edition.** Typed structured output — `faf_score` and `faf_status` now return MCP `structuredContent` with output schemas alongside the text plus one-click install straight from the MCP registry. Built on 5.7.0's canonical foundation: edge-direct remote, 32 must-have tools.
29
+ > 🏆 **v5.7.2 — The Canonical Edition.** Security patchcaller-supplied `path` arguments are now confined: reads are restricted to `.faf` / `.fafm` context files and file ops to the project root (override with `FAF_ALLOWED_ROOTS`), closing an arbitrary local file read. Built on the Canonical foundation: typed structured output (`structuredContent` + output schemas), edge-direct remote, 32 must-have tools.
30
30
 
31
31
  32 MCP tools. IANA-registered formats (`application/vnd.faf+yaml` · `application/vnd.fafm+yaml`). 2,538 test executions per push.
32
32
 
@@ -82,7 +82,7 @@ Same `.faf`, every surface — Claude, Gemini, Grok, Cursor. **[faf-cli on npm
82
82
 
83
83
  **Click** — one-click `.mcpb`
84
84
 
85
- [**⬇ Download `claude-faf-mcp-5.7.1.mcpb`**](https://github.com/Wolfe-Jam/claude-faf-mcp/releases/latest/download/claude-faf-mcp-5.7.1.mcpb)
85
+ [**⬇ Download `claude-faf-mcp-5.7.2.mcpb`**](https://github.com/Wolfe-Jam/claude-faf-mcp/releases/latest/download/claude-faf-mcp-5.7.2.mcpb)
86
86
 
87
87
  Double-click. **No terminal. No JSON config. 32 tools live in 10 seconds.**
88
88
 
@@ -45,6 +45,7 @@ const path = __importStar(require("path"));
45
45
  const fs_1 = require("fs");
46
46
  const file_utils_1 = require("../utils/file-utils");
47
47
  const agents_js_1 = require("./agents.js");
48
+ const inject_1 = require("../inject");
48
49
  const cursor_js_1 = require("./cursor.js");
49
50
  const gemini_js_1 = require("./gemini.js");
50
51
  /**
@@ -134,7 +135,7 @@ async function syncBiDirectional(projectPath, _options = {}) {
134
135
  if (!claudeMdExists) {
135
136
  // Create CLAUDE.md from project.faf
136
137
  const claudeMdContent = fafToClaudeMd(fafContent);
137
- await fs_1.promises.writeFile(claudeMdPath, claudeMdContent, 'utf-8');
138
+ await (0, inject_1.injectFafBlock)(claudeMdPath, claudeMdContent);
138
139
  result.success = true;
139
140
  result.direction = 'faf-to-claude';
140
141
  result.filesChanged.push('CLAUDE.md');
@@ -143,7 +144,7 @@ async function syncBiDirectional(projectPath, _options = {}) {
143
144
  else {
144
145
  // Both files exist - update CLAUDE.md from project.faf
145
146
  const claudeMdContent = fafToClaudeMd(fafContent);
146
- await fs_1.promises.writeFile(claudeMdPath, claudeMdContent, 'utf-8');
147
+ await (0, inject_1.injectFafBlock)(claudeMdPath, claudeMdContent);
147
148
  result.success = true;
148
149
  result.direction = 'faf-to-claude';
149
150
  result.filesChanged.push('CLAUDE.md');
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Block markers for the faf-managed front section. Markdown files use HTML
3
+ * comments; non-markdown files (.cursorrules) pass hash-comment markers.
4
+ */
5
+ export declare const FAF_START = "<!-- faf:start -->";
6
+ export declare const FAF_END = "<!-- faf:end -->";
7
+ /**
8
+ * Non-destructively write a faf-managed block into a file.
9
+ *
10
+ * - no file -> create it with just the block
11
+ * - markers present -> replace ONLY between them (update in place)
12
+ * - legacy faf file (metastamp, no markers) -> reclaim in place (no duplication)
13
+ * - genuine user file -> prefix the block; preserve everything below
14
+ *
15
+ * Idempotent: re-runs update the block, never duplicate or destroy user content.
16
+ * faf owns what's between the markers; the user owns everything else.
17
+ * Enhance, never replace.
18
+ */
19
+ export declare function injectFafBlock(path: string, block: string, start?: string, end?: string): Promise<void>;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FAF_END = exports.FAF_START = void 0;
4
+ exports.injectFafBlock = injectFafBlock;
5
+ const fs_1 = require("fs");
6
+ /**
7
+ * Block markers for the faf-managed front section. Markdown files use HTML
8
+ * comments; non-markdown files (.cursorrules) pass hash-comment markers.
9
+ */
10
+ exports.FAF_START = '<!-- faf:start -->';
11
+ exports.FAF_END = '<!-- faf:end -->';
12
+ /**
13
+ * faf's own metastamp fingerprint. Every faf-generated file begins with it and a
14
+ * user never hand-writes it — so a markerless file led by it is legacy faf output
15
+ * we can safely reclaim, never genuine user content.
16
+ */
17
+ const FAF_METASTAMP = '<!-- faf:';
18
+ /**
19
+ * Non-destructively write a faf-managed block into a file.
20
+ *
21
+ * - no file -> create it with just the block
22
+ * - markers present -> replace ONLY between them (update in place)
23
+ * - legacy faf file (metastamp, no markers) -> reclaim in place (no duplication)
24
+ * - genuine user file -> prefix the block; preserve everything below
25
+ *
26
+ * Idempotent: re-runs update the block, never duplicate or destroy user content.
27
+ * faf owns what's between the markers; the user owns everything else.
28
+ * Enhance, never replace.
29
+ */
30
+ async function injectFafBlock(path, block, start = exports.FAF_START, end = exports.FAF_END) {
31
+ const wrapped = `${start}\n${block.trim()}\n${end}`;
32
+ let existing = null;
33
+ try {
34
+ existing = await fs_1.promises.readFile(path, 'utf-8');
35
+ }
36
+ catch {
37
+ existing = null; // file does not exist yet
38
+ }
39
+ if (existing === null) {
40
+ await fs_1.promises.writeFile(path, `${wrapped}\n`, 'utf-8');
41
+ return;
42
+ }
43
+ const s = existing.indexOf(start);
44
+ const e = existing.indexOf(end);
45
+ if (s !== -1 && e !== -1 && e > s) {
46
+ await fs_1.promises.writeFile(path, existing.slice(0, s) + wrapped + existing.slice(e + end.length), 'utf-8');
47
+ return;
48
+ }
49
+ if (existing.trimStart().startsWith(FAF_METASTAMP)) {
50
+ // Legacy faf output — reclaim in place, no duplication.
51
+ await fs_1.promises.writeFile(path, `${wrapped}\n`, 'utf-8');
52
+ return;
53
+ }
54
+ // Genuine user file — prefix the block, preserve everything.
55
+ await fs_1.promises.writeFile(path, `${wrapped}\n\n${existing}`, 'utf-8');
56
+ }
57
+ //# sourceMappingURL=inject.js.map
@@ -23,6 +23,7 @@ exports.detectAgentsMd = detectAgentsMd;
23
23
  exports.detectGlobalAgentsMd = detectGlobalAgentsMd;
24
24
  const fs_1 = require("fs");
25
25
  const path_1 = __importDefault(require("path"));
26
+ const inject_1 = require("../inject");
26
27
  // ============================================================================
27
28
  // Parsing
28
29
  // ============================================================================
@@ -278,9 +279,9 @@ async function agentsExport(fafContent, outputPath) {
278
279
  lines.push('---');
279
280
  lines.push(`*Generated from project.faf by claude-faf-mcp — ${new Date().toISOString().split('T')[0]}*`);
280
281
  lines.push('');
281
- // Write file
282
+ // Write file — non-destructive: inject/update the faf block, preserve the rest.
282
283
  const content = lines.join('\n');
283
- await fs_1.promises.writeFile(outputPath, content);
284
+ await (0, inject_1.injectFafBlock)(outputPath, content);
284
285
  return {
285
286
  success: true,
286
287
  filePath: outputPath,
@@ -25,6 +25,7 @@ exports.cursorExport = cursorExport;
25
25
  exports.detectCursorRules = detectCursorRules;
26
26
  const fs_1 = require("fs");
27
27
  const path_1 = __importDefault(require("path"));
28
+ const inject_1 = require("../inject");
28
29
  // ============================================================================
29
30
  // Parsing
30
31
  // ============================================================================
@@ -41,6 +42,9 @@ function parseCursorRules(content) {
41
42
  const rawLines = [];
42
43
  for (const line of lines) {
43
44
  rawLines.push(line);
45
+ // Skip faf's own block markers — they are not headings or content.
46
+ if (line.trim() === '# faf:start' || line.trim() === '# faf:end')
47
+ continue;
44
48
  // H1 = Project name
45
49
  const h1Match = line.match(/^#\s+(?:Project:\s*)?(.+)$/);
46
50
  if (h1Match) {
@@ -286,9 +290,9 @@ async function cursorExport(fafContent, outputPath) {
286
290
  lines.push('---');
287
291
  lines.push(`Generated from project.faf by claude-faf-mcp — ${new Date().toISOString().split('T')[0]}`);
288
292
  lines.push('');
289
- // Write file
293
+ // Write file — non-destructive: inject/update the faf block (hash-comment markers), preserve the rest.
290
294
  const content = lines.join('\n');
291
- await fs_1.promises.writeFile(outputPath, content);
295
+ await (0, inject_1.injectFafBlock)(outputPath, content, '# faf:start', '# faf:end');
292
296
  return {
293
297
  success: true,
294
298
  filePath: outputPath,
@@ -23,6 +23,7 @@ exports.detectGeminiMd = detectGeminiMd;
23
23
  exports.detectGlobalGeminiMd = detectGlobalGeminiMd;
24
24
  const fs_1 = require("fs");
25
25
  const path_1 = __importDefault(require("path"));
26
+ const inject_1 = require("../inject");
26
27
  // ============================================================================
27
28
  // Parsing
28
29
  // ============================================================================
@@ -218,9 +219,9 @@ async function geminiExport(fafContent, outputPath) {
218
219
  }
219
220
  lines.push('');
220
221
  }
221
- // Write file
222
+ // Write file — non-destructive: inject/update the faf block, preserve the rest.
222
223
  const content = lines.join('\n');
223
- await fs_1.promises.writeFile(outputPath, content);
224
+ await (0, inject_1.injectFafBlock)(outputPath, content);
224
225
  return {
225
226
  success: true,
226
227
  filePath: outputPath,
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `.fafa` → A2A AgentCard mapper.
3
+ *
4
+ * Maps CFM's FAF Agent Card (`.fafa`) to a conformant **A2A AgentCard**
5
+ * (A2A protocol v1.0, served at `/.well-known/a2a-agent-card`).
6
+ *
7
+ * Posture: `.fafa` is a PEER to the A2A AgentCard — **compatible + enhancing**.
8
+ * Every A2A field maps from `.fafa`; the enhancement is the **FAF context block**,
9
+ * which rides as an A2A extension (`one.faf/context`) — byte-equivalent to the MCP
10
+ * server-card `_meta["one.faf/context"]`. One context, every door.
11
+ *
12
+ * Pure mapper (testable). The live A2A *invocation* endpoint (JSON-RPC `message/send`)
13
+ * is served at the edge — this module produces the discovery card that points at it.
14
+ */
15
+ export declare const A2A_PROTOCOL_VERSION = "1.0";
16
+ export declare const A2A_WELL_KNOWN_PATH = "/.well-known/a2a-agent-card";
17
+ export interface A2AAgentSkill {
18
+ id: string;
19
+ name: string;
20
+ description?: string;
21
+ tags: string[];
22
+ examples?: string[];
23
+ inputModes?: string[];
24
+ outputModes?: string[];
25
+ }
26
+ export interface A2AAgentCard {
27
+ protocolVersion: string;
28
+ name: string;
29
+ description?: string;
30
+ url: string;
31
+ version?: string;
32
+ provider?: {
33
+ organization?: string;
34
+ url?: string;
35
+ };
36
+ capabilities: {
37
+ streaming: boolean;
38
+ pushNotifications: boolean;
39
+ extendedAgentCard: boolean;
40
+ };
41
+ defaultInputModes: string[];
42
+ defaultOutputModes: string[];
43
+ skills: A2AAgentSkill[];
44
+ extensions?: {
45
+ uri: string;
46
+ description?: string;
47
+ required: boolean;
48
+ params?: Record<string, unknown>;
49
+ }[];
50
+ }
51
+ /** Map a parsed `.fafa` document to a conformant A2A AgentCard. */
52
+ export declare function fafaToA2ACard(fafa: any, opts: {
53
+ url: string;
54
+ }): A2AAgentCard;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ /**
3
+ * `.fafa` → A2A AgentCard mapper.
4
+ *
5
+ * Maps CFM's FAF Agent Card (`.fafa`) to a conformant **A2A AgentCard**
6
+ * (A2A protocol v1.0, served at `/.well-known/a2a-agent-card`).
7
+ *
8
+ * Posture: `.fafa` is a PEER to the A2A AgentCard — **compatible + enhancing**.
9
+ * Every A2A field maps from `.fafa`; the enhancement is the **FAF context block**,
10
+ * which rides as an A2A extension (`one.faf/context`) — byte-equivalent to the MCP
11
+ * server-card `_meta["one.faf/context"]`. One context, every door.
12
+ *
13
+ * Pure mapper (testable). The live A2A *invocation* endpoint (JSON-RPC `message/send`)
14
+ * is served at the edge — this module produces the discovery card that points at it.
15
+ */
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ exports.A2A_WELL_KNOWN_PATH = exports.A2A_PROTOCOL_VERSION = void 0;
18
+ exports.fafaToA2ACard = fafaToA2ACard;
19
+ exports.A2A_PROTOCOL_VERSION = '1.0';
20
+ exports.A2A_WELL_KNOWN_PATH = '/.well-known/a2a-agent-card';
21
+ /** Map a parsed `.fafa` document to a conformant A2A AgentCard. */
22
+ function fafaToA2ACard(fafa, opts) {
23
+ const agent = fafa?.agent ?? {};
24
+ const card = {
25
+ protocolVersion: exports.A2A_PROTOCOL_VERSION,
26
+ name: agent.name,
27
+ description: agent.description,
28
+ url: opts.url, // A2A service endpoint (JSON-RPC) — served at the edge
29
+ version: agent.version,
30
+ provider: { organization: agent.vendor, url: agent.homepage },
31
+ // CFM's tools are synchronous request/response; no streaming/push yet.
32
+ capabilities: { streaming: false, pushNotifications: false, extendedAgentCard: false },
33
+ defaultInputModes: ['text/plain', 'application/json'],
34
+ defaultOutputModes: ['text/plain', 'application/json'],
35
+ // A2A skill ≡ FAF capability ≡ MCP tool (per AGENT-FORMAT §3).
36
+ skills: (fafa?.capabilities ?? []).map((c) => ({
37
+ id: c.name,
38
+ name: c.name,
39
+ description: c.description,
40
+ tags: Array.isArray(c.tags) ? c.tags : [],
41
+ })),
42
+ };
43
+ // The enhancement: the FAF context block as an A2A extension (the durable middle).
44
+ if (fafa?.provenance) {
45
+ card.extensions = [{
46
+ uri: 'https://one.faf/context',
47
+ description: 'FAF context provenance — the durable context block (one context, every door).',
48
+ required: false,
49
+ params: fafa.provenance,
50
+ }];
51
+ }
52
+ return card;
53
+ }
54
+ //# sourceMappingURL=a2a-card.js.map
@@ -0,0 +1,72 @@
1
+ export type Priority = 'ephemeral' | 'standard' | 'high' | 'critical';
2
+ /** Normalise a priority string: map legacy aliases, default unknowns to `standard`. */
3
+ export declare function canonicalPriority(p?: string | null): Priority;
4
+ export interface Fact {
5
+ text: string;
6
+ id?: string;
7
+ type?: string;
8
+ priority: Priority;
9
+ tags: string[];
10
+ links: string[];
11
+ timestamp?: string;
12
+ source?: string;
13
+ }
14
+ export interface EtchInput {
15
+ text: string;
16
+ id?: string;
17
+ type?: string;
18
+ priority?: string;
19
+ tags?: string[];
20
+ links?: string[];
21
+ source?: string;
22
+ }
23
+ export interface RecallOpts {
24
+ query?: string;
25
+ tags?: string[];
26
+ type?: string;
27
+ minPriority?: string;
28
+ limit?: number;
29
+ }
30
+ /** A loaded `.fafm` knowledge soul + its basic offline memory ops. */
31
+ export declare class Soul {
32
+ namepoint: string;
33
+ profile: string;
34
+ retention: string;
35
+ created: string;
36
+ last_etched: string;
37
+ private _facts;
38
+ private _byId;
39
+ constructor(namepoint: string, opts?: {
40
+ profile?: string;
41
+ facts?: Fact[];
42
+ retention?: string;
43
+ created?: string;
44
+ });
45
+ get facts(): Fact[];
46
+ static fromObj(o: any): Fact;
47
+ /** Load a `.fafm` soul from disk; namepoint defaults to the filename stem. */
48
+ static load(soulPath: string): Soul;
49
+ /** Load if the soul exists, else a fresh empty soul (offline-first, no account). */
50
+ static open(soulPath: string, namepoint?: string): Soul;
51
+ private factToObj;
52
+ toDoc(): Record<string, any>;
53
+ toYaml(): string;
54
+ save(soulPath: string): string;
55
+ /** Insert or update a Fact by id (O(1) dedup); else append. The merge primitive. */
56
+ add(fact: Fact): Fact;
57
+ /** Write a fact. If `id` matches an existing fact it's updated in place; else appended. */
58
+ etch(input: EtchInput): Fact;
59
+ /**
60
+ * Deterministic recall: case-insensitive substring on `text`, tag intersection,
61
+ * type equality, priority floor — ranked by priority, then recency, then
62
+ * insertion index (so the most-recently-etched fact always wins ties).
63
+ */
64
+ recall(opts?: RecallOpts): Fact[];
65
+ getFact(id: string): Fact | undefined;
66
+ /** The lean index — one-liners for always-loaded recall (`id — text`). */
67
+ index(): {
68
+ id: string;
69
+ text: string;
70
+ priority: Priority;
71
+ }[];
72
+ }
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.Soul = void 0;
37
+ exports.canonicalPriority = canonicalPriority;
38
+ /**
39
+ * FAFMemory — a conformant TypeScript implementation of the `.fafm` v1.1 memory
40
+ * soul (`application/vnd.fafm+yaml`). Local, offline-first.
41
+ *
42
+ * Parity-true with the Python `claude-fafm-sdk` (`Soul.etch` / `Soul.recall`) —
43
+ * same priority vocab, same dedup-by-id, same recall ranking (priority → recency
44
+ * → insertion-index). This is the seed of `claude-fafm-sdk-ts`.
45
+ *
46
+ * Boundary (doctrine): this is FAFMemory — it owns `etch`/`recall`/soul state.
47
+ * Context (`.faf`) is a separate object; the caller composes them. Recall NEVER
48
+ * lives on the context side.
49
+ */
50
+ const fs = __importStar(require("fs"));
51
+ const path = __importStar(require("path"));
52
+ const YAML = __importStar(require("yaml"));
53
+ const PRIORITY_ORDER = ['ephemeral', 'standard', 'high', 'critical'];
54
+ const PRIORITY_RANK = Object.fromEntries(PRIORITY_ORDER.map((p, i) => [p, i]));
55
+ const LEGACY_PRIORITY = { low: 'ephemeral', medium: 'standard' };
56
+ /** Normalise a priority string: map legacy aliases, default unknowns to `standard`. */
57
+ function canonicalPriority(p) {
58
+ if (!p)
59
+ return 'standard';
60
+ if (p in LEGACY_PRIORITY)
61
+ return LEGACY_PRIORITY[p];
62
+ return (p in PRIORITY_RANK ? p : 'standard');
63
+ }
64
+ /** Second-granularity UTC ISO stamp (matches existing souls; ties broken by insertion index). */
65
+ function utcnow() {
66
+ return new Date().toISOString().replace(/\.\d+Z$/, 'Z');
67
+ }
68
+ /** A loaded `.fafm` knowledge soul + its basic offline memory ops. */
69
+ class Soul {
70
+ namepoint;
71
+ profile;
72
+ retention;
73
+ created;
74
+ last_etched;
75
+ _facts;
76
+ _byId;
77
+ constructor(namepoint, opts = {}) {
78
+ this.namepoint = namepoint;
79
+ this.profile = opts.profile ?? 'knowledge';
80
+ this.retention = opts.retention ?? 'forever';
81
+ this.created = opts.created ?? utcnow();
82
+ this.last_etched = this.created;
83
+ this._facts = opts.facts ?? [];
84
+ this._byId = new Map();
85
+ this._facts.forEach((f, i) => { if (f.id != null)
86
+ this._byId.set(f.id, i); });
87
+ }
88
+ get facts() { return this._facts; }
89
+ static fromObj(o) {
90
+ return {
91
+ text: o.text,
92
+ id: o.id ?? undefined,
93
+ type: o.type ?? undefined,
94
+ priority: canonicalPriority(o.priority),
95
+ tags: Array.isArray(o.tags) ? o.tags : [],
96
+ links: Array.isArray(o.links) ? o.links : [],
97
+ timestamp: o.timestamp ?? undefined,
98
+ source: o.source ?? undefined,
99
+ };
100
+ }
101
+ /** Load a `.fafm` soul from disk; namepoint defaults to the filename stem. */
102
+ static load(soulPath) {
103
+ const doc = YAML.parse(fs.readFileSync(soulPath, 'utf-8')) || {};
104
+ if (typeof doc !== 'object' || Array.isArray(doc))
105
+ throw new Error('soul is not a YAML mapping');
106
+ const memory = doc.memory || {};
107
+ const soul = new Soul(doc.namepoint ?? path.basename(soulPath, path.extname(soulPath)), {
108
+ profile: doc.profile ?? 'knowledge',
109
+ facts: (memory.facts || []).map((f) => Soul.fromObj(f)),
110
+ retention: doc.retention ?? 'forever',
111
+ created: doc.created,
112
+ });
113
+ soul.last_etched = doc.last_etched ?? soul.created;
114
+ return soul;
115
+ }
116
+ /** Load if the soul exists, else a fresh empty soul (offline-first, no account). */
117
+ static open(soulPath, namepoint) {
118
+ if (fs.existsSync(soulPath))
119
+ return Soul.load(soulPath);
120
+ return new Soul(namepoint ?? path.basename(soulPath, path.extname(soulPath)));
121
+ }
122
+ factToObj(f) {
123
+ const out = { text: f.text };
124
+ if (f.id != null)
125
+ out.id = f.id;
126
+ if (f.type != null)
127
+ out.type = f.type;
128
+ out.priority = f.priority;
129
+ if (f.tags.length)
130
+ out.tags = f.tags;
131
+ if (f.links.length)
132
+ out.links = f.links;
133
+ if (f.timestamp != null)
134
+ out.timestamp = f.timestamp;
135
+ if (f.source != null)
136
+ out.source = f.source;
137
+ return out;
138
+ }
139
+ toDoc() {
140
+ return {
141
+ version: '1.1',
142
+ profile: this.profile,
143
+ namepoint: this.namepoint,
144
+ created: this.created,
145
+ last_etched: this.last_etched,
146
+ retention: this.retention,
147
+ memory: {
148
+ facts: this._facts.map((f) => this.factToObj(f)),
149
+ sessions: [],
150
+ preferences: {},
151
+ custom: {},
152
+ },
153
+ };
154
+ }
155
+ toYaml() {
156
+ return YAML.stringify(this.toDoc(), { lineWidth: 100 });
157
+ }
158
+ save(soulPath) {
159
+ fs.mkdirSync(path.dirname(path.resolve(soulPath)), { recursive: true });
160
+ fs.writeFileSync(soulPath, this.toYaml(), 'utf-8');
161
+ return soulPath;
162
+ }
163
+ /** Insert or update a Fact by id (O(1) dedup); else append. The merge primitive. */
164
+ add(fact) {
165
+ if (fact.id != null && this._byId.has(fact.id)) {
166
+ this._facts[this._byId.get(fact.id)] = fact;
167
+ }
168
+ else {
169
+ this._facts.push(fact);
170
+ if (fact.id != null)
171
+ this._byId.set(fact.id, this._facts.length - 1);
172
+ }
173
+ if (fact.timestamp && fact.timestamp > (this.last_etched || ''))
174
+ this.last_etched = fact.timestamp;
175
+ return fact;
176
+ }
177
+ /** Write a fact. If `id` matches an existing fact it's updated in place; else appended. */
178
+ etch(input) {
179
+ return this.add({
180
+ text: input.text,
181
+ id: input.id,
182
+ type: input.type,
183
+ priority: canonicalPriority(input.priority),
184
+ tags: [...(input.tags ?? [])],
185
+ links: [...(input.links ?? [])],
186
+ timestamp: utcnow(),
187
+ source: input.source,
188
+ });
189
+ }
190
+ /**
191
+ * Deterministic recall: case-insensitive substring on `text`, tag intersection,
192
+ * type equality, priority floor — ranked by priority, then recency, then
193
+ * insertion index (so the most-recently-etched fact always wins ties).
194
+ */
195
+ recall(opts = {}) {
196
+ const floor = PRIORITY_RANK[canonicalPriority(opts.minPriority ?? 'ephemeral')] ?? 0;
197
+ const q = (opts.query ?? '').toLowerCase();
198
+ const wantTags = new Set(opts.tags ?? []);
199
+ const indexed = this._facts
200
+ .map((f, i) => ({ f, i }))
201
+ .filter(({ f }) => (!q || f.text.toLowerCase().includes(q)) &&
202
+ (wantTags.size === 0 || f.tags.some((t) => wantTags.has(t))) &&
203
+ (opts.type == null || f.type === opts.type) &&
204
+ ((PRIORITY_RANK[f.priority] ?? 1) >= floor));
205
+ indexed.sort((a, b) => {
206
+ const ra = PRIORITY_RANK[a.f.priority] ?? 1, rb = PRIORITY_RANK[b.f.priority] ?? 1;
207
+ if (ra !== rb)
208
+ return rb - ra; // priority desc
209
+ const ta = a.f.timestamp ?? '', tb = b.f.timestamp ?? '';
210
+ if (ta !== tb)
211
+ return ta < tb ? 1 : -1; // recency desc
212
+ return b.i - a.i; // insertion index desc
213
+ });
214
+ const results = indexed.map(({ f }) => f);
215
+ return opts.limit != null ? results.slice(0, opts.limit) : results;
216
+ }
217
+ getFact(id) {
218
+ const i = this._byId.get(id);
219
+ return i != null ? this._facts[i] : undefined;
220
+ }
221
+ /** The lean index — one-liners for always-loaded recall (`id — text`). */
222
+ index() {
223
+ return this._facts
224
+ .filter((f) => f.id != null)
225
+ .map((f) => ({ id: f.id, text: f.text, priority: f.priority }));
226
+ }
227
+ }
228
+ exports.Soul = Soul;
229
+ //# sourceMappingURL=faf-memory.js.map