@stackmemoryai/stackmemory 1.2.9 → 1.3.1

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"];
@@ -292,11 +292,16 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
292
292
  logger.info("FTS5 index rebuilt");
293
293
  }
294
294
  /**
295
- * Incremental garbage collection: delete expired frames and cascade to related tables.
296
- * Respects retention_policy per frame:
295
+ * Incremental garbage collection with generational compression.
296
+ *
297
+ * Phase 1 — Compress: Apply generational strategies to mature/old frames.
298
+ * - 'digest_only': Strip inputs/outputs/events, keep digest + anchors
299
+ * - 'anchors_only': Strip inputs/outputs/events/digest_json, keep digest_text + anchors
300
+ * - 'keep_all': No compression
301
+ *
302
+ * Phase 2 — Delete: Remove frames past retention cutoffs.
297
303
  * - 'keep_forever': never deleted
298
- * - 'default': deleted after retentionDays (default 90)
299
- * - 'archive': same as default
304
+ * - 'default'/'archive': deleted after retentionDays (default 90)
300
305
  * - 'ttl_30d': deleted after 30 days
301
306
  * - 'ttl_7d': deleted after 7 days
302
307
  */
@@ -314,6 +319,62 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
314
319
  const defaultCutoff = nowSec - retentionDays * 86400;
315
320
  const ttl30dCutoff = nowSec - 30 * 86400;
316
321
  const ttl7dCutoff = nowSec - 7 * 86400;
322
+ let framesCompressed = 0;
323
+ const gc = options.generationalGc;
324
+ if (gc && !dryRun) {
325
+ const youngDays = gc.youngCutoffDays ?? 1;
326
+ const matureDays = gc.matureCutoffDays ?? 7;
327
+ const oldDays = gc.oldCutoffDays ?? 30;
328
+ const youngCutoff = nowSec - youngDays * 86400;
329
+ const matureCutoff = nowSec - matureDays * 86400;
330
+ const matureStrategy = gc.mature_strategy ?? "digest_only";
331
+ if (matureStrategy !== "keep_all") {
332
+ const matureFrames = this.db.prepare(
333
+ `SELECT frame_id FROM frames
334
+ WHERE created_at < ? AND created_at >= ?
335
+ AND state = 'closed'
336
+ AND retention_policy != 'keep_forever'
337
+ AND inputs != '{}'
338
+ AND run_id NOT IN (SELECT value FROM json_each(?))
339
+ LIMIT ?`
340
+ ).all(
341
+ youngCutoff,
342
+ matureCutoff,
343
+ JSON.stringify(protectedRunIds),
344
+ batchSize
345
+ );
346
+ if (matureFrames.length > 0) {
347
+ framesCompressed += this.compressFrames(
348
+ matureFrames.map((f) => f.frame_id),
349
+ matureStrategy
350
+ );
351
+ }
352
+ }
353
+ const oldStrategy = gc.old_strategy ?? "anchors_only";
354
+ if (oldStrategy !== "keep_all") {
355
+ const oldCutoff = nowSec - oldDays * 86400;
356
+ const oldFrames = this.db.prepare(
357
+ `SELECT frame_id FROM frames
358
+ WHERE created_at < ? AND created_at >= ?
359
+ AND state = 'closed'
360
+ AND retention_policy != 'keep_forever'
361
+ AND inputs != '{}'
362
+ AND run_id NOT IN (SELECT value FROM json_each(?))
363
+ LIMIT ?`
364
+ ).all(
365
+ matureCutoff,
366
+ oldCutoff,
367
+ JSON.stringify(protectedRunIds),
368
+ batchSize
369
+ );
370
+ if (oldFrames.length > 0) {
371
+ framesCompressed += this.compressFrames(
372
+ oldFrames.map((f) => f.frame_id),
373
+ oldStrategy
374
+ );
375
+ }
376
+ }
377
+ }
317
378
  const candidates = this.db.prepare(
318
379
  `SELECT frame_id FROM frames
319
380
  WHERE (
@@ -340,7 +401,8 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
340
401
  eventsDeleted: 0,
341
402
  anchorsDeleted: 0,
342
403
  embeddingsDeleted: 0,
343
- ftsEntriesDeleted: 0
404
+ ftsEntriesDeleted: 0,
405
+ framesCompressed
344
406
  };
345
407
  }
346
408
  if (dryRun) {
@@ -362,8 +424,9 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
362
424
  eventsDeleted: eventsCount,
363
425
  anchorsDeleted: anchorsCount,
364
426
  embeddingsDeleted: embeddingsCount,
365
- ftsEntriesDeleted: frameIds.length
427
+ ftsEntriesDeleted: frameIds.length,
366
428
  // FTS has one entry per frame
429
+ framesCompressed
367
430
  };
368
431
  }
369
432
  const placeholders = frameIds.map(() => "?").join(",");
@@ -392,14 +455,16 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
392
455
  framesDeleted: frameIds.length,
393
456
  eventsDeleted,
394
457
  anchorsDeleted,
395
- embeddingsDeleted
458
+ embeddingsDeleted,
459
+ framesCompressed
396
460
  });
397
461
  return {
398
462
  framesDeleted: frameIds.length,
399
463
  eventsDeleted,
400
464
  anchorsDeleted,
401
465
  embeddingsDeleted,
402
- ftsEntriesDeleted: frameIds.length
466
+ ftsEntriesDeleted: frameIds.length,
467
+ framesCompressed
403
468
  };
404
469
  }
405
470
  /**
@@ -432,6 +497,85 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
432
497
  if (nowSec - frame.created_at < 86400) score += 0.1;
433
498
  return Math.round(Math.min(score, 1) * 100) / 100;
434
499
  }
500
+ /**
501
+ * Compress a frame by stripping data according to strategy.
502
+ *
503
+ * - 'digest_only': Remove inputs, outputs, events. Keep digest_text, digest_json, anchors.
504
+ * - 'anchors_only': Remove inputs, outputs, events, digest_json. Keep digest_text, anchors.
505
+ *
506
+ * Returns true if the frame was compressed, false if not found.
507
+ */
508
+ compressFrame(frameId, strategy) {
509
+ if (!this.db)
510
+ throw new DatabaseError(
511
+ "Database not connected",
512
+ ErrorCode.DB_CONNECTION_FAILED
513
+ );
514
+ const frame = this.db.prepare("SELECT frame_id FROM frames WHERE frame_id = ?").get(frameId);
515
+ if (!frame) return false;
516
+ this.db.prepare("BEGIN").run();
517
+ try {
518
+ if (strategy === "digest_only") {
519
+ this.db.prepare(
520
+ "UPDATE frames SET inputs = '{}', outputs = '{}' WHERE frame_id = ?"
521
+ ).run(frameId);
522
+ } else {
523
+ this.db.prepare(
524
+ "UPDATE frames SET inputs = '{}', outputs = '{}', digest_json = '{}' WHERE frame_id = ?"
525
+ ).run(frameId);
526
+ }
527
+ this.db.prepare("DELETE FROM events WHERE frame_id = ?").run(frameId);
528
+ if (this.vecEnabled) {
529
+ this.db.prepare("DELETE FROM frame_embeddings WHERE frame_id = ?").run(frameId);
530
+ }
531
+ this.db.prepare("COMMIT").run();
532
+ return true;
533
+ } catch (error) {
534
+ this.db.prepare("ROLLBACK").run();
535
+ throw error;
536
+ }
537
+ }
538
+ /**
539
+ * Compress multiple frames in a single transaction.
540
+ * Returns count of frames compressed.
541
+ */
542
+ compressFrames(frameIds, strategy) {
543
+ if (!this.db || frameIds.length === 0) return 0;
544
+ const placeholders = frameIds.map(() => "?").join(",");
545
+ this.db.prepare("BEGIN").run();
546
+ try {
547
+ if (strategy === "digest_only") {
548
+ this.db.prepare(
549
+ `UPDATE frames SET inputs = '{}', outputs = '{}' WHERE frame_id IN (${placeholders})`
550
+ ).run(...frameIds);
551
+ } else {
552
+ this.db.prepare(
553
+ `UPDATE frames SET inputs = '{}', outputs = '{}', digest_json = '{}' WHERE frame_id IN (${placeholders})`
554
+ ).run(...frameIds);
555
+ }
556
+ this.db.prepare(`DELETE FROM events WHERE frame_id IN (${placeholders})`).run(...frameIds);
557
+ if (this.vecEnabled) {
558
+ this.db.prepare(
559
+ `DELETE FROM frame_embeddings WHERE frame_id IN (${placeholders})`
560
+ ).run(...frameIds);
561
+ }
562
+ this.db.prepare("COMMIT").run();
563
+ return frameIds.length;
564
+ } catch (error) {
565
+ this.db.prepare("ROLLBACK").run();
566
+ throw error;
567
+ }
568
+ }
569
+ /**
570
+ * Get database file size in bytes.
571
+ */
572
+ getDatabaseSize() {
573
+ if (!this.db) return 0;
574
+ const result = this.db.prepare(
575
+ "SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()"
576
+ ).get();
577
+ return result?.size ?? 0;
578
+ }
435
579
  /**
436
580
  * Recompute importance scores for frames still at default score (0.5).
437
581
  * Processes oldest frames first in batches.
@@ -1222,6 +1366,24 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
1222
1366
  }
1223
1367
  });
1224
1368
  }
1369
+ async getTablePage(table, offset, limit) {
1370
+ if (!this.db)
1371
+ throw new DatabaseError(
1372
+ "Database not connected",
1373
+ ErrorCode.DB_CONNECTION_FAILED
1374
+ );
1375
+ const safeLimit = Math.max(1, Math.min(limit, 1e4));
1376
+ const safeOffset = Math.max(0, offset);
1377
+ const allowedTables = ["frames", "events", "anchors"];
1378
+ if (!allowedTables.includes(table)) {
1379
+ throw new DatabaseError(
1380
+ `Invalid table name: ${table}`,
1381
+ ErrorCode.DB_QUERY_FAILED,
1382
+ { table }
1383
+ );
1384
+ }
1385
+ return this.db.prepare(`SELECT * FROM ${table} ORDER BY rowid LIMIT ? OFFSET ?`).all(safeLimit, safeOffset);
1386
+ }
1225
1387
  async vacuum() {
1226
1388
  if (!this.db)
1227
1389
  throw new DatabaseError(
@@ -4,8 +4,7 @@ const __filename = __fileURLToPath(import.meta.url);
4
4
  const __dirname = __pathDirname(__filename);
5
5
  import { logger } from "../../core/monitoring/logger.js";
6
6
  import { STRUCTURED_RESPONSE_SUFFIX } from "../../orchestrators/multimodal/constants.js";
7
- import { exec } from "child_process";
8
- import { promisify } from "util";
7
+ import { spawn } from "child_process";
9
8
  import * as fs from "fs";
10
9
  import * as path from "path";
11
10
  import * as os from "os";
@@ -18,12 +17,11 @@ import {
18
17
  createProvider
19
18
  } from "../../core/extensions/provider-adapter.js";
20
19
  import { AnthropicBatchClient } from "../anthropic/batch-client.js";
21
- const execAsync = promisify(exec);
22
20
  class ClaudeCodeSubagentClient {
23
21
  tempDir;
24
22
  activeSubagents = /* @__PURE__ */ new Map();
25
23
  mockMode;
26
- constructor(mockMode = true) {
24
+ constructor(mockMode = false) {
27
25
  this.mockMode = mockMode;
28
26
  this.tempDir = path.join(os.tmpdir(), "stackmemory-rlm");
29
27
  if (!fs.existsSync(this.tempDir)) {
@@ -148,7 +146,8 @@ class ClaudeCodeSubagentClient {
148
146
  }
149
147
  }
150
148
  /**
151
- * Original CLI-based subagent execution (unchanged behavior)
149
+ * Execute subagent via Claude Code CLI (`claude -p --output-format stream-json`).
150
+ * Spawns a real Claude Code process with full tool use.
152
151
  */
153
152
  async executeSubagentViaCLI(request, startTime, subagentId) {
154
153
  try {
@@ -158,31 +157,24 @@ class ClaudeCodeSubagentClient {
158
157
  contextFile,
159
158
  JSON.stringify(request.context, null, 2)
160
159
  );
161
- const resultFile = path.join(this.tempDir, `${subagentId}-result.json`);
162
- const taskCommand = this.buildTaskCommand(
163
- request,
164
- prompt,
165
- contextFile,
166
- resultFile
167
- );
168
- const result = await this.executeTaskTool(taskCommand, request.timeout);
169
- let subagentResult = {};
170
- if (fs.existsSync(resultFile)) {
171
- const resultContent = await fs.promises.readFile(resultFile, "utf-8");
172
- try {
173
- subagentResult = JSON.parse(resultContent);
174
- } catch {
175
- subagentResult = { rawOutput: resultContent };
176
- }
177
- }
160
+ const fullPrompt = `${prompt}
161
+
162
+ Context (JSON): ${JSON.stringify(request.context)}`;
163
+ const result = await this.spawnClaude(fullPrompt, request.timeout);
178
164
  this.cleanup(subagentId);
165
+ let parsed;
166
+ try {
167
+ parsed = JSON.parse(result.text);
168
+ } catch {
169
+ parsed = { rawOutput: result.text };
170
+ }
179
171
  return {
180
172
  success: true,
181
- result: subagentResult,
182
- output: result.stdout,
173
+ result: parsed,
174
+ output: result.text,
183
175
  duration: Date.now() - startTime,
184
176
  subagentType: request.type,
185
- tokens: this.estimateTokens(prompt + JSON.stringify(subagentResult))
177
+ tokens: this.estimateTokens(fullPrompt + result.text)
186
178
  };
187
179
  } catch (error) {
188
180
  logger.error(`Subagent CLI execution failed: ${request.type}`, {
@@ -347,66 +339,94 @@ class ClaudeCodeSubagentClient {
347
339
  return (request.systemPrompt || prompts[request.type] || prompts.planning) + STRUCTURED_RESPONSE_SUFFIX;
348
340
  }
349
341
  /**
350
- * Build Task tool command
351
- * This creates a command that Claude Code's Task tool can execute
342
+ * Spawn `claude -p --output-format stream-json` and collect the result.
343
+ * Parses stream-json events to extract the final assistant text.
352
344
  */
353
- buildTaskCommand(request, prompt, contextFile, resultFile) {
354
- const scriptContent = `
355
- #!/bin/bash
356
- # Subagent execution script for ${request.type}
357
-
358
- # Read context
359
- CONTEXT=$(cat "${contextFile}")
360
-
361
- # Execute task based on type
362
- case "${request.type}" in
363
- "testing")
364
- # For testing subagent, actually run tests
365
- echo "Generating and running tests..."
366
- # The subagent will generate test files and run them
367
- ;;
368
- "linting")
369
- # For linting subagent, run actual linters
370
- echo "Running linters..."
371
- npm run lint || true
372
- ;;
373
- "code")
374
- # For code generation, create implementation files
375
- echo "Generating implementation..."
376
- ;;
377
- *)
378
- # Default behavior
379
- echo "Executing ${request.type} task..."
380
- ;;
381
- esac
382
-
383
- # Write result
384
- echo '{"status": "completed", "type": "${request.type}"}' > "${resultFile}"
385
- `;
386
- const scriptFile = path.join(this.tempDir, `${request.type}-script.sh`);
387
- fs.writeFileSync(scriptFile, scriptContent);
388
- fs.chmodSync(scriptFile, "755");
389
- return scriptFile;
390
- }
391
- /**
392
- * Execute via Task tool (simulated for now)
393
- * In production, this would use Claude Code's actual Task tool API
394
- */
395
- async executeTaskTool(command, timeout) {
396
- try {
397
- const result = await execAsync(command, {
398
- timeout: timeout || 3e5,
399
- // 5 minutes default
400
- maxBuffer: 10 * 1024 * 1024
401
- // 10MB buffer
345
+ spawnClaude(prompt, timeout) {
346
+ return new Promise((resolve, reject) => {
347
+ const args = [
348
+ "-p",
349
+ "--output-format",
350
+ "stream-json",
351
+ "--dangerously-skip-permissions",
352
+ prompt
353
+ ];
354
+ const claude = spawn("claude", args, {
355
+ cwd: process.cwd(),
356
+ env: { ...process.env },
357
+ stdio: ["pipe", "pipe", "pipe"]
402
358
  });
403
- return result;
404
- } catch (error) {
405
- if (error.killed || error.signal === "SIGTERM") {
406
- throw new Error(`Subagent timeout after ${timeout}ms`);
407
- }
408
- throw error;
409
- }
359
+ const timeoutMs = timeout || 3e5;
360
+ const timer = setTimeout(() => {
361
+ claude.kill("SIGTERM");
362
+ reject(new Error(`Subagent timeout after ${timeoutMs}ms`));
363
+ }, timeoutMs);
364
+ let lastAssistantText = "";
365
+ let toolUseCount = 0;
366
+ let lineBuffer = "";
367
+ let stderr = "";
368
+ claude.stdout.on("data", (chunk) => {
369
+ lineBuffer += chunk.toString();
370
+ const lines = lineBuffer.split("\n");
371
+ lineBuffer = lines.pop() || "";
372
+ for (const line of lines) {
373
+ if (!line.trim()) continue;
374
+ try {
375
+ const event = JSON.parse(line);
376
+ if (event.type === "assistant" && event.message) {
377
+ const textBlocks = (event.message.content || []).filter((b) => b.type === "text").map((b) => b.text);
378
+ if (textBlocks.length > 0) {
379
+ lastAssistantText = textBlocks.join("\n");
380
+ }
381
+ const toolBlocks = (event.message.content || []).filter(
382
+ (b) => b.type === "tool_use"
383
+ );
384
+ toolUseCount += toolBlocks.length;
385
+ }
386
+ if (event.type === "result" && event.result) {
387
+ lastAssistantText = event.result;
388
+ }
389
+ } catch {
390
+ }
391
+ }
392
+ });
393
+ claude.stderr.on("data", (data) => {
394
+ stderr += data.toString();
395
+ });
396
+ claude.on("close", (code) => {
397
+ clearTimeout(timer);
398
+ if (lineBuffer.trim()) {
399
+ try {
400
+ const event = JSON.parse(lineBuffer);
401
+ if (event.type === "result" && event.result) {
402
+ lastAssistantText = event.result;
403
+ }
404
+ } catch {
405
+ }
406
+ }
407
+ logger.info("Claude subagent completed", {
408
+ code,
409
+ toolUseCount,
410
+ outputLength: lastAssistantText.length
411
+ });
412
+ if (code === 0 && lastAssistantText) {
413
+ resolve({ text: lastAssistantText, toolUseCount });
414
+ } else if (code === 0) {
415
+ resolve({
416
+ text: "(Claude completed but produced no text output)",
417
+ toolUseCount
418
+ });
419
+ } else {
420
+ reject(
421
+ new Error(`Claude exited code ${code}: ${stderr.slice(0, 500)}`)
422
+ );
423
+ }
424
+ });
425
+ claude.on("error", (err) => {
426
+ clearTimeout(timer);
427
+ reject(new Error(`Failed to spawn claude: ${err.message}`));
428
+ });
429
+ });
410
430
  }
411
431
  /**
412
432
  * Get mock response for testing
@@ -3,6 +3,7 @@ import { dirname as __pathDirname } from 'path';
3
3
  const __filename = __fileURLToPath(import.meta.url);
4
4
  const __dirname = __pathDirname(__filename);
5
5
  import { v4 as uuidv4 } from "uuid";
6
+ import { spawn } from "child_process";
6
7
  import { logger } from "../../core/monitoring/logger.js";
7
8
  class ClaudeCodeTaskCoordinator {
8
9
  activeTasks = /* @__PURE__ */ new Map();
@@ -205,26 +206,68 @@ class ClaudeCodeTaskCoordinator {
205
206
  agentType: agentConfig.type,
206
207
  promptTokens: this.estimateTokenUsage(prompt, "")
207
208
  });
208
- return this.simulateClaudeCodeExecution(agentName, prompt, agentConfig);
209
+ return this.spawnClaudeCode(agentName, prompt, agentConfig);
209
210
  }
210
211
  /**
211
- * Simulate Claude Code execution (temporary until real integration)
212
+ * Spawn Claude Code CLI as a subprocess.
213
+ * Uses `claude --print` for non-interactive execution with stdout capture.
214
+ * Workspace cwd is inherited from the coordinator's process.
212
215
  */
213
- simulateClaudeCodeExecution(agentName, prompt, agentConfig) {
214
- return new Promise((resolve) => {
215
- const executionTime = agentConfig.type === "oracle" ? 2e3 + Math.random() * 3e3 : 1e3 + Math.random() * 2e3;
216
- setTimeout(() => {
217
- const result = `Claude Code agent '${agentName}' completed task successfully.
218
-
219
- Agent Capabilities Used: ${agentConfig.capabilities.slice(0, 3).join(", ")}
220
- Task Type: ${agentConfig.type}
221
- Specializations: ${agentConfig.specializations.join(", ")}
222
-
223
- Simulated output based on prompt context: ${prompt.substring(0, 100)}...
224
-
225
- This simulation will be replaced with actual Claude Code Task tool integration.`;
226
- resolve(result);
227
- }, executionTime);
216
+ spawnClaudeCode(agentName, prompt, agentConfig) {
217
+ return new Promise((resolve, reject) => {
218
+ const args = ["--print"];
219
+ if (agentConfig.type === "oracle") {
220
+ args.push("--model", "opus");
221
+ }
222
+ if (agentConfig.capabilities.includes("code_implementation")) {
223
+ args.push("--allowedTools", "Edit,Write,Bash,Read,Glob,Grep");
224
+ } else {
225
+ args.push("--allowedTools", "Read,Glob,Grep,Bash");
226
+ }
227
+ args.push(prompt);
228
+ logger.info("Spawning claude CLI", {
229
+ agentName,
230
+ agentType: agentConfig.type,
231
+ cwd: process.cwd(),
232
+ promptLength: prompt.length
233
+ });
234
+ const proc = spawn("claude", args, {
235
+ cwd: process.cwd(),
236
+ env: { ...process.env },
237
+ stdio: ["pipe", "pipe", "pipe"]
238
+ });
239
+ let stdout = "";
240
+ let stderr = "";
241
+ proc.stdout.on("data", (data) => {
242
+ stdout += data.toString();
243
+ });
244
+ proc.stderr.on("data", (data) => {
245
+ stderr += data.toString();
246
+ });
247
+ proc.on("error", (err) => {
248
+ logger.error("Failed to spawn claude CLI", {
249
+ agentName,
250
+ error: err.message
251
+ });
252
+ reject(new Error(`Failed to spawn claude: ${err.message}`));
253
+ });
254
+ proc.on("close", (code) => {
255
+ if (code === 0) {
256
+ logger.debug("Claude CLI completed", {
257
+ agentName,
258
+ outputLength: stdout.length
259
+ });
260
+ resolve(stdout.trim());
261
+ } else {
262
+ const errMsg = stderr.slice(0, 500) || `exit code ${code}`;
263
+ logger.warn("Claude CLI failed", {
264
+ agentName,
265
+ exitCode: code,
266
+ stderr: errMsg
267
+ });
268
+ reject(new Error(`Claude CLI exited with code ${code}: ${errMsg}`));
269
+ }
270
+ });
228
271
  });
229
272
  }
230
273
  /**
@@ -2,11 +2,19 @@ import { fileURLToPath as __fileURLToPath } from 'url';
2
2
  import { dirname as __pathDirname } from 'path';
3
3
  const __filename = __fileURLToPath(import.meta.url);
4
4
  const __dirname = __pathDirname(__filename);
5
+ import { LinearClient } from "../../linear/client.js";
5
6
  import { logger } from "../../../core/monitoring/logger.js";
6
7
  class LinearHandlers {
7
8
  constructor(deps) {
8
9
  this.deps = deps;
9
10
  }
11
+ /**
12
+ * Create an authenticated LinearClient from the auth manager token
13
+ */
14
+ async getClient() {
15
+ const token = await this.deps.linearAuthManager.getValidToken();
16
+ return new LinearClient({ apiKey: token, useBearer: true });
17
+ }
10
18
  /**
11
19
  * Sync tasks with Linear
12
20
  */
@@ -67,7 +75,7 @@ class LinearHandlers {
67
75
  }
68
76
  }
69
77
  /**
70
- * Update Linear task status
78
+ * Update Linear issue directly via GraphQL API
71
79
  */
72
80
  async handleLinearUpdateTask(args) {
73
81
  try {
@@ -75,27 +83,30 @@ class LinearHandlers {
75
83
  if (!linear_id) {
76
84
  throw new Error("Linear ID is required");
77
85
  }
78
- try {
79
- await this.deps.linearAuthManager.getValidToken();
80
- } catch {
81
- throw new Error("Linear authentication required");
82
- }
86
+ const client = await this.getClient();
83
87
  const updateData = {};
84
- if (status) {
85
- updateData.status = status;
86
- }
87
- if (assignee_id) {
88
- updateData.assigneeId = assignee_id;
89
- }
90
- if (priority) {
91
- updateData.priority = priority;
92
- }
88
+ if (status) updateData.stateId = status;
89
+ if (assignee_id) updateData.assigneeId = assignee_id;
90
+ if (priority !== void 0) updateData.priority = priority;
93
91
  if (labels) {
94
- updateData.labels = Array.isArray(labels) ? labels : [labels];
92
+ updateData.labelIds = Array.isArray(labels) ? labels : [labels];
95
93
  }
96
- throw new Error(
97
- "Linear issue updates via MCP are not yet implemented. Use `stackmemory linear sync` instead."
98
- );
94
+ const issue = await client.updateIssue(linear_id, updateData);
95
+ return {
96
+ content: [
97
+ {
98
+ type: "text",
99
+ text: `Updated ${issue.identifier}: ${issue.title}
100
+ Status: ${issue.state.name} | Priority: ${issue.priority}`
101
+ }
102
+ ],
103
+ metadata: {
104
+ id: issue.id,
105
+ identifier: issue.identifier,
106
+ state: issue.state.name,
107
+ url: issue.url
108
+ }
109
+ };
99
110
  } catch (error) {
100
111
  logger.error(
101
112
  "Error updating Linear task",
@@ -105,40 +116,43 @@ class LinearHandlers {
105
116
  }
106
117
  }
107
118
  /**
108
- * Get tasks from Linear
119
+ * Get issues from Linear via GraphQL API
109
120
  */
110
121
  async handleLinearGetTasks(args) {
111
122
  try {
112
- const {
113
- team_id,
114
- assignee_id,
115
- state = "active",
116
- limit = 20,
117
- search
118
- } = args;
119
- try {
120
- await this.deps.linearAuthManager.getValidToken();
121
- } catch {
122
- throw new Error("Linear authentication required");
123
- }
124
- const filters = {
125
- limit
123
+ const { team_id, assignee_id, state = "active", limit = 20 } = args;
124
+ const client = await this.getClient();
125
+ const stateTypeMap = {
126
+ active: "started",
127
+ closed: "completed",
128
+ all: void 0
126
129
  };
127
- if (team_id) {
128
- filters.teamId = team_id;
129
- }
130
- if (assignee_id) {
131
- filters.assigneeId = assignee_id;
132
- }
133
- if (state) {
134
- filters.state = state;
135
- }
136
- if (search) {
137
- filters.search = search;
138
- }
139
- throw new Error(
140
- "Linear issue listing via MCP is not yet implemented. Use `stackmemory linear sync` instead."
130
+ const issues = await client.getIssues({
131
+ teamId: team_id,
132
+ assigneeId: assignee_id,
133
+ stateType: stateTypeMap[state],
134
+ limit
135
+ });
136
+ const issueLines = issues.map(
137
+ (i) => `${i.identifier} [${i.state.name}] ${i.title}${i.assignee ? ` (@${i.assignee.name})` : ""}`
141
138
  );
139
+ const text = issues.length > 0 ? `Found ${issues.length} issues:
140
+ ${issueLines.join("\n")}` : "No issues found matching filters.";
141
+ return {
142
+ content: [{ type: "text", text }],
143
+ metadata: {
144
+ count: issues.length,
145
+ issues: issues.map((i) => ({
146
+ id: i.id,
147
+ identifier: i.identifier,
148
+ title: i.title,
149
+ state: i.state.name,
150
+ priority: i.priority,
151
+ assignee: i.assignee?.name,
152
+ url: i.url
153
+ }))
154
+ }
155
+ };
142
156
  } catch (error) {
143
157
  logger.error(
144
158
  "Error getting Linear tasks",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.2.9",
3
+ "version": "1.3.1",
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",