@astrocyteai/local 0.1.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/.pnpm-config.json +1 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +276 -0
- package/dist/context-tree.d.ts +35 -0
- package/dist/context-tree.js +228 -0
- package/dist/curated-retain.d.ts +41 -0
- package/dist/curated-retain.js +118 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +10 -0
- package/dist/mcp-server.d.ts +18 -0
- package/dist/mcp-server.js +212 -0
- package/dist/search.d.ts +45 -0
- package/dist/search.js +174 -0
- package/dist/tiered-retrieval.d.ts +53 -0
- package/dist/tiered-retrieval.js +187 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +5 -0
- package/package.json +55 -0
- package/src/cli.ts +304 -0
- package/src/context-tree.ts +243 -0
- package/src/curated-retain.ts +160 -0
- package/src/index.ts +21 -0
- package/src/mcp-server.ts +282 -0
- package/src/search.ts +249 -0
- package/src/tiered-retrieval.ts +229 -0
- package/src/types.ts +68 -0
- package/tests/context-tree.test.ts +209 -0
- package/tests/curated-retain.test.ts +142 -0
- package/tests/mcp-server.test.ts +163 -0
- package/tests/search.test.ts +188 -0
- package/tests/tiered-retrieval.test.ts +270 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +8 -0
package/src/search.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search engine — SQLite FTS5 full-text search.
|
|
3
|
+
*
|
|
4
|
+
* See docs/search-contract.md for behavior specification.
|
|
5
|
+
* All operations are sync. Uses better-sqlite3 for SQLite access.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import Database from "better-sqlite3";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import type { MemoryEntry, SearchHit } from "./types.js";
|
|
12
|
+
import type { ContextTree } from "./context-tree.js";
|
|
13
|
+
|
|
14
|
+
export class SearchEngine {
|
|
15
|
+
private db: Database.Database;
|
|
16
|
+
|
|
17
|
+
constructor(private dbPath: string) {
|
|
18
|
+
const dir = path.dirname(dbPath);
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
20
|
+
this.db = new Database(dbPath);
|
|
21
|
+
this.db.pragma("journal_mode = WAL");
|
|
22
|
+
this.createTables();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private createTables(): void {
|
|
26
|
+
this.db.exec(`
|
|
27
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
28
|
+
id,
|
|
29
|
+
bank_id,
|
|
30
|
+
text,
|
|
31
|
+
tags,
|
|
32
|
+
domain,
|
|
33
|
+
memory_layer,
|
|
34
|
+
fact_type,
|
|
35
|
+
file_path,
|
|
36
|
+
tokenize='porter unicode61'
|
|
37
|
+
);
|
|
38
|
+
`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Rebuild the FTS index from the Context Tree. Returns count indexed.
|
|
43
|
+
*/
|
|
44
|
+
buildIndex(tree: ContextTree, bankId?: string): number {
|
|
45
|
+
if (bankId) {
|
|
46
|
+
this.db.prepare("DELETE FROM memory_fts WHERE bank_id = ?").run(bankId);
|
|
47
|
+
} else {
|
|
48
|
+
this.db.exec("DELETE FROM memory_fts");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const entries = tree.scanAll(bankId);
|
|
52
|
+
const insert = this.db.prepare(
|
|
53
|
+
`INSERT INTO memory_fts (id, bank_id, text, tags, domain, memory_layer, fact_type, file_path)
|
|
54
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const insertMany = this.db.transaction((entries: MemoryEntry[]) => {
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
insert.run(
|
|
60
|
+
entry.id,
|
|
61
|
+
entry.bank_id,
|
|
62
|
+
entry.text,
|
|
63
|
+
entry.tags.join(" "),
|
|
64
|
+
entry.domain,
|
|
65
|
+
entry.memory_layer,
|
|
66
|
+
entry.fact_type,
|
|
67
|
+
entry.file_path
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
insertMany(entries);
|
|
73
|
+
return entries.length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Full-text search. Returns scored hits sorted by relevance.
|
|
78
|
+
*/
|
|
79
|
+
search(
|
|
80
|
+
query: string,
|
|
81
|
+
bankId: string,
|
|
82
|
+
options?: {
|
|
83
|
+
limit?: number;
|
|
84
|
+
tags?: string[];
|
|
85
|
+
layers?: string[];
|
|
86
|
+
}
|
|
87
|
+
): SearchHit[] {
|
|
88
|
+
const limit = options?.limit ?? 10;
|
|
89
|
+
const tags = options?.tags;
|
|
90
|
+
const layers = options?.layers;
|
|
91
|
+
|
|
92
|
+
if (query.trim() === "*") {
|
|
93
|
+
return this.searchAll(bankId, { limit, tags, layers });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ftsQuery = SearchEngine.escapeFtsQuery(query);
|
|
97
|
+
if (!ftsQuery) return [];
|
|
98
|
+
|
|
99
|
+
let rows: Record<string, unknown>[];
|
|
100
|
+
try {
|
|
101
|
+
rows = this.db
|
|
102
|
+
.prepare(
|
|
103
|
+
`SELECT id, bank_id, text, tags, domain, memory_layer, fact_type, file_path, rank
|
|
104
|
+
FROM memory_fts
|
|
105
|
+
WHERE memory_fts MATCH ? AND bank_id = ?
|
|
106
|
+
ORDER BY rank
|
|
107
|
+
LIMIT ?`
|
|
108
|
+
)
|
|
109
|
+
.all(ftsQuery, bankId, limit * 3) as Record<string, unknown>[];
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let hits = this.rowsToHits(rows);
|
|
115
|
+
|
|
116
|
+
// Post-filter by tags
|
|
117
|
+
if (tags && tags.length > 0) {
|
|
118
|
+
const tagSet = new Set(tags);
|
|
119
|
+
hits = hits.filter(
|
|
120
|
+
(h) => h.tags && tagSet.size > 0 && [...tagSet].every((t) => h.tags.includes(t))
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Post-filter by layers
|
|
125
|
+
if (layers && layers.length > 0) {
|
|
126
|
+
hits = hits.filter((h) => h.memory_layer && layers.includes(h.memory_layer));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return hits.slice(0, limit);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Add a single entry to the index (incremental update).
|
|
134
|
+
*/
|
|
135
|
+
addDocument(entry: MemoryEntry): void {
|
|
136
|
+
this.db
|
|
137
|
+
.prepare(
|
|
138
|
+
`INSERT INTO memory_fts (id, bank_id, text, tags, domain, memory_layer, fact_type, file_path)
|
|
139
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
140
|
+
)
|
|
141
|
+
.run(
|
|
142
|
+
entry.id,
|
|
143
|
+
entry.bank_id,
|
|
144
|
+
entry.text,
|
|
145
|
+
entry.tags.join(" "),
|
|
146
|
+
entry.domain,
|
|
147
|
+
entry.memory_layer,
|
|
148
|
+
entry.fact_type,
|
|
149
|
+
entry.file_path
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Remove a single entry from the index.
|
|
155
|
+
*/
|
|
156
|
+
removeDocument(entryId: string): void {
|
|
157
|
+
this.db.prepare("DELETE FROM memory_fts WHERE id = ?").run(entryId);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Close the database connection.
|
|
162
|
+
*/
|
|
163
|
+
close(): void {
|
|
164
|
+
this.db.close();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Internal ──
|
|
168
|
+
|
|
169
|
+
private searchAll(
|
|
170
|
+
bankId: string,
|
|
171
|
+
options: { limit: number; tags?: string[]; layers?: string[] }
|
|
172
|
+
): SearchHit[] {
|
|
173
|
+
const rows = this.db
|
|
174
|
+
.prepare(
|
|
175
|
+
`SELECT id, bank_id, text, tags, domain, memory_layer, fact_type, file_path, 0 as rank
|
|
176
|
+
FROM memory_fts WHERE bank_id = ? LIMIT ?`
|
|
177
|
+
)
|
|
178
|
+
.all(bankId, options.limit) as Record<string, unknown>[];
|
|
179
|
+
|
|
180
|
+
let hits = this.rowsToHits(rows, 1.0);
|
|
181
|
+
|
|
182
|
+
if (options.tags && options.tags.length > 0) {
|
|
183
|
+
const tagSet = new Set(options.tags);
|
|
184
|
+
hits = hits.filter(
|
|
185
|
+
(h) => h.tags && [...tagSet].every((t) => h.tags.includes(t))
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (options.layers && options.layers.length > 0) {
|
|
190
|
+
hits = hits.filter(
|
|
191
|
+
(h) => h.memory_layer && options.layers!.includes(h.memory_layer)
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return hits;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private rowsToHits(
|
|
199
|
+
rows: Record<string, unknown>[],
|
|
200
|
+
defaultScore?: number
|
|
201
|
+
): SearchHit[] {
|
|
202
|
+
if (rows.length === 0) return [];
|
|
203
|
+
|
|
204
|
+
// Normalize BM25 scores (more negative = more relevant)
|
|
205
|
+
const rawScores = rows.map((r) => Math.abs(Number(r.rank)));
|
|
206
|
+
const maxScore = Math.max(...rawScores) || 1.0;
|
|
207
|
+
|
|
208
|
+
return rows.map((row) => {
|
|
209
|
+
const score =
|
|
210
|
+
defaultScore !== undefined
|
|
211
|
+
? defaultScore
|
|
212
|
+
: maxScore > 0
|
|
213
|
+
? Math.abs(Number(row.rank)) / maxScore
|
|
214
|
+
: 0.5;
|
|
215
|
+
|
|
216
|
+
const tagStr = (row.tags as string) || "";
|
|
217
|
+
const tags = tagStr.split(/\s+/).filter(Boolean);
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
id: row.id as string,
|
|
221
|
+
text: row.text as string,
|
|
222
|
+
score,
|
|
223
|
+
bank_id: row.bank_id as string,
|
|
224
|
+
domain: row.domain as string,
|
|
225
|
+
file_path: row.file_path as string,
|
|
226
|
+
memory_layer: (row.memory_layer as string) || undefined,
|
|
227
|
+
fact_type: (row.fact_type as string) || undefined,
|
|
228
|
+
tags,
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Escape special FTS5 characters for safe querying.
|
|
235
|
+
* Does NOT quote individual tokens — quoting disables stemming.
|
|
236
|
+
*/
|
|
237
|
+
static escapeFtsQuery(query: string): string {
|
|
238
|
+
let cleaned = query
|
|
239
|
+
.replace(/"/g, " ")
|
|
240
|
+
.replace(/'/g, " ")
|
|
241
|
+
.replace(/\(/g, " ")
|
|
242
|
+
.replace(/\)/g, " ")
|
|
243
|
+
.replace(/:/g, " ")
|
|
244
|
+
.replace(/\^/g, " ");
|
|
245
|
+
|
|
246
|
+
const tokens = cleaned.split(/\s+/).filter(Boolean);
|
|
247
|
+
return tokens.join(" ");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiered retrieval for local Context Tree — cache → FTS5 → LLM-guided.
|
|
3
|
+
*
|
|
4
|
+
* 3-tier progressive escalation adapted for file-based storage:
|
|
5
|
+
* Tier 0: In-memory result cache (exact/fuzzy query match)
|
|
6
|
+
* Tier 1: FTS5 keyword search (standard)
|
|
7
|
+
* Tier 2: LLM-guided query reformulation + FTS5 retry
|
|
8
|
+
*
|
|
9
|
+
* Stops when sufficient results are found. No embeddings needed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { SearchHit } from "./types.js";
|
|
13
|
+
import type { SearchEngine } from "./search.js";
|
|
14
|
+
import type { LLMProvider } from "./curated-retain.js";
|
|
15
|
+
|
|
16
|
+
// ── Recall Cache ──
|
|
17
|
+
|
|
18
|
+
interface CacheEntry {
|
|
19
|
+
query: string;
|
|
20
|
+
bankId: string;
|
|
21
|
+
hits: SearchHit[];
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class LocalRecallCache {
|
|
26
|
+
private cache = new Map<string, CacheEntry>();
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private maxEntries: number = 128,
|
|
30
|
+
private ttlMs: number = 120_000
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
get(query: string, bankId: string): SearchHit[] | null {
|
|
34
|
+
const key = `${bankId}:${query.toLowerCase().trim()}`;
|
|
35
|
+
const entry = this.cache.get(key);
|
|
36
|
+
if (!entry) return null;
|
|
37
|
+
|
|
38
|
+
// Check TTL
|
|
39
|
+
if (performance.now() - entry.timestamp > this.ttlMs) {
|
|
40
|
+
this.cache.delete(key);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return entry.hits;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
put(query: string, bankId: string, hits: SearchHit[]): void {
|
|
48
|
+
const key = `${bankId}:${query.toLowerCase().trim()}`;
|
|
49
|
+
|
|
50
|
+
// LRU eviction
|
|
51
|
+
while (this.cache.size >= this.maxEntries) {
|
|
52
|
+
const oldestKey = this.cache.keys().next().value!;
|
|
53
|
+
this.cache.delete(oldestKey);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.cache.set(key, {
|
|
57
|
+
query,
|
|
58
|
+
bankId,
|
|
59
|
+
hits,
|
|
60
|
+
timestamp: performance.now(),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
invalidateBank(bankId: string): void {
|
|
65
|
+
const prefix = `${bankId}:`;
|
|
66
|
+
for (const key of [...this.cache.keys()]) {
|
|
67
|
+
if (key.startsWith(prefix)) {
|
|
68
|
+
this.cache.delete(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
invalidateAll(): void {
|
|
74
|
+
this.cache.clear();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
size(): number {
|
|
78
|
+
return this.cache.size;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── Tiered Retriever ──
|
|
83
|
+
|
|
84
|
+
export class LocalTieredRetriever {
|
|
85
|
+
readonly maxTier: number;
|
|
86
|
+
|
|
87
|
+
constructor(
|
|
88
|
+
private search: SearchEngine,
|
|
89
|
+
private cache: LocalRecallCache | null = null,
|
|
90
|
+
private llmProvider: LLMProvider | null = null,
|
|
91
|
+
private minResults: number = 2,
|
|
92
|
+
private minScore: number = 0.3,
|
|
93
|
+
maxTier: number = 1
|
|
94
|
+
) {
|
|
95
|
+
this.maxTier = Math.min(maxTier, 2);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run tiered retrieval. Returns [hits, tierUsed].
|
|
100
|
+
*/
|
|
101
|
+
retrieve(
|
|
102
|
+
query: string,
|
|
103
|
+
bankId: string,
|
|
104
|
+
options?: { limit?: number; tags?: string[] }
|
|
105
|
+
): [SearchHit[], number] {
|
|
106
|
+
const limit = options?.limit ?? 10;
|
|
107
|
+
const tags = options?.tags;
|
|
108
|
+
|
|
109
|
+
// ── Tier 0: Cache ──
|
|
110
|
+
if (this.cache && this.maxTier >= 0) {
|
|
111
|
+
const cached = this.cache.get(query, bankId);
|
|
112
|
+
if (cached !== null) {
|
|
113
|
+
return [cached.slice(0, limit), 0];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Tier 1: FTS5 search ──
|
|
118
|
+
let hits: SearchHit[] = [];
|
|
119
|
+
if (this.maxTier >= 1) {
|
|
120
|
+
hits = this.search.search(query, bankId, { limit, tags });
|
|
121
|
+
if (this.sufficient(hits) || this.maxTier <= 1) {
|
|
122
|
+
if (this.cache && hits.length > 0) {
|
|
123
|
+
this.cache.put(query, bankId, hits);
|
|
124
|
+
}
|
|
125
|
+
return [hits, 1];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Tier 2: LLM-guided reformulation (sync path — skip if in async context) ──
|
|
130
|
+
// TypeScript doesn't have asyncio.run() equivalent, so tier 2
|
|
131
|
+
// is only available via aretrieve(). Cache whatever we have.
|
|
132
|
+
if (this.cache && hits.length > 0) {
|
|
133
|
+
this.cache.put(query, bankId, hits);
|
|
134
|
+
}
|
|
135
|
+
return [hits.slice(0, limit), Math.max(this.maxTier >= 1 ? 1 : 0, 0)];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Async version of retrieve — supports LLM reformulation natively.
|
|
140
|
+
*/
|
|
141
|
+
async aretrieve(
|
|
142
|
+
query: string,
|
|
143
|
+
bankId: string,
|
|
144
|
+
options?: { limit?: number; tags?: string[] }
|
|
145
|
+
): Promise<[SearchHit[], number]> {
|
|
146
|
+
const limit = options?.limit ?? 10;
|
|
147
|
+
const tags = options?.tags;
|
|
148
|
+
|
|
149
|
+
// ── Tier 0: Cache ──
|
|
150
|
+
if (this.cache && this.maxTier >= 0) {
|
|
151
|
+
const cached = this.cache.get(query, bankId);
|
|
152
|
+
if (cached !== null) {
|
|
153
|
+
return [cached.slice(0, limit), 0];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Tier 1: FTS5 search ──
|
|
158
|
+
let hits: SearchHit[] = [];
|
|
159
|
+
if (this.maxTier >= 1) {
|
|
160
|
+
hits = this.search.search(query, bankId, { limit, tags });
|
|
161
|
+
if (this.sufficient(hits) || this.maxTier <= 1) {
|
|
162
|
+
if (this.cache && hits.length > 0) {
|
|
163
|
+
this.cache.put(query, bankId, hits);
|
|
164
|
+
}
|
|
165
|
+
return [hits, 1];
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── Tier 2: LLM-guided reformulation ──
|
|
170
|
+
if (this.maxTier >= 2 && this.llmProvider) {
|
|
171
|
+
const reformulated = await this.reformulate(query);
|
|
172
|
+
if (reformulated !== query) {
|
|
173
|
+
const hits2 = this.search.search(reformulated, bankId, { limit, tags });
|
|
174
|
+
const merged = LocalTieredRetriever.mergeHits(hits, hits2);
|
|
175
|
+
if (this.cache) {
|
|
176
|
+
this.cache.put(query, bankId, merged);
|
|
177
|
+
}
|
|
178
|
+
return [merged.slice(0, limit), 2];
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Cache whatever we have from tier 1
|
|
183
|
+
if (this.cache && hits.length > 0) {
|
|
184
|
+
this.cache.put(query, bankId, hits);
|
|
185
|
+
}
|
|
186
|
+
return [hits.slice(0, limit), Math.max(this.maxTier, 0)];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private sufficient(hits: SearchHit[]): boolean {
|
|
190
|
+
if (hits.length < this.minResults) return false;
|
|
191
|
+
const avgScore =
|
|
192
|
+
hits.reduce((sum, h) => sum + h.score, 0) / Math.max(hits.length, 1);
|
|
193
|
+
return avgScore >= this.minScore;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private async reformulate(query: string): Promise<string> {
|
|
197
|
+
if (!this.llmProvider) return query;
|
|
198
|
+
|
|
199
|
+
const prompt =
|
|
200
|
+
"Reformulate this search query to improve keyword-based search results. " +
|
|
201
|
+
"Add synonyms and related terms. Return only the reformulated query.\n\n" +
|
|
202
|
+
`Query: ${query}`;
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const completion = await this.llmProvider.complete({
|
|
206
|
+
messages: [{ role: "user", content: prompt }],
|
|
207
|
+
maxTokens: 100,
|
|
208
|
+
temperature: 0.3,
|
|
209
|
+
});
|
|
210
|
+
return completion.text.trim() || query;
|
|
211
|
+
} catch {
|
|
212
|
+
return query;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Merge two hit lists, deduplicate by ID, keep highest score.
|
|
218
|
+
*/
|
|
219
|
+
static mergeHits(hitsA: SearchHit[], hitsB: SearchHit[]): SearchHit[] {
|
|
220
|
+
const best = new Map<string, SearchHit>();
|
|
221
|
+
for (const h of [...hitsA, ...hitsB]) {
|
|
222
|
+
const prev = best.get(h.id);
|
|
223
|
+
if (!prev || h.score > prev.score) {
|
|
224
|
+
best.set(h.id, h);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return [...best.values()].sort((a, b) => b.score - a.score);
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for astrocyte-local.
|
|
3
|
+
* Matches the Context Tree format spec in docs/context-tree-format.md.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface MemoryEntry {
|
|
7
|
+
id: string;
|
|
8
|
+
bank_id: string;
|
|
9
|
+
text: string;
|
|
10
|
+
domain: string;
|
|
11
|
+
file_path: string;
|
|
12
|
+
memory_layer: "fact" | "observation" | "model";
|
|
13
|
+
fact_type: "world" | "experience" | "observation";
|
|
14
|
+
tags: string[];
|
|
15
|
+
created_at: string; // ISO 8601
|
|
16
|
+
updated_at: string;
|
|
17
|
+
occurred_at?: string;
|
|
18
|
+
recall_count: number;
|
|
19
|
+
last_recalled_at?: string;
|
|
20
|
+
source?: string;
|
|
21
|
+
metadata: Record<string, string | number | boolean | null>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SearchHit {
|
|
25
|
+
id: string;
|
|
26
|
+
text: string;
|
|
27
|
+
score: number; // 0.0 - 1.0 normalized
|
|
28
|
+
bank_id: string;
|
|
29
|
+
domain: string;
|
|
30
|
+
file_path: string;
|
|
31
|
+
memory_layer?: string;
|
|
32
|
+
fact_type?: string;
|
|
33
|
+
tags: string[];
|
|
34
|
+
occurred_at?: string;
|
|
35
|
+
metadata?: Record<string, string | number | boolean | null>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RetainResult {
|
|
39
|
+
stored: boolean;
|
|
40
|
+
memory_id: string;
|
|
41
|
+
domain: string;
|
|
42
|
+
file: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RecallResult {
|
|
47
|
+
hits: SearchHit[];
|
|
48
|
+
total: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface BrowseResult {
|
|
52
|
+
path: string;
|
|
53
|
+
domains: string[];
|
|
54
|
+
entries: Array<{
|
|
55
|
+
file: string;
|
|
56
|
+
title: string;
|
|
57
|
+
memory_id: string;
|
|
58
|
+
recall_count: number;
|
|
59
|
+
}>;
|
|
60
|
+
total_memories: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface LocalConfig {
|
|
64
|
+
default_bank_id: string;
|
|
65
|
+
expose_reflect: boolean;
|
|
66
|
+
expose_forget: boolean;
|
|
67
|
+
root: string;
|
|
68
|
+
}
|