@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.
@@ -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 only (skip raw daily logs, they're in prompt context)
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 logs (YYYY-MM-DD) they're already in prompt context
66
- if (/^\d{4}-\d{2}-\d{2}$/.test(stem))
69
+ // Skip today's daily logit'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
  */
@@ -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("skips daily logs (YYYY-MM-DD.md pattern)", async () => {
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("beacon/memory/2026-03-14.md");
96
- expect(uris).not.toContain("beacon/memory/2026-03-15.md");
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/"))
@@ -1,23 +1,46 @@
1
- import { describe, expect, it } from "vitest";
2
- // classifyUri is not exported, so we test it indirectly via a re-implementation
3
- // or we can import the module and test the search function behavior.
4
- // Since classifyUri is a pure function, let's extract and test its logic.
5
- describe("classifyUri", () => {
6
- // Re-implement the classification logic for unit testing
7
- function classifyUri(uri) {
8
- if (uri.includes("/memory/weekly/"))
9
- return "weekly";
10
- if (uri.includes("/memory/monthly/"))
11
- return "monthly";
12
- const memoryMatch = uri.match(/\/memory\/([^/]+)\.md$/);
13
- if (memoryMatch) {
14
- const stem = memoryMatch[1];
15
- if (/^\d{4}-\d{2}-\d{2}$/.test(stem))
16
- return "daily";
17
- return "typed_memory";
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
- return "other";
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teammates/recall",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Local semantic memory search for teammates. Indexes WISDOM.md and memory files using Vectra + transformers.js.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -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
+ });
@@ -107,9 +107,11 @@ describe("Indexer", () => {
107
107
  expect(uris).toContain("beacon/memory/project_goals.md");
108
108
  });
109
109
 
110
- it("skips daily logs (YYYY-MM-DD.md pattern)", async () => {
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("beacon/memory/2026-03-14.md");
122
- expect(uris).not.toContain("beacon/memory/2026-03-15.md");
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 only (skip raw daily logs, they're in prompt context)
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 logs (YYYY-MM-DD) they're already in prompt context
85
- if (/^\d{4}-\d{2}-\d{2}$/.test(stem)) continue;
88
+ // Skip today's daily logit'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
  */
@@ -1,49 +1,386 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- // classifyUri is not exported, so we test it indirectly via a re-implementation
4
- // or we can import the module and test the search function behavior.
5
- // Since classifyUri is a pure function, let's extract and test its logic.
6
-
7
- describe("classifyUri", () => {
8
- // Re-implement the classification logic for unit testing
9
- function classifyUri(uri: string): string {
10
- if (uri.includes("/memory/weekly/")) return "weekly";
11
- if (uri.includes("/memory/monthly/")) return "monthly";
12
- const memoryMatch = uri.match(/\/memory\/([^/]+)\.md$/);
13
- if (memoryMatch) {
14
- const stem = memoryMatch[1];
15
- if (/^\d{4}-\d{2}-\d{2}$/.test(stem)) return "daily";
16
- return "typed_memory";
17
- }
18
- return "other";
19
- }
20
-
21
- it("classifies weekly summaries", () => {
22
- expect(classifyUri("beacon/memory/weekly/2026-W10.md")).toBe("weekly");
23
- });
24
-
25
- it("classifies monthly summaries", () => {
26
- expect(classifyUri("beacon/memory/monthly/2025-12.md")).toBe("monthly");
27
- });
28
-
29
- it("classifies typed memories", () => {
30
- expect(classifyUri("beacon/memory/feedback_testing.md")).toBe(
31
- "typed_memory",
32
- );
33
- expect(classifyUri("beacon/memory/project_goals.md")).toBe("typed_memory");
34
- });
35
-
36
- it("classifies daily logs", () => {
37
- expect(classifyUri("beacon/memory/2026-03-14.md")).toBe("daily");
38
- expect(classifyUri("beacon/memory/2026-01-01.md")).toBe("daily");
39
- });
40
-
41
- it("classifies WISDOM.md as other", () => {
42
- expect(classifyUri("beacon/WISDOM.md")).toBe("other");
43
- });
44
-
45
- it("classifies non-memory paths as other", () => {
46
- expect(classifyUri("beacon/SOUL.md")).toBe("other");
47
- expect(classifyUri("beacon/notes/todo.md")).toBe("other");
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