circuschief 0.6.0 → 0.8.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 (187) hide show
  1. package/package.json +1 -1
  2. package/packages/server/src/agents/adapters/CodexAdapter.js +5 -4
  3. package/packages/server/src/api/canvas.js +22 -57
  4. package/packages/server/src/api/index.js +2 -0
  5. package/packages/server/src/api/kanban.js +4 -2
  6. package/packages/server/src/api/projects-helpers.js +20 -3
  7. package/packages/server/src/api/projects-session-helpers.js +35 -4
  8. package/packages/server/src/api/projects.js +11 -0
  9. package/packages/server/src/api/providers.js +11 -1
  10. package/packages/server/src/api/sessions-commands.js +35 -17
  11. package/packages/server/src/api/sessions-draft.js +1 -0
  12. package/packages/server/src/api/sessions-lifecycle.js +10 -10
  13. package/packages/server/src/api/sessions-patch.js +4 -0
  14. package/packages/server/src/api/sessions.js +6 -5
  15. package/packages/server/src/api/settings.js +52 -4
  16. package/packages/server/src/database.js +0 -2
  17. package/packages/server/src/db/ConversationRepository.js +16 -3
  18. package/packages/server/src/db/DatabaseManager.js +5 -1
  19. package/packages/server/src/db/ProjectDefaultsRepository.js +50 -40
  20. package/packages/server/src/db/ProviderRepository.js +87 -32
  21. package/packages/server/src/db/SessionRepository.js +13 -8
  22. package/packages/server/src/db/SettingsRepository.js +44 -16
  23. package/packages/server/src/db/conversation-helpers.js +1 -0
  24. package/packages/server/src/db/index.js +0 -3
  25. package/packages/server/src/db/migrations/index.js +36 -200
  26. package/packages/server/src/db/seedBaselineData.js +137 -0
  27. package/packages/server/src/db/session-helpers.js +9 -3
  28. package/packages/server/src/middleware/sessionLookup.js +81 -8
  29. package/packages/server/src/schema.sql +157 -132
  30. package/packages/server/src/scripts/backupDatabase.js +21 -0
  31. package/packages/server/src/scripts/dbUtils.js +81 -0
  32. package/packages/server/src/scripts/inspectDatabaseSchema.js +81 -0
  33. package/packages/server/src/scripts/validateDatabaseBaseline.js +212 -0
  34. package/packages/server/src/services/agentCallLogger.js +1 -1
  35. package/packages/server/src/services/codexSpawnHelper.js +9 -0
  36. package/packages/server/src/services/commandButtonPrompts.js +48 -0
  37. package/packages/server/src/services/commandRunner.js +7 -1
  38. package/packages/server/src/services/draftSessionService.js +2 -0
  39. package/packages/server/src/services/e2eSpawnCapture.js +147 -0
  40. package/packages/server/src/services/gitCommitAttribution.js +120 -0
  41. package/packages/server/src/services/gitService.js +11 -2
  42. package/packages/server/src/services/gitSessionSetup.js +11 -1
  43. package/packages/server/src/services/kanbanTriggers.js +6 -3
  44. package/packages/server/src/services/nodeSpawnHelper.js +9 -0
  45. package/packages/server/src/services/prUrlService.js +3 -3
  46. package/packages/server/src/services/queryParamBuilder.js +90 -0
  47. package/packages/server/src/services/sessionDuplicator.js +1 -5
  48. package/packages/server/src/services/sessionExecution.js +56 -106
  49. package/packages/server/src/services/sessionPrompts.js +16 -47
  50. package/packages/server/src/services/sessionProvider.js +16 -8
  51. package/packages/server/src/services/streamEventCallbacks.js +72 -40
  52. package/packages/server/src/services/streamEventHandler.js +13 -2
  53. package/packages/server/src/services/streamUsageHandler.js +6 -0
  54. package/packages/server/src/services/summaryClaudeClient.js +37 -12
  55. package/packages/server/src/services/summaryModelClient.js +154 -0
  56. package/packages/server/src/services/summaryModelResolver.js +148 -0
  57. package/packages/server/src/services/summaryService.js +11 -7
  58. package/packages/server/src/services/summaryStaleCheck.js +23 -4
  59. package/packages/server/src/services/templateTriggerService.js +3 -1
  60. package/packages/server/src/services/usageTracker.js +5 -1
  61. package/packages/server/src/services/visibleFinalErrorMessage.js +123 -0
  62. package/packages/shared/src/constants.js +4 -2
  63. package/packages/shared/src/contracts/commandButtons.js +16 -2
  64. package/packages/shared/src/contracts/projects.js +4 -2
  65. package/packages/shared/src/contracts/providers.js +60 -0
  66. package/packages/shared/src/contracts/sessions.js +2 -1
  67. package/packages/shared/src/contracts/templates.js +2 -2
  68. package/packages/shared/src/types.js +1 -9
  69. package/packages/shared/src/utils.js +11 -19
  70. package/packages/web/dist/assets/ActiveSessionsView-B0XHqLmv.js +1 -0
  71. package/packages/web/dist/assets/{AgentLogsView-Cdw4nmvd.js → AgentLogsView-DmsjUMlB.js} +2 -2
  72. package/packages/web/dist/assets/ApiClient-C3ztI9s9.js +1 -0
  73. package/packages/web/dist/assets/ArchiveConfirmModal-BlCyn5Vt.js +1 -0
  74. package/packages/web/dist/assets/ArchiveConfirmModal-DeoCVGXt.css +1 -0
  75. package/packages/web/dist/assets/{CommandButtonDetailView-DnFhJY5A.js → CommandButtonDetailView-CdSCPp78.js} +1 -1
  76. package/packages/web/dist/assets/EffortLevelSelector-hc2MNKg6.js +1 -0
  77. package/packages/web/dist/assets/{GeneralSettingsView-CQkmdczf.js → GeneralSettingsView-D1nI8_zk.js} +1 -1
  78. package/packages/web/dist/assets/InputWithButton-CAkttyqx.js +1 -0
  79. package/packages/web/dist/assets/{InputWithButton-cYdrEmTs.css → InputWithButton-D9HMvfR5.css} +1 -1
  80. package/packages/web/dist/assets/{InterpolationHelp-PfYR3KJo.js → InterpolationHelp-BO1j9Z3_.js} +1 -1
  81. package/packages/web/dist/assets/MarkdownEditor-ucRAP_UM.js +2 -0
  82. package/packages/web/dist/assets/{ModelSelector-BZOT1Jc6.css → ModelSelector-BSxKUSus.css} +1 -1
  83. package/packages/web/dist/assets/ModelSelector-CwTz8ZWO.js +1 -0
  84. package/packages/web/dist/assets/NewSessionView-BDPb-1qr.css +1 -0
  85. package/packages/web/dist/assets/NewSessionView-BsDrp8mj.js +3 -0
  86. package/packages/web/dist/assets/ProjectEditView-CwTOeSun.js +1 -0
  87. package/packages/web/dist/assets/ProjectEditView-J15mcsWz.css +1 -0
  88. package/packages/web/dist/assets/{ProjectListView-CuYMmd3O.js → ProjectListView-DcNyuINs.js} +1 -1
  89. package/packages/web/dist/assets/{ProjectNewView-CNaA4Maf.js → ProjectNewView-B5YV62hv.js} +1 -1
  90. package/packages/web/dist/assets/ProvidersView-bZemq_Rv.css +1 -0
  91. package/packages/web/dist/assets/ProvidersView-nY9GnDdO.js +1 -0
  92. package/packages/web/dist/assets/QuickResponseSettings-B352c75l.css +1 -0
  93. package/packages/web/dist/assets/QuickResponseSettings-BQwQXuL7.js +1 -0
  94. package/packages/web/dist/assets/{QuickResponsesPanel-DIBQFj0W.css → QuickResponsesPanel-BlFDvnZ2.css} +1 -1
  95. package/packages/web/dist/assets/{QuickResponsesPanel-BqMYSHb0.js → QuickResponsesPanel-BzSYcCSP.js} +1 -1
  96. package/packages/web/dist/assets/{ResizableTextarea-wYF3K2RO.js → ResizableTextarea-B3YIdIXv.js} +1 -1
  97. package/packages/web/dist/assets/{SessionCard-bLaQEWWX.js → SessionCard-CjE1tXiT.js} +1 -1
  98. package/packages/web/dist/assets/SessionDetailView-3cPZrbS3.js +36 -0
  99. package/packages/web/dist/assets/SessionDetailView-CZRZMrfM.css +1 -0
  100. package/packages/web/dist/assets/SessionFormOptions-B6AxyREh.js +1 -0
  101. package/packages/web/dist/assets/SessionFormOptions-BpUALRKn.css +1 -0
  102. package/packages/web/dist/assets/SessionListView-B5_6gW49.css +1 -0
  103. package/packages/web/dist/assets/SessionListView-CLXBfLcq.js +1 -0
  104. package/packages/web/dist/assets/{SessionLogStream-DTnDAF95.js → SessionLogStream-LlZ3z_Xj.js} +1 -1
  105. package/packages/web/dist/assets/{SettingsView-DNLUSsHV.js → SettingsView-CTGiGvR2.js} +1 -1
  106. package/packages/web/dist/assets/{SlashCommandWizard-CRGFaO8t.js → SlashCommandWizard-Cy04d7-o.js} +1 -1
  107. package/packages/web/dist/assets/{SlashCommandWizard-Dn7sNaBd.css → SlashCommandWizard-DJzw3LP5.css} +1 -1
  108. package/packages/web/dist/assets/SummarySettingsView-BR2ZjEa3.js +1 -0
  109. package/packages/web/dist/assets/SummarySettingsView-l2bxHmZZ.css +1 -0
  110. package/packages/web/dist/assets/TemplateDetailView-DH6Oswsp.js +1 -0
  111. package/packages/web/dist/assets/{commandButtons-Bbjf3fCt.js → commandButtons-BfqR-fqq.js} +1 -1
  112. package/packages/web/dist/assets/index-1zziPL6l.js +1 -0
  113. package/packages/web/dist/assets/index-7kzHPxSF.js +1 -0
  114. package/packages/web/dist/assets/index-B0N_obMc.js +1 -0
  115. package/packages/web/dist/assets/index-BNk_gdfI.js +1 -0
  116. package/packages/web/dist/assets/{index-gmCCsCQ1.css → index-BY174HVJ.css} +1 -1
  117. package/packages/web/dist/assets/index-CSqaAH-0.js +1 -0
  118. package/packages/web/dist/assets/index-C_q4WlK8.js +1 -0
  119. package/packages/web/dist/assets/index-D1wpU4y0.js +7 -0
  120. package/packages/web/dist/assets/index-D5zCA8sD.js +1 -0
  121. package/packages/web/dist/assets/index-DGR8ELWY.js +1 -0
  122. package/packages/web/dist/assets/index-DHga8pXo.js +1 -0
  123. package/packages/web/dist/assets/index-DSby02Wl.js +1 -0
  124. package/packages/web/dist/assets/{index-Cf6vdW-B.js → index-DgkC10TW.js} +3 -3
  125. package/packages/web/dist/assets/index-DqjXJTVI.js +1 -0
  126. package/packages/web/dist/assets/{index-Bs7Qf5D6.js → index-DtfUt785.js} +2 -2
  127. package/packages/web/dist/assets/index-_4S2uLDI.js +1 -0
  128. package/packages/web/dist/assets/{index-BQL_L4gL.js → index-fK8FIZgP.js} +15 -14
  129. package/packages/web/dist/assets/index-gmiZeFXN.js +1 -0
  130. package/packages/web/dist/assets/index-irD539ZM.js +3 -0
  131. package/packages/web/dist/assets/index-yq-E1Y00.js +1 -0
  132. package/packages/web/dist/assets/{projects-CPt3AB7U.js → projects-DXYQNJIi.js} +1 -1
  133. package/packages/web/dist/assets/{providers-ChfeMvUq.js → providers-1bnH-exJ.js} +1 -1
  134. package/packages/web/dist/assets/sessions-6zGUlFrt.js +1 -0
  135. package/packages/web/dist/assets/settings-MbfRir0d.js +1 -0
  136. package/packages/web/dist/index.html +2 -2
  137. package/packages/server/src/api/sessions-notes.js +0 -51
  138. package/packages/server/src/db/SessionNoteRepository.js +0 -60
  139. package/packages/server/src/db/migrations/canvasItemsMigrations.js +0 -109
  140. package/packages/server/src/db/migrations/conversationsMigrations.js +0 -183
  141. package/packages/server/src/db/migrations/kanbanMigrations.js +0 -99
  142. package/packages/server/src/db/migrations/miscMigrations.js +0 -369
  143. package/packages/server/src/db/migrations/projectsMigrations.js +0 -99
  144. package/packages/server/src/db/migrations/sessionsMigrations.js +0 -282
  145. package/packages/web/dist/assets/ActiveSessionsView-UCbQrF1b.js +0 -1
  146. package/packages/web/dist/assets/ApiClient-CWbXWDUY.js +0 -1
  147. package/packages/web/dist/assets/ArchiveConfirmModal-BQ-4gI0R.css +0 -1
  148. package/packages/web/dist/assets/ArchiveConfirmModal-J48eh3zw.js +0 -1
  149. package/packages/web/dist/assets/EffortLevelSelector-bXbPo4Zw.js +0 -1
  150. package/packages/web/dist/assets/InputWithButton-XyM3k6lN.js +0 -1
  151. package/packages/web/dist/assets/MarkdownEditor-P8F5kO-o.js +0 -2
  152. package/packages/web/dist/assets/ModelSelector-CowKfGMP.js +0 -1
  153. package/packages/web/dist/assets/NewSessionView-D_Hi7M9g.css +0 -1
  154. package/packages/web/dist/assets/NewSessionView-DkjFLvHU.js +0 -3
  155. package/packages/web/dist/assets/ProjectEditView-CpeKj-_w.css +0 -1
  156. package/packages/web/dist/assets/ProjectEditView-embVT7NC.js +0 -1
  157. package/packages/web/dist/assets/ProvidersView-C7rydtOd.js +0 -1
  158. package/packages/web/dist/assets/ProvidersView-DE82G_5W.css +0 -1
  159. package/packages/web/dist/assets/QuickResponseSettings-B8188A1D.css +0 -1
  160. package/packages/web/dist/assets/QuickResponseSettings-BTQEKhwJ.js +0 -1
  161. package/packages/web/dist/assets/SessionDetailView-Cv-xMzXp.css +0 -1
  162. package/packages/web/dist/assets/SessionDetailView-CvQOUsW2.js +0 -36
  163. package/packages/web/dist/assets/SessionFormOptions-3pzbgI2Q.js +0 -1
  164. package/packages/web/dist/assets/SessionFormOptions-DhhIkjIS.css +0 -1
  165. package/packages/web/dist/assets/SessionListView-Dranfb72.js +0 -1
  166. package/packages/web/dist/assets/SessionListView-fHlQyecX.css +0 -1
  167. package/packages/web/dist/assets/SummarySettingsView-C7G_suHp.js +0 -1
  168. package/packages/web/dist/assets/SummarySettingsView-DcsmSVJI.css +0 -1
  169. package/packages/web/dist/assets/TemplateDetailView-B78_DLMR.js +0 -1
  170. package/packages/web/dist/assets/index--V7c-VZf.js +0 -1
  171. package/packages/web/dist/assets/index-8Q04yd7H.js +0 -1
  172. package/packages/web/dist/assets/index-B47XRBDH.js +0 -1
  173. package/packages/web/dist/assets/index-BXbgZrhS.js +0 -1
  174. package/packages/web/dist/assets/index-CGhDVPen.js +0 -1
  175. package/packages/web/dist/assets/index-CKcRO1A6.js +0 -1
  176. package/packages/web/dist/assets/index-CTq-SLIW.js +0 -1
  177. package/packages/web/dist/assets/index-CYyos3iC.js +0 -1
  178. package/packages/web/dist/assets/index-CsCREAxF.js +0 -1
  179. package/packages/web/dist/assets/index-DJTTk_8T.js +0 -3
  180. package/packages/web/dist/assets/index-DPqUJ5JK.js +0 -1
  181. package/packages/web/dist/assets/index-EwAe1dKg.js +0 -1
  182. package/packages/web/dist/assets/index-JBA8axyA.js +0 -1
  183. package/packages/web/dist/assets/index-JkVHFtK5.js +0 -7
  184. package/packages/web/dist/assets/index-gMPUwT55.js +0 -1
  185. package/packages/web/dist/assets/index-wadc_0zT.js +0 -1
  186. package/packages/web/dist/assets/sessions-CwPsJOb1.js +0 -1
  187. package/packages/web/dist/assets/settings-BOj6wq6t.js +0 -1
@@ -0,0 +1,154 @@
1
+ import OpenAI from 'openai';
2
+ import { callClaude, SESSION_SUMMARY_SCHEMA } from './summaryClaudeClient.js';
3
+ import { agentCallLogger } from './agentCallLogger.js';
4
+ import { buildProviderEnv } from './sessionProvider.js';
5
+ import { resolveSummaryModel } from './summaryModelResolver.js';
6
+
7
+ export { SESSION_SUMMARY_SCHEMA };
8
+
9
+ export async function callSummaryModel(prompt, recentMessages, sessionStatus, options = {}) {
10
+ const resolution = options.resolvedModel || resolveSummaryModel(options.summarySettings || {});
11
+ if (resolution.kind === 'openai') {
12
+ return callOpenAISummaryModel(prompt, resolution, options);
13
+ }
14
+ return callAnthropicSummaryModel({ prompt, recentMessages, sessionStatus, resolution, options });
15
+ }
16
+
17
+ function callAnthropicSummaryModel({ prompt, recentMessages, sessionStatus, resolution, options }) {
18
+ const providerEnv = resolution.provider && !resolution.provider.isBuiltIn
19
+ ? { ...process.env, ...buildProviderEnv(resolution.provider) }
20
+ : null;
21
+
22
+ return callClaude(prompt, recentMessages, sessionStatus, {
23
+ ...options,
24
+ model: resolution.model,
25
+ providerId: resolution.providerId,
26
+ selectionReason: resolution.selectionReason,
27
+ ...(providerEnv ? { env: providerEnv } : {}),
28
+ });
29
+ }
30
+
31
+ async function callOpenAISummaryModel(prompt, resolution, options) {
32
+ const { logMeta = null, systemPrompt = null, jsonSchema = null } = options || {};
33
+ const schema = jsonSchema || SESSION_SUMMARY_SCHEMA;
34
+ const provider = resolution.provider;
35
+ const client = createOpenAIClient(provider);
36
+
37
+ const callId = startOpenAISummaryLog(logMeta, resolution, prompt.length);
38
+
39
+ try {
40
+ const response = await createOpenAIChatCompletion(client, {
41
+ model: resolution.model,
42
+ prompt,
43
+ systemPrompt,
44
+ schema,
45
+ structured: true,
46
+ }).catch(async (error) => {
47
+ if (!isStructuredOutputUnsupported(error)) throw error;
48
+ return createOpenAIChatCompletion(client, {
49
+ model: resolution.model,
50
+ prompt: withJsonOnlyInstruction(prompt, schema),
51
+ systemPrompt,
52
+ schema,
53
+ structured: false,
54
+ });
55
+ });
56
+
57
+ logOpenAIUsage(callId, response.usage);
58
+
59
+ if (callId) {
60
+ agentCallLogger.completeCall(callId, { success: true });
61
+ }
62
+ return extractOpenAIContent(response);
63
+ } catch (error) {
64
+ if (callId) {
65
+ agentCallLogger.completeCall(callId, { success: false, error });
66
+ }
67
+ throw error;
68
+ }
69
+ }
70
+
71
+ function startOpenAISummaryLog(logMeta, resolution, promptLength) {
72
+ if (!logMeta) return null;
73
+ return agentCallLogger.startCall({
74
+ sessionId: logMeta.sessionId,
75
+ conversationId: logMeta.conversationId || null,
76
+ agentType: 'summary',
77
+ model: resolution.model,
78
+ callType: logMeta.callType,
79
+ promptLength,
80
+ metadata: {
81
+ ...(resolution.providerId ? { providerId: resolution.providerId } : {}),
82
+ ...(resolution.selectionReason ? { selectionReason: resolution.selectionReason } : {}),
83
+ },
84
+ });
85
+ }
86
+
87
+ function logOpenAIUsage(callId, usage) {
88
+ if (!callId || !usage) return;
89
+ agentCallLogger.updateUsage(callId, {
90
+ inputTokens: usage.prompt_tokens || 0,
91
+ outputTokens: usage.completion_tokens || 0,
92
+ thinkingTokens: 0,
93
+ cacheReadInputTokens: 0,
94
+ cacheCreationInputTokens: 0,
95
+ });
96
+ }
97
+
98
+ function createOpenAIClient(provider) {
99
+ const options = {
100
+ apiKey: provider?.authToken || process.env.OPENAI_API_KEY || 'missing',
101
+ };
102
+ if (provider?.baseUrl) options.baseURL = provider.baseUrl;
103
+ if (provider?.apiTimeoutMs) options.timeout = provider.apiTimeoutMs;
104
+ return new OpenAI(options);
105
+ }
106
+
107
+ function createOpenAIChatCompletion(client, { model, prompt, systemPrompt, schema, structured }) {
108
+ const messages = [];
109
+ if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
110
+ messages.push({ role: 'user', content: prompt });
111
+
112
+ const request = {
113
+ model,
114
+ messages,
115
+ temperature: 0,
116
+ };
117
+
118
+ if (structured) {
119
+ request.response_format = {
120
+ type: 'json_schema',
121
+ json_schema: {
122
+ name: 'session_summary',
123
+ schema,
124
+ strict: false,
125
+ },
126
+ };
127
+ } else {
128
+ request.response_format = { type: 'json_object' };
129
+ }
130
+
131
+ return client.chat.completions.create(request);
132
+ }
133
+
134
+ function extractOpenAIContent(response) {
135
+ const content = response?.choices?.[0]?.message?.content;
136
+ if (Array.isArray(content)) {
137
+ return content
138
+ .map((part) => part?.text || part?.content || '')
139
+ .join('');
140
+ }
141
+ return content || '';
142
+ }
143
+
144
+ function isStructuredOutputUnsupported(error) {
145
+ const message = `${error?.message || ''} ${error?.code || ''} ${error?.type || ''}`.toLowerCase();
146
+ return [400, 404, 422].includes(error?.status)
147
+ || message.includes('response_format')
148
+ || message.includes('json_schema')
149
+ || message.includes('unsupported');
150
+ }
151
+
152
+ function withJsonOnlyInstruction(prompt, schema) {
153
+ return `${prompt}\n\nReturn only a valid JSON object matching this JSON Schema:\n${JSON.stringify(schema)}`;
154
+ }
@@ -0,0 +1,148 @@
1
+ import { modelProviders, sessions } from '../database.js';
2
+ import { ACTIVITY_FIELDS_SQL } from '../db/session-helpers.js';
3
+
4
+ export const DEFAULT_ANTHROPIC_SUMMARY_MODEL = 'claude-haiku-4-5-20251001';
5
+ export const DEFAULT_OPENAI_SUMMARY_MODEL = 'gpt-5.4-mini';
6
+ export const BUILT_IN_ANTHROPIC_PROVIDER_ID = 'anthropic-default';
7
+ export const BUILT_IN_OPENAI_PROVIDER_ID = 'openai-default';
8
+
9
+ export const CHEAPEST_SUMMARY_MODEL_BY_BUILT_IN_PROVIDER = Object.freeze({
10
+ [BUILT_IN_ANTHROPIC_PROVIDER_ID]: DEFAULT_ANTHROPIC_SUMMARY_MODEL,
11
+ [BUILT_IN_OPENAI_PROVIDER_ID]: DEFAULT_OPENAI_SUMMARY_MODEL,
12
+ });
13
+
14
+ const ANTHROPIC_TIER_NAMES = new Set(['sonnet', 'opus', 'haiku']);
15
+
16
+ export function isKnownBuiltInAnthropicModel(modelId) {
17
+ if (!modelId || typeof modelId !== 'string') return false;
18
+ if (ANTHROPIC_TIER_NAMES.has(modelId.toLowerCase())) return true;
19
+ if (modelId === DEFAULT_ANTHROPIC_SUMMARY_MODEL) return true;
20
+
21
+ const anthropicProvider = modelProviders.getById(BUILT_IN_ANTHROPIC_PROVIDER_ID);
22
+ return Boolean(
23
+ anthropicProvider?.models?.some((model) => model.modelId === modelId)
24
+ || modelId.startsWith('claude-')
25
+ );
26
+ }
27
+
28
+ export function resolveSummaryModel(summarySettings = {}) {
29
+ const summaryModel = summarySettings?.summaryModel || '';
30
+ const summaryProviderId = summarySettings?.summaryProviderId || null;
31
+
32
+ if (summaryModel) {
33
+ return resolveExplicitSummaryModel(summaryModel, summaryProviderId);
34
+ }
35
+ if (summaryProviderId) {
36
+ throw new Error('summaryModel is required when summaryProviderId is set');
37
+ }
38
+
39
+ const recentFamily = findRecentBuiltInProviderFamily();
40
+ if (recentFamily === BUILT_IN_OPENAI_PROVIDER_ID) {
41
+ return {
42
+ model: DEFAULT_OPENAI_SUMMARY_MODEL,
43
+ provider: modelProviders.getById(BUILT_IN_OPENAI_PROVIDER_ID),
44
+ providerId: BUILT_IN_OPENAI_PROVIDER_ID,
45
+ kind: 'openai',
46
+ isDefault: true,
47
+ selectionReason: 'recent-built-in-provider',
48
+ };
49
+ }
50
+
51
+ if (recentFamily === BUILT_IN_ANTHROPIC_PROVIDER_ID) {
52
+ return defaultAnthropicResolution('recent-built-in-provider');
53
+ }
54
+
55
+ return defaultAnthropicResolution('fallback');
56
+ }
57
+
58
+ function resolveExplicitSummaryModel(summaryModel, summaryProviderId) {
59
+ if (!summaryProviderId) {
60
+ throw new Error('summaryProviderId is required when summaryModel is set');
61
+ }
62
+
63
+ const provider = modelProviders.getById(summaryProviderId);
64
+ if (!provider) {
65
+ throw new Error(`Summary provider not found: ${summaryProviderId}`);
66
+ }
67
+
68
+ const ownsModel = provider.models?.some((model) => model.modelId === summaryModel);
69
+ if (!ownsModel) {
70
+ throw new Error(`Summary provider ${summaryProviderId} does not own model ${summaryModel}`);
71
+ }
72
+ return providerResolution(summaryModel, provider, 'explicit');
73
+ }
74
+
75
+ function providerResolution(model, provider, selectionReason) {
76
+ const kind = provider.kind || 'anthropic';
77
+ return {
78
+ model,
79
+ provider,
80
+ providerId: provider.id,
81
+ kind,
82
+ isDefault: false,
83
+ selectionReason,
84
+ };
85
+ }
86
+
87
+ function defaultAnthropicResolution(selectionReason) {
88
+ return {
89
+ model: DEFAULT_ANTHROPIC_SUMMARY_MODEL,
90
+ provider: null,
91
+ providerId: null,
92
+ kind: 'anthropic',
93
+ isDefault: true,
94
+ selectionReason,
95
+ };
96
+ }
97
+
98
+ function findRecentBuiltInProviderFamily() {
99
+ for (const usage of getRecentSessionModelUsage()) {
100
+ const providerId = usage.providerId || null;
101
+ if (providerId) {
102
+ const provider = modelProviders.getById(providerId);
103
+ const family = builtInFamilyForProvider(provider);
104
+ if (family) return family;
105
+ continue;
106
+ }
107
+
108
+ const model = usage.model || null;
109
+ if (!model) continue;
110
+
111
+ const provider = modelProviders.getProviderByModelId(model);
112
+ const family = builtInFamilyForProvider(provider);
113
+ if (family) return family;
114
+
115
+ if (!provider && isKnownBuiltInAnthropicModel(model)) {
116
+ return BUILT_IN_ANTHROPIC_PROVIDER_ID;
117
+ }
118
+ }
119
+ return null;
120
+ }
121
+
122
+ function builtInFamilyForProvider(provider) {
123
+ if (!provider?.isBuiltIn) return null;
124
+ if (provider.id === BUILT_IN_OPENAI_PROVIDER_ID || provider.kind === 'openai') return BUILT_IN_OPENAI_PROVIDER_ID;
125
+ if (provider.id === BUILT_IN_ANTHROPIC_PROVIDER_ID || (provider.kind || 'anthropic') === 'anthropic') {
126
+ return BUILT_IN_ANTHROPIC_PROVIDER_ID;
127
+ }
128
+ return null;
129
+ }
130
+
131
+ export function getRecentSessionModelUsage(limit = 50) {
132
+ const rows = sessions.db
133
+ .prepare(
134
+ `SELECT s.model, s.provider_id, ${ACTIVITY_FIELDS_SQL}
135
+ FROM sessions s
136
+ ORDER BY COALESCE(last_activity_at, s.updated_at, s.created_at) DESC,
137
+ s.updated_at DESC,
138
+ s.created_at DESC,
139
+ s.rowid DESC
140
+ LIMIT ?`
141
+ )
142
+ .all(limit);
143
+
144
+ return rows.map((row) => ({
145
+ model: row.model || null,
146
+ providerId: row.provider_id || null,
147
+ }));
148
+ }
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { sessions, messages, sessionSummaries, projects, settings } from '../database.js';
15
15
  import { createConcurrencyGuard } from './withConcurrencyGuard.js';
16
- import { callClaude } from './summaryClaudeClient.js';
16
+ import { callSummaryModel } from './summaryModelClient.js';
17
17
  import {
18
18
  MAX_MESSAGES,
19
19
  MIN_MESSAGES_FOR_SUMMARY,
@@ -200,18 +200,19 @@ function updateSessionFromSummary(sessionId, session, summaryData) {
200
200
  if (summaryData.sessionTitle || summaryData.prUrl) {
201
201
  const updateData = {};
202
202
  const freshSession = sessions.getById(sessionId);
203
+ const shouldApplySummaryPrUrl = summaryData.prUrl && !freshSession.prUrlAutoLinkDisabled;
203
204
 
204
205
  if (summaryData.sessionTitle && !freshSession.manuallyNamed) {
205
206
  updateData.name = summaryData.sessionTitle;
206
207
  }
207
208
 
208
- if (summaryData.prUrl) {
209
+ if (shouldApplySummaryPrUrl) {
209
210
  updateData.prUrl = summaryData.prUrl;
210
211
  }
211
212
 
212
213
  const updatedSession = sessions.update(sessionId, updateData);
213
214
 
214
- if (summaryData.prUrl) {
215
+ if (shouldApplySummaryPrUrl) {
215
216
  propagatePrUrlToParent(sessionId, summaryData.prUrl);
216
217
  }
217
218
 
@@ -299,10 +300,11 @@ async function _doGenerateSummary(sessionId, retryCount = 0, force = false, user
299
300
  // Build conversation context and prompt
300
301
  const { prompt } = fetchConversationContext(sessionId, { existingSummary, recentMessages, session, globalSettings });
301
302
 
302
- // Call Claude via SDK
303
- const responseText = await callClaude(prompt, recentMessages, session.status, {
303
+ // Call the configured summary model.
304
+ const responseText = await callSummaryModel(prompt, recentMessages, session.status, {
304
305
  logMeta: { sessionId, callType: 'generateSessionSummary' },
305
306
  systemPrompt: SUMMARY_SYSTEM_PROMPT,
307
+ summarySettings: globalSettings,
306
308
  });
307
309
 
308
310
  // Parse response and retry if needed
@@ -520,7 +522,7 @@ export function propagatePrUrlToParent(sessionId, prUrl) {
520
522
  if (!rootId || rootId === sessionId) return;
521
523
 
522
524
  const root = sessions.getById(rootId);
523
- if (!root || root.prUrl) return; // Don't overwrite existing PR URL
525
+ if (!root || root.prUrl || root.prUrlAutoLinkDisabled) return; // Don't overwrite existing or user-cleared PR URL
524
526
 
525
527
  sessions.update(root.id, { prUrl });
526
528
 
@@ -535,8 +537,10 @@ export {
535
537
  MAX_MESSAGES, MIN_MESSAGES_FOR_SUMMARY, MAX_RETRIES, DEFAULT_SESSION_TITLE_PROMPT,
536
538
  SUMMARY_SYSTEM_PROMPT, formatMessages, buildIncrementalPrompt, parseSummaryResponse,
537
539
  stripMarkdownCodeBlock as _stripMarkdownCodeBlock, trackMessageMetadata as _trackMessageMetadata,
540
+ updateSessionFromSummary as _updateSessionFromSummary,
538
541
  };
539
- export { callClaude };
542
+ export { callSummaryModel };
543
+ export { callClaude } from './summaryClaudeClient.js';
540
544
  export { parsePrUrl, validatePrUrl, extractPrUrlIfNeeded, enrichPrData as _enrichPrData };
541
545
  export { getChildSessions, buildChildSessionContext, aggregateFilesModified };
542
546
 
@@ -3,10 +3,24 @@
3
3
  * Extracted from summaryService.js for modularity.
4
4
  */
5
5
 
6
- import { sessionSummaries, messages } from '../database.js';
6
+ import { sessionSummaries, messages, sessions } from '../database.js';
7
7
 
8
8
  /**
9
- * Check if a summary is stale (message count or last message ID has changed)
9
+ * Check if any descendant session has a summary newer than the given timestamp.
10
+ * @param {string} sessionId
11
+ * @param {number} generatedAt
12
+ * @returns {boolean}
13
+ */
14
+ function hasNewerDescendantSummary(sessionId, generatedAt) {
15
+ const descendantIds = sessions.getAllDescendantIds(sessionId);
16
+ if (descendantIds.length === 0) return false;
17
+
18
+ const descendantSummaries = sessionSummaries.getBySessionIds(descendantIds);
19
+ return descendantSummaries.some(ds => ds.generatedAt > generatedAt);
20
+ }
21
+
22
+ /**
23
+ * Check if a summary is stale (message count, last message ID, or descendant summaries have changed)
10
24
  * @param {string} sessionId
11
25
  * @returns {boolean}
12
26
  */
@@ -27,9 +41,14 @@ export function isSummaryStale(sessionId) {
27
41
  }
28
42
 
29
43
  // Also validate count as a secondary check (defensive programming)
30
- return allMessages.length !== summary.messageCount;
44
+ if (allMessages.length !== summary.messageCount) return true;
31
45
  }
32
46
 
33
47
  // Fallback to count-based staleness detection for old summaries
34
- return allMessages.length !== summary.messageCount;
48
+ if (allMessages.length !== summary.messageCount) return true;
49
+
50
+ // Check if any descendant session has a newer summary
51
+ if (hasNewerDescendantSummary(sessionId, summary.generatedAt)) return true;
52
+
53
+ return false;
35
54
  }
@@ -4,7 +4,7 @@ import { setupGitForSession } from './gitSessionSetup.js';
4
4
  import { runSession } from './sessionManager.js';
5
5
  import { broadcastToProject } from '../websocket.js';
6
6
  import { WS_MESSAGE_TYPES } from '../../../shared/src/index.js';
7
- import { resolveAgentTypeFromModel } from './sessionProvider.js';
7
+ import { resolveAgentTypeFromModel, resolveProviderMetadataFromModel } from './sessionProvider.js';
8
8
 
9
9
  const liquid = new Liquid();
10
10
 
@@ -100,6 +100,8 @@ async function resolveWorkingDirectory(parentSession, project, settings, newSess
100
100
  gitBranch: settings.gitBranch,
101
101
  sessionId: newSessionId,
102
102
  worktreeBasePath: project.worktreePath || null,
103
+ commitAttributionOverride:
104
+ resolveProviderMetadataFromModel(settings.model)?.commitAttributionOverride ?? null,
103
105
  });
104
106
  return { workingDirectory: gitSetup.workingDirectory, gitWorktree: gitSetup.gitWorktree };
105
107
  }
@@ -1,4 +1,4 @@
1
- /** @type {Map<string, {inputTokens: number, outputTokens: number, lastMessageOutput: number, cacheReadInputTokens: number, cacheCreationInputTokens: number}>}
1
+ /** @type {Map<string, {inputTokens: number, outputTokens: number, thinkingTokens: number, lastMessageOutput: number, cacheReadInputTokens: number, cacheCreationInputTokens: number}>}
2
2
  * Current turn usage - accumulates across multiple messages within a turn
3
3
  * Keyed by conversationId (Issue #175)
4
4
  * - inputTokens: MAX seen across all messages (larger context with tool results)
@@ -31,6 +31,7 @@ function updateTurnUsage(conversationId, usage, eventType) {
31
31
  const current = currentTurnUsage.get(conversationId) || {
32
32
  inputTokens: 0,
33
33
  outputTokens: 0,
34
+ thinkingTokens: 0,
34
35
  lastMessageOutput: 0,
35
36
  cacheReadInputTokens: 0,
36
37
  cacheCreationInputTokens: 0,
@@ -46,11 +47,13 @@ function updateTurnUsage(conversationId, usage, eventType) {
46
47
  estimatedOutputTokens.delete(conversationId);
47
48
  // 4. For input tokens, keep the MAX (larger context with tool results)
48
49
  current.inputTokens = Math.max(current.inputTokens, usage.input_tokens || 0);
50
+ current.thinkingTokens = Math.max(current.thinkingTokens, usage.thinking_tokens || usage.thinkingTokens || 0);
49
51
  current.cacheReadInputTokens = Math.max(current.cacheReadInputTokens, usage.cache_read_input_tokens || 0);
50
52
  current.cacheCreationInputTokens = Math.max(current.cacheCreationInputTokens, usage.cache_creation_input_tokens || 0);
51
53
  } else if (eventType === 'message_delta') {
52
54
  // OUTPUT STREAMING - output_tokens is cumulative within this message
53
55
  current.lastMessageOutput = usage.output_tokens || 0;
56
+ current.thinkingTokens = Math.max(current.thinkingTokens, usage.thinking_tokens || usage.thinkingTokens || 0);
54
57
  // Clear estimate when actual output tokens arrive
55
58
  estimatedOutputTokens.delete(conversationId);
56
59
  }
@@ -61,6 +64,7 @@ function updateTurnUsage(conversationId, usage, eventType) {
61
64
  return {
62
65
  inputTokens: current.inputTokens,
63
66
  outputTokens: current.outputTokens + current.lastMessageOutput,
67
+ thinkingTokens: current.thinkingTokens,
64
68
  cacheReadInputTokens: current.cacheReadInputTokens,
65
69
  cacheCreationInputTokens: current.cacheCreationInputTokens,
66
70
  };
@@ -0,0 +1,123 @@
1
+ import { sessions, conversations, messages } from '../database.js';
2
+ import { broadcastToSession } from '../websocket.js';
3
+ import { WS_MESSAGE_TYPES } from '../../../shared/src/index.js';
4
+
5
+ export function normalizeFinalErrorMessage(error) {
6
+ if (error?.message) {
7
+ return error.message;
8
+ }
9
+ if (typeof error === 'string') {
10
+ return error;
11
+ }
12
+ if (error == null) {
13
+ return 'Unknown error';
14
+ }
15
+ return String(error);
16
+ }
17
+
18
+ function normalizeMessageContent(content) {
19
+ return (content || '').trim().replace(/\s+/g, ' ');
20
+ }
21
+
22
+ function buildVisibleErrorContent(agentType, errorMessage) {
23
+ if (agentType === 'codex') {
24
+ return `Codex failed before completing this turn:\n\n${errorMessage}`;
25
+ }
26
+ if (agentType === 'claude-code') {
27
+ return `Claude Code failed before completing this turn:\n\n${errorMessage}`;
28
+ }
29
+ return `The agent failed before completing this turn:\n\n${errorMessage}`;
30
+ }
31
+
32
+ /**
33
+ * Check if a visible error message already exists to prevent duplicates.
34
+ *
35
+ * Uses two strategies to detect existing failures:
36
+ *
37
+ * Strategy 1: Exact match with generated content
38
+ * - Checks if the latest message is an assistant message with content exactly matching
39
+ * what we would generate (e.g., "Codex failed before completing this turn:\n\nerror")
40
+ * - This catches cases where we already created the formatted error message
41
+ *
42
+ * Strategy 2: Raw error in messages after latest user message
43
+ * - Finds the latest user message, then checks all subsequent assistant messages
44
+ * - Returns true if any assistant message contains the raw error text
45
+ * - This catches cases where the agent itself already reported the error
46
+ * (e.g., "Run failed: usage limit reached" contains "usage limit reached")
47
+ *
48
+ * @param {Array} conversationMessages - All messages in the conversation
49
+ * @param {string} generatedContent - The formatted error content we would create
50
+ * @param {string} rawErrorMessage - The raw error message text
51
+ * @returns {boolean} True if a duplicate error message already exists
52
+ */
53
+ function hasExistingVisibleFailure(conversationMessages, generatedContent, rawErrorMessage) {
54
+ const normalizedGenerated = normalizeMessageContent(generatedContent);
55
+ const normalizedError = normalizeMessageContent(rawErrorMessage);
56
+ const latestMessage = conversationMessages[conversationMessages.length - 1];
57
+
58
+ // Strategy 1: Check if the latest message is an exact match with our generated content
59
+ if (
60
+ latestMessage?.role === 'assistant' &&
61
+ normalizeMessageContent(latestMessage.content) === normalizedGenerated
62
+ ) {
63
+ return true;
64
+ }
65
+
66
+ // Find the index of the latest user message
67
+ let latestUserIndex = -1;
68
+ for (let i = conversationMessages.length - 1; i >= 0; i -= 1) {
69
+ if (conversationMessages[i].role === 'user') {
70
+ latestUserIndex = i;
71
+ break;
72
+ }
73
+ }
74
+
75
+ // Strategy 2: Check if any assistant message after the latest user message contains the raw error
76
+ return conversationMessages
77
+ .slice(latestUserIndex + 1)
78
+ .some((message) => {
79
+ if (message.role !== 'assistant') {
80
+ return false;
81
+ }
82
+ const normalizedContent = normalizeMessageContent(message.content);
83
+ return normalizedContent === normalizedGenerated ||
84
+ (normalizedError && normalizedContent.includes(normalizedError));
85
+ });
86
+ }
87
+
88
+ function resolveErrorConversationId(sessionId, activeConversationIds) {
89
+ const activeConversationId = activeConversationIds.get(sessionId);
90
+ if (activeConversationId) {
91
+ return activeConversationId;
92
+ }
93
+ const activeConversation = conversations.ensureActiveConversation(sessionId);
94
+ if (activeConversation?.id) {
95
+ activeConversationIds.set(sessionId, activeConversation.id);
96
+ return activeConversation.id;
97
+ }
98
+ return null;
99
+ }
100
+
101
+ export function createVisibleFinalErrorMessage(sessionId, error, activeConversationIds) {
102
+ const conversationId = resolveErrorConversationId(sessionId, activeConversationIds);
103
+ if (!conversationId) {
104
+ return null;
105
+ }
106
+
107
+ const session = sessions.getById(sessionId);
108
+ const errorMessage = normalizeFinalErrorMessage(error);
109
+ const content = buildVisibleErrorContent(session?.agentType, errorMessage);
110
+ const conversationMessages = messages.getByConversationId(conversationId) || [];
111
+
112
+ if (hasExistingVisibleFailure(conversationMessages, content, errorMessage)) {
113
+ return null;
114
+ }
115
+
116
+ const message = messages.create(sessionId, 'assistant', content, { conversationId });
117
+ sessions.touch(sessionId);
118
+ broadcastToSession(sessionId, WS_MESSAGE_TYPES.SESSION_MESSAGE, {
119
+ message,
120
+ conversationId,
121
+ });
122
+ return message;
123
+ }
@@ -4,8 +4,7 @@ export const API_PREFIX = '/api';
4
4
  export const WS_PATH = '/ws';
5
5
 
6
6
  /**
7
- * Default token cost weights for calculating Billable Token Equivalent (BTE)
8
- * These weights represent relative costs compared to input tokens (1.0 = base rate)
7
+ * Legacy token cost weights kept for settings API compatibility.
9
8
  */
10
9
  export const DEFAULT_TOKEN_COST_WEIGHTS = {
11
10
  input: 1.0, // Base rate
@@ -21,6 +20,9 @@ export const WS_RECONNECT_MAX_DELAY = 30000;
21
20
 
22
21
  export const TOAST_DURATION = 5000;
23
22
 
23
+ /** Default delay (in minutes) before auto-rescheduling a session. */
24
+ export const DEFAULT_RESCHEDULE_DELAY_MINUTES = 60;
25
+
24
26
  export const DEFAULT_SYSTEM_PROMPT = `You are Claude Code, an AI coding assistant. You help users with software engineering tasks including writing code, debugging, refactoring, and explaining code. You have full access to the shell and can execute any commands needed to assist the user. Be helpful, accurate, and thorough.
25
27
 
26
28
  IMPORTANT: Your working directory is already set correctly for this session. NEVER use \`cd\` to change to a hardcoded project path before running commands (e.g., \`cd /path/to/project && git status\`). This bypasses git worktree isolation and causes commands to run in the wrong directory. Always run commands directly without changing directory.
@@ -1,15 +1,29 @@
1
1
  import { z } from 'zod';
2
2
 
3
+ const OPTION_TOKEN_DASHES = /(^|\s)([-\u2010-\u2015\u2212]+)(?=[A-Za-z0-9-])/g;
4
+ const UNICODE_DASHES = /[\u2010-\u2015\u2212]/g;
5
+ const HAS_UNICODE_DASH = /[\u2010-\u2015\u2212]/;
6
+
7
+ export function normalizeCommandOptionDashes(command) {
8
+ return command.replace(OPTION_TOKEN_DASHES, (match, prefix, dashes) => {
9
+ if (!HAS_UNICODE_DASH.test(dashes)) {
10
+ return match;
11
+ }
12
+
13
+ return `${prefix}${dashes.replace(UNICODE_DASHES, '-')}`;
14
+ });
15
+ }
16
+
3
17
  export const CreateCommandButtonRequest = z.object({
4
18
  label: z.string().min(1, 'Label is required'),
5
- command: z.string().min(1, 'Command is required'),
19
+ command: z.string().min(1, 'Command is required').transform(normalizeCommandOptionDashes),
6
20
  sortOrder: z.number().int().optional().default(0),
7
21
  showOnList: z.boolean().optional().default(false),
8
22
  });
9
23
 
10
24
  export const UpdateCommandButtonRequest = z.object({
11
25
  label: z.string().min(1).optional(),
12
- command: z.string().min(1).optional(),
26
+ command: z.string().min(1).transform(normalizeCommandOptionDashes).optional(),
13
27
  sortOrder: z.number().int().optional(),
14
28
  showOnList: z.boolean().optional(),
15
29
  }).refine(obj => Object.keys(obj).length > 0, 'At least one field must be provided for update');
@@ -46,9 +46,10 @@ export const ProjectSessionDefaultsRequest = z.object({
46
46
  thinkingEnabled: z.boolean().nullable().optional(),
47
47
  effortLevel: z.enum(['low', 'medium', 'high', 'max', 'auto']).nullable().optional(),
48
48
  startImmediately: z.boolean().nullable().optional(),
49
- gitMode: z.enum(['branch', 'worktree']).nullable().optional(),
49
+ gitMode: z.enum(['branch', 'worktree', 'current']).nullable().optional(),
50
50
  gitBranch: z.string().nullable().optional(),
51
51
  model: z.string().nullable().optional(),
52
+ providerId: z.string().nullable().optional(),
52
53
  });
53
54
 
54
55
  export const ProjectSessionDefaultsResponse = z.object({
@@ -58,9 +59,10 @@ export const ProjectSessionDefaultsResponse = z.object({
58
59
  thinkingEnabled: z.boolean().nullable(),
59
60
  effortLevel: z.enum(['low', 'medium', 'high', 'max', 'auto']).nullable(),
60
61
  startImmediately: z.boolean().nullable(),
61
- gitMode: z.enum(['branch', 'worktree']).nullable(),
62
+ gitMode: z.enum(['branch', 'worktree', 'current']).nullable(),
62
63
  gitBranch: z.string().nullable(),
63
64
  model: z.string().nullable(),
65
+ providerId: z.string().nullable(),
64
66
  createdAt: z.number(),
65
67
  updatedAt: z.number(),
66
68
  });