@teammates/recall 0.1.1 → 0.2.0

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,262 @@
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 } from "vitest";
5
+ import { Indexer } from "./indexer.js";
6
+
7
+ // Stub embeddings — we don't want to load the real model in tests
8
+ class StubEmbeddings {
9
+ readonly maxTokens = 256;
10
+ async createEmbeddings(inputs: string | string[]) {
11
+ const texts = Array.isArray(inputs) ? inputs : [inputs];
12
+ return {
13
+ status: "success" as const,
14
+ output: texts.map(() => new Array(384).fill(0).map(() => Math.random())),
15
+ };
16
+ }
17
+ }
18
+
19
+ // Create an Indexer with stubbed embeddings
20
+ function createIndexer(teammatesDir: string): Indexer {
21
+ const indexer = new Indexer({ teammatesDir });
22
+ // Swap out the real embeddings with our stub
23
+ (indexer as any)._embeddings = new StubEmbeddings();
24
+ return indexer;
25
+ }
26
+
27
+ let testDir: string;
28
+
29
+ beforeEach(async () => {
30
+ testDir = join(
31
+ tmpdir(),
32
+ `recall-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
33
+ );
34
+ await mkdir(testDir, { recursive: true });
35
+ });
36
+
37
+ afterEach(async () => {
38
+ await rm(testDir, { recursive: true, force: true });
39
+ });
40
+
41
+ describe("Indexer", () => {
42
+ describe("discoverTeammates", () => {
43
+ it("finds directories containing SOUL.md", async () => {
44
+ const beacon = join(testDir, "beacon");
45
+ const scribe = join(testDir, "scribe");
46
+ const notTeammate = join(testDir, "random");
47
+
48
+ await mkdir(beacon, { recursive: true });
49
+ await mkdir(scribe, { recursive: true });
50
+ await mkdir(notTeammate, { recursive: true });
51
+
52
+ await writeFile(join(beacon, "SOUL.md"), "# Beacon");
53
+ await writeFile(join(scribe, "SOUL.md"), "# Scribe");
54
+ // notTeammate has no SOUL.md
55
+
56
+ const indexer = createIndexer(testDir);
57
+ const teammates = await indexer.discoverTeammates();
58
+
59
+ expect(teammates).toContain("beacon");
60
+ expect(teammates).toContain("scribe");
61
+ expect(teammates).not.toContain("random");
62
+ });
63
+
64
+ it("ignores dot-prefixed directories", async () => {
65
+ const hidden = join(testDir, ".tmp");
66
+ await mkdir(hidden, { recursive: true });
67
+ await writeFile(join(hidden, "SOUL.md"), "# Hidden");
68
+
69
+ const indexer = createIndexer(testDir);
70
+ const teammates = await indexer.discoverTeammates();
71
+
72
+ expect(teammates).not.toContain(".tmp");
73
+ expect(teammates).toHaveLength(0);
74
+ });
75
+
76
+ it("returns empty array when no teammates exist", async () => {
77
+ const indexer = createIndexer(testDir);
78
+ const teammates = await indexer.discoverTeammates();
79
+ expect(teammates).toEqual([]);
80
+ });
81
+ });
82
+
83
+ describe("collectFiles", () => {
84
+ it("collects WISDOM.md", async () => {
85
+ const beacon = join(testDir, "beacon");
86
+ await mkdir(beacon, { recursive: true });
87
+ await writeFile(join(beacon, "WISDOM.md"), "# Wisdom");
88
+
89
+ const indexer = createIndexer(testDir);
90
+ const { files } = await indexer.collectFiles("beacon");
91
+
92
+ expect(files).toHaveLength(1);
93
+ expect(files[0].uri).toBe("beacon/WISDOM.md");
94
+ });
95
+
96
+ it("collects typed memory files from memory/", async () => {
97
+ const memDir = join(testDir, "beacon", "memory");
98
+ await mkdir(memDir, { recursive: true });
99
+ await writeFile(join(memDir, "feedback_testing.md"), "# Feedback");
100
+ await writeFile(join(memDir, "project_goals.md"), "# Goals");
101
+
102
+ const indexer = createIndexer(testDir);
103
+ const { files } = await indexer.collectFiles("beacon");
104
+
105
+ const uris = files.map((f) => f.uri);
106
+ expect(uris).toContain("beacon/memory/feedback_testing.md");
107
+ expect(uris).toContain("beacon/memory/project_goals.md");
108
+ });
109
+
110
+ it("skips daily logs (YYYY-MM-DD.md pattern)", async () => {
111
+ const memDir = join(testDir, "beacon", "memory");
112
+ await mkdir(memDir, { recursive: true });
113
+ await writeFile(join(memDir, "2026-03-14.md"), "# Day 1");
114
+ await writeFile(join(memDir, "2026-03-15.md"), "# Day 2");
115
+ await writeFile(join(memDir, "feedback_testing.md"), "# Feedback");
116
+
117
+ const indexer = createIndexer(testDir);
118
+ const { files } = await indexer.collectFiles("beacon");
119
+
120
+ 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).toContain("beacon/memory/feedback_testing.md");
124
+ });
125
+
126
+ it("collects weekly summaries from memory/weekly/", async () => {
127
+ const weeklyDir = join(testDir, "beacon", "memory", "weekly");
128
+ await mkdir(weeklyDir, { recursive: true });
129
+ await writeFile(join(weeklyDir, "2026-W10.md"), "# Week 10");
130
+ await writeFile(join(weeklyDir, "2026-W11.md"), "# Week 11");
131
+
132
+ const indexer = createIndexer(testDir);
133
+ const { files } = await indexer.collectFiles("beacon");
134
+
135
+ const uris = files.map((f) => f.uri);
136
+ expect(uris).toContain("beacon/memory/weekly/2026-W10.md");
137
+ expect(uris).toContain("beacon/memory/weekly/2026-W11.md");
138
+ });
139
+
140
+ it("collects monthly summaries from memory/monthly/", async () => {
141
+ const monthlyDir = join(testDir, "beacon", "memory", "monthly");
142
+ await mkdir(monthlyDir, { recursive: true });
143
+ await writeFile(join(monthlyDir, "2025-12.md"), "# Dec 2025");
144
+
145
+ const indexer = createIndexer(testDir);
146
+ const { files } = await indexer.collectFiles("beacon");
147
+
148
+ const uris = files.map((f) => f.uri);
149
+ expect(uris).toContain("beacon/memory/monthly/2025-12.md");
150
+ });
151
+
152
+ it("skips non-md files", async () => {
153
+ const memDir = join(testDir, "beacon", "memory");
154
+ await mkdir(memDir, { recursive: true });
155
+ await writeFile(join(memDir, "notes.txt"), "not markdown");
156
+ await writeFile(join(memDir, "feedback_test.md"), "# Feedback");
157
+
158
+ const indexer = createIndexer(testDir);
159
+ const { files } = await indexer.collectFiles("beacon");
160
+
161
+ expect(files).toHaveLength(1);
162
+ expect(files[0].uri).toBe("beacon/memory/feedback_test.md");
163
+ });
164
+
165
+ it("returns empty files when teammate has no content", async () => {
166
+ await mkdir(join(testDir, "beacon"), { recursive: true });
167
+
168
+ const indexer = createIndexer(testDir);
169
+ const { files } = await indexer.collectFiles("beacon");
170
+ expect(files).toEqual([]);
171
+ });
172
+ });
173
+
174
+ describe("indexPath", () => {
175
+ it("returns correct path under teammate directory", () => {
176
+ const indexer = createIndexer(testDir);
177
+ const p = indexer.indexPath("beacon");
178
+ expect(p).toBe(join(testDir, "beacon", ".index"));
179
+ });
180
+ });
181
+
182
+ describe("indexTeammate", () => {
183
+ it("creates an index and returns file count", async () => {
184
+ const beacon = join(testDir, "beacon");
185
+ const memDir = join(beacon, "memory");
186
+ await mkdir(memDir, { recursive: true });
187
+ await writeFile(join(beacon, "WISDOM.md"), "# Wisdom content");
188
+ await writeFile(join(memDir, "feedback_test.md"), "# Feedback content");
189
+
190
+ const indexer = createIndexer(testDir);
191
+ const count = await indexer.indexTeammate("beacon");
192
+
193
+ expect(count).toBe(2);
194
+ });
195
+
196
+ it("returns 0 when no files to index", async () => {
197
+ await mkdir(join(testDir, "beacon"), { recursive: true });
198
+
199
+ const indexer = createIndexer(testDir);
200
+ const count = await indexer.indexTeammate("beacon");
201
+ expect(count).toBe(0);
202
+ });
203
+
204
+ it("skips empty files", async () => {
205
+ const beacon = join(testDir, "beacon");
206
+ await mkdir(beacon, { recursive: true });
207
+ await writeFile(join(beacon, "WISDOM.md"), " "); // whitespace only
208
+
209
+ const indexer = createIndexer(testDir);
210
+ const count = await indexer.indexTeammate("beacon");
211
+ expect(count).toBe(0);
212
+ });
213
+ });
214
+
215
+ describe("indexAll", () => {
216
+ it("indexes all discovered teammates", async () => {
217
+ const beacon = join(testDir, "beacon");
218
+ const scribe = join(testDir, "scribe");
219
+ await mkdir(beacon, { recursive: true });
220
+ await mkdir(scribe, { recursive: true });
221
+ await writeFile(join(beacon, "SOUL.md"), "# Beacon");
222
+ await writeFile(join(beacon, "WISDOM.md"), "# Beacon wisdom");
223
+ await writeFile(join(scribe, "SOUL.md"), "# Scribe");
224
+
225
+ const indexer = createIndexer(testDir);
226
+ const results = await indexer.indexAll();
227
+
228
+ expect(results.get("beacon")).toBe(1); // WISDOM.md only (SOUL.md not collected)
229
+ expect(results.get("scribe")).toBe(0); // no indexable files
230
+ });
231
+ });
232
+
233
+ describe("syncTeammate", () => {
234
+ it("falls back to full index when no index exists", async () => {
235
+ const beacon = join(testDir, "beacon");
236
+ await mkdir(beacon, { recursive: true });
237
+ await writeFile(join(beacon, "WISDOM.md"), "# Wisdom");
238
+
239
+ const indexer = createIndexer(testDir);
240
+ const count = await indexer.syncTeammate("beacon");
241
+ expect(count).toBe(1);
242
+ });
243
+
244
+ it("upserts files into existing index", async () => {
245
+ const beacon = join(testDir, "beacon");
246
+ const memDir = join(beacon, "memory");
247
+ await mkdir(memDir, { recursive: true });
248
+ await writeFile(join(beacon, "WISDOM.md"), "# Wisdom");
249
+
250
+ const indexer = createIndexer(testDir);
251
+ // First build the index
252
+ await indexer.indexTeammate("beacon");
253
+
254
+ // Add a new file
255
+ await writeFile(join(memDir, "project_goals.md"), "# Goals");
256
+
257
+ // Sync should pick up the new file
258
+ const count = await indexer.syncTeammate("beacon");
259
+ expect(count).toBe(2); // WISDOM + project_goals
260
+ });
261
+ });
262
+ });
package/src/indexer.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { LocalDocumentIndex } from "vectra";
2
- import { LocalEmbeddings } from "./embeddings.js";
3
1
  import * as fs from "node:fs/promises";
4
2
  import * as path from "node:path";
3
+ import { LocalDocumentIndex } from "vectra";
4
+ import { LocalEmbeddings } from "./embeddings.js";
5
5
 
6
6
  export interface IndexerConfig {
7
7
  /** Path to the .teammates directory */
@@ -16,7 +16,7 @@ interface TeammateFiles {
16
16
  }
17
17
 
18
18
  /**
19
- * Indexes teammate memory files (MEMORIES.md + memory/*.md) into Vectra.
19
+ * Indexes teammate memory files (WISDOM.md + memory/*.md) into Vectra.
20
20
  * One index per teammate, stored at .teammates/<name>/.index/
21
21
  */
22
22
  export class Indexer {
@@ -46,7 +46,7 @@ export class Indexer {
46
46
  const soulPath = path.join(
47
47
  this._config.teammatesDir,
48
48
  entry.name,
49
- "SOUL.md"
49
+ "SOUL.md",
50
50
  );
51
51
  try {
52
52
  await fs.access(soulPath);
@@ -65,21 +65,24 @@ export class Indexer {
65
65
  const teammateDir = path.join(this._config.teammatesDir, teammate);
66
66
  const files: TeammateFiles["files"] = [];
67
67
 
68
- // MEMORIES.md
69
- const memoriesPath = path.join(teammateDir, "MEMORIES.md");
68
+ // WISDOM.md
69
+ const wisdomPath = path.join(teammateDir, "WISDOM.md");
70
70
  try {
71
- await fs.access(memoriesPath);
72
- files.push({ uri: `${teammate}/MEMORIES.md`, absolutePath: memoriesPath });
71
+ await fs.access(wisdomPath);
72
+ files.push({ uri: `${teammate}/WISDOM.md`, absolutePath: wisdomPath });
73
73
  } catch {
74
- // No MEMORIES.md
74
+ // No WISDOM.md
75
75
  }
76
76
 
77
- // memory/*.md (daily logs)
77
+ // memory/*.md — typed memories only (skip raw daily logs, they're in prompt context)
78
78
  const memoryDir = path.join(teammateDir, "memory");
79
79
  try {
80
80
  const memoryEntries = await fs.readdir(memoryDir);
81
81
  for (const entry of memoryEntries) {
82
82
  if (!entry.endsWith(".md")) continue;
83
+ 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;
83
86
  files.push({
84
87
  uri: `${teammate}/memory/${entry}`,
85
88
  absolutePath: path.join(memoryDir, entry),
@@ -89,6 +92,36 @@ export class Indexer {
89
92
  // No memory/ directory
90
93
  }
91
94
 
95
+ // memory/weekly/*.md — weekly summaries (primary episodic search surface)
96
+ const weeklyDir = path.join(memoryDir, "weekly");
97
+ try {
98
+ const weeklyEntries = await fs.readdir(weeklyDir);
99
+ for (const entry of weeklyEntries) {
100
+ if (!entry.endsWith(".md")) continue;
101
+ files.push({
102
+ uri: `${teammate}/memory/weekly/${entry}`,
103
+ absolutePath: path.join(weeklyDir, entry),
104
+ });
105
+ }
106
+ } catch {
107
+ // No weekly/ directory
108
+ }
109
+
110
+ // memory/monthly/*.md — monthly summaries (long-term episodic context)
111
+ const monthlyDir = path.join(memoryDir, "monthly");
112
+ try {
113
+ const monthlyEntries = await fs.readdir(monthlyDir);
114
+ for (const entry of monthlyEntries) {
115
+ if (!entry.endsWith(".md")) continue;
116
+ files.push({
117
+ uri: `${teammate}/memory/monthly/${entry}`,
118
+ absolutePath: path.join(monthlyDir, entry),
119
+ });
120
+ }
121
+ } catch {
122
+ // No monthly/ directory
123
+ }
124
+
92
125
  return { teammate, files };
93
126
  }
94
127
 
@@ -0,0 +1,49 @@
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
+ });
package/src/search.ts CHANGED
@@ -1,8 +1,8 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
1
3
  import { LocalDocumentIndex } from "vectra";
2
4
  import { LocalEmbeddings } from "./embeddings.js";
3
5
  import { Indexer } from "./indexer.js";
4
- import * as path from "node:path";
5
- import * as fs from "node:fs/promises";
6
6
 
7
7
  export interface SearchOptions {
8
8
  /** Path to the .teammates directory */
@@ -19,6 +19,10 @@ export interface SearchOptions {
19
19
  model?: string;
20
20
  /** Skip auto-sync before searching (default: false) */
21
21
  skipSync?: boolean;
22
+ /** Number of recent weekly summaries to always include (default: 2) */
23
+ recencyDepth?: number;
24
+ /** Relevance boost multiplier for typed memories over episodic summaries (default: 1.2) */
25
+ typedMemoryBoost?: number;
22
26
  }
23
27
 
24
28
  export interface SearchResult {
@@ -26,20 +30,47 @@ export interface SearchResult {
26
30
  uri: string;
27
31
  text: string;
28
32
  score: number;
33
+ /** Content type: "typed_memory", "weekly", "monthly", or "other" */
34
+ contentType?: string;
35
+ }
36
+
37
+ /**
38
+ * Classify a URI into a content type for priority scoring.
39
+ */
40
+ function classifyUri(uri: string): string {
41
+ if (uri.includes("/memory/weekly/")) return "weekly";
42
+ if (uri.includes("/memory/monthly/")) return "monthly";
43
+ // Typed memories are in memory/ but not daily logs (YYYY-MM-DD) and not in subdirs
44
+ const memoryMatch = uri.match(/\/memory\/([^/]+)\.md$/);
45
+ if (memoryMatch) {
46
+ const stem = memoryMatch[1];
47
+ if (/^\d{4}-\d{2}-\d{2}$/.test(stem)) return "daily";
48
+ return "typed_memory";
49
+ }
50
+ return "other";
29
51
  }
30
52
 
31
53
  /**
32
- * Search teammate memories using semantic + keyword search.
54
+ * Search teammate memories using multi-pass retrieval.
55
+ *
56
+ * Pass 1 (Recency): Always returns the N most recent weekly summaries.
57
+ * Pass 2 (Semantic): Query-driven search across all indexed content.
58
+ * Results are merged, deduped, and typed memories get a relevance boost.
33
59
  */
34
60
  export async function search(
35
61
  query: string,
36
- options: SearchOptions
62
+ options: SearchOptions,
37
63
  ): Promise<SearchResult[]> {
38
64
  const embeddings = new LocalEmbeddings(options.model);
39
- const indexer = new Indexer({ teammatesDir: options.teammatesDir, model: options.model });
65
+ const indexer = new Indexer({
66
+ teammatesDir: options.teammatesDir,
67
+ model: options.model,
68
+ });
40
69
  const maxResults = options.maxResults ?? 5;
41
70
  const maxChunks = options.maxChunks ?? 3;
42
71
  const maxTokens = options.maxTokens ?? 500;
72
+ const recencyDepth = options.recencyDepth ?? 2;
73
+ const typedMemoryBoost = options.typedMemoryBoost ?? 1.2;
43
74
 
44
75
  // Auto-sync: upsert any new/changed files before searching
45
76
  if (!options.skipSync) {
@@ -59,13 +90,49 @@ export async function search(
59
90
  }
60
91
 
61
92
  const allResults: SearchResult[] = [];
93
+ const seenUris = new Set<string>();
62
94
 
95
+ // ── Pass 1: Recency (recent weekly summaries, always included) ───
96
+ for (const teammate of teammates) {
97
+ const weeklyDir = path.join(
98
+ options.teammatesDir,
99
+ teammate,
100
+ "memory",
101
+ "weekly",
102
+ );
103
+ try {
104
+ const entries = await fs.readdir(weeklyDir);
105
+ const weeklyFiles = entries
106
+ .filter((e) => e.endsWith(".md"))
107
+ .sort()
108
+ .reverse()
109
+ .slice(0, recencyDepth);
110
+
111
+ for (const file of weeklyFiles) {
112
+ const uri = `${teammate}/memory/weekly/${file}`;
113
+ const text = await fs.readFile(path.join(weeklyDir, file), "utf-8");
114
+ if (text.trim().length === 0) continue;
115
+ seenUris.add(uri);
116
+ allResults.push({
117
+ teammate,
118
+ uri,
119
+ text: text.slice(0, maxTokens * 4), // rough token estimate
120
+ score: 0.9, // high base score for recency results
121
+ contentType: "weekly",
122
+ });
123
+ }
124
+ } catch {
125
+ // No weekly/ directory for this teammate
126
+ }
127
+ }
128
+
129
+ // ── Pass 2: Semantic (query-driven across all indexed content) ───
63
130
  for (const teammate of teammates) {
64
131
  const indexPath = indexer.indexPath(teammate);
65
132
  try {
66
133
  await fs.access(indexPath);
67
134
  } catch {
68
- continue; // No index for this teammate
135
+ continue;
69
136
  }
70
137
 
71
138
  const index = new LocalDocumentIndex({
@@ -81,13 +148,25 @@ export async function search(
81
148
  });
82
149
 
83
150
  for (const doc of docs) {
151
+ if (seenUris.has(doc.uri)) continue; // dedup with recency pass
152
+ seenUris.add(doc.uri);
153
+
84
154
  const sections = await doc.renderSections(maxTokens, 1);
155
+ const contentType = classifyUri(doc.uri);
156
+
85
157
  for (const section of sections) {
158
+ let score = section.score;
159
+ // Apply type-based priority boost for typed memories
160
+ if (contentType === "typed_memory") {
161
+ score *= typedMemoryBoost;
162
+ }
163
+
86
164
  allResults.push({
87
165
  teammate,
88
166
  uri: doc.uri,
89
167
  text: section.text,
90
- score: section.score,
168
+ score,
169
+ contentType,
91
170
  });
92
171
  }
93
172
  }
@@ -95,5 +174,5 @@ export async function search(
95
174
 
96
175
  // Sort by score descending, return top results
97
176
  allResults.sort((a, b) => b.score - a.score);
98
- return allResults.slice(0, maxResults);
177
+ return allResults.slice(0, maxResults + recencyDepth); // allow extra slots for recency results
99
178
  }
@@ -0,0 +1,12 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["src/**/*.test.ts"],
6
+ coverage: {
7
+ provider: "v8",
8
+ reporter: ["text", "json-summary"],
9
+ reportsDirectory: "coverage",
10
+ },
11
+ },
12
+ });