@teammates/recall 0.1.0 → 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.
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @teammates/recall
2
2
 
3
- Local semantic memory search for teammates. Indexes `MEMORIES.md` and daily logs (`memory/*.md`) using [Vectra](https://github.com/Stevenic/vectra) for vector search and [transformers.js](https://huggingface.co/docs/transformers.js) for embeddings.
3
+ > Part of the [teammates](https://github.com/Stevenic/teammates) monorepo.
4
+
5
+ Local semantic memory search for teammates. Indexes `WISDOM.md` and memory files (`memory/*.md` — daily logs and typed memories) using [Vectra](https://github.com/Stevenic/vectra) for vector search and [transformers.js](https://huggingface.co/docs/transformers.js) for embeddings.
4
6
 
5
7
  **Zero cloud dependencies.** Everything runs locally — embeddings are generated on-device, indexes are stored as local files.
6
8
 
@@ -88,11 +90,21 @@ teammates-recall status --dir ./.teammates
88
90
  ## How It Works
89
91
 
90
92
  1. **Discovers** teammate directories (any folder under `.teammates/` with a `SOUL.md`)
91
- 2. **Collects** memory files: `MEMORIES.md` + `memory/*.md`
93
+ 2. **Collects** memory files: `WISDOM.md` + `memory/*.md` (daily logs and typed memories)
92
94
  3. **Chunks and embeds** text using transformers.js (`Xenova/all-MiniLM-L6-v2`, 384-dim vectors)
93
- 4. **Stores** the index at `.teammates/.index/<teammate>/` (gitignored)
95
+ 4. **Stores** the index at `.teammates/<teammate>/.index/` (gitignored)
94
96
  5. **Searches** using Vectra's semantic similarity matching
95
97
 
98
+ ## Auto-Sync
99
+
100
+ Every `search` call automatically detects new or changed memory files and indexes them before returning results. This is on by default — no manual `sync` or `index` step is needed.
101
+
102
+ **How it works:** The indexer compares file modification times against stored metadata. Only files that are new or changed since the last sync get re-indexed, so the overhead is minimal for most queries.
103
+
104
+ **Skip it when you need speed:** Pass `--no-sync` (CLI) or `skipSync: true` (library) to skip the check entirely. Useful for hot loops or large indexes where you control sync timing separately.
105
+
106
+ **Why this matters for agents:** Agents write memory files as plain markdown — they shouldn't need to know about index state or remember to run a sync command. Auto-sync closes the gap between "file written" and "file searchable" so agents can write-then-search in a single workflow without extra steps.
107
+
96
108
  ## Use From Any Agent
97
109
 
98
110
  Any AI coding tool that can run shell commands can use recall:
@@ -107,7 +119,7 @@ The `--json` flag returns structured results that agents can parse:
107
119
  [
108
120
  {
109
121
  "teammate": "atlas",
110
- "uri": "atlas/MEMORIES.md",
122
+ "uri": "atlas/WISDOM.md",
111
123
  "text": "### 2026-01-15: JWT Auth Pattern\n...",
112
124
  "score": 0.847
113
125
  }
@@ -134,6 +146,8 @@ const results = await search("database migration", {
134
146
  teammatesDir: "./.teammates",
135
147
  teammate: "atlas",
136
148
  maxResults: 5,
149
+ maxChunks: 3, // max chunks per document (default: 3)
150
+ maxTokens: 500, // max tokens per section (default: 500)
137
151
  });
138
152
 
139
153
  // Search without auto-sync
@@ -153,4 +167,4 @@ Default: `Xenova/all-MiniLM-L6-v2` (~23 MB, 384 dimensions)
153
167
 
154
168
  ## Storage
155
169
 
156
- Indexes live at `.teammates/.index/` and are gitignored. They're derived from the markdown source files and can be rebuilt at any time with `teammates-recall index`.
170
+ Indexes live at `.teammates/<teammate>/.index/` and are gitignored. They're derived from the markdown source files and can be rebuilt at any time with `teammates-recall index`.
package/dist/cli.js CHANGED
@@ -1,26 +1,32 @@
1
1
  #!/usr/bin/env node
2
- import * as path from "node:path";
2
+ import { watch as fsWatch } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
+ import * as path from "node:path";
4
5
  import { Indexer } from "./indexer.js";
5
6
  import { search } from "./search.js";
6
- const HELP = `
7
- teammates-recall — Semantic memory search for teammates
8
-
9
- Usage:
10
- teammates-recall index [options] Full rebuild of all indexes
11
- teammates-recall sync [options] Sync new/changed files into indexes
12
- teammates-recall add <file> [options] Add a single file to a teammate's index
13
- teammates-recall search <query> [options] Search teammate memories (auto-syncs)
14
- teammates-recall status [options] Show index status
15
-
16
- Options:
17
- --dir <path> Path to .teammates directory (default: ./.teammates)
18
- --teammate <name> Limit to a specific teammate
19
- --results <n> Max results (default: 5)
20
- --model <name> Embedding model (default: Xenova/all-MiniLM-L6-v2)
21
- --no-sync Skip auto-sync before search
22
- --json Output as JSON
23
- --help Show this help
7
+ const HELP = `
8
+ teammates-recall — Semantic memory search for teammates
9
+
10
+ Usage:
11
+ teammates-recall index [options] Full rebuild of all indexes
12
+ teammates-recall sync [options] Sync new/changed files into indexes
13
+ teammates-recall add <file> [options] Add a single file to a teammate's index
14
+ teammates-recall search <query> [options] Search teammate memories (auto-syncs)
15
+ teammates-recall status [options] Show index status
16
+ teammates-recall watch [options] Watch for changes and auto-sync
17
+
18
+ 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
+ --max-chunks <n> Max chunks per document (default: 3)
23
+ --max-tokens <n> Max tokens per section (default: 500)
24
+ --recency-depth <n> Number of recent weekly summaries to include (default: 2)
25
+ --typed-memory-boost <n> Relevance boost for typed memories (default: 1.2)
26
+ --model <name> Embedding model (default: Xenova/all-MiniLM-L6-v2)
27
+ --no-sync Skip auto-sync before search
28
+ --json Output as JSON
29
+ --help Show this help
24
30
  `.trim();
25
31
  function parseArgs(argv) {
26
32
  const args = {
@@ -34,17 +40,24 @@ function parseArgs(argv) {
34
40
  };
35
41
  let i = 0;
36
42
  // Skip node and script path
37
- while (i < argv.length && (argv[i].includes("node") || argv[i].includes("teammates-recall") || argv[i].endsWith(".js"))) {
43
+ while (i < argv.length &&
44
+ (argv[i].includes("node") ||
45
+ argv[i].includes("teammates-recall") ||
46
+ argv[i].endsWith(".js"))) {
38
47
  i++;
39
48
  }
40
49
  if (i < argv.length && !argv[i].startsWith("-")) {
41
50
  args.command = argv[i++];
42
51
  }
43
52
  // For search, next non-flag arg is the query; for add, it's the file path
44
- if (args.command === "search" && i < argv.length && !argv[i].startsWith("-")) {
53
+ if (args.command === "search" &&
54
+ i < argv.length &&
55
+ !argv[i].startsWith("-")) {
45
56
  args.query = argv[i++];
46
57
  }
47
- else if (args.command === "add" && i < argv.length && !argv[i].startsWith("-")) {
58
+ else if (args.command === "add" &&
59
+ i < argv.length &&
60
+ !argv[i].startsWith("-")) {
48
61
  args.file = argv[i++];
49
62
  }
50
63
  while (i < argv.length) {
@@ -62,6 +75,18 @@ function parseArgs(argv) {
62
75
  case "--model":
63
76
  args.model = argv[i++];
64
77
  break;
78
+ case "--max-chunks":
79
+ args.maxChunks = parseInt(argv[i++], 10);
80
+ break;
81
+ case "--max-tokens":
82
+ args.maxTokens = parseInt(argv[i++], 10);
83
+ break;
84
+ case "--recency-depth":
85
+ args.recencyDepth = parseInt(argv[i++], 10);
86
+ break;
87
+ case "--typed-memory-boost":
88
+ args.typedMemoryBoost = parseFloat(argv[i++]);
89
+ break;
65
90
  case "--no-sync":
66
91
  args.sync = false;
67
92
  break;
@@ -157,7 +182,11 @@ async function cmdAdd(args) {
157
182
  const indexer = new Indexer({ teammatesDir, model: args.model });
158
183
  await indexer.upsertFile(args.teammate, args.file);
159
184
  if (args.json) {
160
- console.log(JSON.stringify({ teammate: args.teammate, file: args.file, status: "ok" }));
185
+ console.log(JSON.stringify({
186
+ teammate: args.teammate,
187
+ file: args.file,
188
+ status: "ok",
189
+ }));
161
190
  }
162
191
  else {
163
192
  console.log(`Added ${args.file} to ${args.teammate}'s index`);
@@ -174,6 +203,10 @@ async function cmdSearch(args) {
174
203
  teammatesDir,
175
204
  teammate: args.teammate,
176
205
  maxResults: args.results,
206
+ maxChunks: args.maxChunks,
207
+ maxTokens: args.maxTokens,
208
+ recencyDepth: args.recencyDepth,
209
+ typedMemoryBoost: args.typedMemoryBoost,
177
210
  model: args.model,
178
211
  skipSync: !args.sync,
179
212
  });
@@ -199,7 +232,7 @@ async function cmdStatus(args) {
199
232
  const status = {};
200
233
  for (const teammate of teammates) {
201
234
  const { files } = await indexer.collectFiles(teammate);
202
- const indexPath = path.join(indexer.indexRoot, teammate);
235
+ const indexPath = indexer.indexPath(teammate);
203
236
  let indexed = false;
204
237
  try {
205
238
  await fs.access(indexPath);
@@ -220,6 +253,81 @@ async function cmdStatus(args) {
220
253
  }
221
254
  }
222
255
  }
256
+ async function cmdWatch(args) {
257
+ const teammatesDir = await resolveTeammatesDir(args.dir);
258
+ const indexer = new Indexer({ teammatesDir, model: args.model });
259
+ // Initial sync
260
+ console.error("Initial sync...");
261
+ const results = await indexer.syncAll();
262
+ for (const [teammate, count] of results) {
263
+ console.error(` ${teammate}: ${count} files`);
264
+ }
265
+ console.error("Watching for changes...");
266
+ if (args.json) {
267
+ console.log(JSON.stringify({ status: "watching", dir: teammatesDir }));
268
+ }
269
+ // Debounce: collect changes, sync after 2s of quiet
270
+ let syncTimer = null;
271
+ const pendingTeammates = new Set();
272
+ const scheduleSync = (teammate) => {
273
+ pendingTeammates.add(teammate);
274
+ if (syncTimer)
275
+ clearTimeout(syncTimer);
276
+ syncTimer = setTimeout(async () => {
277
+ for (const t of pendingTeammates) {
278
+ try {
279
+ const count = await indexer.syncTeammate(t);
280
+ if (args.json) {
281
+ console.log(JSON.stringify({ event: "sync", teammate: t, files: count }));
282
+ }
283
+ else {
284
+ console.error(` synced ${t}: ${count} files`);
285
+ }
286
+ }
287
+ catch (err) {
288
+ const msg = err instanceof Error ? err.message : String(err);
289
+ console.error(` error syncing ${t}: ${msg}`);
290
+ }
291
+ }
292
+ pendingTeammates.clear();
293
+ }, 2000);
294
+ };
295
+ // Watch each teammate's directory for changes
296
+ const watchers = [];
297
+ const teammates = await indexer.discoverTeammates();
298
+ for (const teammate of teammates) {
299
+ const teammateDir = path.join(teammatesDir, teammate);
300
+ try {
301
+ const watcher = fsWatch(teammateDir, { recursive: true }, (_eventType, filename) => {
302
+ if (!filename)
303
+ return;
304
+ // Only care about .md files, skip .index/
305
+ if (!filename.endsWith(".md") || filename.includes(".index"))
306
+ return;
307
+ scheduleSync(teammate);
308
+ });
309
+ watchers.push(watcher);
310
+ }
311
+ catch {
312
+ console.error(` warning: could not watch ${teammate}/`);
313
+ }
314
+ }
315
+ // Keep alive until killed
316
+ const shutdown = () => {
317
+ if (syncTimer)
318
+ clearTimeout(syncTimer);
319
+ for (const w of watchers)
320
+ w.close();
321
+ if (args.json) {
322
+ console.log(JSON.stringify({ status: "stopped" }));
323
+ }
324
+ process.exit(0);
325
+ };
326
+ process.on("SIGTERM", shutdown);
327
+ process.on("SIGINT", shutdown);
328
+ // Block forever
329
+ await new Promise(() => { });
330
+ }
223
331
  async function main() {
224
332
  const args = parseArgs(process.argv);
225
333
  switch (args.command) {
@@ -238,6 +346,9 @@ async function main() {
238
346
  case "status":
239
347
  await cmdStatus(args);
240
348
  break;
349
+ case "watch":
350
+ await cmdWatch(args);
351
+ break;
241
352
  default:
242
353
  console.log(HELP);
243
354
  process.exit(args.command ? 1 : 0);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,270 @@
1
+ import { describe, expect, it } from "vitest";
2
+ function parseArgs(argv) {
3
+ const args = {
4
+ command: "",
5
+ query: "",
6
+ file: "",
7
+ dir: "./.teammates",
8
+ results: 5,
9
+ json: false,
10
+ sync: true,
11
+ };
12
+ let i = 0;
13
+ while (i < argv.length &&
14
+ (argv[i].includes("node") ||
15
+ argv[i].includes("teammates-recall") ||
16
+ argv[i].endsWith(".js"))) {
17
+ i++;
18
+ }
19
+ if (i < argv.length && !argv[i].startsWith("-")) {
20
+ args.command = argv[i++];
21
+ }
22
+ if (args.command === "search" &&
23
+ i < argv.length &&
24
+ !argv[i].startsWith("-")) {
25
+ args.query = argv[i++];
26
+ }
27
+ else if (args.command === "add" &&
28
+ i < argv.length &&
29
+ !argv[i].startsWith("-")) {
30
+ args.file = argv[i++];
31
+ }
32
+ while (i < argv.length) {
33
+ const arg = argv[i++];
34
+ switch (arg) {
35
+ case "--dir":
36
+ args.dir = argv[i++];
37
+ break;
38
+ case "--teammate":
39
+ args.teammate = argv[i++];
40
+ break;
41
+ case "--results":
42
+ args.results = parseInt(argv[i++], 10);
43
+ break;
44
+ case "--model":
45
+ args.model = argv[i++];
46
+ break;
47
+ case "--max-chunks":
48
+ args.maxChunks = parseInt(argv[i++], 10);
49
+ break;
50
+ case "--max-tokens":
51
+ args.maxTokens = parseInt(argv[i++], 10);
52
+ break;
53
+ case "--recency-depth":
54
+ args.recencyDepth = parseInt(argv[i++], 10);
55
+ break;
56
+ case "--typed-memory-boost":
57
+ args.typedMemoryBoost = parseFloat(argv[i++]);
58
+ break;
59
+ case "--no-sync":
60
+ args.sync = false;
61
+ break;
62
+ case "--json":
63
+ args.json = true;
64
+ break;
65
+ }
66
+ }
67
+ return args;
68
+ }
69
+ describe("parseArgs", () => {
70
+ it("parses search command with query", () => {
71
+ const args = parseArgs(["node", "cli.js", "search", "hello world"]);
72
+ expect(args.command).toBe("search");
73
+ expect(args.query).toBe("hello world");
74
+ });
75
+ it("parses add command with file path", () => {
76
+ const args = parseArgs([
77
+ "node",
78
+ "cli.js",
79
+ "add",
80
+ "memory/foo.md",
81
+ "--teammate",
82
+ "beacon",
83
+ ]);
84
+ expect(args.command).toBe("add");
85
+ expect(args.file).toBe("memory/foo.md");
86
+ expect(args.teammate).toBe("beacon");
87
+ });
88
+ it("parses index command", () => {
89
+ const args = parseArgs(["node", "cli.js", "index"]);
90
+ expect(args.command).toBe("index");
91
+ });
92
+ it("parses sync command", () => {
93
+ const args = parseArgs(["node", "cli.js", "sync"]);
94
+ expect(args.command).toBe("sync");
95
+ });
96
+ it("parses status command", () => {
97
+ const args = parseArgs(["node", "cli.js", "status"]);
98
+ expect(args.command).toBe("status");
99
+ });
100
+ it("parses watch command", () => {
101
+ const args = parseArgs(["node", "cli.js", "watch"]);
102
+ expect(args.command).toBe("watch");
103
+ });
104
+ it("defaults dir to ./.teammates", () => {
105
+ const args = parseArgs(["node", "cli.js", "index"]);
106
+ expect(args.dir).toBe("./.teammates");
107
+ });
108
+ it("parses --dir flag", () => {
109
+ const args = parseArgs([
110
+ "node",
111
+ "cli.js",
112
+ "index",
113
+ "--dir",
114
+ "/path/to/.teammates",
115
+ ]);
116
+ expect(args.dir).toBe("/path/to/.teammates");
117
+ });
118
+ it("parses --teammate flag", () => {
119
+ const args = parseArgs([
120
+ "node",
121
+ "cli.js",
122
+ "search",
123
+ "query",
124
+ "--teammate",
125
+ "scribe",
126
+ ]);
127
+ expect(args.teammate).toBe("scribe");
128
+ });
129
+ it("parses --results flag", () => {
130
+ const args = parseArgs([
131
+ "node",
132
+ "cli.js",
133
+ "search",
134
+ "query",
135
+ "--results",
136
+ "10",
137
+ ]);
138
+ expect(args.results).toBe(10);
139
+ });
140
+ it("parses --model flag", () => {
141
+ const args = parseArgs([
142
+ "node",
143
+ "cli.js",
144
+ "index",
145
+ "--model",
146
+ "custom/model",
147
+ ]);
148
+ expect(args.model).toBe("custom/model");
149
+ });
150
+ it("parses --json flag", () => {
151
+ const args = parseArgs(["node", "cli.js", "status", "--json"]);
152
+ expect(args.json).toBe(true);
153
+ });
154
+ it("defaults json to false", () => {
155
+ const args = parseArgs(["node", "cli.js", "status"]);
156
+ expect(args.json).toBe(false);
157
+ });
158
+ it("parses --no-sync flag", () => {
159
+ const args = parseArgs(["node", "cli.js", "search", "query", "--no-sync"]);
160
+ expect(args.sync).toBe(false);
161
+ });
162
+ it("defaults sync to true", () => {
163
+ const args = parseArgs(["node", "cli.js", "search", "query"]);
164
+ expect(args.sync).toBe(true);
165
+ });
166
+ it("defaults results to 5", () => {
167
+ const args = parseArgs(["node", "cli.js", "search", "query"]);
168
+ expect(args.results).toBe(5);
169
+ });
170
+ it("returns empty command for no args", () => {
171
+ const args = parseArgs(["node", "cli.js"]);
172
+ expect(args.command).toBe("");
173
+ });
174
+ it("handles multiple flags together", () => {
175
+ const args = parseArgs([
176
+ "node",
177
+ "cli.js",
178
+ "search",
179
+ "my query",
180
+ "--dir",
181
+ "/tmp/.teammates",
182
+ "--teammate",
183
+ "beacon",
184
+ "--results",
185
+ "3",
186
+ "--json",
187
+ "--no-sync",
188
+ ]);
189
+ expect(args.command).toBe("search");
190
+ expect(args.query).toBe("my query");
191
+ expect(args.dir).toBe("/tmp/.teammates");
192
+ expect(args.teammate).toBe("beacon");
193
+ expect(args.results).toBe(3);
194
+ expect(args.json).toBe(true);
195
+ expect(args.sync).toBe(false);
196
+ });
197
+ it("parses --max-chunks flag", () => {
198
+ const args = parseArgs([
199
+ "node",
200
+ "cli.js",
201
+ "search",
202
+ "query",
203
+ "--max-chunks",
204
+ "7",
205
+ ]);
206
+ expect(args.maxChunks).toBe(7);
207
+ });
208
+ it("parses --max-tokens flag", () => {
209
+ const args = parseArgs([
210
+ "node",
211
+ "cli.js",
212
+ "search",
213
+ "query",
214
+ "--max-tokens",
215
+ "1000",
216
+ ]);
217
+ expect(args.maxTokens).toBe(1000);
218
+ });
219
+ it("parses --recency-depth flag", () => {
220
+ const args = parseArgs([
221
+ "node",
222
+ "cli.js",
223
+ "search",
224
+ "query",
225
+ "--recency-depth",
226
+ "4",
227
+ ]);
228
+ expect(args.recencyDepth).toBe(4);
229
+ });
230
+ it("parses --typed-memory-boost flag", () => {
231
+ const args = parseArgs([
232
+ "node",
233
+ "cli.js",
234
+ "search",
235
+ "query",
236
+ "--typed-memory-boost",
237
+ "1.5",
238
+ ]);
239
+ expect(args.typedMemoryBoost).toBe(1.5);
240
+ });
241
+ it("handles multiple new search flags together", () => {
242
+ const args = parseArgs([
243
+ "node",
244
+ "cli.js",
245
+ "search",
246
+ "my query",
247
+ "--max-chunks",
248
+ "5",
249
+ "--max-tokens",
250
+ "800",
251
+ "--recency-depth",
252
+ "3",
253
+ "--typed-memory-boost",
254
+ "2.0",
255
+ ]);
256
+ expect(args.command).toBe("search");
257
+ expect(args.query).toBe("my query");
258
+ expect(args.maxChunks).toBe(5);
259
+ expect(args.maxTokens).toBe(800);
260
+ expect(args.recencyDepth).toBe(3);
261
+ expect(args.typedMemoryBoost).toBe(2.0);
262
+ });
263
+ it("leaves new flags undefined when not provided", () => {
264
+ const args = parseArgs(["node", "cli.js", "search", "query"]);
265
+ expect(args.maxChunks).toBeUndefined();
266
+ expect(args.maxTokens).toBeUndefined();
267
+ expect(args.recencyDepth).toBeUndefined();
268
+ expect(args.typedMemoryBoost).toBeUndefined();
269
+ });
270
+ });
@@ -13,7 +13,10 @@ export class LocalEmbeddings {
13
13
  async createEmbeddings(inputs) {
14
14
  try {
15
15
  const extractor = await this._getExtractor();
16
- const texts = Array.isArray(inputs) ? inputs : [inputs];
16
+ const texts = (Array.isArray(inputs) ? inputs : [inputs]).filter((t) => t.trim().length > 0);
17
+ if (texts.length === 0) {
18
+ return { status: "success", output: [] };
19
+ }
17
20
  const output = await extractor(texts, {
18
21
  pooling: "mean",
19
22
  normalize: true,
package/dist/index.d.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";
package/dist/indexer.d.ts CHANGED
@@ -12,14 +12,15 @@ interface TeammateFiles {
12
12
  }[];
13
13
  }
14
14
  /**
15
- * Indexes teammate memory files (MEMORIES.md + memory/*.md) into Vectra.
16
- * One index per teammate, stored at .teammates/.index/<name>/
15
+ * Indexes teammate memory files (WISDOM.md + memory/*.md) into Vectra.
16
+ * One index per teammate, stored at .teammates/<name>/.index/
17
17
  */
18
18
  export declare class Indexer {
19
19
  private _config;
20
20
  private _embeddings;
21
21
  constructor(config: IndexerConfig);
22
- get indexRoot(): string;
22
+ /** Get the index path for a specific teammate */
23
+ indexPath(teammate: string): string;
23
24
  /**
24
25
  * Discover all teammate directories (folders containing SOUL.md).
25
26
  */