@stackmemoryai/stackmemory 1.5.4 → 1.5.6

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.
@@ -8,10 +8,13 @@ import {
8
8
  mkdirSync,
9
9
  rmSync,
10
10
  writeFileSync,
11
+ readdirSync,
11
12
  createWriteStream
12
13
  } from "fs";
13
14
  import { join, dirname } from "path";
14
15
  import { tmpdir, homedir } from "os";
16
+ import { createReadStream } from "fs";
17
+ import { createInterface } from "readline";
15
18
  import { fileURLToPath } from "url";
16
19
  import { Transform } from "stream";
17
20
  import { logger } from "../../core/monitoring/logger.js";
@@ -128,7 +131,7 @@ const DEFAULT_CONFIG = {
128
131
  inProgressState: "In Progress",
129
132
  inReviewState: "In Review",
130
133
  pollIntervalMs: 3e4,
131
- maxConcurrent: 3,
134
+ maxConcurrent: 5,
132
135
  workspaceRoot: join(tmpdir(), "conductor_workspaces"),
133
136
  repoRoot: process.cwd(),
134
137
  baseBranch: "main",
@@ -158,6 +161,23 @@ class Conductor {
158
161
  stateCache = /* @__PURE__ */ new Map();
159
162
  activeStatesLower;
160
163
  terminalStatesLower;
164
+ /** Global rate limit backoff state */
165
+ rateLimit = {
166
+ inBackoff: false,
167
+ backoffUntil: 0,
168
+ totalHits: 0,
169
+ consecutiveHits: 0,
170
+ lastHitAt: 0
171
+ };
172
+ /** Aggregated usage stats */
173
+ usage = {
174
+ inputTokens: 0,
175
+ outputTokens: 0,
176
+ cacheCreationTokens: 0,
177
+ cacheReadTokens: 0,
178
+ estimatedMessages: 0,
179
+ perAgent: /* @__PURE__ */ new Map()
180
+ };
161
181
  constructor(config = {}) {
162
182
  this.config = { ...DEFAULT_CONFIG, ...config };
163
183
  this.activeStatesLower = this.config.activeStates.map(
@@ -294,7 +314,9 @@ class Conductor {
294
314
  failed: this.failCount,
295
315
  totalAttempts: this.totalAttempts,
296
316
  uptime: Date.now() - this.startedAt,
297
- issues
317
+ issues,
318
+ rateLimit: { ...this.rateLimit },
319
+ usage: { ...this.usage, perAgent: new Map(this.usage.perAgent) }
298
320
  };
299
321
  }
300
322
  // ── Status File ──
@@ -324,7 +346,31 @@ class Conductor {
324
346
  failed: this.failCount,
325
347
  totalAttempts: this.totalAttempts,
326
348
  maxConcurrent: this.config.maxConcurrent,
327
- stopping: this.stopping
349
+ stopping: this.stopping,
350
+ rateLimit: {
351
+ inBackoff: this.rateLimit.inBackoff,
352
+ backoffUntil: this.rateLimit.backoffUntil,
353
+ backoffRemainingSec: this.rateLimit.inBackoff ? Math.max(
354
+ 0,
355
+ Math.ceil((this.rateLimit.backoffUntil - Date.now()) / 1e3)
356
+ ) : 0,
357
+ totalHits: this.rateLimit.totalHits
358
+ },
359
+ usage: (() => {
360
+ const summary = this.getUsageSummary();
361
+ return {
362
+ inputTokens: summary.inputTokens,
363
+ outputTokens: summary.outputTokens,
364
+ totalTokens: summary.totalTokens,
365
+ estimatedMessages: summary.estimatedMessages,
366
+ tokensPerMin: summary.tokensPerMin,
367
+ budgetPct5x: summary.budgetPct5x,
368
+ budgetPct20x: summary.budgetPct20x,
369
+ minutesRemaining5x: summary.minutesRemaining5x,
370
+ minutesRemaining20x: summary.minutesRemaining20x,
371
+ cacheHitRate: summary.cacheHitRate
372
+ };
373
+ })()
328
374
  };
329
375
  try {
330
376
  writeFileSync(
@@ -360,7 +406,8 @@ class Conductor {
360
406
  phase: run.phase,
361
407
  filesModified: run.filesModified,
362
408
  toolCalls: run.toolCalls,
363
- tokensUsed: run.tokensUsed
409
+ tokensUsed: run.tokensUsed,
410
+ workspacePath: run.workspacePath || void 0
364
411
  };
365
412
  writeFileSync(join(dir, "status.json"), JSON.stringify(status, null, 2));
366
413
  } catch {
@@ -390,6 +437,21 @@ class Conductor {
390
437
  }
391
438
  async poll() {
392
439
  if (!this.client || this.stopping) return;
440
+ if (this.rateLimit.inBackoff) {
441
+ const remaining = this.rateLimit.backoffUntil - Date.now();
442
+ if (remaining > 0) {
443
+ logger.info("Rate limit backoff active, skipping poll", {
444
+ remainingMs: remaining,
445
+ remainingSec: Math.ceil(remaining / 1e3),
446
+ totalHits: this.rateLimit.totalHits
447
+ });
448
+ return;
449
+ }
450
+ this.rateLimit.inBackoff = false;
451
+ this.rateLimit.backoffUntil = 0;
452
+ logger.info("Rate limit backoff expired, resuming dispatch");
453
+ console.log("[rate-limit] Backoff expired, resuming dispatch");
454
+ }
393
455
  await this.reconcile();
394
456
  const available = this.config.maxConcurrent - this.running.size;
395
457
  if (available <= 0) {
@@ -407,6 +469,29 @@ class Conductor {
407
469
  if (eligible.length === 0) return;
408
470
  const sorted = eligible.sort((a, b) => (a.priority || 4) - (b.priority || 4)).slice(0, available);
409
471
  const toDispatch = this.preflightFilter(sorted);
472
+ if (this.usage.inputTokens > 0) {
473
+ const summary = this.getUsageSummary();
474
+ logger.info("Usage summary", {
475
+ totalTokens: summary.totalTokens,
476
+ estimatedMessages: summary.estimatedMessages,
477
+ tokensPerMin: summary.tokensPerMin,
478
+ budgetPct5x: `${summary.budgetPct5x}%`,
479
+ budgetPct20x: `${summary.budgetPct20x}%`,
480
+ minutesRemaining5x: summary.minutesRemaining5x,
481
+ minutesRemaining20x: summary.minutesRemaining20x,
482
+ rateLimitHits: this.rateLimit.totalHits
483
+ });
484
+ if (summary.budgetPct5x >= 75 && summary.budgetPct5x < 100) {
485
+ console.log(
486
+ `[usage] \u26A0 ${summary.budgetPct5x}% of Max 5x budget used \u2014 ~${summary.minutesRemaining5x}min remaining at ${summary.tokensPerMin} tok/min`
487
+ );
488
+ }
489
+ if (summary.budgetPct5x >= 100) {
490
+ console.log(
491
+ `[usage] \u{1F6D1} Max 5x budget likely exhausted (${summary.estimatedMessages} est. messages / 225 limit). Expect 429s.`
492
+ );
493
+ }
494
+ }
410
495
  logger.info("Dispatching issues", {
411
496
  count: toDispatch.length,
412
497
  identifiers: toDispatch.map((i) => i.identifier),
@@ -569,6 +654,9 @@ class Conductor {
569
654
  error: run.error,
570
655
  attempt: run.attempt
571
656
  });
657
+ if (this.handleRateLimitError(run.error, issue.identifier)) {
658
+ throw err;
659
+ }
572
660
  if (run.workspacePath) {
573
661
  await this.runHook(
574
662
  "after-run",
@@ -595,6 +683,179 @@ class Conductor {
595
683
  }
596
684
  }
597
685
  }
686
+ // ── Rate Limit Detection ──
687
+ /**
688
+ * Check if an error indicates a rate limit (429) or usage cap.
689
+ * Triggers global backoff so no new agents are dispatched.
690
+ *
691
+ * Claude Max limits (approximate, shared between claude.ai + Claude Code):
692
+ * - Max 5x: ~225 messages per 5h window
693
+ * - Max 20x: ~900 messages per 5h window
694
+ * Messages are token-weighted: heavy agent usage ≈ 5-10x a casual message.
695
+ */
696
+ handleRateLimitError(error, identifier) {
697
+ const rateLimitPatterns = [
698
+ "usage limits",
699
+ "rate_limit",
700
+ "rate limit",
701
+ "overloaded",
702
+ "429",
703
+ "too many requests",
704
+ "quota exceeded",
705
+ "capacity"
706
+ ];
707
+ const isRateLimit = rateLimitPatterns.some(
708
+ (p) => error.toLowerCase().includes(p)
709
+ );
710
+ if (!isRateLimit) return false;
711
+ this.rateLimit.totalHits++;
712
+ this.rateLimit.lastHitAt = Date.now();
713
+ const timeSinceLast = Date.now() - this.rateLimit.lastHitAt;
714
+ if (timeSinceLast < 6e5) {
715
+ this.rateLimit.consecutiveHits++;
716
+ } else {
717
+ this.rateLimit.consecutiveHits = 1;
718
+ }
719
+ const backoffSec = Math.min(
720
+ 60 * Math.pow(2, this.rateLimit.consecutiveHits - 1),
721
+ 900
722
+ );
723
+ this.rateLimit.inBackoff = true;
724
+ this.rateLimit.backoffUntil = Date.now() + backoffSec * 1e3;
725
+ logger.warn("Rate limit hit \u2014 global backoff", {
726
+ identifier,
727
+ backoffSec,
728
+ consecutiveHits: this.rateLimit.consecutiveHits,
729
+ totalHits: this.rateLimit.totalHits,
730
+ error: error.slice(0, 200)
731
+ });
732
+ console.log(
733
+ `[rate-limit] Hit rate limit on ${identifier} \u2014 backing off ${backoffSec}s (hit #${this.rateLimit.totalHits})`
734
+ );
735
+ return true;
736
+ }
737
+ // ── Usage Tracking ──
738
+ /**
739
+ * Track token usage from a stream-json event (CLI mode) or JSON-RPC message (adapter mode).
740
+ * Updates both global and per-agent counters.
741
+ */
742
+ trackUsage(identifier, usage) {
743
+ const input = usage.input_tokens || 0;
744
+ const output = usage.output_tokens || 0;
745
+ const cacheCreate = usage.cache_creation_input_tokens || 0;
746
+ const cacheRead = usage.cache_read_input_tokens || 0;
747
+ this.usage.inputTokens += input;
748
+ this.usage.outputTokens += output;
749
+ this.usage.cacheCreationTokens += cacheCreate;
750
+ this.usage.cacheReadTokens += cacheRead;
751
+ this.usage.estimatedMessages = Math.ceil(
752
+ (this.usage.inputTokens + this.usage.outputTokens) / 1e4
753
+ );
754
+ const agent = this.usage.perAgent.get(identifier) || {
755
+ inputTokens: 0,
756
+ outputTokens: 0
757
+ };
758
+ agent.inputTokens += input;
759
+ agent.outputTokens += output;
760
+ this.usage.perAgent.set(identifier, agent);
761
+ }
762
+ /**
763
+ * Scan Claude Code JSONL logs for token usage from conductor-spawned sessions.
764
+ * Reads logs from ~/.claude/projects/ matching conductor workspace paths.
765
+ */
766
+ async scanUsageLogs() {
767
+ const logDirs = [
768
+ join(homedir(), ".claude", "projects"),
769
+ join(homedir(), ".config", "claude", "projects")
770
+ ];
771
+ for (const logDir of logDirs) {
772
+ if (!existsSync(logDir)) continue;
773
+ try {
774
+ const entries = readdirSync(logDir);
775
+ const conductorDirs = entries.filter(
776
+ (e) => e.includes("conductor_workspaces") || e.includes("conductor-")
777
+ );
778
+ for (const dir of conductorDirs) {
779
+ const projectDir = join(logDir, dir);
780
+ if (!existsSync(projectDir)) continue;
781
+ const jsonlFiles = readdirSync(projectDir).filter(
782
+ (f) => f.endsWith(".jsonl")
783
+ );
784
+ for (const file of jsonlFiles) {
785
+ await this.parseUsageFromJsonl(join(projectDir, file));
786
+ }
787
+ }
788
+ } catch (err) {
789
+ logger.debug("Usage log scan failed", {
790
+ error: err.message
791
+ });
792
+ }
793
+ }
794
+ return this.usage;
795
+ }
796
+ async parseUsageFromJsonl(filePath) {
797
+ try {
798
+ const rl = createInterface({
799
+ input: createReadStream(filePath),
800
+ crlfDelay: Infinity
801
+ });
802
+ for await (const line of rl) {
803
+ if (!line.trim()) continue;
804
+ try {
805
+ const entry = JSON.parse(line);
806
+ if (entry.type === "assistant" && entry.message?.usage) {
807
+ const usage = entry.message.usage;
808
+ const identifier = entry.sessionId?.slice(0, 8) || "unknown";
809
+ this.trackUsage(identifier, usage);
810
+ }
811
+ } catch {
812
+ }
813
+ }
814
+ } catch {
815
+ }
816
+ }
817
+ /**
818
+ * Get current usage summary with time-to-exhaustion estimate.
819
+ *
820
+ * Claude Max limits (approximate, shared between claude.ai + Claude Code):
821
+ * - Max 5x: ~225 messages per 5h window (~10k tokens per "message")
822
+ * - Max 20x: ~900 messages per 5h window
823
+ *
824
+ * Heavy agent usage burns 5-10x faster than casual chat.
825
+ */
826
+ getUsageSummary() {
827
+ const totalCache = this.usage.cacheCreationTokens + this.usage.cacheReadTokens;
828
+ const cacheHitRate = totalCache > 0 ? this.usage.cacheReadTokens / totalCache : 0;
829
+ const uptimeMin = Math.max(1, (Date.now() - this.startedAt) / 6e4);
830
+ const totalTokens = this.usage.inputTokens + this.usage.outputTokens;
831
+ const tokensPerMin = Math.round(totalTokens / uptimeMin);
832
+ const estMessages = this.usage.estimatedMessages;
833
+ const MAX_5X_MESSAGES = 225;
834
+ const MAX_20X_MESSAGES = 900;
835
+ const budgetPct5x = Math.round(estMessages / MAX_5X_MESSAGES * 100);
836
+ const budgetPct20x = Math.round(estMessages / MAX_20X_MESSAGES * 100);
837
+ const msgsPerMin = tokensPerMin > 0 ? tokensPerMin / 1e4 : 0;
838
+ const minutesRemaining5x = msgsPerMin > 0 ? Math.round((MAX_5X_MESSAGES - estMessages) / msgsPerMin) : -1;
839
+ const minutesRemaining20x = msgsPerMin > 0 ? Math.round((MAX_20X_MESSAGES - estMessages) / msgsPerMin) : -1;
840
+ return {
841
+ totalTokens,
842
+ inputTokens: this.usage.inputTokens,
843
+ outputTokens: this.usage.outputTokens,
844
+ estimatedMessages: estMessages,
845
+ cacheHitRate: Math.round(cacheHitRate * 100),
846
+ tokensPerMin,
847
+ budgetPct5x,
848
+ budgetPct20x,
849
+ minutesRemaining5x: minutesRemaining5x < 0 ? -1 : minutesRemaining5x,
850
+ minutesRemaining20x: minutesRemaining20x < 0 ? -1 : minutesRemaining20x,
851
+ perAgent: Array.from(this.usage.perAgent.entries()).map(
852
+ ([id, stats]) => ({
853
+ id,
854
+ ...stats
855
+ })
856
+ )
857
+ };
858
+ }
598
859
  // ── Workspace Management ──
599
860
  async createWorkspace(issue) {
600
861
  const wsKey = this.sanitizeIdentifier(issue.identifier);
@@ -672,6 +933,21 @@ class Conductor {
672
933
  sanitizeIdentifier(identifier) {
673
934
  return identifier.replace(/[^A-Za-z0-9._-]/g, "_");
674
935
  }
936
+ /**
937
+ * Find the real claude binary, skipping shell wrappers (cmux, claude-smd).
938
+ * Wrappers inject --settings with hooks that block headless -p mode.
939
+ */
940
+ findClaudeBinary() {
941
+ const candidates = [
942
+ join(homedir(), ".local", "bin", "claude"),
943
+ "/usr/local/bin/claude",
944
+ "/opt/homebrew/bin/claude"
945
+ ];
946
+ for (const c of candidates) {
947
+ if (existsSync(c)) return c;
948
+ }
949
+ return "claude";
950
+ }
675
951
  // ── Agent Execution ──
676
952
  async runAgent(issue, run) {
677
953
  if (this.config.agentMode === "cli") {
@@ -679,7 +955,10 @@ class Conductor {
679
955
  return await this.runAgentCLI(issue, run);
680
956
  } catch (err) {
681
957
  const msg = err.message || "";
682
- if (msg.includes("usage limits") || msg.includes("rate_limit") || msg.includes("overloaded")) {
958
+ if (this.handleRateLimitError(msg, issue.identifier)) {
959
+ throw err;
960
+ }
961
+ if (msg.includes("usage limits") || msg.includes("overloaded")) {
683
962
  logger.warn("CLI mode hit limits, falling back to adapter", {
684
963
  identifier: issue.identifier,
685
964
  error: msg.slice(0, 200)
@@ -702,13 +981,16 @@ class Conductor {
702
981
  runAgentCLI(issue, run) {
703
982
  return new Promise((resolve, reject) => {
704
983
  const prompt = this.buildPrompt(issue, run.attempt);
984
+ const claudeBin = this.findClaudeBinary();
705
985
  const proc = spawn(
706
- "claude",
986
+ claudeBin,
707
987
  [
708
988
  "-p",
709
989
  "--output-format",
710
990
  "stream-json",
711
991
  "--dangerously-skip-permissions",
992
+ "--settings",
993
+ '{"hooks":{}}',
712
994
  prompt
713
995
  ],
714
996
  {
@@ -729,6 +1011,7 @@ class Conductor {
729
1011
  }
730
1012
  );
731
1013
  run.process = proc;
1014
+ proc.stdin.end();
732
1015
  const logStream = this.openAgentLogStream(issue.identifier);
733
1016
  run.logStream = logStream;
734
1017
  const tee = new TeeTransform(logStream);
@@ -759,6 +1042,10 @@ class Conductor {
759
1042
  }
760
1043
  if (event.type === "assistant") {
761
1044
  const message = event.message;
1045
+ const msgUsage = message?.usage;
1046
+ if (msgUsage) {
1047
+ this.trackUsage(issue.identifier, msgUsage);
1048
+ }
762
1049
  const content = message?.content || [];
763
1050
  for (const block of content) {
764
1051
  if (block.type === "tool_use") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
4
4
  "description": "Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, and automatic hooks.",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",
@@ -100,6 +100,7 @@
100
100
  "postinstall": "node scripts/install-claude-hooks-auto.js || true",
101
101
  "init": "node dist/scripts/initialize.js",
102
102
  "build": "rm -rf dist && node esbuild.config.js",
103
+ "typecheck": "NODE_OPTIONS='--max-old-space-size=8192' tsc --noEmit",
103
104
  "lint": "eslint 'src/**/*.ts' 'scripts/**/*.ts'",
104
105
  "lint:fix": "eslint 'src/**/*.ts' 'scripts/**/*.ts' --fix --max-warnings=-1",
105
106
  "lint:fast": "oxlint src scripts",