@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.
@@ -0,0 +1 @@
1
+ {"onlyBuiltDependencies":["better-sqlite3","esbuild"]}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI for Astrocyte Local — retain, search, browse, forget, export.
4
+ *
5
+ * See docs/cli-reference.md for the full command specification.
6
+ *
7
+ * Usage:
8
+ * astrocyte-local retain "Calvin prefers dark mode" --tags preference
9
+ * astrocyte-local search "dark mode"
10
+ * astrocyte-local browse
11
+ * astrocyte-local forget a1b2c3d4e5f6
12
+ * astrocyte-local export --output backup.ama.jsonl
13
+ * astrocyte-local health
14
+ * astrocyte-local mcp
15
+ */
16
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI for Astrocyte Local — retain, search, browse, forget, export.
4
+ *
5
+ * See docs/cli-reference.md for the full command specification.
6
+ *
7
+ * Usage:
8
+ * astrocyte-local retain "Calvin prefers dark mode" --tags preference
9
+ * astrocyte-local search "dark mode"
10
+ * astrocyte-local browse
11
+ * astrocyte-local forget a1b2c3d4e5f6
12
+ * astrocyte-local export --output backup.ama.jsonl
13
+ * astrocyte-local health
14
+ * astrocyte-local mcp
15
+ */
16
+ import { Command } from "commander";
17
+ import path from "node:path";
18
+ import fs from "node:fs";
19
+ import { ContextTree } from "./context-tree.js";
20
+ import { SearchEngine } from "./search.js";
21
+ import { startMcpServer } from "./mcp-server.js";
22
+ const program = new Command();
23
+ program
24
+ .name("astrocyte-local")
25
+ .description("Local memory for AI coding agents")
26
+ .version("0.1.0")
27
+ .option("-r, --root <path>", "Context Tree root directory", ".astrocyte")
28
+ .option("-b, --bank <id>", "Memory bank ID", "project")
29
+ .option("-f, --format <fmt>", "Output format (text|json)", "text");
30
+ // ── retain ──
31
+ program
32
+ .command("retain")
33
+ .description("Store content into memory")
34
+ .argument("[content]", "Content to retain")
35
+ .option("--tags <tags>", "Comma-separated tags")
36
+ .option("--domain <domain>", "Context Tree domain")
37
+ .option("--stdin", "Read from stdin")
38
+ .action(async (content, opts) => {
39
+ const globals = program.opts();
40
+ const tree = new ContextTree(globals.root);
41
+ const search = new SearchEngine(path.join(globals.root, "_search.db"));
42
+ let text = content;
43
+ if (opts.stdin || !text) {
44
+ const chunks = [];
45
+ for await (const chunk of process.stdin) {
46
+ chunks.push(chunk);
47
+ }
48
+ text = Buffer.concat(chunks).toString("utf-8").trim();
49
+ }
50
+ if (!text) {
51
+ console.error("Error: no content provided");
52
+ process.exit(2);
53
+ }
54
+ const tags = opts.tags ? opts.tags.split(",").map((t) => t.trim()) : [];
55
+ const domain = opts.domain || (tags[0] || "general");
56
+ const entry = tree.store({
57
+ content: text,
58
+ bank_id: globals.bank,
59
+ domain,
60
+ tags,
61
+ });
62
+ search.addDocument(entry);
63
+ search.close();
64
+ if (globals.format === "json") {
65
+ console.log(JSON.stringify({
66
+ stored: true,
67
+ memory_id: entry.id,
68
+ domain: entry.domain,
69
+ file: entry.file_path,
70
+ }));
71
+ }
72
+ else {
73
+ console.log(`Stored: ${entry.id} → ${entry.file_path}`);
74
+ }
75
+ });
76
+ // ── search ──
77
+ program
78
+ .command("search")
79
+ .description("Search memory")
80
+ .argument("<query>", "Search query")
81
+ .option("--tags <tags>", "Filter by tags (comma-separated)")
82
+ .option("--max-results <n>", "Maximum results", "10")
83
+ .action((query, opts) => {
84
+ const globals = program.opts();
85
+ const tree = new ContextTree(globals.root);
86
+ const search = new SearchEngine(path.join(globals.root, "_search.db"));
87
+ search.buildIndex(tree, globals.bank);
88
+ const tags = opts.tags ? opts.tags.split(",").map((t) => t.trim()) : undefined;
89
+ const hits = search.search(query, globals.bank, {
90
+ limit: parseInt(opts.maxResults || "10", 10),
91
+ tags,
92
+ });
93
+ search.close();
94
+ if (globals.format === "json") {
95
+ const hitDicts = hits.map((h) => ({
96
+ score: Math.round(h.score * 10000) / 10000,
97
+ text: h.text,
98
+ domain: h.domain,
99
+ file: h.file_path,
100
+ memory_id: h.id,
101
+ }));
102
+ console.log(JSON.stringify({ hits: hitDicts }));
103
+ }
104
+ else {
105
+ if (hits.length === 0) {
106
+ console.log("No results found.");
107
+ }
108
+ for (const h of hits) {
109
+ console.log(`[${h.score.toFixed(2)}] ${h.file_path}`);
110
+ console.log(` ${h.text.slice(0, 100)}`);
111
+ console.log();
112
+ }
113
+ }
114
+ });
115
+ // ── browse ──
116
+ program
117
+ .command("browse")
118
+ .description("Browse the Context Tree")
119
+ .argument("[path]", "Path to browse", "")
120
+ .action((browsePath) => {
121
+ const globals = program.opts();
122
+ const tree = new ContextTree(globals.root);
123
+ if (!browsePath) {
124
+ const domains = tree.listDomains(globals.bank);
125
+ const total = tree.count(globals.bank);
126
+ if (globals.format === "json") {
127
+ console.log(JSON.stringify({ path: "", domains, total_memories: total }));
128
+ }
129
+ else {
130
+ console.log(`${globals.root}/memory/`);
131
+ for (const d of domains) {
132
+ const count = tree.listEntries(globals.bank, d).length;
133
+ console.log(` ${d}/ (${count} entries)`);
134
+ }
135
+ console.log(`\nTotal: ${total} memories`);
136
+ }
137
+ }
138
+ else {
139
+ const entries = tree.listEntries(globals.bank, browsePath);
140
+ if (globals.format === "json") {
141
+ const entryDicts = entries.map((e) => ({
142
+ file: e.file_path,
143
+ title: e.text.slice(0, 80),
144
+ memory_id: e.id,
145
+ }));
146
+ console.log(JSON.stringify({ path: browsePath, entries: entryDicts }));
147
+ }
148
+ else {
149
+ for (const e of entries) {
150
+ console.log(` ${e.file_path} [${e.id}]`);
151
+ console.log(` ${e.text.slice(0, 80)}`);
152
+ }
153
+ }
154
+ }
155
+ });
156
+ // ── forget ──
157
+ program
158
+ .command("forget")
159
+ .description("Remove memories")
160
+ .argument("[ids...]", "Memory IDs to delete")
161
+ .option("--all", "Delete all in bank")
162
+ .action((ids, opts) => {
163
+ const globals = program.opts();
164
+ const tree = new ContextTree(globals.root);
165
+ const search = new SearchEngine(path.join(globals.root, "_search.db"));
166
+ let deleted = 0;
167
+ if (opts.all) {
168
+ const entries = tree.listEntries(globals.bank);
169
+ for (const e of entries) {
170
+ if (tree.delete(e.id)) {
171
+ search.removeDocument(e.id);
172
+ deleted++;
173
+ }
174
+ }
175
+ }
176
+ else {
177
+ for (const mid of ids) {
178
+ if (tree.delete(mid)) {
179
+ search.removeDocument(mid);
180
+ deleted++;
181
+ }
182
+ }
183
+ }
184
+ search.close();
185
+ if (globals.format === "json") {
186
+ console.log(JSON.stringify({ deleted_count: deleted }));
187
+ }
188
+ else {
189
+ console.log(`Deleted ${deleted} memories`);
190
+ }
191
+ });
192
+ // ── export ──
193
+ program
194
+ .command("export")
195
+ .description("Export to AMA format")
196
+ .option("-o, --output <file>", "Output file path")
197
+ .action((opts) => {
198
+ const globals = program.opts();
199
+ const tree = new ContextTree(globals.root);
200
+ const entries = tree.scanAll(globals.bank);
201
+ const header = {
202
+ _ama_version: 1,
203
+ bank_id: globals.bank,
204
+ memory_count: entries.length,
205
+ };
206
+ const lines = [JSON.stringify(header)];
207
+ for (const e of entries) {
208
+ const record = {
209
+ id: e.id,
210
+ text: e.text,
211
+ fact_type: e.fact_type,
212
+ tags: e.tags,
213
+ created_at: e.created_at,
214
+ };
215
+ if (e.occurred_at)
216
+ record.occurred_at = e.occurred_at;
217
+ if (e.source)
218
+ record.source = e.source;
219
+ lines.push(JSON.stringify(record));
220
+ }
221
+ const output = lines.join("\n") + "\n";
222
+ if (opts.output) {
223
+ fs.writeFileSync(opts.output, output, "utf-8");
224
+ console.log(`Exported ${entries.length} memories to ${opts.output}`);
225
+ }
226
+ else {
227
+ process.stdout.write(output);
228
+ }
229
+ });
230
+ // ── health ──
231
+ program
232
+ .command("health")
233
+ .description("System health check")
234
+ .action(() => {
235
+ const globals = program.opts();
236
+ const tree = new ContextTree(globals.root);
237
+ const total = tree.count();
238
+ const domains = tree.listDomains();
239
+ if (globals.format === "json") {
240
+ console.log(JSON.stringify({ healthy: true, total_memories: total, root: globals.root }));
241
+ }
242
+ else {
243
+ console.log("Status: healthy");
244
+ console.log(`Root: ${globals.root}`);
245
+ console.log(`Memories: ${total}`);
246
+ console.log(`Domains: ${domains.length > 0 ? domains.join(", ") : "(none)"}`);
247
+ }
248
+ });
249
+ // ── rebuild-index ──
250
+ program
251
+ .command("rebuild-index")
252
+ .description("Rebuild the search index")
253
+ .action(() => {
254
+ const globals = program.opts();
255
+ const tree = new ContextTree(globals.root);
256
+ const search = new SearchEngine(path.join(globals.root, "_search.db"));
257
+ const count = search.buildIndex(tree);
258
+ search.close();
259
+ console.log(`Rebuilt index: ${count} entries`);
260
+ });
261
+ // ── mcp ──
262
+ program
263
+ .command("mcp")
264
+ .description("Start MCP server")
265
+ .option("--transport <type>", "Transport type (stdio|sse)", "stdio")
266
+ .option("--port <n>", "SSE port", "8090")
267
+ .action(async (opts) => {
268
+ const globals = program.opts();
269
+ await startMcpServer({
270
+ root: globals.root,
271
+ defaultBank: globals.bank,
272
+ transport: opts.transport,
273
+ port: parseInt(opts.port, 10),
274
+ });
275
+ });
276
+ program.parse();
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Context Tree — hierarchical markdown file storage.
3
+ *
4
+ * Stores memories as .md files with YAML frontmatter in a
5
+ * domain-based directory structure. See docs/context-tree-format.md.
6
+ */
7
+ import type { MemoryEntry } from "./types.js";
8
+ export declare class ContextTree {
9
+ private root;
10
+ private memoryDir;
11
+ constructor(root: string);
12
+ store(options: {
13
+ content: string;
14
+ bank_id: string;
15
+ domain?: string;
16
+ tags?: string[];
17
+ memory_layer?: "fact" | "observation" | "model";
18
+ fact_type?: "world" | "experience" | "observation";
19
+ occurred_at?: string;
20
+ source?: string;
21
+ metadata?: Record<string, string | number | boolean | null>;
22
+ }): MemoryEntry;
23
+ read(entryId: string): MemoryEntry | null;
24
+ update(entryId: string, content: string): MemoryEntry | null;
25
+ delete(entryId: string): boolean;
26
+ recordRecall(entryId: string): void;
27
+ listDomains(bankId?: string): string[];
28
+ listEntries(bankId: string, domain?: string): MemoryEntry[];
29
+ scanAll(bankId?: string): MemoryEntry[];
30
+ count(bankId?: string): number;
31
+ private makeFilename;
32
+ private writeEntry;
33
+ private readFile;
34
+ private allMdFiles;
35
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Context Tree — hierarchical markdown file storage.
3
+ *
4
+ * Stores memories as .md files with YAML frontmatter in a
5
+ * domain-based directory structure. See docs/context-tree-format.md.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
+ import YAML from "yaml";
11
+ export class ContextTree {
12
+ root;
13
+ memoryDir;
14
+ constructor(root) {
15
+ this.root = root;
16
+ this.memoryDir = path.join(root, "memory");
17
+ fs.mkdirSync(this.memoryDir, { recursive: true });
18
+ }
19
+ store(options) {
20
+ const id = randomUUID().replace(/-/g, "").slice(0, 12);
21
+ const now = new Date().toISOString();
22
+ const domain = options.domain || "general";
23
+ const domainDir = path.join(this.memoryDir, domain);
24
+ fs.mkdirSync(domainDir, { recursive: true });
25
+ const filename = this.makeFilename(options.content);
26
+ let filePath = path.join(domainDir, `${filename}.md`);
27
+ let counter = 2;
28
+ while (fs.existsSync(filePath)) {
29
+ filePath = path.join(domainDir, `${filename}-${counter}.md`);
30
+ counter++;
31
+ }
32
+ const relPath = path.relative(this.memoryDir, filePath);
33
+ const entry = {
34
+ id,
35
+ bank_id: options.bank_id,
36
+ text: options.content,
37
+ domain,
38
+ file_path: relPath,
39
+ memory_layer: options.memory_layer || "fact",
40
+ fact_type: options.fact_type || "world",
41
+ tags: options.tags || [],
42
+ created_at: now,
43
+ updated_at: now,
44
+ occurred_at: options.occurred_at,
45
+ recall_count: 0,
46
+ source: options.source,
47
+ metadata: options.metadata || {},
48
+ };
49
+ this.writeEntry(filePath, entry);
50
+ return entry;
51
+ }
52
+ read(entryId) {
53
+ for (const entry of this.scanAll()) {
54
+ if (entry.id === entryId)
55
+ return entry;
56
+ }
57
+ return null;
58
+ }
59
+ update(entryId, content) {
60
+ for (const mdFile of this.allMdFiles()) {
61
+ const entry = this.readFile(mdFile);
62
+ if (entry && entry.id === entryId) {
63
+ entry.text = content;
64
+ entry.updated_at = new Date().toISOString();
65
+ this.writeEntry(mdFile, entry);
66
+ return entry;
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+ delete(entryId) {
72
+ for (const mdFile of this.allMdFiles()) {
73
+ const entry = this.readFile(mdFile);
74
+ if (entry && entry.id === entryId) {
75
+ fs.unlinkSync(mdFile);
76
+ // Remove empty domain directories
77
+ const parent = path.dirname(mdFile);
78
+ if (parent !== this.memoryDir) {
79
+ try {
80
+ const remaining = fs.readdirSync(parent);
81
+ if (remaining.length === 0)
82
+ fs.rmdirSync(parent);
83
+ }
84
+ catch { }
85
+ }
86
+ return true;
87
+ }
88
+ }
89
+ return false;
90
+ }
91
+ recordRecall(entryId) {
92
+ for (const mdFile of this.allMdFiles()) {
93
+ const entry = this.readFile(mdFile);
94
+ if (entry && entry.id === entryId) {
95
+ entry.recall_count++;
96
+ entry.last_recalled_at = new Date().toISOString();
97
+ this.writeEntry(mdFile, entry);
98
+ return;
99
+ }
100
+ }
101
+ }
102
+ listDomains(bankId) {
103
+ if (!fs.existsSync(this.memoryDir))
104
+ return [];
105
+ const domains = [];
106
+ for (const d of fs.readdirSync(this.memoryDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
107
+ if (!d.isDirectory() || d.name.startsWith("_"))
108
+ continue;
109
+ if (!bankId) {
110
+ domains.push(d.name);
111
+ }
112
+ else {
113
+ const domainPath = path.join(this.memoryDir, d.name);
114
+ const files = fs.readdirSync(domainPath).filter((f) => f.endsWith(".md"));
115
+ for (const f of files) {
116
+ const entry = this.readFile(path.join(domainPath, f));
117
+ if (entry && entry.bank_id === bankId) {
118
+ domains.push(d.name);
119
+ break;
120
+ }
121
+ }
122
+ }
123
+ }
124
+ return domains;
125
+ }
126
+ listEntries(bankId, domain) {
127
+ const searchDir = domain ? path.join(this.memoryDir, domain) : this.memoryDir;
128
+ if (!fs.existsSync(searchDir))
129
+ return [];
130
+ const entries = [];
131
+ for (const mdFile of this.allMdFiles(searchDir)) {
132
+ const entry = this.readFile(mdFile);
133
+ if (entry && entry.bank_id === bankId)
134
+ entries.push(entry);
135
+ }
136
+ return entries;
137
+ }
138
+ scanAll(bankId) {
139
+ if (!fs.existsSync(this.memoryDir))
140
+ return [];
141
+ const entries = [];
142
+ for (const mdFile of this.allMdFiles()) {
143
+ const entry = this.readFile(mdFile);
144
+ if (entry && (!bankId || entry.bank_id === bankId))
145
+ entries.push(entry);
146
+ }
147
+ return entries;
148
+ }
149
+ count(bankId) {
150
+ return this.scanAll(bankId).length;
151
+ }
152
+ // ── Internal ──
153
+ makeFilename(content) {
154
+ let slug = content.slice(0, 50).toLowerCase().trim();
155
+ slug = slug.replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
156
+ return slug || "memory";
157
+ }
158
+ writeEntry(filePath, entry) {
159
+ const frontmatter = {
160
+ id: entry.id,
161
+ bank_id: entry.bank_id,
162
+ memory_layer: entry.memory_layer,
163
+ fact_type: entry.fact_type,
164
+ tags: entry.tags,
165
+ created_at: entry.created_at,
166
+ updated_at: entry.updated_at,
167
+ recall_count: entry.recall_count,
168
+ };
169
+ if (entry.occurred_at)
170
+ frontmatter.occurred_at = entry.occurred_at;
171
+ if (entry.last_recalled_at)
172
+ frontmatter.last_recalled_at = entry.last_recalled_at;
173
+ if (entry.source)
174
+ frontmatter.source = entry.source;
175
+ if (Object.keys(entry.metadata).length > 0)
176
+ frontmatter.metadata = entry.metadata;
177
+ const fmStr = YAML.stringify(frontmatter);
178
+ fs.writeFileSync(filePath, `---\n${fmStr}---\n\n${entry.text}\n`, "utf-8");
179
+ }
180
+ readFile(filePath) {
181
+ try {
182
+ const content = fs.readFileSync(filePath, "utf-8");
183
+ if (!content.startsWith("---"))
184
+ return null;
185
+ const parts = content.split("---");
186
+ if (parts.length < 3)
187
+ return null;
188
+ const fm = YAML.parse(parts[1]) || {};
189
+ const text = parts.slice(2).join("---").trim();
190
+ const relPath = path.relative(this.memoryDir, filePath);
191
+ const domain = path.dirname(filePath) === this.memoryDir ? "general" : path.basename(path.dirname(filePath));
192
+ return {
193
+ id: fm.id || "",
194
+ bank_id: fm.bank_id || "",
195
+ text,
196
+ domain,
197
+ file_path: relPath,
198
+ memory_layer: fm.memory_layer || "fact",
199
+ fact_type: fm.fact_type || "world",
200
+ tags: fm.tags || [],
201
+ created_at: fm.created_at || "",
202
+ updated_at: fm.updated_at || "",
203
+ occurred_at: fm.occurred_at,
204
+ recall_count: fm.recall_count || 0,
205
+ last_recalled_at: fm.last_recalled_at,
206
+ source: fm.source,
207
+ metadata: fm.metadata || {},
208
+ };
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ }
214
+ *allMdFiles(dir) {
215
+ const searchDir = dir || this.memoryDir;
216
+ if (!fs.existsSync(searchDir))
217
+ return;
218
+ for (const entry of fs.readdirSync(searchDir, { withFileTypes: true })) {
219
+ const fullPath = path.join(searchDir, entry.name);
220
+ if (entry.isDirectory() && !entry.name.startsWith("_")) {
221
+ yield* this.allMdFiles(fullPath);
222
+ }
223
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
224
+ yield fullPath;
225
+ }
226
+ }
227
+ }
228
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * LLM-curated retain for local Context Tree.
3
+ *
4
+ * When an LLM provider is available, the LLM decides:
5
+ * - What action to take: ADD, UPDATE, MERGE, SKIP, DELETE
6
+ * - Which domain to store in (instead of just using the first tag)
7
+ * - What memory_layer to assign (fact, observation, model)
8
+ *
9
+ * Falls back to simple mechanical retain when no LLM is configured.
10
+ */
11
+ import type { ContextTree } from "./context-tree.js";
12
+ import type { SearchEngine } from "./search.js";
13
+ export interface LLMProvider {
14
+ complete(options: {
15
+ messages: Array<{
16
+ role: string;
17
+ content: string;
18
+ }>;
19
+ maxTokens?: number;
20
+ temperature?: number;
21
+ }): Promise<{
22
+ text: string;
23
+ }>;
24
+ }
25
+ export interface CurationDecision {
26
+ action: "add" | "update" | "merge" | "skip" | "delete";
27
+ domain: string;
28
+ content: string;
29
+ memory_layer: "fact" | "observation" | "model";
30
+ reasoning: string;
31
+ target_id?: string;
32
+ }
33
+ export declare function curateLocalRetain(options: {
34
+ content: string;
35
+ bankId: string;
36
+ tree: ContextTree;
37
+ search: SearchEngine;
38
+ llmProvider: LLMProvider;
39
+ contextLimit?: number;
40
+ }): Promise<CurationDecision>;
41
+ export declare function parseResponse(response: string, originalContent: string): CurationDecision;