@crewhaus/tool-memory 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/package.json +44 -0
- package/src/index.test.ts +96 -0
- package/src/index.ts +103 -0
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-memory",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "M4.2 — Remember and Recall tools wrapping @crewhaus/memory-store for cross-session persistent memory.",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "bun test src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@crewhaus/memory-store": "0.0.0",
|
|
16
|
+
"@crewhaus/tool-builder": "0.0.0",
|
|
17
|
+
"@crewhaus/tool-catalog": "0.0.0",
|
|
18
|
+
"zod": "^3.23.8"
|
|
19
|
+
},
|
|
20
|
+
"license": "Apache-2.0",
|
|
21
|
+
"author": {
|
|
22
|
+
"name": "Max Meier",
|
|
23
|
+
"email": "max@studiomax.io",
|
|
24
|
+
"url": "https://studiomax.io"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
29
|
+
"directory": "packages/tool-memory"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-memory#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "restricted"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"src",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"NOTICE"
|
|
43
|
+
]
|
|
44
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Remember + Recall tools. The underlying memory-store
|
|
3
|
+
* is tested in @crewhaus/memory-store; here we focus on the tool
|
|
4
|
+
* surface (schemas, execute outputs, flags).
|
|
5
|
+
*/
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { createMemoryTools } from "./index";
|
|
11
|
+
|
|
12
|
+
let tmp: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmp = mkdtempSync(join(tmpdir(), "tool-memory-"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe("createMemoryTools — Remember", () => {
|
|
23
|
+
test("returns a destructive tool named 'Remember'", () => {
|
|
24
|
+
const { remember } = createMemoryTools({ specName: "s", rootDir: tmp });
|
|
25
|
+
expect(remember.name).toBe("Remember");
|
|
26
|
+
expect(remember.destructive).toBe(true);
|
|
27
|
+
expect(remember.readOnly).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("execute persists the memory and returns confirmation", async () => {
|
|
31
|
+
const { remember, store } = createMemoryTools({ specName: "s", rootDir: tmp });
|
|
32
|
+
const result = await remember.execute({
|
|
33
|
+
text: "the user's birthday is March 15",
|
|
34
|
+
});
|
|
35
|
+
expect(result).toContain("remembered (mem_");
|
|
36
|
+
expect(result).toContain("the user's birthday is March 15");
|
|
37
|
+
expect(await store.size()).toBe(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("execute renders tags as a suffix in the confirmation", async () => {
|
|
41
|
+
const { remember } = createMemoryTools({ specName: "s", rootDir: tmp });
|
|
42
|
+
const result = await remember.execute({
|
|
43
|
+
text: "anniversary on June 1",
|
|
44
|
+
tags: ["personal", "important"],
|
|
45
|
+
});
|
|
46
|
+
expect(result).toContain("[personal, important]");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("schema validates text presence (zod rejects empty)", () => {
|
|
50
|
+
const { remember } = createMemoryTools({ specName: "s", rootDir: tmp });
|
|
51
|
+
expect(() => remember.inputSchema.parse({ text: "" })).toThrow();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("createMemoryTools — Recall", () => {
|
|
56
|
+
test("returns a read-only tool named 'Recall'", () => {
|
|
57
|
+
const { recall } = createMemoryTools({ specName: "s", rootDir: tmp });
|
|
58
|
+
expect(recall.name).toBe("Recall");
|
|
59
|
+
expect(recall.readOnly).toBe(true);
|
|
60
|
+
expect(recall.destructive).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("recall returns 'no memories' message when empty", async () => {
|
|
64
|
+
const { recall } = createMemoryTools({ specName: "s", rootDir: tmp });
|
|
65
|
+
const out = await recall.execute({ query: "anything" });
|
|
66
|
+
expect(out).toContain("no memories matched");
|
|
67
|
+
expect(out).toContain("store size: 0");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("recall lists matches ordered by score", async () => {
|
|
71
|
+
const { remember, recall } = createMemoryTools({ specName: "s", rootDir: tmp });
|
|
72
|
+
await remember.execute({ text: "the user prefers TypeScript" });
|
|
73
|
+
await remember.execute({ text: "we discussed Python yesterday" });
|
|
74
|
+
await remember.execute({ text: "TypeScript is the user's daily driver" });
|
|
75
|
+
const out = await recall.execute({ query: "TypeScript" });
|
|
76
|
+
expect(out).toContain("memory match(es)");
|
|
77
|
+
expect(out).toContain("TypeScript");
|
|
78
|
+
expect(out).not.toContain("Python");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("k parameter caps the listed matches", async () => {
|
|
82
|
+
const { remember, recall } = createMemoryTools({ specName: "s", rootDir: tmp });
|
|
83
|
+
for (let i = 0; i < 5; i++) {
|
|
84
|
+
await remember.execute({ text: `note ${i} about coffee` });
|
|
85
|
+
}
|
|
86
|
+
const out = await recall.execute({ query: "coffee", k: 2 });
|
|
87
|
+
expect((out.match(/•/g) ?? []).length).toBe(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("recall schema validates k bounds", () => {
|
|
91
|
+
const { recall } = createMemoryTools({ specName: "s", rootDir: tmp });
|
|
92
|
+
expect(() => recall.inputSchema.parse({ query: "q", k: 0 })).toThrow();
|
|
93
|
+
expect(() => recall.inputSchema.parse({ query: "q", k: 51 })).toThrow();
|
|
94
|
+
expect(() => recall.inputSchema.parse({ query: "q", k: 5 })).not.toThrow();
|
|
95
|
+
});
|
|
96
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catalog R3 — tool-memory. Exposes `Remember` and `Recall` tools that
|
|
3
|
+
* wrap @crewhaus/memory-store for cross-session persistent memory
|
|
4
|
+
* (M4.2 of the heavy-hitter plan).
|
|
5
|
+
*
|
|
6
|
+
* Each tool is bound to a specific spec name at construction time so
|
|
7
|
+
* one process can run multiple specs without their memories cross-
|
|
8
|
+
* contaminating. The spec name is what `runChatLoop` passes as
|
|
9
|
+
* `sessionName`; tool-catalog can supply it via factory functions
|
|
10
|
+
* (`createMemoryTools(specName)`) or downstream consumers can wire
|
|
11
|
+
* it through their own catalog initialization.
|
|
12
|
+
*
|
|
13
|
+
* Pillar 3: memory writes carry `origin: "user"` semantics (the user
|
|
14
|
+
* is the one who decided what to remember). The runtime's session-
|
|
15
|
+
* start hook reads recalled memories with the same origin so cached
|
|
16
|
+
* verdicts apply correctly. No cross-origin lateral movement: a
|
|
17
|
+
* memory written by one user cannot influence another user's session.
|
|
18
|
+
*/
|
|
19
|
+
import { type MemoryStore, createMemoryStore } from "@crewhaus/memory-store";
|
|
20
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
21
|
+
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
|
|
24
|
+
export type CreateMemoryToolsOptions = {
|
|
25
|
+
readonly specName: string;
|
|
26
|
+
readonly rootDir?: string;
|
|
27
|
+
/** Inject a custom store implementation for tests. */
|
|
28
|
+
readonly store?: MemoryStore;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type MemoryToolBundle = {
|
|
32
|
+
readonly remember: RegisteredTool;
|
|
33
|
+
readonly recall: RegisteredTool;
|
|
34
|
+
/** Exposed for direct inspection (tests, /clear-style ops). */
|
|
35
|
+
readonly store: MemoryStore;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const rememberSchema = z.object({
|
|
39
|
+
text: z.string().min(1).describe("The fact to remember. One short, self-contained sentence."),
|
|
40
|
+
tags: z
|
|
41
|
+
.array(z.string().min(1))
|
|
42
|
+
.max(8)
|
|
43
|
+
.optional()
|
|
44
|
+
.describe("Optional tags for grouping (e.g. 'family', 'work', 'preference')."),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const recallSchema = z.object({
|
|
48
|
+
query: z
|
|
49
|
+
.string()
|
|
50
|
+
.min(1)
|
|
51
|
+
.describe("Text to search for. Returns the top-K matching memories ranked by relevance."),
|
|
52
|
+
k: z.number().int().min(1).max(50).optional().describe("Max results to return. Default 5."),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Construct the Remember + Recall tool pair bound to a spec's memory
|
|
57
|
+
* store. The caller is expected to add the returned tools to the
|
|
58
|
+
* runtime's tool catalog (e.g. `defaultCatalog.register(bundle.remember)`).
|
|
59
|
+
*/
|
|
60
|
+
export function createMemoryTools(opts: CreateMemoryToolsOptions): MemoryToolBundle {
|
|
61
|
+
const store: MemoryStore =
|
|
62
|
+
opts.store ??
|
|
63
|
+
createMemoryStore({
|
|
64
|
+
specName: opts.specName,
|
|
65
|
+
...(opts.rootDir !== undefined ? { rootDir: opts.rootDir } : {}),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const remember: RegisteredTool = buildTool({
|
|
69
|
+
name: "Remember",
|
|
70
|
+
description:
|
|
71
|
+
"Store a fact for future sessions. Use sparingly — once a user mentions something they want you to remember (preferences, important dates, contact details, opinions), call this. The memory persists in `.crewhaus/memories/<spec>.jsonl` and is searchable via Recall.",
|
|
72
|
+
inputSchema: rememberSchema,
|
|
73
|
+
destructive: true, // writes to disk — gate behind permissions
|
|
74
|
+
execute: async (input) => {
|
|
75
|
+
const entry = await store.remember(input.text, input.tags ?? []);
|
|
76
|
+
const tagSuffix = entry.tags.length > 0 ? ` [${entry.tags.join(", ")}]` : "";
|
|
77
|
+
return `remembered (${entry.id})${tagSuffix}: ${entry.text}`;
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const recall: RegisteredTool = buildTool({
|
|
82
|
+
name: "Recall",
|
|
83
|
+
description:
|
|
84
|
+
"Search prior memories for relevance to a query. Use at the start of a session, or whenever the user references something you might have stored. Returns up to K results ranked by BM25 relevance score, ordered most-relevant first.",
|
|
85
|
+
inputSchema: recallSchema,
|
|
86
|
+
readOnly: true,
|
|
87
|
+
execute: async (input) => {
|
|
88
|
+
const k = input.k ?? 5;
|
|
89
|
+
const results = await store.recall(input.query, k);
|
|
90
|
+
if (results.length === 0) {
|
|
91
|
+
return `no memories matched "${input.query}" (store size: ${await store.size()})`;
|
|
92
|
+
}
|
|
93
|
+
const lines = [`${results.length} memory match(es) for "${input.query}":`];
|
|
94
|
+
for (const r of results) {
|
|
95
|
+
const tagSuffix = r.entry.tags.length > 0 ? ` [${r.entry.tags.join(", ")}]` : "";
|
|
96
|
+
lines.push(` • (${r.score.toFixed(2)}) ${r.entry.text}${tagSuffix}`);
|
|
97
|
+
}
|
|
98
|
+
return lines.join("\n");
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return { remember, recall, store };
|
|
103
|
+
}
|