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