@bli-cockpit/memory-mcp 0.1.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/README.md +97 -0
- package/dist/container-tag.d.ts +49 -0
- package/dist/container-tag.js +108 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +71 -0
- package/dist/print-config.d.ts +48 -0
- package/dist/print-config.js +106 -0
- package/dist/server.d.ts +74 -0
- package/dist/server.js +297 -0
- package/dist/session.d.ts +50 -0
- package/dist/session.js +101 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# @bli-cockpit/memory-mcp — BLI Memory
|
|
2
|
+
|
|
3
|
+
An MCP server for **BLI Memory**, the memory layer BLI owns: one table in our
|
|
4
|
+
own Postgres, four operations, no vendor in the path.
|
|
5
|
+
|
|
6
|
+
Four tools:
|
|
7
|
+
|
|
8
|
+
| Tool | What it does | Annotations |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| `search_memory` | Search one space by meaning, by keyword, and by recency at once. | read-only, idempotent |
|
|
11
|
+
| `save_memory` | Remember something durable. `customId` upserts. `extract: true` distils atomic facts first. | additive |
|
|
12
|
+
| `update_memory` | Replace what a memory says, keeping the old version as history. | additive |
|
|
13
|
+
| `forget_memory` | Tombstone a memory by id, by exact text, or by the closest match ≥ 0.85. | **destructive** |
|
|
14
|
+
|
|
15
|
+
`search_memory` and `save_memory` keep the names and argument names the hosted
|
|
16
|
+
Supermemory MCP used, so an agent, a skill or a habit pointed at those words
|
|
17
|
+
keeps working. `update_memory` is new — the vendor had no update tool at all,
|
|
18
|
+
and correction there meant forget-then-save, which loses the link between the
|
|
19
|
+
two.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm i -g @bli-cockpit/memory-mcp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then register it. The server prints the exact block each host wants, so nothing
|
|
28
|
+
has to be retyped:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bli-memory-mcp --print-config # both
|
|
32
|
+
bli-memory-mcp --print-config=claude # ~/.claude.json (or a plugin's .mcp.json)
|
|
33
|
+
bli-memory-mcp --print-config=codex # ~/.codex/config.toml
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`cockpit memory install` (a later slice of BLI-3580) consumes those two blocks
|
|
37
|
+
verbatim rather than keeping its own copy of them.
|
|
38
|
+
|
|
39
|
+
## Auth
|
|
40
|
+
|
|
41
|
+
None to set up. The server reads the collector's existing paired session at
|
|
42
|
+
`~/.config/bli-cockpit/session.json` — the one `cockpit login` wrote — and
|
|
43
|
+
sends that device token to Tower's memory doors. If the machine is not paired
|
|
44
|
+
it says so, in one sentence, on **stderr**, and exits 1.
|
|
45
|
+
|
|
46
|
+
Optional environment overrides:
|
|
47
|
+
|
|
48
|
+
| Variable | Effect |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `BLI_MEMORY_DEVICE_TOKEN` | Use this token instead of the session file (CI, containers). |
|
|
51
|
+
| `BLI_MEMORY_DASHBOARD_URL` | Point at another Tower deployment. |
|
|
52
|
+
| `BLI_MEMORY_CONTAINER_TAG` | Force the memory space, instead of deriving it. |
|
|
53
|
+
| `BLI_MEMORY_ISOLATE_WORKTREES=1` | Give each worktree its own space rather than sharing the repo's. |
|
|
54
|
+
|
|
55
|
+
## The space a memory lands in
|
|
56
|
+
|
|
57
|
+
The container tag is computed exactly the way the old vendor plugin computed
|
|
58
|
+
it — `repo_<name>__<sha256(normalised git remote)[0:16]>` — because that is
|
|
59
|
+
what makes the switch invisible: the memories a repo had yesterday are the
|
|
60
|
+
memories it has today. The **remote** is the identity, so every worktree of one
|
|
61
|
+
repo shares a space. A folder that is not a git repository still gets its own
|
|
62
|
+
space (`dir_<name>__<sha16>`), because a folder is a legitimate workspace.
|
|
63
|
+
|
|
64
|
+
## The stdio contract
|
|
65
|
+
|
|
66
|
+
**stdout carries the JSON-RPC stream and nothing else.** Every operational
|
|
67
|
+
line — the startup banner, an auth failure, a warning — goes to stderr. The one
|
|
68
|
+
exception is `--print-config`, which prints a config block to stdout and exits
|
|
69
|
+
without opening a transport.
|
|
70
|
+
|
|
71
|
+
## Two things worth knowing
|
|
72
|
+
|
|
73
|
+
**Zero matches is data; a transport failure is not.** A search that found
|
|
74
|
+
nothing returns text saying the record is silent. A search that could not run
|
|
75
|
+
returns `isError` and names why. An agent that cannot tell those apart will
|
|
76
|
+
confidently report the first when the second happened.
|
|
77
|
+
|
|
78
|
+
**The old plugin's auto-approve regex does not match this server.** The vendor's
|
|
79
|
+
Claude Code plugin ships a `PreToolUse` hook matching
|
|
80
|
+
`^mcp__(?:plugin_supermemory_|claude_ai_)?supermemory__(.+)$` and auto-allowing
|
|
81
|
+
its read-only tools. Our server is named `bli-memory`, so tool ids read
|
|
82
|
+
`mcp__bli-memory__search_memory` and that hook does not fire — reads will
|
|
83
|
+
prompt for permission until BLI ships its own plugin (with its own approve
|
|
84
|
+
hook) through `bli-agents-marketplace`. Renaming this server to contain
|
|
85
|
+
"supermemory" to inherit somebody else's auto-approve would be a worse trade
|
|
86
|
+
than one extra prompt.
|
|
87
|
+
|
|
88
|
+
## Development
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm run build --workspace=@bli-cockpit/memory-mcp
|
|
92
|
+
npm test --workspace=@bli-cockpit/memory-mcp
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Publish: `npm publish --workspace=@bli-cockpit/memory-mcp --access public --tag latest`.
|
|
96
|
+
|
|
97
|
+
Architecture, the trap list and the trial plan: `docs/architecture/agent-memory.md`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheme modified from supermemoryai/supermemory's Claude Code plugin
|
|
3
|
+
* `hooks/lib/container-tag.js:106-121` (plugin 0.1.5; repo commit
|
|
4
|
+
* 4d8a4ebfddadc3430f7f59a752cd374670833f50), MIT — see
|
|
5
|
+
* THIRD_PARTY_NOTICES.md. Rewritten in TypeScript with an argument-array
|
|
6
|
+
* spawn; the tag format is theirs and is kept byte for byte on purpose.
|
|
7
|
+
*
|
|
8
|
+
* Which space does this repository's memory live in? (BLI-3580)
|
|
9
|
+
*
|
|
10
|
+
* The scheme is the vendor plugin's, deliberately and exactly:
|
|
11
|
+
*
|
|
12
|
+
* repo_<sanitised repo name, ≤72>__<sha256(normalised git remote)[0:16]>
|
|
13
|
+
*
|
|
14
|
+
* Matching it byte for byte is what makes the cutover invisible: an exported
|
|
15
|
+
* Supermemory container lands under the same tag the plugin was already
|
|
16
|
+
* writing, so the memories a repo had yesterday are the memories it has today.
|
|
17
|
+
* Inventing a nicer scheme would strand 5.7k memories in a space nothing asks
|
|
18
|
+
* about.
|
|
19
|
+
*
|
|
20
|
+
* The REMOTE is the identity, not the path, which is why every worktree of a
|
|
21
|
+
* repo shares one container — the property that made
|
|
22
|
+
* `repo_bli_cockpit__c69eb0d08c…` the biggest space on the shelf. With no
|
|
23
|
+
* remote (or with `BLI_MEMORY_ISOLATE_WORKTREES=1`) the fallback is the real
|
|
24
|
+
* path of the git root, hashed.
|
|
25
|
+
*
|
|
26
|
+
* No shell string is ever built here: `git` is spawned with an argument array
|
|
27
|
+
* and `shell: false`, the same rule `commands/editor.ts` follows in the
|
|
28
|
+
* collector.
|
|
29
|
+
*/
|
|
30
|
+
export declare const CONTAINER_TAG_MAX_NAME = 72;
|
|
31
|
+
export interface ContainerTagOptions {
|
|
32
|
+
cwd?: string;
|
|
33
|
+
env?: NodeJS.ProcessEnv;
|
|
34
|
+
/** Injected in tests. Returns stdout, or null when git said no. */
|
|
35
|
+
git?: (args: string[], cwd: string) => string | null;
|
|
36
|
+
realpath?: (value: string) => string;
|
|
37
|
+
}
|
|
38
|
+
export interface ContainerTagResult {
|
|
39
|
+
containerTag: string;
|
|
40
|
+
/** How it was derived, for the startup line. */
|
|
41
|
+
basis: "override" | "remote" | "path" | "cwd";
|
|
42
|
+
}
|
|
43
|
+
export declare function resolveContainerTag(options?: ContainerTagOptions): ContainerTagResult;
|
|
44
|
+
/**
|
|
45
|
+
* The plugin's normalisation, restated: scheme and credentials dropped, `.git`
|
|
46
|
+
* suffix dropped, lowercased. `git@github.com:org/repo.git` and
|
|
47
|
+
* `https://github.com/org/repo` are one identity, which is the point.
|
|
48
|
+
*/
|
|
49
|
+
export declare function normalizeRemote(remote: string): string;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheme modified from supermemoryai/supermemory's Claude Code plugin
|
|
3
|
+
* `hooks/lib/container-tag.js:106-121` (plugin 0.1.5; repo commit
|
|
4
|
+
* 4d8a4ebfddadc3430f7f59a752cd374670833f50), MIT — see
|
|
5
|
+
* THIRD_PARTY_NOTICES.md. Rewritten in TypeScript with an argument-array
|
|
6
|
+
* spawn; the tag format is theirs and is kept byte for byte on purpose.
|
|
7
|
+
*
|
|
8
|
+
* Which space does this repository's memory live in? (BLI-3580)
|
|
9
|
+
*
|
|
10
|
+
* The scheme is the vendor plugin's, deliberately and exactly:
|
|
11
|
+
*
|
|
12
|
+
* repo_<sanitised repo name, ≤72>__<sha256(normalised git remote)[0:16]>
|
|
13
|
+
*
|
|
14
|
+
* Matching it byte for byte is what makes the cutover invisible: an exported
|
|
15
|
+
* Supermemory container lands under the same tag the plugin was already
|
|
16
|
+
* writing, so the memories a repo had yesterday are the memories it has today.
|
|
17
|
+
* Inventing a nicer scheme would strand 5.7k memories in a space nothing asks
|
|
18
|
+
* about.
|
|
19
|
+
*
|
|
20
|
+
* The REMOTE is the identity, not the path, which is why every worktree of a
|
|
21
|
+
* repo shares one container — the property that made
|
|
22
|
+
* `repo_bli_cockpit__c69eb0d08c…` the biggest space on the shelf. With no
|
|
23
|
+
* remote (or with `BLI_MEMORY_ISOLATE_WORKTREES=1`) the fallback is the real
|
|
24
|
+
* path of the git root, hashed.
|
|
25
|
+
*
|
|
26
|
+
* No shell string is ever built here: `git` is spawned with an argument array
|
|
27
|
+
* and `shell: false`, the same rule `commands/editor.ts` follows in the
|
|
28
|
+
* collector.
|
|
29
|
+
*/
|
|
30
|
+
import { spawnSync } from "node:child_process";
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
import fs from "node:fs";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
export const CONTAINER_TAG_MAX_NAME = 72;
|
|
35
|
+
export function resolveContainerTag(options = {}) {
|
|
36
|
+
const env = options.env ?? process.env;
|
|
37
|
+
const cwd = options.cwd ?? process.cwd();
|
|
38
|
+
const git = options.git ?? runGit;
|
|
39
|
+
const realpath = options.realpath ?? ((value) => fs.realpathSync(value));
|
|
40
|
+
const override = (env.BLI_MEMORY_CONTAINER_TAG ?? env.SUPERMEMORY_REPO_TAG ?? "").trim();
|
|
41
|
+
if (override.length > 0)
|
|
42
|
+
return { containerTag: override, basis: "override" };
|
|
43
|
+
const root = git(["rev-parse", "--show-toplevel"], cwd);
|
|
44
|
+
if (!root) {
|
|
45
|
+
// Not a repository. A folder is still a legitimate workspace — the
|
|
46
|
+
// session-first commandment says so about collection and it holds here —
|
|
47
|
+
// so it gets its own space rather than being refused.
|
|
48
|
+
return {
|
|
49
|
+
containerTag: `dir_${sanitize(path.basename(cwd))}__${hash16(`path:${safeRealpath(cwd, realpath)}`)}`,
|
|
50
|
+
basis: "cwd",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const isolate = (env.BLI_MEMORY_ISOLATE_WORKTREES ?? "").trim() === "1";
|
|
54
|
+
const remote = isolate ? null : git(["config", "--get", "remote.origin.url"], cwd);
|
|
55
|
+
const name = sanitize(path.basename(root));
|
|
56
|
+
if (remote && remote.length > 0) {
|
|
57
|
+
return { containerTag: `repo_${name}__${hash16(normalizeRemote(remote))}`, basis: "remote" };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
containerTag: `repo_${name}__${hash16(`path:${safeRealpath(root, realpath)}`)}`,
|
|
61
|
+
basis: "path",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The plugin's normalisation, restated: scheme and credentials dropped, `.git`
|
|
66
|
+
* suffix dropped, lowercased. `git@github.com:org/repo.git` and
|
|
67
|
+
* `https://github.com/org/repo` are one identity, which is the point.
|
|
68
|
+
*/
|
|
69
|
+
export function normalizeRemote(remote) {
|
|
70
|
+
let value = remote.trim().toLowerCase();
|
|
71
|
+
value = value.replace(/^ssh:\/\//, "").replace(/^https?:\/\//, "").replace(/^git:\/\//, "");
|
|
72
|
+
value = value.replace(/^[^@/]+@/, "");
|
|
73
|
+
value = value.replace(/:/g, "/");
|
|
74
|
+
value = value.replace(/\.git$/, "");
|
|
75
|
+
return value.replace(/\/+$/, "");
|
|
76
|
+
}
|
|
77
|
+
function sanitize(name) {
|
|
78
|
+
const cleaned = name.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
|
|
79
|
+
const safe = cleaned.length > 0 ? cleaned : "workspace";
|
|
80
|
+
return safe.slice(0, CONTAINER_TAG_MAX_NAME);
|
|
81
|
+
}
|
|
82
|
+
function hash16(value) {
|
|
83
|
+
return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 16);
|
|
84
|
+
}
|
|
85
|
+
function safeRealpath(value, realpath) {
|
|
86
|
+
try {
|
|
87
|
+
return realpath(value);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// A path that will not resolve still identifies a workspace well enough to
|
|
91
|
+
// hash. Silence here is deliberate and bounded: the fallback is the same
|
|
92
|
+
// string the caller passed, so nothing is invented.
|
|
93
|
+
return value;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function runGit(args, cwd) {
|
|
97
|
+
const result = spawnSync("git", args, {
|
|
98
|
+
cwd,
|
|
99
|
+
encoding: "utf8",
|
|
100
|
+
shell: false,
|
|
101
|
+
windowsHide: true,
|
|
102
|
+
timeout: 5_000,
|
|
103
|
+
});
|
|
104
|
+
if (result.status !== 0 || typeof result.stdout !== "string")
|
|
105
|
+
return null;
|
|
106
|
+
const value = result.stdout.trim();
|
|
107
|
+
return value.length > 0 ? value : null;
|
|
108
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* BLI Memory MCP server — entry point (BLI-3580).
|
|
4
|
+
*
|
|
5
|
+
* stdio only. **stdout is the JSON-RPC channel and carries nothing else**;
|
|
6
|
+
* every operational line goes to stderr. The one exception is
|
|
7
|
+
* `--print-config`, which prints a config block and exits without ever opening
|
|
8
|
+
* a transport.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* bli-memory-mcp run the server over stdio
|
|
12
|
+
* bli-memory-mcp --print-config both host blocks
|
|
13
|
+
* bli-memory-mcp --print-config --claude the Claude Code JSON block
|
|
14
|
+
* bli-memory-mcp --print-config --codex the Codex TOML block
|
|
15
|
+
* bli-memory-mcp --print-config=claude the same, as one token
|
|
16
|
+
*
|
|
17
|
+
* Both spellings are accepted deliberately. `cockpit memory install`
|
|
18
|
+
* (BLI-3580's installer slice) spawns this with separate flags; a person
|
|
19
|
+
* typing it by hand tends to write the `=` form. A config printer that only
|
|
20
|
+
* understands one of them is a config printer somebody works around by
|
|
21
|
+
* hand-copying the block, which is how the two hosts drift apart.
|
|
22
|
+
*
|
|
23
|
+
* Auth is the collector's own device token (`cockpit login`). Environment
|
|
24
|
+
* overrides, all optional: `BLI_MEMORY_DEVICE_TOKEN`,
|
|
25
|
+
* `BLI_MEMORY_DASHBOARD_URL`, `BLI_MEMORY_CONTAINER_TAG`,
|
|
26
|
+
* `BLI_MEMORY_ISOLATE_WORKTREES=1`.
|
|
27
|
+
*/
|
|
28
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* BLI Memory MCP server — entry point (BLI-3580).
|
|
4
|
+
*
|
|
5
|
+
* stdio only. **stdout is the JSON-RPC channel and carries nothing else**;
|
|
6
|
+
* every operational line goes to stderr. The one exception is
|
|
7
|
+
* `--print-config`, which prints a config block and exits without ever opening
|
|
8
|
+
* a transport.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* bli-memory-mcp run the server over stdio
|
|
12
|
+
* bli-memory-mcp --print-config both host blocks
|
|
13
|
+
* bli-memory-mcp --print-config --claude the Claude Code JSON block
|
|
14
|
+
* bli-memory-mcp --print-config --codex the Codex TOML block
|
|
15
|
+
* bli-memory-mcp --print-config=claude the same, as one token
|
|
16
|
+
*
|
|
17
|
+
* Both spellings are accepted deliberately. `cockpit memory install`
|
|
18
|
+
* (BLI-3580's installer slice) spawns this with separate flags; a person
|
|
19
|
+
* typing it by hand tends to write the `=` form. A config printer that only
|
|
20
|
+
* understands one of them is a config printer somebody works around by
|
|
21
|
+
* hand-copying the block, which is how the two hosts drift apart.
|
|
22
|
+
*
|
|
23
|
+
* Auth is the collector's own device token (`cockpit login`). Environment
|
|
24
|
+
* overrides, all optional: `BLI_MEMORY_DEVICE_TOKEN`,
|
|
25
|
+
* `BLI_MEMORY_DASHBOARD_URL`, `BLI_MEMORY_CONTAINER_TAG`,
|
|
26
|
+
* `BLI_MEMORY_ISOLATE_WORKTREES=1`.
|
|
27
|
+
*/
|
|
28
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
29
|
+
import { resolveContainerTag } from "./container-tag.js";
|
|
30
|
+
import { parseFormat, printConfig } from "./print-config.js";
|
|
31
|
+
import { createServer, SERVER_NAME } from "./server.js";
|
|
32
|
+
import { loadMemorySession } from "./session.js";
|
|
33
|
+
const TAG = "[bli-memory-mcp]";
|
|
34
|
+
async function main() {
|
|
35
|
+
const argv = process.argv.slice(2);
|
|
36
|
+
const format = parseFormat(argv);
|
|
37
|
+
if (format === "invalid") {
|
|
38
|
+
process.stderr.write(`${TAG} unknown --print-config value — use claude, codex, or omit it for both\n`);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
if (format) {
|
|
42
|
+
// The one thing that goes to stdout besides the protocol: an installer
|
|
43
|
+
// pipes this straight into a config file.
|
|
44
|
+
process.stdout.write(`${printConfig(format, {
|
|
45
|
+
command: "npx",
|
|
46
|
+
args: ["-y", "@bli-cockpit/memory-mcp"],
|
|
47
|
+
serverName: SERVER_NAME,
|
|
48
|
+
})}\n`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const session = loadMemorySession({ env: process.env });
|
|
52
|
+
if (!session.ok) {
|
|
53
|
+
process.stderr.write(`${TAG} ${session.reason}: ${session.message}\n`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
const container = resolveContainerTag({ cwd: process.cwd(), env: process.env });
|
|
57
|
+
// The startup line. Metadata only: which space, how it was derived, which
|
|
58
|
+
// host, where the credential came from. Never the token, never a path.
|
|
59
|
+
process.stderr.write(`${TAG} ready — container ${container.containerTag} (${container.basis}), ` +
|
|
60
|
+
`dashboard ${session.session.dashboardUrl}, auth ${session.session.source}\n`);
|
|
61
|
+
const server = createServer({
|
|
62
|
+
session: session.session,
|
|
63
|
+
fetchImpl: globalThis.fetch,
|
|
64
|
+
defaultContainerTag: container.containerTag,
|
|
65
|
+
});
|
|
66
|
+
await server.connect(new StdioServerTransport());
|
|
67
|
+
}
|
|
68
|
+
main().catch((error) => {
|
|
69
|
+
process.stderr.write(`${TAG} fatal: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bli-memory-mcp --print-config` — the exact block each host wants
|
|
3
|
+
* (BLI-3580).
|
|
4
|
+
*
|
|
5
|
+
* A third slice of this ticket builds `cockpit memory install`, which registers
|
|
6
|
+
* this server on every machine, for Claude Code and for Codex. That installer
|
|
7
|
+
* must not hand-assemble JSON or TOML from a template it maintains separately —
|
|
8
|
+
* that is how the two drift and how a fleet ends up half-registered. It reads
|
|
9
|
+
* THESE strings verbatim, and so does a person doing it by hand.
|
|
10
|
+
*
|
|
11
|
+
* Two hosts, two formats, one server:
|
|
12
|
+
*
|
|
13
|
+
* claude → the `mcpServers` entry for `~/.claude.json` (project or global) or
|
|
14
|
+
* a plugin's `.mcp.json`. Claude Code speaks stdio here.
|
|
15
|
+
* codex → the `[mcp_servers.bli-memory]` block for `~/.codex/config.toml`.
|
|
16
|
+
*
|
|
17
|
+
* The stdio contract, stated once because both installers depend on it:
|
|
18
|
+
* **stdout is the MCP protocol channel and carries nothing else.** Every
|
|
19
|
+
* operational line — the startup banner, an auth failure, a warning — goes to
|
|
20
|
+
* stderr. A single stray `console.log` here corrupts the JSON-RPC stream and
|
|
21
|
+
* the host reports a server that "does not work" with no reason attached.
|
|
22
|
+
*/
|
|
23
|
+
export interface PrintConfigOptions {
|
|
24
|
+
/** How the host should launch this server. */
|
|
25
|
+
command: string;
|
|
26
|
+
args: string[];
|
|
27
|
+
serverName: string;
|
|
28
|
+
/** Env the block should carry, e.g. a dashboard override. Usually empty. */
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
export type ConfigFormat = "claude" | "codex" | "both";
|
|
32
|
+
export declare function claudeConfigBlock(options: PrintConfigOptions): string;
|
|
33
|
+
export declare function codexConfigBlock(options: PrintConfigOptions): string;
|
|
34
|
+
export declare function printConfig(format: ConfigFormat, options: PrintConfigOptions): string;
|
|
35
|
+
/**
|
|
36
|
+
* Which block was asked for, if any.
|
|
37
|
+
*
|
|
38
|
+
* Both spellings are accepted deliberately: `cockpit memory install` spawns
|
|
39
|
+
* separate flags (`--print-config --codex`) and a person typing it by hand
|
|
40
|
+
* writes `--print-config=codex`. A printer that understands only one of them
|
|
41
|
+
* is a printer somebody works around by hand-copying the block, which is how
|
|
42
|
+
* two hosts' configs drift apart.
|
|
43
|
+
*
|
|
44
|
+
* `null` means nobody asked and the server should start; `"invalid"` means a
|
|
45
|
+
* host was named that does not exist, which is a refusal rather than a
|
|
46
|
+
* silently-defaulted "both".
|
|
47
|
+
*/
|
|
48
|
+
export declare function parseFormat(argv: readonly string[]): ConfigFormat | "invalid" | null;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `bli-memory-mcp --print-config` — the exact block each host wants
|
|
3
|
+
* (BLI-3580).
|
|
4
|
+
*
|
|
5
|
+
* A third slice of this ticket builds `cockpit memory install`, which registers
|
|
6
|
+
* this server on every machine, for Claude Code and for Codex. That installer
|
|
7
|
+
* must not hand-assemble JSON or TOML from a template it maintains separately —
|
|
8
|
+
* that is how the two drift and how a fleet ends up half-registered. It reads
|
|
9
|
+
* THESE strings verbatim, and so does a person doing it by hand.
|
|
10
|
+
*
|
|
11
|
+
* Two hosts, two formats, one server:
|
|
12
|
+
*
|
|
13
|
+
* claude → the `mcpServers` entry for `~/.claude.json` (project or global) or
|
|
14
|
+
* a plugin's `.mcp.json`. Claude Code speaks stdio here.
|
|
15
|
+
* codex → the `[mcp_servers.bli-memory]` block for `~/.codex/config.toml`.
|
|
16
|
+
*
|
|
17
|
+
* The stdio contract, stated once because both installers depend on it:
|
|
18
|
+
* **stdout is the MCP protocol channel and carries nothing else.** Every
|
|
19
|
+
* operational line — the startup banner, an auth failure, a warning — goes to
|
|
20
|
+
* stderr. A single stray `console.log` here corrupts the JSON-RPC stream and
|
|
21
|
+
* the host reports a server that "does not work" with no reason attached.
|
|
22
|
+
*/
|
|
23
|
+
export function claudeConfigBlock(options) {
|
|
24
|
+
const entry = {
|
|
25
|
+
command: options.command,
|
|
26
|
+
args: options.args,
|
|
27
|
+
};
|
|
28
|
+
if (options.env && Object.keys(options.env).length > 0)
|
|
29
|
+
entry.env = options.env;
|
|
30
|
+
return JSON.stringify({ mcpServers: { [options.serverName]: entry } }, null, 2);
|
|
31
|
+
}
|
|
32
|
+
export function codexConfigBlock(options) {
|
|
33
|
+
const lines = [
|
|
34
|
+
`[mcp_servers.${options.serverName}]`,
|
|
35
|
+
`command = ${tomlString(options.command)}`,
|
|
36
|
+
`args = [${options.args.map(tomlString).join(", ")}]`,
|
|
37
|
+
];
|
|
38
|
+
if (options.env && Object.keys(options.env).length > 0) {
|
|
39
|
+
lines.push(`[mcp_servers.${options.serverName}.env]`);
|
|
40
|
+
for (const [key, value] of Object.entries(options.env)) {
|
|
41
|
+
lines.push(`${key} = ${tomlString(value)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return lines.join("\n");
|
|
45
|
+
}
|
|
46
|
+
export function printConfig(format, options) {
|
|
47
|
+
if (format === "claude")
|
|
48
|
+
return claudeConfigBlock(options);
|
|
49
|
+
if (format === "codex")
|
|
50
|
+
return codexConfigBlock(options);
|
|
51
|
+
return [
|
|
52
|
+
"# Claude Code — merge into ~/.claude.json (or a plugin's .mcp.json)",
|
|
53
|
+
claudeConfigBlock(options),
|
|
54
|
+
"",
|
|
55
|
+
"# Codex — append to ~/.codex/config.toml",
|
|
56
|
+
codexConfigBlock(options),
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A TOML basic string. Escaping is explicit rather than `JSON.stringify` by
|
|
61
|
+
* luck: the two formats agree on `\"` and `\\` and disagree elsewhere, and a
|
|
62
|
+
* path with a backslash in it is the normal case on Windows.
|
|
63
|
+
*/
|
|
64
|
+
function tomlString(value) {
|
|
65
|
+
const escaped = value
|
|
66
|
+
.replace(/\\/g, "\\\\")
|
|
67
|
+
.replace(/"/g, '\\"')
|
|
68
|
+
.replace(/\n/g, "\\n")
|
|
69
|
+
.replace(/\t/g, "\\t");
|
|
70
|
+
return `"${escaped}"`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Which block was asked for, if any.
|
|
74
|
+
*
|
|
75
|
+
* Both spellings are accepted deliberately: `cockpit memory install` spawns
|
|
76
|
+
* separate flags (`--print-config --codex`) and a person typing it by hand
|
|
77
|
+
* writes `--print-config=codex`. A printer that understands only one of them
|
|
78
|
+
* is a printer somebody works around by hand-copying the block, which is how
|
|
79
|
+
* two hosts' configs drift apart.
|
|
80
|
+
*
|
|
81
|
+
* `null` means nobody asked and the server should start; `"invalid"` means a
|
|
82
|
+
* host was named that does not exist, which is a refusal rather than a
|
|
83
|
+
* silently-defaulted "both".
|
|
84
|
+
*/
|
|
85
|
+
export function parseFormat(argv) {
|
|
86
|
+
let asked = false;
|
|
87
|
+
let format = "both";
|
|
88
|
+
for (const arg of argv) {
|
|
89
|
+
if (arg === "--print-config") {
|
|
90
|
+
asked = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (arg.startsWith("--print-config=")) {
|
|
94
|
+
asked = true;
|
|
95
|
+
const value = arg.slice("--print-config=".length).trim();
|
|
96
|
+
if (value !== "claude" && value !== "codex" && value !== "both")
|
|
97
|
+
return "invalid";
|
|
98
|
+
format = value;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (asked && (arg === "--claude" || arg === "--codex" || arg === "--both")) {
|
|
102
|
+
format = arg.slice(2);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return asked ? format : null;
|
|
106
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool shapes modified from supermemoryai/supermemory
|
|
3
|
+
* `apps/mcp/src/server/tools/*.ts` + `container-tag.ts` at commit
|
|
4
|
+
* 4d8a4ebfddadc3430f7f59a752cd374670833f50, MIT — see THIRD_PARTY_NOTICES.md.
|
|
5
|
+
*
|
|
6
|
+
* BLI Memory — the MCP server (BLI-3580).
|
|
7
|
+
*
|
|
8
|
+
* Four tools, forwarding to the dashboard's four doors with this machine's
|
|
9
|
+
* collector device token. Tool NAMES and argument names match what the vendor
|
|
10
|
+
* MCP exposed (`search_memory`, `save_memory`), because every agent config,
|
|
11
|
+
* skill and habit already points at those words — the swap should be invisible
|
|
12
|
+
* from the outside. What a PERSON reads says BLI Memory.
|
|
13
|
+
*
|
|
14
|
+
* Shapes lifted from supermemoryai/supermemory `apps/mcp/src/server/tools/*`
|
|
15
|
+
* and `container-tag.ts` at commit 4d8a4ebfddadc3430f7f59a752cd374670833f50
|
|
16
|
+
* (MIT — see THIRD_PARTY_NOTICES.md): the argument names, the bounds
|
|
17
|
+
* (query ≤ 1000, content ≤ 200000, containerTag 1..128), the text-block +
|
|
18
|
+
* `structuredContent` twin (a model reads the prose, a program reads the rows),
|
|
19
|
+
* and the annotation sets. Reimplemented against our doors; the transport,
|
|
20
|
+
* the auth and every message are ours.
|
|
21
|
+
*
|
|
22
|
+
* Three wire contracts, each from somewhere that learned it the hard way:
|
|
23
|
+
*
|
|
24
|
+
* - **Zero matches is data; a transport failure is not.** An empty search
|
|
25
|
+
* returns text and NOT `isError`; a search that could not run returns
|
|
26
|
+
* `isError` and names why. Collapsing them lets the calling agent
|
|
27
|
+
* synthesise around an outage — ByteRover states it exactly this way and it
|
|
28
|
+
* is this repo's own never-fail-silently rule as a wire contract.
|
|
29
|
+
* - **Annotations on every tool.** `readOnlyHint` / `destructiveHint` /
|
|
30
|
+
* `idempotentHint`, so a client knows not to retry a forget.
|
|
31
|
+
* - **Tell the model when NOT to call it.** Each description carries USE WHEN
|
|
32
|
+
* and DO NOT USE lines.
|
|
33
|
+
*/
|
|
34
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
import type { MemorySession } from "./session.js";
|
|
37
|
+
export declare const PACKAGE_NAME = "@bli-cockpit/memory-mcp";
|
|
38
|
+
export declare const PACKAGE_VERSION = "0.1.0";
|
|
39
|
+
/** The server id an MCP client registers. Named in every install block. */
|
|
40
|
+
export declare const SERVER_NAME = "bli-memory";
|
|
41
|
+
export type FetchImpl = typeof fetch;
|
|
42
|
+
export interface ServerDeps {
|
|
43
|
+
session: MemorySession;
|
|
44
|
+
fetchImpl: FetchImpl;
|
|
45
|
+
/** The container tag when the caller did not name one. */
|
|
46
|
+
defaultContainerTag: string;
|
|
47
|
+
/** Injected in tests. */
|
|
48
|
+
now?: () => number;
|
|
49
|
+
}
|
|
50
|
+
export declare const searchMemoryInput: {
|
|
51
|
+
query: z.ZodString;
|
|
52
|
+
containerTag: z.ZodOptional<z.ZodString>;
|
|
53
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
54
|
+
includeProfile: z.ZodOptional<z.ZodBoolean>;
|
|
55
|
+
};
|
|
56
|
+
export declare const saveMemoryInput: {
|
|
57
|
+
content: z.ZodString;
|
|
58
|
+
containerTag: z.ZodOptional<z.ZodString>;
|
|
59
|
+
customId: z.ZodOptional<z.ZodString>;
|
|
60
|
+
extract: z.ZodOptional<z.ZodBoolean>;
|
|
61
|
+
};
|
|
62
|
+
export declare const updateMemoryInput: {
|
|
63
|
+
id: z.ZodString;
|
|
64
|
+
content: z.ZodString;
|
|
65
|
+
};
|
|
66
|
+
export declare const forgetMemoryInput: {
|
|
67
|
+
id: z.ZodOptional<z.ZodString>;
|
|
68
|
+
content: z.ZodOptional<z.ZodString>;
|
|
69
|
+
containerTag: z.ZodOptional<z.ZodString>;
|
|
70
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
71
|
+
};
|
|
72
|
+
export declare function createServer(deps: ServerDeps): McpServer;
|
|
73
|
+
/** The container tag this process will use when a tool names none. */
|
|
74
|
+
export declare function defaultContainerTagFor(cwd: string, env: NodeJS.ProcessEnv): string;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool shapes modified from supermemoryai/supermemory
|
|
3
|
+
* `apps/mcp/src/server/tools/*.ts` + `container-tag.ts` at commit
|
|
4
|
+
* 4d8a4ebfddadc3430f7f59a752cd374670833f50, MIT — see THIRD_PARTY_NOTICES.md.
|
|
5
|
+
*
|
|
6
|
+
* BLI Memory — the MCP server (BLI-3580).
|
|
7
|
+
*
|
|
8
|
+
* Four tools, forwarding to the dashboard's four doors with this machine's
|
|
9
|
+
* collector device token. Tool NAMES and argument names match what the vendor
|
|
10
|
+
* MCP exposed (`search_memory`, `save_memory`), because every agent config,
|
|
11
|
+
* skill and habit already points at those words — the swap should be invisible
|
|
12
|
+
* from the outside. What a PERSON reads says BLI Memory.
|
|
13
|
+
*
|
|
14
|
+
* Shapes lifted from supermemoryai/supermemory `apps/mcp/src/server/tools/*`
|
|
15
|
+
* and `container-tag.ts` at commit 4d8a4ebfddadc3430f7f59a752cd374670833f50
|
|
16
|
+
* (MIT — see THIRD_PARTY_NOTICES.md): the argument names, the bounds
|
|
17
|
+
* (query ≤ 1000, content ≤ 200000, containerTag 1..128), the text-block +
|
|
18
|
+
* `structuredContent` twin (a model reads the prose, a program reads the rows),
|
|
19
|
+
* and the annotation sets. Reimplemented against our doors; the transport,
|
|
20
|
+
* the auth and every message are ours.
|
|
21
|
+
*
|
|
22
|
+
* Three wire contracts, each from somewhere that learned it the hard way:
|
|
23
|
+
*
|
|
24
|
+
* - **Zero matches is data; a transport failure is not.** An empty search
|
|
25
|
+
* returns text and NOT `isError`; a search that could not run returns
|
|
26
|
+
* `isError` and names why. Collapsing them lets the calling agent
|
|
27
|
+
* synthesise around an outage — ByteRover states it exactly this way and it
|
|
28
|
+
* is this repo's own never-fail-silently rule as a wire contract.
|
|
29
|
+
* - **Annotations on every tool.** `readOnlyHint` / `destructiveHint` /
|
|
30
|
+
* `idempotentHint`, so a client knows not to retry a forget.
|
|
31
|
+
* - **Tell the model when NOT to call it.** Each description carries USE WHEN
|
|
32
|
+
* and DO NOT USE lines.
|
|
33
|
+
*/
|
|
34
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
import { resolveContainerTag } from "./container-tag.js";
|
|
37
|
+
export const PACKAGE_NAME = "@bli-cockpit/memory-mcp";
|
|
38
|
+
export const PACKAGE_VERSION = "0.1.0";
|
|
39
|
+
/** The server id an MCP client registers. Named in every install block. */
|
|
40
|
+
export const SERVER_NAME = "bli-memory";
|
|
41
|
+
const READ_ONLY_ANNOTATIONS = {
|
|
42
|
+
readOnlyHint: true,
|
|
43
|
+
destructiveHint: false,
|
|
44
|
+
idempotentHint: true,
|
|
45
|
+
openWorldHint: false,
|
|
46
|
+
};
|
|
47
|
+
const ADDITIVE_ANNOTATIONS = {
|
|
48
|
+
readOnlyHint: false,
|
|
49
|
+
destructiveHint: false,
|
|
50
|
+
idempotentHint: false,
|
|
51
|
+
openWorldHint: false,
|
|
52
|
+
};
|
|
53
|
+
const DESTRUCTIVE_ANNOTATIONS = {
|
|
54
|
+
readOnlyHint: false,
|
|
55
|
+
destructiveHint: true,
|
|
56
|
+
idempotentHint: false,
|
|
57
|
+
openWorldHint: false,
|
|
58
|
+
};
|
|
59
|
+
const containerTagSchema = z
|
|
60
|
+
.string()
|
|
61
|
+
.min(1)
|
|
62
|
+
.max(128)
|
|
63
|
+
.describe("The memory space. Omit it and this machine's own repository space is used, which is almost always what you want.");
|
|
64
|
+
export const searchMemoryInput = {
|
|
65
|
+
query: z.string().min(1).max(1000).describe("What to look for, in plain language."),
|
|
66
|
+
containerTag: containerTagSchema.optional(),
|
|
67
|
+
limit: z.number().int().min(1).max(20).optional().describe("How many memories. Default 5."),
|
|
68
|
+
includeProfile: z
|
|
69
|
+
.boolean()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe("Also return the newest memories in this space as recent context. Default true."),
|
|
72
|
+
};
|
|
73
|
+
export const saveMemoryInput = {
|
|
74
|
+
content: z.string().min(1).max(200_000).describe("The memory to store."),
|
|
75
|
+
containerTag: containerTagSchema.optional(),
|
|
76
|
+
customId: z
|
|
77
|
+
.string()
|
|
78
|
+
.min(1)
|
|
79
|
+
.max(255)
|
|
80
|
+
.optional()
|
|
81
|
+
.describe("A stable id for this memory's source, e.g. the session id. Saving twice with the same one updates rather than duplicates."),
|
|
82
|
+
extract: z
|
|
83
|
+
.boolean()
|
|
84
|
+
.optional()
|
|
85
|
+
.describe("Distil the content into atomic facts first (costs one model call). Default false: store it as given."),
|
|
86
|
+
};
|
|
87
|
+
export const updateMemoryInput = {
|
|
88
|
+
id: z.string().min(1).describe("The memory id, as returned by search_memory."),
|
|
89
|
+
content: z.string().min(1).max(200_000).describe("What it should say now."),
|
|
90
|
+
};
|
|
91
|
+
export const forgetMemoryInput = {
|
|
92
|
+
id: z.string().min(1).optional().describe("The memory id to forget."),
|
|
93
|
+
content: z
|
|
94
|
+
.string()
|
|
95
|
+
.min(1)
|
|
96
|
+
.max(10_000)
|
|
97
|
+
.optional()
|
|
98
|
+
.describe("The exact text to forget, when you do not have an id. Needs a containerTag."),
|
|
99
|
+
containerTag: containerTagSchema.optional(),
|
|
100
|
+
reason: z
|
|
101
|
+
.string()
|
|
102
|
+
.max(120)
|
|
103
|
+
.optional()
|
|
104
|
+
.describe("A short label for why, e.g. asked_by_person. Never prose."),
|
|
105
|
+
};
|
|
106
|
+
async function callDoor(deps, path, body) {
|
|
107
|
+
const url = `${deps.session.dashboardUrl}${path}`;
|
|
108
|
+
let response;
|
|
109
|
+
try {
|
|
110
|
+
response = await deps.fetchImpl(url, {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: {
|
|
113
|
+
// The device token. Never logged, never echoed into a message.
|
|
114
|
+
authorization: `Bearer ${deps.session.deviceToken}`,
|
|
115
|
+
"content-type": "application/json",
|
|
116
|
+
accept: "application/json",
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify(body),
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
status: 0,
|
|
125
|
+
body: {},
|
|
126
|
+
transportError: error instanceof Error ? error.message.split("\n")[0] : String(error),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
let parsed = {};
|
|
130
|
+
try {
|
|
131
|
+
const text = await response.text();
|
|
132
|
+
parsed = text ? JSON.parse(text) : {};
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
status: response.status,
|
|
138
|
+
body: {},
|
|
139
|
+
transportError: `Tower answered ${response.status} with a body this server could not read as JSON.`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return { ok: response.ok, status: response.status, body: parsed, transportError: null };
|
|
143
|
+
}
|
|
144
|
+
function textResult(text, structured) {
|
|
145
|
+
return {
|
|
146
|
+
content: [{ type: "text", text }],
|
|
147
|
+
...(structured ? { structuredContent: structured } : {}),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* A failure, said out loud. `isError` is the difference between "the shelf is
|
|
152
|
+
* empty" and "nobody could reach the shelf", and an agent that cannot tell
|
|
153
|
+
* them apart will confidently report the first when the second happened.
|
|
154
|
+
*/
|
|
155
|
+
function errorResult(what, detail) {
|
|
156
|
+
return {
|
|
157
|
+
isError: true,
|
|
158
|
+
content: [{ type: "text", text: `${what} ${detail}` }],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
function doorFailure(door, response) {
|
|
162
|
+
if (response.transportError) {
|
|
163
|
+
return errorResult(`BLI Memory could not be reached (${door}).`, `${response.transportError} Nothing was read or written. This is an outage, not an empty memory store.`);
|
|
164
|
+
}
|
|
165
|
+
const message = typeof response.body.message === "string"
|
|
166
|
+
? response.body.message
|
|
167
|
+
: `Tower answered ${response.status}.`;
|
|
168
|
+
const label = typeof response.body.error === "string" ? response.body.error : "unknown_error";
|
|
169
|
+
return errorResult(`BLI Memory refused the ${door} (${label}).`, message);
|
|
170
|
+
}
|
|
171
|
+
export function createServer(deps) {
|
|
172
|
+
const server = new McpServer({ name: SERVER_NAME, version: PACKAGE_VERSION });
|
|
173
|
+
const tagFor = (given) => given?.trim() || deps.defaultContainerTag;
|
|
174
|
+
server.registerTool("search_memory", {
|
|
175
|
+
title: "Search BLI Memory",
|
|
176
|
+
description: "Search what this project and this team have deliberately remembered — decisions, preferences, " +
|
|
177
|
+
"stable facts about people and systems. USE WHEN a question turns on something decided or " +
|
|
178
|
+
"learned earlier that the files in front of you do not answer. DO NOT USE to search code, " +
|
|
179
|
+
"recent commits, or the contents of the current repository — read those directly. " +
|
|
180
|
+
"Zero matches means the record is silent, not that the memory store is down; a store that " +
|
|
181
|
+
"could not answer says so explicitly.",
|
|
182
|
+
inputSchema: searchMemoryInput,
|
|
183
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
184
|
+
}, async (args) => {
|
|
185
|
+
const containerTag = tagFor(args.containerTag);
|
|
186
|
+
const response = await callDoor(deps, "/api/memory/search", {
|
|
187
|
+
query: args.query,
|
|
188
|
+
containerTag,
|
|
189
|
+
limit: args.limit,
|
|
190
|
+
includeProfile: args.includeProfile,
|
|
191
|
+
});
|
|
192
|
+
if (!response.ok)
|
|
193
|
+
return doorFailure("search", response);
|
|
194
|
+
const results = Array.isArray(response.body.results)
|
|
195
|
+
? response.body.results
|
|
196
|
+
: [];
|
|
197
|
+
const recent = Array.isArray(response.body.recent)
|
|
198
|
+
? response.body.recent
|
|
199
|
+
: [];
|
|
200
|
+
const lines = [];
|
|
201
|
+
if (results.length > 0) {
|
|
202
|
+
lines.push("## Matching memories");
|
|
203
|
+
for (const row of results) {
|
|
204
|
+
const similarity = Math.round(Number(row.similarity ?? 0) * 100);
|
|
205
|
+
lines.push(`- [${similarity}%] ${String(row.memory ?? "")} (id: ${String(row.id ?? "")})`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
lines.push("No memories matched. The record is silent on this — it does not mean the store is empty.");
|
|
210
|
+
}
|
|
211
|
+
if (recent.length > 0) {
|
|
212
|
+
lines.push("", "## Recent context");
|
|
213
|
+
for (const row of recent)
|
|
214
|
+
lines.push(`- ${String(row.preview ?? row.memory ?? "")}`);
|
|
215
|
+
}
|
|
216
|
+
if (typeof response.body.degraded === "string" && response.body.degraded) {
|
|
217
|
+
lines.push("", `Note: this answer is thinner than usual (${response.body.degraded}) — one search channel did not run.`);
|
|
218
|
+
}
|
|
219
|
+
return textResult(lines.join("\n"), {
|
|
220
|
+
query: args.query,
|
|
221
|
+
containerTag,
|
|
222
|
+
results,
|
|
223
|
+
recent,
|
|
224
|
+
degraded: response.body.degraded ?? null,
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
server.registerTool("save_memory", {
|
|
228
|
+
title: "Save to BLI Memory",
|
|
229
|
+
description: "Remember something durable about this project or team. USE WHEN a decision is made, a " +
|
|
230
|
+
"preference is stated, a system's shape is established, or somebody commits to something — " +
|
|
231
|
+
"anything a teammate would need to know next month. DO NOT USE for transient state, code, " +
|
|
232
|
+
"tool output, or anything already in a file. Pass customId (e.g. the session id) when you " +
|
|
233
|
+
"may save the same thing again: it updates instead of duplicating.",
|
|
234
|
+
inputSchema: saveMemoryInput,
|
|
235
|
+
annotations: ADDITIVE_ANNOTATIONS,
|
|
236
|
+
}, async (args) => {
|
|
237
|
+
const containerTag = tagFor(args.containerTag);
|
|
238
|
+
const response = await callDoor(deps, "/api/memory/save", {
|
|
239
|
+
content: args.content,
|
|
240
|
+
containerTag,
|
|
241
|
+
customId: args.customId,
|
|
242
|
+
mode: args.extract ? "extract" : "verbatim",
|
|
243
|
+
sourceRef: { kind: "mcp", device_id: deps.session.deviceId },
|
|
244
|
+
});
|
|
245
|
+
if (!response.ok)
|
|
246
|
+
return doorFailure("save", response);
|
|
247
|
+
const outcome = String(response.body.outcome ?? "created");
|
|
248
|
+
const id = response.body.id === null ? "—" : String(response.body.id ?? "—");
|
|
249
|
+
const embedNote = response.body.embedded === false && response.body.embedSkippedReason
|
|
250
|
+
? ` It was stored without a search vector (${String(response.body.embedSkippedReason)}), so it is findable by keyword and not yet by meaning.`
|
|
251
|
+
: "";
|
|
252
|
+
return textResult(`Memory ${outcome} in ${containerTag} (id: ${id}).${embedNote}`, response.body);
|
|
253
|
+
});
|
|
254
|
+
server.registerTool("update_memory", {
|
|
255
|
+
title: "Correct a memory",
|
|
256
|
+
description: "Replace what a memory says, keeping the old version as history. USE WHEN a remembered fact " +
|
|
257
|
+
"has CHANGED — a decision reversed, a preference moved, a name corrected. DO NOT USE to add a " +
|
|
258
|
+
"related fact (save it instead) and DO NOT USE to remove one (forget it instead). Search " +
|
|
259
|
+
"first: this needs the memory's id.",
|
|
260
|
+
inputSchema: updateMemoryInput,
|
|
261
|
+
annotations: ADDITIVE_ANNOTATIONS,
|
|
262
|
+
}, async (args) => {
|
|
263
|
+
const response = await callDoor(deps, "/api/memory/update", {
|
|
264
|
+
id: args.id,
|
|
265
|
+
content: args.content,
|
|
266
|
+
});
|
|
267
|
+
if (!response.ok)
|
|
268
|
+
return doorFailure("update", response);
|
|
269
|
+
return textResult(`Updated. The new memory is ${String(response.body.id ?? "")}, and ${String(response.body.supersededId ?? args.id)} is kept as its previous version.`, response.body);
|
|
270
|
+
});
|
|
271
|
+
server.registerTool("forget_memory", {
|
|
272
|
+
title: "Forget a memory",
|
|
273
|
+
description: "Remove a memory so it never comes back — including through later extraction, which is the " +
|
|
274
|
+
"part most memory tools get wrong. USE WHEN a person asks you to forget something, or a " +
|
|
275
|
+
"remembered fact turns out to be false. DO NOT USE for a fact that merely became outdated: " +
|
|
276
|
+
"update it, so the history survives. Give an id, or the exact text plus its containerTag.",
|
|
277
|
+
inputSchema: forgetMemoryInput,
|
|
278
|
+
annotations: DESTRUCTIVE_ANNOTATIONS,
|
|
279
|
+
}, async (args) => {
|
|
280
|
+
const response = await callDoor(deps, "/api/memory/forget", {
|
|
281
|
+
id: args.id,
|
|
282
|
+
content: args.content,
|
|
283
|
+
// Only defaulted for a content match, which is the case that would
|
|
284
|
+
// otherwise sweep the whole shelf. An id needs no space.
|
|
285
|
+
containerTag: args.content ? tagFor(args.containerTag) : args.containerTag,
|
|
286
|
+
reason: args.reason,
|
|
287
|
+
});
|
|
288
|
+
if (!response.ok)
|
|
289
|
+
return doorFailure("forget", response);
|
|
290
|
+
return textResult(String(response.body.message ?? "Forgotten."), response.body);
|
|
291
|
+
});
|
|
292
|
+
return server;
|
|
293
|
+
}
|
|
294
|
+
/** The container tag this process will use when a tool names none. */
|
|
295
|
+
export function defaultContainerTagFor(cwd, env) {
|
|
296
|
+
return resolveContainerTag({ cwd, env }).containerTag;
|
|
297
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is this machine, and where does it send a memory? (BLI-3580)
|
|
3
|
+
*
|
|
4
|
+
* The collector already paired this laptop: `cockpit login` wrote
|
|
5
|
+
* `~/.config/bli-cockpit/session.json` holding the dashboard URL, the device id
|
|
6
|
+
* and a `bli_dev_*` device token, and `resolveCaller` in the dashboard already
|
|
7
|
+
* accepts that token on the memory doors. So this server invents NO fourth
|
|
8
|
+
* credential — it reads the one the fleet already has.
|
|
9
|
+
*
|
|
10
|
+
* The file is read directly rather than through `@bli-cockpit/local-collector`,
|
|
11
|
+
* deliberately: this package is published on its own and installed by an MCP
|
|
12
|
+
* config on machines that may not have the collector's node_modules on hand.
|
|
13
|
+
* The path and the three field names are the contract, and they are pinned by
|
|
14
|
+
* a test.
|
|
15
|
+
*
|
|
16
|
+
* The token is never logged, never printed, never returned in an error. What
|
|
17
|
+
* travels is the reason a session could not be loaded, which is what an
|
|
18
|
+
* operator can act on.
|
|
19
|
+
*/
|
|
20
|
+
export declare const SESSION_RELATIVE_PATH: string[];
|
|
21
|
+
export type SessionFailure = "session_file_missing" | "session_file_unreadable" | "session_file_invalid" | "session_missing_token";
|
|
22
|
+
export interface MemorySession {
|
|
23
|
+
dashboardUrl: string;
|
|
24
|
+
deviceToken: string;
|
|
25
|
+
deviceId: string;
|
|
26
|
+
/** Where it came from, for the startup line. Never the token itself. */
|
|
27
|
+
source: "env" | "session_file";
|
|
28
|
+
}
|
|
29
|
+
export type LoadSessionResult = {
|
|
30
|
+
ok: true;
|
|
31
|
+
session: MemorySession;
|
|
32
|
+
} | {
|
|
33
|
+
ok: false;
|
|
34
|
+
reason: SessionFailure;
|
|
35
|
+
message: string;
|
|
36
|
+
};
|
|
37
|
+
export interface LoadSessionOptions {
|
|
38
|
+
env?: NodeJS.ProcessEnv;
|
|
39
|
+
homeDir?: string;
|
|
40
|
+
readFile?: (file: string) => string;
|
|
41
|
+
}
|
|
42
|
+
export declare function sessionFilePath(homeDir?: string): string;
|
|
43
|
+
/**
|
|
44
|
+
* Environment first (CI, a container, a machine that pairs some other way),
|
|
45
|
+
* then the paired session file. Both paths produce the same shape.
|
|
46
|
+
*/
|
|
47
|
+
export declare function loadMemorySession(options?: LoadSessionOptions): LoadSessionResult;
|
|
48
|
+
/** The collector's own default (`local-state.ts:51`), restated for a package
|
|
49
|
+
* that must install without it. If that one moves, this moves. */
|
|
50
|
+
export declare const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is this machine, and where does it send a memory? (BLI-3580)
|
|
3
|
+
*
|
|
4
|
+
* The collector already paired this laptop: `cockpit login` wrote
|
|
5
|
+
* `~/.config/bli-cockpit/session.json` holding the dashboard URL, the device id
|
|
6
|
+
* and a `bli_dev_*` device token, and `resolveCaller` in the dashboard already
|
|
7
|
+
* accepts that token on the memory doors. So this server invents NO fourth
|
|
8
|
+
* credential — it reads the one the fleet already has.
|
|
9
|
+
*
|
|
10
|
+
* The file is read directly rather than through `@bli-cockpit/local-collector`,
|
|
11
|
+
* deliberately: this package is published on its own and installed by an MCP
|
|
12
|
+
* config on machines that may not have the collector's node_modules on hand.
|
|
13
|
+
* The path and the three field names are the contract, and they are pinned by
|
|
14
|
+
* a test.
|
|
15
|
+
*
|
|
16
|
+
* The token is never logged, never printed, never returned in an error. What
|
|
17
|
+
* travels is the reason a session could not be loaded, which is what an
|
|
18
|
+
* operator can act on.
|
|
19
|
+
*/
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
export const SESSION_RELATIVE_PATH = [".config", "bli-cockpit", "session.json"];
|
|
24
|
+
export function sessionFilePath(homeDir = os.homedir()) {
|
|
25
|
+
return path.join(homeDir, ...SESSION_RELATIVE_PATH);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Environment first (CI, a container, a machine that pairs some other way),
|
|
29
|
+
* then the paired session file. Both paths produce the same shape.
|
|
30
|
+
*/
|
|
31
|
+
export function loadMemorySession(options = {}) {
|
|
32
|
+
const env = options.env ?? process.env;
|
|
33
|
+
const readFile = options.readFile ?? ((file) => fs.readFileSync(file, "utf8"));
|
|
34
|
+
const envToken = (env.BLI_MEMORY_DEVICE_TOKEN ?? "").trim();
|
|
35
|
+
if (envToken.length > 0) {
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
session: {
|
|
39
|
+
deviceToken: envToken,
|
|
40
|
+
deviceId: (env.BLI_MEMORY_DEVICE_ID ?? "env").trim() || "env",
|
|
41
|
+
dashboardUrl: normalizeUrl(env.BLI_MEMORY_DASHBOARD_URL ?? env.COCKPIT_DASHBOARD_URL ?? ""),
|
|
42
|
+
source: "env",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const file = sessionFilePath(options.homeDir ?? env.HOME ?? os.homedir());
|
|
47
|
+
let raw;
|
|
48
|
+
try {
|
|
49
|
+
raw = readFile(file);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
const missing = error?.code === "ENOENT";
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
reason: missing ? "session_file_missing" : "session_file_unreadable",
|
|
56
|
+
message: missing
|
|
57
|
+
? "This machine is not paired with Tower. Run `cockpit login` once, then restart your agent."
|
|
58
|
+
: "The Tower session file exists but could not be read. Run `cockpit login` again.",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(raw);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: "session_file_invalid",
|
|
69
|
+
message: "The Tower session file is not valid JSON. Run `cockpit login` again.",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const record = parsed && typeof parsed === "object" ? parsed : {};
|
|
73
|
+
const deviceToken = typeof record.device_token === "string" ? record.device_token.trim() : "";
|
|
74
|
+
if (deviceToken.length === 0) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
reason: "session_missing_token",
|
|
78
|
+
message: "The Tower session file carries no device token. Run `cockpit login` again.",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const envUrl = env.BLI_MEMORY_DASHBOARD_URL ?? env.COCKPIT_DASHBOARD_URL ?? "";
|
|
82
|
+
const fileUrl = typeof record.dashboard_url === "string" ? record.dashboard_url : "";
|
|
83
|
+
return {
|
|
84
|
+
ok: true,
|
|
85
|
+
session: {
|
|
86
|
+
deviceToken,
|
|
87
|
+
deviceId: typeof record.device_id === "string" ? record.device_id : "unknown",
|
|
88
|
+
// The env override wins so a developer can point one agent at a preview
|
|
89
|
+
// deployment without re-pairing the machine.
|
|
90
|
+
dashboardUrl: normalizeUrl(envUrl || fileUrl),
|
|
91
|
+
source: "session_file",
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** The collector's own default (`local-state.ts:51`), restated for a package
|
|
96
|
+
* that must install without it. If that one moves, this moves. */
|
|
97
|
+
export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
|
|
98
|
+
function normalizeUrl(value) {
|
|
99
|
+
const trimmed = value.trim().replace(/\/+$/, "");
|
|
100
|
+
return trimmed.length > 0 ? trimmed : DEFAULT_DASHBOARD_URL;
|
|
101
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bli-cockpit/memory-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "BLI Memory — an MCP server for the memory layer BLI owns (save, search, update, forget).",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"bli-memory-mcp": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist/",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"prepack": "rm -rf dist && tsc",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"start": "node dist/index.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
29
|
+
"zod": "^4.3.6"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^20",
|
|
33
|
+
"typescript": "^5",
|
|
34
|
+
"vitest": "^3.2.4"
|
|
35
|
+
}
|
|
36
|
+
}
|