@teammates/recall 0.5.0 → 0.5.1
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/dist/embeddings.test.d.ts +1 -0
- package/dist/embeddings.test.js +81 -0
- package/dist/indexer.d.ts +5 -0
- package/dist/indexer.js +24 -3
- package/dist/indexer.test.js +65 -3
- package/dist/search.d.ts +4 -0
- package/dist/search.js +1 -1
- package/dist/search.test.js +263 -19
- package/package.json +1 -1
- package/src/embeddings.test.ts +106 -0
- package/src/indexer.test.ts +78 -3
- package/src/indexer.ts +26 -3
- package/src/search.test.ts +386 -49
- package/src/search.ts +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { LocalEmbeddings } from "./embeddings.js";
|
|
3
|
+
// Mock @huggingface/transformers to avoid loading the real model
|
|
4
|
+
vi.mock("@huggingface/transformers", () => ({
|
|
5
|
+
pipeline: vi.fn(async () => {
|
|
6
|
+
// Return a fake extractor function
|
|
7
|
+
return async (texts, _opts) => ({
|
|
8
|
+
tolist: () => texts.map(() => new Array(384).fill(0.1)),
|
|
9
|
+
});
|
|
10
|
+
}),
|
|
11
|
+
}));
|
|
12
|
+
describe("LocalEmbeddings", () => {
|
|
13
|
+
it("has maxTokens set to 256", () => {
|
|
14
|
+
const emb = new LocalEmbeddings();
|
|
15
|
+
expect(emb.maxTokens).toBe(256);
|
|
16
|
+
});
|
|
17
|
+
describe("createEmbeddings", () => {
|
|
18
|
+
it("returns embeddings for a single string input", async () => {
|
|
19
|
+
const emb = new LocalEmbeddings();
|
|
20
|
+
const result = await emb.createEmbeddings("hello world");
|
|
21
|
+
expect(result.status).toBe("success");
|
|
22
|
+
expect(result.output).toHaveLength(1);
|
|
23
|
+
expect(result.output[0]).toHaveLength(384);
|
|
24
|
+
});
|
|
25
|
+
it("returns embeddings for an array of strings", async () => {
|
|
26
|
+
const emb = new LocalEmbeddings();
|
|
27
|
+
const result = await emb.createEmbeddings(["hello", "world"]);
|
|
28
|
+
expect(result.status).toBe("success");
|
|
29
|
+
expect(result.output).toHaveLength(2);
|
|
30
|
+
expect(result.output[0]).toHaveLength(384);
|
|
31
|
+
expect(result.output[1]).toHaveLength(384);
|
|
32
|
+
});
|
|
33
|
+
it("returns empty output for empty string input", async () => {
|
|
34
|
+
const emb = new LocalEmbeddings();
|
|
35
|
+
const result = await emb.createEmbeddings("");
|
|
36
|
+
expect(result.status).toBe("success");
|
|
37
|
+
expect(result.output).toHaveLength(0);
|
|
38
|
+
});
|
|
39
|
+
it("returns empty output for whitespace-only input", async () => {
|
|
40
|
+
const emb = new LocalEmbeddings();
|
|
41
|
+
const result = await emb.createEmbeddings(" ");
|
|
42
|
+
expect(result.status).toBe("success");
|
|
43
|
+
expect(result.output).toHaveLength(0);
|
|
44
|
+
});
|
|
45
|
+
it("filters out empty strings from array input", async () => {
|
|
46
|
+
const emb = new LocalEmbeddings();
|
|
47
|
+
const result = await emb.createEmbeddings(["hello", "", " ", "world"]);
|
|
48
|
+
expect(result.status).toBe("success");
|
|
49
|
+
expect(result.output).toHaveLength(2);
|
|
50
|
+
});
|
|
51
|
+
it("returns error status when extractor throws", async () => {
|
|
52
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
53
|
+
// Override mock to throw
|
|
54
|
+
pipeline.mockImplementationOnce(async () => {
|
|
55
|
+
return async () => {
|
|
56
|
+
throw new Error("Model load failed");
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
const emb = new LocalEmbeddings("bad-model");
|
|
60
|
+
const result = await emb.createEmbeddings("test");
|
|
61
|
+
expect(result.status).toBe("error");
|
|
62
|
+
expect(result.message).toBe("Model load failed");
|
|
63
|
+
});
|
|
64
|
+
it("reuses the extractor on subsequent calls (lazy init)", async () => {
|
|
65
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
66
|
+
pipeline.mockClear();
|
|
67
|
+
const emb = new LocalEmbeddings();
|
|
68
|
+
await emb.createEmbeddings("first");
|
|
69
|
+
await emb.createEmbeddings("second");
|
|
70
|
+
// pipeline() should only be called once (lazy singleton)
|
|
71
|
+
expect(pipeline).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
it("accepts a custom model name", async () => {
|
|
74
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
75
|
+
pipeline.mockClear();
|
|
76
|
+
const emb = new LocalEmbeddings("custom/model-name");
|
|
77
|
+
await emb.createEmbeddings("test");
|
|
78
|
+
expect(pipeline).toHaveBeenCalledWith("feature-extraction", "custom/model-name", expect.objectContaining({ dtype: "fp32" }));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
package/dist/indexer.d.ts
CHANGED
|
@@ -47,6 +47,11 @@ export declare class Indexer {
|
|
|
47
47
|
* Upserts new/changed files without a full rebuild.
|
|
48
48
|
*/
|
|
49
49
|
syncTeammate(teammate: string): Promise<number>;
|
|
50
|
+
/**
|
|
51
|
+
* Delete a document from a teammate's index by URI.
|
|
52
|
+
* Used to purge stale daily logs after they age out on disk.
|
|
53
|
+
*/
|
|
54
|
+
deleteDocument(teammate: string, uri: string): Promise<void>;
|
|
50
55
|
/**
|
|
51
56
|
* Sync indexes for all teammates.
|
|
52
57
|
*/
|
package/dist/indexer.js
CHANGED
|
@@ -54,16 +54,20 @@ export class Indexer {
|
|
|
54
54
|
catch {
|
|
55
55
|
// No WISDOM.md
|
|
56
56
|
}
|
|
57
|
-
// memory/*.md — typed memories
|
|
57
|
+
// memory/*.md — typed memories + daily logs (day 2+)
|
|
58
|
+
// Today's daily log is excluded (still being written). Older dailies are
|
|
59
|
+
// indexed so recall can surface high-resolution episodic context beyond
|
|
60
|
+
// the 7-day prompt window. Dailies older than 30 days are purged elsewhere.
|
|
58
61
|
const memoryDir = path.join(teammateDir, "memory");
|
|
62
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
59
63
|
try {
|
|
60
64
|
const memoryEntries = await fs.readdir(memoryDir);
|
|
61
65
|
for (const entry of memoryEntries) {
|
|
62
66
|
if (!entry.endsWith(".md"))
|
|
63
67
|
continue;
|
|
64
68
|
const stem = path.basename(entry, ".md");
|
|
65
|
-
// Skip daily
|
|
66
|
-
if (
|
|
69
|
+
// Skip today's daily log — it's still being written and already in prompt context
|
|
70
|
+
if (stem === today)
|
|
67
71
|
continue;
|
|
68
72
|
files.push({
|
|
69
73
|
uri: `${teammate}/memory/${entry}`,
|
|
@@ -194,6 +198,23 @@ export class Indexer {
|
|
|
194
198
|
}
|
|
195
199
|
return count;
|
|
196
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Delete a document from a teammate's index by URI.
|
|
203
|
+
* Used to purge stale daily logs after they age out on disk.
|
|
204
|
+
*/
|
|
205
|
+
async deleteDocument(teammate, uri) {
|
|
206
|
+
const indexPath = this.indexPath(teammate);
|
|
207
|
+
const index = new LocalDocumentIndex({
|
|
208
|
+
folderPath: indexPath,
|
|
209
|
+
embeddings: this._embeddings,
|
|
210
|
+
});
|
|
211
|
+
if (!(await index.isIndexCreated()))
|
|
212
|
+
return;
|
|
213
|
+
const docId = await index.getDocumentId(uri);
|
|
214
|
+
if (docId) {
|
|
215
|
+
await index.deleteDocument(uri);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
197
218
|
/**
|
|
198
219
|
* Sync indexes for all teammates.
|
|
199
220
|
*/
|
package/dist/indexer.test.js
CHANGED
|
@@ -83,17 +83,20 @@ describe("Indexer", () => {
|
|
|
83
83
|
expect(uris).toContain("beacon/memory/feedback_testing.md");
|
|
84
84
|
expect(uris).toContain("beacon/memory/project_goals.md");
|
|
85
85
|
});
|
|
86
|
-
it("
|
|
86
|
+
it("includes older daily logs but skips today's", async () => {
|
|
87
87
|
const memDir = join(testDir, "beacon", "memory");
|
|
88
88
|
await mkdir(memDir, { recursive: true });
|
|
89
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
90
|
+
await writeFile(join(memDir, `${today}.md`), "# Today");
|
|
89
91
|
await writeFile(join(memDir, "2026-03-14.md"), "# Day 1");
|
|
90
92
|
await writeFile(join(memDir, "2026-03-15.md"), "# Day 2");
|
|
91
93
|
await writeFile(join(memDir, "feedback_testing.md"), "# Feedback");
|
|
92
94
|
const indexer = createIndexer(testDir);
|
|
93
95
|
const { files } = await indexer.collectFiles("beacon");
|
|
94
96
|
const uris = files.map((f) => f.uri);
|
|
95
|
-
expect(uris).not.toContain(
|
|
96
|
-
expect(uris).
|
|
97
|
+
expect(uris).not.toContain(`beacon/memory/${today}.md`);
|
|
98
|
+
expect(uris).toContain("beacon/memory/2026-03-14.md");
|
|
99
|
+
expect(uris).toContain("beacon/memory/2026-03-15.md");
|
|
97
100
|
expect(uris).toContain("beacon/memory/feedback_testing.md");
|
|
98
101
|
});
|
|
99
102
|
it("collects weekly summaries from memory/weekly/", async () => {
|
|
@@ -181,6 +184,65 @@ describe("Indexer", () => {
|
|
|
181
184
|
expect(results.get("scribe")).toBe(0); // no indexable files
|
|
182
185
|
});
|
|
183
186
|
});
|
|
187
|
+
describe("upsertFile", () => {
|
|
188
|
+
it("upserts a single file into a new index", async () => {
|
|
189
|
+
const beacon = join(testDir, "beacon");
|
|
190
|
+
await mkdir(beacon, { recursive: true });
|
|
191
|
+
const filePath = join(beacon, "WISDOM.md");
|
|
192
|
+
await writeFile(filePath, "# Upsert test wisdom");
|
|
193
|
+
const indexer = createIndexer(testDir);
|
|
194
|
+
await indexer.upsertFile("beacon", filePath);
|
|
195
|
+
// Verify index was created by syncing (which reads the index)
|
|
196
|
+
const count = await indexer.syncTeammate("beacon");
|
|
197
|
+
expect(count).toBeGreaterThanOrEqual(1);
|
|
198
|
+
});
|
|
199
|
+
it("upserts into an existing index without rebuilding", async () => {
|
|
200
|
+
const beacon = join(testDir, "beacon");
|
|
201
|
+
const memDir = join(beacon, "memory");
|
|
202
|
+
await mkdir(memDir, { recursive: true });
|
|
203
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Wisdom");
|
|
204
|
+
const indexer = createIndexer(testDir);
|
|
205
|
+
// Build initial index
|
|
206
|
+
await indexer.indexTeammate("beacon");
|
|
207
|
+
// Upsert a new file
|
|
208
|
+
const newFile = join(memDir, "feedback_test.md");
|
|
209
|
+
await writeFile(newFile, "# New feedback content");
|
|
210
|
+
await indexer.upsertFile("beacon", newFile);
|
|
211
|
+
// Sync should see both files
|
|
212
|
+
const count = await indexer.syncTeammate("beacon");
|
|
213
|
+
expect(count).toBe(2);
|
|
214
|
+
});
|
|
215
|
+
it("skips empty files", async () => {
|
|
216
|
+
const beacon = join(testDir, "beacon");
|
|
217
|
+
await mkdir(beacon, { recursive: true });
|
|
218
|
+
const filePath = join(beacon, "WISDOM.md");
|
|
219
|
+
await writeFile(filePath, " "); // whitespace only
|
|
220
|
+
const indexer = createIndexer(testDir);
|
|
221
|
+
// Should not throw, just skip
|
|
222
|
+
await indexer.upsertFile("beacon", filePath);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
describe("syncAll", () => {
|
|
226
|
+
it("syncs all discovered teammates", async () => {
|
|
227
|
+
const beacon = join(testDir, "beacon");
|
|
228
|
+
const scribe = join(testDir, "scribe");
|
|
229
|
+
await mkdir(beacon, { recursive: true });
|
|
230
|
+
await mkdir(scribe, { recursive: true });
|
|
231
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
232
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Beacon wisdom");
|
|
233
|
+
await writeFile(join(scribe, "SOUL.md"), "# Scribe");
|
|
234
|
+
await writeFile(join(scribe, "WISDOM.md"), "# Scribe wisdom");
|
|
235
|
+
const indexer = createIndexer(testDir);
|
|
236
|
+
const results = await indexer.syncAll();
|
|
237
|
+
expect(results.get("beacon")).toBe(1);
|
|
238
|
+
expect(results.get("scribe")).toBe(1);
|
|
239
|
+
});
|
|
240
|
+
it("returns empty map when no teammates exist", async () => {
|
|
241
|
+
const indexer = createIndexer(testDir);
|
|
242
|
+
const results = await indexer.syncAll();
|
|
243
|
+
expect(results.size).toBe(0);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
184
246
|
describe("syncTeammate", () => {
|
|
185
247
|
it("falls back to full index when no index exists", async () => {
|
|
186
248
|
const beacon = join(testDir, "beacon");
|
package/dist/search.d.ts
CHANGED
|
@@ -33,6 +33,10 @@ export interface SearchResult {
|
|
|
33
33
|
/** Content type: "typed_memory", "weekly", "monthly", or "other" */
|
|
34
34
|
contentType?: string;
|
|
35
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Classify a URI into a content type for priority scoring.
|
|
38
|
+
*/
|
|
39
|
+
export declare function classifyUri(uri: string): string;
|
|
36
40
|
/**
|
|
37
41
|
* Search teammate memories using multi-pass retrieval.
|
|
38
42
|
*
|
package/dist/search.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Indexer } from "./indexer.js";
|
|
|
6
6
|
/**
|
|
7
7
|
* Classify a URI into a content type for priority scoring.
|
|
8
8
|
*/
|
|
9
|
-
function classifyUri(uri) {
|
|
9
|
+
export function classifyUri(uri) {
|
|
10
10
|
if (uri.includes("/memory/weekly/"))
|
|
11
11
|
return "weekly";
|
|
12
12
|
if (uri.includes("/memory/monthly/"))
|
package/dist/search.test.js
CHANGED
|
@@ -1,23 +1,46 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Indexer } from "./indexer.js";
|
|
6
|
+
import { classifyUri, multiSearch, search } from "./search.js";
|
|
7
|
+
// Deterministic stub embeddings based on text content
|
|
8
|
+
function stubCreateEmbeddings(inputs) {
|
|
9
|
+
const texts = Array.isArray(inputs) ? inputs : [inputs];
|
|
10
|
+
return {
|
|
11
|
+
status: "success",
|
|
12
|
+
output: texts.map((t) => {
|
|
13
|
+
const vec = new Array(384).fill(0);
|
|
14
|
+
for (let i = 0; i < t.length; i++) {
|
|
15
|
+
vec[i % 384] += t.charCodeAt(i) / 1000;
|
|
16
|
+
}
|
|
17
|
+
return vec;
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
// Mock LocalEmbeddings so search() uses stubs instead of real model
|
|
22
|
+
vi.mock("./embeddings.js", () => ({
|
|
23
|
+
LocalEmbeddings: class {
|
|
24
|
+
maxTokens = 256;
|
|
25
|
+
async createEmbeddings(inputs) {
|
|
26
|
+
return stubCreateEmbeddings(inputs);
|
|
18
27
|
}
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
function createIndexer(teammatesDir) {
|
|
31
|
+
const indexer = new Indexer({ teammatesDir });
|
|
32
|
+
// Indexer also creates LocalEmbeddings internally — already mocked above
|
|
33
|
+
return indexer;
|
|
34
|
+
}
|
|
35
|
+
let testDir;
|
|
36
|
+
beforeEach(async () => {
|
|
37
|
+
testDir = join(tmpdir(), `recall-search-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
38
|
+
await mkdir(testDir, { recursive: true });
|
|
39
|
+
});
|
|
40
|
+
afterEach(async () => {
|
|
41
|
+
await rm(testDir, { recursive: true, force: true });
|
|
42
|
+
});
|
|
43
|
+
describe("classifyUri", () => {
|
|
21
44
|
it("classifies weekly summaries", () => {
|
|
22
45
|
expect(classifyUri("beacon/memory/weekly/2026-W10.md")).toBe("weekly");
|
|
23
46
|
});
|
|
@@ -40,3 +63,224 @@ describe("classifyUri", () => {
|
|
|
40
63
|
expect(classifyUri("beacon/notes/todo.md")).toBe("other");
|
|
41
64
|
});
|
|
42
65
|
});
|
|
66
|
+
describe("search", () => {
|
|
67
|
+
it("returns results from an indexed teammate", async () => {
|
|
68
|
+
const beacon = join(testDir, "beacon");
|
|
69
|
+
const memDir = join(beacon, "memory");
|
|
70
|
+
await mkdir(memDir, { recursive: true });
|
|
71
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
72
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Beacon wisdom about coding");
|
|
73
|
+
await writeFile(join(memDir, "feedback_testing.md"), "# Testing feedback\nAlways run tests before committing.");
|
|
74
|
+
// Pre-build the index with stub embeddings
|
|
75
|
+
const indexer = createIndexer(testDir);
|
|
76
|
+
await indexer.indexTeammate("beacon");
|
|
77
|
+
// Patch search to use stub embeddings by searching with skipSync
|
|
78
|
+
const results = await search("testing feedback", {
|
|
79
|
+
teammatesDir: testDir,
|
|
80
|
+
teammate: "beacon",
|
|
81
|
+
skipSync: true,
|
|
82
|
+
model: "stub", // won't matter since we mock at the index level
|
|
83
|
+
});
|
|
84
|
+
expect(results.length).toBeGreaterThan(0);
|
|
85
|
+
expect(results[0].teammate).toBe("beacon");
|
|
86
|
+
expect(results[0].score).toBeGreaterThan(0);
|
|
87
|
+
});
|
|
88
|
+
it("returns empty results when no index exists", async () => {
|
|
89
|
+
const beacon = join(testDir, "beacon");
|
|
90
|
+
await mkdir(beacon, { recursive: true });
|
|
91
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
92
|
+
const results = await search("anything", {
|
|
93
|
+
teammatesDir: testDir,
|
|
94
|
+
teammate: "beacon",
|
|
95
|
+
skipSync: true,
|
|
96
|
+
});
|
|
97
|
+
expect(results).toEqual([]);
|
|
98
|
+
});
|
|
99
|
+
it("includes recent weekly summaries via recency pass", async () => {
|
|
100
|
+
const beacon = join(testDir, "beacon");
|
|
101
|
+
const weeklyDir = join(beacon, "memory", "weekly");
|
|
102
|
+
await mkdir(weeklyDir, { recursive: true });
|
|
103
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
104
|
+
await writeFile(join(weeklyDir, "2026-W10.md"), "# Week 10\nWorked on search.");
|
|
105
|
+
await writeFile(join(weeklyDir, "2026-W11.md"), "# Week 11\nWorked on indexer.");
|
|
106
|
+
await writeFile(join(weeklyDir, "2026-W09.md"), "# Week 9\nOld stuff.");
|
|
107
|
+
const results = await search("anything", {
|
|
108
|
+
teammatesDir: testDir,
|
|
109
|
+
teammate: "beacon",
|
|
110
|
+
skipSync: true,
|
|
111
|
+
recencyDepth: 2,
|
|
112
|
+
});
|
|
113
|
+
const uris = results.map((r) => r.uri);
|
|
114
|
+
// Should include the 2 most recent weeks (W11 and W10), not W09
|
|
115
|
+
expect(uris).toContain("beacon/memory/weekly/2026-W11.md");
|
|
116
|
+
expect(uris).toContain("beacon/memory/weekly/2026-W10.md");
|
|
117
|
+
expect(uris).not.toContain("beacon/memory/weekly/2026-W09.md");
|
|
118
|
+
});
|
|
119
|
+
it("respects recencyDepth: 0 (no weekly summaries)", async () => {
|
|
120
|
+
const beacon = join(testDir, "beacon");
|
|
121
|
+
const weeklyDir = join(beacon, "memory", "weekly");
|
|
122
|
+
await mkdir(weeklyDir, { recursive: true });
|
|
123
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
124
|
+
await writeFile(join(weeklyDir, "2026-W11.md"), "# Week 11\nContent here.");
|
|
125
|
+
const results = await search("anything", {
|
|
126
|
+
teammatesDir: testDir,
|
|
127
|
+
teammate: "beacon",
|
|
128
|
+
skipSync: true,
|
|
129
|
+
recencyDepth: 0,
|
|
130
|
+
});
|
|
131
|
+
const uris = results.map((r) => r.uri);
|
|
132
|
+
expect(uris).not.toContain("beacon/memory/weekly/2026-W11.md");
|
|
133
|
+
});
|
|
134
|
+
it("searches all teammates when no teammate specified", async () => {
|
|
135
|
+
const beacon = join(testDir, "beacon");
|
|
136
|
+
const scribe = join(testDir, "scribe");
|
|
137
|
+
await mkdir(join(beacon, "memory"), { recursive: true });
|
|
138
|
+
await mkdir(join(scribe, "memory"), { recursive: true });
|
|
139
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
140
|
+
await writeFile(join(scribe, "SOUL.md"), "# Scribe");
|
|
141
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Beacon wisdom");
|
|
142
|
+
await writeFile(join(scribe, "WISDOM.md"), "# Scribe wisdom");
|
|
143
|
+
// Build indexes
|
|
144
|
+
const indexer = createIndexer(testDir);
|
|
145
|
+
await indexer.indexTeammate("beacon");
|
|
146
|
+
await indexer.indexTeammate("scribe");
|
|
147
|
+
const results = await search("wisdom", {
|
|
148
|
+
teammatesDir: testDir,
|
|
149
|
+
skipSync: true,
|
|
150
|
+
});
|
|
151
|
+
const teammates = new Set(results.map((r) => r.teammate));
|
|
152
|
+
expect(teammates.size).toBeGreaterThanOrEqual(1);
|
|
153
|
+
});
|
|
154
|
+
it("deduplicates recency results with semantic results", async () => {
|
|
155
|
+
const beacon = join(testDir, "beacon");
|
|
156
|
+
const weeklyDir = join(beacon, "memory", "weekly");
|
|
157
|
+
await mkdir(weeklyDir, { recursive: true });
|
|
158
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
159
|
+
await writeFile(join(weeklyDir, "2026-W11.md"), "# Week 11\nSearch implementation details.");
|
|
160
|
+
// Build index that includes the weekly file
|
|
161
|
+
const indexer = createIndexer(testDir);
|
|
162
|
+
await indexer.indexTeammate("beacon");
|
|
163
|
+
const results = await search("search implementation", {
|
|
164
|
+
teammatesDir: testDir,
|
|
165
|
+
teammate: "beacon",
|
|
166
|
+
skipSync: true,
|
|
167
|
+
recencyDepth: 2,
|
|
168
|
+
});
|
|
169
|
+
// The weekly file should appear only once despite being picked up by both passes
|
|
170
|
+
const weeklyUris = results.filter((r) => r.uri === "beacon/memory/weekly/2026-W11.md");
|
|
171
|
+
expect(weeklyUris).toHaveLength(1);
|
|
172
|
+
});
|
|
173
|
+
it("applies typed memory boost", async () => {
|
|
174
|
+
const beacon = join(testDir, "beacon");
|
|
175
|
+
const memDir = join(beacon, "memory");
|
|
176
|
+
await mkdir(memDir, { recursive: true });
|
|
177
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
178
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Some wisdom about testing");
|
|
179
|
+
await writeFile(join(memDir, "feedback_testing.md"), "# Testing feedback\nAlways verify test output carefully.");
|
|
180
|
+
const indexer = createIndexer(testDir);
|
|
181
|
+
await indexer.indexTeammate("beacon");
|
|
182
|
+
const results = await search("testing", {
|
|
183
|
+
teammatesDir: testDir,
|
|
184
|
+
teammate: "beacon",
|
|
185
|
+
skipSync: true,
|
|
186
|
+
typedMemoryBoost: 1.5,
|
|
187
|
+
});
|
|
188
|
+
// Find typed memory results — they should have boosted scores
|
|
189
|
+
const typedResults = results.filter((r) => r.contentType === "typed_memory");
|
|
190
|
+
if (typedResults.length > 0) {
|
|
191
|
+
// Just verify the contentType was set correctly
|
|
192
|
+
expect(typedResults[0].contentType).toBe("typed_memory");
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
describe("multiSearch", () => {
|
|
197
|
+
it("merges results from primary and additional queries", async () => {
|
|
198
|
+
const beacon = join(testDir, "beacon");
|
|
199
|
+
const memDir = join(beacon, "memory");
|
|
200
|
+
await mkdir(memDir, { recursive: true });
|
|
201
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
202
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Wisdom about architecture");
|
|
203
|
+
await writeFile(join(memDir, "feedback_code_review.md"), "# Code review feedback\nAlways review pull requests thoroughly.");
|
|
204
|
+
const indexer = createIndexer(testDir);
|
|
205
|
+
await indexer.indexTeammate("beacon");
|
|
206
|
+
const results = await multiSearch("architecture", {
|
|
207
|
+
teammatesDir: testDir,
|
|
208
|
+
teammate: "beacon",
|
|
209
|
+
skipSync: true,
|
|
210
|
+
additionalQueries: ["code review"],
|
|
211
|
+
});
|
|
212
|
+
expect(results.length).toBeGreaterThan(0);
|
|
213
|
+
});
|
|
214
|
+
it("deduplicates by URI — highest score wins", async () => {
|
|
215
|
+
const beacon = join(testDir, "beacon");
|
|
216
|
+
await mkdir(beacon, { recursive: true });
|
|
217
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
218
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Wisdom about testing and code quality");
|
|
219
|
+
const indexer = createIndexer(testDir);
|
|
220
|
+
await indexer.indexTeammate("beacon");
|
|
221
|
+
const results = await multiSearch("testing", {
|
|
222
|
+
teammatesDir: testDir,
|
|
223
|
+
teammate: "beacon",
|
|
224
|
+
skipSync: true,
|
|
225
|
+
additionalQueries: ["testing code quality"],
|
|
226
|
+
});
|
|
227
|
+
// Check no duplicate URIs
|
|
228
|
+
const uris = results.map((r) => r.uri);
|
|
229
|
+
const uniqueUris = new Set(uris);
|
|
230
|
+
expect(uris.length).toBe(uniqueUris.size);
|
|
231
|
+
});
|
|
232
|
+
it("merges catalog matches into results", async () => {
|
|
233
|
+
const beacon = join(testDir, "beacon");
|
|
234
|
+
await mkdir(beacon, { recursive: true });
|
|
235
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
236
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Wisdom");
|
|
237
|
+
const indexer = createIndexer(testDir);
|
|
238
|
+
await indexer.indexTeammate("beacon");
|
|
239
|
+
const catalogMatches = [
|
|
240
|
+
{
|
|
241
|
+
teammate: "beacon",
|
|
242
|
+
uri: "beacon/memory/project_goals.md",
|
|
243
|
+
text: "# Project Goals\nBuild the best recall system.",
|
|
244
|
+
score: 0.92,
|
|
245
|
+
contentType: "typed_memory",
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
const results = await multiSearch("goals", {
|
|
249
|
+
teammatesDir: testDir,
|
|
250
|
+
teammate: "beacon",
|
|
251
|
+
skipSync: true,
|
|
252
|
+
catalogMatches,
|
|
253
|
+
});
|
|
254
|
+
const goalResult = results.find((r) => r.uri === "beacon/memory/project_goals.md");
|
|
255
|
+
expect(goalResult).toBeDefined();
|
|
256
|
+
expect(goalResult.score).toBe(0.92);
|
|
257
|
+
});
|
|
258
|
+
it("additional queries skip recency pass (recencyDepth: 0)", async () => {
|
|
259
|
+
const beacon = join(testDir, "beacon");
|
|
260
|
+
const weeklyDir = join(beacon, "memory", "weekly");
|
|
261
|
+
await mkdir(weeklyDir, { recursive: true });
|
|
262
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
263
|
+
await writeFile(join(weeklyDir, "2026-W11.md"), "# Week 11\nDid some work.");
|
|
264
|
+
const results = await multiSearch("work", {
|
|
265
|
+
teammatesDir: testDir,
|
|
266
|
+
teammate: "beacon",
|
|
267
|
+
skipSync: true,
|
|
268
|
+
recencyDepth: 2,
|
|
269
|
+
additionalQueries: ["more work"],
|
|
270
|
+
});
|
|
271
|
+
// Weekly should appear at most once (from primary, not duplicated from additional)
|
|
272
|
+
const weeklyResults = results.filter((r) => r.uri === "beacon/memory/weekly/2026-W11.md");
|
|
273
|
+
expect(weeklyResults.length).toBeLessThanOrEqual(1);
|
|
274
|
+
});
|
|
275
|
+
it("returns empty when no index and no catalog matches", async () => {
|
|
276
|
+
const beacon = join(testDir, "beacon");
|
|
277
|
+
await mkdir(beacon, { recursive: true });
|
|
278
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
279
|
+
const results = await multiSearch("anything", {
|
|
280
|
+
teammatesDir: testDir,
|
|
281
|
+
teammate: "beacon",
|
|
282
|
+
skipSync: true,
|
|
283
|
+
});
|
|
284
|
+
expect(results).toEqual([]);
|
|
285
|
+
});
|
|
286
|
+
});
|
package/package.json
CHANGED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { LocalEmbeddings } from "./embeddings.js";
|
|
3
|
+
|
|
4
|
+
// Mock @huggingface/transformers to avoid loading the real model
|
|
5
|
+
vi.mock("@huggingface/transformers", () => ({
|
|
6
|
+
pipeline: vi.fn(async () => {
|
|
7
|
+
// Return a fake extractor function
|
|
8
|
+
return async (texts: string[], _opts: any) => ({
|
|
9
|
+
tolist: () => texts.map(() => new Array(384).fill(0.1)),
|
|
10
|
+
});
|
|
11
|
+
}),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
describe("LocalEmbeddings", () => {
|
|
15
|
+
it("has maxTokens set to 256", () => {
|
|
16
|
+
const emb = new LocalEmbeddings();
|
|
17
|
+
expect(emb.maxTokens).toBe(256);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe("createEmbeddings", () => {
|
|
21
|
+
it("returns embeddings for a single string input", async () => {
|
|
22
|
+
const emb = new LocalEmbeddings();
|
|
23
|
+
const result = await emb.createEmbeddings("hello world");
|
|
24
|
+
|
|
25
|
+
expect(result.status).toBe("success");
|
|
26
|
+
expect(result.output).toHaveLength(1);
|
|
27
|
+
expect(result.output![0]).toHaveLength(384);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns embeddings for an array of strings", async () => {
|
|
31
|
+
const emb = new LocalEmbeddings();
|
|
32
|
+
const result = await emb.createEmbeddings(["hello", "world"]);
|
|
33
|
+
|
|
34
|
+
expect(result.status).toBe("success");
|
|
35
|
+
expect(result.output).toHaveLength(2);
|
|
36
|
+
expect(result.output![0]).toHaveLength(384);
|
|
37
|
+
expect(result.output![1]).toHaveLength(384);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns empty output for empty string input", async () => {
|
|
41
|
+
const emb = new LocalEmbeddings();
|
|
42
|
+
const result = await emb.createEmbeddings("");
|
|
43
|
+
|
|
44
|
+
expect(result.status).toBe("success");
|
|
45
|
+
expect(result.output).toHaveLength(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns empty output for whitespace-only input", async () => {
|
|
49
|
+
const emb = new LocalEmbeddings();
|
|
50
|
+
const result = await emb.createEmbeddings(" ");
|
|
51
|
+
|
|
52
|
+
expect(result.status).toBe("success");
|
|
53
|
+
expect(result.output).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("filters out empty strings from array input", async () => {
|
|
57
|
+
const emb = new LocalEmbeddings();
|
|
58
|
+
const result = await emb.createEmbeddings(["hello", "", " ", "world"]);
|
|
59
|
+
|
|
60
|
+
expect(result.status).toBe("success");
|
|
61
|
+
expect(result.output).toHaveLength(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns error status when extractor throws", async () => {
|
|
65
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
66
|
+
// Override mock to throw
|
|
67
|
+
(pipeline as any).mockImplementationOnce(async () => {
|
|
68
|
+
return async () => {
|
|
69
|
+
throw new Error("Model load failed");
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const emb = new LocalEmbeddings("bad-model");
|
|
74
|
+
const result = await emb.createEmbeddings("test");
|
|
75
|
+
|
|
76
|
+
expect(result.status).toBe("error");
|
|
77
|
+
expect(result.message).toBe("Model load failed");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("reuses the extractor on subsequent calls (lazy init)", async () => {
|
|
81
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
82
|
+
(pipeline as any).mockClear();
|
|
83
|
+
|
|
84
|
+
const emb = new LocalEmbeddings();
|
|
85
|
+
await emb.createEmbeddings("first");
|
|
86
|
+
await emb.createEmbeddings("second");
|
|
87
|
+
|
|
88
|
+
// pipeline() should only be called once (lazy singleton)
|
|
89
|
+
expect(pipeline).toHaveBeenCalledTimes(1);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("accepts a custom model name", async () => {
|
|
93
|
+
const { pipeline } = await import("@huggingface/transformers");
|
|
94
|
+
(pipeline as any).mockClear();
|
|
95
|
+
|
|
96
|
+
const emb = new LocalEmbeddings("custom/model-name");
|
|
97
|
+
await emb.createEmbeddings("test");
|
|
98
|
+
|
|
99
|
+
expect(pipeline).toHaveBeenCalledWith(
|
|
100
|
+
"feature-extraction",
|
|
101
|
+
"custom/model-name",
|
|
102
|
+
expect.objectContaining({ dtype: "fp32" }),
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
package/src/indexer.test.ts
CHANGED
|
@@ -107,9 +107,11 @@ describe("Indexer", () => {
|
|
|
107
107
|
expect(uris).toContain("beacon/memory/project_goals.md");
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
it("
|
|
110
|
+
it("includes older daily logs but skips today's", async () => {
|
|
111
111
|
const memDir = join(testDir, "beacon", "memory");
|
|
112
112
|
await mkdir(memDir, { recursive: true });
|
|
113
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
114
|
+
await writeFile(join(memDir, `${today}.md`), "# Today");
|
|
113
115
|
await writeFile(join(memDir, "2026-03-14.md"), "# Day 1");
|
|
114
116
|
await writeFile(join(memDir, "2026-03-15.md"), "# Day 2");
|
|
115
117
|
await writeFile(join(memDir, "feedback_testing.md"), "# Feedback");
|
|
@@ -118,8 +120,9 @@ describe("Indexer", () => {
|
|
|
118
120
|
const { files } = await indexer.collectFiles("beacon");
|
|
119
121
|
|
|
120
122
|
const uris = files.map((f) => f.uri);
|
|
121
|
-
expect(uris).not.toContain(
|
|
122
|
-
expect(uris).
|
|
123
|
+
expect(uris).not.toContain(`beacon/memory/${today}.md`);
|
|
124
|
+
expect(uris).toContain("beacon/memory/2026-03-14.md");
|
|
125
|
+
expect(uris).toContain("beacon/memory/2026-03-15.md");
|
|
123
126
|
expect(uris).toContain("beacon/memory/feedback_testing.md");
|
|
124
127
|
});
|
|
125
128
|
|
|
@@ -230,6 +233,78 @@ describe("Indexer", () => {
|
|
|
230
233
|
});
|
|
231
234
|
});
|
|
232
235
|
|
|
236
|
+
describe("upsertFile", () => {
|
|
237
|
+
it("upserts a single file into a new index", async () => {
|
|
238
|
+
const beacon = join(testDir, "beacon");
|
|
239
|
+
await mkdir(beacon, { recursive: true });
|
|
240
|
+
const filePath = join(beacon, "WISDOM.md");
|
|
241
|
+
await writeFile(filePath, "# Upsert test wisdom");
|
|
242
|
+
|
|
243
|
+
const indexer = createIndexer(testDir);
|
|
244
|
+
await indexer.upsertFile("beacon", filePath);
|
|
245
|
+
|
|
246
|
+
// Verify index was created by syncing (which reads the index)
|
|
247
|
+
const count = await indexer.syncTeammate("beacon");
|
|
248
|
+
expect(count).toBeGreaterThanOrEqual(1);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("upserts into an existing index without rebuilding", async () => {
|
|
252
|
+
const beacon = join(testDir, "beacon");
|
|
253
|
+
const memDir = join(beacon, "memory");
|
|
254
|
+
await mkdir(memDir, { recursive: true });
|
|
255
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Wisdom");
|
|
256
|
+
|
|
257
|
+
const indexer = createIndexer(testDir);
|
|
258
|
+
// Build initial index
|
|
259
|
+
await indexer.indexTeammate("beacon");
|
|
260
|
+
|
|
261
|
+
// Upsert a new file
|
|
262
|
+
const newFile = join(memDir, "feedback_test.md");
|
|
263
|
+
await writeFile(newFile, "# New feedback content");
|
|
264
|
+
await indexer.upsertFile("beacon", newFile);
|
|
265
|
+
|
|
266
|
+
// Sync should see both files
|
|
267
|
+
const count = await indexer.syncTeammate("beacon");
|
|
268
|
+
expect(count).toBe(2);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("skips empty files", async () => {
|
|
272
|
+
const beacon = join(testDir, "beacon");
|
|
273
|
+
await mkdir(beacon, { recursive: true });
|
|
274
|
+
const filePath = join(beacon, "WISDOM.md");
|
|
275
|
+
await writeFile(filePath, " "); // whitespace only
|
|
276
|
+
|
|
277
|
+
const indexer = createIndexer(testDir);
|
|
278
|
+
// Should not throw, just skip
|
|
279
|
+
await indexer.upsertFile("beacon", filePath);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("syncAll", () => {
|
|
284
|
+
it("syncs all discovered teammates", async () => {
|
|
285
|
+
const beacon = join(testDir, "beacon");
|
|
286
|
+
const scribe = join(testDir, "scribe");
|
|
287
|
+
await mkdir(beacon, { recursive: true });
|
|
288
|
+
await mkdir(scribe, { recursive: true });
|
|
289
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
290
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Beacon wisdom");
|
|
291
|
+
await writeFile(join(scribe, "SOUL.md"), "# Scribe");
|
|
292
|
+
await writeFile(join(scribe, "WISDOM.md"), "# Scribe wisdom");
|
|
293
|
+
|
|
294
|
+
const indexer = createIndexer(testDir);
|
|
295
|
+
const results = await indexer.syncAll();
|
|
296
|
+
|
|
297
|
+
expect(results.get("beacon")).toBe(1);
|
|
298
|
+
expect(results.get("scribe")).toBe(1);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("returns empty map when no teammates exist", async () => {
|
|
302
|
+
const indexer = createIndexer(testDir);
|
|
303
|
+
const results = await indexer.syncAll();
|
|
304
|
+
expect(results.size).toBe(0);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
233
308
|
describe("syncTeammate", () => {
|
|
234
309
|
it("falls back to full index when no index exists", async () => {
|
|
235
310
|
const beacon = join(testDir, "beacon");
|
package/src/indexer.ts
CHANGED
|
@@ -74,15 +74,19 @@ export class Indexer {
|
|
|
74
74
|
// No WISDOM.md
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// memory/*.md — typed memories
|
|
77
|
+
// memory/*.md — typed memories + daily logs (day 2+)
|
|
78
|
+
// Today's daily log is excluded (still being written). Older dailies are
|
|
79
|
+
// indexed so recall can surface high-resolution episodic context beyond
|
|
80
|
+
// the 7-day prompt window. Dailies older than 30 days are purged elsewhere.
|
|
78
81
|
const memoryDir = path.join(teammateDir, "memory");
|
|
82
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
79
83
|
try {
|
|
80
84
|
const memoryEntries = await fs.readdir(memoryDir);
|
|
81
85
|
for (const entry of memoryEntries) {
|
|
82
86
|
if (!entry.endsWith(".md")) continue;
|
|
83
87
|
const stem = path.basename(entry, ".md");
|
|
84
|
-
// Skip daily
|
|
85
|
-
if (
|
|
88
|
+
// Skip today's daily log — it's still being written and already in prompt context
|
|
89
|
+
if (stem === today) continue;
|
|
86
90
|
files.push({
|
|
87
91
|
uri: `${teammate}/memory/${entry}`,
|
|
88
92
|
absolutePath: path.join(memoryDir, entry),
|
|
@@ -222,6 +226,25 @@ export class Indexer {
|
|
|
222
226
|
return count;
|
|
223
227
|
}
|
|
224
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Delete a document from a teammate's index by URI.
|
|
231
|
+
* Used to purge stale daily logs after they age out on disk.
|
|
232
|
+
*/
|
|
233
|
+
async deleteDocument(teammate: string, uri: string): Promise<void> {
|
|
234
|
+
const indexPath = this.indexPath(teammate);
|
|
235
|
+
const index = new LocalDocumentIndex({
|
|
236
|
+
folderPath: indexPath,
|
|
237
|
+
embeddings: this._embeddings,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (!(await index.isIndexCreated())) return;
|
|
241
|
+
|
|
242
|
+
const docId = await index.getDocumentId(uri);
|
|
243
|
+
if (docId) {
|
|
244
|
+
await index.deleteDocument(uri);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
225
248
|
/**
|
|
226
249
|
* Sync indexes for all teammates.
|
|
227
250
|
*/
|
package/src/search.test.ts
CHANGED
|
@@ -1,49 +1,386 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { Indexer } from "./indexer.js";
|
|
6
|
+
import { classifyUri, multiSearch, search } from "./search.js";
|
|
7
|
+
import type { SearchResult } from "./search.js";
|
|
8
|
+
|
|
9
|
+
// Deterministic stub embeddings based on text content
|
|
10
|
+
function stubCreateEmbeddings(inputs: string | string[]) {
|
|
11
|
+
const texts = Array.isArray(inputs) ? inputs : [inputs];
|
|
12
|
+
return {
|
|
13
|
+
status: "success" as const,
|
|
14
|
+
output: texts.map((t) => {
|
|
15
|
+
const vec = new Array(384).fill(0);
|
|
16
|
+
for (let i = 0; i < t.length; i++) {
|
|
17
|
+
vec[i % 384] += t.charCodeAt(i) / 1000;
|
|
18
|
+
}
|
|
19
|
+
return vec;
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Mock LocalEmbeddings so search() uses stubs instead of real model
|
|
25
|
+
vi.mock("./embeddings.js", () => ({
|
|
26
|
+
LocalEmbeddings: class {
|
|
27
|
+
readonly maxTokens = 256;
|
|
28
|
+
async createEmbeddings(inputs: string | string[]) {
|
|
29
|
+
return stubCreateEmbeddings(inputs);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
function createIndexer(teammatesDir: string): Indexer {
|
|
35
|
+
const indexer = new Indexer({ teammatesDir });
|
|
36
|
+
// Indexer also creates LocalEmbeddings internally — already mocked above
|
|
37
|
+
return indexer;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let testDir: string;
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
testDir = join(
|
|
44
|
+
tmpdir(),
|
|
45
|
+
`recall-search-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
46
|
+
);
|
|
47
|
+
await mkdir(testDir, { recursive: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(async () => {
|
|
51
|
+
await rm(testDir, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("classifyUri", () => {
|
|
55
|
+
it("classifies weekly summaries", () => {
|
|
56
|
+
expect(classifyUri("beacon/memory/weekly/2026-W10.md")).toBe("weekly");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("classifies monthly summaries", () => {
|
|
60
|
+
expect(classifyUri("beacon/memory/monthly/2025-12.md")).toBe("monthly");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("classifies typed memories", () => {
|
|
64
|
+
expect(classifyUri("beacon/memory/feedback_testing.md")).toBe(
|
|
65
|
+
"typed_memory",
|
|
66
|
+
);
|
|
67
|
+
expect(classifyUri("beacon/memory/project_goals.md")).toBe("typed_memory");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("classifies daily logs", () => {
|
|
71
|
+
expect(classifyUri("beacon/memory/2026-03-14.md")).toBe("daily");
|
|
72
|
+
expect(classifyUri("beacon/memory/2026-01-01.md")).toBe("daily");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("classifies WISDOM.md as other", () => {
|
|
76
|
+
expect(classifyUri("beacon/WISDOM.md")).toBe("other");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("classifies non-memory paths as other", () => {
|
|
80
|
+
expect(classifyUri("beacon/SOUL.md")).toBe("other");
|
|
81
|
+
expect(classifyUri("beacon/notes/todo.md")).toBe("other");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("search", () => {
|
|
86
|
+
it("returns results from an indexed teammate", async () => {
|
|
87
|
+
const beacon = join(testDir, "beacon");
|
|
88
|
+
const memDir = join(beacon, "memory");
|
|
89
|
+
await mkdir(memDir, { recursive: true });
|
|
90
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
91
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Beacon wisdom about coding");
|
|
92
|
+
await writeFile(
|
|
93
|
+
join(memDir, "feedback_testing.md"),
|
|
94
|
+
"# Testing feedback\nAlways run tests before committing.",
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Pre-build the index with stub embeddings
|
|
98
|
+
const indexer = createIndexer(testDir);
|
|
99
|
+
await indexer.indexTeammate("beacon");
|
|
100
|
+
|
|
101
|
+
// Patch search to use stub embeddings by searching with skipSync
|
|
102
|
+
const results = await search("testing feedback", {
|
|
103
|
+
teammatesDir: testDir,
|
|
104
|
+
teammate: "beacon",
|
|
105
|
+
skipSync: true,
|
|
106
|
+
model: "stub", // won't matter since we mock at the index level
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(results.length).toBeGreaterThan(0);
|
|
110
|
+
expect(results[0].teammate).toBe("beacon");
|
|
111
|
+
expect(results[0].score).toBeGreaterThan(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns empty results when no index exists", async () => {
|
|
115
|
+
const beacon = join(testDir, "beacon");
|
|
116
|
+
await mkdir(beacon, { recursive: true });
|
|
117
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
118
|
+
|
|
119
|
+
const results = await search("anything", {
|
|
120
|
+
teammatesDir: testDir,
|
|
121
|
+
teammate: "beacon",
|
|
122
|
+
skipSync: true,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(results).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("includes recent weekly summaries via recency pass", async () => {
|
|
129
|
+
const beacon = join(testDir, "beacon");
|
|
130
|
+
const weeklyDir = join(beacon, "memory", "weekly");
|
|
131
|
+
await mkdir(weeklyDir, { recursive: true });
|
|
132
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
133
|
+
await writeFile(
|
|
134
|
+
join(weeklyDir, "2026-W10.md"),
|
|
135
|
+
"# Week 10\nWorked on search.",
|
|
136
|
+
);
|
|
137
|
+
await writeFile(
|
|
138
|
+
join(weeklyDir, "2026-W11.md"),
|
|
139
|
+
"# Week 11\nWorked on indexer.",
|
|
140
|
+
);
|
|
141
|
+
await writeFile(
|
|
142
|
+
join(weeklyDir, "2026-W09.md"),
|
|
143
|
+
"# Week 9\nOld stuff.",
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const results = await search("anything", {
|
|
147
|
+
teammatesDir: testDir,
|
|
148
|
+
teammate: "beacon",
|
|
149
|
+
skipSync: true,
|
|
150
|
+
recencyDepth: 2,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const uris = results.map((r) => r.uri);
|
|
154
|
+
// Should include the 2 most recent weeks (W11 and W10), not W09
|
|
155
|
+
expect(uris).toContain("beacon/memory/weekly/2026-W11.md");
|
|
156
|
+
expect(uris).toContain("beacon/memory/weekly/2026-W10.md");
|
|
157
|
+
expect(uris).not.toContain("beacon/memory/weekly/2026-W09.md");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("respects recencyDepth: 0 (no weekly summaries)", async () => {
|
|
161
|
+
const beacon = join(testDir, "beacon");
|
|
162
|
+
const weeklyDir = join(beacon, "memory", "weekly");
|
|
163
|
+
await mkdir(weeklyDir, { recursive: true });
|
|
164
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
165
|
+
await writeFile(
|
|
166
|
+
join(weeklyDir, "2026-W11.md"),
|
|
167
|
+
"# Week 11\nContent here.",
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const results = await search("anything", {
|
|
171
|
+
teammatesDir: testDir,
|
|
172
|
+
teammate: "beacon",
|
|
173
|
+
skipSync: true,
|
|
174
|
+
recencyDepth: 0,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const uris = results.map((r) => r.uri);
|
|
178
|
+
expect(uris).not.toContain("beacon/memory/weekly/2026-W11.md");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("searches all teammates when no teammate specified", async () => {
|
|
182
|
+
const beacon = join(testDir, "beacon");
|
|
183
|
+
const scribe = join(testDir, "scribe");
|
|
184
|
+
await mkdir(join(beacon, "memory"), { recursive: true });
|
|
185
|
+
await mkdir(join(scribe, "memory"), { recursive: true });
|
|
186
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
187
|
+
await writeFile(join(scribe, "SOUL.md"), "# Scribe");
|
|
188
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Beacon wisdom");
|
|
189
|
+
await writeFile(join(scribe, "WISDOM.md"), "# Scribe wisdom");
|
|
190
|
+
|
|
191
|
+
// Build indexes
|
|
192
|
+
const indexer = createIndexer(testDir);
|
|
193
|
+
await indexer.indexTeammate("beacon");
|
|
194
|
+
await indexer.indexTeammate("scribe");
|
|
195
|
+
|
|
196
|
+
const results = await search("wisdom", {
|
|
197
|
+
teammatesDir: testDir,
|
|
198
|
+
skipSync: true,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const teammates = new Set(results.map((r) => r.teammate));
|
|
202
|
+
expect(teammates.size).toBeGreaterThanOrEqual(1);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("deduplicates recency results with semantic results", async () => {
|
|
206
|
+
const beacon = join(testDir, "beacon");
|
|
207
|
+
const weeklyDir = join(beacon, "memory", "weekly");
|
|
208
|
+
await mkdir(weeklyDir, { recursive: true });
|
|
209
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
210
|
+
await writeFile(
|
|
211
|
+
join(weeklyDir, "2026-W11.md"),
|
|
212
|
+
"# Week 11\nSearch implementation details.",
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// Build index that includes the weekly file
|
|
216
|
+
const indexer = createIndexer(testDir);
|
|
217
|
+
await indexer.indexTeammate("beacon");
|
|
218
|
+
|
|
219
|
+
const results = await search("search implementation", {
|
|
220
|
+
teammatesDir: testDir,
|
|
221
|
+
teammate: "beacon",
|
|
222
|
+
skipSync: true,
|
|
223
|
+
recencyDepth: 2,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// The weekly file should appear only once despite being picked up by both passes
|
|
227
|
+
const weeklyUris = results.filter(
|
|
228
|
+
(r) => r.uri === "beacon/memory/weekly/2026-W11.md",
|
|
229
|
+
);
|
|
230
|
+
expect(weeklyUris).toHaveLength(1);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("applies typed memory boost", async () => {
|
|
234
|
+
const beacon = join(testDir, "beacon");
|
|
235
|
+
const memDir = join(beacon, "memory");
|
|
236
|
+
await mkdir(memDir, { recursive: true });
|
|
237
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
238
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Some wisdom about testing");
|
|
239
|
+
await writeFile(
|
|
240
|
+
join(memDir, "feedback_testing.md"),
|
|
241
|
+
"# Testing feedback\nAlways verify test output carefully.",
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const indexer = createIndexer(testDir);
|
|
245
|
+
await indexer.indexTeammate("beacon");
|
|
246
|
+
|
|
247
|
+
const results = await search("testing", {
|
|
248
|
+
teammatesDir: testDir,
|
|
249
|
+
teammate: "beacon",
|
|
250
|
+
skipSync: true,
|
|
251
|
+
typedMemoryBoost: 1.5,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Find typed memory results — they should have boosted scores
|
|
255
|
+
const typedResults = results.filter(
|
|
256
|
+
(r) => r.contentType === "typed_memory",
|
|
257
|
+
);
|
|
258
|
+
if (typedResults.length > 0) {
|
|
259
|
+
// Just verify the contentType was set correctly
|
|
260
|
+
expect(typedResults[0].contentType).toBe("typed_memory");
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("multiSearch", () => {
|
|
266
|
+
it("merges results from primary and additional queries", async () => {
|
|
267
|
+
const beacon = join(testDir, "beacon");
|
|
268
|
+
const memDir = join(beacon, "memory");
|
|
269
|
+
await mkdir(memDir, { recursive: true });
|
|
270
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
271
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Wisdom about architecture");
|
|
272
|
+
await writeFile(
|
|
273
|
+
join(memDir, "feedback_code_review.md"),
|
|
274
|
+
"# Code review feedback\nAlways review pull requests thoroughly.",
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const indexer = createIndexer(testDir);
|
|
278
|
+
await indexer.indexTeammate("beacon");
|
|
279
|
+
|
|
280
|
+
const results = await multiSearch("architecture", {
|
|
281
|
+
teammatesDir: testDir,
|
|
282
|
+
teammate: "beacon",
|
|
283
|
+
skipSync: true,
|
|
284
|
+
additionalQueries: ["code review"],
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
expect(results.length).toBeGreaterThan(0);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("deduplicates by URI — highest score wins", async () => {
|
|
291
|
+
const beacon = join(testDir, "beacon");
|
|
292
|
+
await mkdir(beacon, { recursive: true });
|
|
293
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
294
|
+
await writeFile(
|
|
295
|
+
join(beacon, "WISDOM.md"),
|
|
296
|
+
"# Wisdom about testing and code quality",
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
const indexer = createIndexer(testDir);
|
|
300
|
+
await indexer.indexTeammate("beacon");
|
|
301
|
+
|
|
302
|
+
const results = await multiSearch("testing", {
|
|
303
|
+
teammatesDir: testDir,
|
|
304
|
+
teammate: "beacon",
|
|
305
|
+
skipSync: true,
|
|
306
|
+
additionalQueries: ["testing code quality"],
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Check no duplicate URIs
|
|
310
|
+
const uris = results.map((r) => r.uri);
|
|
311
|
+
const uniqueUris = new Set(uris);
|
|
312
|
+
expect(uris.length).toBe(uniqueUris.size);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("merges catalog matches into results", async () => {
|
|
316
|
+
const beacon = join(testDir, "beacon");
|
|
317
|
+
await mkdir(beacon, { recursive: true });
|
|
318
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
319
|
+
await writeFile(join(beacon, "WISDOM.md"), "# Wisdom");
|
|
320
|
+
|
|
321
|
+
const indexer = createIndexer(testDir);
|
|
322
|
+
await indexer.indexTeammate("beacon");
|
|
323
|
+
|
|
324
|
+
const catalogMatches: SearchResult[] = [
|
|
325
|
+
{
|
|
326
|
+
teammate: "beacon",
|
|
327
|
+
uri: "beacon/memory/project_goals.md",
|
|
328
|
+
text: "# Project Goals\nBuild the best recall system.",
|
|
329
|
+
score: 0.92,
|
|
330
|
+
contentType: "typed_memory",
|
|
331
|
+
},
|
|
332
|
+
];
|
|
333
|
+
|
|
334
|
+
const results = await multiSearch("goals", {
|
|
335
|
+
teammatesDir: testDir,
|
|
336
|
+
teammate: "beacon",
|
|
337
|
+
skipSync: true,
|
|
338
|
+
catalogMatches,
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const goalResult = results.find(
|
|
342
|
+
(r) => r.uri === "beacon/memory/project_goals.md",
|
|
343
|
+
);
|
|
344
|
+
expect(goalResult).toBeDefined();
|
|
345
|
+
expect(goalResult!.score).toBe(0.92);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("additional queries skip recency pass (recencyDepth: 0)", async () => {
|
|
349
|
+
const beacon = join(testDir, "beacon");
|
|
350
|
+
const weeklyDir = join(beacon, "memory", "weekly");
|
|
351
|
+
await mkdir(weeklyDir, { recursive: true });
|
|
352
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
353
|
+
await writeFile(
|
|
354
|
+
join(weeklyDir, "2026-W11.md"),
|
|
355
|
+
"# Week 11\nDid some work.",
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const results = await multiSearch("work", {
|
|
359
|
+
teammatesDir: testDir,
|
|
360
|
+
teammate: "beacon",
|
|
361
|
+
skipSync: true,
|
|
362
|
+
recencyDepth: 2,
|
|
363
|
+
additionalQueries: ["more work"],
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// Weekly should appear at most once (from primary, not duplicated from additional)
|
|
367
|
+
const weeklyResults = results.filter(
|
|
368
|
+
(r) => r.uri === "beacon/memory/weekly/2026-W11.md",
|
|
369
|
+
);
|
|
370
|
+
expect(weeklyResults.length).toBeLessThanOrEqual(1);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("returns empty when no index and no catalog matches", async () => {
|
|
374
|
+
const beacon = join(testDir, "beacon");
|
|
375
|
+
await mkdir(beacon, { recursive: true });
|
|
376
|
+
await writeFile(join(beacon, "SOUL.md"), "# Beacon");
|
|
377
|
+
|
|
378
|
+
const results = await multiSearch("anything", {
|
|
379
|
+
teammatesDir: testDir,
|
|
380
|
+
teammate: "beacon",
|
|
381
|
+
skipSync: true,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
expect(results).toEqual([]);
|
|
385
|
+
});
|
|
386
|
+
});
|
package/src/search.ts
CHANGED
|
@@ -45,7 +45,7 @@ export interface SearchResult {
|
|
|
45
45
|
/**
|
|
46
46
|
* Classify a URI into a content type for priority scoring.
|
|
47
47
|
*/
|
|
48
|
-
function classifyUri(uri: string): string {
|
|
48
|
+
export function classifyUri(uri: string): string {
|
|
49
49
|
if (uri.includes("/memory/weekly/")) return "weekly";
|
|
50
50
|
if (uri.includes("/memory/monthly/")) return "monthly";
|
|
51
51
|
// Typed memories are in memory/ but not daily logs (YYYY-MM-DD) and not in subdirs
|