@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.
- package/dist/embeddings.test.d.ts +1 -0
- package/dist/embeddings.test.js +81 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/indexer.d.ts +5 -0
- package/dist/indexer.js +24 -3
- package/dist/indexer.test.js +65 -3
- package/dist/memory-index.d.ts +34 -0
- package/dist/memory-index.js +118 -0
- package/dist/memory-index.test.d.ts +1 -0
- package/dist/memory-index.test.js +96 -0
- package/dist/query-expansion.d.ts +20 -0
- package/dist/query-expansion.js +92 -0
- package/dist/query-expansion.test.d.ts +1 -0
- package/dist/query-expansion.test.js +79 -0
- package/dist/search.d.ts +20 -0
- package/dist/search.js +50 -1
- package/dist/search.test.js +263 -19
- package/package.json +1 -1
- package/src/embeddings.test.ts +106 -0
- package/src/index.ts +9 -1
- package/src/indexer.test.ts +78 -3
- package/src/indexer.ts +26 -3
- package/src/memory-index.test.ts +149 -0
- package/src/memory-index.ts +151 -0
- package/src/query-expansion.test.ts +90 -0
- package/src/query-expansion.ts +105 -0
- package/src/search.test.ts +386 -49
- package/src/search.ts +67 -1
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
|
@@ -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
|
+
}
|