@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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildQueryVariations, extractKeywords } from "./query-expansion.js";
|
|
3
|
+
describe("extractKeywords", () => {
|
|
4
|
+
it("removes stopwords", () => {
|
|
5
|
+
const result = extractKeywords("the quick brown fox jumps over the lazy dog");
|
|
6
|
+
expect(result).toContain("quick");
|
|
7
|
+
expect(result).toContain("brown");
|
|
8
|
+
expect(result).toContain("fox");
|
|
9
|
+
expect(result).toContain("jumps");
|
|
10
|
+
expect(result).toContain("lazy");
|
|
11
|
+
expect(result).toContain("dog");
|
|
12
|
+
expect(result).not.toContain("the");
|
|
13
|
+
expect(result).not.toContain("over");
|
|
14
|
+
});
|
|
15
|
+
it("filters short tokens (length <= 2)", () => {
|
|
16
|
+
const result = extractKeywords("an AI is a type of ML system");
|
|
17
|
+
expect(result).not.toContain("an");
|
|
18
|
+
expect(result).not.toContain("is");
|
|
19
|
+
// "type" and "system" stay, "AI" and "ML" filtered (length 2)
|
|
20
|
+
expect(result).toContain("type");
|
|
21
|
+
expect(result).toContain("system");
|
|
22
|
+
});
|
|
23
|
+
it("deduplicates while preserving order", () => {
|
|
24
|
+
const result = extractKeywords("recall search recall index search");
|
|
25
|
+
expect(result).toEqual(["recall", "search", "index"]);
|
|
26
|
+
});
|
|
27
|
+
it("lowercases all output", () => {
|
|
28
|
+
const result = extractKeywords("Update the HOOKS spec");
|
|
29
|
+
expect(result).toContain("update");
|
|
30
|
+
expect(result).toContain("hooks");
|
|
31
|
+
expect(result).toContain("spec");
|
|
32
|
+
});
|
|
33
|
+
it("returns empty array for all-stopword input", () => {
|
|
34
|
+
const result = extractKeywords("the is a");
|
|
35
|
+
expect(result).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
it("preserves @mentions and paths", () => {
|
|
38
|
+
const result = extractKeywords("deploy @pipeline src/hooks");
|
|
39
|
+
expect(result).toContain("deploy");
|
|
40
|
+
expect(result).toContain("@pipeline");
|
|
41
|
+
expect(result).toContain("src/hooks");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("buildQueryVariations", () => {
|
|
45
|
+
it("always includes the original prompt as the first query", () => {
|
|
46
|
+
const result = buildQueryVariations("fix the authentication bug");
|
|
47
|
+
expect(result[0]).toBe("fix the authentication bug");
|
|
48
|
+
});
|
|
49
|
+
it("generates a keyword-focused query when prompt is verbose", () => {
|
|
50
|
+
const verbose = "I want you to please update the recall search system so that it handles multiple queries at the same time and deduplicates the results properly";
|
|
51
|
+
const result = buildQueryVariations(verbose);
|
|
52
|
+
expect(result.length).toBeGreaterThanOrEqual(2);
|
|
53
|
+
// The keyword query should be shorter than the original
|
|
54
|
+
if (result.length > 1) {
|
|
55
|
+
expect(result[1].length).toBeLessThan(verbose.length);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
it("adds a conversation-derived query when context is provided", () => {
|
|
59
|
+
const conversationContext = `## Conversation History
|
|
60
|
+
|
|
61
|
+
**stevenic:** lets talk about the CI pipeline and hooks
|
|
62
|
+
|
|
63
|
+
**pipeline:** CI Pipeline Hooks — Analysis`;
|
|
64
|
+
const result = buildQueryVariations("what should we do next?", conversationContext);
|
|
65
|
+
// Should have at least the original + conversation query
|
|
66
|
+
expect(result.length).toBeGreaterThanOrEqual(2);
|
|
67
|
+
});
|
|
68
|
+
it("skips conversation query when no context", () => {
|
|
69
|
+
const result = buildQueryVariations("short task");
|
|
70
|
+
// Short prompts with few keywords may only produce 1 query
|
|
71
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
72
|
+
expect(result[0]).toBe("short task");
|
|
73
|
+
});
|
|
74
|
+
it("does not generate keyword query when prompt is already concise", () => {
|
|
75
|
+
const result = buildQueryVariations("recall search");
|
|
76
|
+
// Very short — keyword query wouldn't differ meaningfully
|
|
77
|
+
expect(result[0]).toBe("recall search");
|
|
78
|
+
});
|
|
79
|
+
});
|
package/dist/search.d.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface SearchOptions {
|
|
|
18
18
|
/** Relevance boost multiplier for typed memories over episodic summaries (default: 1.2) */
|
|
19
19
|
typedMemoryBoost?: number;
|
|
20
20
|
}
|
|
21
|
+
/** Options for multi-query search with deduplication. */
|
|
22
|
+
export interface MultiSearchOptions extends SearchOptions {
|
|
23
|
+
/** Additional queries beyond the primary (keyword-focused, conversation-derived, etc.) */
|
|
24
|
+
additionalQueries?: string[];
|
|
25
|
+
/** Pre-matched memory catalog results to merge into the final set */
|
|
26
|
+
catalogMatches?: SearchResult[];
|
|
27
|
+
}
|
|
21
28
|
export interface SearchResult {
|
|
22
29
|
teammate: string;
|
|
23
30
|
uri: string;
|
|
@@ -26,6 +33,10 @@ export interface SearchResult {
|
|
|
26
33
|
/** Content type: "typed_memory", "weekly", "monthly", or "other" */
|
|
27
34
|
contentType?: string;
|
|
28
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Classify a URI into a content type for priority scoring.
|
|
38
|
+
*/
|
|
39
|
+
export declare function classifyUri(uri: string): string;
|
|
29
40
|
/**
|
|
30
41
|
* Search teammate memories using multi-pass retrieval.
|
|
31
42
|
*
|
|
@@ -34,3 +45,12 @@ export interface SearchResult {
|
|
|
34
45
|
* Results are merged, deduped, and typed memories get a relevance boost.
|
|
35
46
|
*/
|
|
36
47
|
export declare function search(query: string, options: SearchOptions): Promise<SearchResult[]>;
|
|
48
|
+
/**
|
|
49
|
+
* Multi-query search with deduplication and catalog merge.
|
|
50
|
+
*
|
|
51
|
+
* Fires the primary query plus any additional queries (keyword-focused,
|
|
52
|
+
* conversation-derived) and merges results. Catalog matches (from frontmatter
|
|
53
|
+
* text matching) are also merged. Deduplication is by URI — when the same
|
|
54
|
+
* URI appears from multiple queries, the highest score wins.
|
|
55
|
+
*/
|
|
56
|
+
export declare function multiSearch(primaryQuery: string, options: MultiSearchOptions): Promise<SearchResult[]>;
|
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/"))
|
|
@@ -132,3 +132,52 @@ export async function search(query, options) {
|
|
|
132
132
|
allResults.sort((a, b) => b.score - a.score);
|
|
133
133
|
return allResults.slice(0, maxResults + recencyDepth); // allow extra slots for recency results
|
|
134
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Multi-query search with deduplication and catalog merge.
|
|
137
|
+
*
|
|
138
|
+
* Fires the primary query plus any additional queries (keyword-focused,
|
|
139
|
+
* conversation-derived) and merges results. Catalog matches (from frontmatter
|
|
140
|
+
* text matching) are also merged. Deduplication is by URI — when the same
|
|
141
|
+
* URI appears from multiple queries, the highest score wins.
|
|
142
|
+
*/
|
|
143
|
+
export async function multiSearch(primaryQuery, options) {
|
|
144
|
+
const additionalQueries = options.additionalQueries ?? [];
|
|
145
|
+
const catalogMatches = options.catalogMatches ?? [];
|
|
146
|
+
const maxResults = options.maxResults ?? 5;
|
|
147
|
+
const recencyDepth = options.recencyDepth ?? 2;
|
|
148
|
+
// Fire all queries — primary gets full treatment (recency pass + semantic)
|
|
149
|
+
// Additional queries get semantic only (skipRecency to avoid duplicate weeklies)
|
|
150
|
+
const primaryResults = await search(primaryQuery, options);
|
|
151
|
+
// Collect all results keyed by URI, keeping highest score
|
|
152
|
+
const bestByUri = new Map();
|
|
153
|
+
for (const r of primaryResults) {
|
|
154
|
+
const existing = bestByUri.get(r.uri);
|
|
155
|
+
if (!existing || r.score > existing.score) {
|
|
156
|
+
bestByUri.set(r.uri, r);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// Fire additional queries (reuse same search options minus recency to avoid dupes)
|
|
160
|
+
for (const query of additionalQueries) {
|
|
161
|
+
const results = await search(query, {
|
|
162
|
+
...options,
|
|
163
|
+
recencyDepth: 0, // primary already got the weekly summaries
|
|
164
|
+
});
|
|
165
|
+
for (const r of results) {
|
|
166
|
+
const existing = bestByUri.get(r.uri);
|
|
167
|
+
if (!existing || r.score > existing.score) {
|
|
168
|
+
bestByUri.set(r.uri, r);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Merge catalog matches (frontmatter text-matched results)
|
|
173
|
+
for (const r of catalogMatches) {
|
|
174
|
+
const existing = bestByUri.get(r.uri);
|
|
175
|
+
if (!existing || r.score > existing.score) {
|
|
176
|
+
bestByUri.set(r.uri, r);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Sort by score descending, return top results
|
|
180
|
+
const merged = [...bestByUri.values()];
|
|
181
|
+
merged.sort((a, b) => b.score - a.score);
|
|
182
|
+
return merged.slice(0, maxResults + recencyDepth);
|
|
183
|
+
}
|
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/index.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
export { LocalEmbeddings } from "./embeddings.js";
|
|
2
2
|
export { Indexer, type IndexerConfig } from "./indexer.js";
|
|
3
|
-
export {
|
|
3
|
+
export { matchMemoryCatalog, scanMemoryCatalog } from "./memory-index.js";
|
|
4
|
+
export { buildQueryVariations, extractKeywords } from "./query-expansion.js";
|
|
5
|
+
export {
|
|
6
|
+
type MultiSearchOptions,
|
|
7
|
+
type SearchOptions,
|
|
8
|
+
type SearchResult,
|
|
9
|
+
multiSearch,
|
|
10
|
+
search,
|
|
11
|
+
} from "./search.js";
|