@vinaes/succ 1.3.22 → 1.3.31

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 (60) hide show
  1. package/README.md +4 -0
  2. package/dist/commands/analyze-utils.d.ts +1 -1
  3. package/dist/commands/analyze-utils.d.ts.map +1 -1
  4. package/dist/commands/analyze-utils.js +1 -2
  5. package/dist/commands/analyze-utils.js.map +1 -1
  6. package/dist/daemon/service.d.ts.map +1 -1
  7. package/dist/daemon/service.js +34 -1
  8. package/dist/daemon/service.js.map +1 -1
  9. package/dist/lib/config-types.d.ts +1 -0
  10. package/dist/lib/config-types.d.ts.map +1 -1
  11. package/dist/lib/config.d.ts +6 -0
  12. package/dist/lib/config.d.ts.map +1 -1
  13. package/dist/lib/config.js +21 -0
  14. package/dist/lib/config.js.map +1 -1
  15. package/dist/lib/db/index.d.ts +1 -1
  16. package/dist/lib/db/index.d.ts.map +1 -1
  17. package/dist/lib/db/index.js +1 -1
  18. package/dist/lib/db/index.js.map +1 -1
  19. package/dist/lib/db/memories.d.ts +6 -0
  20. package/dist/lib/db/memories.d.ts.map +1 -1
  21. package/dist/lib/db/memories.js +34 -0
  22. package/dist/lib/db/memories.js.map +1 -1
  23. package/dist/lib/graph-export.js +1 -2
  24. package/dist/lib/graph-export.js.map +1 -1
  25. package/dist/lib/hook-rules.d.ts +31 -0
  26. package/dist/lib/hook-rules.d.ts.map +1 -0
  27. package/dist/lib/hook-rules.js +102 -0
  28. package/dist/lib/hook-rules.js.map +1 -0
  29. package/dist/lib/llm.d.ts.map +1 -1
  30. package/dist/lib/llm.js +4 -4
  31. package/dist/lib/llm.js.map +1 -1
  32. package/dist/lib/ort-session.d.ts.map +1 -1
  33. package/dist/lib/ort-session.js +0 -1
  34. package/dist/lib/ort-session.js.map +1 -1
  35. package/dist/lib/session-summary.d.ts +1 -0
  36. package/dist/lib/session-summary.d.ts.map +1 -1
  37. package/dist/lib/session-summary.js +3 -0
  38. package/dist/lib/session-summary.js.map +1 -1
  39. package/dist/lib/storage/backends/postgresql.d.ts +1 -0
  40. package/dist/lib/storage/backends/postgresql.d.ts.map +1 -1
  41. package/dist/lib/storage/backends/postgresql.js +33 -0
  42. package/dist/lib/storage/backends/postgresql.js.map +1 -1
  43. package/dist/lib/storage/dispatcher.d.ts +1 -0
  44. package/dist/lib/storage/dispatcher.d.ts.map +1 -1
  45. package/dist/lib/storage/dispatcher.js +6 -0
  46. package/dist/lib/storage/dispatcher.js.map +1 -1
  47. package/dist/lib/storage/index.d.ts +1 -0
  48. package/dist/lib/storage/index.d.ts.map +1 -1
  49. package/dist/lib/storage/index.js +4 -0
  50. package/dist/lib/storage/index.js.map +1 -1
  51. package/dist/mcp/tools/memory.d.ts.map +1 -1
  52. package/dist/mcp/tools/memory.js +20 -3
  53. package/dist/mcp/tools/memory.js.map +1 -1
  54. package/dist/prompts/extraction.d.ts +2 -2
  55. package/dist/prompts/extraction.d.ts.map +1 -1
  56. package/dist/prompts/extraction.js +6 -2
  57. package/dist/prompts/extraction.js.map +1 -1
  58. package/hooks/succ-pre-tool.cjs +304 -76
  59. package/hooks/succ-session-start.cjs +38 -7
  60. package/package.json +4 -3
@@ -1,19 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * PreToolUse Hook — Command safety guard + commit context injection
3
+ * PreToolUse Hook — Command safety guard + commit context + file-linked memories
4
4
  *
5
- * Fires before every Bash tool call. Three features:
5
+ * Fires before every tool call. Four features:
6
6
  *
7
- * 1. Command safety guard blocks dangerous git/filesystem/database/docker commands
7
+ * 1. File-linked memoriesintercepts Edit/Write, queries daemon for related memories,
8
+ * injects them as additionalContext (~10ms, fail-open)
9
+ *
10
+ * 2. Command safety guard — blocks dangerous git/filesystem/database/docker commands
8
11
  * Config: commandSafetyGuard.mode = 'deny' | 'ask' | 'off' (default: 'deny')
9
12
  * Config: commandSafetyGuard.allowlist = string[]
10
13
  * Config: commandSafetyGuard.customPatterns = [{ pattern: "regex", reason: "why" }]
11
14
  *
12
- * 2. Commit guidelines injection — injects co-author format into context
15
+ * 3. Commit guidelines injection — injects co-author format into context
13
16
  * when Claude is about to run git commit
14
17
  * Config: includeCoAuthoredBy (default: true)
15
18
  *
16
- * 3. Pre-commit diff review reminder — injects reminder to run diff-reviewer
19
+ * 4. Pre-commit diff review reminder — injects reminder to run diff-reviewer
17
20
  * Config: preCommitReview (default: false)
18
21
  */
19
22
 
@@ -24,72 +27,182 @@ const path = require('path');
24
27
 
25
28
  const DANGEROUS_PATTERNS = [
26
29
  // ── Git — data loss ──
27
- { pattern: /\bgit\s+reset\s+--hard\b/, reason: 'git reset --hard destroys uncommitted changes. Use git stash first.' },
28
- { pattern: /\bgit\s+reset\s+--merge\b/, reason: 'git reset --merge can destroy uncommitted changes.' },
29
- { pattern: /\bgit\s+checkout\s+--\s/, reason: 'git checkout -- discards file modifications. Use git stash first.' },
30
- { pattern: /\bgit\s+checkout\s+\.\s*($|[;&|])/, reason: 'git checkout . discards all modifications. Use git stash first.' },
31
- { pattern: /\bgit\s+restore\s+--staged\s+--worktree\b/, reason: 'git restore --staged --worktree discards both staged and unstaged changes.' },
32
- { pattern: /\bgit\s+clean\s+-[a-zA-Z]*f/, reason: 'git clean -f permanently deletes untracked files.' },
33
- { pattern: /\bgit\s+push\s+.*--force(?!-with-lease)\b/, reason: 'git push --force rewrites remote history. Use --force-with-lease instead.' },
34
- { pattern: /\bgit\s+push\s+-f\b/, reason: 'git push -f rewrites remote history. Use --force-with-lease instead.' },
35
- { pattern: /\bgit\s+branch\s+-D\b/, reason: 'git branch -D force-deletes without merge verification. Use -d for safe delete.' },
36
- { pattern: /\bgit\s+stash\s+drop\b/, reason: 'git stash drop permanently destroys stashed work.' },
30
+ {
31
+ pattern: /\bgit\s+reset\s+--hard\b/,
32
+ reason: 'git reset --hard destroys uncommitted changes. Use git stash first.',
33
+ },
34
+ {
35
+ pattern: /\bgit\s+reset\s+--merge\b/,
36
+ reason: 'git reset --merge can destroy uncommitted changes.',
37
+ },
38
+ {
39
+ pattern: /\bgit\s+checkout\s+--\s/,
40
+ reason: 'git checkout -- discards file modifications. Use git stash first.',
41
+ },
42
+ {
43
+ pattern: /\bgit\s+checkout\s+\.\s*($|[;&|])/,
44
+ reason: 'git checkout . discards all modifications. Use git stash first.',
45
+ },
46
+ {
47
+ pattern: /\bgit\s+restore\s+--staged\s+--worktree\b/,
48
+ reason: 'git restore --staged --worktree discards both staged and unstaged changes.',
49
+ },
50
+ {
51
+ pattern: /\bgit\s+clean\s+-[a-zA-Z]*f/,
52
+ reason: 'git clean -f permanently deletes untracked files.',
53
+ },
54
+ {
55
+ pattern: /\bgit\s+push\s+.*--force(?!-with-lease)\b/,
56
+ reason: 'git push --force rewrites remote history. Use --force-with-lease instead.',
57
+ },
58
+ {
59
+ pattern: /\bgit\s+push\s+-f\b/,
60
+ reason: 'git push -f rewrites remote history. Use --force-with-lease instead.',
61
+ },
62
+ {
63
+ pattern: /\bgit\s+branch\s+-D\b/,
64
+ reason: 'git branch -D force-deletes without merge verification. Use -d for safe delete.',
65
+ },
66
+ {
67
+ pattern: /\bgit\s+stash\s+drop\b/,
68
+ reason: 'git stash drop permanently destroys stashed work.',
69
+ },
37
70
  { pattern: /\bgit\s+stash\s+clear\b/, reason: 'git stash clear destroys ALL stashed work.' },
38
- { pattern: /\bgit\s+rebase\s+-i\b/, reason: 'git rebase -i requires interactive terminal (not available in hooks).' },
39
- { pattern: /\bgit\s+reflog\s+expire\s+--expire=now\b/, reason: 'git reflog expire --expire=now permanently removes recovery points.' },
71
+ {
72
+ pattern: /\bgit\s+rebase\s+-i\b/,
73
+ reason: 'git rebase -i requires interactive terminal (not available in hooks).',
74
+ },
75
+ {
76
+ pattern: /\bgit\s+reflog\s+expire\s+--expire=now\b/,
77
+ reason: 'git reflog expire --expire=now permanently removes recovery points.',
78
+ },
40
79
 
41
80
  // ── Filesystem — data loss ──
42
- { pattern: /\brm\s+.*\.succ\b/, reason: '.succ/ contains your memory, brain vault, and config. This would destroy all succ data.' },
43
- { pattern: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\b/, reason: 'rm -rf can permanently delete files. Verify the target path.', checkPath: true },
44
- { pattern: /\brm\s+-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*\b/, reason: 'rm -fr can permanently delete files. Verify the target path.', checkPath: true },
81
+ {
82
+ pattern: /\brm\s+.*\.succ\b/,
83
+ reason:
84
+ '.succ/ contains your memory, brain vault, and config. This would destroy all succ data.',
85
+ },
86
+ {
87
+ pattern: /\brm\s+-[a-zA-Z]*r[a-zA-Z]*f[a-zA-Z]*\b/,
88
+ reason: 'rm -rf can permanently delete files. Verify the target path.',
89
+ checkPath: true,
90
+ },
91
+ {
92
+ pattern: /\brm\s+-[a-zA-Z]*f[a-zA-Z]*r[a-zA-Z]*\b/,
93
+ reason: 'rm -fr can permanently delete files. Verify the target path.',
94
+ checkPath: true,
95
+ },
45
96
 
46
97
  // ── Docker — container/image/volume destruction ──
47
- { pattern: /\bdocker\s+system\s+prune\b/, reason: 'docker system prune removes all unused containers, networks, images, and optionally volumes.' },
48
- { pattern: /\bdocker\s+volume\s+prune\b/, reason: 'docker volume prune removes all unused volumes (potential data loss).' },
49
- { pattern: /\bdocker\s+rm\s+-f\b/, reason: 'docker rm -f force-removes running containers without graceful shutdown.' },
50
- { pattern: /\bdocker\s+rmi\s+-f\b/, reason: 'docker rmi -f force-removes images that may be in use.' },
51
- { pattern: /\bdocker-compose\s+down\s+-v\b/, reason: 'docker-compose down -v removes named volumes (database data loss).' },
52
- { pattern: /\bdocker\s+compose\s+down\s+-v\b/, reason: 'docker compose down -v removes named volumes (database data loss).' },
98
+ {
99
+ pattern: /\bdocker\s+system\s+prune\b/,
100
+ reason:
101
+ 'docker system prune removes all unused containers, networks, images, and optionally volumes.',
102
+ },
103
+ {
104
+ pattern: /\bdocker\s+volume\s+prune\b/,
105
+ reason: 'docker volume prune removes all unused volumes (potential data loss).',
106
+ },
107
+ {
108
+ pattern: /\bdocker\s+rm\s+-f\b/,
109
+ reason: 'docker rm -f force-removes running containers without graceful shutdown.',
110
+ },
111
+ {
112
+ pattern: /\bdocker\s+rmi\s+-f\b/,
113
+ reason: 'docker rmi -f force-removes images that may be in use.',
114
+ },
115
+ {
116
+ pattern: /\bdocker-compose\s+down\s+-v\b/,
117
+ reason: 'docker-compose down -v removes named volumes (database data loss).',
118
+ },
119
+ {
120
+ pattern: /\bdocker\s+compose\s+down\s+-v\b/,
121
+ reason: 'docker compose down -v removes named volumes (database data loss).',
122
+ },
53
123
 
54
124
  // ── SQLite — database destruction ──
55
- { pattern: /\bsqlite3?\b.*\bDROP\s+TABLE\b/i, reason: 'DROP TABLE permanently deletes a SQLite table and all its data.' },
56
- { pattern: /\bsqlite3?\b.*\bDROP\s+DATABASE\b/i, reason: 'DROP DATABASE permanently deletes the entire SQLite database.' },
57
- { pattern: /\bsqlite3?\b.*\bDELETE\s+FROM\s+\w+\s*;/i, reason: 'DELETE FROM without WHERE deletes all rows in a SQLite table.' },
58
- { pattern: /\bsqlite3?\b.*\bTRUNCATE\b/i, reason: 'TRUNCATE removes all data from a SQLite table.' },
125
+ {
126
+ pattern: /\bsqlite3?\b.*\bDROP\s+TABLE\b/i,
127
+ reason: 'DROP TABLE permanently deletes a SQLite table and all its data.',
128
+ },
129
+ {
130
+ pattern: /\bsqlite3?\b.*\bDROP\s+DATABASE\b/i,
131
+ reason: 'DROP DATABASE permanently deletes the entire SQLite database.',
132
+ },
133
+ {
134
+ pattern: /\bsqlite3?\b.*\bDELETE\s+FROM\s+\w+\s*;/i,
135
+ reason: 'DELETE FROM without WHERE deletes all rows in a SQLite table.',
136
+ },
137
+ {
138
+ pattern: /\bsqlite3?\b.*\bTRUNCATE\b/i,
139
+ reason: 'TRUNCATE removes all data from a SQLite table.',
140
+ },
59
141
 
60
142
  // ── PostgreSQL — database destruction ──
61
- { pattern: /\bpsql\b.*\bDROP\s+TABLE\b/i, reason: 'DROP TABLE permanently deletes a PostgreSQL table and all its data.' },
62
- { pattern: /\bpsql\b.*\bDROP\s+DATABASE\b/i, reason: 'DROP DATABASE permanently deletes the entire PostgreSQL database.' },
63
- { pattern: /\bpsql\b.*\bDELETE\s+FROM\s+\w+\s*;/i, reason: 'DELETE FROM without WHERE deletes all rows in a PostgreSQL table.' },
64
- { pattern: /\bpsql\b.*\bTRUNCATE\b/i, reason: 'TRUNCATE removes all data from a PostgreSQL table.' },
143
+ {
144
+ pattern: /\bpsql\b.*\bDROP\s+TABLE\b/i,
145
+ reason: 'DROP TABLE permanently deletes a PostgreSQL table and all its data.',
146
+ },
147
+ {
148
+ pattern: /\bpsql\b.*\bDROP\s+DATABASE\b/i,
149
+ reason: 'DROP DATABASE permanently deletes the entire PostgreSQL database.',
150
+ },
151
+ {
152
+ pattern: /\bpsql\b.*\bDELETE\s+FROM\s+\w+\s*;/i,
153
+ reason: 'DELETE FROM without WHERE deletes all rows in a PostgreSQL table.',
154
+ },
155
+ {
156
+ pattern: /\bpsql\b.*\bTRUNCATE\b/i,
157
+ reason: 'TRUNCATE removes all data from a PostgreSQL table.',
158
+ },
65
159
  { pattern: /\bdropdb\b/, reason: 'dropdb permanently deletes a PostgreSQL database.' },
66
160
  { pattern: /\bdropuser\b/, reason: 'dropuser permanently deletes a PostgreSQL user/role.' },
67
161
 
68
162
  // ── Qdrant — vector database destruction ──
69
- { pattern: /\bcurl\b.*\bqdrant\b.*\bDELETE\b/i, reason: 'DELETE on Qdrant API can remove collections or points permanently.' },
70
- { pattern: /\bcurl\b.*\b:6333\b.*\bDELETE\b/i, reason: 'DELETE on Qdrant port 6333 can remove collections or points permanently.' },
71
- { pattern: /\bcurl\b.*\b:6334\b.*\bDELETE\b/i, reason: 'DELETE on Qdrant gRPC port can remove data permanently.' },
163
+ {
164
+ pattern: /\bcurl\b.*\bqdrant\b.*\bDELETE\b/i,
165
+ reason: 'DELETE on Qdrant API can remove collections or points permanently.',
166
+ },
167
+ {
168
+ pattern: /\bcurl\b.*\b:6333\b.*\bDELETE\b/i,
169
+ reason: 'DELETE on Qdrant port 6333 can remove collections or points permanently.',
170
+ },
171
+ {
172
+ pattern: /\bcurl\b.*\b:6334\b.*\bDELETE\b/i,
173
+ reason: 'DELETE on Qdrant gRPC port can remove data permanently.',
174
+ },
72
175
  ];
73
176
 
74
177
  // Paths where rm -rf is considered safe (normalized, lowercase)
75
178
  const SAFE_RM_PATHS = [
76
- '/tmp', '/var/tmp',
77
- 'node_modules', 'dist', 'build', '.cache',
78
- '__pycache__', '.pytest_cache', '.tox',
79
- 'target/debug', 'target/release',
80
- '.next', '.nuxt', '.turbo', 'coverage',
179
+ '/tmp',
180
+ '/var/tmp',
181
+ 'node_modules',
182
+ 'dist',
183
+ 'build',
184
+ '.cache',
185
+ '__pycache__',
186
+ '.pytest_cache',
187
+ '.tox',
188
+ 'target/debug',
189
+ 'target/release',
190
+ '.next',
191
+ '.nuxt',
192
+ '.turbo',
193
+ 'coverage',
81
194
  ];
82
195
 
83
196
  // Prefixes that indicate the command is data, not execution
84
197
  const DATA_PREFIXES = [
85
- /^\s*(?:#|\/\/)/, // comments
86
- /^\s*echo\b/, // echo
87
- /^\s*printf\b/, // printf
88
- /^\s*cat\s*<</, // heredoc (cat <<)
89
- /^\s*grep\b/, // grep
90
- /^\s*rg\b/, // ripgrep
91
- /^\s*ag\b/, // silver searcher
92
- /^\s*(?:"|').*(?:"|')\s*$/,// quoted string
198
+ /^\s*(?:#|\/\/)/, // comments
199
+ /^\s*echo\b/, // echo
200
+ /^\s*printf\b/, // printf
201
+ /^\s*cat\s*<</, // heredoc (cat <<)
202
+ /^\s*grep\b/, // grep
203
+ /^\s*rg\b/, // ripgrep
204
+ /^\s*ag\b/, // silver searcher
205
+ /^\s*(?:"|').*(?:"|')\s*$/, // quoted string
93
206
  ];
94
207
 
95
208
  // ─── Helpers ─────────────────────────────────────────────────────────
@@ -143,7 +256,7 @@ function loadConfig(projectDir) {
143
256
 
144
257
  function isDataContext(command) {
145
258
  const trimmed = command.trim();
146
- return DATA_PREFIXES.some(prefix => prefix.test(trimmed));
259
+ return DATA_PREFIXES.some((prefix) => prefix.test(trimmed));
147
260
  }
148
261
 
149
262
  function isRmPathSafe(command) {
@@ -153,7 +266,7 @@ function isRmPathSafe(command) {
153
266
  const target = match[1].trim().replace(/["']/g, '');
154
267
  const normalized = target.toLowerCase().replace(/\\/g, '/');
155
268
 
156
- return SAFE_RM_PATHS.some(safe => {
269
+ return SAFE_RM_PATHS.some((safe) => {
157
270
  if (normalized === safe || normalized.endsWith('/' + safe)) return true;
158
271
  if (safe === '/tmp' && normalized.startsWith('/tmp/')) return true;
159
272
  if (safe === '/var/tmp' && normalized.startsWith('/var/tmp/')) return true;
@@ -236,6 +349,58 @@ MEDIUM and below — commit is OK, mention findings in summary.
236
349
  return parts.join('\n');
237
350
  }
238
351
 
352
+ // ─── Daemon helpers ──────────────────────────────────────────────────
353
+
354
+ function getDaemonPort(projectDir) {
355
+ const portFile = path.join(projectDir, '.succ', '.tmp', 'daemon.port');
356
+ if (!fs.existsSync(portFile)) return null;
357
+ const port = parseInt(fs.readFileSync(portFile, 'utf8').trim(), 10);
358
+ return port && !isNaN(port) ? port : null;
359
+ }
360
+
361
+ async function recallFileMemories(projectDir, fileName) {
362
+ const port = getDaemonPort(projectDir);
363
+ if (!port) return [];
364
+
365
+ try {
366
+ const res = await fetch(`http://127.0.0.1:${port}/api/recall-by-tag`, {
367
+ method: 'POST',
368
+ headers: { 'Content-Type': 'application/json' },
369
+ body: JSON.stringify({ tag: `file:${fileName}`, limit: 5 }),
370
+ signal: AbortSignal.timeout(2000),
371
+ });
372
+ if (!res.ok) return [];
373
+ const data = await res.json();
374
+ return data.results || [];
375
+ } catch {
376
+ return []; // fail-open
377
+ }
378
+ }
379
+
380
+ async function fetchHookRules(projectDir, toolName, toolInput) {
381
+ const port = getDaemonPort(projectDir);
382
+ if (!port) return [];
383
+
384
+ try {
385
+ const res = await fetch(`http://127.0.0.1:${port}/api/hook-rules`, {
386
+ method: 'POST',
387
+ headers: { 'Content-Type': 'application/json' },
388
+ body: JSON.stringify({ tool_name: toolName, tool_input: toolInput || {} }),
389
+ signal: AbortSignal.timeout(2000),
390
+ });
391
+ if (!res.ok) return [];
392
+ const data = await res.json();
393
+ return data.rules || [];
394
+ } catch {
395
+ return []; // fail-open
396
+ }
397
+ }
398
+
399
+ function formatFileContext(memories, fileName) {
400
+ const lines = memories.map((m) => `- [${m.type || 'observation'}] ${m.content.slice(0, 200)}`);
401
+ return `<file-context file="${fileName}">\nRelated memories:\n${lines.join('\n')}\n</file-context>`;
402
+ }
403
+
239
404
  // ─── Main ────────────────────────────────────────────────────────────
240
405
 
241
406
  let input = '';
@@ -247,7 +412,7 @@ process.stdin.on('readable', () => {
247
412
  }
248
413
  });
249
414
 
250
- process.stdin.on('end', () => {
415
+ process.stdin.on('end', async () => {
251
416
  try {
252
417
  const hookInput = JSON.parse(input);
253
418
  let projectDir = hookInput.cwd || process.cwd();
@@ -267,43 +432,106 @@ process.stdin.on('end', () => {
267
432
  process.exit(0);
268
433
  }
269
434
 
270
- const command = hookInput.tool_input?.command || '';
271
- if (!command) {
272
- process.exit(0);
435
+ const toolName = hookInput.tool_name || '';
436
+ const toolInput = hookInput.tool_input || {};
437
+ const filePath = toolInput.file_path || '';
438
+ const command = toolInput.command || '';
439
+ const contextParts = [];
440
+ let askReason = null;
441
+
442
+ // 1. Dynamic hook rules from memory (ALL tools)
443
+ // Note: deny/ask exit immediately — any accumulated contextParts are intentionally
444
+ // discarded because the tool call is being blocked or requires confirmation.
445
+ const rules = await fetchHookRules(projectDir, toolName, toolInput);
446
+ for (const rule of rules) {
447
+ if (rule.action === 'deny') {
448
+ const output = {
449
+ hookSpecificOutput: {
450
+ hookEventName: 'PreToolUse',
451
+ permissionDecision: 'deny',
452
+ permissionDecisionReason: `[succ rule] ${rule.content}`,
453
+ },
454
+ };
455
+ console.log(JSON.stringify(output));
456
+ process.exit(0);
457
+ }
458
+ if (rule.action === 'ask' && !askReason) {
459
+ askReason = rule.content;
460
+ }
461
+ if (rule.action === 'inject') {
462
+ contextParts.push(`<hook-rule>${rule.content}</hook-rule>`);
463
+ }
273
464
  }
274
465
 
275
- const config = loadConfig(projectDir);
276
-
277
- // 1. Command safety guard
278
- const dangerousResult = checkDangerous(command, config);
279
- if (dangerousResult) {
280
- const output = {
281
- hookSpecificOutput: {
282
- hookEventName: 'PreToolUse',
283
- permissionDecision: dangerousResult.mode === 'ask' ? 'ask' : 'deny',
284
- permissionDecisionReason: `[succ guard] ${dangerousResult.reason}`,
285
- },
286
- };
287
- console.log(JSON.stringify(output));
288
- process.exit(0);
466
+ // 2. File-linked memories (Edit/Write only — Read is too frequent, wastes context)
467
+ if ((toolName === 'Edit' || toolName === 'Write') && filePath) {
468
+ const fileName = path.basename(filePath);
469
+ const memories = await recallFileMemories(projectDir, fileName);
470
+ if (memories.length > 0) {
471
+ contextParts.push(formatFileContext(memories, fileName));
472
+ }
289
473
  }
290
474
 
291
- // 2. Git commit inject guidelines + diff review reminder
292
- if (/\bgit\s+commit\b/.test(command)) {
293
- const additionalContext = buildCommitContext(config);
294
- if (additionalContext) {
475
+ // 3. Command safety guard (Bash only)
476
+ if (command) {
477
+ const config = loadConfig(projectDir);
478
+ const dangerousResult = checkDangerous(command, config);
479
+ if (dangerousResult) {
480
+ const output = {
481
+ hookSpecificOutput: {
482
+ hookEventName: 'PreToolUse',
483
+ permissionDecision: dangerousResult.mode === 'ask' ? 'ask' : 'deny',
484
+ permissionDecisionReason: `[succ guard] ${dangerousResult.reason}`,
485
+ },
486
+ };
487
+ console.log(JSON.stringify(output));
488
+ process.exit(0);
489
+ }
490
+
491
+ // 4. Hook rule ask (after safety guard, so deny takes priority)
492
+ if (askReason) {
295
493
  const output = {
296
494
  hookSpecificOutput: {
297
495
  hookEventName: 'PreToolUse',
298
- additionalContext,
496
+ permissionDecision: 'ask',
497
+ permissionDecisionReason: `[succ rule] ${askReason}`,
299
498
  },
300
499
  };
301
500
  console.log(JSON.stringify(output));
302
501
  process.exit(0);
303
502
  }
503
+
504
+ // 5. Git commit — inject guidelines + diff review reminder
505
+ if (/\bgit\s+commit\b/.test(command)) {
506
+ const commitContext = buildCommitContext(config);
507
+ if (commitContext) {
508
+ contextParts.push(commitContext);
509
+ }
510
+ }
511
+ } else if (askReason) {
512
+ // Non-Bash ask rule
513
+ const output = {
514
+ hookSpecificOutput: {
515
+ hookEventName: 'PreToolUse',
516
+ permissionDecision: 'ask',
517
+ permissionDecisionReason: `[succ rule] ${askReason}`,
518
+ },
519
+ };
520
+ console.log(JSON.stringify(output));
521
+ process.exit(0);
522
+ }
523
+
524
+ // 6. Emit combined context
525
+ if (contextParts.length > 0) {
526
+ const output = {
527
+ hookSpecificOutput: {
528
+ hookEventName: 'PreToolUse',
529
+ additionalContext: contextParts.join('\n'),
530
+ },
531
+ };
532
+ console.log(JSON.stringify(output));
304
533
  }
305
534
 
306
- // No action needed
307
535
  process.exit(0);
308
536
  } catch {
309
537
  // Fail-open: don't block on hook errors
@@ -63,13 +63,15 @@ process.stdin.on('end', async () => {
63
63
  process.exit(0);
64
64
  }
65
65
 
66
- // Load config settings
66
+ // Load config settings (project config overrides global, but both are checked)
67
67
  let includeCoAuthoredBy = true; // default: true
68
68
  let communicationAutoAdapt = true; // default: true
69
69
  let communicationTrackHistory = false; // default: false
70
+ let hasOpenRouterKey = !!process.env.OPENROUTER_API_KEY;
70
71
  const configPaths = [
71
- path.join(succDir, 'config.json'),
72
+ // Global first, then project overrides
72
73
  path.join(require('os').homedir(), '.succ', 'config.json'),
74
+ path.join(succDir, 'config.json'),
73
75
  ];
74
76
  for (const configPath of configPaths) {
75
77
  if (fs.existsSync(configPath)) {
@@ -84,7 +86,14 @@ process.stdin.on('end', async () => {
84
86
  if (config.communicationTrackHistory === true) {
85
87
  communicationTrackHistory = true;
86
88
  }
87
- break;
89
+ // Check for OpenRouter API key: llm.api_key, llm.embeddings.api_key, or web_search.api_key
90
+ if (!hasOpenRouterKey) {
91
+ const keys = [config.llm?.api_key, config.llm?.embeddings?.api_key, config.web_search?.api_key];
92
+ if (keys.some(k => typeof k === 'string' && k.startsWith('sk-or-'))) {
93
+ hasOpenRouterKey = true;
94
+ }
95
+ }
96
+ // No break — merge both configs (global defaults, project overrides)
88
97
  } catch {
89
98
  // Ignore parse errors
90
99
  }
@@ -156,6 +165,28 @@ Without it, succ works in global-only mode and can't access project data.
156
165
  → Record failed approach (boosted in recall to prevent retrying)
157
166
  </memory>
158
167
 
168
+ <hook-rules hint="Dynamic pre-tool rules from memory. Saved rules auto-fire before matching tool calls.">
169
+ When user asks to remember a rule about tool behavior (e.g., "before deploy run tests",
170
+ "always review before commit", "block rm -rf"), save with hook-rule convention:
171
+
172
+ **succ_remember** content="..." tags=["hook-rule", "tool:{ToolName}", "match:{regex}"] type="decision|error|pattern"
173
+
174
+ Tags:
175
+ - **hook-rule** — required, marks memory as a pre-tool rule
176
+ - **tool:{Name}** — optional, filter by tool (Bash, Edit, Write, Read, Skill, Task). Omit = all tools
177
+ - **match:{regex}** — optional, regex tested against tool input (command, skill name, file basename, prompt)
178
+
179
+ Action via type:
180
+ - **decision/observation/learning** → inject as additionalContext (guide the agent)
181
+ - **error** → deny the tool call (block it)
182
+ - **pattern** → ask user for confirmation before proceeding
183
+
184
+ Examples:
185
+ \`succ_remember content="Run diff-reviewer before deploying" tags=["hook-rule","tool:Skill","match:deploy"] type="decision"\`
186
+ \`succ_remember content="Never force-push to main" tags=["hook-rule","tool:Bash","match:push.*--force.*main"] type="error"\`
187
+ \`succ_remember content="Editing test files — run tests after" tags=["hook-rule","tool:Edit","match:\\\\.test\\\\."] type="decision"\`
188
+ </hook-rules>
189
+
159
190
  <ops>
160
191
  **succ_index_file** file="doc.md" [force=true] — index doc for succ_search
161
192
  **succ_index_code_file** file="src/auth.ts" [force=true] — index code for succ_search_code
@@ -179,12 +210,12 @@ Without it, succ works in global-only mode and can't access project data.
179
210
  **succ_prd_export** [prd_id="prd_xxx"] — Obsidian Mermaid export
180
211
  </prd>
181
212
 
182
- <web-search hint="Perplexity Sonar via OpenRouter. Requires OPENROUTER_API_KEY.">
213
+ ${hasOpenRouterKey ? `<web-search hint="Perplexity Sonar via OpenRouter.">
183
214
  **succ_quick_search** query="..." — cheap & fast, simple facts
184
215
  **succ_web_search** query="..." [model="perplexity/sonar-pro"] — quality search, complex queries
185
216
  **succ_deep_research** query="..." — multi-step research (30-120s, 30+ sources)
186
217
  **succ_web_search_history** [tool_name="..."] [limit=20] — past searches and costs
187
- </web-search>
218
+ </web-search>` : ''}
188
219
 
189
220
  <debug hint="Structured debugging with hypothesis testing. Sessions in .succ/debugs/.">
190
221
  **succ_debug** action="create|hypothesis|instrument|result|resolve|abandon|status|list|log|show_log|detect_lang|gen_log"
@@ -203,8 +234,8 @@ Without it, succ works in global-only mode and can't access project data.
203
234
  | Multi-step tasks, research | succ-general | general-purpose agent |
204
235
  | Code review | succ-code-reviewer | built-in review |
205
236
  | Pre-commit review | succ-diff-reviewer | manual diff reading |
206
- | Web page fetch | succ_fetch | WebFetch |
207
- | Web search | succ_quick_search / succ_web_search | WebSearch / Brave |
237
+ | Web page fetch | succ_fetch | WebFetch |${hasOpenRouterKey ? `
238
+ | Web search | succ_quick_search / succ_web_search | WebSearch / Brave |` : ''}
208
239
 
209
240
  Direct file reads (Read/Grep) are fine when you know the exact path — for discovery, always succ agents.
210
241
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vinaes/succ",
3
- "version": "1.3.22",
3
+ "version": "1.3.31",
4
4
  "description": "Semantic Understanding for Code Contexts — persistent memory for AI coding assistants (Claude Code, Cursor, Windsurf, Continue.dev)",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -27,7 +27,8 @@
27
27
  "succ-mcp": "./dist/mcp-server.js"
28
28
  },
29
29
  "scripts": {
30
- "build": "tsc",
30
+ "build": "prettier --check src/ && eslint src/ && tsc",
31
+ "build:compile": "tsc",
31
32
  "dev": "tsx src/cli.ts",
32
33
  "test": "vitest",
33
34
  "test:storage": "vitest --run src/lib/storage/",
@@ -42,7 +43,7 @@
42
43
  "lint:fix": "eslint src/ --fix",
43
44
  "format": "prettier --write src/",
44
45
  "format:check": "prettier --check src/",
45
- "prepare": "npm run build",
46
+ "prepare": "npm run build:compile",
46
47
  "prepublishOnly": "npm run build && npm run lint"
47
48
  },
48
49
  "keywords": [