circuschief 0.1.4 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/package.json +1 -1
  2. package/packages/server/src/api/commands.js +50 -55
  3. package/packages/server/src/api/projects-helpers.js +13 -4
  4. package/packages/server/src/api/projects.js +33 -18
  5. package/packages/server/src/cli.js +82 -0
  6. package/packages/server/src/db/AgentCallLogRepository.js +30 -31
  7. package/packages/server/src/db/ConversationRepository.js +27 -16
  8. package/packages/server/src/db/ProjectRepository.js +21 -31
  9. package/packages/server/src/db/QuickResponseRepository.js +14 -19
  10. package/packages/server/src/db/migrations/sessionsMigrations.js +61 -61
  11. package/packages/server/src/index.js +42 -29
  12. package/packages/server/src/services/commandRunner.js +52 -99
  13. package/packages/server/src/services/kanbanTriggers.js +83 -56
  14. package/packages/server/src/services/schedulerService.js +68 -44
  15. package/packages/server/src/services/sessionExecution.js +102 -61
  16. package/packages/server/src/services/sessionManager.js +63 -37
  17. package/packages/server/src/services/summaryService.js +56 -53
  18. package/packages/server/src/services/templateTriggerService.js +58 -31
  19. package/packages/server/src/ws/WebSocketManager.js +5 -0
  20. package/packages/web/dist/assets/ActiveSessionsView-3697sD8N.js +1 -0
  21. package/packages/web/dist/assets/ActiveSessionsView-DfYXc6dz.css +1 -0
  22. package/packages/web/dist/assets/{AgentLogsView-c42v_j_5.js → AgentLogsView-D4l0N9ZA.js} +1 -1
  23. package/packages/web/dist/assets/{ArchiveConfirmModal-DBuOmtXu.js → ArchiveConfirmModal-Bv3vGOMM.js} +1 -1
  24. package/packages/web/dist/assets/{CommandButtonDetailView-CkKJ3Htz.js → CommandButtonDetailView-Bk_SHxpu.js} +1 -1
  25. package/packages/web/dist/assets/{EffortLevelSelector-BHJHSqul.js → EffortLevelSelector-VfBEelvO.js} +1 -1
  26. package/packages/web/dist/assets/{GeneralSettingsView-CdxfteZ2.js → GeneralSettingsView-BqCzCX-z.js} +1 -1
  27. package/packages/web/dist/assets/{InterpolationHelp-DabnHhE4.js → InterpolationHelp-Dc1Y0T6v.js} +1 -1
  28. package/packages/web/dist/assets/MarkdownEditor-DwBQkZbs.js +2 -0
  29. package/packages/web/dist/assets/{ModelSelector-BWIU4ud7.js → ModelSelector-DSxaZWBL.js} +1 -1
  30. package/packages/web/dist/assets/{NewSessionView-BIZl8QlH.js → NewSessionView-BsI7JtO9.js} +2 -2
  31. package/packages/web/dist/assets/{PathChooser-nhat_Pz4.js → PathChooser-CXFxb8Oj.js} +1 -1
  32. package/packages/web/dist/assets/{ProjectEditView-DD-2_VrW.js → ProjectEditView-Bes4Mib4.js} +1 -1
  33. package/packages/web/dist/assets/{ProjectListView-BOWbfoXQ.js → ProjectListView-DzEu-C36.js} +1 -1
  34. package/packages/web/dist/assets/{ProjectNewView-DC4uvSn2.js → ProjectNewView-Cv-iEAgl.js} +1 -1
  35. package/packages/web/dist/assets/ProvidersView-CgAr0qms.js +1 -0
  36. package/packages/web/dist/assets/{QuickResponseSettings-Bk9mq96x.js → QuickResponseSettings-uDDpwaza.js} +1 -1
  37. package/packages/web/dist/assets/{QuickResponsesPanel-BRvcnkQr.js → QuickResponsesPanel-D0qs0Fm_.js} +1 -1
  38. package/packages/web/dist/assets/{ResizableTextarea-CwGM4P3c.js → ResizableTextarea-_kHi1Mg3.js} +1 -1
  39. package/packages/web/dist/assets/{SessionCard-BGDVHU9u.js → SessionCard-Be1-bK0C.js} +1 -1
  40. package/packages/web/dist/assets/{SessionCard-D20G3bX8.css → SessionCard-CcqIjL8q.css} +1 -1
  41. package/packages/web/dist/assets/{SessionDetailView-CHYrx2Ab.js → SessionDetailView-DUYb7qTA.js} +17 -17
  42. package/packages/web/dist/assets/{SessionDetailView-7bWgC7Es.css → SessionDetailView-mnGRMaLY.css} +1 -1
  43. package/packages/web/dist/assets/{SessionFormOptions-8qvL25ca.js → SessionFormOptions-DvhOyP6z.js} +1 -1
  44. package/packages/web/dist/assets/{SessionListView-BAIBtJF7.css → SessionListView-78k6TTz6.css} +1 -1
  45. package/packages/web/dist/assets/SessionListView-CuHsWj85.js +1 -0
  46. package/packages/web/dist/assets/{SessionLogStream-B-w3n4c3.js → SessionLogStream-Da_GniUZ.js} +1 -1
  47. package/packages/web/dist/assets/{SettingsView-Dd0ZJ4Nv.js → SettingsView-5RDCXNUa.js} +1 -1
  48. package/packages/web/dist/assets/{SlashCommandWizard-CzyLjsdJ.js → SlashCommandWizard-B_8ifpxN.js} +1 -1
  49. package/packages/web/dist/assets/{SummarySettingsView-DTbh7uAF.js → SummarySettingsView-KvgSGHdd.js} +1 -1
  50. package/packages/web/dist/assets/{TemplateDetailView-BOnhkdtH.js → TemplateDetailView-BhOjYIvS.js} +1 -1
  51. package/packages/web/dist/assets/{commandButtons-CY87n64i.js → commandButtons-B4OYZP0J.js} +1 -1
  52. package/packages/web/dist/assets/{index-DxboI9i-.js → index-80Qu7W6P.js} +1 -1
  53. package/packages/web/dist/assets/{index-NzLFVaCi.js → index-B8_Iqwcq.js} +1 -1
  54. package/packages/web/dist/assets/{index-aCw-iXPX.js → index-B9JErft2.js} +1 -1
  55. package/packages/web/dist/assets/{index-Ce6sL47U.js → index-BHVnr8MO.js} +1 -1
  56. package/packages/web/dist/assets/{index-BXUcbV4K.js → index-BarVnQIj.js} +1 -1
  57. package/packages/web/dist/assets/{index--OtPwBbF.js → index-BqVgX_Jy.js} +3 -3
  58. package/packages/web/dist/assets/{index-gMpnPf1V.js → index-BsvRdU0B.js} +1 -1
  59. package/packages/web/dist/assets/{index-BRUlEEHm.js → index-Bugg2M-E.js} +1 -1
  60. package/packages/web/dist/assets/{index-Dx0sYW7H.js → index-C2Pjy-M8.js} +1 -1
  61. package/packages/web/dist/assets/{index-DkLkDgig.js → index-CSOPrlmq.js} +23 -23
  62. package/packages/web/dist/assets/{index-CO4EBOFw.js → index-CS_wb_Vj.js} +1 -1
  63. package/packages/web/dist/assets/{index-CjHb9rXv.js → index-ClzNIdCp.js} +1 -1
  64. package/packages/web/dist/assets/{index-DPwwgloE.js → index-Cn9Ajkye.js} +1 -1
  65. package/packages/web/dist/assets/{index-i1o916sk.js → index-CucpVX4L.js} +1 -1
  66. package/packages/web/dist/assets/{index-C6m-WfqP.js → index-D9hZYvW3.js} +1 -1
  67. package/packages/web/dist/assets/{index-jGjvGBfk.js → index-DA0dK_PG.js} +1 -1
  68. package/packages/web/dist/assets/{index-Bi4bQ_UB.js → index-DgpSn-jR.js} +1 -1
  69. package/packages/web/dist/assets/{index-DcA6pqXV.js → index-HZwIyC9t.js} +1 -1
  70. package/packages/web/dist/assets/{index-BshkV3r5.js → index-SS3wA2sI.js} +1 -1
  71. package/packages/web/dist/assets/{projects-C2Y29PSJ.js → projects-B2du-GX8.js} +1 -1
  72. package/packages/web/dist/assets/{providers-CeJXuo0Q.js → providers-B__J6FX0.js} +1 -1
  73. package/packages/web/dist/assets/sessions-VDrd87yA.js +1 -0
  74. package/packages/web/dist/assets/{settings-BplIxCbi.js → settings-CZ7Pc-Pt.js} +1 -1
  75. package/packages/web/dist/index.html +1 -1
  76. package/packages/web/dist/assets/ActiveSessionsView-BryJ-V3f.js +0 -1
  77. package/packages/web/dist/assets/ActiveSessionsView-ofSvx-K1.css +0 -1
  78. package/packages/web/dist/assets/MarkdownEditor-k4zBLGqU.js +0 -2
  79. package/packages/web/dist/assets/ProvidersView-DT5afh1V.js +0 -1
  80. package/packages/web/dist/assets/SessionListView-927Yq6Il.js +0 -1
  81. package/packages/web/dist/assets/sessions-CMby7ij3.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circuschief",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "Local-first web UI for managing Claude Code sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -71,6 +71,49 @@ router.get('/:name', async (req, res) => {
71
71
  * - sessionId: Session to execute command in (required)
72
72
  * - args: Object with argument values keyed by argument name (optional)
73
73
  */
74
+ /**
75
+ * Handle execution of a skill invocation (skill body -> system prompt, args -> user message).
76
+ * Returns the response payload, or null if the command is not a skill.
77
+ */
78
+ async function handleSkillExecution({ workingDirectory, name, args, sessionId, project }) {
79
+ const skillInvocation = await slashCommandService.buildSkillInvocation(
80
+ workingDirectory, name, args
81
+ );
82
+
83
+ if (!skillInvocation) return null;
84
+
85
+ const skillSystemPrompt = slashCommandService.buildSkillSystemPrompt(
86
+ project.systemPrompt || null, skillInvocation
87
+ );
88
+ const userMessage = slashCommandService.buildSkillUserMessage(skillInvocation);
89
+
90
+ continueSession(sessionId, userMessage, workingDirectory, {
91
+ systemPrompt: skillSystemPrompt,
92
+ }).catch(err => {
93
+ console.error('Error executing skill:', err);
94
+ });
95
+
96
+ return { success: true, command: name, message: userMessage };
97
+ }
98
+
99
+ /**
100
+ * Handle execution of a non-skill slash command.
101
+ * Returns the response payload.
102
+ */
103
+ async function handleCommandExecution({ workingDirectory, name, args, sessionId, project }) {
104
+ const commandString = await slashCommandService.buildCommandString(
105
+ workingDirectory, name, args
106
+ );
107
+
108
+ continueSession(sessionId, commandString, workingDirectory, {
109
+ systemPrompt: project.systemPrompt || null,
110
+ }).catch(err => {
111
+ console.error('Error executing command:', err);
112
+ });
113
+
114
+ return { success: true, command: name, message: commandString };
115
+ }
116
+
74
117
  router.post('/:name/execute', async (req, res) => {
75
118
  const { name } = req.params;
76
119
  const { sessionId, args = {} } = req.body;
@@ -103,63 +146,15 @@ router.post('/:name/execute', async (req, res) => {
103
146
  }
104
147
 
105
148
  try {
106
- // Check if this is a skill — skills need special handling:
107
- // skill body system prompt, user args user message
108
- const skillInvocation = await slashCommandService.buildSkillInvocation(
109
- workingDirectory,
110
- name,
111
- args
112
- );
113
-
114
- if (skillInvocation) {
115
- // Skill: inject body as system prompt context, args as user message
116
- const skillSystemPrompt = slashCommandService.buildSkillSystemPrompt(
117
- project.systemPrompt || null,
118
- skillInvocation
119
- );
120
- const userMessage = slashCommandService.buildSkillUserMessage(skillInvocation);
121
-
122
- continueSession(
123
- sessionId,
124
- userMessage,
125
- workingDirectory,
126
- { systemPrompt: skillSystemPrompt } // No file attachments for slash commands
127
- ).catch(err => {
128
- console.error('Error executing skill:', err);
129
- });
130
-
131
- res.json({
132
- success: true,
133
- command: name,
134
- message: userMessage,
135
- });
136
- return;
149
+ // Check if this is a skill — skills need special handling
150
+ const skillResult = await handleSkillExecution({ workingDirectory, name, args, sessionId, project });
151
+ if (skillResult) {
152
+ return res.json(skillResult);
137
153
  }
138
154
 
139
- // Non-skill command: build command string and send as user message
140
- const commandString = await slashCommandService.buildCommandString(
141
- workingDirectory,
142
- name,
143
- args
144
- );
145
-
146
- // Use continueSession to send the command
147
- // This handles the full message flow including broadcasting to WebSocket
148
- continueSession(
149
- sessionId,
150
- commandString,
151
- workingDirectory,
152
- { systemPrompt: project.systemPrompt || null } // No file attachments for slash commands
153
- ).catch(err => {
154
- // Log but don't throw - continueSession runs asynchronously
155
- console.error('Error executing command:', err);
156
- });
157
-
158
- res.json({
159
- success: true,
160
- command: name,
161
- message: commandString,
162
- });
155
+ // Non-skill command
156
+ const cmdResult = await handleCommandExecution({ workingDirectory, name, args, sessionId, project });
157
+ res.json(cmdResult);
163
158
  } catch (err) {
164
159
  console.error('Error executing command:', err);
165
160
  res.status(500).json({ error: err.message });
@@ -1,19 +1,28 @@
1
1
  import { isGitRepo } from '../services/gitService.js';
2
2
 
3
3
  /**
4
- * Validate that git projects have required gitMode and gitBranch settings.
4
+ * Validate and default git settings for git-backed projects.
5
+ * If gitMode or gitBranch are missing for a git project, defaults are applied
6
+ * (gitMode: 'none', gitBranch: 'main') instead of rejecting the request.
5
7
  * @param {Object} config - The session configuration
6
8
  * @param {Object} project - The project object
7
- * @returns {Promise<string|null>} Error message if validation fails, null otherwise.
9
+ * @returns {Promise<{config: Object, error: string|null}>} Updated config and error message if validation fails.
8
10
  */
9
11
  export async function validateGitSettings(config, project) {
10
12
  if (!config.gitMode || !config.gitBranch) {
11
13
  const isGit = await isGitRepo(project.workingDirectory);
12
14
  if (isGit) {
13
- return 'Git projects require both gitMode and gitBranch. Set project defaults or provide them per-session.';
15
+ return {
16
+ config: {
17
+ ...config,
18
+ gitMode: config.gitMode || 'none',
19
+ gitBranch: config.gitBranch || 'main',
20
+ },
21
+ error: null,
22
+ };
14
23
  }
15
24
  }
16
- return null;
25
+ return { config, error: null };
17
26
  }
18
27
 
19
28
  /**
@@ -151,38 +151,53 @@ router.get('/:id/sessions', (req, res) => {
151
151
  }
152
152
  });
153
153
 
154
- // POST /api/projects/:id/sessions - Create session
155
- // Supports both JSON and multipart/form-data (for file attachments)
156
- router.post('/:id/sessions', uploadMiddleware('files', 10), handleUploadError, async (req, res) => {
157
- const project = projects.getById(req.params.id);
158
- if (!project) {
159
- return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
160
- }
161
-
162
- const projectDefs = projectDefaults.getByProjectId(req.params.id);
154
+ /**
155
+ * Validate and prepare the session configuration from the request body.
156
+ * Returns { config, nextTemplateId } on success, or { error, status } on failure.
157
+ */
158
+ async function validateAndPrepareSessionConfig(reqBody, reqFiles, projectId, project) {
159
+ const projectDefs = projectDefaults.getByProjectId(projectId);
163
160
  const systemDefaults = ProjectDefaultsRepository.getSystemDefaults();
164
- const config = prepareSessionConfig(req.body, projectDefs, systemDefaults);
165
- config.files = req.files || [];
161
+ const config = prepareSessionConfig(reqBody, projectDefs, systemDefaults);
162
+ config.files = reqFiles || [];
166
163
 
167
164
  if (!config.prompt) {
168
- return res.status(400).json({ error: 'Prompt is required' });
165
+ return { error: 'Prompt is required', status: 400 };
169
166
  }
170
167
 
171
168
  // Apply template overrides and resolve nextTemplateId
172
169
  applyTemplateOverrides(config);
173
- const { nextTemplateId, error: nextTemplateError } = resolveNextTemplateId(req.body, config.nextTemplateId || null);
170
+ const { nextTemplateId, error: nextTemplateError } = resolveNextTemplateId(reqBody, config.nextTemplateId || null);
174
171
  if (nextTemplateError) {
175
- return res.status(400).json({ error: nextTemplateError });
172
+ return { error: nextTemplateError, status: 400 };
176
173
  }
177
174
  config.nextTemplateId = nextTemplateId;
178
175
 
179
- const initialStatus = determineInitialStatus(config);
180
-
181
176
  // Validate git settings for git repos
182
- const gitError = await validateGitSettings(config, project);
177
+ const { config: updatedConfig, error: gitError } = await validateGitSettings(config, project);
183
178
  if (gitError) {
184
- return res.status(400).json({ error: gitError });
179
+ return { error: gitError, status: 400 };
185
180
  }
181
+ Object.assign(config, updatedConfig);
182
+
183
+ return { config, nextTemplateId };
184
+ }
185
+
186
+ // POST /api/projects/:id/sessions - Create session
187
+ // Supports both JSON and multipart/form-data (for file attachments)
188
+ router.post('/:id/sessions', uploadMiddleware('files', 10), handleUploadError, async (req, res) => {
189
+ const project = projects.getById(req.params.id);
190
+ if (!project) {
191
+ return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
192
+ }
193
+
194
+ const prepared = await validateAndPrepareSessionConfig(req.body, req.files, req.params.id, project);
195
+ if (prepared.error) {
196
+ return res.status(prepared.status).json({ error: prepared.error });
197
+ }
198
+
199
+ const { config, nextTemplateId } = prepared;
200
+ const initialStatus = determineInitialStatus(config);
186
201
 
187
202
  const sessionName = config.name || generateInitialName(config.prompt);
188
203
  const session = sessions.create(req.params.id, sessionName, config.prompt, {
@@ -0,0 +1,82 @@
1
+ import { parseArgs } from 'node:util';
2
+ import { readFileSync } from 'fs';
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+ import { DEFAULT_SERVER_PORT } from '../../shared/src/index.js';
6
+
7
+ const __filename = fileURLToPath(import.meta.url);
8
+ const __dirname = dirname(__filename);
9
+
10
+ function showHelp() {
11
+ console.log(`Usage: circuschief [options]
12
+
13
+ Options:
14
+ -p, --port <number> Port to listen on (env: PORT, default: ${DEFAULT_SERVER_PORT})
15
+ --no-analytics Disable anonymous usage analytics
16
+ -h, --help Show this help message
17
+ -V, --version Show version number`);
18
+ }
19
+
20
+ function getVersion() {
21
+ try {
22
+ const pkg = JSON.parse(
23
+ readFileSync(join(__dirname, '../package.json'), 'utf-8')
24
+ );
25
+ return pkg.version;
26
+ } catch {
27
+ return 'unknown';
28
+ }
29
+ }
30
+
31
+ export function parseCliOptions(argv = process.argv) {
32
+ let values;
33
+ try {
34
+ ({ values } = parseArgs({
35
+ args: argv.slice(2),
36
+ strict: true,
37
+ options: {
38
+ port: {
39
+ type: 'string',
40
+ short: 'p',
41
+ default: process.env.PORT || String(DEFAULT_SERVER_PORT),
42
+ },
43
+ help: {
44
+ type: 'boolean',
45
+ short: 'h',
46
+ default: false,
47
+ },
48
+ version: {
49
+ type: 'boolean',
50
+ short: 'V',
51
+ default: false,
52
+ },
53
+ 'no-analytics': {
54
+ type: 'boolean',
55
+ default: false,
56
+ },
57
+ },
58
+ }));
59
+ } catch (err) {
60
+ console.error(err.message);
61
+ showHelp();
62
+ process.exit(1);
63
+ }
64
+
65
+ if (values.help) {
66
+ showHelp();
67
+ process.exit(0);
68
+ }
69
+
70
+ if (values.version) {
71
+ console.log(getVersion());
72
+ process.exit(0);
73
+ }
74
+
75
+ const port = parseInt(values.port, 10);
76
+ if (isNaN(port) || port < 1 || port > 65535) {
77
+ console.error(`Error: Invalid port "${values.port}". Must be 1-65535.`);
78
+ process.exit(1);
79
+ }
80
+
81
+ return { port, disableAnalytics: values['no-analytics'] };
82
+ }
@@ -133,6 +133,33 @@ export class AgentCallLogRepository extends BaseRepository {
133
133
  );
134
134
  }
135
135
 
136
+ /**
137
+ * Build WHERE conditions and params from filter options.
138
+ * @param {Object} options - Filter options
139
+ * @returns {{ conditions: string[], params: any[] }}
140
+ */
141
+ static buildFilters({ agentType, callType, status, model, sessionId, startDate, endDate }) {
142
+ const filterMap = [
143
+ [agentType, 'acl.agent_type = ?'],
144
+ [callType, 'acl.call_type = ?'],
145
+ [status, 'acl.status = ?'],
146
+ [model, 'acl.model = ?'],
147
+ [sessionId, 'acl.session_id = ?'],
148
+ [startDate, 'acl.started_at >= ?'],
149
+ [endDate, 'acl.started_at <= ?'],
150
+ ];
151
+
152
+ const conditions = [];
153
+ const params = [];
154
+ for (const [value, condition] of filterMap) {
155
+ if (value) {
156
+ conditions.push(condition);
157
+ params.push(value);
158
+ }
159
+ }
160
+ return { conditions, params };
161
+ }
162
+
136
163
  /**
137
164
  * Get all call logs with optional filtering, sorting, and pagination.
138
165
  * Returns { rows, total } where total is the full count (before limit/offset).
@@ -162,37 +189,9 @@ export class AgentCallLogRepository extends BaseRepository {
162
189
  const safeSortBy = SORTABLE_COLUMNS.includes(sortBy) ? sortBy : 'started_at';
163
190
  const safeSortOrder = sortOrder === 'ASC' ? 'ASC' : 'DESC';
164
191
 
165
- const conditions = [];
166
- const params = [];
167
-
168
- if (agentType) {
169
- conditions.push('acl.agent_type = ?');
170
- params.push(agentType);
171
- }
172
- if (callType) {
173
- conditions.push('acl.call_type = ?');
174
- params.push(callType);
175
- }
176
- if (status) {
177
- conditions.push('acl.status = ?');
178
- params.push(status);
179
- }
180
- if (model) {
181
- conditions.push('acl.model = ?');
182
- params.push(model);
183
- }
184
- if (sessionId) {
185
- conditions.push('acl.session_id = ?');
186
- params.push(sessionId);
187
- }
188
- if (startDate) {
189
- conditions.push('acl.started_at >= ?');
190
- params.push(startDate);
191
- }
192
- if (endDate) {
193
- conditions.push('acl.started_at <= ?');
194
- params.push(endDate);
195
- }
192
+ const { conditions, params } = AgentCallLogRepository.buildFilters({
193
+ agentType, callType, status, model, sessionId, startDate, endDate,
194
+ });
196
195
 
197
196
  const whereClause = conditions.length > 0 ? `WHERE ${ conditions.join(' AND ')}` : '';
198
197
 
@@ -91,26 +91,31 @@ export class ConversationRepository extends BaseRepository {
91
91
  * @param {Object} data - Fields to update
92
92
  * @returns {Object} The updated conversation
93
93
  */
94
- update(id, data) {
94
+ /**
95
+ * Build the SET clause fields from data, collecting updates and values.
96
+ * Handles the isActive special case (deactivating other conversations).
97
+ * @param {string} id - Conversation ID
98
+ * @param {Object} data - Fields to update
99
+ * @returns {{ updates: string[], values: any[] }}
100
+ */
101
+ #buildUpdateFields(id, data) {
102
+ const FIELD_MAP = {
103
+ name: 'name',
104
+ summary: 'summary',
105
+ summaryGeneratedAt: 'summary_generated_at',
106
+ claudeSessionId: 'claude_session_id',
107
+ };
108
+
95
109
  const updates = [];
96
110
  const values = [];
97
111
 
98
- if (data.name !== undefined) {
99
- updates.push('name = ?');
100
- values.push(data.name);
101
- }
102
- if (data.summary !== undefined) {
103
- updates.push('summary = ?');
104
- values.push(data.summary);
105
- }
106
- if (data.summaryGeneratedAt !== undefined) {
107
- updates.push('summary_generated_at = ?');
108
- values.push(data.summaryGeneratedAt);
109
- }
110
- if (data.claudeSessionId !== undefined) {
111
- updates.push('claude_session_id = ?');
112
- values.push(data.claudeSessionId);
112
+ for (const [key, column] of Object.entries(FIELD_MAP)) {
113
+ if (data[key] !== undefined) {
114
+ updates.push(`${column} = ?`);
115
+ values.push(data[key]);
116
+ }
113
117
  }
118
+
114
119
  if (data.isActive !== undefined) {
115
120
  // If setting this conversation as active, deactivate others first
116
121
  if (data.isActive) {
@@ -125,6 +130,12 @@ export class ConversationRepository extends BaseRepository {
125
130
  values.push(data.isActive ? 1 : 0);
126
131
  }
127
132
 
133
+ return { updates, values };
134
+ }
135
+
136
+ update(id, data) {
137
+ const { updates, values } = this.#buildUpdateFields(id, data);
138
+
128
139
  if (updates.length === 0) {
129
140
  return this.getById(id);
130
141
  }
@@ -61,42 +61,32 @@ export class ProjectRepository extends BaseRepository {
61
61
  return this.mapAll(rows);
62
62
  }
63
63
 
64
+ /**
65
+ * Field mapping from camelCase data keys to snake_case column names.
66
+ * Entries with a transform function apply that transform to the value.
67
+ */
68
+ static #FIELD_MAP = {
69
+ name: { column: 'name' },
70
+ workingDirectory: { column: 'working_directory' },
71
+ systemPrompt: { column: 'system_prompt' },
72
+ onSessionCreated: { column: 'on_session_created' },
73
+ onSessionDeleted: { column: 'on_session_deleted' },
74
+ prPollInterval: { column: 'pr_poll_interval' },
75
+ repoUrl: { column: 'repo_url' },
76
+ kanbanEnabled: { column: 'kanban_enabled', transform: (v) => v ? 1 : 0 },
77
+ };
78
+
64
79
  update(id, data) {
65
80
  const updates = [];
66
81
  const values = [];
67
82
 
68
- if (data.name !== undefined) {
69
- updates.push('name = ?');
70
- values.push(data.name);
71
- }
72
- if (data.workingDirectory !== undefined) {
73
- updates.push('working_directory = ?');
74
- values.push(data.workingDirectory);
75
- }
76
- if (data.systemPrompt !== undefined) {
77
- updates.push('system_prompt = ?');
78
- values.push(data.systemPrompt);
79
- }
80
- if (data.onSessionCreated !== undefined) {
81
- updates.push('on_session_created = ?');
82
- values.push(data.onSessionCreated);
83
- }
84
- if (data.onSessionDeleted !== undefined) {
85
- updates.push('on_session_deleted = ?');
86
- values.push(data.onSessionDeleted);
87
- }
88
- if (data.prPollInterval !== undefined) {
89
- updates.push('pr_poll_interval = ?');
90
- values.push(data.prPollInterval);
91
- }
92
- if (data.repoUrl !== undefined) {
93
- updates.push('repo_url = ?');
94
- values.push(data.repoUrl);
95
- }
96
- if (data.kanbanEnabled !== undefined) {
97
- updates.push('kanban_enabled = ?');
98
- values.push(data.kanbanEnabled ? 1 : 0);
83
+ for (const [key, { column, transform }] of Object.entries(ProjectRepository.#FIELD_MAP)) {
84
+ if (data[key] !== undefined) {
85
+ updates.push(`${column} = ?`);
86
+ values.push(transform ? transform(data[key]) : data[key]);
87
+ }
99
88
  }
89
+
100
90
  if (updates.length === 0) return this.getById(id);
101
91
 
102
92
  updates.push('updated_at = ?');
@@ -102,6 +102,15 @@ export class QuickResponseRepository extends BaseRepository {
102
102
  * @param {Object} data - Fields to update
103
103
  * @returns {Object|null} Updated quick response or null if not found
104
104
  */
105
+ /** @type {Array<[string, string, ((v: any) => any)?]>} [dataKey, columnName, transform?] */
106
+ static #UPDATE_FIELDS = [
107
+ ['label', 'label'],
108
+ ['content', 'content'],
109
+ ['autoSubmit', 'auto_submit', (v) => v ? 1 : 0],
110
+ ['category', 'category'],
111
+ ['sortOrder', 'sort_order'],
112
+ ];
113
+
105
114
  update(id, data) {
106
115
  const existing = this.getById(id);
107
116
  if (!existing) {
@@ -111,25 +120,11 @@ export class QuickResponseRepository extends BaseRepository {
111
120
  const updates = [];
112
121
  const values = [];
113
122
 
114
- if (data.label !== undefined) {
115
- updates.push('label = ?');
116
- values.push(data.label);
117
- }
118
- if (data.content !== undefined) {
119
- updates.push('content = ?');
120
- values.push(data.content);
121
- }
122
- if (data.autoSubmit !== undefined) {
123
- updates.push('auto_submit = ?');
124
- values.push(data.autoSubmit ? 1 : 0);
125
- }
126
- if (data.category !== undefined) {
127
- updates.push('category = ?');
128
- values.push(data.category);
129
- }
130
- if (data.sortOrder !== undefined) {
131
- updates.push('sort_order = ?');
132
- values.push(data.sortOrder);
123
+ for (const [key, column, transform] of QuickResponseRepository.#UPDATE_FIELDS) {
124
+ if (data[key] !== undefined) {
125
+ updates.push(`${column} = ?`);
126
+ values.push(transform ? transform(data[key]) : data[key]);
127
+ }
133
128
  }
134
129
 
135
130
  if (updates.length > 0) {