@teammates/recall 0.6.1 → 0.6.3

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/src/indexer.ts CHANGED
@@ -1,260 +1,260 @@
1
- import * as fs from "node:fs/promises";
2
- import * as path from "node:path";
3
- import { LocalDocumentIndex } from "vectra";
4
- import { LocalEmbeddings } from "./embeddings.js";
5
-
6
- export interface IndexerConfig {
7
- /** Path to the .teammates directory */
8
- teammatesDir: string;
9
- /** Embedding model name (default: Xenova/all-MiniLM-L6-v2) */
10
- model?: string;
11
- }
12
-
13
- interface TeammateFiles {
14
- teammate: string;
15
- files: { uri: string; absolutePath: string }[];
16
- }
17
-
18
- /**
19
- * Indexes teammate memory files (WISDOM.md + memory/*.md) into Vectra.
20
- * One index per teammate, stored at .teammates/<name>/.index/
21
- */
22
- export class Indexer {
23
- private _config: IndexerConfig;
24
- private _embeddings: LocalEmbeddings;
25
-
26
- constructor(config: IndexerConfig) {
27
- this._config = config;
28
- this._embeddings = new LocalEmbeddings(config.model);
29
- }
30
-
31
- /** Get the index path for a specific teammate */
32
- indexPath(teammate: string): string {
33
- return path.join(this._config.teammatesDir, teammate, ".index");
34
- }
35
-
36
- /**
37
- * Discover all teammate directories (folders containing SOUL.md).
38
- */
39
- async discoverTeammates(): Promise<string[]> {
40
- const entries = await fs.readdir(this._config.teammatesDir, {
41
- withFileTypes: true,
42
- });
43
- const teammates: string[] = [];
44
- for (const entry of entries) {
45
- if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
46
- const soulPath = path.join(
47
- this._config.teammatesDir,
48
- entry.name,
49
- "SOUL.md",
50
- );
51
- try {
52
- await fs.access(soulPath);
53
- teammates.push(entry.name);
54
- } catch {
55
- // Not a teammate folder
56
- }
57
- }
58
- return teammates;
59
- }
60
-
61
- /**
62
- * Collect all indexable memory files for a teammate.
63
- */
64
- async collectFiles(teammate: string): Promise<TeammateFiles> {
65
- const teammateDir = path.join(this._config.teammatesDir, teammate);
66
- const files: TeammateFiles["files"] = [];
67
-
68
- // WISDOM.md
69
- const wisdomPath = path.join(teammateDir, "WISDOM.md");
70
- try {
71
- await fs.access(wisdomPath);
72
- files.push({ uri: `${teammate}/WISDOM.md`, absolutePath: wisdomPath });
73
- } catch {
74
- // No WISDOM.md
75
- }
76
-
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.
81
- const memoryDir = path.join(teammateDir, "memory");
82
- const today = new Date().toISOString().slice(0, 10);
83
- try {
84
- const memoryEntries = await fs.readdir(memoryDir);
85
- for (const entry of memoryEntries) {
86
- if (!entry.endsWith(".md")) continue;
87
- const stem = path.basename(entry, ".md");
88
- // Skip today's daily log — it's still being written and already in prompt context
89
- if (stem === today) continue;
90
- files.push({
91
- uri: `${teammate}/memory/${entry}`,
92
- absolutePath: path.join(memoryDir, entry),
93
- });
94
- }
95
- } catch {
96
- // No memory/ directory
97
- }
98
-
99
- // memory/weekly/*.md — weekly summaries (primary episodic search surface)
100
- const weeklyDir = path.join(memoryDir, "weekly");
101
- try {
102
- const weeklyEntries = await fs.readdir(weeklyDir);
103
- for (const entry of weeklyEntries) {
104
- if (!entry.endsWith(".md")) continue;
105
- files.push({
106
- uri: `${teammate}/memory/weekly/${entry}`,
107
- absolutePath: path.join(weeklyDir, entry),
108
- });
109
- }
110
- } catch {
111
- // No weekly/ directory
112
- }
113
-
114
- // memory/monthly/*.md — monthly summaries (long-term episodic context)
115
- const monthlyDir = path.join(memoryDir, "monthly");
116
- try {
117
- const monthlyEntries = await fs.readdir(monthlyDir);
118
- for (const entry of monthlyEntries) {
119
- if (!entry.endsWith(".md")) continue;
120
- files.push({
121
- uri: `${teammate}/memory/monthly/${entry}`,
122
- absolutePath: path.join(monthlyDir, entry),
123
- });
124
- }
125
- } catch {
126
- // No monthly/ directory
127
- }
128
-
129
- return { teammate, files };
130
- }
131
-
132
- /**
133
- * Build or rebuild the index for a single teammate.
134
- */
135
- async indexTeammate(teammate: string): Promise<number> {
136
- const { files } = await this.collectFiles(teammate);
137
- if (files.length === 0) return 0;
138
-
139
- const indexPath = this.indexPath(teammate);
140
- const index = new LocalDocumentIndex({
141
- folderPath: indexPath,
142
- embeddings: this._embeddings,
143
- });
144
-
145
- // Recreate index from scratch
146
- await index.createIndex({ version: 1, deleteIfExists: true });
147
-
148
- let count = 0;
149
- for (const file of files) {
150
- const text = await fs.readFile(file.absolutePath, "utf-8");
151
- if (text.trim().length === 0) continue;
152
- await index.upsertDocument(file.uri, text, "md");
153
- count++;
154
- }
155
-
156
- return count;
157
- }
158
-
159
- /**
160
- * Build or rebuild indexes for all teammates.
161
- */
162
- async indexAll(): Promise<Map<string, number>> {
163
- const teammates = await this.discoverTeammates();
164
- const results = new Map<string, number>();
165
- for (const teammate of teammates) {
166
- const count = await this.indexTeammate(teammate);
167
- results.set(teammate, count);
168
- }
169
- return results;
170
- }
171
-
172
- /**
173
- * Upsert a single file into an existing teammate index.
174
- * Creates the index if it doesn't exist yet.
175
- */
176
- async upsertFile(teammate: string, filePath: string): Promise<void> {
177
- const teammateDir = path.join(this._config.teammatesDir, teammate);
178
- const absolutePath = path.resolve(filePath);
179
- const relativePath = path.relative(teammateDir, absolutePath);
180
- const uri = `${teammate}/${relativePath.replace(/\\/g, "/")}`;
181
-
182
- const text = await fs.readFile(absolutePath, "utf-8");
183
- if (text.trim().length === 0) return;
184
-
185
- const indexPath = this.indexPath(teammate);
186
- const index = new LocalDocumentIndex({
187
- folderPath: indexPath,
188
- embeddings: this._embeddings,
189
- });
190
-
191
- if (!(await index.isIndexCreated())) {
192
- await index.createIndex({ version: 1 });
193
- }
194
-
195
- await index.upsertDocument(uri, text, "md");
196
- }
197
-
198
- /**
199
- * Sync a teammate's index with their current memory files.
200
- * Upserts new/changed files without a full rebuild.
201
- */
202
- async syncTeammate(teammate: string): Promise<number> {
203
- const { files } = await this.collectFiles(teammate);
204
- if (files.length === 0) return 0;
205
-
206
- const indexPath = this.indexPath(teammate);
207
- const index = new LocalDocumentIndex({
208
- folderPath: indexPath,
209
- embeddings: this._embeddings,
210
- });
211
-
212
- if (!(await index.isIndexCreated())) {
213
- // No index yet — do a full build
214
- return this.indexTeammate(teammate);
215
- }
216
-
217
- // Upsert all files (Vectra handles dedup internally via URI)
218
- let count = 0;
219
- for (const file of files) {
220
- const text = await fs.readFile(file.absolutePath, "utf-8");
221
- if (text.trim().length === 0) continue;
222
- await index.upsertDocument(file.uri, text, "md");
223
- count++;
224
- }
225
-
226
- return count;
227
- }
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
-
248
- /**
249
- * Sync indexes for all teammates.
250
- */
251
- async syncAll(): Promise<Map<string, number>> {
252
- const teammates = await this.discoverTeammates();
253
- const results = new Map<string, number>();
254
- for (const teammate of teammates) {
255
- const count = await this.syncTeammate(teammate);
256
- results.set(teammate, count);
257
- }
258
- return results;
259
- }
260
- }
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { LocalDocumentIndex } from "vectra";
4
+ import { LocalEmbeddings } from "./embeddings.js";
5
+
6
+ export interface IndexerConfig {
7
+ /** Path to the .teammates directory */
8
+ teammatesDir: string;
9
+ /** Embedding model name (default: Xenova/all-MiniLM-L6-v2) */
10
+ model?: string;
11
+ }
12
+
13
+ interface TeammateFiles {
14
+ teammate: string;
15
+ files: { uri: string; absolutePath: string }[];
16
+ }
17
+
18
+ /**
19
+ * Indexes teammate memory files (WISDOM.md + memory/*.md) into Vectra.
20
+ * One index per teammate, stored at .teammates/<name>/.index/
21
+ */
22
+ export class Indexer {
23
+ private _config: IndexerConfig;
24
+ private _embeddings: LocalEmbeddings;
25
+
26
+ constructor(config: IndexerConfig) {
27
+ this._config = config;
28
+ this._embeddings = new LocalEmbeddings(config.model);
29
+ }
30
+
31
+ /** Get the index path for a specific teammate */
32
+ indexPath(teammate: string): string {
33
+ return path.join(this._config.teammatesDir, teammate, ".index");
34
+ }
35
+
36
+ /**
37
+ * Discover all teammate directories (folders containing SOUL.md).
38
+ */
39
+ async discoverTeammates(): Promise<string[]> {
40
+ const entries = await fs.readdir(this._config.teammatesDir, {
41
+ withFileTypes: true,
42
+ });
43
+ const teammates: string[] = [];
44
+ for (const entry of entries) {
45
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
46
+ const soulPath = path.join(
47
+ this._config.teammatesDir,
48
+ entry.name,
49
+ "SOUL.md",
50
+ );
51
+ try {
52
+ await fs.access(soulPath);
53
+ teammates.push(entry.name);
54
+ } catch {
55
+ // Not a teammate folder
56
+ }
57
+ }
58
+ return teammates;
59
+ }
60
+
61
+ /**
62
+ * Collect all indexable memory files for a teammate.
63
+ */
64
+ async collectFiles(teammate: string): Promise<TeammateFiles> {
65
+ const teammateDir = path.join(this._config.teammatesDir, teammate);
66
+ const files: TeammateFiles["files"] = [];
67
+
68
+ // WISDOM.md
69
+ const wisdomPath = path.join(teammateDir, "WISDOM.md");
70
+ try {
71
+ await fs.access(wisdomPath);
72
+ files.push({ uri: `${teammate}/WISDOM.md`, absolutePath: wisdomPath });
73
+ } catch {
74
+ // No WISDOM.md
75
+ }
76
+
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.
81
+ const memoryDir = path.join(teammateDir, "memory");
82
+ const today = new Date().toISOString().slice(0, 10);
83
+ try {
84
+ const memoryEntries = await fs.readdir(memoryDir);
85
+ for (const entry of memoryEntries) {
86
+ if (!entry.endsWith(".md")) continue;
87
+ const stem = path.basename(entry, ".md");
88
+ // Skip today's daily log — it's still being written and already in prompt context
89
+ if (stem === today) continue;
90
+ files.push({
91
+ uri: `${teammate}/memory/${entry}`,
92
+ absolutePath: path.join(memoryDir, entry),
93
+ });
94
+ }
95
+ } catch {
96
+ // No memory/ directory
97
+ }
98
+
99
+ // memory/weekly/*.md — weekly summaries (primary episodic search surface)
100
+ const weeklyDir = path.join(memoryDir, "weekly");
101
+ try {
102
+ const weeklyEntries = await fs.readdir(weeklyDir);
103
+ for (const entry of weeklyEntries) {
104
+ if (!entry.endsWith(".md")) continue;
105
+ files.push({
106
+ uri: `${teammate}/memory/weekly/${entry}`,
107
+ absolutePath: path.join(weeklyDir, entry),
108
+ });
109
+ }
110
+ } catch {
111
+ // No weekly/ directory
112
+ }
113
+
114
+ // memory/monthly/*.md — monthly summaries (long-term episodic context)
115
+ const monthlyDir = path.join(memoryDir, "monthly");
116
+ try {
117
+ const monthlyEntries = await fs.readdir(monthlyDir);
118
+ for (const entry of monthlyEntries) {
119
+ if (!entry.endsWith(".md")) continue;
120
+ files.push({
121
+ uri: `${teammate}/memory/monthly/${entry}`,
122
+ absolutePath: path.join(monthlyDir, entry),
123
+ });
124
+ }
125
+ } catch {
126
+ // No monthly/ directory
127
+ }
128
+
129
+ return { teammate, files };
130
+ }
131
+
132
+ /**
133
+ * Build or rebuild the index for a single teammate.
134
+ */
135
+ async indexTeammate(teammate: string): Promise<number> {
136
+ const { files } = await this.collectFiles(teammate);
137
+ if (files.length === 0) return 0;
138
+
139
+ const indexPath = this.indexPath(teammate);
140
+ const index = new LocalDocumentIndex({
141
+ folderPath: indexPath,
142
+ embeddings: this._embeddings,
143
+ });
144
+
145
+ // Recreate index from scratch
146
+ await index.createIndex({ version: 1, deleteIfExists: true });
147
+
148
+ let count = 0;
149
+ for (const file of files) {
150
+ const text = await fs.readFile(file.absolutePath, "utf-8");
151
+ if (text.trim().length === 0) continue;
152
+ await index.upsertDocument(file.uri, text, "md");
153
+ count++;
154
+ }
155
+
156
+ return count;
157
+ }
158
+
159
+ /**
160
+ * Build or rebuild indexes for all teammates.
161
+ */
162
+ async indexAll(): Promise<Map<string, number>> {
163
+ const teammates = await this.discoverTeammates();
164
+ const results = new Map<string, number>();
165
+ for (const teammate of teammates) {
166
+ const count = await this.indexTeammate(teammate);
167
+ results.set(teammate, count);
168
+ }
169
+ return results;
170
+ }
171
+
172
+ /**
173
+ * Upsert a single file into an existing teammate index.
174
+ * Creates the index if it doesn't exist yet.
175
+ */
176
+ async upsertFile(teammate: string, filePath: string): Promise<void> {
177
+ const teammateDir = path.join(this._config.teammatesDir, teammate);
178
+ const absolutePath = path.resolve(filePath);
179
+ const relativePath = path.relative(teammateDir, absolutePath);
180
+ const uri = `${teammate}/${relativePath.replace(/\\/g, "/")}`;
181
+
182
+ const text = await fs.readFile(absolutePath, "utf-8");
183
+ if (text.trim().length === 0) return;
184
+
185
+ const indexPath = this.indexPath(teammate);
186
+ const index = new LocalDocumentIndex({
187
+ folderPath: indexPath,
188
+ embeddings: this._embeddings,
189
+ });
190
+
191
+ if (!(await index.isIndexCreated())) {
192
+ await index.createIndex({ version: 1 });
193
+ }
194
+
195
+ await index.upsertDocument(uri, text, "md");
196
+ }
197
+
198
+ /**
199
+ * Sync a teammate's index with their current memory files.
200
+ * Upserts new/changed files without a full rebuild.
201
+ */
202
+ async syncTeammate(teammate: string): Promise<number> {
203
+ const { files } = await this.collectFiles(teammate);
204
+ if (files.length === 0) return 0;
205
+
206
+ const indexPath = this.indexPath(teammate);
207
+ const index = new LocalDocumentIndex({
208
+ folderPath: indexPath,
209
+ embeddings: this._embeddings,
210
+ });
211
+
212
+ if (!(await index.isIndexCreated())) {
213
+ // No index yet — do a full build
214
+ return this.indexTeammate(teammate);
215
+ }
216
+
217
+ // Upsert all files (Vectra handles dedup internally via URI)
218
+ let count = 0;
219
+ for (const file of files) {
220
+ const text = await fs.readFile(file.absolutePath, "utf-8");
221
+ if (text.trim().length === 0) continue;
222
+ await index.upsertDocument(file.uri, text, "md");
223
+ count++;
224
+ }
225
+
226
+ return count;
227
+ }
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
+
248
+ /**
249
+ * Sync indexes for all teammates.
250
+ */
251
+ async syncAll(): Promise<Map<string, number>> {
252
+ const teammates = await this.discoverTeammates();
253
+ const results = new Map<string, number>();
254
+ for (const teammate of teammates) {
255
+ const count = await this.syncTeammate(teammate);
256
+ results.set(teammate, count);
257
+ }
258
+ return results;
259
+ }
260
+ }
@@ -25,7 +25,9 @@ interface MemoryEntry {
25
25
  * Parse YAML-ish frontmatter from a markdown file's content.
26
26
  * Returns name and description fields, or null if no frontmatter found.
27
27
  */
28
- function parseFrontmatter(content: string): { name: string; description: string } | null {
28
+ function parseFrontmatter(
29
+ content: string,
30
+ ): { name: string; description: string } | null {
29
31
  const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
30
32
  if (!match) return null;
31
33
 
@@ -3,7 +3,9 @@ import { buildQueryVariations, extractKeywords } from "./query-expansion.js";
3
3
 
4
4
  describe("extractKeywords", () => {
5
5
  it("removes stopwords", () => {
6
- const result = extractKeywords("the quick brown fox jumps over the lazy dog");
6
+ const result = extractKeywords(
7
+ "the quick brown fox jumps over the lazy dog",
8
+ );
7
9
  expect(result).toContain("quick");
8
10
  expect(result).toContain("brown");
9
11
  expect(result).toContain("fox");
@@ -55,7 +57,8 @@ describe("buildQueryVariations", () => {
55
57
  });
56
58
 
57
59
  it("generates a keyword-focused query when prompt is verbose", () => {
58
- 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";
60
+ const verbose =
61
+ "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";
59
62
  const result = buildQueryVariations(verbose);
60
63
  expect(result.length).toBeGreaterThanOrEqual(2);
61
64
  // The keyword query should be shorter than the original
@@ -70,7 +73,10 @@ describe("buildQueryVariations", () => {
70
73
  **stevenic:** lets talk about the CI pipeline and hooks
71
74
 
72
75
  **pipeline:** CI Pipeline Hooks — Analysis`;
73
- const result = buildQueryVariations("what should we do next?", conversationContext);
76
+ const result = buildQueryVariations(
77
+ "what should we do next?",
78
+ conversationContext,
79
+ );
74
80
  // Should have at least the original + conversation query
75
81
  expect(result.length).toBeGreaterThanOrEqual(2);
76
82
  });