@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,324 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ // parseArgs is not exported, so we re-implement the parsing logic for testing.
4
+ // This validates that the arg parsing contract is correct.
5
+
6
+ interface Args {
7
+ command: string;
8
+ query: string;
9
+ file: string;
10
+ dir: string;
11
+ teammate?: string;
12
+ results: number;
13
+ maxChunks?: number;
14
+ maxTokens?: number;
15
+ recencyDepth?: number;
16
+ typedMemoryBoost?: number;
17
+ model?: string;
18
+ json: boolean;
19
+ sync: boolean;
20
+ }
21
+
22
+ function parseArgs(argv: string[]): Args {
23
+ const args: Args = {
24
+ command: "",
25
+ query: "",
26
+ file: "",
27
+ dir: "./.teammates",
28
+ results: 5,
29
+ json: false,
30
+ sync: true,
31
+ };
32
+
33
+ let i = 0;
34
+ while (
35
+ i < argv.length &&
36
+ (argv[i].includes("node") ||
37
+ argv[i].includes("teammates-recall") ||
38
+ argv[i].endsWith(".js"))
39
+ ) {
40
+ i++;
41
+ }
42
+
43
+ if (i < argv.length && !argv[i].startsWith("-")) {
44
+ args.command = argv[i++];
45
+ }
46
+
47
+ if (
48
+ args.command === "search" &&
49
+ i < argv.length &&
50
+ !argv[i].startsWith("-")
51
+ ) {
52
+ args.query = argv[i++];
53
+ } else if (
54
+ args.command === "add" &&
55
+ i < argv.length &&
56
+ !argv[i].startsWith("-")
57
+ ) {
58
+ args.file = argv[i++];
59
+ }
60
+
61
+ while (i < argv.length) {
62
+ const arg = argv[i++];
63
+ switch (arg) {
64
+ case "--dir":
65
+ args.dir = argv[i++];
66
+ break;
67
+ case "--teammate":
68
+ args.teammate = argv[i++];
69
+ break;
70
+ case "--results":
71
+ args.results = parseInt(argv[i++], 10);
72
+ break;
73
+ case "--model":
74
+ args.model = argv[i++];
75
+ break;
76
+ case "--max-chunks":
77
+ args.maxChunks = parseInt(argv[i++], 10);
78
+ break;
79
+ case "--max-tokens":
80
+ args.maxTokens = parseInt(argv[i++], 10);
81
+ break;
82
+ case "--recency-depth":
83
+ args.recencyDepth = parseInt(argv[i++], 10);
84
+ break;
85
+ case "--typed-memory-boost":
86
+ args.typedMemoryBoost = parseFloat(argv[i++]);
87
+ break;
88
+ case "--no-sync":
89
+ args.sync = false;
90
+ break;
91
+ case "--json":
92
+ args.json = true;
93
+ break;
94
+ }
95
+ }
96
+
97
+ return args;
98
+ }
99
+
100
+ describe("parseArgs", () => {
101
+ it("parses search command with query", () => {
102
+ const args = parseArgs(["node", "cli.js", "search", "hello world"]);
103
+ expect(args.command).toBe("search");
104
+ expect(args.query).toBe("hello world");
105
+ });
106
+
107
+ it("parses add command with file path", () => {
108
+ const args = parseArgs([
109
+ "node",
110
+ "cli.js",
111
+ "add",
112
+ "memory/foo.md",
113
+ "--teammate",
114
+ "beacon",
115
+ ]);
116
+ expect(args.command).toBe("add");
117
+ expect(args.file).toBe("memory/foo.md");
118
+ expect(args.teammate).toBe("beacon");
119
+ });
120
+
121
+ it("parses index command", () => {
122
+ const args = parseArgs(["node", "cli.js", "index"]);
123
+ expect(args.command).toBe("index");
124
+ });
125
+
126
+ it("parses sync command", () => {
127
+ const args = parseArgs(["node", "cli.js", "sync"]);
128
+ expect(args.command).toBe("sync");
129
+ });
130
+
131
+ it("parses status command", () => {
132
+ const args = parseArgs(["node", "cli.js", "status"]);
133
+ expect(args.command).toBe("status");
134
+ });
135
+
136
+ it("parses watch command", () => {
137
+ const args = parseArgs(["node", "cli.js", "watch"]);
138
+ expect(args.command).toBe("watch");
139
+ });
140
+
141
+ it("defaults dir to ./.teammates", () => {
142
+ const args = parseArgs(["node", "cli.js", "index"]);
143
+ expect(args.dir).toBe("./.teammates");
144
+ });
145
+
146
+ it("parses --dir flag", () => {
147
+ const args = parseArgs([
148
+ "node",
149
+ "cli.js",
150
+ "index",
151
+ "--dir",
152
+ "/path/to/.teammates",
153
+ ]);
154
+ expect(args.dir).toBe("/path/to/.teammates");
155
+ });
156
+
157
+ it("parses --teammate flag", () => {
158
+ const args = parseArgs([
159
+ "node",
160
+ "cli.js",
161
+ "search",
162
+ "query",
163
+ "--teammate",
164
+ "scribe",
165
+ ]);
166
+ expect(args.teammate).toBe("scribe");
167
+ });
168
+
169
+ it("parses --results flag", () => {
170
+ const args = parseArgs([
171
+ "node",
172
+ "cli.js",
173
+ "search",
174
+ "query",
175
+ "--results",
176
+ "10",
177
+ ]);
178
+ expect(args.results).toBe(10);
179
+ });
180
+
181
+ it("parses --model flag", () => {
182
+ const args = parseArgs([
183
+ "node",
184
+ "cli.js",
185
+ "index",
186
+ "--model",
187
+ "custom/model",
188
+ ]);
189
+ expect(args.model).toBe("custom/model");
190
+ });
191
+
192
+ it("parses --json flag", () => {
193
+ const args = parseArgs(["node", "cli.js", "status", "--json"]);
194
+ expect(args.json).toBe(true);
195
+ });
196
+
197
+ it("defaults json to false", () => {
198
+ const args = parseArgs(["node", "cli.js", "status"]);
199
+ expect(args.json).toBe(false);
200
+ });
201
+
202
+ it("parses --no-sync flag", () => {
203
+ const args = parseArgs(["node", "cli.js", "search", "query", "--no-sync"]);
204
+ expect(args.sync).toBe(false);
205
+ });
206
+
207
+ it("defaults sync to true", () => {
208
+ const args = parseArgs(["node", "cli.js", "search", "query"]);
209
+ expect(args.sync).toBe(true);
210
+ });
211
+
212
+ it("defaults results to 5", () => {
213
+ const args = parseArgs(["node", "cli.js", "search", "query"]);
214
+ expect(args.results).toBe(5);
215
+ });
216
+
217
+ it("returns empty command for no args", () => {
218
+ const args = parseArgs(["node", "cli.js"]);
219
+ expect(args.command).toBe("");
220
+ });
221
+
222
+ it("handles multiple flags together", () => {
223
+ const args = parseArgs([
224
+ "node",
225
+ "cli.js",
226
+ "search",
227
+ "my query",
228
+ "--dir",
229
+ "/tmp/.teammates",
230
+ "--teammate",
231
+ "beacon",
232
+ "--results",
233
+ "3",
234
+ "--json",
235
+ "--no-sync",
236
+ ]);
237
+ expect(args.command).toBe("search");
238
+ expect(args.query).toBe("my query");
239
+ expect(args.dir).toBe("/tmp/.teammates");
240
+ expect(args.teammate).toBe("beacon");
241
+ expect(args.results).toBe(3);
242
+ expect(args.json).toBe(true);
243
+ expect(args.sync).toBe(false);
244
+ });
245
+
246
+ it("parses --max-chunks flag", () => {
247
+ const args = parseArgs([
248
+ "node",
249
+ "cli.js",
250
+ "search",
251
+ "query",
252
+ "--max-chunks",
253
+ "7",
254
+ ]);
255
+ expect(args.maxChunks).toBe(7);
256
+ });
257
+
258
+ it("parses --max-tokens flag", () => {
259
+ const args = parseArgs([
260
+ "node",
261
+ "cli.js",
262
+ "search",
263
+ "query",
264
+ "--max-tokens",
265
+ "1000",
266
+ ]);
267
+ expect(args.maxTokens).toBe(1000);
268
+ });
269
+
270
+ it("parses --recency-depth flag", () => {
271
+ const args = parseArgs([
272
+ "node",
273
+ "cli.js",
274
+ "search",
275
+ "query",
276
+ "--recency-depth",
277
+ "4",
278
+ ]);
279
+ expect(args.recencyDepth).toBe(4);
280
+ });
281
+
282
+ it("parses --typed-memory-boost flag", () => {
283
+ const args = parseArgs([
284
+ "node",
285
+ "cli.js",
286
+ "search",
287
+ "query",
288
+ "--typed-memory-boost",
289
+ "1.5",
290
+ ]);
291
+ expect(args.typedMemoryBoost).toBe(1.5);
292
+ });
293
+
294
+ it("handles multiple new search flags together", () => {
295
+ const args = parseArgs([
296
+ "node",
297
+ "cli.js",
298
+ "search",
299
+ "my query",
300
+ "--max-chunks",
301
+ "5",
302
+ "--max-tokens",
303
+ "800",
304
+ "--recency-depth",
305
+ "3",
306
+ "--typed-memory-boost",
307
+ "2.0",
308
+ ]);
309
+ expect(args.command).toBe("search");
310
+ expect(args.query).toBe("my query");
311
+ expect(args.maxChunks).toBe(5);
312
+ expect(args.maxTokens).toBe(800);
313
+ expect(args.recencyDepth).toBe(3);
314
+ expect(args.typedMemoryBoost).toBe(2.0);
315
+ });
316
+
317
+ it("leaves new flags undefined when not provided", () => {
318
+ const args = parseArgs(["node", "cli.js", "search", "query"]);
319
+ expect(args.maxChunks).toBeUndefined();
320
+ expect(args.maxTokens).toBeUndefined();
321
+ expect(args.recencyDepth).toBeUndefined();
322
+ expect(args.typedMemoryBoost).toBeUndefined();
323
+ });
324
+ });
package/src/cli.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import * as path from "node:path";
3
+ import { type FSWatcher, watch as fsWatch } from "node:fs";
4
4
  import * as fs from "node:fs/promises";
5
+ import * as path from "node:path";
5
6
  import { Indexer } from "./indexer.js";
6
7
  import { search } from "./search.js";
7
8
 
@@ -14,15 +15,20 @@ Usage:
14
15
  teammates-recall add <file> [options] Add a single file to a teammate's index
15
16
  teammates-recall search <query> [options] Search teammate memories (auto-syncs)
16
17
  teammates-recall status [options] Show index status
18
+ teammates-recall watch [options] Watch for changes and auto-sync
17
19
 
18
20
  Options:
19
- --dir <path> Path to .teammates directory (default: ./.teammates)
20
- --teammate <name> Limit to a specific teammate
21
- --results <n> Max results (default: 5)
22
- --model <name> Embedding model (default: Xenova/all-MiniLM-L6-v2)
23
- --no-sync Skip auto-sync before search
24
- --json Output as JSON
25
- --help Show this help
21
+ --dir <path> Path to .teammates directory (default: ./.teammates)
22
+ --teammate <name> Limit to a specific teammate
23
+ --results <n> Max results (default: 5)
24
+ --max-chunks <n> Max chunks per document (default: 3)
25
+ --max-tokens <n> Max tokens per section (default: 500)
26
+ --recency-depth <n> Number of recent weekly summaries to include (default: 2)
27
+ --typed-memory-boost <n> Relevance boost for typed memories (default: 1.2)
28
+ --model <name> Embedding model (default: Xenova/all-MiniLM-L6-v2)
29
+ --no-sync Skip auto-sync before search
30
+ --json Output as JSON
31
+ --help Show this help
26
32
  `.trim();
27
33
 
28
34
  interface Args {
@@ -32,6 +38,10 @@ interface Args {
32
38
  dir: string;
33
39
  teammate?: string;
34
40
  results: number;
41
+ maxChunks?: number;
42
+ maxTokens?: number;
43
+ recencyDepth?: number;
44
+ typedMemoryBoost?: number;
35
45
  model?: string;
36
46
  json: boolean;
37
47
  sync: boolean;
@@ -50,7 +60,12 @@ function parseArgs(argv: string[]): Args {
50
60
 
51
61
  let i = 0;
52
62
  // Skip node and script path
53
- while (i < argv.length && (argv[i].includes("node") || argv[i].includes("teammates-recall") || argv[i].endsWith(".js"))) {
63
+ while (
64
+ i < argv.length &&
65
+ (argv[i].includes("node") ||
66
+ argv[i].includes("teammates-recall") ||
67
+ argv[i].endsWith(".js"))
68
+ ) {
54
69
  i++;
55
70
  }
56
71
 
@@ -59,9 +74,17 @@ function parseArgs(argv: string[]): Args {
59
74
  }
60
75
 
61
76
  // For search, next non-flag arg is the query; for add, it's the file path
62
- if (args.command === "search" && i < argv.length && !argv[i].startsWith("-")) {
77
+ if (
78
+ args.command === "search" &&
79
+ i < argv.length &&
80
+ !argv[i].startsWith("-")
81
+ ) {
63
82
  args.query = argv[i++];
64
- } else if (args.command === "add" && i < argv.length && !argv[i].startsWith("-")) {
83
+ } else if (
84
+ args.command === "add" &&
85
+ i < argv.length &&
86
+ !argv[i].startsWith("-")
87
+ ) {
65
88
  args.file = argv[i++];
66
89
  }
67
90
 
@@ -80,6 +103,18 @@ function parseArgs(argv: string[]): Args {
80
103
  case "--model":
81
104
  args.model = argv[i++];
82
105
  break;
106
+ case "--max-chunks":
107
+ args.maxChunks = parseInt(argv[i++], 10);
108
+ break;
109
+ case "--max-tokens":
110
+ args.maxTokens = parseInt(argv[i++], 10);
111
+ break;
112
+ case "--recency-depth":
113
+ args.recencyDepth = parseInt(argv[i++], 10);
114
+ break;
115
+ case "--typed-memory-boost":
116
+ args.typedMemoryBoost = parseFloat(argv[i++]);
117
+ break;
83
118
  case "--no-sync":
84
119
  args.sync = false;
85
120
  break;
@@ -177,7 +212,13 @@ async function cmdAdd(args: Args): Promise<void> {
177
212
  await indexer.upsertFile(args.teammate, args.file);
178
213
 
179
214
  if (args.json) {
180
- console.log(JSON.stringify({ teammate: args.teammate, file: args.file, status: "ok" }));
215
+ console.log(
216
+ JSON.stringify({
217
+ teammate: args.teammate,
218
+ file: args.file,
219
+ status: "ok",
220
+ }),
221
+ );
181
222
  } else {
182
223
  console.log(`Added ${args.file} to ${args.teammate}'s index`);
183
224
  }
@@ -195,6 +236,10 @@ async function cmdSearch(args: Args): Promise<void> {
195
236
  teammatesDir,
196
237
  teammate: args.teammate,
197
238
  maxResults: args.results,
239
+ maxChunks: args.maxChunks,
240
+ maxTokens: args.maxTokens,
241
+ recencyDepth: args.recencyDepth,
242
+ typedMemoryBoost: args.typedMemoryBoost,
198
243
  model: args.model,
199
244
  skipSync: !args.sync,
200
245
  });
@@ -207,7 +252,9 @@ async function cmdSearch(args: Args): Promise<void> {
207
252
  return;
208
253
  }
209
254
  for (const result of results) {
210
- console.log(`--- ${result.teammate} | ${result.uri} (score: ${result.score.toFixed(3)}) ---`);
255
+ console.log(
256
+ `--- ${result.teammate} | ${result.uri} (score: ${result.score.toFixed(3)}) ---`,
257
+ );
211
258
  console.log(result.text);
212
259
  console.log();
213
260
  }
@@ -244,6 +291,88 @@ async function cmdStatus(args: Args): Promise<void> {
244
291
  }
245
292
  }
246
293
 
294
+ async function cmdWatch(args: Args): Promise<void> {
295
+ const teammatesDir = await resolveTeammatesDir(args.dir);
296
+ const indexer = new Indexer({ teammatesDir, model: args.model });
297
+
298
+ // Initial sync
299
+ console.error("Initial sync...");
300
+ const results = await indexer.syncAll();
301
+ for (const [teammate, count] of results) {
302
+ console.error(` ${teammate}: ${count} files`);
303
+ }
304
+ console.error("Watching for changes...");
305
+
306
+ if (args.json) {
307
+ console.log(JSON.stringify({ status: "watching", dir: teammatesDir }));
308
+ }
309
+
310
+ // Debounce: collect changes, sync after 2s of quiet
311
+ let syncTimer: ReturnType<typeof setTimeout> | null = null;
312
+ const pendingTeammates = new Set<string>();
313
+
314
+ const scheduleSync = (teammate: string) => {
315
+ pendingTeammates.add(teammate);
316
+ if (syncTimer) clearTimeout(syncTimer);
317
+ syncTimer = setTimeout(async () => {
318
+ for (const t of pendingTeammates) {
319
+ try {
320
+ const count = await indexer.syncTeammate(t);
321
+ if (args.json) {
322
+ console.log(
323
+ JSON.stringify({ event: "sync", teammate: t, files: count }),
324
+ );
325
+ } else {
326
+ console.error(` synced ${t}: ${count} files`);
327
+ }
328
+ } catch (err: unknown) {
329
+ const msg = err instanceof Error ? err.message : String(err);
330
+ console.error(` error syncing ${t}: ${msg}`);
331
+ }
332
+ }
333
+ pendingTeammates.clear();
334
+ }, 2000);
335
+ };
336
+
337
+ // Watch each teammate's directory for changes
338
+ const watchers: FSWatcher[] = [];
339
+ const teammates = await indexer.discoverTeammates();
340
+
341
+ for (const teammate of teammates) {
342
+ const teammateDir = path.join(teammatesDir, teammate);
343
+ try {
344
+ const watcher = fsWatch(
345
+ teammateDir,
346
+ { recursive: true },
347
+ (_eventType, filename) => {
348
+ if (!filename) return;
349
+ // Only care about .md files, skip .index/
350
+ if (!filename.endsWith(".md") || filename.includes(".index")) return;
351
+ scheduleSync(teammate);
352
+ },
353
+ );
354
+ watchers.push(watcher);
355
+ } catch {
356
+ console.error(` warning: could not watch ${teammate}/`);
357
+ }
358
+ }
359
+
360
+ // Keep alive until killed
361
+ const shutdown = () => {
362
+ if (syncTimer) clearTimeout(syncTimer);
363
+ for (const w of watchers) w.close();
364
+ if (args.json) {
365
+ console.log(JSON.stringify({ status: "stopped" }));
366
+ }
367
+ process.exit(0);
368
+ };
369
+ process.on("SIGTERM", shutdown);
370
+ process.on("SIGINT", shutdown);
371
+
372
+ // Block forever
373
+ await new Promise(() => {});
374
+ }
375
+
247
376
  async function main(): Promise<void> {
248
377
  const args = parseArgs(process.argv);
249
378
 
@@ -263,6 +392,9 @@ async function main(): Promise<void> {
263
392
  case "status":
264
393
  await cmdStatus(args);
265
394
  break;
395
+ case "watch":
396
+ await cmdWatch(args);
397
+ break;
266
398
  default:
267
399
  console.log(HELP);
268
400
  process.exit(args.command ? 1 : 0);
package/src/embeddings.ts CHANGED
@@ -17,11 +17,13 @@ export class LocalEmbeddings implements EmbeddingsModel {
17
17
  }
18
18
 
19
19
  async createEmbeddings(
20
- inputs: string | string[]
20
+ inputs: string | string[],
21
21
  ): Promise<EmbeddingsResponse> {
22
22
  try {
23
23
  const extractor = await this._getExtractor();
24
- const texts = (Array.isArray(inputs) ? inputs : [inputs]).filter((t) => t.trim().length > 0);
24
+ const texts = (Array.isArray(inputs) ? inputs : [inputs]).filter(
25
+ (t) => t.trim().length > 0,
26
+ );
25
27
  if (texts.length === 0) {
26
28
  return { status: "success", output: [] };
27
29
  }
package/src/index.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  export { LocalEmbeddings } from "./embeddings.js";
2
2
  export { Indexer, type IndexerConfig } from "./indexer.js";
3
- export { search, type SearchOptions, type SearchResult } from "./search.js";
3
+ export { type SearchOptions, type SearchResult, search } from "./search.js";