create-walle 0.1.0

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 (136) hide show
  1. package/bin/create-walle.js +134 -0
  2. package/package.json +18 -0
  3. package/template/.env.example +40 -0
  4. package/template/CLAUDE.md +12 -0
  5. package/template/LICENSE +21 -0
  6. package/template/README.md +167 -0
  7. package/template/bin/setup.js +100 -0
  8. package/template/claude-code-skill.md +60 -0
  9. package/template/claude-task-manager/api-prompts.js +1841 -0
  10. package/template/claude-task-manager/api-reviews.js +275 -0
  11. package/template/claude-task-manager/approval-agent.js +454 -0
  12. package/template/claude-task-manager/bin/restart-ctm.sh +16 -0
  13. package/template/claude-task-manager/db.js +1721 -0
  14. package/template/claude-task-manager/docs/PROMPT-MANAGEMENT-DESIGN.md +631 -0
  15. package/template/claude-task-manager/git-utils.js +214 -0
  16. package/template/claude-task-manager/package-lock.json +1607 -0
  17. package/template/claude-task-manager/package.json +31 -0
  18. package/template/claude-task-manager/prompt-harvest.js +1148 -0
  19. package/template/claude-task-manager/public/css/prompts.css +880 -0
  20. package/template/claude-task-manager/public/css/reviews.css +430 -0
  21. package/template/claude-task-manager/public/css/walle.css +732 -0
  22. package/template/claude-task-manager/public/favicon.ico +0 -0
  23. package/template/claude-task-manager/public/icon.svg +37 -0
  24. package/template/claude-task-manager/public/index.html +8346 -0
  25. package/template/claude-task-manager/public/js/prompts.js +3159 -0
  26. package/template/claude-task-manager/public/js/reviews.js +1292 -0
  27. package/template/claude-task-manager/public/js/walle.js +3081 -0
  28. package/template/claude-task-manager/public/manifest.json +13 -0
  29. package/template/claude-task-manager/public/prompts.html +4353 -0
  30. package/template/claude-task-manager/public/setup.html +216 -0
  31. package/template/claude-task-manager/queue-engine.js +404 -0
  32. package/template/claude-task-manager/server-state.js +5 -0
  33. package/template/claude-task-manager/server.js +2254 -0
  34. package/template/claude-task-manager/session-utils.js +124 -0
  35. package/template/claude-task-manager/start.sh +17 -0
  36. package/template/claude-task-manager/tests/test-ai-search.js +61 -0
  37. package/template/claude-task-manager/tests/test-editor-ux.js +76 -0
  38. package/template/claude-task-manager/tests/test-editor-ux2.js +51 -0
  39. package/template/claude-task-manager/tests/test-features-v2.js +127 -0
  40. package/template/claude-task-manager/tests/test-insights-cached.js +78 -0
  41. package/template/claude-task-manager/tests/test-insights.js +124 -0
  42. package/template/claude-task-manager/tests/test-permissions-v2.js +127 -0
  43. package/template/claude-task-manager/tests/test-permissions.js +122 -0
  44. package/template/claude-task-manager/tests/test-pin.js +51 -0
  45. package/template/claude-task-manager/tests/test-prompts.js +164 -0
  46. package/template/claude-task-manager/tests/test-recent-sessions.js +96 -0
  47. package/template/claude-task-manager/tests/test-review.js +104 -0
  48. package/template/claude-task-manager/tests/test-send-dropdown.js +76 -0
  49. package/template/claude-task-manager/tests/test-send-final.js +30 -0
  50. package/template/claude-task-manager/tests/test-send-fixes.js +76 -0
  51. package/template/claude-task-manager/tests/test-send-integration.js +107 -0
  52. package/template/claude-task-manager/tests/test-send-visual.js +34 -0
  53. package/template/claude-task-manager/tests/test-session-create.js +147 -0
  54. package/template/claude-task-manager/tests/test-sidebar-ux.js +83 -0
  55. package/template/claude-task-manager/tests/test-url-hash.js +68 -0
  56. package/template/claude-task-manager/tests/test-ux-crop.js +34 -0
  57. package/template/claude-task-manager/tests/test-ux-review.js +130 -0
  58. package/template/claude-task-manager/tests/test-zoom-card.js +76 -0
  59. package/template/claude-task-manager/tests/test-zoom.js +92 -0
  60. package/template/claude-task-manager/tests/test-zoom2.js +67 -0
  61. package/template/docs/site/api/README.md +187 -0
  62. package/template/docs/site/guides/claude-code.md +58 -0
  63. package/template/docs/site/guides/configuration.md +96 -0
  64. package/template/docs/site/guides/quickstart.md +158 -0
  65. package/template/docs/site/index.md +14 -0
  66. package/template/docs/site/skills/README.md +135 -0
  67. package/template/wall-e/.dockerignore +11 -0
  68. package/template/wall-e/Dockerfile +25 -0
  69. package/template/wall-e/adapters/adapter-base.js +37 -0
  70. package/template/wall-e/adapters/ctm.js +193 -0
  71. package/template/wall-e/adapters/slack.js +56 -0
  72. package/template/wall-e/agent.js +319 -0
  73. package/template/wall-e/api-walle.js +1073 -0
  74. package/template/wall-e/brain.js +1235 -0
  75. package/template/wall-e/channels/agent-api.js +172 -0
  76. package/template/wall-e/channels/channel-base.js +14 -0
  77. package/template/wall-e/channels/imessage-channel.js +113 -0
  78. package/template/wall-e/channels/slack-channel.js +118 -0
  79. package/template/wall-e/chat.js +778 -0
  80. package/template/wall-e/decision/confidence.js +93 -0
  81. package/template/wall-e/deploy.sh +35 -0
  82. package/template/wall-e/docs/specs/2026-04-01-publish-plan.md +112 -0
  83. package/template/wall-e/docs/specs/SKILL-FORMAT.md +326 -0
  84. package/template/wall-e/extraction/contradiction.js +168 -0
  85. package/template/wall-e/extraction/knowledge-extractor.js +190 -0
  86. package/template/wall-e/fly.toml +24 -0
  87. package/template/wall-e/loops/ingest.js +34 -0
  88. package/template/wall-e/loops/reflect.js +63 -0
  89. package/template/wall-e/loops/tasks.js +487 -0
  90. package/template/wall-e/loops/think.js +125 -0
  91. package/template/wall-e/package-lock.json +533 -0
  92. package/template/wall-e/package.json +18 -0
  93. package/template/wall-e/scripts/ingest-slack-search.js +85 -0
  94. package/template/wall-e/scripts/pull-slack-via-claude.js +98 -0
  95. package/template/wall-e/scripts/slack-backfill.js +295 -0
  96. package/template/wall-e/scripts/slack-channel-history.js +454 -0
  97. package/template/wall-e/server.js +93 -0
  98. package/template/wall-e/skills/_bundled/email-digest/SKILL.md +95 -0
  99. package/template/wall-e/skills/_bundled/email-sync/SKILL.md +65 -0
  100. package/template/wall-e/skills/_bundled/email-sync/mail-reader.jxa +104 -0
  101. package/template/wall-e/skills/_bundled/email-sync/run.js +213 -0
  102. package/template/wall-e/skills/_bundled/google-calendar/SKILL.md +73 -0
  103. package/template/wall-e/skills/_bundled/google-calendar/cal-reader.swift +81 -0
  104. package/template/wall-e/skills/_bundled/google-calendar/run.js +181 -0
  105. package/template/wall-e/skills/_bundled/memory-search/SKILL.md +92 -0
  106. package/template/wall-e/skills/_bundled/morning-briefing/SKILL.md +131 -0
  107. package/template/wall-e/skills/_bundled/morning-briefing/run.js +264 -0
  108. package/template/wall-e/skills/_bundled/slack-backfill/SKILL.md +60 -0
  109. package/template/wall-e/skills/_bundled/slack-sync/SKILL.md +55 -0
  110. package/template/wall-e/skills/claude-code-reader.js +144 -0
  111. package/template/wall-e/skills/mcp-client.js +407 -0
  112. package/template/wall-e/skills/skill-executor.js +163 -0
  113. package/template/wall-e/skills/skill-loader.js +410 -0
  114. package/template/wall-e/skills/skill-planner.js +88 -0
  115. package/template/wall-e/skills/slack-ingest.js +329 -0
  116. package/template/wall-e/skills/slack-pull-live.js +270 -0
  117. package/template/wall-e/skills/tool-executor.js +188 -0
  118. package/template/wall-e/tests/adapter-base.test.js +20 -0
  119. package/template/wall-e/tests/adapter-ctm.test.js +122 -0
  120. package/template/wall-e/tests/adapter-slack.test.js +98 -0
  121. package/template/wall-e/tests/agent-api.test.js +256 -0
  122. package/template/wall-e/tests/api-walle.test.js +222 -0
  123. package/template/wall-e/tests/brain.test.js +602 -0
  124. package/template/wall-e/tests/channels.test.js +104 -0
  125. package/template/wall-e/tests/chat.test.js +103 -0
  126. package/template/wall-e/tests/confidence.test.js +134 -0
  127. package/template/wall-e/tests/contradiction.test.js +217 -0
  128. package/template/wall-e/tests/ingest.test.js +113 -0
  129. package/template/wall-e/tests/mcp-client.test.js +71 -0
  130. package/template/wall-e/tests/reflect.test.js +103 -0
  131. package/template/wall-e/tests/server.test.js +111 -0
  132. package/template/wall-e/tests/skills.test.js +198 -0
  133. package/template/wall-e/tests/slack-ingest.test.js +103 -0
  134. package/template/wall-e/tests/think.test.js +435 -0
  135. package/template/wall-e/tools/local-tools.js +697 -0
  136. package/template/wall-e/tools/slack-mcp.js +290 -0
@@ -0,0 +1,1841 @@
1
+ // --- Prompt Editor & Manager API Routes ---
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+ const db = require('./db');
5
+ const queueEngine = require('./queue-engine');
6
+ const harvest = require('./prompt-harvest');
7
+ // AI search uses direct HTTP calls to Claude API (supports Portkey proxy)
8
+
9
+ function readBody(req, limit = 1024 * 1024) {
10
+ return new Promise((resolve, reject) => {
11
+ let body = '';
12
+ req.on('data', chunk => {
13
+ body += chunk;
14
+ if (body.length > limit) { req.destroy(); reject(new Error('Body too large')); }
15
+ });
16
+ req.on('end', () => {
17
+ try { resolve(JSON.parse(body)); } catch (e) { reject(e); }
18
+ });
19
+ req.on('error', reject);
20
+ });
21
+ }
22
+
23
+ function readRawBody(req, limit = 10 * 1024 * 1024) {
24
+ return new Promise((resolve, reject) => {
25
+ const chunks = [];
26
+ let size = 0;
27
+ req.on('data', chunk => {
28
+ size += chunk.length;
29
+ if (size > limit) { reject(new Error('Body too large')); return; }
30
+ chunks.push(chunk);
31
+ });
32
+ req.on('end', () => resolve(Buffer.concat(chunks)));
33
+ req.on('error', reject);
34
+ });
35
+ }
36
+
37
+ function jsonResponse(res, status, data) {
38
+ res.writeHead(status, { 'Content-Type': 'application/json' });
39
+ res.end(JSON.stringify(data));
40
+ }
41
+
42
+ function handlePromptApi(req, res, url) {
43
+ const p = url.pathname;
44
+ const m = req.method;
45
+
46
+ // --- Prompts ---
47
+ if (p === '/api/prompts' && m === 'GET') return handleListPrompts(req, res, url);
48
+ if (p === '/api/prompts' && m === 'POST') return handleCreatePrompt(req, res);
49
+ if (p.match(/^\/api\/prompts\/\d+$/) && m === 'GET') return handleGetPrompt(req, res, url);
50
+ if (p.match(/^\/api\/prompts\/\d+$/) && m === 'PUT') return handleUpdatePrompt(req, res, url);
51
+ if (p.match(/^\/api\/prompts\/\d+$/) && m === 'DELETE') return handleDeletePrompt(req, res, url);
52
+ if (p === '/api/prompts/reorder' && m === 'POST') return handleReorderPrompts(req, res);
53
+ if (p === '/api/prompts/ai-search' && m === 'POST') return handleAiSearch(req, res);
54
+ if (p.match(/^\/api\/prompts\/\d+\/duplicate$/) && m === 'POST') return handleDuplicatePrompt(req, res, url);
55
+ if (p.match(/^\/api\/prompts\/\d+\/versions$/) && m === 'GET') return handleGetVersions(req, res, url);
56
+ if (p.match(/^\/api\/prompts\/\d+\/restore$/) && m === 'POST') return handleRestoreVersion(req, res, url);
57
+ if (p.match(/^\/api\/prompts\/\d+\/usage$/) && m === 'GET') return handleGetUsage(req, res, url);
58
+ if (p.match(/^\/api\/prompts\/\d+\/usage$/) && m === 'POST') return handleTrackUsage(req, res, url);
59
+ if (p.match(/^\/api\/prompts\/\d+\/usage\/sessions$/) && m === 'GET') return handleGetUsageSessions(req, res, url);
60
+ if (p.match(/^\/api\/prompts\/\d+\/children$/) && m === 'GET') return handleListChildren(req, res, url);
61
+ if (p.match(/^\/api\/prompts\/\d+\/parent$/) && m === 'PUT') return handleSetParent(req, res, url);
62
+ if (p === '/api/prompts/create-group' && m === 'POST') return handleCreateGroup(req, res);
63
+
64
+ // --- Folders ---
65
+ if (p === '/api/folders' && m === 'GET') return handleListFolders(req, res);
66
+ if (p === '/api/folders' && m === 'POST') return handleCreateFolder(req, res);
67
+ if (p.match(/^\/api\/folders\/\d+$/) && m === 'PUT') return handleUpdateFolder(req, res, url);
68
+ if (p.match(/^\/api\/folders\/\d+$/) && m === 'DELETE') return handleDeleteFolder(req, res, url);
69
+ if (p === '/api/folders/reorder' && m === 'POST') return handleReorderFolders(req, res);
70
+
71
+ // --- Images ---
72
+ if (p === '/api/images/upload' && m === 'POST') return handleUploadImage(req, res, url);
73
+ if (p.match(/^\/api\/images\/\d+$/) && m === 'GET') return handleGetImage(req, res, url);
74
+ if (p.match(/^\/api\/images\/\d+\/annotations$/) && m === 'PUT') return handleUpdateAnnotations(req, res, url);
75
+ if (p.match(/^\/api\/images\/\d+$/) && m === 'DELETE') return handleDeleteImage(req, res, url);
76
+ if (p.match(/^\/api\/images\/file\//)) return handleServeImage(req, res, url);
77
+
78
+ // --- Chains ---
79
+ if (p === '/api/chains' && m === 'GET') return handleListChains(req, res);
80
+ if (p === '/api/chains' && m === 'POST') return handleCreateChain(req, res);
81
+ if (p.match(/^\/api\/chains\/\d+$/) && m === 'GET') return handleGetChain(req, res, url);
82
+ if (p.match(/^\/api\/chains\/\d+$/) && m === 'PUT') return handleUpdateChain(req, res, url);
83
+ if (p.match(/^\/api\/chains\/\d+$/) && m === 'DELETE') return handleDeleteChain(req, res, url);
84
+
85
+ // --- Permissions ---
86
+ if (p === '/api/permissions/rules' && m === 'GET') return handleListPermRules(req, res);
87
+ if (p === '/api/permissions/rules' && m === 'POST') return handleUpsertPermRule(req, res);
88
+ if (p.match(/^\/api\/permissions\/rules\/\d+$/) && m === 'DELETE') return handleDeletePermRule(req, res, url);
89
+ if (p === '/api/permissions/log' && m === 'GET') return handleListPermLog(req, res, url);
90
+
91
+ // --- Session Conversations ---
92
+ if (p === '/api/conversations' && m === 'GET') return handleListConversations(req, res, url);
93
+ if (p === '/api/conversations/import' && m === 'POST') return handleImportConversations(req, res);
94
+ if (p.match(/^\/api\/conversations\/[a-f0-9-]+$/) && m === 'GET') return handleGetConversation(req, res, url);
95
+
96
+ // --- Templates ---
97
+ if (p === '/api/templates' && m === 'GET') return handleListTemplates(req, res);
98
+ if (p === '/api/templates' && m === 'POST') return handleCreateTemplate(req, res);
99
+ if (p.match(/^\/api\/templates\/\d+$/) && m === 'GET') return handleGetTemplate(req, res, url);
100
+ if (p.match(/^\/api\/templates\/\d+$/) && m === 'DELETE') return handleDeleteTemplate(req, res, url);
101
+
102
+ // --- Prompt Usage by Session ---
103
+ if (p === '/api/session-prompts' && m === 'GET') return handleSessionPrompts(req, res, url);
104
+
105
+ // --- Settings ---
106
+ if (p === '/api/settings' && m === 'GET') return handleGetSettings(req, res, url);
107
+ if (p === '/api/settings' && m === 'PUT') return handlePutSettings(req, res);
108
+
109
+ // --- Screenshot ---
110
+ if (p === '/api/screenshot' && m === 'POST') return handleScreenshot(req, res);
111
+
112
+ // --- Backups ---
113
+ if (p === '/api/backups' && m === 'GET') return handleListBackups(req, res);
114
+ if (p === '/api/backups' && m === 'POST') return handleCreateBackup(req, res);
115
+ if (p === '/api/backups/restore' && m === 'POST') return handleRestoreBackup(req, res);
116
+ if (p.match(/^\/api\/backups\/[^/]+$/) && m === 'DELETE') return handleDeleteBackup(req, res, url);
117
+
118
+ // --- Tool Permissions (Claude Code native) ---
119
+ if (p === '/api/tool-permissions/scan' && m === 'POST') return handleScanToolUsage(req, res);
120
+ if (p === '/api/tool-permissions/scan-cache' && m === 'GET') return handleGetScanCache(req, res);
121
+ if (p === '/api/tool-permissions/rules' && m === 'GET') return handleGetToolPermRules(req, res);
122
+ if (p === '/api/tool-permissions/rules' && m === 'POST') return handleSetToolPermRules(req, res);
123
+ if (p === '/api/tool-permissions/projects' && m === 'GET') return handleGetProjects(req, res);
124
+ if (p === '/api/tool-permissions/always-ask' && m === 'GET') return handleGetAlwaysAsk(req, res);
125
+ if (p === '/api/tool-permissions/always-ask' && m === 'POST') return handleSetAlwaysAsk(req, res);
126
+ if (p === '/api/tool-permissions/cache' && m === 'GET') return handleGetPermCache(req, res);
127
+ if (p === '/api/tool-permissions/ai-suggest' && m === 'POST') return handlePermAISuggest(req, res);
128
+
129
+ // --- Auto Approvals ---
130
+ if (p === '/api/auto-approvals' && m === 'GET') return handleListAutoApprovals(req, res);
131
+ if (p === '/api/auto-approvals' && m === 'POST') return handleUpsertAutoApproval(req, res);
132
+ if (p === '/api/auto-approvals/scan' && m === 'POST') return handleScanAutoApprovals(req, res);
133
+ if (p === '/api/auto-approvals/toggle' && m === 'POST') return handleToggleAutoApproval(req, res);
134
+ if (p.match(/^\/api\/auto-approvals\/\d+$/) && m === 'DELETE') {
135
+ const aaId = parseInt(p.split('/').pop());
136
+ return handleDeleteAutoApproval(req, res, aaId);
137
+ }
138
+
139
+ // --- Approval Rules (AI Agent) ---
140
+ if (p === '/api/approval-rules' && m === 'GET') return handleListApprovalRules(req, res);
141
+ if (p === '/api/approval-rules' && m === 'POST') return handleUpsertApprovalRule(req, res);
142
+ if (p === '/api/approval-rules/toggle' && m === 'POST') return handleToggleApprovalRule(req, res);
143
+ if (p.match(/^\/api\/approval-rules\/\d+$/) && m === 'DELETE') {
144
+ return handleDeleteApprovalRule(req, res, parseInt(p.split('/').pop()));
145
+ }
146
+ if (p === '/api/approval-decisions' && m === 'GET') return handleListApprovalDecisions(req, res);
147
+ if (p.match(/^\/api\/approval-decisions\/\d+\/resolve$/) && m === 'POST') {
148
+ return handleResolveApprovalDecision(req, res, parseInt(p.split('/')[3]));
149
+ }
150
+
151
+ // --- Prompt Queue ---
152
+ if (p === '/api/queues' && m === 'GET') return handleListQueues(req, res);
153
+ if (p === '/api/queues' && m === 'POST') return handleCreateQueue(req, res);
154
+ const queueLinkedMatch = p.match(/^\/api\/queue-linked-prompts\/(\d+)$/);
155
+ if (queueLinkedMatch && m === 'GET') return handleGetQueueLinkedItems(req, res, parseInt(queueLinkedMatch[1]));
156
+ if (queueLinkedMatch && m === 'DELETE') return handleDeleteQueueLinkedItems(req, res, parseInt(queueLinkedMatch[1]));
157
+ const draftMatch = p.match(/^\/api\/queue-draft\/([^/]+)$/);
158
+ if (draftMatch && m === 'GET') return handleGetQueueDraft(req, res, draftMatch[1]);
159
+ if (draftMatch && m === 'PUT') return handleSaveQueueDraft(req, res, draftMatch[1]);
160
+ if (draftMatch && m === 'DELETE') return handleDeleteQueueDraft(req, res, draftMatch[1]);
161
+ const queueMatch = p.match(/^\/api\/queues\/([^/]+)$/);
162
+ const queueActionMatch = p.match(/^\/api\/queues\/([^/]+)\/(start|pause|resume|next|skip|stop|mode)$/);
163
+ if (queueMatch && !queueActionMatch && m === 'GET') return handleGetQueue(req, res, queueMatch[1]);
164
+ if (queueMatch && !queueActionMatch && m === 'DELETE') return handleDeleteQueue(req, res, queueMatch[1]);
165
+ if (queueActionMatch && m === 'POST') return handleQueueAction(req, res, queueActionMatch[1], queueActionMatch[2]);
166
+
167
+ // --- Harvest & Autocomplete & Copilot ---
168
+ if (p === '/api/harvest/preview' && m === 'GET') return handleHarvestPreview(req, res);
169
+ if (p === '/api/harvest' && m === 'POST') return handleRunHarvest(req, res);
170
+ if (p === '/api/autocomplete' && m === 'GET') return handleAutocomplete(req, res, url);
171
+ if (p === '/api/similar' && m === 'GET') return handleSimilarPrompts(req, res, url);
172
+ if (p === '/api/patterns' && m === 'GET') return handleListPatterns(req, res);
173
+ if (p === '/api/patterns/detect' && m === 'POST') return handleDetectPatterns(req, res);
174
+ if (p === '/api/copilot/suggest' && m === 'POST') return handleCopilotSuggest(req, res);
175
+ if (p === '/api/copilot/chat' && m === 'POST') return handleCopilotChat(req, res);
176
+ if (p === '/api/prompt-executions' && m === 'GET') return handleListExecutions(req, res, url);
177
+ const execSessionMatch = p.match(/^\/api\/prompt-executions\/session\/([^/]+)$/);
178
+ if (execSessionMatch && m === 'GET') return handleSessionExecutions(req, res, execSessionMatch[1]);
179
+ const execOutcomeMatch = p.match(/^\/api\/prompt-executions\/(\d+)\/outcome$/);
180
+ if (execOutcomeMatch && m === 'POST') return handleSetOutcome(req, res, parseInt(execOutcomeMatch[1]));
181
+ const promptSessionsMatch = p.match(/^\/api\/prompts\/(\d+)\/sessions$/);
182
+ if (promptSessionsMatch && m === 'GET') return handlePromptSessions(req, res, parseInt(promptSessionsMatch[1]));
183
+ if (p === '/api/frequent-questions' && m === 'GET') return handleFrequentQuestions(req, res, url);
184
+ if (p === '/api/lifecycle/refresh' && m === 'POST') return handleLifecycleRefresh(req, res);
185
+
186
+ return false;
187
+ }
188
+
189
+ // --- Prompts ---
190
+ function handleListPrompts(req, res, url) {
191
+ const opts = {};
192
+ if (url.searchParams.has('folder_id')) opts.folder_id = parseInt(url.searchParams.get('folder_id'));
193
+ if (url.searchParams.has('context_type')) opts.context_type = url.searchParams.get('context_type');
194
+ if (url.searchParams.has('search')) opts.search = url.searchParams.get('search');
195
+ if (url.searchParams.has('starred')) opts.starred = true;
196
+ if (url.searchParams.has('lifecycle_status')) opts.lifecycle_status = url.searchParams.get('lifecycle_status');
197
+ if (url.searchParams.has('include_children')) opts.include_children = true;
198
+ if (url.searchParams.has('limit')) opts.limit = parseInt(url.searchParams.get('limit'));
199
+ if (url.searchParams.has('offset')) opts.offset = parseInt(url.searchParams.get('offset'));
200
+ jsonResponse(res, 200, db.listPrompts(opts));
201
+ }
202
+
203
+ async function handleCreatePrompt(req, res) {
204
+ try {
205
+ const data = await readBody(req);
206
+ const id = db.createPrompt(data);
207
+ jsonResponse(res, 201, { id, ok: true });
208
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
209
+ }
210
+
211
+ function handleGetPrompt(req, res, url) {
212
+ const id = parseInt(url.pathname.split('/').pop());
213
+ const prompt = db.getPrompt(id);
214
+ if (!prompt) return jsonResponse(res, 404, { error: 'Not found' });
215
+ prompt.images = db.listImages(id);
216
+ prompt.usage = db.getPromptUsageStats(id);
217
+ prompt.children = db.listChildPrompts(id);
218
+ jsonResponse(res, 200, prompt);
219
+ }
220
+
221
+ async function handleUpdatePrompt(req, res, url) {
222
+ try {
223
+ const id = parseInt(url.pathname.split('/').pop());
224
+ const data = await readBody(req);
225
+ db.updatePrompt(id, data);
226
+ jsonResponse(res, 200, { ok: true });
227
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
228
+ }
229
+
230
+ function handleDeletePrompt(req, res, url) {
231
+ const id = parseInt(url.pathname.split('/').pop());
232
+ db.deletePrompt(id);
233
+ jsonResponse(res, 200, { ok: true });
234
+ }
235
+
236
+ function handleGetVersions(req, res, url) {
237
+ const id = parseInt(url.pathname.split('/')[3]);
238
+ jsonResponse(res, 200, db.getPromptVersions(id));
239
+ }
240
+
241
+ async function handleRestoreVersion(req, res, url) {
242
+ try {
243
+ const promptId = parseInt(url.pathname.split('/')[3]);
244
+ const data = await readBody(req);
245
+ db.restorePromptVersion(promptId, data.version_id);
246
+ jsonResponse(res, 200, { ok: true });
247
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
248
+ }
249
+
250
+ function handleGetUsage(req, res, url) {
251
+ const id = parseInt(url.pathname.split('/')[3]);
252
+ jsonResponse(res, 200, db.getPromptUsageStats(id));
253
+ }
254
+
255
+ async function handleTrackUsage(req, res, url) {
256
+ const id = parseInt(url.pathname.split('/')[3]);
257
+ const body = await readBody(req);
258
+ db.trackPromptUsage({
259
+ prompt_id: id,
260
+ session_id: body.session_id || '',
261
+ result: body.result || '',
262
+ effectiveness_score: body.effectiveness_score || null,
263
+ });
264
+ jsonResponse(res, 200, { ok: true });
265
+ }
266
+
267
+ function handleGetUsageSessions(req, res, url) {
268
+ const id = parseInt(url.pathname.split('/')[3]);
269
+ const rows = db.getDb().prepare(
270
+ 'SELECT session_id, used_at, result FROM prompt_usage WHERE prompt_id = ? ORDER BY used_at DESC LIMIT 50'
271
+ ).all(id);
272
+ jsonResponse(res, 200, rows);
273
+ }
274
+
275
+ function handleSessionPrompts(req, res, url) {
276
+ const sessionId = url.searchParams.get('session_id');
277
+ if (!sessionId) {
278
+ jsonResponse(res, 400, { error: 'Missing session_id' });
279
+ return;
280
+ }
281
+ const rows = db.getDb().prepare(
282
+ `SELECT pu.prompt_id, pu.used_at, pu.result, p.title
283
+ FROM prompt_usage pu
284
+ LEFT JOIN prompts p ON p.id = pu.prompt_id
285
+ WHERE pu.session_id = ?
286
+ ORDER BY pu.used_at DESC`
287
+ ).all(sessionId);
288
+ jsonResponse(res, 200, rows);
289
+ }
290
+
291
+ // --- Folders ---
292
+ function handleListFolders(req, res) {
293
+ jsonResponse(res, 200, db.listFolders());
294
+ }
295
+
296
+ async function handleCreateFolder(req, res) {
297
+ try {
298
+ const data = await readBody(req);
299
+ const id = db.createFolder(data);
300
+ jsonResponse(res, 201, { id, ok: true });
301
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
302
+ }
303
+
304
+ async function handleUpdateFolder(req, res, url) {
305
+ try {
306
+ const id = parseInt(url.pathname.split('/').pop());
307
+ const data = await readBody(req);
308
+ db.updateFolder(id, data);
309
+ jsonResponse(res, 200, { ok: true });
310
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
311
+ }
312
+
313
+ function handleDeleteFolder(req, res, url) {
314
+ const id = parseInt(url.pathname.split('/').pop());
315
+ db.deleteFolder(id);
316
+ jsonResponse(res, 200, { ok: true });
317
+ }
318
+
319
+ async function handleReorderPrompts(req, res) {
320
+ try {
321
+ const { ids } = await readBody(req);
322
+ db.reorderPrompts(ids);
323
+ jsonResponse(res, 200, { ok: true });
324
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
325
+ }
326
+
327
+ async function handleDuplicatePrompt(req, res, url) {
328
+ try {
329
+ const id = parseInt(url.pathname.split('/')[3]);
330
+ const newId = db.duplicatePrompt(id);
331
+ jsonResponse(res, 201, { id: newId, ok: true });
332
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
333
+ }
334
+
335
+ function handleListChildren(req, res, url) {
336
+ const id = parseInt(url.pathname.split('/')[3]);
337
+ jsonResponse(res, 200, db.listChildPrompts(id));
338
+ }
339
+
340
+ async function handleSetParent(req, res, url) {
341
+ try {
342
+ const id = parseInt(url.pathname.split('/')[3]);
343
+ const { parent_id } = await readBody(req);
344
+ db.setPromptParent(id, parent_id);
345
+ jsonResponse(res, 200, { ok: true });
346
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
347
+ }
348
+
349
+ async function handleCreateGroup(req, res) {
350
+ try {
351
+ const { ids } = await readBody(req);
352
+ if (!ids || ids.length < 2) return jsonResponse(res, 400, { error: 'Need at least 2 prompt ids' });
353
+
354
+ // Fetch prompts to build AI context
355
+ const prompts = ids.map(id => db.getPrompt(id)).filter(Boolean);
356
+ if (prompts.length < 2) return jsonResponse(res, 400, { error: 'Prompts not found' });
357
+
358
+ // Generate AI summary for the group
359
+ let title, content, contentHtml;
360
+ try {
361
+ const promptDescriptions = prompts.map(p => {
362
+ const snippet = (p.content || '').slice(0, 500).trim();
363
+ return `Title: ${p.title || 'Untitled'}\nContent: ${snippet || '(empty)'}`;
364
+ }).join('\n\n---\n\n');
365
+
366
+ const aiResponse = await callClaude([{
367
+ role: 'user',
368
+ content: `You are summarizing a group of prompts that a user wants to organize together. Generate a concise group title and a brief summary describing what these prompts have in common or what they collectively do.
369
+
370
+ Here are the prompts being grouped:
371
+
372
+ ${promptDescriptions}
373
+
374
+ Respond in this exact JSON format (no markdown, no code fences):
375
+ {"title": "short group title (max 60 chars)", "summary": "1-2 sentence summary of what these prompts do together"}`
376
+ }], 256);
377
+
378
+ const aiText = aiResponse.content?.[0]?.text || '';
379
+ const parsed = JSON.parse(aiText);
380
+ title = parsed.title;
381
+ content = parsed.summary;
382
+ const safeSummary = (parsed.summary || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
383
+ contentHtml = `<p>${safeSummary}</p>`;
384
+ } catch (aiErr) {
385
+ // AI failed — fall through to DB fallback (concatenated titles)
386
+ console.error('[create-group] AI summary failed, using fallback:', aiErr.message);
387
+ }
388
+
389
+ const groupId = db.createGroupFromPrompts(ids, { title, content, contentHtml });
390
+ jsonResponse(res, 201, { id: groupId, ok: true });
391
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
392
+ }
393
+
394
+ async function handleReorderFolders(req, res) {
395
+ try {
396
+ const { ids } = await readBody(req);
397
+ db.reorderFolders(ids);
398
+ jsonResponse(res, 200, { ok: true });
399
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
400
+ }
401
+
402
+ // --- AI Search ---
403
+ function parseCustomHeaders() {
404
+ const headers = {};
405
+ const b64 = process.env.ANTHROPIC_CUSTOM_HEADERS_B64;
406
+ if (b64) {
407
+ const decoded = Buffer.from(b64, 'base64').toString();
408
+ for (const line of decoded.split('\n')) {
409
+ const idx = line.indexOf(':');
410
+ if (idx > 0) headers[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
411
+ }
412
+ }
413
+ return headers;
414
+ }
415
+
416
+ async function callClaude(messages, maxTokens = 1024) {
417
+ const baseUrl = process.env.ANTHROPIC_BASE_URL || 'https://api.anthropic.com';
418
+ const apiKey = process.env.ANTHROPIC_API_KEY || 'dummy';
419
+ const customHeaders = parseCustomHeaders();
420
+
421
+ const res = await fetch(`${baseUrl}/messages`, {
422
+ method: 'POST',
423
+ headers: {
424
+ 'Content-Type': 'application/json',
425
+ 'x-api-key': apiKey,
426
+ 'anthropic-version': '2023-06-01',
427
+ ...customHeaders,
428
+ },
429
+ body: JSON.stringify({
430
+ model: 'claude-sonnet-4-20250514',
431
+ max_tokens: maxTokens,
432
+ messages,
433
+ }),
434
+ });
435
+
436
+ if (!res.ok) {
437
+ const text = await res.text();
438
+ throw new Error(`Claude API ${res.status}: ${text}`);
439
+ }
440
+ return await res.json();
441
+ }
442
+
443
+ async function handleAiSearch(req, res) {
444
+ try {
445
+ const { query } = await readBody(req);
446
+ if (!query || !query.trim()) return jsonResponse(res, 400, { error: 'Query required' });
447
+
448
+ // Get all prompts with summaries
449
+ const allPrompts = db.listPrompts();
450
+ if (!allPrompts.length) return jsonResponse(res, 200, { results: [] });
451
+
452
+ // Build prompt catalog for Claude
453
+ const catalog = allPrompts.map(p => {
454
+ const snippet = (p.content || '').slice(0, 300).replace(/\n+/g, ' ').trim();
455
+ const tags = p.tags || '[]';
456
+ return `[ID:${p.id}] "${p.title}" (${p.context_type}) tags:${tags}\n ${snippet}`;
457
+ }).join('\n\n');
458
+
459
+ const response = await callClaude([{
460
+ role: 'user',
461
+ content: `You are a search engine for a prompt library. Given the user's search query, find the most relevant prompts from the catalog below and return them ranked by relevance.
462
+
463
+ Search query: "${query.trim()}"
464
+
465
+ Prompt catalog:
466
+ ${catalog}
467
+
468
+ Return a JSON array of objects with these fields:
469
+ - id: the prompt ID number
470
+ - relevance: a score from 0-100
471
+ - reason: a brief explanation (1 sentence) of why this prompt matches
472
+
473
+ Only include prompts with relevance >= 30. Return at most 10 results, sorted by relevance descending.
474
+ Return ONLY the JSON array, no other text.`
475
+ }]);
476
+
477
+ const text = response.content?.[0]?.text || '[]';
478
+ // Extract JSON from response (handle markdown code blocks)
479
+ const jsonMatch = text.match(/\[[\s\S]*\]/);
480
+ const results = jsonMatch ? JSON.parse(jsonMatch[0]) : [];
481
+
482
+ // Enrich results with prompt data
483
+ const enriched = results.map(r => {
484
+ const prompt = allPrompts.find(p => p.id === r.id);
485
+ if (!prompt) return null;
486
+ return {
487
+ id: r.id,
488
+ title: prompt.title,
489
+ context_type: prompt.context_type,
490
+ tags: prompt.tags,
491
+ updated_at: prompt.updated_at,
492
+ pinned: prompt.pinned,
493
+ starred: prompt.starred,
494
+ relevance: r.relevance,
495
+ reason: r.reason,
496
+ };
497
+ }).filter(Boolean);
498
+
499
+ jsonResponse(res, 200, { results: enriched });
500
+ } catch (e) {
501
+ console.error('AI Search error:', e.message);
502
+ jsonResponse(res, 500, { error: 'AI search failed: ' + e.message });
503
+ }
504
+ }
505
+
506
+ // --- Images ---
507
+ async function handleUploadImage(req, res, url) {
508
+ try {
509
+ const promptId = parseInt(url.searchParams.get('prompt_id') || '0');
510
+ const filename = url.searchParams.get('filename') || 'image.png';
511
+ const mimeType = req.headers['content-type'] || 'image/png';
512
+ const buffer = await readRawBody(req);
513
+ const result = db.saveImage(promptId, buffer, filename, mimeType);
514
+ jsonResponse(res, 201, result);
515
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
516
+ }
517
+
518
+ function handleGetImage(req, res, url) {
519
+ const id = parseInt(url.pathname.split('/').pop());
520
+ const img = db.getImage(id);
521
+ if (!img) return jsonResponse(res, 404, { error: 'Not found' });
522
+ jsonResponse(res, 200, img);
523
+ }
524
+
525
+ function handleServeImage(req, res, url) {
526
+ const rawFilename = url.pathname.replace('/api/images/file/', '');
527
+ const safeFilename = path.basename(rawFilename);
528
+ let filePath = path.join(db.DEFAULT_IMAGES_DIR, safeFilename);
529
+ if (!fs.existsSync(filePath)) {
530
+ // Filename might be the original upload name — look up actual file_path from DB
531
+ const img = db.getDb().prepare('SELECT file_path FROM images WHERE filename = ? LIMIT 1').get(safeFilename);
532
+ const resolvedImgPath = img ? path.resolve(img.file_path) : null;
533
+ if (resolvedImgPath && resolvedImgPath.startsWith(path.resolve(db.DEFAULT_IMAGES_DIR) + path.sep) && fs.existsSync(resolvedImgPath)) {
534
+ filePath = resolvedImgPath;
535
+ } else {
536
+ res.writeHead(404); res.end('Not found'); return;
537
+ }
538
+ }
539
+ const ext = path.extname(filePath).toLowerCase();
540
+ const mimeMap = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
541
+ res.writeHead(200, {
542
+ 'Content-Type': mimeMap[ext] || 'application/octet-stream',
543
+ 'Cache-Control': 'public, max-age=86400'
544
+ });
545
+ fs.createReadStream(filePath).pipe(res);
546
+ }
547
+
548
+ async function handleUpdateAnnotations(req, res, url) {
549
+ try {
550
+ const id = parseInt(url.pathname.split('/')[3]);
551
+ const data = await readBody(req);
552
+ db.updateImageAnnotations(id, data.annotations);
553
+ jsonResponse(res, 200, { ok: true });
554
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
555
+ }
556
+
557
+ function handleDeleteImage(req, res, url) {
558
+ const id = parseInt(url.pathname.split('/').pop());
559
+ db.deleteImage(id);
560
+ jsonResponse(res, 200, { ok: true });
561
+ }
562
+
563
+ // --- Chains ---
564
+ function handleListChains(req, res) {
565
+ jsonResponse(res, 200, db.listChains());
566
+ }
567
+
568
+ async function handleCreateChain(req, res) {
569
+ try {
570
+ const data = await readBody(req);
571
+ const id = db.createChain(data);
572
+ jsonResponse(res, 201, { id, ok: true });
573
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
574
+ }
575
+
576
+ function handleGetChain(req, res, url) {
577
+ const id = parseInt(url.pathname.split('/').pop());
578
+ const chain = db.getChain(id);
579
+ if (!chain) return jsonResponse(res, 404, { error: 'Not found' });
580
+ jsonResponse(res, 200, chain);
581
+ }
582
+
583
+ async function handleUpdateChain(req, res, url) {
584
+ try {
585
+ const id = parseInt(url.pathname.split('/').pop());
586
+ const data = await readBody(req);
587
+ db.updateChain(id, data);
588
+ jsonResponse(res, 200, { ok: true });
589
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
590
+ }
591
+
592
+ function handleDeleteChain(req, res, url) {
593
+ const id = parseInt(url.pathname.split('/').pop());
594
+ db.deleteChain(id);
595
+ jsonResponse(res, 200, { ok: true });
596
+ }
597
+
598
+ // --- Permissions ---
599
+ function handleListPermRules(req, res) {
600
+ jsonResponse(res, 200, db.listPermissionRules());
601
+ }
602
+
603
+ async function handleUpsertPermRule(req, res) {
604
+ try {
605
+ const data = await readBody(req);
606
+ db.upsertPermissionRule(data);
607
+ jsonResponse(res, 200, { ok: true });
608
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
609
+ }
610
+
611
+ function handleDeletePermRule(req, res, url) {
612
+ const id = parseInt(url.pathname.split('/').pop());
613
+ db.deletePermissionRule(id);
614
+ jsonResponse(res, 200, { ok: true });
615
+ }
616
+
617
+ function handleListPermLog(req, res, url) {
618
+ const opts = {};
619
+ if (url.searchParams.has('session_id')) opts.session_id = url.searchParams.get('session_id');
620
+ if (url.searchParams.has('tool_name')) opts.tool_name = url.searchParams.get('tool_name');
621
+ if (url.searchParams.has('limit')) opts.limit = parseInt(url.searchParams.get('limit'));
622
+ jsonResponse(res, 200, db.listPermissionLog(opts));
623
+ }
624
+
625
+ // --- Session Conversations ---
626
+ function handleListConversations(req, res, url) {
627
+ const opts = {};
628
+ if (url.searchParams.has('search')) opts.search = url.searchParams.get('search');
629
+ if (url.searchParams.has('limit')) opts.limit = parseInt(url.searchParams.get('limit'));
630
+ if (url.searchParams.has('offset')) opts.offset = parseInt(url.searchParams.get('offset'));
631
+ jsonResponse(res, 200, db.listSessionConversations(opts));
632
+ }
633
+
634
+ async function handleImportConversations(req, res) {
635
+ // Import all sessions from ~/.claude/projects/ into the DB
636
+ try {
637
+ const { getAllSessionFiles, parseSessionFile } = require('./session-utils');
638
+ let imported = 0;
639
+ const allFiles = getAllSessionFiles();
640
+
641
+ for (const { filePath, projectPath, projectEntry } of allFiles) {
642
+ try {
643
+ const parsed = parseSessionFile(filePath, projectPath, projectEntry);
644
+ if (parsed.isEmpty) continue;
645
+
646
+ const existing = db.getSessionConversation(parsed.sessionId);
647
+ if (existing && existing.file_size === parsed.fileSize) continue;
648
+
649
+ // Read full messages
650
+ const content = fs.readFileSync(filePath, 'utf8');
651
+ const lines = content.split('\n').filter(Boolean);
652
+ const messages = [];
653
+ let assistantCount = 0;
654
+
655
+ for (const line of lines) {
656
+ try {
657
+ const entry = JSON.parse(line);
658
+ if (entry.type === 'user' && entry.message?.role === 'user') {
659
+ const c = entry.message.content;
660
+ const text = typeof c === 'string' ? c
661
+ : Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join('\n') : '';
662
+ if (text) messages.push({ role: 'user', text: text.slice(0, 5000), timestamp: entry.timestamp });
663
+ } else if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
664
+ const c = entry.message.content;
665
+ if (!Array.isArray(c)) continue;
666
+ const parts = [];
667
+ for (const block of c) {
668
+ if (block.type === 'text' && block.text) parts.push(block.text);
669
+ else if (block.type === 'tool_use') parts.push(`[Tool: ${block.name}]`);
670
+ }
671
+ if (parts.length > 0) {
672
+ const lastMsg = messages[messages.length - 1];
673
+ if (lastMsg && lastMsg.role === 'assistant' && lastMsg._parent === entry.parentUuid) {
674
+ lastMsg.text = parts.join('\n').slice(0, 5000);
675
+ } else {
676
+ messages.push({ role: 'assistant', text: parts.join('\n').slice(0, 5000), timestamp: entry.timestamp, _parent: entry.parentUuid });
677
+ assistantCount++;
678
+ }
679
+ }
680
+ }
681
+ } catch {}
682
+ }
683
+ messages.forEach(m => delete m._parent);
684
+
685
+ db.importSessionConversation({
686
+ session_id: parsed.sessionId,
687
+ project_path: parsed.project,
688
+ messages,
689
+ user_msg_count: parsed.userMsgCount,
690
+ assistant_msg_count: assistantCount,
691
+ title: parsed.title,
692
+ first_message: parsed.firstMessage,
693
+ git_branch: parsed.gitBranch,
694
+ file_size: parsed.fileSize,
695
+ session_created_at: parsed.timestamp,
696
+ });
697
+ imported++;
698
+ } catch {}
699
+ }
700
+
701
+ jsonResponse(res, 200, { ok: true, imported });
702
+ } catch (e) { jsonResponse(res, 500, { error: e.message }); }
703
+ }
704
+
705
+ function handleGetConversation(req, res, url) {
706
+ const sessionId = url.pathname.split('/').pop();
707
+ const conv = db.getSessionConversation(sessionId);
708
+ if (!conv) return jsonResponse(res, 404, { error: 'Not found' });
709
+ jsonResponse(res, 200, conv);
710
+ }
711
+
712
+ // --- Templates ---
713
+ function handleListTemplates(req, res) {
714
+ jsonResponse(res, 200, db.listTemplates());
715
+ }
716
+
717
+ async function handleCreateTemplate(req, res) {
718
+ try {
719
+ const data = await readBody(req);
720
+ const id = db.createTemplate(data);
721
+ jsonResponse(res, 201, { id, ok: true });
722
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
723
+ }
724
+
725
+ function handleGetTemplate(req, res, url) {
726
+ const id = parseInt(url.pathname.split('/').pop());
727
+ const t = db.getTemplate(id);
728
+ if (!t) return jsonResponse(res, 404, { error: 'Not found' });
729
+ jsonResponse(res, 200, t);
730
+ }
731
+
732
+ function handleDeleteTemplate(req, res, url) {
733
+ const id = parseInt(url.pathname.split('/').pop());
734
+ db.deleteTemplate(id);
735
+ jsonResponse(res, 200, { ok: true });
736
+ }
737
+
738
+ // --- Settings ---
739
+ function handleGetSettings(req, res, url) {
740
+ const key = url.searchParams.get('key');
741
+ const prefix = url.searchParams.get('prefix');
742
+ if (key) {
743
+ jsonResponse(res, 200, { key, value: db.getSetting(key) });
744
+ } else if (prefix) {
745
+ const rows = db.getSettingsByPrefix(prefix);
746
+ const result = {};
747
+ for (const r of rows) result[r.key] = r.value;
748
+ jsonResponse(res, 200, result);
749
+ } else {
750
+ const allKeys = ['db_path', 'images_dir', 'default_context_type', 'editor_theme', 'auto_version'];
751
+ const settings = {};
752
+ for (const k of allKeys) settings[k] = db.getSetting(k);
753
+ jsonResponse(res, 200, settings);
754
+ }
755
+ }
756
+
757
+ async function handlePutSettings(req, res) {
758
+ try {
759
+ const data = await readBody(req);
760
+ for (const [k, v] of Object.entries(data)) {
761
+ db.setSetting(k, v);
762
+ }
763
+ jsonResponse(res, 200, { ok: true });
764
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
765
+ }
766
+
767
+ // --- Screenshot (macOS) ---
768
+ async function handleScreenshot(req, res) {
769
+ try {
770
+ const { execSync } = require('child_process');
771
+ const tmpFile = path.join(db.DEFAULT_IMAGES_DIR, `screenshot-${Date.now()}.png`);
772
+ execSync(`screencapture -i "${tmpFile}"`, { timeout: 30000 });
773
+ if (!fs.existsSync(tmpFile)) {
774
+ return jsonResponse(res, 400, { error: 'Screenshot cancelled' });
775
+ }
776
+ const buffer = fs.readFileSync(tmpFile);
777
+ const result = db.saveImage(0, buffer, path.basename(tmpFile), 'image/png');
778
+ jsonResponse(res, 201, result);
779
+ } catch (e) { jsonResponse(res, 500, { error: e.message }); }
780
+ }
781
+
782
+ // --- Backups ---
783
+ function handleListBackups(req, res) {
784
+ jsonResponse(res, 200, db.listBackups());
785
+ }
786
+
787
+ async function handleCreateBackup(req, res) {
788
+ try {
789
+ const data = await readBody(req).catch(() => ({}));
790
+ const result = db.createBackupSync(data.label || 'manual');
791
+ jsonResponse(res, 201, result);
792
+ } catch (e) { jsonResponse(res, 500, { error: e.message }); }
793
+ }
794
+
795
+ async function handleRestoreBackup(req, res) {
796
+ try {
797
+ const data = await readBody(req);
798
+ if (!data.name) return jsonResponse(res, 400, { error: 'Missing backup name' });
799
+ const result = db.restoreBackup(data.name);
800
+ jsonResponse(res, 200, result);
801
+ } catch (e) { jsonResponse(res, 500, { error: e.message }); }
802
+ }
803
+
804
+ function handleDeleteBackup(req, res, url) {
805
+ const name = decodeURIComponent(url.pathname.split('/').pop());
806
+ db.deleteBackup(name);
807
+ jsonResponse(res, 200, { ok: true });
808
+ }
809
+
810
+ // ============================================================
811
+ // Tool Permissions (reads/writes .claude/settings.local.json)
812
+ // ============================================================
813
+ // Claude Code reads pre-authorized permissions from:
814
+ // Global: ~/.claude/settings.local.json -> permissions.allow[]
815
+ // Per-project: <project>/.claude/settings.local.json -> permissions.allow[]
816
+ // Legacy: ~/.claude.json -> projects[path].allowedTools[] (session approval history, read-only)
817
+
818
+ const CLAUDE_JSON_PATH = path.join(process.env.HOME, '.claude.json');
819
+ const GLOBAL_SETTINGS_PATH = path.join(process.env.HOME, '.claude', 'settings.local.json');
820
+
821
+ function readJsonFile(filePath) {
822
+ try {
823
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
824
+ } catch { return {}; }
825
+ }
826
+
827
+ function writeJsonFile(filePath, data) {
828
+ const dir = path.dirname(filePath);
829
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
830
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
831
+ }
832
+
833
+ function getProjectSettingsPath(projectPath) {
834
+ return path.join(projectPath, '.claude', 'settings.local.json');
835
+ }
836
+
837
+ // Validate permission rule patterns — Claude Code rejects rules where :* is not at the end
838
+ function isValidPermRule(rule) {
839
+ // Match Bash(prefix:*suffix) — :* must be followed only by )
840
+ const m = rule.match(/^Bash\(.*:\*(.*)?\)$/);
841
+ if (m && m[1]) return false; // something after :* before )
842
+ return true;
843
+ }
844
+
845
+ function getSettingsAllowList(settingsPath) {
846
+ const settings = readJsonFile(settingsPath);
847
+ return (settings.permissions && settings.permissions.allow) || [];
848
+ }
849
+
850
+ function getSettingsDenyList(settingsPath) {
851
+ const settings = readJsonFile(settingsPath);
852
+ return (settings.permissions && settings.permissions.deny) || [];
853
+ }
854
+
855
+ // --- Collect all rules from JSON files ---
856
+ function collectJsonRules() {
857
+ const rules = [];
858
+
859
+ // Global allow/deny from ~/.claude/settings.local.json
860
+ for (const tool of getSettingsAllowList(GLOBAL_SETTINGS_PATH)) {
861
+ if (!isValidPermRule(tool)) { console.warn(` Skipping invalid perm rule: ${tool}`); continue; }
862
+ rules.push({ rule: tool, listType: 'allow', scope: 'global', project: '__global__' });
863
+ }
864
+ for (const tool of getSettingsDenyList(GLOBAL_SETTINGS_PATH)) {
865
+ if (!isValidPermRule(tool)) continue;
866
+ rules.push({ rule: tool, listType: 'deny', scope: 'global', project: '__global__' });
867
+ }
868
+
869
+ // Per-project from <project>/.claude/settings.local.json
870
+ const claudeJson = readJsonFile(CLAUDE_JSON_PATH);
871
+ const projectPaths = Object.keys(claudeJson.projects || {});
872
+ for (const projectPath of projectPaths) {
873
+ const settingsPath = getProjectSettingsPath(projectPath);
874
+ for (const tool of getSettingsAllowList(settingsPath)) {
875
+ if (!isValidPermRule(tool)) { console.warn(` Skipping invalid perm rule: ${tool}`); continue; }
876
+ rules.push({ rule: tool, listType: 'allow', scope: 'project', project: projectPath });
877
+ }
878
+ for (const tool of getSettingsDenyList(settingsPath)) {
879
+ if (!isValidPermRule(tool)) continue;
880
+ rules.push({ rule: tool, listType: 'deny', scope: 'project', project: projectPath });
881
+ }
882
+ }
883
+
884
+ // Legacy: ~/.claude.json projects[].allowedTools
885
+ for (const [projectPath, projectData] of Object.entries(claudeJson.projects || {})) {
886
+ const allowedTools = projectData.allowedTools || [];
887
+ for (const tool of allowedTools) {
888
+ if (!isValidPermRule(tool)) continue;
889
+ rules.push({ rule: tool, listType: 'allow', scope: 'project', project: projectPath });
890
+ }
891
+ }
892
+
893
+ return rules;
894
+ }
895
+
896
+ // --- Merge JSON rules into DB (import any new ones Claude Code added) ---
897
+ function mergeJsonRulesIntoDb() {
898
+ const jsonRules = collectJsonRules();
899
+ const before = db.listPermRules().length;
900
+ for (const r of jsonRules) {
901
+ db.addPermRule(r); // INSERT OR IGNORE — skips duplicates
902
+ }
903
+ const after = db.listPermRules().length;
904
+ const imported = after - before;
905
+ if (imported > 0) {
906
+ console.log(` Merged ${imported} new permission rules from JSON files into DB`);
907
+ }
908
+ return imported;
909
+ }
910
+
911
+ // --- Import JSON files into SQLite (run on startup) ---
912
+ function importPermissionsToDb() {
913
+ const existing = db.listPermRules();
914
+ if (existing.length > 0) {
915
+ // DB already has rules. Merge any new rules Claude Code added to JSON files.
916
+ mergeJsonRulesIntoDb();
917
+ // Then sync DB (now the complete set) back to JSON files.
918
+ syncDbToJsonFiles();
919
+ return;
920
+ }
921
+
922
+ // First time: seed DB from JSON files
923
+ const rules = collectJsonRules();
924
+
925
+ if (rules.length > 0) {
926
+ db.bulkSetPermRules(rules);
927
+ console.log(` Imported ${rules.length} permission rules from JSON files into DB`);
928
+ syncDbToJsonFiles();
929
+ }
930
+ }
931
+
932
+ // --- Sync SQLite → JSON files (merge first, then write) ---
933
+ function syncDbToJsonFiles() {
934
+ // First: import any new rules Claude Code added directly to JSON files
935
+ const jsonRules = collectJsonRules();
936
+ for (const r of jsonRules) {
937
+ db.addPermRule(r); // INSERT OR IGNORE — skips duplicates
938
+ }
939
+ // Scrub invalid rules from DB (Claude Code sometimes writes bad patterns like "node:*1")
940
+ for (const r of db.listPermRules()) {
941
+ if (!isValidPermRule(r.rule)) {
942
+ console.warn(` Removing invalid perm rule from DB: ${r.rule}`);
943
+ db.removePermRule({ rule: r.rule, listType: r.list_type, project: r.project });
944
+ }
945
+ }
946
+
947
+ // Now write the complete DB state to all JSON files
948
+ const byProject = db.getPermRulesByProject();
949
+
950
+ // Global rules → ~/.claude/settings.local.json
951
+ const globalRules = byProject['__global__'] || { allow: [], deny: [] };
952
+ const globalSettings = readJsonFile(GLOBAL_SETTINGS_PATH);
953
+ if (!globalSettings.permissions) globalSettings.permissions = {};
954
+ globalSettings.permissions.allow = globalRules.allow.filter(isValidPermRule);
955
+ globalSettings.permissions.deny = globalRules.deny.filter(isValidPermRule);
956
+ writeJsonFile(GLOBAL_SETTINGS_PATH, globalSettings);
957
+
958
+ // Per-project rules → <project>/.claude/settings.local.json
959
+ for (const [project, lists] of Object.entries(byProject)) {
960
+ if (project === '__global__') continue;
961
+ const settingsPath = getProjectSettingsPath(project);
962
+ try {
963
+ const settings = readJsonFile(settingsPath);
964
+ if (!settings.permissions) settings.permissions = {};
965
+ settings.permissions.allow = lists.allow.filter(isValidPermRule);
966
+ settings.permissions.deny = lists.deny.filter(isValidPermRule);
967
+ writeJsonFile(settingsPath, settings);
968
+ } catch (e) {
969
+ // Project dir may not exist — skip
970
+ }
971
+ }
972
+ }
973
+
974
+ function handleGetProjects(req, res) {
975
+ const claudeJson = readJsonFile(CLAUDE_JSON_PATH);
976
+ const projects = claudeJson.projects || {};
977
+ const dbRules = db.listPermRules({ listType: 'allow' });
978
+ const result = Object.entries(projects).map(([projectPath]) => {
979
+ const allowedTools = dbRules.filter(r => r.project === projectPath).map(r => r.rule);
980
+ return { path: projectPath, allowedTools };
981
+ });
982
+ jsonResponse(res, 200, result);
983
+ }
984
+
985
+ function handleGetToolPermRules(req, res) {
986
+ // Merge any new rules Claude Code may have added to JSON files
987
+ for (const r of collectJsonRules()) {
988
+ db.addPermRule(r); // INSERT OR IGNORE
989
+ }
990
+ // Read from SQLite (source of truth)
991
+ const allRules = db.listPermRules();
992
+
993
+ const rules = [];
994
+ const denyRules = [];
995
+ const projectSet = new Set();
996
+
997
+ for (const r of allRules) {
998
+ const entry = { scope: r.scope, project: r.project, rule: r.rule };
999
+ if (r.list_type === 'allow') rules.push(entry);
1000
+ else denyRules.push(entry);
1001
+ if (r.project !== '__global__') projectSet.add(r.project);
1002
+ }
1003
+
1004
+ // Also include projects from ~/.claude.json that might not have rules
1005
+ const claudeJson = readJsonFile(CLAUDE_JSON_PATH);
1006
+ for (const p of Object.keys(claudeJson.projects || {})) {
1007
+ projectSet.add(p);
1008
+ }
1009
+
1010
+ jsonResponse(res, 200, { rules, denyRules, projects: Array.from(projectSet) });
1011
+ }
1012
+
1013
+ async function handleSetToolPermRules(req, res) {
1014
+ try {
1015
+ const body = await readBody(req);
1016
+ const { action, rule, project, listType } = body;
1017
+ const lt = listType === 'deny' ? 'deny' : 'allow';
1018
+ const proj = project || '__global__';
1019
+ const scope = proj === '__global__' ? 'global' : 'project';
1020
+
1021
+ if (action === 'add') {
1022
+ db.addPermRule({ rule, listType: lt, scope, project: proj });
1023
+ } else if (action === 'remove') {
1024
+ db.removePermRule({ rule, listType: lt, project: proj });
1025
+ }
1026
+
1027
+ // Sync DB → JSON files
1028
+ syncDbToJsonFiles();
1029
+
1030
+ jsonResponse(res, 200, { ok: true });
1031
+ } catch (e) {
1032
+ jsonResponse(res, 500, { error: e.message });
1033
+ }
1034
+ }
1035
+
1036
+ async function handlePermAISuggest(req, res) {
1037
+ try {
1038
+ const body = await readBody(req);
1039
+ const query = (body.query || '').toLowerCase().trim();
1040
+ if (!query) return jsonResponse(res, 400, { error: 'No query provided' });
1041
+
1042
+ // Exception mode: context is the parent rule we're adding exceptions to
1043
+ const parentRule = body.parentRule || null;
1044
+ if (parentRule) {
1045
+ return handlePermAIException(res, query, parentRule, body);
1046
+ }
1047
+
1048
+ // Use LLM to interpret the natural language permission request
1049
+ const result = await classifyPermissionIntent(body.query || '');
1050
+ jsonResponse(res, 200, result);
1051
+ } catch (e) {
1052
+ jsonResponse(res, 500, { error: e.message });
1053
+ }
1054
+ }
1055
+
1056
+ async function classifyPermissionIntent(query) {
1057
+ const prompt = `You are a Claude Code permission rule generator. Given a user's natural language request, output a JSON object with:
1058
+ - "listType": "allow" or "deny" (does the user want to ALLOW or BLOCK/DENY this action?)
1059
+ - "rules": array of Claude Code permission rule strings
1060
+ - "explanation": short explanation of what the rules do
1061
+
1062
+ Claude Code permission rule syntax:
1063
+ - Bash(command:*) - match any bash command starting with "command" (e.g. Bash(git:*) matches all git commands)
1064
+ - Bash(git push:*) - match "git push" with any args
1065
+ - Bash(git push) - match bare "git push" with no args
1066
+ - Bash(*) - match ALL bash commands
1067
+ - Read - allow reading files
1068
+ - Edit - allow editing files
1069
+ - Write - allow creating/writing files
1070
+ - Grep - allow searching file contents
1071
+ - Glob - allow finding files by name
1072
+ - Agent - allow sub-agents
1073
+ - WebSearch - allow web search
1074
+ - WebFetch - allow fetching URLs
1075
+ - mcp__<server>__* - allow all tools from an MCP server (e.g. mcp__sendgrid__*, mcp__plugin_slack_slack__*)
1076
+
1077
+ Deny intent indicators: "stop", "block", "deny", "prevent", "no", "never", "don't allow", "disable", "forbid", "ban"
1078
+ Allow intent indicators: "allow", "enable", "approve", "let", "permit", "auto-approve"
1079
+
1080
+ For deny rules, be SPECIFIC (e.g. "no push" -> Bash(git push:*), NOT Bash(git:*)).
1081
+ For allow rules, match the scope the user requests.
1082
+
1083
+ IMPORTANT: Output ONLY the JSON object, no markdown fencing.
1084
+
1085
+ User request: "${query.replace(/"/g, '\\"')}"`;
1086
+
1087
+ try {
1088
+ const result = await callClaude([{ role: 'user', content: prompt }], 256);
1089
+ const text = result.content?.[0]?.text || '';
1090
+ const parsed = JSON.parse(text);
1091
+ return {
1092
+ rules: Array.isArray(parsed.rules) ? parsed.rules : [],
1093
+ explanation: parsed.explanation || '',
1094
+ listType: parsed.listType === 'deny' ? 'deny' : 'allow',
1095
+ };
1096
+ } catch (e) {
1097
+ // Fallback: treat as raw rule if it looks like one
1098
+ return { rules: [], explanation: `Failed to interpret: ${e.message}`, listType: 'allow' };
1099
+ }
1100
+ }
1101
+
1102
+ function handlePermAIException(res, query, parentRule, body) {
1103
+ const rules = [];
1104
+ let explanation = '';
1105
+
1106
+ // Extract the binary from the parent rule (e.g., "git" from "Bash(git:*)")
1107
+ const parentMatch = parentRule.match(/^Bash\(([a-zA-Z0-9_./-]+)/);
1108
+ const parentBin = parentMatch ? parentMatch[1] : null;
1109
+
1110
+ // Check if it looks like a direct rule pattern already
1111
+ if (/^Bash\(/.test(body.query || '')) {
1112
+ rules.push(body.query.trim());
1113
+ explanation = `Add deny rule: ${body.query.trim()}`;
1114
+ return jsonResponse(res, 200, { rules, explanation });
1115
+ }
1116
+
1117
+ if (parentBin) {
1118
+ // Common dangerous subcommand patterns
1119
+ const dangerousPatterns = {
1120
+ 'git': {
1121
+ 'force push': [`Bash(git push --force:*)`, `Bash(git push -f:*)`],
1122
+ 'force': [`Bash(git push --force:*)`, `Bash(git push -f:*)`],
1123
+ 'reset hard': [`Bash(git reset --hard:*)`],
1124
+ 'clean': [`Bash(git clean -f:*)`, `Bash(git clean -fd:*)`],
1125
+ 'delete branch': [`Bash(git branch -D:*)`, `Bash(git branch -d:*)`],
1126
+ 'push': [`Bash(git push:*)`],
1127
+ 'checkout discard': [`Bash(git checkout -- :*)`],
1128
+ 'rebase': [`Bash(git rebase:*)`],
1129
+ },
1130
+ 'rm': {
1131
+ 'recursive': [`Bash(rm -rf:*)`, `Bash(rm -r:*)`],
1132
+ 'force': [`Bash(rm -f:*)`, `Bash(rm -rf:*)`],
1133
+ },
1134
+ 'docker': {
1135
+ 'prune': [`Bash(docker system prune:*)`, `Bash(docker image prune:*)`],
1136
+ 'remove': [`Bash(docker rm:*)`, `Bash(docker rmi:*)`],
1137
+ },
1138
+ 'kubectl': {
1139
+ 'delete': [`Bash(kubectl delete:*)`],
1140
+ 'exec': [`Bash(kubectl exec:*)`],
1141
+ },
1142
+ 'npm': {
1143
+ 'publish': [`Bash(npm publish:*)`],
1144
+ },
1145
+ };
1146
+
1147
+ // Try to match known dangerous patterns
1148
+ const binPatterns = dangerousPatterns[parentBin] || {};
1149
+ for (const [key, denyRules] of Object.entries(binPatterns)) {
1150
+ if (query.includes(key)) {
1151
+ rules.push(...denyRules);
1152
+ explanation = `Deny ${key} operations for ${parentBin}.`;
1153
+ }
1154
+ }
1155
+
1156
+ // If no pattern matched, try to extract subcommand from the query
1157
+ if (rules.length === 0) {
1158
+ // Remove common filler words
1159
+ const cleaned = query.replace(/\b(don't|dont|do not|no|never|block|deny|prevent|forbid|disallow|except|exclude|ban)\b/g, '').trim();
1160
+ const subWords = cleaned.split(/\s+/).filter(w => w.length > 1 && !/^(the|and|or|a|an|for|to|of|in|with|that|this|from|all|any)$/.test(w));
1161
+
1162
+ if (subWords.length > 0) {
1163
+ const subCmd = subWords.join(' ');
1164
+ rules.push(`Bash(${parentBin} ${subCmd}:*)`);
1165
+ explanation = `Deny "${parentBin} ${subCmd}" commands.`;
1166
+ } else {
1167
+ explanation = `Couldn't parse an exception from "${body.query}". Try specifying a subcommand like "push --force" or "reset --hard".`;
1168
+ }
1169
+ }
1170
+ } else {
1171
+ // Non-Bash rule — just use the raw query as a deny pattern
1172
+ const cleaned = query.replace(/\b(don't|dont|do not|no|never|block|deny|prevent|forbid|disallow|except|exclude|ban)\b/g, '').trim();
1173
+ if (cleaned) {
1174
+ rules.push(cleaned);
1175
+ explanation = `Deny: ${cleaned}`;
1176
+ } else {
1177
+ explanation = `Couldn't parse an exception. Try a specific pattern.`;
1178
+ }
1179
+ }
1180
+
1181
+ jsonResponse(res, 200, { rules, explanation });
1182
+ }
1183
+
1184
+ function handleGetAlwaysAsk(req, res) {
1185
+ const tools = db.getAlwaysAskTools();
1186
+ jsonResponse(res, 200, { tools });
1187
+ }
1188
+
1189
+ async function handleSetAlwaysAsk(req, res) {
1190
+ try {
1191
+ const body = await readBody(req);
1192
+ const { tool, alwaysAsk } = body;
1193
+ if (!tool) { jsonResponse(res, 400, { error: 'Missing tool' }); return; }
1194
+ db.setAlwaysAsk(tool, !!alwaysAsk);
1195
+ jsonResponse(res, 200, { ok: true });
1196
+ } catch (e) {
1197
+ jsonResponse(res, 500, { error: e.message });
1198
+ }
1199
+ }
1200
+
1201
+ function handleGetPermCache(req, res) {
1202
+ // Return perm_rules as cache (unified table)
1203
+ const allRules = db.listPermRules({ listType: 'allow' });
1204
+ const cache = allRules.map(r => ({
1205
+ id: r.id,
1206
+ tool_name: r.rule,
1207
+ project_path: r.project,
1208
+ always_ask: r.always_ask || 0,
1209
+ synced_at: r.created_at,
1210
+ }));
1211
+ const alwaysAsk = db.getAlwaysAskTools();
1212
+ jsonResponse(res, 200, { cache, alwaysAsk });
1213
+ }
1214
+
1215
+ function handleGetScanCache(req, res) {
1216
+ const cached = db.getSetting('perm_scan_cache', null);
1217
+ if (cached) {
1218
+ jsonResponse(res, 200, cached);
1219
+ } else {
1220
+ jsonResponse(res, 200, { tools: [], scannedFiles: 0 });
1221
+ }
1222
+ }
1223
+
1224
+ function handleScanToolUsage(req, res) {
1225
+ // Scan session JSONL files and extract tool usage, then generalize into rules
1226
+ const { getAllSessionFiles } = require('./session-utils');
1227
+ const allFiles = getAllSessionFiles();
1228
+ const toolUsage = {}; // tool_name -> { count, commands: Map<binary, Set<subcmds>>, projects: Set }
1229
+
1230
+ const recentFiles = allFiles
1231
+ .map(f => ({ ...f, mtime: fs.statSync(f.filePath).mtime }))
1232
+ .sort((a, b) => b.mtime - a.mtime)
1233
+ .slice(0, 50);
1234
+
1235
+ for (const { filePath, projectPath } of recentFiles) {
1236
+ try {
1237
+ const content = fs.readFileSync(filePath, 'utf8');
1238
+ for (const line of content.split('\n')) {
1239
+ if (!line.includes('tool_use')) continue;
1240
+ try {
1241
+ const obj = JSON.parse(line);
1242
+ const msg = obj.message;
1243
+ if (!msg || !Array.isArray(msg.content)) continue;
1244
+ for (const block of msg.content) {
1245
+ if (block.type !== 'tool_use') continue;
1246
+ const name = block.name;
1247
+ if (!name) continue;
1248
+ if (!toolUsage[name]) toolUsage[name] = { count: 0, commands: new Map(), projects: new Set() };
1249
+ toolUsage[name].count++;
1250
+ toolUsage[name].projects.add(projectPath);
1251
+ const inp = block.input || {};
1252
+ if (inp.command) {
1253
+ // Extract the first pipeline segment, get binary + subcommand
1254
+ const firstCmd = inp.command.trim().split(/[|;&\n]/)[0].trim();
1255
+ const parts = firstCmd.split(/\s+/);
1256
+ let binary = parts[0] || '';
1257
+ // Skip noise: comments, env vars, cd, shell builtins
1258
+ if (/^[#$]/.test(binary) || ['cd', 'echo', 'export', 'source', 'true', 'false', 'set', 'unset', 'if', 'then', 'else', 'fi', 'for', 'do', 'done', 'while', 'test', '[', '[['].includes(binary)) binary = '';
1259
+ // Handle env-prefixed commands: VAR=val command -> command
1260
+ if (binary && /^[A-Z_]+=/.test(binary)) {
1261
+ const envIdx = parts.findIndex(p => !/^[A-Z_]+=/.test(p));
1262
+ if (envIdx > 0) binary = parts[envIdx] || '';
1263
+ else binary = ''; // pure env assignment, skip
1264
+ }
1265
+ // Skip strings that look like tokens, hashes, or paths with = signs
1266
+ if (/[0-9a-f]{16,}/.test(binary) || binary.includes('=')) binary = '';
1267
+ const binIdx = parts.indexOf(binary);
1268
+ const subcmd = binIdx >= 0 ? parts.slice(binIdx + 1, binIdx + 3).join(' ') : '';
1269
+ if (binary && /^[a-zA-Z]/.test(binary)) {
1270
+ if (!toolUsage[name].commands.has(binary)) {
1271
+ toolUsage[name].commands.set(binary, new Set());
1272
+ }
1273
+ if (subcmd) toolUsage[name].commands.get(binary).add(subcmd);
1274
+ }
1275
+ }
1276
+ }
1277
+ } catch {}
1278
+ }
1279
+ } catch {}
1280
+ }
1281
+
1282
+ // Read existing rules from SQLite (source of truth)
1283
+ const existingRules = new Set();
1284
+ for (const r of db.listPermRules({ listType: 'allow' })) {
1285
+ existingRules.add(r.rule);
1286
+ }
1287
+
1288
+ // Build generalized suggestions
1289
+ const result = Object.entries(toolUsage)
1290
+ .map(([name, data]) => {
1291
+ const suggestions = [];
1292
+
1293
+ if (name === 'Bash' && data.commands.size > 0) {
1294
+ // Group Bash commands by binary and suggest generalized rules
1295
+ for (const [binary, subcmds] of data.commands) {
1296
+ const broadRule = `Bash(${binary}:*)`;
1297
+ const altBroadRule = `Bash(${binary} *)`;
1298
+ // Check if any existing rule already covers this binary
1299
+ const coveredBy = Array.from(existingRules).find(r => {
1300
+ if (r === 'Bash' || r === 'Bash(*)') return true;
1301
+ if (r === broadRule || r === altBroadRule) return true;
1302
+ // Check patterns like Bash(git:*) or Bash(git *)
1303
+ const m = r.match(/^Bash\((.+?)[\s:]\*\)$/);
1304
+ if (m && m[1] === binary) return true;
1305
+ return false;
1306
+ });
1307
+ suggestions.push({
1308
+ rule: broadRule,
1309
+ label: binary,
1310
+ subcmds: Array.from(subcmds).slice(0, 8),
1311
+ count: subcmds.size + 1,
1312
+ covered: !!coveredBy,
1313
+ coveredBy: coveredBy || null,
1314
+ });
1315
+ }
1316
+ // Sort: uncovered first, then by subcommand count
1317
+ suggestions.sort((a, b) => (a.covered - b.covered) || (b.count - a.count));
1318
+ } else {
1319
+ // Non-Bash tools: just check if the tool name is already approved
1320
+ const covered = existingRules.has(name);
1321
+ suggestions.push({
1322
+ rule: name,
1323
+ label: name,
1324
+ subcmds: [],
1325
+ count: data.count,
1326
+ covered,
1327
+ coveredBy: covered ? name : null,
1328
+ });
1329
+ }
1330
+
1331
+ return {
1332
+ name,
1333
+ count: data.count,
1334
+ suggestions,
1335
+ projects: Array.from(data.projects),
1336
+ };
1337
+ })
1338
+ .sort((a, b) => b.count - a.count);
1339
+
1340
+ // Cache scan results in DB
1341
+ const scanData = { tools: result, scannedFiles: recentFiles.length, scannedAt: new Date().toISOString() };
1342
+ db.setSetting('perm_scan_cache', scanData);
1343
+
1344
+ jsonResponse(res, 200, scanData);
1345
+ }
1346
+
1347
+ // --- Auto Approvals ---
1348
+ function handleListAutoApprovals(req, res) {
1349
+ jsonResponse(res, 200, { rules: db.listAutoApprovals() });
1350
+ }
1351
+
1352
+ async function handleUpsertAutoApproval(req, res) {
1353
+ try {
1354
+ const body = await readBody(req);
1355
+ db.upsertAutoApproval(body);
1356
+ jsonResponse(res, 200, { ok: true });
1357
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
1358
+ }
1359
+
1360
+ async function handleToggleAutoApproval(req, res) {
1361
+ try {
1362
+ const { id, enabled } = await readBody(req);
1363
+ db.toggleAutoApproval(id, enabled);
1364
+ jsonResponse(res, 200, { ok: true });
1365
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
1366
+ }
1367
+
1368
+ function handleDeleteAutoApproval(req, res, id) {
1369
+ db.deleteAutoApproval(id);
1370
+ jsonResponse(res, 200, { ok: true });
1371
+ }
1372
+
1373
+ async function handleScanAutoApprovals(req, res) {
1374
+ // Scan session JSONL files and find approval patterns:
1375
+ // assistant asks a question -> user responds with short affirmative
1376
+ const { getAllSessionFiles } = require('./session-utils');
1377
+ const allFiles = getAllSessionFiles();
1378
+ const recentFiles = allFiles
1379
+ .map(f => { try { return { ...f, mtime: fs.statSync(f.filePath).mtime }; } catch { return null; } })
1380
+ .filter(Boolean)
1381
+ .sort((a, b) => b.mtime - a.mtime)
1382
+ .slice(0, 200);
1383
+
1384
+ const AFFIRMATIVES = new Set([
1385
+ 'y', 'yes', 'yes please', 'yes, please', 'go ahead', 'proceed',
1386
+ 'ok', 'sure', 'do it', 'yep', 'yeah', 'confirm', 'approved',
1387
+ 'continue', 'looks good', 'lgtm', 'go for it',
1388
+ ]);
1389
+
1390
+ const approvalPairs = []; // { question, response, file }
1391
+
1392
+ for (const { filePath } of recentFiles) {
1393
+ try {
1394
+ const content = fs.readFileSync(filePath, 'utf8');
1395
+ const entries = [];
1396
+ for (const line of content.split('\n')) {
1397
+ if (!line) continue;
1398
+ try { entries.push(JSON.parse(line)); } catch {}
1399
+ }
1400
+
1401
+ for (let i = 0; i < entries.length; i++) {
1402
+ const obj = entries[i];
1403
+ if (obj.type !== 'user') continue;
1404
+ const msg = obj.message;
1405
+ if (!msg) continue;
1406
+ const ct = msg.content;
1407
+ let text = '';
1408
+ if (typeof ct === 'string') text = ct.trim();
1409
+ else if (Array.isArray(ct)) {
1410
+ text = ct.filter(c => c.type === 'text').map(c => c.text).join(' ').trim();
1411
+ }
1412
+ if (!text || text.length > 40) continue;
1413
+ if (!AFFIRMATIVES.has(text.toLowerCase().replace(/[!.,?]+$/, '').trim())) continue;
1414
+
1415
+ // Find preceding assistant message
1416
+ for (let j = i - 1; j >= Math.max(i - 5, 0); j--) {
1417
+ if (entries[j].type !== 'assistant') continue;
1418
+ const amsg = entries[j].message;
1419
+ if (!amsg) break;
1420
+ const act = amsg.content;
1421
+ let atext = '';
1422
+ if (typeof act === 'string') atext = act;
1423
+ else if (Array.isArray(act)) {
1424
+ atext = act.filter(c => c.type === 'text').map(c => c.text).join(' ');
1425
+ }
1426
+ if (!atext) break;
1427
+ // Get the last portion that likely contains the question
1428
+ const lastPart = atext.slice(-500).trim();
1429
+ if (lastPart) {
1430
+ approvalPairs.push({
1431
+ question: lastPart,
1432
+ response: text,
1433
+ file: path.basename(filePath, '.jsonl').slice(0, 12),
1434
+ });
1435
+ }
1436
+ break;
1437
+ }
1438
+ }
1439
+ } catch {}
1440
+ }
1441
+
1442
+ if (approvalPairs.length === 0) {
1443
+ jsonResponse(res, 200, { suggestions: [], scannedFiles: recentFiles.length });
1444
+ return;
1445
+ }
1446
+
1447
+ // Use Claude to categorize and propose auto-approval rules
1448
+ try {
1449
+ const samplePairs = approvalPairs.slice(0, 30);
1450
+ const prompt = `Analyze these approval interactions from Claude Code sessions. Each pair shows what Claude asked and the user's approval response.
1451
+
1452
+ ${samplePairs.map((p, i) => `${i + 1}. Claude asked (last part): "${p.question.slice(-300)}"
1453
+ User responded: "${p.response}"`).join('\n\n')}
1454
+
1455
+ Categorize these into distinct auto-approval SCENARIOS. For each scenario:
1456
+ 1. Give it a short label (e.g. "Commit changes", "Create file", "Run tests")
1457
+ 2. Give it a category (one of: "git", "file-ops", "code-changes", "testing", "deployment", "other")
1458
+ 3. Create a regex pattern that would match the assistant's question text (match the last line or key phrase). Use simple patterns.
1459
+ 4. Count how many of the ${samplePairs.length} examples match this scenario
1460
+
1461
+ Return ONLY a JSON array (no markdown fences) of objects:
1462
+ [{"label": "...", "category": "...", "pattern": "...", "count": N, "example": "short example of what Claude asks"}]
1463
+
1464
+ Merge similar scenarios. Aim for 3-10 distinct scenarios. Only include scenarios with count >= 1.`;
1465
+
1466
+ const result = await callClaude([{ role: 'user', content: prompt }], 2048);
1467
+ const text = result.content?.[0]?.text || result;
1468
+ const match = (typeof text === 'string' ? text : '').match(/\[[\s\S]*\]/);
1469
+ if (!match) {
1470
+ jsonResponse(res, 200, { suggestions: [], scannedFiles: recentFiles.length, rawPairs: approvalPairs.length });
1471
+ return;
1472
+ }
1473
+ const suggestions = JSON.parse(match[0]);
1474
+ // Add default response
1475
+ for (const s of suggestions) {
1476
+ s.response = 'yes';
1477
+ s.occurrences = s.count || 0;
1478
+ }
1479
+ jsonResponse(res, 200, { suggestions, scannedFiles: recentFiles.length, rawPairs: approvalPairs.length });
1480
+ } catch (e) {
1481
+ // Fallback: generate basic suggestions from raw pairs without AI
1482
+ const suggestions = generateFallbackSuggestions(approvalPairs);
1483
+ jsonResponse(res, 200, {
1484
+ suggestions,
1485
+ scannedFiles: recentFiles.length,
1486
+ rawPairs: approvalPairs.length,
1487
+ warning: 'AI categorization unavailable, using pattern matching: ' + e.message,
1488
+ });
1489
+ }
1490
+ }
1491
+
1492
+ function generateFallbackSuggestions(pairs) {
1493
+ // Categorize pairs by simple keyword matching on the question
1494
+ const categories = [
1495
+ { label: 'Commit changes', category: 'git', keywords: ['commit', 'shall i commit', 'go ahead and commit'], pattern: 'Shall I (go ahead and )?commit' },
1496
+ { label: 'Proceed with changes', category: 'code-changes', keywords: ['proceed', 'shall i proceed', 'should i proceed'], pattern: 'Shall I proceed' },
1497
+ { label: 'Create files', category: 'file-ops', keywords: ['create', 'shall i create', 'create the file', 'create these files'], pattern: 'Shall I create' },
1498
+ { label: 'Run tests', category: 'testing', keywords: ['run the test', 'run tests', 'execute test'], pattern: 'run (the )?tests?' },
1499
+ { label: 'Push changes', category: 'git', keywords: ['push', 'shall i push', 'go ahead and push'], pattern: 'Shall I (go ahead and )?push' },
1500
+ { label: 'Delete/remove', category: 'file-ops', keywords: ['delete', 'remove', 'shall i delete', 'shall i remove'], pattern: 'Shall I (delete|remove)' },
1501
+ { label: 'Install dependencies', category: 'deployment', keywords: ['install', 'npm install', 'pip install'], pattern: '(npm|pip|yarn) install' },
1502
+ { label: 'Continue task', category: 'other', keywords: ['continue', 'shall i continue', 'should i continue'], pattern: 'Shall I continue' },
1503
+ ];
1504
+
1505
+ const results = [];
1506
+ const unmatched = [];
1507
+
1508
+ for (const pair of pairs) {
1509
+ const q = pair.question.toLowerCase();
1510
+ let matched = false;
1511
+ for (const cat of categories) {
1512
+ if (cat.keywords.some(k => q.includes(k))) {
1513
+ cat._count = (cat._count || 0) + 1;
1514
+ cat._example = pair.question.slice(-100);
1515
+ matched = true;
1516
+ break;
1517
+ }
1518
+ }
1519
+ if (!matched) unmatched.push(pair);
1520
+ }
1521
+
1522
+ for (const cat of categories) {
1523
+ if (cat._count > 0) {
1524
+ results.push({
1525
+ label: cat.label,
1526
+ category: cat.category,
1527
+ pattern: cat.pattern,
1528
+ count: cat._count,
1529
+ occurrences: cat._count,
1530
+ response: 'yes',
1531
+ example: cat._example,
1532
+ });
1533
+ }
1534
+ }
1535
+
1536
+ // Group remaining unmatched by last sentence pattern
1537
+ if (unmatched.length > 0) {
1538
+ results.push({
1539
+ label: 'Other confirmations',
1540
+ category: 'other',
1541
+ pattern: '(Shall|Should) I',
1542
+ count: unmatched.length,
1543
+ occurrences: unmatched.length,
1544
+ response: 'yes',
1545
+ example: unmatched[0].question.slice(-100),
1546
+ });
1547
+ }
1548
+
1549
+ return results;
1550
+ }
1551
+
1552
+ // --- Prompt Queue ---
1553
+ function handleListQueues(req, res) {
1554
+ jsonResponse(res, 200, queueEngine.getAllStates());
1555
+ }
1556
+
1557
+ async function handleCreateQueue(req, res) {
1558
+ try {
1559
+ const body = await readBody(req);
1560
+ const { sessionId, mode, items, idleTimeoutMs, autoStart } = body;
1561
+ if (!sessionId || !Array.isArray(items) || items.length === 0) {
1562
+ return jsonResponse(res, 400, { error: 'sessionId and non-empty items[] required' });
1563
+ }
1564
+ let state = queueEngine.createQueue(sessionId, { mode, items, idleTimeoutMs });
1565
+ if (autoStart) {
1566
+ state = queueEngine.start(sessionId) || state;
1567
+ }
1568
+ jsonResponse(res, 201, state);
1569
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
1570
+ }
1571
+
1572
+ function handleGetQueue(req, res, sessionId) {
1573
+ const state = queueEngine.getState(sessionId);
1574
+ if (!state) return jsonResponse(res, 404, { error: 'No queue for this session' });
1575
+ jsonResponse(res, 200, state);
1576
+ }
1577
+
1578
+ function handleDeleteQueue(req, res, sessionId) {
1579
+ queueEngine.deleteQueue(sessionId);
1580
+ jsonResponse(res, 200, { ok: true });
1581
+ }
1582
+
1583
+ async function handleQueueAction(req, res, sessionId, action) {
1584
+ let state;
1585
+ switch (action) {
1586
+ case 'start': state = queueEngine.start(sessionId); break;
1587
+ case 'pause': state = queueEngine.pause(sessionId); break;
1588
+ case 'resume': state = queueEngine.resume(sessionId); break;
1589
+ case 'next': state = queueEngine.sendNext(sessionId); break;
1590
+ case 'skip': state = queueEngine.skip(sessionId); break;
1591
+ case 'stop': state = queueEngine.stop(sessionId); break;
1592
+ case 'mode': {
1593
+ try {
1594
+ const body = await readBody(req);
1595
+ state = queueEngine.setMode(sessionId, body.mode);
1596
+ } catch (e) { return jsonResponse(res, 400, { error: e.message }); }
1597
+ break;
1598
+ }
1599
+ }
1600
+ if (!state) return jsonResponse(res, 404, { error: 'No queue for this session' });
1601
+ jsonResponse(res, 200, state);
1602
+ }
1603
+
1604
+ // --- Queue-Prompt Linked Items (cross-session search) ---
1605
+
1606
+ function handleGetQueueLinkedItems(req, res, promptId) {
1607
+ const drafts = db.getSettingsByPrefix('queue_draft_');
1608
+ const linked = [];
1609
+ for (const { key, value } of drafts) {
1610
+ const sessionId = key.replace('queue_draft_', '');
1611
+ const items = value.items || [];
1612
+ // Use == for loose comparison (promptId may be string or number in stored JSON)
1613
+ const matches = items.filter(i => i.type === 'prompt' && i.promptId == promptId);
1614
+ if (matches.length > 0) {
1615
+ linked.push({ sessionId, count: matches.length });
1616
+ }
1617
+ }
1618
+ jsonResponse(res, 200, { promptId, linked, totalCount: linked.reduce((s, l) => s + l.count, 0) });
1619
+ }
1620
+
1621
+ function handleDeleteQueueLinkedItems(req, res, promptId) {
1622
+ const drafts = db.getSettingsByPrefix('queue_draft_');
1623
+ let totalRemoved = 0;
1624
+ for (const { key, value } of drafts) {
1625
+ const items = value.items || [];
1626
+ const filtered = items.filter(i => !(i.type === 'prompt' && i.promptId == promptId));
1627
+ if (filtered.length !== items.length) {
1628
+ totalRemoved += items.length - filtered.length;
1629
+ value.items = filtered;
1630
+ db.setSetting(key, value);
1631
+ }
1632
+ }
1633
+ jsonResponse(res, 200, { ok: true, removed: totalRemoved });
1634
+ }
1635
+
1636
+ // --- Queue Draft (per-session builder state persisted to DB) ---
1637
+
1638
+ function handleGetQueueDraft(req, res, sessionId) {
1639
+ const key = 'queue_draft_' + sessionId;
1640
+ const draft = db.getSetting(key, { items: [], mode: 'manual' });
1641
+ jsonResponse(res, 200, draft);
1642
+ }
1643
+
1644
+ async function handleSaveQueueDraft(req, res, sessionId) {
1645
+ const body = await readBody(req);
1646
+ const key = 'queue_draft_' + sessionId;
1647
+ const existing = db.getSetting(key, { items: [], mode: 'manual' });
1648
+ if (body.items !== undefined) existing.items = body.items;
1649
+ if (body.mode !== undefined) existing.mode = body.mode;
1650
+ db.setSetting(key, existing);
1651
+ jsonResponse(res, 200, { ok: true });
1652
+ }
1653
+
1654
+ function handleDeleteQueueDraft(req, res, sessionId) {
1655
+ const key = 'queue_draft_' + sessionId;
1656
+ db.setSetting(key, { items: [], mode: 'manual' });
1657
+ jsonResponse(res, 200, { ok: true });
1658
+ }
1659
+
1660
+ // --- Approval Rules (AI Agent) handlers ---
1661
+ function handleListApprovalRules(req, res) {
1662
+ jsonResponse(res, 200, {
1663
+ rules: db.listApprovalRules(),
1664
+ decisions: db.listApprovalDecisions({ limit: 20 }),
1665
+ pending: db.getPendingEscalations(),
1666
+ });
1667
+ }
1668
+
1669
+ async function handleUpsertApprovalRule(req, res) {
1670
+ try {
1671
+ const body = await readBody(req);
1672
+ db.upsertApprovalRule(body);
1673
+ jsonResponse(res, 200, { ok: true });
1674
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
1675
+ }
1676
+
1677
+ async function handleToggleApprovalRule(req, res) {
1678
+ try {
1679
+ const { id, enabled } = await readBody(req);
1680
+ db.toggleApprovalRule(id, enabled);
1681
+ jsonResponse(res, 200, { ok: true });
1682
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
1683
+ }
1684
+
1685
+ function handleDeleteApprovalRule(req, res, id) {
1686
+ db.deleteApprovalRule(id);
1687
+ jsonResponse(res, 200, { ok: true });
1688
+ }
1689
+
1690
+ function handleListApprovalDecisions(req, res) {
1691
+ const url = new URL(req.url, `http://${req.headers.host}`);
1692
+ const limit = parseInt(url.searchParams.get('limit')) || 50;
1693
+ const sessionId = url.searchParams.get('sessionId') || null;
1694
+ jsonResponse(res, 200, { decisions: db.listApprovalDecisions({ limit, sessionId }) });
1695
+ }
1696
+
1697
+ async function handleResolveApprovalDecision(req, res, id) {
1698
+ try {
1699
+ const { decision, sessionId } = await readBody(req);
1700
+ db.resolveApprovalDecision(id, decision);
1701
+
1702
+ // If user approved, send "1" to the session terminal
1703
+ if (decision === 'approved' && sessionId) {
1704
+ const { sessions } = require('./server-state');
1705
+ const session = sessions?.get(sessionId);
1706
+ if (session?.ptyProcess) {
1707
+ session.ptyProcess.write('1\n');
1708
+ }
1709
+ }
1710
+ // If user denied, send "2" to the session terminal
1711
+ if (decision === 'denied' && sessionId) {
1712
+ const { sessions } = require('./server-state');
1713
+ const session = sessions?.get(sessionId);
1714
+ if (session?.ptyProcess) {
1715
+ session.ptyProcess.write('2\n');
1716
+ }
1717
+ }
1718
+ jsonResponse(res, 200, { ok: true });
1719
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
1720
+ }
1721
+
1722
+ // --- Harvest & Autocomplete & Copilot ---
1723
+
1724
+ function handleHarvestPreview(req, res) {
1725
+ try {
1726
+ jsonResponse(res, 200, harvest.getHarvestPreview());
1727
+ } catch (e) { jsonResponse(res, 500, { error: e.message }); }
1728
+ }
1729
+
1730
+ async function handleRunHarvest(req, res) {
1731
+ try {
1732
+ const body = await readBody(req);
1733
+ const result = harvest.runHarvest({
1734
+ scope: body.scope || 'incremental',
1735
+ maxDays: body.maxDays,
1736
+ projectFilter: body.projectFilter,
1737
+ });
1738
+ harvest.invalidateAutocompleteCache();
1739
+ jsonResponse(res, 200, result);
1740
+ } catch (e) { jsonResponse(res, 500, { error: e.message }); }
1741
+ }
1742
+
1743
+ function handleAutocomplete(req, res, url) {
1744
+ const q = url.searchParams.get('q') || '';
1745
+ const limit = parseInt(url.searchParams.get('limit')) || 8;
1746
+ jsonResponse(res, 200, { results: harvest.searchAutocomplete(q, limit) });
1747
+ }
1748
+
1749
+ function handleSimilarPrompts(req, res, url) {
1750
+ const text = url.searchParams.get('text') || '';
1751
+ const limit = parseInt(url.searchParams.get('limit')) || 5;
1752
+ jsonResponse(res, 200, { results: harvest.findSimilarPrompts(text, limit) });
1753
+ }
1754
+
1755
+ function handleListPatterns(req, res) {
1756
+ try {
1757
+ const d = db.getDb();
1758
+ const patterns = d.prepare('SELECT * FROM prompt_patterns ORDER BY frequency DESC LIMIT 50').all();
1759
+ jsonResponse(res, 200, { patterns });
1760
+ } catch (e) { jsonResponse(res, 200, { patterns: [] }); }
1761
+ }
1762
+
1763
+ function handleDetectPatterns(req, res) {
1764
+ try {
1765
+ const patterns = harvest.detectPatterns();
1766
+ jsonResponse(res, 200, { patterns, count: patterns.length });
1767
+ } catch (e) { jsonResponse(res, 500, { error: e.message }); }
1768
+ }
1769
+
1770
+ async function handleCopilotSuggest(req, res) {
1771
+ try {
1772
+ const body = await readBody(req);
1773
+ const result = await harvest.getCopilotSuggestion(body.text || '', {
1774
+ projectPath: body.projectPath,
1775
+ promptId: body.promptId,
1776
+ });
1777
+ jsonResponse(res, 200, result || { suggestion: null });
1778
+ } catch (e) { jsonResponse(res, 500, { error: e.message }); }
1779
+ }
1780
+
1781
+ async function handleCopilotChat(req, res) {
1782
+ try {
1783
+ const body = await readBody(req);
1784
+ const result = await harvest.copilotChat(body.message || '', body.history || []);
1785
+ jsonResponse(res, 200, result);
1786
+ } catch (e) { jsonResponse(res, 500, { error: e.message }); }
1787
+ }
1788
+
1789
+ function handleListExecutions(req, res, url) {
1790
+ try {
1791
+ const d = db.getDb();
1792
+ const limit = parseInt(url.searchParams.get('limit')) || 50;
1793
+ const role = url.searchParams.get('role') || null;
1794
+ const project = url.searchParams.get('project') || null;
1795
+ let sql = 'SELECT * FROM prompt_executions WHERE 1=1';
1796
+ const params = [];
1797
+ if (role) { sql += ' AND role = ?'; params.push(role); }
1798
+ if (project) { sql += ' AND project_path LIKE ?'; params.push('%' + project + '%'); }
1799
+ sql += ' ORDER BY executed_at DESC LIMIT ?';
1800
+ params.push(limit);
1801
+ jsonResponse(res, 200, { executions: d.prepare(sql).all(...params) });
1802
+ } catch (e) { jsonResponse(res, 200, { executions: [] }); }
1803
+ }
1804
+
1805
+ function handleSessionExecutions(req, res, sessionId) {
1806
+ try {
1807
+ const result = harvest.getPromptsForSession(sessionId);
1808
+ jsonResponse(res, 200, result);
1809
+ } catch (e) { jsonResponse(res, 200, { executions: [], sentPrompts: [] }); }
1810
+ }
1811
+
1812
+ async function handleSetOutcome(req, res, id) {
1813
+ try {
1814
+ const body = await readBody(req);
1815
+ harvest.setExecutionOutcome(id, body.outcome, body.notes);
1816
+ jsonResponse(res, 200, { ok: true });
1817
+ } catch (e) { jsonResponse(res, 400, { error: e.message }); }
1818
+ }
1819
+
1820
+ function handlePromptSessions(req, res, promptId) {
1821
+ try {
1822
+ const sessions = harvest.getSessionsForPrompt(promptId);
1823
+ jsonResponse(res, 200, { sessions });
1824
+ } catch (e) { jsonResponse(res, 200, { sessions: [] }); }
1825
+ }
1826
+
1827
+ function handleFrequentQuestions(req, res, url) {
1828
+ try {
1829
+ const limit = parseInt(url.searchParams.get('limit')) || 20;
1830
+ jsonResponse(res, 200, { questions: harvest.getFrequentQuestions(limit) });
1831
+ } catch (e) { jsonResponse(res, 200, { questions: [] }); }
1832
+ }
1833
+
1834
+ function handleLifecycleRefresh(req, res) {
1835
+ try {
1836
+ const result = harvest.refreshLifecycleStatuses();
1837
+ jsonResponse(res, 200, result);
1838
+ } catch (e) { jsonResponse(res, 500, { error: e.message }); }
1839
+ }
1840
+
1841
+ module.exports = { handlePromptApi, queueEngine, importPermissionsToDb };