@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.
@@ -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
+ };
@@ -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: "requester",
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("requester") || [];
391
+ const notifications = this.notifications.get(requesterId) || [];
389
392
  notifications.push(changeRequestNotification);
390
- this.notifications.set("requester", notifications);
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
- void safeLimit;
311
- void safeOffset;
312
- switch (table) {
313
- case "frames":
314
- return [];
315
- // Placeholder
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.2.9",
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",