@stackmemoryai/stackmemory 1.0.1 → 1.2.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.
Files changed (51) hide show
  1. package/dist/src/cli/claude-sm.js +65 -0
  2. package/dist/src/cli/commands/audit.js +134 -0
  3. package/dist/src/cli/commands/bench.js +252 -0
  4. package/dist/src/cli/commands/dashboard.js +2 -1
  5. package/dist/src/cli/commands/stats.js +118 -0
  6. package/dist/src/cli/index.js +6 -0
  7. package/dist/src/core/config/feature-flags.js +7 -1
  8. package/dist/src/core/context/enhanced-rehydration.js +24 -5
  9. package/dist/src/core/extensions/cerebras-adapter.js +28 -0
  10. package/dist/src/core/extensions/deepinfra-adapter.js +32 -0
  11. package/dist/src/core/extensions/provider-adapter.js +33 -240
  12. package/dist/src/core/models/complexity-scorer.js +154 -0
  13. package/dist/src/core/models/model-router.js +230 -36
  14. package/dist/src/core/models/provider-pricing.js +63 -0
  15. package/dist/src/core/models/sensitive-guard.js +112 -0
  16. package/dist/src/core/monitoring/feedback-loops.js +88 -0
  17. package/dist/src/hooks/schemas.js +12 -1
  18. package/dist/src/integrations/anthropic/batch-client.js +256 -0
  19. package/dist/src/integrations/anthropic/client.js +87 -72
  20. package/dist/src/integrations/claude-code/subagent-client.js +133 -12
  21. package/dist/src/integrations/graphiti/client.js +16 -4
  22. package/dist/src/integrations/mcp/handlers/index.js +25 -1
  23. package/dist/src/integrations/mcp/handlers/provider-handlers.js +227 -0
  24. package/dist/src/integrations/mcp/server.js +316 -1
  25. package/dist/src/integrations/mcp/tool-definitions.js +90 -1
  26. package/dist/src/orchestrators/multimodal/baselines.js +128 -0
  27. package/dist/src/orchestrators/multimodal/constants.js +9 -1
  28. package/dist/src/orchestrators/multimodal/harness.js +86 -6
  29. package/dist/src/orchestrators/multimodal/providers.js +113 -2
  30. package/dist/src/skills/recursive-agent-orchestrator.js +15 -10
  31. package/dist/src/utils/fuzzy-edit.js +162 -0
  32. package/dist/src/utils/hook-installer.js +155 -0
  33. package/package.json +6 -2
  34. package/scripts/gepa/.before-optimize.md +159 -0
  35. package/scripts/gepa/generations/gen-000/baseline.md +159 -124
  36. package/scripts/gepa/generations/gen-001/baseline.md +159 -0
  37. package/scripts/gepa/generations/gen-001/variant-a.md +166 -0
  38. package/scripts/gepa/generations/gen-001/variant-b.md +237 -0
  39. package/scripts/gepa/generations/gen-001/variant-c.md +61 -0
  40. package/scripts/gepa/generations/gen-001/variant-d.md +119 -0
  41. package/scripts/gepa/results/eval-1-baseline.json +41 -0
  42. package/scripts/gepa/results/eval-1-variant-a.json +41 -0
  43. package/scripts/gepa/results/eval-1-variant-b.json +41 -0
  44. package/scripts/gepa/results/eval-1-variant-c.json +41 -0
  45. package/scripts/gepa/results/eval-1-variant-d.json +41 -0
  46. package/scripts/gepa/state.json +41 -2
  47. package/scripts/install-claude-hooks-auto.js +176 -44
  48. package/templates/claude-hooks/auto-checkpoint.js +174 -0
  49. package/templates/claude-hooks/chime-on-stop.sh +22 -0
  50. package/templates/claude-hooks/session-rescue.sh +15 -0
  51. package/templates/claude-hooks/stop-checkpoint.js +120 -0
@@ -2,11 +2,20 @@ 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 { callClaude, callCodexCLI, implementWithClaude } from "./providers.js";
5
+ import {
6
+ callClaude,
7
+ callCodexCLI,
8
+ captureGitDiff,
9
+ implementWithClaude,
10
+ parseEditMetrics,
11
+ runPostImplChecks
12
+ } from "./providers.js";
6
13
  import * as fs from "fs";
7
14
  import * as path from "path";
8
15
  import { FrameManager } from "../../core/context/index.js";
9
16
  import { deriveProjectId } from "./utils.js";
17
+ import { HARNESS_TARGETS, summarizeRuns } from "./baselines.js";
18
+ import { feedbackLoops } from "../../core/monitoring/feedback-loops.js";
10
19
  function heuristicPlan(input) {
11
20
  return {
12
21
  summary: `Plan for: ${input.task}`,
@@ -50,6 +59,7 @@ Repo: ${input.repoPath}
50
59
  Notes: ${input.contextNotes || "(none)"}
51
60
  ${contextSummary}
52
61
  Constraints: Keep the plan minimal and implementable in a single PR.`;
62
+ const t0 = Date.now();
53
63
  let plan;
54
64
  try {
55
65
  const raw = await callClaude(plannerPrompt, {
@@ -65,6 +75,7 @@ Constraints: Keep the plan minimal and implementable in a single PR.`;
65
75
  } catch {
66
76
  plan = heuristicPlan(input);
67
77
  }
78
+ const planLatencyMs = Date.now() - t0;
68
79
  const implementer = options.implementer || "codex";
69
80
  const maxIters = Math.max(1, options.maxIters ?? 2);
70
81
  const iterations = [];
@@ -104,11 +115,25 @@ Incorporate reviewer suggestions: ${lastCritique.suggestions.join("; ")}`;
104
115
  lastCommand = `claude:${options.plannerModel || "sonnet"} prompt`;
105
116
  lastOutput = impl.output;
106
117
  }
107
- const criticSystem = `You are a strict code reviewer. Return a JSON object: { approved: boolean, issues: string[], suggestions: string[] }`;
118
+ const diff = options.dryRun !== false ? "(dry run \u2014 no diff)" : captureGitDiff(input.repoPath);
119
+ const checks = options.dryRun !== false ? null : runPostImplChecks(input.repoPath);
120
+ const checksSection = checks ? `
121
+
122
+ Post-implementation checks:
123
+ Lint: ${checks.lintOk ? "PASS" : "FAIL"}
124
+ ${checks.lintOutput}
125
+ Tests: ${checks.testsOk ? "PASS" : "FAIL"}
126
+ ${checks.testOutput}` : "";
127
+ const criticSystem = `You are a strict code reviewer. Review the git diff against the plan. Check for: correctness, missing steps, unrelated changes, bugs, security issues. Also review lint and test results if provided. Return raw JSON only (no markdown fences): { "approved": boolean, "issues": ["string"], "suggestions": ["string"] }`;
108
128
  const criticPrompt = `Plan: ${plan.summary}
129
+ Acceptance criteria:
130
+ ${plan.steps.map((s) => s.acceptanceCriteria?.join(", ") || s.title).join("\n")}
131
+
109
132
  Attempt ${i + 1}/${maxIters}
110
- Command: ${lastCommand}
111
- Output: ${lastOutput.slice(0, 2e3)}`;
133
+ Implementer exit: ${ok ? "success" : "failed"}
134
+
135
+ Git diff:
136
+ ${diff}${checksSection}`;
112
137
  try {
113
138
  const raw = await callClaude(criticPrompt, {
114
139
  model: options.reviewerModel,
@@ -126,7 +151,7 @@ Output: ${lastOutput.slice(0, 2e3)}`;
126
151
  iterations.push({
127
152
  command: lastCommand,
128
153
  ok,
129
- outputPreview: lastOutput.slice(0, 400),
154
+ outputPreview: diff.slice(0, 2e3),
130
155
  critique: lastCritique
131
156
  });
132
157
  if (lastCritique.approved) {
@@ -134,6 +159,24 @@ Output: ${lastOutput.slice(0, 2e3)}`;
134
159
  break;
135
160
  }
136
161
  }
162
+ const totalLatencyMs = Date.now() - t0;
163
+ const finalDiff = options.dryRun !== false ? "(dry run \u2014 no diff)" : captureGitDiff(input.repoPath);
164
+ const editMetrics = parseEditMetrics(finalDiff);
165
+ const runMetrics = {
166
+ timestamp: Date.now(),
167
+ task: input.task,
168
+ plannerModel: options.plannerModel || "default",
169
+ reviewerModel: options.reviewerModel || "default",
170
+ implementer,
171
+ planLatencyMs,
172
+ totalLatencyMs,
173
+ iterations: iterations.length,
174
+ approved,
175
+ editAttempts: editMetrics.editAttempts,
176
+ editSuccesses: editMetrics.editSuccesses,
177
+ editFuzzyFallbacks: editMetrics.editFuzzyFallbacks,
178
+ contextTokens: Math.ceil(finalDiff.length / 4)
179
+ };
137
180
  try {
138
181
  const dir = options.auditDir || path.join(input.repoPath, ".stackmemory", "build");
139
182
  fs.mkdirSync(dir, { recursive: true });
@@ -146,12 +189,49 @@ Output: ${lastOutput.slice(0, 2e3)}`;
146
189
  input,
147
190
  options: { ...options, auditDir: void 0 },
148
191
  plan,
149
- iterations
192
+ iterations,
193
+ metrics: runMetrics
150
194
  },
151
195
  null,
152
196
  2
153
197
  )
154
198
  );
199
+ const metricsFile = path.join(dir, "harness-metrics.jsonl");
200
+ fs.appendFileSync(metricsFile, JSON.stringify(runMetrics) + "\n");
201
+ try {
202
+ const lines = fs.readFileSync(metricsFile, "utf-8").split("\n").filter((l) => l.trim());
203
+ const recent = lines.slice(-10).map((l) => JSON.parse(l));
204
+ if (recent.length >= 3) {
205
+ const summary = summarizeRuns(recent);
206
+ if (summary.approvalRate < HARNESS_TARGETS.firstPassApprovalRate) {
207
+ feedbackLoops.fire(
208
+ "harnessRegression",
209
+ "metrics_append",
210
+ {
211
+ metric: "approvalRate",
212
+ current: summary.approvalRate,
213
+ target: HARNESS_TARGETS.firstPassApprovalRate,
214
+ window: recent.length
215
+ },
216
+ "regression_alert"
217
+ );
218
+ }
219
+ if (summary.p95TotalLatencyMs > HARNESS_TARGETS.totalLatencyP95Ms) {
220
+ feedbackLoops.fire(
221
+ "harnessRegression",
222
+ "metrics_append",
223
+ {
224
+ metric: "totalLatencyP95",
225
+ current: summary.p95TotalLatencyMs,
226
+ target: HARNESS_TARGETS.totalLatencyP95Ms,
227
+ window: recent.length
228
+ },
229
+ "regression_alert"
230
+ );
231
+ }
232
+ }
233
+ } catch {
234
+ }
155
235
  } catch {
156
236
  }
157
237
  if (options.record) {
@@ -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 { spawnSync } from "child_process";
6
+ import { STRUCTURED_RESPONSE_SUFFIX } from "./constants.js";
6
7
  async function callClaude(prompt, options) {
7
8
  const apiKey = process.env["ANTHROPIC_API_KEY"];
8
9
  if (!apiKey) {
@@ -15,7 +16,7 @@ async function callClaude(prompt, options) {
15
16
  const { Anthropic } = await import("@anthropic-ai/sdk");
16
17
  const client = new Anthropic({ apiKey });
17
18
  const model = options.model || "claude-sonnet-4-20250514";
18
- const system = options.system || "You are a precise software planning assistant.";
19
+ const system = (options.system || "You are a precise software planning assistant.") + STRUCTURED_RESPONSE_SUFFIX;
19
20
  try {
20
21
  const msg = await client.messages.create({
21
22
  model,
@@ -79,6 +80,113 @@ function callCodexCLI(prompt, args = [], dryRun = true, cwd) {
79
80
  return { ok: false, output: e?.message || String(e), command: printable };
80
81
  }
81
82
  }
83
+ function captureGitDiff(cwd, maxLen = 12e3) {
84
+ try {
85
+ const unstaged = spawnSync("git", ["diff"], {
86
+ cwd,
87
+ encoding: "utf8",
88
+ timeout: 1e4
89
+ });
90
+ const staged = spawnSync("git", ["diff", "--cached"], {
91
+ cwd,
92
+ encoding: "utf8",
93
+ timeout: 1e4
94
+ });
95
+ const untracked = spawnSync(
96
+ "git",
97
+ ["ls-files", "--others", "--exclude-standard"],
98
+ { cwd, encoding: "utf8", timeout: 1e4 }
99
+ );
100
+ let diff = "";
101
+ if (staged.stdout?.trim()) diff += staged.stdout;
102
+ if (unstaged.stdout?.trim()) diff += (diff ? "\n" : "") + unstaged.stdout;
103
+ if (untracked.stdout?.trim()) {
104
+ const newFiles = untracked.stdout.trim().split("\n").slice(0, 10);
105
+ diff += (diff ? "\n" : "") + `New untracked files:
106
+ ${newFiles.join("\n")}`;
107
+ }
108
+ if (!diff.trim()) return "(no changes detected)";
109
+ if (diff.length > maxLen) {
110
+ return diff.slice(0, maxLen) + `
111
+ ... (truncated, ${diff.length} total chars)`;
112
+ }
113
+ return diff;
114
+ } catch {
115
+ return "(git diff failed)";
116
+ }
117
+ }
118
+ function runPostImplChecks(cwd) {
119
+ const maxOutput = 2e3;
120
+ function truncate(s) {
121
+ if (s.length <= maxOutput) return s;
122
+ return s.slice(0, maxOutput) + `
123
+ ... (truncated, ${s.length} total chars)`;
124
+ }
125
+ let lintOk = false;
126
+ let lintOutput = "";
127
+ try {
128
+ const lint = spawnSync("npm", ["run", "lint"], {
129
+ cwd,
130
+ encoding: "utf8",
131
+ timeout: 3e4
132
+ });
133
+ lintOk = lint.status === 0;
134
+ lintOutput = truncate((lint.stdout || "") + (lint.stderr || ""));
135
+ } catch (e) {
136
+ lintOutput = truncate(e instanceof Error ? e.message : String(e));
137
+ }
138
+ let testsOk = false;
139
+ let testOutput = "";
140
+ try {
141
+ const tests = spawnSync(
142
+ "npx",
143
+ ["vitest", "run", "--reporter=dot", "--bail=1"],
144
+ {
145
+ cwd,
146
+ encoding: "utf8",
147
+ timeout: 12e4
148
+ }
149
+ );
150
+ testsOk = tests.status === 0;
151
+ testOutput = truncate((tests.stdout || "") + (tests.stderr || ""));
152
+ } catch (e) {
153
+ testOutput = truncate(e instanceof Error ? e.message : String(e));
154
+ }
155
+ return { lintOk, lintOutput, testsOk, testOutput };
156
+ }
157
+ function parseEditMetrics(diff) {
158
+ if (!diff || diff.startsWith("(")) {
159
+ return { editAttempts: 0, editSuccesses: 0, editFuzzyFallbacks: 0 };
160
+ }
161
+ const lines = diff.split("\n");
162
+ let currentFileHunks = 0;
163
+ let currentFileHasConflict = false;
164
+ let totalAttempts = 0;
165
+ let totalSuccesses = 0;
166
+ const flushFile = () => {
167
+ totalAttempts += currentFileHunks;
168
+ if (!currentFileHasConflict) {
169
+ totalSuccesses += currentFileHunks;
170
+ }
171
+ currentFileHunks = 0;
172
+ currentFileHasConflict = false;
173
+ };
174
+ for (const line of lines) {
175
+ if (/^diff --git /.test(line)) {
176
+ flushFile();
177
+ } else if (/^@@ /.test(line)) {
178
+ currentFileHunks++;
179
+ } else if (/^[<>=]{7}/.test(line)) {
180
+ currentFileHasConflict = true;
181
+ }
182
+ }
183
+ flushFile();
184
+ return {
185
+ editAttempts: totalAttempts,
186
+ editSuccesses: totalSuccesses,
187
+ editFuzzyFallbacks: 0
188
+ };
189
+ }
82
190
  async function implementWithClaude(prompt, options) {
83
191
  try {
84
192
  const out = await callClaude(prompt, {
@@ -93,5 +201,8 @@ async function implementWithClaude(prompt, options) {
93
201
  export {
94
202
  callClaude,
95
203
  callCodexCLI,
96
- implementWithClaude
204
+ captureGitDiff,
205
+ implementWithClaude,
206
+ parseEditMetrics,
207
+ runPostImplChecks
97
208
  };
@@ -8,6 +8,7 @@ import { logger } from "../core/monitoring/logger.js";
8
8
  import { ParallelExecutor } from "../core/execution/parallel-executor.js";
9
9
  import { RecursiveContextManager } from "../core/context/recursive-context-manager.js";
10
10
  import { ClaudeCodeSubagentClient } from "../integrations/claude-code/subagent-client.js";
11
+ import { STRUCTURED_RESPONSE_SUFFIX } from "../orchestrators/multimodal/constants.js";
11
12
  class RecursiveAgentOrchestrator {
12
13
  frameManager;
13
14
  contextRetriever;
@@ -72,7 +73,7 @@ Rules:
72
73
  - Maximize parallelism \u2014 independent tasks run concurrently
73
74
  - Each subtask names its agent type: planning, code, testing, linting, review, improve, context, publish
74
75
  - Include failure modes and rollback steps for risky operations
75
- - Keep subtask descriptions actionable (verb + object + constraint)`,
76
+ - Keep subtask descriptions actionable (verb + object + constraint)` + STRUCTURED_RESPONSE_SUFFIX,
76
77
  capabilities: ["decompose", "analyze", "strategize", "prioritize"]
77
78
  });
78
79
  configs.set("code", {
@@ -87,7 +88,7 @@ Rules:
87
88
  - Add .js extensions to relative TypeScript imports (ESM)
88
89
  - Return undefined over throwing; log+continue over crash
89
90
  - No emojis, no unnecessary comments, functions under 20 lines
90
- - Validate inputs at system boundaries only`,
91
+ - Validate inputs at system boundaries only` + STRUCTURED_RESPONSE_SUFFIX,
91
92
  capabilities: ["implement", "refactor", "optimize", "document"]
92
93
  });
93
94
  configs.set("testing", {
@@ -102,7 +103,7 @@ Rules:
102
103
  - Prioritize: critical paths > edge cases > happy paths
103
104
  - Each test should assert meaningful behavior, not implementation details
104
105
  - Use parameterized tests (it.each) to consolidate similar cases
105
- - Run tests after writing: npm run test:run`,
106
+ - Run tests after writing: npm run test:run` + STRUCTURED_RESPONSE_SUFFIX,
106
107
  capabilities: [
107
108
  "generate-tests",
108
109
  "validate",
@@ -121,7 +122,7 @@ Rules:
121
122
  - Run: npm run lint (ESLint + Prettier)
122
123
  - Auto-fix: npm run lint:fix
123
124
  - ESM imports require .js extension on relative paths
124
- - Report unfixable issues with file:line format`,
125
+ - Report unfixable issues with file:line format` + STRUCTURED_RESPONSE_SUFFIX,
125
126
  capabilities: ["lint", "format", "type-check", "security-scan"]
126
127
  });
127
128
  configs.set("review", {
@@ -136,7 +137,7 @@ Rules:
136
137
  - Flag: SQL injection, XSS, secret exposure, command injection
137
138
  - Flag: functions > 20 lines, cyclomatic complexity > 5
138
139
  - Flag: missing error handling at system boundaries
139
- - Suggest specific fixes, not vague improvements`,
140
+ - Suggest specific fixes, not vague improvements` + STRUCTURED_RESPONSE_SUFFIX,
140
141
  capabilities: [
141
142
  "review",
142
143
  "critique",
@@ -155,7 +156,7 @@ Rules:
155
156
  - Apply only the specific improvements requested \u2014 no scope creep
156
157
  - Maintain backward compatibility unless explicitly breaking
157
158
  - Run lint + tests after changes to verify nothing regressed
158
- - Keep changes minimal and focused`,
159
+ - Keep changes minimal and focused` + STRUCTURED_RESPONSE_SUFFIX,
159
160
  capabilities: ["enhance", "refactor", "optimize", "polish"]
160
161
  });
161
162
  configs.set("context", {
@@ -169,7 +170,7 @@ Rules:
169
170
  - Check docs/specs/ for ONE_PAGER.md, DEV_SPEC.md, PROMPT_PLAN.md
170
171
  - Check CLAUDE.md and AGENTS.md for project conventions
171
172
  - Search src/ for relevant implementations
172
- - Return concise summaries, not full file contents`,
173
+ - Return concise summaries, not full file contents` + STRUCTURED_RESPONSE_SUFFIX,
173
174
  capabilities: ["search", "retrieve", "summarize", "contextualize"]
174
175
  });
175
176
  configs.set("publish", {
@@ -183,7 +184,7 @@ Rules:
183
184
  - Verify lint + tests + build pass before any publish
184
185
  - Follow semver: breaking=major, feature=minor, fix=patch
185
186
  - Generate changelog from git log since last tag
186
- - Never force-push or skip pre-publish hooks`,
187
+ - Never force-push or skip pre-publish hooks` + STRUCTURED_RESPONSE_SUFFIX,
187
188
  capabilities: ["publish-npm", "github-release", "deploy", "document"]
188
189
  });
189
190
  return configs;
@@ -557,9 +558,13 @@ ${sections.join("\n\n")}` : "";
557
558
  const pricing = {
558
559
  "claude-sonnet-4-5-20250929": 15,
559
560
  "claude-haiku-4-5-20251001": 1,
560
- "claude-opus-4-6": 75
561
+ "claude-opus-4-6": 75,
562
+ // External providers — much cheaper
563
+ "llama-4-scout-17b-16e-instruct": 0.35,
564
+ "THUDM/glm-4-9b-chat": 0.06
561
565
  };
562
- return tokens / 1e6 * (pricing[model] || 10);
566
+ const modelName = typeof model === "string" ? model : model.model;
567
+ return tokens / 1e6 * (pricing[modelName] || 10);
563
568
  }
564
569
  countGeneratedTests(node) {
565
570
  let count = 0;
@@ -0,0 +1,162 @@
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
+ function levenshtein(a, b) {
6
+ const m = a.length;
7
+ const n = b.length;
8
+ const dp = Array.from(
9
+ { length: m + 1 },
10
+ () => new Array(n + 1).fill(0)
11
+ );
12
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
13
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
14
+ for (let i = 1; i <= m; i++) {
15
+ for (let j = 1; j <= n; j++) {
16
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
17
+ dp[i][j] = Math.min(
18
+ dp[i - 1][j] + 1,
19
+ dp[i][j - 1] + 1,
20
+ dp[i - 1][j - 1] + cost
21
+ );
22
+ }
23
+ }
24
+ return dp[m][n];
25
+ }
26
+ function normalizeWhitespace(text) {
27
+ return text.split("\n").map((line) => line.trim().replace(/\s+/g, " ")).join("\n");
28
+ }
29
+ function stripIndentation(text) {
30
+ return text.split("\n").map((line) => line.trimStart()).join("\n");
31
+ }
32
+ function fuzzyMatch(content, oldString, threshold = 0.85) {
33
+ if (!oldString) return null;
34
+ const exactIdx = content.indexOf(oldString);
35
+ if (exactIdx !== -1) {
36
+ return {
37
+ found: true,
38
+ startIndex: exactIdx,
39
+ endIndex: exactIdx + oldString.length,
40
+ confidence: 1,
41
+ matchedText: oldString,
42
+ method: "exact"
43
+ };
44
+ }
45
+ const normContent = normalizeWhitespace(content);
46
+ const normOld = normalizeWhitespace(oldString);
47
+ const normIdx = normContent.indexOf(normOld);
48
+ if (normIdx !== -1) {
49
+ const match = mapNormalizedToOriginal(
50
+ content,
51
+ normContent,
52
+ normOld,
53
+ normIdx
54
+ );
55
+ if (match) {
56
+ return {
57
+ found: true,
58
+ startIndex: match.start,
59
+ endIndex: match.end,
60
+ confidence: 0.95,
61
+ matchedText: content.slice(match.start, match.end),
62
+ method: "whitespace-normalized"
63
+ };
64
+ }
65
+ }
66
+ const stripContent = stripIndentation(content);
67
+ const stripOld = stripIndentation(oldString);
68
+ const stripIdx = stripContent.indexOf(stripOld);
69
+ if (stripIdx !== -1) {
70
+ const match = mapStrippedToOriginal(
71
+ content,
72
+ stripContent,
73
+ stripOld,
74
+ stripIdx
75
+ );
76
+ if (match) {
77
+ return {
78
+ found: true,
79
+ startIndex: match.start,
80
+ endIndex: match.end,
81
+ confidence: 0.9,
82
+ matchedText: content.slice(match.start, match.end),
83
+ method: "indentation-insensitive"
84
+ };
85
+ }
86
+ }
87
+ const contentLines = content.split("\n");
88
+ const oldLines = oldString.split("\n");
89
+ const windowSize = oldLines.length;
90
+ if (windowSize === 0 || contentLines.length < windowSize) return null;
91
+ let bestScore = 0;
92
+ let bestStart = -1;
93
+ let bestEnd = -1;
94
+ for (let i = 0; i <= contentLines.length - windowSize; i++) {
95
+ const windowText = contentLines.slice(i, i + windowSize).join("\n");
96
+ const maxLen = Math.max(windowText.length, oldString.length);
97
+ if (maxLen === 0) continue;
98
+ const dist = levenshtein(windowText, oldString);
99
+ const similarity = 1 - dist / maxLen;
100
+ if (similarity > bestScore) {
101
+ bestScore = similarity;
102
+ bestStart = i;
103
+ bestEnd = i + windowSize;
104
+ }
105
+ }
106
+ if (bestScore >= threshold && bestStart >= 0) {
107
+ let startCharIdx = 0;
108
+ for (let i = 0; i < bestStart; i++) {
109
+ startCharIdx += contentLines[i].length + 1;
110
+ }
111
+ let endCharIdx = startCharIdx;
112
+ for (let i = bestStart; i < bestEnd; i++) {
113
+ endCharIdx += contentLines[i].length + (i < bestEnd - 1 ? 1 : 0);
114
+ }
115
+ return {
116
+ found: true,
117
+ startIndex: startCharIdx,
118
+ endIndex: endCharIdx,
119
+ confidence: Math.round(bestScore * 100) / 100,
120
+ matchedText: contentLines.slice(bestStart, bestEnd).join("\n"),
121
+ method: "line-fuzzy"
122
+ };
123
+ }
124
+ return null;
125
+ }
126
+ function fuzzyEdit(content, oldString, newString, threshold = 0.85) {
127
+ const match = fuzzyMatch(content, oldString, threshold);
128
+ if (!match) return null;
129
+ const result = content.slice(0, match.startIndex) + newString + content.slice(match.endIndex);
130
+ return { content: result, match };
131
+ }
132
+ function mapNormalizedToOriginal(original, _normalized, normNeedle, normIdx) {
133
+ const normLines = _normalized.split("\n");
134
+ const origLines = original.split("\n");
135
+ let charCount = 0;
136
+ let startLine = 0;
137
+ for (let i = 0; i < normLines.length; i++) {
138
+ if (charCount + normLines[i].length >= normIdx) {
139
+ startLine = i;
140
+ break;
141
+ }
142
+ charCount += normLines[i].length + 1;
143
+ }
144
+ const needleLineCount = normNeedle.split("\n").length;
145
+ const endLine = startLine + needleLineCount;
146
+ let startChar = 0;
147
+ for (let i = 0; i < startLine; i++) {
148
+ startChar += origLines[i].length + 1;
149
+ }
150
+ let endChar = startChar;
151
+ for (let i = startLine; i < endLine && i < origLines.length; i++) {
152
+ endChar += origLines[i].length + (i < endLine - 1 ? 1 : 0);
153
+ }
154
+ return { start: startChar, end: endChar };
155
+ }
156
+ function mapStrippedToOriginal(original, stripped, stripNeedle, stripIdx) {
157
+ return mapNormalizedToOriginal(original, stripped, stripNeedle, stripIdx);
158
+ }
159
+ export {
160
+ fuzzyEdit,
161
+ fuzzyMatch
162
+ };
@@ -0,0 +1,155 @@
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 * as fs from "fs";
6
+ import * as path from "path";
7
+ import * as os from "os";
8
+ const CANONICAL_HOOKS = [
9
+ {
10
+ scriptName: "session-rescue.sh",
11
+ eventType: "Stop",
12
+ timeout: 12,
13
+ required: true
14
+ },
15
+ {
16
+ scriptName: "stop-checkpoint.js",
17
+ eventType: "Stop",
18
+ timeout: 5,
19
+ commandPrefix: "node",
20
+ required: true
21
+ },
22
+ {
23
+ scriptName: "chime-on-stop.sh",
24
+ eventType: "Stop",
25
+ timeout: 2,
26
+ required: true
27
+ },
28
+ {
29
+ scriptName: "auto-checkpoint.js",
30
+ eventType: "PostToolUse",
31
+ timeout: 2,
32
+ commandPrefix: "node",
33
+ required: true
34
+ }
35
+ ];
36
+ const DEAD_HOOKS = ["sms-response-handler.js"];
37
+ function buildCommand(entry, hooksDir) {
38
+ const scriptPath = path.join(hooksDir, entry.scriptName);
39
+ if (entry.commandPrefix) {
40
+ return `${entry.commandPrefix} ${scriptPath}`;
41
+ }
42
+ return scriptPath;
43
+ }
44
+ function hookExists(settings, entry) {
45
+ const groups = settings.hooks?.[entry.eventType];
46
+ if (!groups) return false;
47
+ for (const group of groups) {
48
+ for (const hook of group.hooks) {
49
+ if (hook.command.includes(entry.scriptName)) {
50
+ return true;
51
+ }
52
+ }
53
+ }
54
+ return false;
55
+ }
56
+ function hasDeadHooks(settings) {
57
+ if (!settings.hooks) return false;
58
+ for (const groups of Object.values(settings.hooks)) {
59
+ for (const group of groups) {
60
+ for (const hook of group.hooks) {
61
+ for (const dead of DEAD_HOOKS) {
62
+ if (hook.command.includes(dead)) return true;
63
+ }
64
+ }
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+ function removeDeadHooks(settings) {
70
+ if (!settings.hooks) return false;
71
+ let removed = false;
72
+ for (const eventType of Object.keys(settings.hooks)) {
73
+ const groups = settings.hooks[eventType];
74
+ for (const group of groups) {
75
+ const before = group.hooks.length;
76
+ group.hooks = group.hooks.filter((hook) => {
77
+ for (const dead of DEAD_HOOKS) {
78
+ if (hook.command.includes(dead)) return false;
79
+ }
80
+ return true;
81
+ });
82
+ if (group.hooks.length < before) removed = true;
83
+ }
84
+ settings.hooks[eventType] = groups.filter((g) => g.hooks.length > 0);
85
+ if (settings.hooks[eventType].length === 0) {
86
+ delete settings.hooks[eventType];
87
+ }
88
+ }
89
+ return removed;
90
+ }
91
+ function addHook(settings, entry, hooksDir) {
92
+ if (!settings.hooks) settings.hooks = {};
93
+ const eventGroups = settings.hooks[entry.eventType] || [];
94
+ const command = buildCommand(entry, hooksDir);
95
+ const hookCmd = { type: "command", command };
96
+ if (entry.timeout) hookCmd.timeout = entry.timeout;
97
+ const matcherValue = entry.matcher ?? void 0;
98
+ const targetGroup = eventGroups.find((g) => {
99
+ if (matcherValue) return g.matcher === matcherValue;
100
+ return !g.matcher;
101
+ });
102
+ if (targetGroup) {
103
+ targetGroup.hooks.push(hookCmd);
104
+ } else {
105
+ const newGroup = { hooks: [hookCmd] };
106
+ if (matcherValue) newGroup.matcher = matcherValue;
107
+ eventGroups.push(newGroup);
108
+ }
109
+ settings.hooks[entry.eventType] = eventGroups;
110
+ }
111
+ function mergeSettings(existing, hooksDir) {
112
+ const merged = JSON.parse(JSON.stringify(existing));
113
+ removeDeadHooks(merged);
114
+ for (const entry of CANONICAL_HOOKS) {
115
+ if (!hookExists(merged, entry)) {
116
+ addHook(merged, entry, hooksDir);
117
+ }
118
+ }
119
+ return merged;
120
+ }
121
+ function getSettingsPath() {
122
+ return path.join(os.homedir(), ".claude", "settings.json");
123
+ }
124
+ function readSettings(settingsPath) {
125
+ const p = settingsPath ?? getSettingsPath();
126
+ try {
127
+ if (fs.existsSync(p)) {
128
+ return JSON.parse(fs.readFileSync(p, "utf8"));
129
+ }
130
+ } catch {
131
+ }
132
+ return {};
133
+ }
134
+ function writeSettingsAtomic(settings, settingsPath) {
135
+ const p = settingsPath ?? getSettingsPath();
136
+ const dir = path.dirname(p);
137
+ if (!fs.existsSync(dir)) {
138
+ fs.mkdirSync(dir, { recursive: true });
139
+ }
140
+ const tmp = p + ".tmp";
141
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
142
+ fs.renameSync(tmp, p);
143
+ }
144
+ export {
145
+ CANONICAL_HOOKS,
146
+ DEAD_HOOKS,
147
+ buildCommand,
148
+ getSettingsPath,
149
+ hasDeadHooks,
150
+ hookExists,
151
+ mergeSettings,
152
+ readSettings,
153
+ removeDeadHooks,
154
+ writeSettingsAtomic
155
+ };