@teammates/recall 0.4.1 → 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.
@@ -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
@@ -25,6 +25,14 @@ export interface SearchOptions {
25
25
  typedMemoryBoost?: number;
26
26
  }
27
27
 
28
+ /** Options for multi-query search with deduplication. */
29
+ export interface MultiSearchOptions extends SearchOptions {
30
+ /** Additional queries beyond the primary (keyword-focused, conversation-derived, etc.) */
31
+ additionalQueries?: string[];
32
+ /** Pre-matched memory catalog results to merge into the final set */
33
+ catalogMatches?: SearchResult[];
34
+ }
35
+
28
36
  export interface SearchResult {
29
37
  teammate: string;
30
38
  uri: string;
@@ -37,7 +45,7 @@ export interface SearchResult {
37
45
  /**
38
46
  * Classify a URI into a content type for priority scoring.
39
47
  */
40
- function classifyUri(uri: string): string {
48
+ export function classifyUri(uri: string): string {
41
49
  if (uri.includes("/memory/weekly/")) return "weekly";
42
50
  if (uri.includes("/memory/monthly/")) return "monthly";
43
51
  // Typed memories are in memory/ but not daily logs (YYYY-MM-DD) and not in subdirs
@@ -176,3 +184,61 @@ export async function search(
176
184
  allResults.sort((a, b) => b.score - a.score);
177
185
  return allResults.slice(0, maxResults + recencyDepth); // allow extra slots for recency results
178
186
  }
187
+
188
+ /**
189
+ * Multi-query search with deduplication and catalog merge.
190
+ *
191
+ * Fires the primary query plus any additional queries (keyword-focused,
192
+ * conversation-derived) and merges results. Catalog matches (from frontmatter
193
+ * text matching) are also merged. Deduplication is by URI — when the same
194
+ * URI appears from multiple queries, the highest score wins.
195
+ */
196
+ export async function multiSearch(
197
+ primaryQuery: string,
198
+ options: MultiSearchOptions,
199
+ ): Promise<SearchResult[]> {
200
+ const additionalQueries = options.additionalQueries ?? [];
201
+ const catalogMatches = options.catalogMatches ?? [];
202
+ const maxResults = options.maxResults ?? 5;
203
+ const recencyDepth = options.recencyDepth ?? 2;
204
+
205
+ // Fire all queries — primary gets full treatment (recency pass + semantic)
206
+ // Additional queries get semantic only (skipRecency to avoid duplicate weeklies)
207
+ const primaryResults = await search(primaryQuery, options);
208
+
209
+ // Collect all results keyed by URI, keeping highest score
210
+ const bestByUri = new Map<string, SearchResult>();
211
+ for (const r of primaryResults) {
212
+ const existing = bestByUri.get(r.uri);
213
+ if (!existing || r.score > existing.score) {
214
+ bestByUri.set(r.uri, r);
215
+ }
216
+ }
217
+
218
+ // Fire additional queries (reuse same search options minus recency to avoid dupes)
219
+ for (const query of additionalQueries) {
220
+ const results = await search(query, {
221
+ ...options,
222
+ recencyDepth: 0, // primary already got the weekly summaries
223
+ });
224
+ for (const r of results) {
225
+ const existing = bestByUri.get(r.uri);
226
+ if (!existing || r.score > existing.score) {
227
+ bestByUri.set(r.uri, r);
228
+ }
229
+ }
230
+ }
231
+
232
+ // Merge catalog matches (frontmatter text-matched results)
233
+ for (const r of catalogMatches) {
234
+ const existing = bestByUri.get(r.uri);
235
+ if (!existing || r.score > existing.score) {
236
+ bestByUri.set(r.uri, r);
237
+ }
238
+ }
239
+
240
+ // Sort by score descending, return top results
241
+ const merged = [...bestByUri.values()];
242
+ merged.sort((a, b) => b.score - a.score);
243
+ return merged.slice(0, maxResults + recencyDepth);
244
+ }