@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
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
export class LocalRecallCache {
|
|
12
|
+
maxEntries;
|
|
13
|
+
ttlMs;
|
|
14
|
+
cache = new Map();
|
|
15
|
+
constructor(maxEntries = 128, ttlMs = 120_000) {
|
|
16
|
+
this.maxEntries = maxEntries;
|
|
17
|
+
this.ttlMs = ttlMs;
|
|
18
|
+
}
|
|
19
|
+
get(query, bankId) {
|
|
20
|
+
const key = `${bankId}:${query.toLowerCase().trim()}`;
|
|
21
|
+
const entry = this.cache.get(key);
|
|
22
|
+
if (!entry)
|
|
23
|
+
return null;
|
|
24
|
+
// Check TTL
|
|
25
|
+
if (performance.now() - entry.timestamp > this.ttlMs) {
|
|
26
|
+
this.cache.delete(key);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
return entry.hits;
|
|
30
|
+
}
|
|
31
|
+
put(query, bankId, hits) {
|
|
32
|
+
const key = `${bankId}:${query.toLowerCase().trim()}`;
|
|
33
|
+
// LRU eviction
|
|
34
|
+
while (this.cache.size >= this.maxEntries) {
|
|
35
|
+
const oldestKey = this.cache.keys().next().value;
|
|
36
|
+
this.cache.delete(oldestKey);
|
|
37
|
+
}
|
|
38
|
+
this.cache.set(key, {
|
|
39
|
+
query,
|
|
40
|
+
bankId,
|
|
41
|
+
hits,
|
|
42
|
+
timestamp: performance.now(),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
invalidateBank(bankId) {
|
|
46
|
+
const prefix = `${bankId}:`;
|
|
47
|
+
for (const key of [...this.cache.keys()]) {
|
|
48
|
+
if (key.startsWith(prefix)) {
|
|
49
|
+
this.cache.delete(key);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
invalidateAll() {
|
|
54
|
+
this.cache.clear();
|
|
55
|
+
}
|
|
56
|
+
size() {
|
|
57
|
+
return this.cache.size;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// ── Tiered Retriever ──
|
|
61
|
+
export class LocalTieredRetriever {
|
|
62
|
+
search;
|
|
63
|
+
cache;
|
|
64
|
+
llmProvider;
|
|
65
|
+
minResults;
|
|
66
|
+
minScore;
|
|
67
|
+
maxTier;
|
|
68
|
+
constructor(search, cache = null, llmProvider = null, minResults = 2, minScore = 0.3, maxTier = 1) {
|
|
69
|
+
this.search = search;
|
|
70
|
+
this.cache = cache;
|
|
71
|
+
this.llmProvider = llmProvider;
|
|
72
|
+
this.minResults = minResults;
|
|
73
|
+
this.minScore = minScore;
|
|
74
|
+
this.maxTier = Math.min(maxTier, 2);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Run tiered retrieval. Returns [hits, tierUsed].
|
|
78
|
+
*/
|
|
79
|
+
retrieve(query, bankId, options) {
|
|
80
|
+
const limit = options?.limit ?? 10;
|
|
81
|
+
const tags = options?.tags;
|
|
82
|
+
// ── Tier 0: Cache ──
|
|
83
|
+
if (this.cache && this.maxTier >= 0) {
|
|
84
|
+
const cached = this.cache.get(query, bankId);
|
|
85
|
+
if (cached !== null) {
|
|
86
|
+
return [cached.slice(0, limit), 0];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// ── Tier 1: FTS5 search ──
|
|
90
|
+
let hits = [];
|
|
91
|
+
if (this.maxTier >= 1) {
|
|
92
|
+
hits = this.search.search(query, bankId, { limit, tags });
|
|
93
|
+
if (this.sufficient(hits) || this.maxTier <= 1) {
|
|
94
|
+
if (this.cache && hits.length > 0) {
|
|
95
|
+
this.cache.put(query, bankId, hits);
|
|
96
|
+
}
|
|
97
|
+
return [hits, 1];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// ── Tier 2: LLM-guided reformulation (sync path — skip if in async context) ──
|
|
101
|
+
// TypeScript doesn't have asyncio.run() equivalent, so tier 2
|
|
102
|
+
// is only available via aretrieve(). Cache whatever we have.
|
|
103
|
+
if (this.cache && hits.length > 0) {
|
|
104
|
+
this.cache.put(query, bankId, hits);
|
|
105
|
+
}
|
|
106
|
+
return [hits.slice(0, limit), Math.max(this.maxTier >= 1 ? 1 : 0, 0)];
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Async version of retrieve — supports LLM reformulation natively.
|
|
110
|
+
*/
|
|
111
|
+
async aretrieve(query, bankId, options) {
|
|
112
|
+
const limit = options?.limit ?? 10;
|
|
113
|
+
const tags = options?.tags;
|
|
114
|
+
// ── Tier 0: Cache ──
|
|
115
|
+
if (this.cache && this.maxTier >= 0) {
|
|
116
|
+
const cached = this.cache.get(query, bankId);
|
|
117
|
+
if (cached !== null) {
|
|
118
|
+
return [cached.slice(0, limit), 0];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// ── Tier 1: FTS5 search ──
|
|
122
|
+
let hits = [];
|
|
123
|
+
if (this.maxTier >= 1) {
|
|
124
|
+
hits = this.search.search(query, bankId, { limit, tags });
|
|
125
|
+
if (this.sufficient(hits) || this.maxTier <= 1) {
|
|
126
|
+
if (this.cache && hits.length > 0) {
|
|
127
|
+
this.cache.put(query, bankId, hits);
|
|
128
|
+
}
|
|
129
|
+
return [hits, 1];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// ── Tier 2: LLM-guided reformulation ──
|
|
133
|
+
if (this.maxTier >= 2 && this.llmProvider) {
|
|
134
|
+
const reformulated = await this.reformulate(query);
|
|
135
|
+
if (reformulated !== query) {
|
|
136
|
+
const hits2 = this.search.search(reformulated, bankId, { limit, tags });
|
|
137
|
+
const merged = LocalTieredRetriever.mergeHits(hits, hits2);
|
|
138
|
+
if (this.cache) {
|
|
139
|
+
this.cache.put(query, bankId, merged);
|
|
140
|
+
}
|
|
141
|
+
return [merged.slice(0, limit), 2];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Cache whatever we have from tier 1
|
|
145
|
+
if (this.cache && hits.length > 0) {
|
|
146
|
+
this.cache.put(query, bankId, hits);
|
|
147
|
+
}
|
|
148
|
+
return [hits.slice(0, limit), Math.max(this.maxTier, 0)];
|
|
149
|
+
}
|
|
150
|
+
sufficient(hits) {
|
|
151
|
+
if (hits.length < this.minResults)
|
|
152
|
+
return false;
|
|
153
|
+
const avgScore = hits.reduce((sum, h) => sum + h.score, 0) / Math.max(hits.length, 1);
|
|
154
|
+
return avgScore >= this.minScore;
|
|
155
|
+
}
|
|
156
|
+
async reformulate(query) {
|
|
157
|
+
if (!this.llmProvider)
|
|
158
|
+
return query;
|
|
159
|
+
const prompt = "Reformulate this search query to improve keyword-based search results. " +
|
|
160
|
+
"Add synonyms and related terms. Return only the reformulated query.\n\n" +
|
|
161
|
+
`Query: ${query}`;
|
|
162
|
+
try {
|
|
163
|
+
const completion = await this.llmProvider.complete({
|
|
164
|
+
messages: [{ role: "user", content: prompt }],
|
|
165
|
+
maxTokens: 100,
|
|
166
|
+
temperature: 0.3,
|
|
167
|
+
});
|
|
168
|
+
return completion.text.trim() || query;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return query;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Merge two hit lists, deduplicate by ID, keep highest score.
|
|
176
|
+
*/
|
|
177
|
+
static mergeHits(hitsA, hitsB) {
|
|
178
|
+
const best = new Map();
|
|
179
|
+
for (const h of [...hitsA, ...hitsB]) {
|
|
180
|
+
const prev = best.get(h.id);
|
|
181
|
+
if (!prev || h.score > prev.score) {
|
|
182
|
+
best.set(h.id, h);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return [...best.values()].sort((a, b) => b.score - a.score);
|
|
186
|
+
}
|
|
187
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for astrocyte-local.
|
|
3
|
+
* Matches the Context Tree format spec in docs/context-tree-format.md.
|
|
4
|
+
*/
|
|
5
|
+
export interface MemoryEntry {
|
|
6
|
+
id: string;
|
|
7
|
+
bank_id: string;
|
|
8
|
+
text: string;
|
|
9
|
+
domain: string;
|
|
10
|
+
file_path: string;
|
|
11
|
+
memory_layer: "fact" | "observation" | "model";
|
|
12
|
+
fact_type: "world" | "experience" | "observation";
|
|
13
|
+
tags: string[];
|
|
14
|
+
created_at: string;
|
|
15
|
+
updated_at: string;
|
|
16
|
+
occurred_at?: string;
|
|
17
|
+
recall_count: number;
|
|
18
|
+
last_recalled_at?: string;
|
|
19
|
+
source?: string;
|
|
20
|
+
metadata: Record<string, string | number | boolean | null>;
|
|
21
|
+
}
|
|
22
|
+
export interface SearchHit {
|
|
23
|
+
id: string;
|
|
24
|
+
text: string;
|
|
25
|
+
score: number;
|
|
26
|
+
bank_id: string;
|
|
27
|
+
domain: string;
|
|
28
|
+
file_path: string;
|
|
29
|
+
memory_layer?: string;
|
|
30
|
+
fact_type?: string;
|
|
31
|
+
tags: string[];
|
|
32
|
+
occurred_at?: string;
|
|
33
|
+
metadata?: Record<string, string | number | boolean | null>;
|
|
34
|
+
}
|
|
35
|
+
export interface RetainResult {
|
|
36
|
+
stored: boolean;
|
|
37
|
+
memory_id: string;
|
|
38
|
+
domain: string;
|
|
39
|
+
file: string;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface RecallResult {
|
|
43
|
+
hits: SearchHit[];
|
|
44
|
+
total: number;
|
|
45
|
+
}
|
|
46
|
+
export interface BrowseResult {
|
|
47
|
+
path: string;
|
|
48
|
+
domains: string[];
|
|
49
|
+
entries: Array<{
|
|
50
|
+
file: string;
|
|
51
|
+
title: string;
|
|
52
|
+
memory_id: string;
|
|
53
|
+
recall_count: number;
|
|
54
|
+
}>;
|
|
55
|
+
total_memories: number;
|
|
56
|
+
}
|
|
57
|
+
export interface LocalConfig {
|
|
58
|
+
default_bank_id: string;
|
|
59
|
+
expose_reflect: boolean;
|
|
60
|
+
expose_forget: boolean;
|
|
61
|
+
root: string;
|
|
62
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@astrocyteai/local",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Zero-infrastructure memory for AI coding agents — Context Tree + FTS5 search",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"astrocyte-local": "dist/cli.js",
|
|
10
|
+
"astrocyte-local-mcp": "dist/mcp-server.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"dev": "tsc --watch",
|
|
15
|
+
"test": "vitest",
|
|
16
|
+
"lint": "eslint src/",
|
|
17
|
+
"mcp": "tsx src/mcp-server.ts"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"astrocyte",
|
|
21
|
+
"memory",
|
|
22
|
+
"mcp",
|
|
23
|
+
"ai-agent",
|
|
24
|
+
"context-tree"
|
|
25
|
+
],
|
|
26
|
+
"author": "AstrocyteAI",
|
|
27
|
+
"license": "Apache-2.0",
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/AstrocyteAI/astrocyte-local"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
37
|
+
"better-sqlite3": "^11.0.0",
|
|
38
|
+
"commander": "^13.0.0",
|
|
39
|
+
"yaml": "^2.4.0",
|
|
40
|
+
"zod": "^4.3.6"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
44
|
+
"@types/node": "^22.0.0",
|
|
45
|
+
"eslint": "^9.0.0",
|
|
46
|
+
"tsx": "^4.0.0",
|
|
47
|
+
"typescript": "^5.7.0",
|
|
48
|
+
"vitest": "^3.0.0"
|
|
49
|
+
},
|
|
50
|
+
"pnpm": {
|
|
51
|
+
"onlyBuiltDependencies": [
|
|
52
|
+
"better-sqlite3"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI for Astrocyte Local — retain, search, browse, forget, export.
|
|
5
|
+
*
|
|
6
|
+
* See docs/cli-reference.md for the full command specification.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* astrocyte-local retain "Calvin prefers dark mode" --tags preference
|
|
10
|
+
* astrocyte-local search "dark mode"
|
|
11
|
+
* astrocyte-local browse
|
|
12
|
+
* astrocyte-local forget a1b2c3d4e5f6
|
|
13
|
+
* astrocyte-local export --output backup.ama.jsonl
|
|
14
|
+
* astrocyte-local health
|
|
15
|
+
* astrocyte-local mcp
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Command } from "commander";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import { ContextTree } from "./context-tree.js";
|
|
22
|
+
import { SearchEngine } from "./search.js";
|
|
23
|
+
import { startMcpServer } from "./mcp-server.js";
|
|
24
|
+
|
|
25
|
+
const program = new Command();
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.name("astrocyte-local")
|
|
29
|
+
.description("Local memory for AI coding agents")
|
|
30
|
+
.version("0.1.0")
|
|
31
|
+
.option("-r, --root <path>", "Context Tree root directory", ".astrocyte")
|
|
32
|
+
.option("-b, --bank <id>", "Memory bank ID", "project")
|
|
33
|
+
.option("-f, --format <fmt>", "Output format (text|json)", "text");
|
|
34
|
+
|
|
35
|
+
// ── retain ──
|
|
36
|
+
|
|
37
|
+
program
|
|
38
|
+
.command("retain")
|
|
39
|
+
.description("Store content into memory")
|
|
40
|
+
.argument("[content]", "Content to retain")
|
|
41
|
+
.option("--tags <tags>", "Comma-separated tags")
|
|
42
|
+
.option("--domain <domain>", "Context Tree domain")
|
|
43
|
+
.option("--stdin", "Read from stdin")
|
|
44
|
+
.action(async (content: string | undefined, opts: Record<string, string | boolean>) => {
|
|
45
|
+
const globals = program.opts();
|
|
46
|
+
const tree = new ContextTree(globals.root);
|
|
47
|
+
const search = new SearchEngine(path.join(globals.root, "_search.db"));
|
|
48
|
+
|
|
49
|
+
let text = content;
|
|
50
|
+
if (opts.stdin || !text) {
|
|
51
|
+
const chunks: Buffer[] = [];
|
|
52
|
+
for await (const chunk of process.stdin) {
|
|
53
|
+
chunks.push(chunk);
|
|
54
|
+
}
|
|
55
|
+
text = Buffer.concat(chunks).toString("utf-8").trim();
|
|
56
|
+
}
|
|
57
|
+
if (!text) {
|
|
58
|
+
console.error("Error: no content provided");
|
|
59
|
+
process.exit(2);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const tags = opts.tags ? (opts.tags as string).split(",").map((t: string) => t.trim()) : [];
|
|
63
|
+
const domain = (opts.domain as string) || (tags[0] || "general");
|
|
64
|
+
|
|
65
|
+
const entry = tree.store({
|
|
66
|
+
content: text,
|
|
67
|
+
bank_id: globals.bank,
|
|
68
|
+
domain,
|
|
69
|
+
tags,
|
|
70
|
+
});
|
|
71
|
+
search.addDocument(entry);
|
|
72
|
+
search.close();
|
|
73
|
+
|
|
74
|
+
if (globals.format === "json") {
|
|
75
|
+
console.log(
|
|
76
|
+
JSON.stringify({
|
|
77
|
+
stored: true,
|
|
78
|
+
memory_id: entry.id,
|
|
79
|
+
domain: entry.domain,
|
|
80
|
+
file: entry.file_path,
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
} else {
|
|
84
|
+
console.log(`Stored: ${entry.id} → ${entry.file_path}`);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── search ──
|
|
89
|
+
|
|
90
|
+
program
|
|
91
|
+
.command("search")
|
|
92
|
+
.description("Search memory")
|
|
93
|
+
.argument("<query>", "Search query")
|
|
94
|
+
.option("--tags <tags>", "Filter by tags (comma-separated)")
|
|
95
|
+
.option("--max-results <n>", "Maximum results", "10")
|
|
96
|
+
.action((query: string, opts: Record<string, string>) => {
|
|
97
|
+
const globals = program.opts();
|
|
98
|
+
const tree = new ContextTree(globals.root);
|
|
99
|
+
const search = new SearchEngine(path.join(globals.root, "_search.db"));
|
|
100
|
+
|
|
101
|
+
search.buildIndex(tree, globals.bank);
|
|
102
|
+
|
|
103
|
+
const tags = opts.tags ? opts.tags.split(",").map((t: string) => t.trim()) : undefined;
|
|
104
|
+
const hits = search.search(query, globals.bank, {
|
|
105
|
+
limit: parseInt(opts.maxResults || "10", 10),
|
|
106
|
+
tags,
|
|
107
|
+
});
|
|
108
|
+
search.close();
|
|
109
|
+
|
|
110
|
+
if (globals.format === "json") {
|
|
111
|
+
const hitDicts = hits.map((h) => ({
|
|
112
|
+
score: Math.round(h.score * 10000) / 10000,
|
|
113
|
+
text: h.text,
|
|
114
|
+
domain: h.domain,
|
|
115
|
+
file: h.file_path,
|
|
116
|
+
memory_id: h.id,
|
|
117
|
+
}));
|
|
118
|
+
console.log(JSON.stringify({ hits: hitDicts }));
|
|
119
|
+
} else {
|
|
120
|
+
if (hits.length === 0) {
|
|
121
|
+
console.log("No results found.");
|
|
122
|
+
}
|
|
123
|
+
for (const h of hits) {
|
|
124
|
+
console.log(`[${h.score.toFixed(2)}] ${h.file_path}`);
|
|
125
|
+
console.log(` ${h.text.slice(0, 100)}`);
|
|
126
|
+
console.log();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// ── browse ──
|
|
132
|
+
|
|
133
|
+
program
|
|
134
|
+
.command("browse")
|
|
135
|
+
.description("Browse the Context Tree")
|
|
136
|
+
.argument("[path]", "Path to browse", "")
|
|
137
|
+
.action((browsePath: string) => {
|
|
138
|
+
const globals = program.opts();
|
|
139
|
+
const tree = new ContextTree(globals.root);
|
|
140
|
+
|
|
141
|
+
if (!browsePath) {
|
|
142
|
+
const domains = tree.listDomains(globals.bank);
|
|
143
|
+
const total = tree.count(globals.bank);
|
|
144
|
+
if (globals.format === "json") {
|
|
145
|
+
console.log(JSON.stringify({ path: "", domains, total_memories: total }));
|
|
146
|
+
} else {
|
|
147
|
+
console.log(`${globals.root}/memory/`);
|
|
148
|
+
for (const d of domains) {
|
|
149
|
+
const count = tree.listEntries(globals.bank, d).length;
|
|
150
|
+
console.log(` ${d}/ (${count} entries)`);
|
|
151
|
+
}
|
|
152
|
+
console.log(`\nTotal: ${total} memories`);
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
const entries = tree.listEntries(globals.bank, browsePath);
|
|
156
|
+
if (globals.format === "json") {
|
|
157
|
+
const entryDicts = entries.map((e) => ({
|
|
158
|
+
file: e.file_path,
|
|
159
|
+
title: e.text.slice(0, 80),
|
|
160
|
+
memory_id: e.id,
|
|
161
|
+
}));
|
|
162
|
+
console.log(JSON.stringify({ path: browsePath, entries: entryDicts }));
|
|
163
|
+
} else {
|
|
164
|
+
for (const e of entries) {
|
|
165
|
+
console.log(` ${e.file_path} [${e.id}]`);
|
|
166
|
+
console.log(` ${e.text.slice(0, 80)}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ── forget ──
|
|
173
|
+
|
|
174
|
+
program
|
|
175
|
+
.command("forget")
|
|
176
|
+
.description("Remove memories")
|
|
177
|
+
.argument("[ids...]", "Memory IDs to delete")
|
|
178
|
+
.option("--all", "Delete all in bank")
|
|
179
|
+
.action((ids: string[], opts: Record<string, boolean>) => {
|
|
180
|
+
const globals = program.opts();
|
|
181
|
+
const tree = new ContextTree(globals.root);
|
|
182
|
+
const search = new SearchEngine(path.join(globals.root, "_search.db"));
|
|
183
|
+
let deleted = 0;
|
|
184
|
+
|
|
185
|
+
if (opts.all) {
|
|
186
|
+
const entries = tree.listEntries(globals.bank);
|
|
187
|
+
for (const e of entries) {
|
|
188
|
+
if (tree.delete(e.id)) {
|
|
189
|
+
search.removeDocument(e.id);
|
|
190
|
+
deleted++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
for (const mid of ids) {
|
|
195
|
+
if (tree.delete(mid)) {
|
|
196
|
+
search.removeDocument(mid);
|
|
197
|
+
deleted++;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
search.close();
|
|
202
|
+
|
|
203
|
+
if (globals.format === "json") {
|
|
204
|
+
console.log(JSON.stringify({ deleted_count: deleted }));
|
|
205
|
+
} else {
|
|
206
|
+
console.log(`Deleted ${deleted} memories`);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ── export ──
|
|
211
|
+
|
|
212
|
+
program
|
|
213
|
+
.command("export")
|
|
214
|
+
.description("Export to AMA format")
|
|
215
|
+
.option("-o, --output <file>", "Output file path")
|
|
216
|
+
.action((opts: Record<string, string>) => {
|
|
217
|
+
const globals = program.opts();
|
|
218
|
+
const tree = new ContextTree(globals.root);
|
|
219
|
+
const entries = tree.scanAll(globals.bank);
|
|
220
|
+
|
|
221
|
+
const header = {
|
|
222
|
+
_ama_version: 1,
|
|
223
|
+
bank_id: globals.bank,
|
|
224
|
+
memory_count: entries.length,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const lines: string[] = [JSON.stringify(header)];
|
|
228
|
+
for (const e of entries) {
|
|
229
|
+
const record: Record<string, unknown> = {
|
|
230
|
+
id: e.id,
|
|
231
|
+
text: e.text,
|
|
232
|
+
fact_type: e.fact_type,
|
|
233
|
+
tags: e.tags,
|
|
234
|
+
created_at: e.created_at,
|
|
235
|
+
};
|
|
236
|
+
if (e.occurred_at) record.occurred_at = e.occurred_at;
|
|
237
|
+
if (e.source) record.source = e.source;
|
|
238
|
+
lines.push(JSON.stringify(record));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const output = lines.join("\n") + "\n";
|
|
242
|
+
if (opts.output) {
|
|
243
|
+
fs.writeFileSync(opts.output, output, "utf-8");
|
|
244
|
+
console.log(`Exported ${entries.length} memories to ${opts.output}`);
|
|
245
|
+
} else {
|
|
246
|
+
process.stdout.write(output);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ── health ──
|
|
251
|
+
|
|
252
|
+
program
|
|
253
|
+
.command("health")
|
|
254
|
+
.description("System health check")
|
|
255
|
+
.action(() => {
|
|
256
|
+
const globals = program.opts();
|
|
257
|
+
const tree = new ContextTree(globals.root);
|
|
258
|
+
const total = tree.count();
|
|
259
|
+
const domains = tree.listDomains();
|
|
260
|
+
|
|
261
|
+
if (globals.format === "json") {
|
|
262
|
+
console.log(
|
|
263
|
+
JSON.stringify({ healthy: true, total_memories: total, root: globals.root })
|
|
264
|
+
);
|
|
265
|
+
} else {
|
|
266
|
+
console.log("Status: healthy");
|
|
267
|
+
console.log(`Root: ${globals.root}`);
|
|
268
|
+
console.log(`Memories: ${total}`);
|
|
269
|
+
console.log(`Domains: ${domains.length > 0 ? domains.join(", ") : "(none)"}`);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// ── rebuild-index ──
|
|
274
|
+
|
|
275
|
+
program
|
|
276
|
+
.command("rebuild-index")
|
|
277
|
+
.description("Rebuild the search index")
|
|
278
|
+
.action(() => {
|
|
279
|
+
const globals = program.opts();
|
|
280
|
+
const tree = new ContextTree(globals.root);
|
|
281
|
+
const search = new SearchEngine(path.join(globals.root, "_search.db"));
|
|
282
|
+
const count = search.buildIndex(tree);
|
|
283
|
+
search.close();
|
|
284
|
+
console.log(`Rebuilt index: ${count} entries`);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ── mcp ──
|
|
288
|
+
|
|
289
|
+
program
|
|
290
|
+
.command("mcp")
|
|
291
|
+
.description("Start MCP server")
|
|
292
|
+
.option("--transport <type>", "Transport type (stdio|sse)", "stdio")
|
|
293
|
+
.option("--port <n>", "SSE port", "8090")
|
|
294
|
+
.action(async (opts: Record<string, string>) => {
|
|
295
|
+
const globals = program.opts();
|
|
296
|
+
await startMcpServer({
|
|
297
|
+
root: globals.root,
|
|
298
|
+
defaultBank: globals.bank,
|
|
299
|
+
transport: opts.transport as "stdio" | "sse",
|
|
300
|
+
port: parseInt(opts.port, 10),
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
program.parse();
|