@stackmemoryai/stackmemory 1.2.9 → 1.3.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/dist/src/cli/commands/symphony.js +267 -0
- package/dist/src/cli/index.js +2 -0
- package/dist/src/core/context/frame-handoff-manager.js +7 -4
- package/dist/src/core/database/database-adapter.js +7 -0
- package/dist/src/core/database/migration-manager.js +6 -19
- package/dist/src/core/database/sqlite-adapter.js +18 -0
- package/package.json +1 -1
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { fileURLToPath as __fileURLToPath } from 'url';
|
|
2
|
+
import { dirname as __pathDirname } from 'path';
|
|
3
|
+
const __filename = __fileURLToPath(import.meta.url);
|
|
4
|
+
const __dirname = __pathDirname(__filename);
|
|
5
|
+
import { Command } from "commander";
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import Database from "better-sqlite3";
|
|
10
|
+
import { logger } from "../../core/monitoring/logger.js";
|
|
11
|
+
function getGlobalStorePath() {
|
|
12
|
+
const dir = join(homedir(), ".stackmemory", "symphony");
|
|
13
|
+
if (!existsSync(dir)) {
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
return dir;
|
|
17
|
+
}
|
|
18
|
+
function getGlobalDb() {
|
|
19
|
+
const dbPath = join(getGlobalStorePath(), "context.db");
|
|
20
|
+
const db = new Database(dbPath);
|
|
21
|
+
db.pragma("journal_mode = WAL");
|
|
22
|
+
db.pragma("foreign_keys = ON");
|
|
23
|
+
db.exec(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS symphony_contexts (
|
|
25
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
|
+
issue_id TEXT NOT NULL,
|
|
27
|
+
attempt INTEGER NOT NULL DEFAULT 1,
|
|
28
|
+
workspace TEXT,
|
|
29
|
+
captured_at INTEGER NOT NULL,
|
|
30
|
+
context_type TEXT NOT NULL DEFAULT 'run',
|
|
31
|
+
summary TEXT,
|
|
32
|
+
frames_json TEXT,
|
|
33
|
+
anchors_json TEXT,
|
|
34
|
+
events_json TEXT,
|
|
35
|
+
metadata_json TEXT
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_symphony_issue
|
|
38
|
+
ON symphony_contexts(issue_id);
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_symphony_captured
|
|
40
|
+
ON symphony_contexts(captured_at);
|
|
41
|
+
`);
|
|
42
|
+
return db;
|
|
43
|
+
}
|
|
44
|
+
function createSymphonyCommands() {
|
|
45
|
+
const cmd = new Command("symphony");
|
|
46
|
+
cmd.description("Symphony orchestrator integration commands");
|
|
47
|
+
cmd.command("capture").description("Capture workspace context after an agent run").requiredOption("--issue <id>", "Issue identifier (e.g., STA-476)").option("--workspace <path>", "Workspace directory", process.cwd()).option("--attempt <n>", "Attempt number", "1").action(async (options) => {
|
|
48
|
+
const workspace = options.workspace;
|
|
49
|
+
const issueId = options.issue;
|
|
50
|
+
const attempt = parseInt(options.attempt, 10);
|
|
51
|
+
const dbPath = join(workspace, ".stackmemory", "context.db");
|
|
52
|
+
let summary = "";
|
|
53
|
+
let framesJson = "[]";
|
|
54
|
+
let anchorsJson = "[]";
|
|
55
|
+
let eventsJson = "[]";
|
|
56
|
+
if (existsSync(dbPath)) {
|
|
57
|
+
try {
|
|
58
|
+
const db = new Database(dbPath, { readonly: true });
|
|
59
|
+
const frames = db.prepare(
|
|
60
|
+
"SELECT frame_id, name, type, digest_text, created_at FROM frames ORDER BY created_at DESC LIMIT 20"
|
|
61
|
+
).all();
|
|
62
|
+
framesJson = JSON.stringify(frames);
|
|
63
|
+
const anchors = db.prepare(
|
|
64
|
+
"SELECT anchor_id, type, text, priority FROM anchors WHERE type IN ('DECISION', 'FACT', 'CONSTRAINT', 'RISK') ORDER BY priority DESC LIMIT 30"
|
|
65
|
+
).all();
|
|
66
|
+
anchorsJson = JSON.stringify(anchors);
|
|
67
|
+
const events = db.prepare(
|
|
68
|
+
"SELECT event_type, payload, ts FROM events ORDER BY ts DESC LIMIT 50"
|
|
69
|
+
).all();
|
|
70
|
+
eventsJson = JSON.stringify(events);
|
|
71
|
+
const digests = frames.filter((f) => f.digest_text).map((f) => f.digest_text).slice(0, 5);
|
|
72
|
+
summary = digests.join("\n");
|
|
73
|
+
db.close();
|
|
74
|
+
} catch (err) {
|
|
75
|
+
logger.warn("Failed to read workspace database", {
|
|
76
|
+
error: err.message
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
let metadata = { workspace, attempt };
|
|
81
|
+
try {
|
|
82
|
+
const { execSync } = await import("child_process");
|
|
83
|
+
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
|
84
|
+
cwd: workspace,
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
87
|
+
timeout: 5e3
|
|
88
|
+
}).trim();
|
|
89
|
+
const lastCommit = execSync("git log -1 --oneline", {
|
|
90
|
+
cwd: workspace,
|
|
91
|
+
encoding: "utf8",
|
|
92
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
93
|
+
timeout: 5e3
|
|
94
|
+
}).trim();
|
|
95
|
+
metadata = { ...metadata, branch, lastCommit };
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
const globalDb = getGlobalDb();
|
|
99
|
+
globalDb.prepare(
|
|
100
|
+
`INSERT INTO symphony_contexts
|
|
101
|
+
(issue_id, attempt, workspace, captured_at, context_type, summary, frames_json, anchors_json, events_json, metadata_json)
|
|
102
|
+
VALUES (?, ?, ?, ?, 'run', ?, ?, ?, ?, ?)`
|
|
103
|
+
).run(
|
|
104
|
+
issueId,
|
|
105
|
+
attempt,
|
|
106
|
+
workspace,
|
|
107
|
+
Math.floor(Date.now() / 1e3),
|
|
108
|
+
summary,
|
|
109
|
+
framesJson,
|
|
110
|
+
anchorsJson,
|
|
111
|
+
eventsJson,
|
|
112
|
+
JSON.stringify(metadata)
|
|
113
|
+
);
|
|
114
|
+
globalDb.close();
|
|
115
|
+
const frameCount = JSON.parse(framesJson).length;
|
|
116
|
+
const anchorCount = JSON.parse(anchorsJson).length;
|
|
117
|
+
console.log(
|
|
118
|
+
`Captured ${frameCount} frames, ${anchorCount} anchors for ${issueId} (attempt ${attempt})`
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
cmd.command("restore").description("Restore context from prior runs into workspace").requiredOption("--issue <id>", "Issue identifier (e.g., STA-476)").option("--workspace <path>", "Workspace directory", process.cwd()).option("--related", "Also include context from related issues", false).action(async (options) => {
|
|
122
|
+
const workspace = options.workspace;
|
|
123
|
+
const issueId = options.issue;
|
|
124
|
+
const globalDbPath = join(getGlobalStorePath(), "context.db");
|
|
125
|
+
if (!existsSync(globalDbPath)) {
|
|
126
|
+
console.log("No prior Symphony context found");
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const globalDb = getGlobalDb();
|
|
130
|
+
const contexts = globalDb.prepare(
|
|
131
|
+
`SELECT issue_id, attempt, summary, anchors_json, metadata_json, captured_at
|
|
132
|
+
FROM symphony_contexts
|
|
133
|
+
WHERE issue_id = ?
|
|
134
|
+
ORDER BY captured_at DESC
|
|
135
|
+
LIMIT 10`
|
|
136
|
+
).all(issueId);
|
|
137
|
+
if (contexts.length === 0 && !options.related) {
|
|
138
|
+
console.log(`No prior context for ${issueId}`);
|
|
139
|
+
globalDb.close();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const lines = [
|
|
143
|
+
`# Prior Context for ${issueId}`,
|
|
144
|
+
"",
|
|
145
|
+
`Found ${contexts.length} prior run(s).`,
|
|
146
|
+
""
|
|
147
|
+
];
|
|
148
|
+
for (const ctx of contexts) {
|
|
149
|
+
const date = new Date(ctx.captured_at * 1e3).toISOString();
|
|
150
|
+
lines.push(`## Attempt ${ctx.attempt} (${date})`);
|
|
151
|
+
if (ctx.summary) {
|
|
152
|
+
lines.push("", ctx.summary);
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const anchors = JSON.parse(ctx.anchors_json || "[]");
|
|
156
|
+
const decisions = anchors.filter((a) => a.type === "DECISION");
|
|
157
|
+
const risks = anchors.filter((a) => a.type === "RISK");
|
|
158
|
+
if (decisions.length > 0) {
|
|
159
|
+
lines.push("", "### Decisions");
|
|
160
|
+
for (const d of decisions.slice(0, 10)) {
|
|
161
|
+
lines.push(`- ${d.text}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (risks.length > 0) {
|
|
165
|
+
lines.push("", "### Risks");
|
|
166
|
+
for (const r of risks.slice(0, 5)) {
|
|
167
|
+
lines.push(`- ${r.text}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const meta = JSON.parse(ctx.metadata_json || "{}");
|
|
174
|
+
if (meta.branch || meta.lastCommit) {
|
|
175
|
+
lines.push("", "### Git State");
|
|
176
|
+
if (meta.branch) lines.push(`- Branch: ${meta.branch}`);
|
|
177
|
+
if (meta.lastCommit)
|
|
178
|
+
lines.push(`- Last commit: ${meta.lastCommit}`);
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
}
|
|
182
|
+
lines.push("");
|
|
183
|
+
}
|
|
184
|
+
const restoreDir = join(workspace, ".stackmemory");
|
|
185
|
+
if (!existsSync(restoreDir)) {
|
|
186
|
+
mkdirSync(restoreDir, { recursive: true });
|
|
187
|
+
}
|
|
188
|
+
const restorePath = join(restoreDir, "symphony-context.md");
|
|
189
|
+
writeFileSync(restorePath, lines.join("\n"));
|
|
190
|
+
globalDb.close();
|
|
191
|
+
console.log(
|
|
192
|
+
`Restored ${contexts.length} prior run(s) for ${issueId} \u2192 ${restorePath}`
|
|
193
|
+
);
|
|
194
|
+
});
|
|
195
|
+
cmd.command("archive").description("Archive workspace context before removal").requiredOption("--issue <id>", "Issue identifier (e.g., STA-476)").option("--workspace <path>", "Workspace directory", process.cwd()).action(async (options) => {
|
|
196
|
+
const workspace = options.workspace;
|
|
197
|
+
const issueId = options.issue;
|
|
198
|
+
const dbPath = join(workspace, ".stackmemory", "context.db");
|
|
199
|
+
if (!existsSync(dbPath)) {
|
|
200
|
+
console.log(`No context to archive for ${issueId}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const db = new Database(dbPath, { readonly: true });
|
|
204
|
+
const frames = db.prepare("SELECT * FROM frames ORDER BY created_at DESC").all();
|
|
205
|
+
const anchors = db.prepare("SELECT * FROM anchors").all();
|
|
206
|
+
const events = db.prepare("SELECT * FROM events ORDER BY ts DESC LIMIT 100").all();
|
|
207
|
+
const digests = frames.filter((f) => f.digest_text).map((f) => f.digest_text).slice(0, 10);
|
|
208
|
+
db.close();
|
|
209
|
+
const globalDb = getGlobalDb();
|
|
210
|
+
globalDb.prepare(
|
|
211
|
+
`INSERT INTO symphony_contexts
|
|
212
|
+
(issue_id, attempt, workspace, captured_at, context_type, summary, frames_json, anchors_json, events_json, metadata_json)
|
|
213
|
+
VALUES (?, 0, ?, ?, 'archive', ?, ?, ?, ?, ?)`
|
|
214
|
+
).run(
|
|
215
|
+
issueId,
|
|
216
|
+
workspace,
|
|
217
|
+
Math.floor(Date.now() / 1e3),
|
|
218
|
+
digests.join("\n"),
|
|
219
|
+
JSON.stringify(frames),
|
|
220
|
+
JSON.stringify(anchors),
|
|
221
|
+
JSON.stringify(events),
|
|
222
|
+
JSON.stringify({ archived: true, workspace })
|
|
223
|
+
);
|
|
224
|
+
globalDb.close();
|
|
225
|
+
console.log(
|
|
226
|
+
`Archived ${frames.length} frames, ${anchors.length} anchors for ${issueId}`
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
cmd.command("search").description("Search across all Symphony issue contexts").argument("<query>", "Search query").option("--limit <n>", "Max results", "10").action(async (query, options) => {
|
|
230
|
+
const globalDbPath = join(getGlobalStorePath(), "context.db");
|
|
231
|
+
if (!existsSync(globalDbPath)) {
|
|
232
|
+
console.log("No Symphony context database found");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const limit = parseInt(options.limit, 10);
|
|
236
|
+
const globalDb = getGlobalDb();
|
|
237
|
+
const results = globalDb.prepare(
|
|
238
|
+
`SELECT issue_id, attempt, context_type, summary, anchors_json, captured_at
|
|
239
|
+
FROM symphony_contexts
|
|
240
|
+
WHERE summary LIKE ? OR anchors_json LIKE ?
|
|
241
|
+
ORDER BY captured_at DESC
|
|
242
|
+
LIMIT ?`
|
|
243
|
+
).all(`%${query}%`, `%${query}%`, limit);
|
|
244
|
+
if (results.length === 0) {
|
|
245
|
+
console.log(`No results for "${query}"`);
|
|
246
|
+
globalDb.close();
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
console.log(`Found ${results.length} result(s) for "${query}":
|
|
250
|
+
`);
|
|
251
|
+
for (const r of results) {
|
|
252
|
+
const date = new Date(r.captured_at * 1e3).toISOString().slice(0, 16);
|
|
253
|
+
console.log(
|
|
254
|
+
` ${r.issue_id} [${r.context_type}] attempt ${r.attempt} (${date})`
|
|
255
|
+
);
|
|
256
|
+
if (r.summary) {
|
|
257
|
+
const snippet = r.summary.slice(0, 120).replace(/\n/g, " ");
|
|
258
|
+
console.log(` ${snippet}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
globalDb.close();
|
|
262
|
+
});
|
|
263
|
+
return cmd;
|
|
264
|
+
}
|
|
265
|
+
export {
|
|
266
|
+
createSymphonyCommands
|
|
267
|
+
};
|
package/dist/src/cli/index.js
CHANGED
|
@@ -57,6 +57,7 @@ import { createBenchCommand } from "./commands/bench.js";
|
|
|
57
57
|
import { createDigestCommands } from "./commands/digest.js";
|
|
58
58
|
import { createTeamCommands } from "./commands/team.js";
|
|
59
59
|
import { createDesiresCommands } from "./commands/desires.js";
|
|
60
|
+
import { createSymphonyCommands } from "./commands/symphony.js";
|
|
60
61
|
import chalk from "chalk";
|
|
61
62
|
import * as fs from "fs";
|
|
62
63
|
import * as path from "path";
|
|
@@ -507,6 +508,7 @@ program.addCommand(createBenchCommand());
|
|
|
507
508
|
program.addCommand(createDigestCommands());
|
|
508
509
|
program.addCommand(createTeamCommands());
|
|
509
510
|
program.addCommand(createDesiresCommands());
|
|
511
|
+
program.addCommand(createSymphonyCommands());
|
|
510
512
|
registerSetupCommands(program);
|
|
511
513
|
program.command("mm-spike").description(
|
|
512
514
|
"Run multi-agent planning/implementation spike (planner/implementer/critic)"
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
class FrameHandoffManager {
|
|
13
13
|
dualStackManager;
|
|
14
14
|
activeHandoffs = /* @__PURE__ */ new Map();
|
|
15
|
+
handoffMetadata = /* @__PURE__ */ new Map();
|
|
15
16
|
pendingApprovals = /* @__PURE__ */ new Map();
|
|
16
17
|
notifications = /* @__PURE__ */ new Map();
|
|
17
18
|
constructor(dualStackManager) {
|
|
@@ -53,6 +54,7 @@ class FrameHandoffManager {
|
|
|
53
54
|
errors: []
|
|
54
55
|
};
|
|
55
56
|
this.activeHandoffs.set(requestId, progress);
|
|
57
|
+
this.handoffMetadata.set(requestId, metadata);
|
|
56
58
|
await this.createHandoffNotifications(requestId, metadata, targetUserId);
|
|
57
59
|
await this.scheduleHandoffReminders(requestId, metadata);
|
|
58
60
|
logger.info(`Initiated enhanced handoff: ${requestId}`, {
|
|
@@ -372,12 +374,13 @@ class FrameHandoffManager {
|
|
|
372
374
|
async notifyChangesRequested(requestId, approval) {
|
|
373
375
|
const progress = this.activeHandoffs.get(requestId);
|
|
374
376
|
if (!progress) return;
|
|
377
|
+
const metadata = this.handoffMetadata.get(requestId);
|
|
378
|
+
const requesterId = metadata?.initiatorId || "unknown";
|
|
375
379
|
const changeRequestNotification = {
|
|
376
380
|
id: `${requestId}-changes-${Date.now()}`,
|
|
377
381
|
type: "request",
|
|
378
382
|
requestId,
|
|
379
|
-
recipientId:
|
|
380
|
-
// TODO: Get actual requester from handoff metadata
|
|
383
|
+
recipientId: requesterId,
|
|
381
384
|
title: "Changes Requested for Handoff",
|
|
382
385
|
message: `${approval.reviewerId} has requested changes: ${approval.feedback || "See detailed suggestions"}`,
|
|
383
386
|
actionRequired: true,
|
|
@@ -385,9 +388,9 @@ class FrameHandoffManager {
|
|
|
385
388
|
// 48 hours
|
|
386
389
|
createdAt: /* @__PURE__ */ new Date()
|
|
387
390
|
};
|
|
388
|
-
const notifications = this.notifications.get(
|
|
391
|
+
const notifications = this.notifications.get(requesterId) || [];
|
|
389
392
|
notifications.push(changeRequestNotification);
|
|
390
|
-
this.notifications.set(
|
|
393
|
+
this.notifications.set(requesterId, notifications);
|
|
391
394
|
logger.info(`Changes requested for handoff: ${requestId}`, {
|
|
392
395
|
reviewer: approval.reviewerId,
|
|
393
396
|
feedback: approval.feedback,
|
|
@@ -9,6 +9,13 @@ class DatabaseAdapter {
|
|
|
9
9
|
this.projectId = projectId;
|
|
10
10
|
this.config = config || {};
|
|
11
11
|
}
|
|
12
|
+
// Paginated table reads (for migration)
|
|
13
|
+
async getTablePage(table, offset, limit) {
|
|
14
|
+
void table;
|
|
15
|
+
void offset;
|
|
16
|
+
void limit;
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
12
19
|
/** Returns the configured embedding provider, if any */
|
|
13
20
|
getEmbeddingProvider() {
|
|
14
21
|
return void 0;
|
|
@@ -307,25 +307,12 @@ class MigrationManager extends EventEmitter {
|
|
|
307
307
|
}
|
|
308
308
|
const safeLimit = Math.max(1, Math.min(limit, 1e4));
|
|
309
309
|
const safeOffset = Math.max(0, offset);
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
case "events":
|
|
317
|
-
return [];
|
|
318
|
-
// Placeholder
|
|
319
|
-
case "anchors":
|
|
320
|
-
return [];
|
|
321
|
-
// Placeholder
|
|
322
|
-
default:
|
|
323
|
-
throw new DatabaseError(
|
|
324
|
-
`Unsupported table: ${table}`,
|
|
325
|
-
ErrorCode.DB_QUERY_FAILED,
|
|
326
|
-
{ table }
|
|
327
|
-
);
|
|
328
|
-
}
|
|
310
|
+
const validTable = table;
|
|
311
|
+
return this.config.sourceAdapter.getTablePage(
|
|
312
|
+
validTable,
|
|
313
|
+
safeOffset,
|
|
314
|
+
safeLimit
|
|
315
|
+
);
|
|
329
316
|
}
|
|
330
317
|
async migrateBatch(table, batch) {
|
|
331
318
|
const allowedTables = ["frames", "events", "anchors"];
|
|
@@ -1222,6 +1222,24 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
|
|
|
1222
1222
|
}
|
|
1223
1223
|
});
|
|
1224
1224
|
}
|
|
1225
|
+
async getTablePage(table, offset, limit) {
|
|
1226
|
+
if (!this.db)
|
|
1227
|
+
throw new DatabaseError(
|
|
1228
|
+
"Database not connected",
|
|
1229
|
+
ErrorCode.DB_CONNECTION_FAILED
|
|
1230
|
+
);
|
|
1231
|
+
const safeLimit = Math.max(1, Math.min(limit, 1e4));
|
|
1232
|
+
const safeOffset = Math.max(0, offset);
|
|
1233
|
+
const allowedTables = ["frames", "events", "anchors"];
|
|
1234
|
+
if (!allowedTables.includes(table)) {
|
|
1235
|
+
throw new DatabaseError(
|
|
1236
|
+
`Invalid table name: ${table}`,
|
|
1237
|
+
ErrorCode.DB_QUERY_FAILED,
|
|
1238
|
+
{ table }
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
return this.db.prepare(`SELECT * FROM ${table} ORDER BY rowid LIMIT ? OFFSET ?`).all(safeLimit, safeOffset);
|
|
1242
|
+
}
|
|
1225
1243
|
async vacuum() {
|
|
1226
1244
|
if (!this.db)
|
|
1227
1245
|
throw new DatabaseError(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stackmemoryai/stackmemory",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, Claude/Codex/OpenCode wrappers, Linear sync, automatic hooks, and log analysis.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=20.0.0",
|