@seanhogg/builderforce-memory-mcp 2026.9.27 → 2026.9.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +25 -0
  2. package/dist/backends/memory-store.d.ts.map +1 -1
  3. package/dist/backends/memory-store.js +15 -68
  4. package/dist/backends/memory-store.js.map +1 -1
  5. package/dist/bin/http.d.ts +1 -0
  6. package/dist/bin/http.d.ts.map +1 -1
  7. package/dist/bin/http.js +9 -0
  8. package/dist/bin/http.js.map +1 -1
  9. package/dist/bin/stdio.d.ts +4 -0
  10. package/dist/bin/stdio.d.ts.map +1 -1
  11. package/dist/bin/stdio.js +7 -0
  12. package/dist/bin/stdio.js.map +1 -1
  13. package/dist/dynamic-import.d.ts +10 -0
  14. package/dist/dynamic-import.d.ts.map +1 -0
  15. package/dist/dynamic-import.js +13 -0
  16. package/dist/dynamic-import.js.map +1 -0
  17. package/dist/embedding/evermind-embedder.d.ts +0 -1
  18. package/dist/embedding/evermind-embedder.d.ts.map +1 -1
  19. package/dist/embedding/evermind-embedder.js +8 -44
  20. package/dist/embedding/evermind-embedder.js.map +1 -1
  21. package/dist/experience/file-store.d.ts +52 -0
  22. package/dist/experience/file-store.d.ts.map +1 -0
  23. package/dist/experience/file-store.js +119 -0
  24. package/dist/experience/file-store.js.map +1 -0
  25. package/dist/experience/host.d.ts +46 -0
  26. package/dist/experience/host.d.ts.map +1 -0
  27. package/dist/experience/host.js +57 -0
  28. package/dist/experience/host.js.map +1 -0
  29. package/dist/experience/tools.d.ts +16 -0
  30. package/dist/experience/tools.d.ts.map +1 -0
  31. package/dist/experience/tools.js +279 -0
  32. package/dist/experience/tools.js.map +1 -0
  33. package/dist/experience/train.d.ts +48 -0
  34. package/dist/experience/train.d.ts.map +1 -0
  35. package/dist/experience/train.js +72 -0
  36. package/dist/experience/train.js.map +1 -0
  37. package/dist/gateway-tools.d.ts +8 -0
  38. package/dist/gateway-tools.d.ts.map +1 -0
  39. package/dist/gateway-tools.js +71 -0
  40. package/dist/gateway-tools.js.map +1 -0
  41. package/dist/index.d.ts +5 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +4 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/model/evermind-package.d.ts +55 -0
  46. package/dist/model/evermind-package.d.ts.map +1 -0
  47. package/dist/model/evermind-package.js +86 -0
  48. package/dist/model/evermind-package.js.map +1 -0
  49. package/dist/persistence/shared-json-file.d.ts +65 -0
  50. package/dist/persistence/shared-json-file.d.ts.map +1 -0
  51. package/dist/persistence/shared-json-file.js +110 -0
  52. package/dist/persistence/shared-json-file.js.map +1 -0
  53. package/dist/tool-core.d.ts +28 -0
  54. package/dist/tool-core.d.ts.map +1 -0
  55. package/dist/tool-core.js +19 -0
  56. package/dist/tool-core.js.map +1 -0
  57. package/dist/tools.d.ts +9 -17
  58. package/dist/tools.d.ts.map +1 -1
  59. package/dist/tools.js +5 -73
  60. package/dist/tools.js.map +1 -1
  61. package/dist/transports/http.d.ts.map +1 -1
  62. package/dist/transports/http.js +8 -1
  63. package/dist/transports/http.js.map +1 -1
  64. package/dist/transports/sdk.d.ts.map +1 -1
  65. package/dist/transports/sdk.js +2 -4
  66. package/dist/transports/sdk.js.map +1 -1
  67. package/package.json +6 -6
  68. package/server.json +3 -3
  69. package/src/backends/memory-store.ts +16 -85
  70. package/src/bin/http.ts +12 -0
  71. package/src/bin/stdio.ts +7 -0
  72. package/src/dynamic-import.ts +12 -0
  73. package/src/embedding/evermind-embedder.ts +11 -61
  74. package/src/experience/file-store.ts +143 -0
  75. package/src/experience/host.ts +113 -0
  76. package/src/experience/tools.ts +363 -0
  77. package/src/experience/train.ts +96 -0
  78. package/src/gateway-tools.ts +97 -0
  79. package/src/index.ts +8 -1
  80. package/src/model/evermind-package.ts +127 -0
  81. package/src/persistence/shared-json-file.ts +131 -0
  82. package/src/tool-core.ts +39 -0
  83. package/src/tools.ts +13 -116
  84. package/src/transports/http.ts +8 -1
  85. package/src/transports/sdk.ts +2 -5
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Load a local `.evermind` text model and the tokenizer behind its token ids.
3
+ *
4
+ * Two things on a headless host read the person's private model: recall (which
5
+ * embeds with it) and experience training (which adapts it). They must agree on
6
+ * which vocabulary a checkpoint speaks, so the resolution lives here once.
7
+ */
8
+
9
+ import { dynamicImport } from "../dynamic-import.js";
10
+
11
+ /** The tokenizer surface both embedding and adaptation need. */
12
+ export interface TextCodec {
13
+ encode(text: string): number[];
14
+ }
15
+
16
+ /** Structural view of the engine's package — enough to embed with or adapt. */
17
+ export interface EvermindPackageLike {
18
+ manifest: { modelType?: string; version?: string; name?: string };
19
+ checkpoint: ArrayBuffer;
20
+ loadLM(): unknown;
21
+ /** Present on engines that support the embedded-tokenizer section. */
22
+ loadTokenizer?(): TextCodec | null;
23
+ toBlob(): ArrayBuffer;
24
+ }
25
+
26
+ /** Minimal structural view of the optional engine package. */
27
+ interface PackageEngine {
28
+ EvermindModelPackage: {
29
+ fromBlob(blob: ArrayBuffer): EvermindPackageLike;
30
+ manifestEnd(header: ArrayBuffer): number;
31
+ readManifest(prefix: ArrayBuffer): EvermindPackageLike["manifest"];
32
+ };
33
+ PACKAGE_HEADER_BYTES: number;
34
+ BPETokenizer: new () => { loadFromSpec(spec: unknown): void; loadHuggingFace(spec: unknown): void } & TextCodec;
35
+ }
36
+
37
+ export type ModelFs = {
38
+ readFileSync(path: string, enc: "utf8"): string;
39
+ readFileSync(path: string): Uint8Array;
40
+ writeFileSync(path: string, data: string | Uint8Array): void;
41
+ existsSync(path: string): boolean;
42
+ copyFileSync(src: string, dest: string): void;
43
+ renameSync(from: string, to: string): void;
44
+ };
45
+
46
+ export interface LoadedEvermind {
47
+ pkg: EvermindPackageLike;
48
+ codec: TextCodec;
49
+ fs: ModelFs;
50
+ }
51
+
52
+ type PrefixFs = {
53
+ openSync(path: string, flags: "r"): number;
54
+ readSync(fd: number, buffer: Uint8Array, offset: number, length: number, position: number): number;
55
+ closeSync(fd: number): void;
56
+ };
57
+
58
+ /**
59
+ * The version in a `.evermind` file's manifest, reading only the header and manifest —
60
+ * never the checkpoint — so a caller can ask on every poll, and always sees the file
61
+ * as it is now (a model restored from `.prev` keeps the size and, on Windows, the
62
+ * modification time of the one it replaces). Null when there is no readable package.
63
+ */
64
+ export async function readEvermindVersion(modelFile: string): Promise<string | null> {
65
+ let fd: number | undefined;
66
+ const fs = (await dynamicImport("node:fs")) as PrefixFs;
67
+ try {
68
+ const engine = (await dynamicImport("@seanhogg/builderforce-memory-engine")) as Partial<PackageEngine>;
69
+ if (!engine?.EvermindModelPackage || !engine.PACKAGE_HEADER_BYTES) return null;
70
+ fd = fs.openSync(modelFile, "r");
71
+ const read = (n: number) => {
72
+ const buf = new Uint8Array(n);
73
+ const got = fs.readSync(fd!, buf, 0, n, 0);
74
+ return buf.buffer.slice(0, got) as ArrayBuffer;
75
+ };
76
+ const end = engine.EvermindModelPackage.manifestEnd(read(engine.PACKAGE_HEADER_BYTES));
77
+ return engine.EvermindModelPackage.readManifest(read(end)).version ?? "0";
78
+ } catch {
79
+ return null;
80
+ } finally {
81
+ if (fd !== undefined) fs.closeSync(fd);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Load the tokenizer spec from `file`, accepting either `BPETokenizer.toObject()`
87
+ * output or a Hugging Face `tokenizer.json`.
88
+ */
89
+ function loadCodec(engine: PackageEngine, fs: ModelFs, file: string): TextCodec {
90
+ const spec = JSON.parse(fs.readFileSync(file, "utf8")) as { vocab?: unknown; merges?: unknown };
91
+ const tok = new engine.BPETokenizer();
92
+ if (spec.vocab && spec.merges) tok.loadFromSpec(spec);
93
+ else tok.loadHuggingFace(spec);
94
+ return tok;
95
+ }
96
+
97
+ /**
98
+ * The `evermind-lm` package at `modelFile` with its tokenizer, or `null` — never a
99
+ * throw — when the engine peer is absent, a file is missing, or the package is not a
100
+ * text LM. Both callers treat a missing model as a degrade, not a fault.
101
+ *
102
+ * Tokenizer resolution: the package's OWN tokenizer (checksummed and vocab-checked
103
+ * at package time, so it cannot be the wrong vocabulary — which a separate file
104
+ * silently can be), then `tokenizerFile`, then a sibling `<modelFile>.tokenizer.json`.
105
+ */
106
+ export async function loadEvermindPackage(modelFile: string, tokenizerFile?: string): Promise<LoadedEvermind | null> {
107
+ try {
108
+ const engine = (await dynamicImport("@seanhogg/builderforce-memory-engine")) as Partial<PackageEngine>;
109
+ if (!engine?.EvermindModelPackage || !engine.BPETokenizer) return null;
110
+
111
+ const fs = (await dynamicImport("node:fs")) as ModelFs;
112
+ if (!fs.existsSync(modelFile)) return null;
113
+
114
+ const bytes = fs.readFileSync(modelFile);
115
+ const blob = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
116
+ const pkg = engine.EvermindModelPackage.fromBlob(blob);
117
+ if (pkg.manifest.modelType !== "evermind-lm") return null;
118
+
119
+ const embedded = pkg.loadTokenizer?.() ?? null;
120
+ if (embedded) return { pkg, codec: embedded, fs };
121
+ const file = tokenizerFile ?? `${modelFile}.tokenizer.json`;
122
+ if (!fs.existsSync(file)) return null;
123
+ return { pkg, codec: loadCodec(engine as PackageEngine, fs, file), fs };
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * A JSON file several processes share — the durable tier of every local store here.
3
+ *
4
+ * Each MCP client spawns its own server, and other tools (the BuilderForce VS Code
5
+ * extension's compactor, Synapse) rewrite the same files directly. So a store never
6
+ * trusts its in-memory copy blindly: before each operation it asks
7
+ * {@link SharedJsonFile.readIfChanged}, which re-reads the file only when it changed
8
+ * underneath this process.
9
+ *
10
+ * The loop guard is the stamp recorded immediately AFTER each of our own writes: the
11
+ * next check stats the file, sees the same mtime+size, and returns without reading a
12
+ * byte. A stat per call is the entire steady-state cost.
13
+ */
14
+
15
+ export type SharedFileFs = {
16
+ readFileSync(path: string, enc: "utf8"): string;
17
+ writeFileSync(path: string, data: string): void;
18
+ /** When present, writes land atomically (temp file + rename). */
19
+ renameSync?(from: string, to: string): void;
20
+ unlinkSync?(path: string): void;
21
+ existsSync(path: string): boolean;
22
+ statSync(path: string): { mtimeMs: number; size: number };
23
+ };
24
+
25
+ /** Identity of the file as this process last left it. */
26
+ interface FileStamp {
27
+ mtimeMs: number;
28
+ size: number;
29
+ /** Digest of the exact bytes — settles the case where a file is touched but unchanged. */
30
+ hash: string;
31
+ }
32
+
33
+ /**
34
+ * FNV-1a over the file text. Non-cryptographic on purpose: this only has to separate
35
+ * "someone rewrote the file" from "the mtime moved but the bytes are ours", and
36
+ * node:crypto is deliberately not imported (these modules stay bundleable for the
37
+ * browser, where the disk path is never taken).
38
+ */
39
+ export function contentHash(text: string): string {
40
+ let h = 0x811c9dc5;
41
+ for (let i = 0; i < text.length; i++) {
42
+ h ^= text.charCodeAt(i);
43
+ h = Math.imul(h, 0x01000193);
44
+ }
45
+ return (h >>> 0).toString(16);
46
+ }
47
+
48
+ /** A changed file's text, and the call that marks it as absorbed. */
49
+ export interface ChangedFile {
50
+ text: string;
51
+ /**
52
+ * Call once the text was parsed and applied. Not calling it (a half-written file
53
+ * that failed to parse) leaves the stamp alone, so the next check reads again.
54
+ */
55
+ accept(): void;
56
+ }
57
+
58
+ export class SharedJsonFile {
59
+ /** How the file looked when this process last wrote or absorbed it. */
60
+ private stamp: FileStamp | null = null;
61
+
62
+ constructor(
63
+ readonly path: string,
64
+ private readonly fs: SharedFileFs,
65
+ ) {}
66
+
67
+ /**
68
+ * The file's text when it changed since we last touched it — and on first use,
69
+ * which IS the boot hydration (one routine, so disk and memory never diverge by
70
+ * taking two paths). Null when unchanged, absent or unreadable.
71
+ */
72
+ readIfChanged(): ChangedFile | null {
73
+ if (!this.fs.existsSync(this.path)) return null;
74
+ let st: { mtimeMs: number; size: number };
75
+ try {
76
+ st = this.fs.statSync(this.path);
77
+ } catch {
78
+ return null;
79
+ }
80
+ // Fast path: byte-for-byte what we last wrote. No read, no parse.
81
+ if (this.stamp && st.mtimeMs === this.stamp.mtimeMs && st.size === this.stamp.size) return null;
82
+ let text: string;
83
+ try {
84
+ text = this.fs.readFileSync(this.path, "utf8");
85
+ } catch {
86
+ return null;
87
+ }
88
+ const hash = contentHash(text);
89
+ // Touched (mtime moved) but identical content — re-stamp, nothing to absorb.
90
+ if (this.stamp && hash === this.stamp.hash) {
91
+ this.stamp = { mtimeMs: st.mtimeMs, size: st.size, hash };
92
+ return null;
93
+ }
94
+ return { text, accept: () => (this.stamp = { mtimeMs: st.mtimeMs, size: st.size, hash }) };
95
+ }
96
+
97
+ /**
98
+ * Write, and stamp our own write so the next check does not read it back as foreign.
99
+ *
100
+ * Atomic where the filesystem allows: the text goes to a sibling temp file that is
101
+ * then renamed over the target, so a crash mid-write leaves the previous complete
102
+ * file, never a truncated one (which would read as empty and be overwritten by the
103
+ * next write). If the rename is refused (Windows, while another process holds the
104
+ * file open), the write falls back to writing in place.
105
+ */
106
+ write(text: string): void {
107
+ const rename = this.fs.renameSync?.bind(this.fs);
108
+ let written = false;
109
+ if (rename) {
110
+ const tmp = `${this.path}.${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}.tmp`;
111
+ try {
112
+ this.fs.writeFileSync(tmp, text);
113
+ rename(tmp, this.path);
114
+ written = true;
115
+ } catch {
116
+ try {
117
+ if (this.fs.existsSync(tmp)) this.fs.unlinkSync?.(tmp);
118
+ } catch {
119
+ /* best effort: a stray temp file is harmless */
120
+ }
121
+ }
122
+ }
123
+ if (!written) this.fs.writeFileSync(this.path, text);
124
+ try {
125
+ const st = this.fs.statSync(this.path);
126
+ this.stamp = { mtimeMs: st.mtimeMs, size: st.size, hash: contentHash(text) };
127
+ } catch {
128
+ this.stamp = null;
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The framework-neutral tool shape every tool family here is written in (memory,
3
+ * gateway cost, experience), and the result helpers they share. Each transport maps
4
+ * a {@link MemoryTool} 1:1 onto Agent-SDK `tool()` or MCP `registerTool()`.
5
+ */
6
+
7
+ import type { z } from "zod";
8
+
9
+ /** The MCP CallToolResult shape both server frameworks expect. */
10
+ export interface ToolResult {
11
+ content: Array<{ type: "text"; text: string }>;
12
+ isError?: boolean;
13
+ }
14
+
15
+ /** A framework-neutral tool: maps 1:1 onto Agent-SDK `tool()` and MCP `registerTool()`. */
16
+ export interface MemoryTool {
17
+ name: string;
18
+ description: string;
19
+ /** Zod *raw shape* (e.g. `{ query: z.string() }`), not a ZodObject. */
20
+ inputSchema: z.ZodRawShape;
21
+ handler: (args: Record<string, unknown>) => Promise<ToolResult>;
22
+ }
23
+
24
+ export function clip(s: string, n: number): string {
25
+ return s.length <= n ? s : `${s.slice(0, n)}…`;
26
+ }
27
+
28
+ export function ok(text: string): ToolResult {
29
+ return { content: [{ type: "text", text }] };
30
+ }
31
+
32
+ export function fail(text: string): ToolResult {
33
+ return { content: [{ type: "text", text }], isError: true };
34
+ }
35
+
36
+ /** A structured result, as indented JSON — for tools a program reads as well as a model. */
37
+ export function okJson(value: unknown): ToolResult {
38
+ return ok(JSON.stringify(value, null, 2));
39
+ }
package/src/tools.ts CHANGED
@@ -17,21 +17,12 @@
17
17
  import { z } from "zod";
18
18
  import type { MemoryBackend, RankedRecall, RecallHit, RecallMethod, RememberInput } from "./backend.js";
19
19
  import { DEFAULT_STUB_CHARS, planCompaction, type CompactionSkip } from "./compaction.js";
20
+ import { buildExperienceTools } from "./experience/tools.js";
21
+ import type { ExperienceHost } from "./experience/host.js";
22
+ import { appendGatewayCostTools } from "./gateway-tools.js";
23
+ import { clip, fail, ok, type MemoryTool, type ToolResult } from "./tool-core.js";
20
24
 
21
- /** The MCP CallToolResult shape both server frameworks expect. */
22
- export interface ToolResult {
23
- content: Array<{ type: "text"; text: string }>;
24
- isError?: boolean;
25
- }
26
-
27
- /** A framework-neutral tool: maps 1:1 onto Agent-SDK `tool()` and MCP `registerTool()`. */
28
- export interface MemoryTool {
29
- name: string;
30
- description: string;
31
- /** Zod *raw shape* (e.g. `{ query: z.string() }`), not a ZodObject. */
32
- inputSchema: z.ZodRawShape;
33
- handler: (args: Record<string, unknown>) => Promise<ToolResult>;
34
- }
25
+ export type { MemoryTool, ToolResult } from "./tool-core.js";
35
26
 
36
27
  export interface MemoryToolsOptions {
37
28
  /** Hard cap on entries any recall tool returns to the model. Default 5. */
@@ -47,23 +38,17 @@ export interface MemoryToolsOptions {
47
38
  gatewayUrl?: string;
48
39
  /** A `bfk_*` tenant API key. Required (with {@link gatewayUrl}) for cost tools. */
49
40
  gatewayApiKey?: string;
41
+ /**
42
+ * Experience (demonstrations, Train Once skills, runs, training the private model).
43
+ * When present and the server is writable, the experience tools are registered
44
+ * beside the memory tools. See {@link createExperienceHost}.
45
+ */
46
+ experience?: ExperienceHost | null;
50
47
  }
51
48
 
52
49
  const DEFAULT_MAX_RESULTS = 5;
53
50
  const DEFAULT_MAX_CONTENT = 500;
54
51
 
55
- function clip(s: string, n: number): string {
56
- return s.length <= n ? s : `${s.slice(0, n)}…`;
57
- }
58
-
59
- function ok(text: string): ToolResult {
60
- return { content: [{ type: "text", text }] };
61
- }
62
-
63
- function fail(text: string): ToolResult {
64
- return { content: [{ type: "text", text }], isError: true };
65
- }
66
-
67
52
  /**
68
53
  * How the ordering was produced, in one line the model can read.
69
54
  *
@@ -338,6 +323,8 @@ export function buildMemoryTools(backend: MemoryBackend, opts: MemoryToolsOption
338
323
  });
339
324
  }
340
325
 
326
+ if (writable && opts.experience) tools.push(...buildExperienceTools(opts.experience));
327
+
341
328
  // Gateway-backed cost tools — only when BOTH a gateway URL and key are
342
329
  // present, so memory-only deployments are unchanged.
343
330
  if (opts.gatewayUrl && opts.gatewayApiKey) {
@@ -346,93 +333,3 @@ export function buildMemoryTools(backend: MemoryBackend, opts: MemoryToolsOption
346
333
 
347
334
  return tools;
348
335
  }
349
-
350
- /** Snapshot shape returned by GET /llm/v1/builder-insights. */
351
- interface BuilderInsightsSnapshot {
352
- windowLabel?: string;
353
- todayTokens?: number;
354
- todayCostUsd?: number;
355
- dailyCapTokens?: number | null;
356
- pctOfDailyCap?: number | null;
357
- topModel?: { model: string; tokens: number } | null;
358
- costPerMergedPrUsd?: number | null;
359
- tip?: string | null;
360
- }
361
-
362
- /** Ranking shape returned by GET /llm/v1/model-analytics. */
363
- interface ModelAnalytics {
364
- byAction?: Array<{
365
- actionType?: string;
366
- label?: string;
367
- models?: Array<{
368
- model: string;
369
- samples?: number;
370
- avgScore?: number;
371
- mergeRate?: number;
372
- avgCostMillicents?: number;
373
- }>;
374
- }>;
375
- }
376
-
377
- async function gatewayGet<T>(gatewayUrl: string, path: string, apiKey: string): Promise<T> {
378
- const res = await fetch(`${gatewayUrl}${path}`, {
379
- headers: { authorization: `Bearer ${apiKey}` },
380
- });
381
- if (!res.ok) throw new Error(`gateway ${path} → HTTP ${res.status}`);
382
- return (await res.json()) as T;
383
- }
384
-
385
- function appendGatewayCostTools(tools: MemoryTool[], gatewayUrl: string, apiKey: string): void {
386
- tools.push({
387
- name: "token_usage",
388
- description: "Current token spend + budget for the workspace (today).",
389
- inputSchema: {},
390
- handler: async () => {
391
- try {
392
- const s = await gatewayGet<BuilderInsightsSnapshot>(gatewayUrl, "/llm/v1/builder-insights", apiKey);
393
- const lines = [
394
- `Token usage (${s.windowLabel ?? "today"}):`,
395
- `• Tokens: ${(s.todayTokens ?? 0).toLocaleString()}`,
396
- `• Cost: $${(s.todayCostUsd ?? 0).toFixed(2)}`,
397
- `• % of daily cap: ${
398
- s.pctOfDailyCap == null
399
- ? "no cap"
400
- : `${s.pctOfDailyCap}%${s.dailyCapTokens ? ` of ${s.dailyCapTokens.toLocaleString()}` : ""}`
401
- }`,
402
- `• Top model: ${s.topModel ? `${s.topModel.model} (${s.topModel.tokens.toLocaleString()} tok)` : "—"}`,
403
- ];
404
- if (s.costPerMergedPrUsd != null) lines.push(`• Cost / merged PR: $${s.costPerMergedPrUsd.toFixed(2)}`);
405
- if (s.tip) lines.push(`• Tip: ${s.tip}`);
406
- return ok(lines.join("\n"));
407
- } catch (err) {
408
- return fail(`token_usage failed: ${String(err)}`);
409
- }
410
- },
411
- });
412
-
413
- tools.push({
414
- name: "model_efficiency",
415
- description: "Which models performed best/cheapest for this workspace's recent work.",
416
- inputSchema: {},
417
- handler: async () => {
418
- try {
419
- const a = await gatewayGet<ModelAnalytics>(gatewayUrl, "/llm/v1/model-analytics", apiKey);
420
- const groups = a.byAction ?? [];
421
- if (groups.length === 0) return ok("No model efficiency data yet for this workspace.");
422
- const out: string[] = ["Model efficiency by action type (best first):"];
423
- for (const g of groups) {
424
- out.push(`\n${g.label ?? g.actionType ?? "action"}:`);
425
- for (const m of (g.models ?? []).slice(0, 3)) {
426
- const cost = m.avgCostMillicents != null ? ` $${(m.avgCostMillicents / 100_000).toFixed(4)}/call` : "";
427
- const merge = m.mergeRate != null ? ` merge=${Math.round(m.mergeRate * 100)}%` : "";
428
- const score = m.avgScore != null ? ` score=${m.avgScore}` : "";
429
- out.push(` • ${m.model}${score}${merge}${cost} (n=${m.samples ?? 0})`);
430
- }
431
- }
432
- return ok(out.join("\n"));
433
- } catch (err) {
434
- return fail(`model_efficiency failed: ${String(err)}`);
435
- }
436
- },
437
- });
438
- }
@@ -89,6 +89,8 @@ export function createMemoryHttpHandler(
89
89
  // ── Resolve the per-request backend + a rate-limit key ────────────────
90
90
  let resolved: MemoryBackend | undefined;
91
91
  let rateKey: string;
92
+ // True only when the caller proved the owner's shared secret (see below).
93
+ let ownerAuthed = false;
92
94
 
93
95
  if (tenantIndex) {
94
96
  // Multi-tenant: token → that tenant's backend (hash-keyed lookup).
@@ -109,6 +111,7 @@ export function createMemoryHttpHandler(
109
111
  }
110
112
  resolved = backend;
111
113
  rateKey = `t:${hashToken(token)}`;
114
+ ownerAuthed = true;
112
115
  } else {
113
116
  // Open (trusted gateway): rate-limit by client IP.
114
117
  resolved = backend;
@@ -121,7 +124,11 @@ export function createMemoryHttpHandler(
121
124
  return;
122
125
  }
123
126
 
124
- const server = buildMcpServer(resolved, opts);
127
+ // Experience is ONE person's local store (it can delete their recordings and
128
+ // rewrite their model), so it is served only to a caller holding the owner's
129
+ // shared secret — never to a token-selected tenant, and never in open mode,
130
+ // where anyone who can reach the port would get it.
131
+ const server = buildMcpServer(resolved, ownerAuthed ? opts : { ...opts, experience: null });
125
132
  // Stateless: no session id generator → a fresh transport per request.
126
133
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
127
134
 
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { MemoryBackend } from "../backend.js";
15
+ import { dynamicImport } from "../dynamic-import.js";
15
16
  import { buildMemoryTools, type MemoryToolsOptions } from "../tools.js";
16
17
 
17
18
  export interface SdkServerOptions extends MemoryToolsOptions {
@@ -49,11 +50,7 @@ export async function createMemoryMcpServer(
49
50
  backend: MemoryBackend,
50
51
  opts: SdkServerOptions = {},
51
52
  ): Promise<SdkMcpServerConfig> {
52
- const _import = (m: string): Promise<unknown> =>
53
- // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func
54
- new Function("m", "return import(m)")(m) as Promise<unknown>;
55
-
56
- const sdk = (await _import("@anthropic-ai/claude-agent-sdk")) as {
53
+ const sdk = (await dynamicImport("@anthropic-ai/claude-agent-sdk")) as {
57
54
  tool: (
58
55
  name: string,
59
56
  description: string,