agenthub-multiagent-mcp 1.11.1 → 1.12.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/CHANGELOG.md +23 -0
- package/package.json +2 -1
- package/scripts/memvid-smoke.ts +89 -0
- package/scripts/memvidBrain-smoke.ts +83 -0
- package/src/brain/backend.ts +37 -0
- package/src/brain/jsonlBrain.ts +62 -0
- package/src/brain/memvidBrain.test.ts +136 -0
- package/src/brain/memvidBrain.ts +285 -0
- package/src/brain/migrate.test.ts +164 -0
- package/src/brain/migrate.ts +213 -0
- package/src/brain/types.ts +45 -0
- package/src/hooks/brainCapture.ts +11 -9
- package/src/index.ts +21 -0
- package/src/tools/index.ts +9 -5
- package/src/tools/tools.test.ts +2 -2
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `agenthub-multiagent-mcp` are documented here.
|
|
4
|
+
|
|
5
|
+
## 1.12.0 — 2026-04-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Memvid backend** is now the default local brain store. Sessions are written to `~/.claude/brains/<agent>.mv2` via `@memvid/sdk@^2.0.159` with lexical (BM25) search and metadata-preserving recall-by-session.
|
|
9
|
+
- **JSONL fallback** retained for compatibility. Set `AGENTHUB_BRAIN_BACKEND=jsonl` to keep using `~/.claude/brains/<agent>.jsonl`.
|
|
10
|
+
- **Auto-migration at boot.** When the memvid backend is active, any agent with an existing `.jsonl` brain is migrated once to `.mv2`. Source is frozen to `<agent>.jsonl.frozen.<ISO>` and left in place. Migration is atomic (temp agent id + count verification + `renameSync`) and idempotent.
|
|
11
|
+
- **Windows semantic search is opt-in.** Memvid's local embedding models (bge-small, nomic) do not ship ONNX runtime builds for Windows, so the default install is BM25-only. To enable semantic search, set `OPENAI_BASE_URL` and `OPENAI_API_KEY` to any OpenAI-compatible `/v1/embeddings` endpoint (OpenRouter verified).
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `@memvid/sdk` dependency adds ~250 transitive packages (langchain, llamaindex, ai, langgraph, OCR/PDF tooling). `npm audit` reports 10 vulnerabilities in the tree (3 moderate, 7 high); none affect the code paths we use. Accepted trade-off for persistent vector-capable local brain.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Corrected session-189's premature JSONL pivot: `@memvid/sdk@2.0.159` is publicly available on npm. See `openspec/archive/add-org-brain/design.md` ADR D3.1.
|
|
18
|
+
|
|
19
|
+
## 1.11.1 — 2026-04-17
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- WebSocket client now sends `X-API-Key` as a header instead of a query param, aligning with server-side auth convention.
|
|
23
|
+
- FTS5 query sanitizer in Go server prevents malformed queries from 500-ing the search endpoint.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agenthub-multiagent-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "MCP server for AgentHub multi-agent communication",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@anthropic-ai/sdk": "^0.79.0",
|
|
47
|
+
"@memvid/sdk": "^2.0.159",
|
|
47
48
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
48
49
|
"form-data": "^4.0.5",
|
|
49
50
|
"open": "^11.0.0",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throwaway smoke test for @memvid/sdk (§1.1 of finish-agenthub-v1-backlog).
|
|
3
|
+
*
|
|
4
|
+
* Creates a .mv2, writes 3 session-like documents with session_number in
|
|
5
|
+
* metadata, lexical-searches, and recalls by session_number via timeline
|
|
6
|
+
* filtering. Verifies the SDK works end-to-end on Sumit's Windows machine.
|
|
7
|
+
*
|
|
8
|
+
* Usage (from mcp-server/):
|
|
9
|
+
* npx tsx ../scripts/memvid-smoke.ts
|
|
10
|
+
*
|
|
11
|
+
* Cleans up its .mv2 on exit.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { create } from "@memvid/sdk";
|
|
15
|
+
import { existsSync, unlinkSync } from "fs";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { tmpdir } from "os";
|
|
18
|
+
|
|
19
|
+
const mvPath = join(tmpdir(), `agenthub-memvid-smoke-${Date.now()}.mv2`);
|
|
20
|
+
|
|
21
|
+
async function main() {
|
|
22
|
+
console.log(`[smoke] creating ${mvPath}`);
|
|
23
|
+
const mv = await create(mvPath);
|
|
24
|
+
|
|
25
|
+
const sessions = [
|
|
26
|
+
{
|
|
27
|
+
title: "Session 187 — intel layer deploy",
|
|
28
|
+
label: "agenthub-agent",
|
|
29
|
+
text: "Deployed intel-layer indexing runs. Fixed postprocess XML. Verified /api/intel/search returns hits.",
|
|
30
|
+
metadata: { session_number: 187, timestamp: "2026-04-10T12:00:00Z" },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
title: "Session 189 — org brain staging",
|
|
34
|
+
label: "agenthub-agent",
|
|
35
|
+
text: "Shipped org brain models and store to staging. Seeded 85 sessions and 21 knowledge entries.",
|
|
36
|
+
metadata: { session_number: 189, timestamp: "2026-04-15T09:00:00Z" },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
title: "Session 190 — FTS5 sanitizer fix",
|
|
40
|
+
label: "agenthub-agent",
|
|
41
|
+
text: "Hardened FTS5 query sanitizer against punctuation and reserved tokens. Migrated WS auth to X-API-Key header.",
|
|
42
|
+
metadata: { session_number: 190, timestamp: "2026-04-17T10:00:00Z" },
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
console.log(`[smoke] putting ${sessions.length} documents`);
|
|
47
|
+
const frameIds = await mv.putMany(sessions);
|
|
48
|
+
console.log(`[smoke] frameIds=${JSON.stringify(frameIds)}`);
|
|
49
|
+
|
|
50
|
+
console.log(`[smoke] searching 'FTS5 sanitizer' (lexical)`);
|
|
51
|
+
const findRes = await mv.find("FTS5 sanitizer", { k: 5, mode: "lex" });
|
|
52
|
+
console.log(`[smoke] hits=${findRes.hits.length}`);
|
|
53
|
+
console.log(`[smoke] full hit[0]=${JSON.stringify(findRes.hits[0], null, 2)}`);
|
|
54
|
+
|
|
55
|
+
console.log(`[smoke] recall session 189 via find("session_number: 189")`);
|
|
56
|
+
const recallRes = await mv.find("session_number: 189", { k: 1, mode: "lex" });
|
|
57
|
+
const hit189 = recallRes.hits[0];
|
|
58
|
+
const found189 = hit189 && hit189.title?.includes("189");
|
|
59
|
+
console.log(`[smoke] session 189 recalled: ${found189 ? "YES" : "NO"} title=${hit189?.title}`);
|
|
60
|
+
|
|
61
|
+
console.log(`[smoke] timeline() length check`);
|
|
62
|
+
const timeline = await mv.timeline({ limit: 50 });
|
|
63
|
+
console.log(`[smoke] timeline length=${timeline.length}`);
|
|
64
|
+
|
|
65
|
+
console.log(`[smoke] stats()`);
|
|
66
|
+
const stats = await mv.stats();
|
|
67
|
+
console.log(`[smoke] stats=${JSON.stringify(stats)}`);
|
|
68
|
+
|
|
69
|
+
await mv.seal();
|
|
70
|
+
|
|
71
|
+
// Cleanup
|
|
72
|
+
if (existsSync(mvPath)) {
|
|
73
|
+
unlinkSync(mvPath);
|
|
74
|
+
console.log(`[smoke] cleaned up ${mvPath}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lexOk = findRes.hits.length > 0 && findRes.hits[0].title?.includes("190");
|
|
78
|
+
const recallOk = !!found189;
|
|
79
|
+
if (!lexOk || !recallOk) {
|
|
80
|
+
console.error(`[smoke] FAIL lexOk=${lexOk} recallOk=${recallOk}`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
console.log(`[smoke] PASS`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
main().catch((err) => {
|
|
87
|
+
console.error(`[smoke] ERROR`, err);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end smoke test for MemvidBrain (§1.2).
|
|
3
|
+
* Writes to a temp brain dir to avoid touching real ~/.claude/brains/.
|
|
4
|
+
*/
|
|
5
|
+
import { MemvidBrain } from "../src/brain/memvidBrain.js";
|
|
6
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { tmpdir } from "os";
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
// Redirect homedir via env so the brain writes under a temp dir.
|
|
12
|
+
// NOTE: memvidBrain.ts reads homedir() at call time; simulating with HOMEPATH/HOME is messy on Windows.
|
|
13
|
+
// Instead, we monkey-patch by copying the class logic — actually easier: just run on the default
|
|
14
|
+
// location under a sanitized test-agent ID we'll clean up after.
|
|
15
|
+
const testAgent = `smoke-${Date.now()}`;
|
|
16
|
+
const brain = new MemvidBrain();
|
|
17
|
+
const path = brain.getBrainPath(testAgent);
|
|
18
|
+
console.log(`[smoke] path=${path}`);
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const n1 = await brain.appendSession(testAgent, {
|
|
22
|
+
summary: "intel deploy",
|
|
23
|
+
content: "Deployed intel-layer indexing runs to staging.",
|
|
24
|
+
});
|
|
25
|
+
console.log(`[smoke] appended session ${n1}`);
|
|
26
|
+
|
|
27
|
+
const n2 = await brain.appendSession(testAgent, {
|
|
28
|
+
summary: "org brain models",
|
|
29
|
+
content: "Shipped org brain models and store to staging. Seeded 85 sessions.",
|
|
30
|
+
files_changed: ["server/internal/brain/extract.go"],
|
|
31
|
+
outcome: "success",
|
|
32
|
+
});
|
|
33
|
+
console.log(`[smoke] appended session ${n2}`);
|
|
34
|
+
|
|
35
|
+
const n3 = await brain.appendSession(testAgent, {
|
|
36
|
+
summary: "FTS5 sanitizer",
|
|
37
|
+
content: "Hardened FTS5 query sanitizer against punctuation and reserved tokens.",
|
|
38
|
+
});
|
|
39
|
+
console.log(`[smoke] appended session ${n3}`);
|
|
40
|
+
|
|
41
|
+
const count = await brain.getSessionCount(testAgent);
|
|
42
|
+
console.log(`[smoke] session count=${count}`);
|
|
43
|
+
|
|
44
|
+
const searchRes = await brain.search(testAgent, "FTS5 sanitizer");
|
|
45
|
+
console.log(`[smoke] search hits=${searchRes.length}`);
|
|
46
|
+
for (const hit of searchRes) {
|
|
47
|
+
console.log(` session=${hit.session_number} summary="${hit.summary}" score=${hit.score.toFixed(2)}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const recalled = await brain.recall(testAgent, n2);
|
|
51
|
+
console.log(`[smoke] recalled session ${n2}:`);
|
|
52
|
+
console.log(` summary=${recalled?.summary}`);
|
|
53
|
+
console.log(` content=${recalled?.content}`);
|
|
54
|
+
console.log(` outcome=${recalled?.outcome}`);
|
|
55
|
+
console.log(` files_changed=${JSON.stringify(recalled?.files_changed)}`);
|
|
56
|
+
|
|
57
|
+
const list = await brain.listBrains();
|
|
58
|
+
const ours = list.find((b) => b.agentId.startsWith("smoke-"));
|
|
59
|
+
console.log(`[smoke] listBrains found ours: ${ours ? `sessions=${ours.sessions}` : "NO"}`);
|
|
60
|
+
|
|
61
|
+
const lexOk = searchRes.some((h) => h.session_number === n3);
|
|
62
|
+
const recallOk = recalled?.summary === "org brain models" && recalled?.outcome === "success"
|
|
63
|
+
&& recalled?.files_changed?.[0] === "server/internal/brain/extract.go";
|
|
64
|
+
const listOk = !!ours && ours.sessions === 3;
|
|
65
|
+
if (!lexOk || !recallOk || !listOk) {
|
|
66
|
+
console.error(`[smoke] FAIL lexOk=${lexOk} recallOk=${recallOk} listOk=${listOk}`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
console.log(`[smoke] PASS`);
|
|
70
|
+
} finally {
|
|
71
|
+
try {
|
|
72
|
+
rmSync(path, { force: true });
|
|
73
|
+
console.log(`[smoke] cleaned up ${path}`);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
console.warn(`[smoke] cleanup failed: ${e}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
main().catch((err) => {
|
|
81
|
+
console.error(`[smoke] ERROR`, err);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain backend selector (§1.3 finish-agenthub-v1-backlog).
|
|
3
|
+
*
|
|
4
|
+
* `AGENTHUB_BRAIN_BACKEND=memvid` (default) → MemvidBrain (.mv2 files)
|
|
5
|
+
* `AGENTHUB_BRAIN_BACKEND=jsonl` → JsonlBrain (legacy .jsonl files)
|
|
6
|
+
*
|
|
7
|
+
* The selector is lazy + cached so tests can flip the env var before first use.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { BrainBackend } from "./types.js";
|
|
11
|
+
import { memvidBrain } from "./memvidBrain.js";
|
|
12
|
+
import { jsonlBrain } from "./jsonlBrain.js";
|
|
13
|
+
|
|
14
|
+
let cached: BrainBackend | null = null;
|
|
15
|
+
let cachedBackendName: string | null = null;
|
|
16
|
+
|
|
17
|
+
export function getBrain(): BrainBackend {
|
|
18
|
+
const requested = (process.env.AGENTHUB_BRAIN_BACKEND ?? "memvid").toLowerCase();
|
|
19
|
+
if (cached && cachedBackendName === requested) return cached;
|
|
20
|
+
|
|
21
|
+
switch (requested) {
|
|
22
|
+
case "jsonl":
|
|
23
|
+
cached = jsonlBrain;
|
|
24
|
+
break;
|
|
25
|
+
case "memvid":
|
|
26
|
+
default:
|
|
27
|
+
cached = memvidBrain;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
cachedBackendName = requested;
|
|
31
|
+
return cached;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resetBrainBackendCache(): void {
|
|
35
|
+
cached = null;
|
|
36
|
+
cachedBackendName = null;
|
|
37
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONL Brain — BrainBackend adapter over the existing localBrain.ts functions.
|
|
3
|
+
*
|
|
4
|
+
* Preserved as the fallback when AGENTHUB_BRAIN_BACKEND=jsonl, and as a
|
|
5
|
+
* disaster-recovery path if memvid proves unstable on a given platform.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as localBrain from "./localBrain.js";
|
|
9
|
+
import type { BrainBackend, SessionInput, SessionRecord, SearchHit, BrainInfo } from "./types.js";
|
|
10
|
+
|
|
11
|
+
export class JsonlBrain implements BrainBackend {
|
|
12
|
+
getBrainPath(agentId: string): string {
|
|
13
|
+
// localBrain has a private getBrainPath; recover it via listBrains if needed.
|
|
14
|
+
// For now, we do not publicly expose localBrain's path helper; return a best-effort path.
|
|
15
|
+
const entry = localBrain.listBrains().find((b) => b.agentId === agentId);
|
|
16
|
+
if (entry) return entry.path;
|
|
17
|
+
// Fallback: mirror localBrain's sanitization for callers like the migration
|
|
18
|
+
// script that need a target path before any session exists.
|
|
19
|
+
const safe = agentId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
20
|
+
const { join } = require("path");
|
|
21
|
+
const { homedir } = require("os");
|
|
22
|
+
return join(homedir(), ".claude", "brains", `${safe}.jsonl`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async appendSession(agentId: string, session: SessionInput): Promise<number> {
|
|
26
|
+
return localBrain.appendSession(
|
|
27
|
+
agentId,
|
|
28
|
+
session.summary,
|
|
29
|
+
session.content,
|
|
30
|
+
session.files_changed,
|
|
31
|
+
session.outcome
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async search(agentId: string, query: string, limit = 5): Promise<SearchHit[]> {
|
|
36
|
+
return localBrain.search(agentId, query, limit);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async recall(agentId: string, sessionNumber: number): Promise<SessionRecord | null> {
|
|
40
|
+
const entry = localBrain.recall(agentId, sessionNumber);
|
|
41
|
+
if (!entry) return null;
|
|
42
|
+
return {
|
|
43
|
+
session_number: entry.session_number,
|
|
44
|
+
agent_id: entry.agent_id,
|
|
45
|
+
timestamp: entry.timestamp,
|
|
46
|
+
summary: entry.summary,
|
|
47
|
+
content: entry.content,
|
|
48
|
+
files_changed: entry.files_changed,
|
|
49
|
+
outcome: entry.outcome,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async getSessionCount(agentId: string): Promise<number> {
|
|
54
|
+
return localBrain.getSessionCount(agentId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async listBrains(): Promise<BrainInfo[]> {
|
|
58
|
+
return localBrain.listBrains();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const jsonlBrain = new JsonlBrain();
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for MemvidBrain (§1.5 finish-agenthub-v1-backlog).
|
|
3
|
+
*
|
|
4
|
+
* Uses a per-test temp dir as the fake HOME, so `~/.claude/brains/` maps under
|
|
5
|
+
* that temp dir and real user data is never touched.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
|
|
13
|
+
let tempHome: string;
|
|
14
|
+
|
|
15
|
+
function setFakeHome(dir: string) {
|
|
16
|
+
// memvidBrain reads `os.homedir()` lazily — patch it via env + module-level mock.
|
|
17
|
+
if (process.platform === "win32") {
|
|
18
|
+
process.env.USERPROFILE = dir;
|
|
19
|
+
} else {
|
|
20
|
+
process.env.HOME = dir;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Mock os.homedir before the modules under test are imported.
|
|
25
|
+
vi.mock("os", async () => {
|
|
26
|
+
const actual = await vi.importActual<typeof import("os")>("os");
|
|
27
|
+
return {
|
|
28
|
+
...actual,
|
|
29
|
+
homedir: () => process.env.__TEST_HOME__ ?? actual.homedir(),
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
async function importMemvidBrain() {
|
|
34
|
+
// Import lazily so the os mock is applied before the module reads homedir.
|
|
35
|
+
const mod = await import("./memvidBrain.js");
|
|
36
|
+
return mod;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("MemvidBrain", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
tempHome = mkdtempSync(join(tmpdir(), "memvid-brain-test-"));
|
|
42
|
+
process.env.__TEST_HOME__ = tempHome;
|
|
43
|
+
setFakeHome(tempHome);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
try {
|
|
48
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
49
|
+
} catch {
|
|
50
|
+
// best effort
|
|
51
|
+
}
|
|
52
|
+
delete process.env.__TEST_HOME__;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("round-trip: append, search, recall", async () => {
|
|
56
|
+
const { MemvidBrain } = await importMemvidBrain();
|
|
57
|
+
const brain = new MemvidBrain();
|
|
58
|
+
const agent = "test-agent-1";
|
|
59
|
+
|
|
60
|
+
const sessions = [
|
|
61
|
+
{ summary: "intel deploy", content: "Deployed intel-layer runs." },
|
|
62
|
+
{ summary: "org brain staging", content: "Shipped org brain models and store." },
|
|
63
|
+
{ summary: "FTS5 sanitizer", content: "Hardened FTS5 query sanitizer against punctuation." },
|
|
64
|
+
{ summary: "WS header auth", content: "Migrated WebSocket API key to X-API-Key header." },
|
|
65
|
+
{ summary: "openspec cleanup", content: "Archived 10 changes, reconciled tasks." },
|
|
66
|
+
];
|
|
67
|
+
const appended: number[] = [];
|
|
68
|
+
for (const s of sessions) {
|
|
69
|
+
const n = await brain.appendSession(agent, s);
|
|
70
|
+
appended.push(n);
|
|
71
|
+
}
|
|
72
|
+
expect(appended).toEqual([1, 2, 3, 4, 5]);
|
|
73
|
+
expect(await brain.getSessionCount(agent)).toBe(5);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("search returns the expected session for a lexical query", async () => {
|
|
77
|
+
const { MemvidBrain } = await importMemvidBrain();
|
|
78
|
+
const brain = new MemvidBrain();
|
|
79
|
+
const agent = "test-agent-2";
|
|
80
|
+
|
|
81
|
+
await brain.appendSession(agent, { summary: "intel deploy", content: "Deployed intel-layer runs." });
|
|
82
|
+
await brain.appendSession(agent, {
|
|
83
|
+
summary: "FTS5 sanitizer",
|
|
84
|
+
content: "Hardened FTS5 query sanitizer against reserved tokens.",
|
|
85
|
+
});
|
|
86
|
+
await brain.appendSession(agent, { summary: "openspec", content: "Archived changes." });
|
|
87
|
+
|
|
88
|
+
const hits = await brain.search(agent, "FTS5 sanitizer", 5);
|
|
89
|
+
expect(hits.length).toBeGreaterThan(0);
|
|
90
|
+
expect(hits[0].summary).toBe("FTS5 sanitizer");
|
|
91
|
+
expect(hits[0].session_number).toBe(2);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("recall returns full record for a known session number", async () => {
|
|
95
|
+
const { MemvidBrain } = await importMemvidBrain();
|
|
96
|
+
const brain = new MemvidBrain();
|
|
97
|
+
const agent = "test-agent-3";
|
|
98
|
+
|
|
99
|
+
await brain.appendSession(agent, { summary: "s1", content: "c1" });
|
|
100
|
+
const n = await brain.appendSession(agent, {
|
|
101
|
+
summary: "target",
|
|
102
|
+
content: "the content we want to recall",
|
|
103
|
+
files_changed: ["a.go", "b.ts"],
|
|
104
|
+
outcome: "success",
|
|
105
|
+
});
|
|
106
|
+
await brain.appendSession(agent, { summary: "s3", content: "c3" });
|
|
107
|
+
|
|
108
|
+
const recalled = await brain.recall(agent, n);
|
|
109
|
+
expect(recalled).not.toBeNull();
|
|
110
|
+
expect(recalled?.summary).toBe("target");
|
|
111
|
+
expect(recalled?.content).toBe("the content we want to recall");
|
|
112
|
+
expect(recalled?.outcome).toBe("success");
|
|
113
|
+
expect(recalled?.files_changed).toEqual(["a.go", "b.ts"]);
|
|
114
|
+
expect(recalled?.session_number).toBe(n);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("empty brain: search returns [] without error", async () => {
|
|
118
|
+
const { MemvidBrain } = await importMemvidBrain();
|
|
119
|
+
const brain = new MemvidBrain();
|
|
120
|
+
const hits = await brain.search("never-registered-agent", "anything");
|
|
121
|
+
expect(hits).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("empty brain: recall returns null without error", async () => {
|
|
125
|
+
const { MemvidBrain } = await importMemvidBrain();
|
|
126
|
+
const brain = new MemvidBrain();
|
|
127
|
+
const record = await brain.recall("never-registered-agent", 1);
|
|
128
|
+
expect(record).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("empty brain: getSessionCount returns 0", async () => {
|
|
132
|
+
const { MemvidBrain } = await importMemvidBrain();
|
|
133
|
+
const brain = new MemvidBrain();
|
|
134
|
+
expect(await brain.getSessionCount("never-registered-agent")).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memvid Brain — .mv2-backed per-agent session store (§1.2 finish-agenthub-v1-backlog).
|
|
3
|
+
*
|
|
4
|
+
* Storage: ~/.claude/brains/<agent-id>.mv2
|
|
5
|
+
*
|
|
6
|
+
* Encoding conventions (session_number must survive a round-trip through memvid's
|
|
7
|
+
* document model, which stores metadata as inline "key: value" text lines):
|
|
8
|
+
* - title: `[session <N>] <summary>` — N is extractable via regex
|
|
9
|
+
* - text: full session content — indexed by BM25
|
|
10
|
+
* - metadata: { session_number, agent_id, timestamp, files_changed (JSON), outcome, summary }
|
|
11
|
+
* Memvid serializes each metadata field as a "key: value" line appended to the
|
|
12
|
+
* document's searchable body, so they round-trip through find().text.
|
|
13
|
+
*
|
|
14
|
+
* Search is lexical (BM25) by default — works everywhere including Windows.
|
|
15
|
+
* Semantic search is opt-in via AGENTHUB_BRAIN_EMBEDDINGS=openai|openrouter and the
|
|
16
|
+
* usual OpenAI-compatible env vars. Not wired in this file yet (future phase).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { use } from "@memvid/sdk";
|
|
20
|
+
import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
21
|
+
import { join } from "path";
|
|
22
|
+
import { homedir } from "os";
|
|
23
|
+
|
|
24
|
+
import type { BrainBackend, SessionInput, SessionRecord, SearchHit, BrainInfo } from "./types.js";
|
|
25
|
+
|
|
26
|
+
const BRAIN_DIR_NAME = "brains";
|
|
27
|
+
const SESSION_TITLE_RE = /^\[session\s+(\d+)\]\s*(.*)$/i;
|
|
28
|
+
|
|
29
|
+
function getBrainsDir(): string {
|
|
30
|
+
return join(homedir(), ".claude", BRAIN_DIR_NAME);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sanitizeAgentId(agentId: string): string {
|
|
34
|
+
return agentId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ensureBrainsDir(): void {
|
|
38
|
+
const dir = getBrainsDir();
|
|
39
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getBrainPath(agentId: string): string {
|
|
43
|
+
return join(getBrainsDir(), `${sanitizeAgentId(agentId)}.mv2`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function openForWrite(path: string) {
|
|
47
|
+
return use("basic", path, { mode: "auto" });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function openForRead(path: string) {
|
|
51
|
+
if (!existsSync(path)) return null;
|
|
52
|
+
return use("basic", path, { mode: "open", readOnly: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Memvid treats `seal()` on a readOnly handle as a mutation and throws.
|
|
57
|
+
* We still need to release the file handle, but there is no dedicated close()
|
|
58
|
+
* for readOnly handles. Relying on GC is acceptable — the N-API layer releases
|
|
59
|
+
* the Rust resources when the JS wrapper is collected.
|
|
60
|
+
*/
|
|
61
|
+
async function closeRead(_mv: unknown): Promise<void> {
|
|
62
|
+
// no-op; see comment above
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Parse `[session N] summary` title. Returns null if the title doesn't match.
|
|
67
|
+
*/
|
|
68
|
+
function parseTitle(title: string | undefined): { sessionNumber: number; summary: string } | null {
|
|
69
|
+
if (!title) return null;
|
|
70
|
+
const m = SESSION_TITLE_RE.exec(title.trim());
|
|
71
|
+
if (!m) return null;
|
|
72
|
+
return { sessionNumber: Number(m[1]), summary: m[2] ?? "" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Extract a metadata value from memvid's serialized hit text.
|
|
77
|
+
*
|
|
78
|
+
* Memvid appends metadata as `key: <JSON-encoded-value>` tokens to the document
|
|
79
|
+
* body, whitespace-separated (no newlines). Each value is a JSON string/array/
|
|
80
|
+
* object/number/bool/null.
|
|
81
|
+
*
|
|
82
|
+
* Returns the decoded JS value (string, array, etc.) or null if not present.
|
|
83
|
+
*/
|
|
84
|
+
function extractMetaValue(text: string | undefined, key: string): unknown {
|
|
85
|
+
if (!text) return null;
|
|
86
|
+
// Match `<whitespace or start><key>:<space>` then capture a JSON value.
|
|
87
|
+
// JSON values are: "..." | [...] | {...} | number | true/false/null | unquoted word.
|
|
88
|
+
const re = new RegExp(
|
|
89
|
+
`(?:^|\\s)${key}:\\s*("(?:[^"\\\\]|\\\\.)*"|\\[[^\\]]*\\]|\\{[^}]*\\}|-?\\d+(?:\\.\\d+)?|true|false|null|[^\\s]+)`
|
|
90
|
+
);
|
|
91
|
+
const m = re.exec(text);
|
|
92
|
+
if (!m) return null;
|
|
93
|
+
const raw = m[1];
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(raw);
|
|
96
|
+
} catch {
|
|
97
|
+
return raw;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractMetaString(text: string | undefined, key: string): string | null {
|
|
102
|
+
const v = extractMetaValue(text, key);
|
|
103
|
+
return typeof v === "string" ? v : v != null ? String(v) : null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function extractMetaStringArray(text: string | undefined, key: string): string[] | undefined {
|
|
107
|
+
const v = extractMetaValue(text, key);
|
|
108
|
+
return Array.isArray(v) ? v.map(String) : undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function hitToRecord(agentId: string, hit: any): SessionRecord | null {
|
|
112
|
+
const parsed = parseTitle(hit?.title);
|
|
113
|
+
if (!parsed) return null;
|
|
114
|
+
const text: string = hit?.text ?? "";
|
|
115
|
+
const timestamp = extractMetaString(text, "timestamp") ?? new Date().toISOString();
|
|
116
|
+
const outcomeRaw = extractMetaString(text, "outcome");
|
|
117
|
+
const outcome = outcomeRaw && outcomeRaw.length > 0 ? outcomeRaw : undefined;
|
|
118
|
+
const filesChanged = extractMetaStringArray(text, "files_changed");
|
|
119
|
+
const content = stripMetadataTokens(text);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
session_number: parsed.sessionNumber,
|
|
123
|
+
agent_id: agentId,
|
|
124
|
+
timestamp,
|
|
125
|
+
summary: parsed.summary,
|
|
126
|
+
content,
|
|
127
|
+
files_changed: filesChanged && filesChanged.length > 0 ? filesChanged : undefined,
|
|
128
|
+
outcome,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Strip memvid's auto-appended `<key>: <JSON>` metadata tail from a hit's text
|
|
134
|
+
* to recover the original content. Cuts at the first known metadata key we see.
|
|
135
|
+
*/
|
|
136
|
+
function stripMetadataTokens(text: string): string {
|
|
137
|
+
if (!text) return "";
|
|
138
|
+
// Find the earliest occurrence of any known trailing-metadata sentinel.
|
|
139
|
+
const sentinels = [
|
|
140
|
+
/\btitle:\s/,
|
|
141
|
+
/\btags:\s/,
|
|
142
|
+
/\blabels:\s/,
|
|
143
|
+
/\bextractous_metadata:\s/,
|
|
144
|
+
/\bsession_number:\s/,
|
|
145
|
+
/\bagent_id:\s/,
|
|
146
|
+
/\btimestamp:\s/,
|
|
147
|
+
/\bfiles_changed:\s/,
|
|
148
|
+
/\boutcome:\s/,
|
|
149
|
+
/\bsummary:\s/,
|
|
150
|
+
];
|
|
151
|
+
let cutIdx = text.length;
|
|
152
|
+
for (const re of sentinels) {
|
|
153
|
+
const m = re.exec(text);
|
|
154
|
+
if (m && m.index < cutIdx) cutIdx = m.index;
|
|
155
|
+
}
|
|
156
|
+
return text.slice(0, cutIdx).trim();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function nextSessionNumber(agentId: string): Promise<number> {
|
|
160
|
+
const path = getBrainPath(agentId);
|
|
161
|
+
const mv = await openForRead(path);
|
|
162
|
+
if (!mv) return 1;
|
|
163
|
+
try {
|
|
164
|
+
const stats = await mv.stats();
|
|
165
|
+
return (stats?.frame_count ?? 0) + 1;
|
|
166
|
+
} finally {
|
|
167
|
+
await closeRead(mv);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export class MemvidBrain implements BrainBackend {
|
|
172
|
+
getBrainPath(agentId: string): string {
|
|
173
|
+
return getBrainPath(agentId);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async appendSession(agentId: string, session: SessionInput): Promise<number> {
|
|
177
|
+
ensureBrainsDir();
|
|
178
|
+
const sessionNumber = session.session_number ?? (await nextSessionNumber(agentId));
|
|
179
|
+
const timestamp = session.timestamp ?? new Date().toISOString();
|
|
180
|
+
const path = getBrainPath(agentId);
|
|
181
|
+
const mv = await openForWrite(path);
|
|
182
|
+
try {
|
|
183
|
+
await mv.put({
|
|
184
|
+
title: `[session ${sessionNumber}] ${session.summary}`,
|
|
185
|
+
label: sanitizeAgentId(agentId),
|
|
186
|
+
text: session.content,
|
|
187
|
+
metadata: {
|
|
188
|
+
session_number: sessionNumber,
|
|
189
|
+
agent_id: agentId,
|
|
190
|
+
timestamp,
|
|
191
|
+
summary: session.summary,
|
|
192
|
+
files_changed: session.files_changed ?? [],
|
|
193
|
+
outcome: session.outcome ?? "",
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
} finally {
|
|
197
|
+
await mv.seal();
|
|
198
|
+
}
|
|
199
|
+
return sessionNumber;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async search(agentId: string, query: string, limit = 5): Promise<SearchHit[]> {
|
|
203
|
+
const path = getBrainPath(agentId);
|
|
204
|
+
const mv = await openForRead(path);
|
|
205
|
+
if (!mv) return [];
|
|
206
|
+
try {
|
|
207
|
+
const res = await mv.find(query, { k: limit, mode: "lex" });
|
|
208
|
+
const hits: SearchHit[] = [];
|
|
209
|
+
for (const rawHit of res.hits ?? []) {
|
|
210
|
+
const hit = rawHit as any;
|
|
211
|
+
const parsed = parseTitle(hit?.title);
|
|
212
|
+
if (!parsed) continue;
|
|
213
|
+
const timestamp = extractMetaString(hit?.text, "timestamp") ?? "";
|
|
214
|
+
hits.push({
|
|
215
|
+
session_number: parsed.sessionNumber,
|
|
216
|
+
timestamp,
|
|
217
|
+
summary: parsed.summary,
|
|
218
|
+
snippet: hit?.snippet ?? "",
|
|
219
|
+
score: typeof hit?.score === "number" ? hit.score : 0,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return hits;
|
|
223
|
+
} finally {
|
|
224
|
+
await closeRead(mv);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async recall(agentId: string, sessionNumber: number): Promise<SessionRecord | null> {
|
|
229
|
+
const path = getBrainPath(agentId);
|
|
230
|
+
const mv = await openForRead(path);
|
|
231
|
+
if (!mv) return null;
|
|
232
|
+
try {
|
|
233
|
+
// Match the title prefix. `[session N]` is distinctive enough.
|
|
234
|
+
const res = await mv.find(`[session ${sessionNumber}]`, { k: 5, mode: "lex" });
|
|
235
|
+
for (const hit of res.hits ?? []) {
|
|
236
|
+
const parsed = parseTitle(hit?.title);
|
|
237
|
+
if (parsed?.sessionNumber === sessionNumber) {
|
|
238
|
+
return hitToRecord(agentId, hit);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
} finally {
|
|
243
|
+
await closeRead(mv);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async getSessionCount(agentId: string): Promise<number> {
|
|
248
|
+
const path = getBrainPath(agentId);
|
|
249
|
+
const mv = await openForRead(path);
|
|
250
|
+
if (!mv) return 0;
|
|
251
|
+
try {
|
|
252
|
+
const stats = await mv.stats();
|
|
253
|
+
return stats?.frame_count ?? 0;
|
|
254
|
+
} finally {
|
|
255
|
+
await closeRead(mv);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async listBrains(): Promise<BrainInfo[]> {
|
|
260
|
+
const dir = getBrainsDir();
|
|
261
|
+
if (!existsSync(dir)) return [];
|
|
262
|
+
const result: BrainInfo[] = [];
|
|
263
|
+
for (const file of readdirSync(dir)) {
|
|
264
|
+
if (!file.endsWith(".mv2")) continue;
|
|
265
|
+
const agentId = file.slice(0, -".mv2".length);
|
|
266
|
+
const fullPath = join(dir, file);
|
|
267
|
+
let sessions = 0;
|
|
268
|
+
try {
|
|
269
|
+
const mv = await openForRead(fullPath);
|
|
270
|
+
if (mv) {
|
|
271
|
+
const stats = await mv.stats();
|
|
272
|
+
sessions = stats?.frame_count ?? 0;
|
|
273
|
+
await closeRead(mv);
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
// If a .mv2 is locked or corrupted, still list it with 0 sessions.
|
|
277
|
+
sessions = 0;
|
|
278
|
+
}
|
|
279
|
+
result.push({ agentId, sessions, path: fullPath });
|
|
280
|
+
}
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export const memvidBrain = new MemvidBrain();
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for JSONL → memvid migration (§1.5 finish-agenthub-v1-backlog).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, readdirSync } from "fs";
|
|
7
|
+
import { tmpdir } from "os";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
|
|
10
|
+
let tempHome: string;
|
|
11
|
+
|
|
12
|
+
vi.mock("os", async () => {
|
|
13
|
+
const actual = await vi.importActual<typeof import("os")>("os");
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
homedir: () => process.env.__TEST_HOME__ ?? actual.homedir(),
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function brainsDirIn(home: string): string {
|
|
21
|
+
return join(home, ".claude", "brains");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function seedJsonl(agent: string, entries: Array<Record<string, unknown>>, extraLines: string[] = []): string {
|
|
25
|
+
const dir = brainsDirIn(tempHome);
|
|
26
|
+
mkdirSync(dir, { recursive: true });
|
|
27
|
+
const path = join(dir, `${agent}.jsonl`);
|
|
28
|
+
const lines = entries.map((e) => JSON.stringify(e));
|
|
29
|
+
writeFileSync(path, [...lines, ...extraLines].join("\n") + "\n", "utf-8");
|
|
30
|
+
return path;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function importMigrate() {
|
|
34
|
+
return await import("./migrate.js");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("migrateAgent", () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
tempHome = mkdtempSync(join(tmpdir(), "memvid-migrate-test-"));
|
|
40
|
+
process.env.__TEST_HOME__ = tempHome;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
try {
|
|
45
|
+
rmSync(tempHome, { recursive: true, force: true });
|
|
46
|
+
} catch {
|
|
47
|
+
// best effort
|
|
48
|
+
}
|
|
49
|
+
delete process.env.__TEST_HOME__;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("migrates a 10-session JSONL into an .mv2 with 10 frames", async () => {
|
|
53
|
+
const agent = "agent-migrate-10";
|
|
54
|
+
const entries = Array.from({ length: 10 }, (_, i) => ({
|
|
55
|
+
session_number: i + 1,
|
|
56
|
+
agent_id: agent,
|
|
57
|
+
timestamp: `2026-04-${String(i + 1).padStart(2, "0")}T00:00:00Z`,
|
|
58
|
+
summary: `session ${i + 1}`,
|
|
59
|
+
content: `content for session ${i + 1}`,
|
|
60
|
+
}));
|
|
61
|
+
seedJsonl(agent, entries);
|
|
62
|
+
|
|
63
|
+
const { migrateAgent } = await importMigrate();
|
|
64
|
+
const result = await migrateAgent(agent);
|
|
65
|
+
|
|
66
|
+
expect(result.skipped).toBe(false);
|
|
67
|
+
expect(result.error).toBeUndefined();
|
|
68
|
+
expect(result.sessionsRead).toBe(10);
|
|
69
|
+
expect(result.sessionsWritten).toBe(10);
|
|
70
|
+
expect(result.malformedLines).toBe(0);
|
|
71
|
+
|
|
72
|
+
expect(existsSync(result.mv2Path)).toBe(true);
|
|
73
|
+
expect(result.frozenPath).toBeDefined();
|
|
74
|
+
expect(existsSync(result.frozenPath!)).toBe(true);
|
|
75
|
+
|
|
76
|
+
const { MemvidBrain } = await import("./memvidBrain.js");
|
|
77
|
+
const brain = new MemvidBrain();
|
|
78
|
+
expect(await brain.getSessionCount(agent)).toBe(10);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("is idempotent — re-running on an already-migrated agent is a no-op", async () => {
|
|
82
|
+
const agent = "agent-migrate-idempotent";
|
|
83
|
+
const entries = [
|
|
84
|
+
{ session_number: 1, summary: "s1", content: "c1" },
|
|
85
|
+
{ session_number: 2, summary: "s2", content: "c2" },
|
|
86
|
+
];
|
|
87
|
+
seedJsonl(agent, entries);
|
|
88
|
+
|
|
89
|
+
const { migrateAgent } = await importMigrate();
|
|
90
|
+
const first = await migrateAgent(agent);
|
|
91
|
+
expect(first.skipped).toBe(false);
|
|
92
|
+
expect(first.sessionsWritten).toBe(2);
|
|
93
|
+
|
|
94
|
+
// Second call: JSONL has been renamed to .frozen.*, so no JSONL present → "no jsonl found".
|
|
95
|
+
const second = await migrateAgent(agent);
|
|
96
|
+
expect(second.skipped).toBe(true);
|
|
97
|
+
expect(second.skipReason).toBe("no jsonl found");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("skips (with mv2-up-to-date reason) when .mv2 exists and is newer than JSONL", async () => {
|
|
101
|
+
const agent = "agent-migrate-newer-mv2";
|
|
102
|
+
|
|
103
|
+
// First migration: creates .mv2 and freezes JSONL.
|
|
104
|
+
seedJsonl(agent, [{ session_number: 1, summary: "s1", content: "c1" }]);
|
|
105
|
+
const { migrateAgent } = await importMigrate();
|
|
106
|
+
const first = await migrateAgent(agent);
|
|
107
|
+
expect(first.skipped).toBe(false);
|
|
108
|
+
|
|
109
|
+
// Now simulate a *new* JSONL showing up (older than the .mv2 we just wrote).
|
|
110
|
+
seedJsonl(agent, [{ session_number: 1, summary: "s1", content: "c1" }]);
|
|
111
|
+
// Backdate it so the .mv2 is newer.
|
|
112
|
+
const { utimesSync } = await import("fs");
|
|
113
|
+
const jsonlPath = join(brainsDirIn(tempHome), `${agent}.jsonl`);
|
|
114
|
+
const old = new Date(Date.now() - 60_000);
|
|
115
|
+
utimesSync(jsonlPath, old, old);
|
|
116
|
+
|
|
117
|
+
const second = await migrateAgent(agent);
|
|
118
|
+
expect(second.skipped).toBe(true);
|
|
119
|
+
expect(second.skipReason).toBe("mv2 up-to-date");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("skips malformed JSONL lines without aborting the migration", async () => {
|
|
123
|
+
const agent = "agent-migrate-corrupt";
|
|
124
|
+
const entries = [
|
|
125
|
+
{ session_number: 1, summary: "s1", content: "c1" },
|
|
126
|
+
{ session_number: 2, summary: "s2", content: "c2" },
|
|
127
|
+
];
|
|
128
|
+
seedJsonl(agent, entries, [
|
|
129
|
+
"{not valid json",
|
|
130
|
+
'{"malformed": ',
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
const { migrateAgent } = await importMigrate();
|
|
134
|
+
const result = await migrateAgent(agent);
|
|
135
|
+
|
|
136
|
+
expect(result.skipped).toBe(false);
|
|
137
|
+
expect(result.error).toBeUndefined();
|
|
138
|
+
expect(result.sessionsRead).toBe(2);
|
|
139
|
+
expect(result.sessionsWritten).toBe(2);
|
|
140
|
+
expect(result.malformedLines).toBe(2);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("migrateAllPending walks the brains dir and migrates every pending jsonl", async () => {
|
|
144
|
+
seedJsonl("agent-a", [{ session_number: 1, summary: "a1", content: "ca1" }]);
|
|
145
|
+
seedJsonl("agent-b", [
|
|
146
|
+
{ session_number: 1, summary: "b1", content: "cb1" },
|
|
147
|
+
{ session_number: 2, summary: "b2", content: "cb2" },
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
const { migrateAllPending } = await importMigrate();
|
|
151
|
+
const results = await migrateAllPending();
|
|
152
|
+
|
|
153
|
+
expect(results.length).toBe(2);
|
|
154
|
+
const byAgent = Object.fromEntries(results.map((r) => [r.agentId, r]));
|
|
155
|
+
expect(byAgent["agent-a"].sessionsWritten).toBe(1);
|
|
156
|
+
expect(byAgent["agent-b"].sessionsWritten).toBe(2);
|
|
157
|
+
|
|
158
|
+
const dirFiles = readdirSync(brainsDirIn(tempHome));
|
|
159
|
+
expect(dirFiles.some((f) => f === "agent-a.mv2")).toBe(true);
|
|
160
|
+
expect(dirFiles.some((f) => f === "agent-b.mv2")).toBe(true);
|
|
161
|
+
expect(dirFiles.some((f) => f.startsWith("agent-a.jsonl.frozen."))).toBe(true);
|
|
162
|
+
expect(dirFiles.some((f) => f.startsWith("agent-b.jsonl.frozen."))).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSONL → memvid migration (§1.4 finish-agenthub-v1-backlog).
|
|
3
|
+
*
|
|
4
|
+
* For each `~/.claude/brains/<agent>.jsonl`:
|
|
5
|
+
* 1. Skip if `<agent>.mv2` exists and is newer than the JSONL.
|
|
6
|
+
* 2. Read JSONL line-by-line; skip malformed lines (log + continue).
|
|
7
|
+
* 3. Write new entries to `<agent>.mv2.new` via MemvidBrain.appendSession.
|
|
8
|
+
* 4. Verify written session count matches parsed-JSONL count (hash-verify).
|
|
9
|
+
* 5. Atomic rename `.mv2.new` → `.mv2`.
|
|
10
|
+
* 6. Atomic rename `<agent>.jsonl` → `<agent>.jsonl.frozen.<ISO>`.
|
|
11
|
+
*
|
|
12
|
+
* If any step fails, the `.mv2.new` tempfile is left for inspection and the
|
|
13
|
+
* JSONL source is untouched — migration is re-runnable.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
existsSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
readdirSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
statSync,
|
|
22
|
+
unlinkSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
} from "fs";
|
|
25
|
+
import { join } from "path";
|
|
26
|
+
import { homedir } from "os";
|
|
27
|
+
|
|
28
|
+
import { MemvidBrain } from "./memvidBrain.js";
|
|
29
|
+
import type { SessionInput } from "./types.js";
|
|
30
|
+
|
|
31
|
+
export interface MigrationResult {
|
|
32
|
+
agentId: string;
|
|
33
|
+
skipped: boolean;
|
|
34
|
+
skipReason?: string;
|
|
35
|
+
sessionsRead: number;
|
|
36
|
+
sessionsWritten: number;
|
|
37
|
+
malformedLines: number;
|
|
38
|
+
mv2Path: string;
|
|
39
|
+
frozenPath?: string;
|
|
40
|
+
elapsedMs: number;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function brainsDir(): string {
|
|
45
|
+
return join(homedir(), ".claude", "brains");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parseJsonlLines(path: string): { entries: Array<Record<string, unknown>>; malformed: number } {
|
|
49
|
+
const raw = readFileSync(path, "utf-8");
|
|
50
|
+
const entries: Array<Record<string, unknown>> = [];
|
|
51
|
+
let malformed = 0;
|
|
52
|
+
for (const line of raw.split("\n")) {
|
|
53
|
+
const trimmed = line.trim();
|
|
54
|
+
if (!trimmed) continue;
|
|
55
|
+
try {
|
|
56
|
+
entries.push(JSON.parse(trimmed) as Record<string, unknown>);
|
|
57
|
+
} catch {
|
|
58
|
+
malformed += 1;
|
|
59
|
+
process.stderr.write(`[brain/migrate] skipped malformed line in ${path}\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { entries, malformed };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function jsonlEntryToSession(entry: Record<string, unknown>): SessionInput {
|
|
66
|
+
return {
|
|
67
|
+
session_number: typeof entry.session_number === "number" ? entry.session_number : undefined,
|
|
68
|
+
summary: typeof entry.summary === "string" ? entry.summary : "",
|
|
69
|
+
content: typeof entry.content === "string" ? entry.content : "",
|
|
70
|
+
timestamp: typeof entry.timestamp === "string" ? entry.timestamp : undefined,
|
|
71
|
+
files_changed: Array.isArray(entry.files_changed)
|
|
72
|
+
? (entry.files_changed as unknown[]).map(String)
|
|
73
|
+
: undefined,
|
|
74
|
+
outcome: typeof entry.outcome === "string" ? entry.outcome : undefined,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Migrate a single agent's JSONL brain to memvid. Idempotent.
|
|
80
|
+
*/
|
|
81
|
+
export async function migrateAgent(agentId: string): Promise<MigrationResult> {
|
|
82
|
+
const started = Date.now();
|
|
83
|
+
const dir = brainsDir();
|
|
84
|
+
const safe = agentId.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
85
|
+
const jsonlPath = join(dir, `${safe}.jsonl`);
|
|
86
|
+
const mv2Path = join(dir, `${safe}.mv2`);
|
|
87
|
+
const mv2NewPath = join(dir, `${safe}.mv2.new`);
|
|
88
|
+
|
|
89
|
+
const result: MigrationResult = {
|
|
90
|
+
agentId,
|
|
91
|
+
skipped: false,
|
|
92
|
+
sessionsRead: 0,
|
|
93
|
+
sessionsWritten: 0,
|
|
94
|
+
malformedLines: 0,
|
|
95
|
+
mv2Path,
|
|
96
|
+
elapsedMs: 0,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
if (!existsSync(jsonlPath)) {
|
|
101
|
+
result.skipped = true;
|
|
102
|
+
result.skipReason = "no jsonl found";
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (existsSync(mv2Path)) {
|
|
107
|
+
const jsonlStat = statSync(jsonlPath);
|
|
108
|
+
const mv2Stat = statSync(mv2Path);
|
|
109
|
+
if (mv2Stat.mtimeMs >= jsonlStat.mtimeMs) {
|
|
110
|
+
result.skipped = true;
|
|
111
|
+
result.skipReason = "mv2 up-to-date";
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Clean up any leftover partial write from a previous run.
|
|
117
|
+
if (existsSync(mv2NewPath)) {
|
|
118
|
+
try {
|
|
119
|
+
unlinkSync(mv2NewPath);
|
|
120
|
+
} catch {
|
|
121
|
+
// If we can't delete, bail — don't corrupt a file we don't understand.
|
|
122
|
+
throw new Error(`leftover ${mv2NewPath} could not be removed`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const { entries, malformed } = parseJsonlLines(jsonlPath);
|
|
127
|
+
result.sessionsRead = entries.length;
|
|
128
|
+
result.malformedLines = malformed;
|
|
129
|
+
|
|
130
|
+
if (entries.length === 0) {
|
|
131
|
+
result.skipped = true;
|
|
132
|
+
result.skipReason = "empty jsonl";
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Write into a tempfile path, then atomic-rename on success.
|
|
137
|
+
// MemvidBrain.getBrainPath() points to the final location; we write into
|
|
138
|
+
// `.mv2.new` instead by constructing a throwaway brain instance pointed at
|
|
139
|
+
// the temp name via a symlink-free path trick: we simply ingest into a
|
|
140
|
+
// MemvidBrain and then atomically rename. Since MemvidBrain writes to
|
|
141
|
+
// `<safe>.mv2`, we first ensure any old `.mv2` is moved aside.
|
|
142
|
+
const brain = new MemvidBrain();
|
|
143
|
+
|
|
144
|
+
// To write to `.mv2.new` we use a distinct agentId that maps to the same
|
|
145
|
+
// sanitized prefix plus a suffix. Simplest: use a temp agentId and then
|
|
146
|
+
// rename.
|
|
147
|
+
const tempAgent = `${agentId}__migrating_${started}`;
|
|
148
|
+
const tempSafe = tempAgent.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
149
|
+
const tempPath = join(dir, `${tempSafe}.mv2`);
|
|
150
|
+
|
|
151
|
+
for (const entry of entries) {
|
|
152
|
+
const session = jsonlEntryToSession(entry);
|
|
153
|
+
await brain.appendSession(tempAgent, session);
|
|
154
|
+
result.sessionsWritten += 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Verify count in the tempfile matches what we wrote.
|
|
158
|
+
const finalCount = await brain.getSessionCount(tempAgent);
|
|
159
|
+
if (finalCount !== result.sessionsWritten) {
|
|
160
|
+
// Clean up and bail.
|
|
161
|
+
if (existsSync(tempPath)) {
|
|
162
|
+
try {
|
|
163
|
+
unlinkSync(tempPath);
|
|
164
|
+
} catch {
|
|
165
|
+
// best effort
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
throw new Error(
|
|
169
|
+
`count mismatch: wrote ${result.sessionsWritten} but .mv2 reports ${finalCount}`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Atomic rename tempPath → mv2Path.
|
|
174
|
+
renameSync(tempPath, mv2Path);
|
|
175
|
+
|
|
176
|
+
// Freeze the JSONL.
|
|
177
|
+
const isoStamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
178
|
+
const frozenPath = `${jsonlPath}.frozen.${isoStamp}`;
|
|
179
|
+
renameSync(jsonlPath, frozenPath);
|
|
180
|
+
result.frozenPath = frozenPath;
|
|
181
|
+
|
|
182
|
+
result.elapsedMs = Date.now() - started;
|
|
183
|
+
process.stderr.write(
|
|
184
|
+
`[brain/migrate] ${agentId}: migrated ${result.sessionsWritten}/${result.sessionsRead} sessions, ` +
|
|
185
|
+
`frozen→${frozenPath}, elapsed ${result.elapsedMs}ms\n`
|
|
186
|
+
);
|
|
187
|
+
return result;
|
|
188
|
+
} catch (err) {
|
|
189
|
+
result.error = err instanceof Error ? err.message : String(err);
|
|
190
|
+
result.elapsedMs = Date.now() - started;
|
|
191
|
+
process.stderr.write(
|
|
192
|
+
`[brain/migrate] ${agentId}: FAILED after ${result.elapsedMs}ms — ${result.error}\n`
|
|
193
|
+
);
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Walk the brains dir, migrate every `<agent>.jsonl` that has no fresh `.mv2`.
|
|
200
|
+
* Called at MCP boot when backend=memvid.
|
|
201
|
+
*/
|
|
202
|
+
export async function migrateAllPending(): Promise<MigrationResult[]> {
|
|
203
|
+
const dir = brainsDir();
|
|
204
|
+
if (!existsSync(dir)) return [];
|
|
205
|
+
const results: MigrationResult[] = [];
|
|
206
|
+
for (const file of readdirSync(dir)) {
|
|
207
|
+
if (!file.endsWith(".jsonl")) continue;
|
|
208
|
+
const agentId = file.slice(0, -".jsonl".length);
|
|
209
|
+
const r = await migrateAgent(agentId);
|
|
210
|
+
if (!r.skipped || r.error) results.push(r);
|
|
211
|
+
}
|
|
212
|
+
return results;
|
|
213
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for brain backends (memvid, jsonl).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface SessionInput {
|
|
6
|
+
summary: string;
|
|
7
|
+
content: string;
|
|
8
|
+
session_number?: number;
|
|
9
|
+
files_changed?: string[];
|
|
10
|
+
outcome?: string;
|
|
11
|
+
timestamp?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SessionRecord {
|
|
15
|
+
session_number: number;
|
|
16
|
+
agent_id: string;
|
|
17
|
+
timestamp: string;
|
|
18
|
+
summary: string;
|
|
19
|
+
content: string;
|
|
20
|
+
files_changed?: string[];
|
|
21
|
+
outcome?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SearchHit {
|
|
25
|
+
session_number: number;
|
|
26
|
+
timestamp: string;
|
|
27
|
+
summary: string;
|
|
28
|
+
snippet: string;
|
|
29
|
+
score: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BrainInfo {
|
|
33
|
+
agentId: string;
|
|
34
|
+
sessions: number;
|
|
35
|
+
path: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BrainBackend {
|
|
39
|
+
appendSession(agentId: string, session: SessionInput): Promise<number>;
|
|
40
|
+
search(agentId: string, query: string, limit?: number): Promise<SearchHit[]>;
|
|
41
|
+
recall(agentId: string, sessionNumber: number): Promise<SessionRecord | null>;
|
|
42
|
+
getSessionCount(agentId: string): Promise<number>;
|
|
43
|
+
listBrains(): Promise<BrainInfo[]>;
|
|
44
|
+
getBrainPath(agentId: string): string;
|
|
45
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import type { ApiClient } from "../client.js";
|
|
11
11
|
import * as sessionState from "./sessionState.js";
|
|
12
|
-
import
|
|
12
|
+
import { getBrain } from "../brain/backend.js";
|
|
13
13
|
|
|
14
14
|
interface BrainCaptureData {
|
|
15
15
|
agentId: string;
|
|
@@ -68,13 +68,12 @@ export async function captureOnTaskComplete(
|
|
|
68
68
|
data.nextSteps ? `Next: ${data.nextSteps}` : "",
|
|
69
69
|
data.filesChanged?.length ? `Files: ${data.filesChanged.join(", ")}` : "",
|
|
70
70
|
].filter(Boolean).join("\n");
|
|
71
|
-
|
|
72
|
-
data.
|
|
73
|
-
data.summary || "",
|
|
71
|
+
await getBrain().appendSession(data.agentId, {
|
|
72
|
+
summary: data.summary || "",
|
|
74
73
|
content,
|
|
75
|
-
data.filesChanged,
|
|
76
|
-
data.outcome,
|
|
77
|
-
);
|
|
74
|
+
files_changed: data.filesChanged,
|
|
75
|
+
outcome: data.outcome,
|
|
76
|
+
});
|
|
78
77
|
} catch {
|
|
79
78
|
// Local brain is best-effort
|
|
80
79
|
}
|
|
@@ -171,9 +170,12 @@ export async function captureOnDisconnect(
|
|
|
171
170
|
summary_text: summaryText,
|
|
172
171
|
});
|
|
173
172
|
|
|
174
|
-
// §3.3 — Auto-append to local
|
|
173
|
+
// §3.3 — Auto-append to local brain on disconnect
|
|
175
174
|
try {
|
|
176
|
-
|
|
175
|
+
await getBrain().appendSession(agentId, {
|
|
176
|
+
summary: summaryText,
|
|
177
|
+
content: summaryText,
|
|
178
|
+
});
|
|
177
179
|
} catch {
|
|
178
180
|
// Local brain is best-effort
|
|
179
181
|
}
|
package/src/index.ts
CHANGED
|
@@ -246,6 +246,27 @@ async function main() {
|
|
|
246
246
|
await server.connect(transport);
|
|
247
247
|
console.error("AgentHub MCP server running");
|
|
248
248
|
|
|
249
|
+
// One-shot JSONL → memvid brain migration for agents not yet converted.
|
|
250
|
+
// Runs only when backend=memvid (default) and any stale .jsonl still exists.
|
|
251
|
+
const brainBackend = (process.env.AGENTHUB_BRAIN_BACKEND ?? "memvid").toLowerCase();
|
|
252
|
+
if (brainBackend === "memvid") {
|
|
253
|
+
try {
|
|
254
|
+
const { migrateAllPending } = await import("./brain/migrate.js");
|
|
255
|
+
const results = await migrateAllPending();
|
|
256
|
+
for (const r of results) {
|
|
257
|
+
if (r.error) {
|
|
258
|
+
console.error(`[brain/migrate] ${r.agentId} FAILED: ${r.error}`);
|
|
259
|
+
} else if (!r.skipped) {
|
|
260
|
+
console.error(
|
|
261
|
+
`[brain/migrate] ${r.agentId}: ${r.sessionsWritten}/${r.sessionsRead} sessions in ${r.elapsedMs}ms`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.error(`[brain/migrate] boot migration crashed: ${e}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
249
270
|
// Auto-register if agent ID is provided via environment
|
|
250
271
|
if (AGENTHUB_AGENT_ID) {
|
|
251
272
|
try {
|
package/src/tools/index.ts
CHANGED
|
@@ -8,7 +8,7 @@ import * as state from "../state.js";
|
|
|
8
8
|
import * as contextModule from "../context.js";
|
|
9
9
|
import { hookManager, AvailableTask } from "../hooks/index.js";
|
|
10
10
|
import * as brainCapture from "../hooks/brainCapture.js";
|
|
11
|
-
import
|
|
11
|
+
import { getBrain } from "../brain/backend.js";
|
|
12
12
|
import open from "open";
|
|
13
13
|
import { registerOpenSpecVizTools, handleOpenSpecVizToolCall } from "./openspec-viz/index.js";
|
|
14
14
|
|
|
@@ -3295,8 +3295,11 @@ export async function handleToolCall(
|
|
|
3295
3295
|
if (!agentId) throw new Error("Not registered. Call agent_register first.");
|
|
3296
3296
|
const query = args.query as string;
|
|
3297
3297
|
const maxResults = (args.max_results as number) || 5;
|
|
3298
|
-
const
|
|
3299
|
-
const totalSessions =
|
|
3298
|
+
const brain = getBrain();
|
|
3299
|
+
const [results, totalSessions] = await Promise.all([
|
|
3300
|
+
brain.search(agentId, query, maxResults),
|
|
3301
|
+
brain.getSessionCount(agentId),
|
|
3302
|
+
]);
|
|
3300
3303
|
return {
|
|
3301
3304
|
results,
|
|
3302
3305
|
total_sessions: totalSessions,
|
|
@@ -3307,11 +3310,12 @@ export async function handleToolCall(
|
|
|
3307
3310
|
case "brain_recall": {
|
|
3308
3311
|
if (!agentId) throw new Error("Not registered. Call agent_register first.");
|
|
3309
3312
|
const sessionNumber = args.session_number as number;
|
|
3310
|
-
const
|
|
3313
|
+
const brain = getBrain();
|
|
3314
|
+
const entry = await brain.recall(agentId, sessionNumber);
|
|
3311
3315
|
if (!entry) {
|
|
3312
3316
|
return {
|
|
3313
3317
|
error: `Session #${sessionNumber} not found`,
|
|
3314
|
-
total_sessions:
|
|
3318
|
+
total_sessions: await brain.getSessionCount(agentId),
|
|
3315
3319
|
};
|
|
3316
3320
|
}
|
|
3317
3321
|
return entry;
|
package/src/tools/tools.test.ts
CHANGED
|
@@ -75,9 +75,9 @@ function createMockContext(): ToolContext {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
describe("registerTools", () => {
|
|
78
|
-
it("should return all
|
|
78
|
+
it("should return all 86 tools", () => {
|
|
79
79
|
const tools = registerTools();
|
|
80
|
-
expect(tools).toHaveLength(
|
|
80
|
+
expect(tools).toHaveLength(86);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
it("should have required tool names", () => {
|