@stackmemoryai/stackmemory 1.3.0 → 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.
@@ -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.
@@ -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.3.0",
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",