@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.
- 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 +170 -8
- package/dist/src/integrations/claude-code/subagent-client.js +103 -83
- package/dist/src/integrations/claude-code/task-coordinator.js +60 -17
- package/dist/src/integrations/mcp/handlers/linear-handlers.js +62 -48
- 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"];
|
|
@@ -292,11 +292,16 @@ class SQLiteAdapter extends FeatureAwareDatabaseAdapter {
|
|
|
292
292
|
logger.info("FTS5 index rebuilt");
|
|
293
293
|
}
|
|
294
294
|
/**
|
|
295
|
-
* Incremental garbage collection
|
|
296
|
-
*
|
|
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 {
|
|
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 =
|
|
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
|
-
*
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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:
|
|
182
|
-
output: result.
|
|
173
|
+
result: parsed,
|
|
174
|
+
output: result.text,
|
|
183
175
|
duration: Date.now() - startTime,
|
|
184
176
|
subagentType: request.type,
|
|
185
|
-
tokens: this.estimateTokens(
|
|
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
|
-
*
|
|
351
|
-
*
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
}
|
|
408
|
-
|
|
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.
|
|
209
|
+
return this.spawnClaudeCode(agentName, prompt, agentConfig);
|
|
209
210
|
}
|
|
210
211
|
/**
|
|
211
|
-
*
|
|
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
|
-
|
|
214
|
-
return new Promise((resolve) => {
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
92
|
+
updateData.labelIds = Array.isArray(labels) ? labels : [labels];
|
|
95
93
|
}
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
119
|
+
* Get issues from Linear via GraphQL API
|
|
109
120
|
*/
|
|
110
121
|
async handleLinearGetTasks(args) {
|
|
111
122
|
try {
|
|
112
|
-
const {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
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.
|
|
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",
|