@seanhogg/builderforce-memory-mcp 2026.9.28 → 2026.9.30
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/README.md +25 -0
- package/dist/backends/memory-store.d.ts.map +1 -1
- package/dist/backends/memory-store.js +15 -68
- package/dist/backends/memory-store.js.map +1 -1
- package/dist/bin/http.d.ts +1 -0
- package/dist/bin/http.d.ts.map +1 -1
- package/dist/bin/http.js +9 -0
- package/dist/bin/http.js.map +1 -1
- package/dist/bin/stdio.d.ts +4 -0
- package/dist/bin/stdio.d.ts.map +1 -1
- package/dist/bin/stdio.js +7 -0
- package/dist/bin/stdio.js.map +1 -1
- package/dist/dynamic-import.d.ts +10 -0
- package/dist/dynamic-import.d.ts.map +1 -0
- package/dist/dynamic-import.js +13 -0
- package/dist/dynamic-import.js.map +1 -0
- package/dist/embedding/evermind-embedder.d.ts +0 -1
- package/dist/embedding/evermind-embedder.d.ts.map +1 -1
- package/dist/embedding/evermind-embedder.js +8 -44
- package/dist/embedding/evermind-embedder.js.map +1 -1
- package/dist/experience/file-store.d.ts +52 -0
- package/dist/experience/file-store.d.ts.map +1 -0
- package/dist/experience/file-store.js +119 -0
- package/dist/experience/file-store.js.map +1 -0
- package/dist/experience/host.d.ts +46 -0
- package/dist/experience/host.d.ts.map +1 -0
- package/dist/experience/host.js +57 -0
- package/dist/experience/host.js.map +1 -0
- package/dist/experience/tools.d.ts +16 -0
- package/dist/experience/tools.d.ts.map +1 -0
- package/dist/experience/tools.js +279 -0
- package/dist/experience/tools.js.map +1 -0
- package/dist/experience/train.d.ts +48 -0
- package/dist/experience/train.d.ts.map +1 -0
- package/dist/experience/train.js +72 -0
- package/dist/experience/train.js.map +1 -0
- package/dist/gateway-tools.d.ts +8 -0
- package/dist/gateway-tools.d.ts.map +1 -0
- package/dist/gateway-tools.js +71 -0
- package/dist/gateway-tools.js.map +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/install/install.d.ts.map +1 -1
- package/dist/install/install.js +2 -2
- package/dist/install/install.js.map +1 -1
- package/dist/model/evermind-package.d.ts +55 -0
- package/dist/model/evermind-package.d.ts.map +1 -0
- package/dist/model/evermind-package.js +86 -0
- package/dist/model/evermind-package.js.map +1 -0
- package/dist/persistence/shared-json-file.d.ts +65 -0
- package/dist/persistence/shared-json-file.d.ts.map +1 -0
- package/dist/persistence/shared-json-file.js +110 -0
- package/dist/persistence/shared-json-file.js.map +1 -0
- package/dist/tool-core.d.ts +28 -0
- package/dist/tool-core.d.ts.map +1 -0
- package/dist/tool-core.js +19 -0
- package/dist/tool-core.js.map +1 -0
- package/dist/tools.d.ts +9 -17
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -73
- package/dist/tools.js.map +1 -1
- package/dist/transports/http.d.ts.map +1 -1
- package/dist/transports/http.js +8 -1
- package/dist/transports/http.js.map +1 -1
- package/dist/transports/sdk.d.ts.map +1 -1
- package/dist/transports/sdk.js +2 -4
- package/dist/transports/sdk.js.map +1 -1
- package/package.json +6 -6
- package/server.json +3 -3
- package/src/backends/memory-store.ts +16 -85
- package/src/bin/http.ts +12 -0
- package/src/bin/stdio.ts +7 -0
- package/src/dynamic-import.ts +12 -0
- package/src/embedding/evermind-embedder.ts +11 -61
- package/src/experience/file-store.ts +143 -0
- package/src/experience/host.ts +113 -0
- package/src/experience/tools.ts +363 -0
- package/src/experience/train.ts +96 -0
- package/src/gateway-tools.ts +97 -0
- package/src/index.ts +8 -1
- package/src/install/install.ts +2 -2
- package/src/model/evermind-package.ts +127 -0
- package/src/persistence/shared-json-file.ts +131 -0
- package/src/tool-core.ts +39 -0
- package/src/tools.ts +13 -116
- package/src/transports/http.ts +8 -1
- 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
|
+
}
|
package/src/tool-core.ts
ADDED
|
@@ -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
|
-
|
|
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
|
-
}
|
package/src/transports/http.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/transports/sdk.ts
CHANGED
|
@@ -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
|
|
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,
|