@loicngr/kobo 1.7.5 → 1.7.7

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 (106) hide show
  1. package/README.md +92 -3
  2. package/dist/mcp-server/kobo-tasks-handlers.js +15 -0
  3. package/dist/mcp-server/kobo-tasks-server.js +117 -8
  4. package/dist/server/db/migrations.js +38 -0
  5. package/dist/server/db/schema.js +16 -0
  6. package/dist/server/index.js +4 -0
  7. package/dist/server/routes/health.js +68 -3
  8. package/dist/server/routes/voice.js +149 -0
  9. package/dist/server/routes/workspaces.js +102 -1
  10. package/dist/server/services/agent/engines/claude-code/engine.js +13 -9
  11. package/dist/server/services/agent/engines/claude-code/event-mapper.js +95 -10
  12. package/dist/server/services/agent/orchestrator.js +41 -0
  13. package/dist/server/services/auto-loop-service.js +8 -3
  14. package/dist/server/services/cron-service.js +279 -0
  15. package/dist/server/services/settings-service.js +57 -0
  16. package/dist/server/services/transcription-service.js +206 -0
  17. package/dist/server/services/wakeup-service.js +1 -1
  18. package/dist/server/services/workspace-service.js +18 -0
  19. package/dist/server/utils/git-ops.js +8 -1
  20. package/package.json +13 -10
  21. package/src/client/dist/spa/assets/{ActivityFeed-oW9PgZ8E.js → ActivityFeed-DlPVoOGb.js} +2 -2
  22. package/src/client/dist/spa/assets/{ActivityFeed-DVBfmJWJ.css → ActivityFeed-tE4LVYck.css} +1 -1
  23. package/src/client/dist/spa/assets/{AutoLoopChip-Y53cnGfZ.js → AutoLoopChip-CkSzkC0C.js} +1 -1
  24. package/src/client/dist/spa/assets/{ClosePopup-D_UAdwkA.js → ClosePopup-DTcbxsC0.js} +1 -1
  25. package/src/client/dist/spa/assets/CreatePage-BoRappO3.css +1 -0
  26. package/src/client/dist/spa/assets/CreatePage-DpCVNwYk.js +2 -0
  27. package/src/client/dist/spa/assets/{DiffViewer-rc3tE9fq.js → DiffViewer-D-uNbBq0.js} +3 -3
  28. package/src/client/dist/spa/assets/{DiffViewer-wFfQ9tcY.css → DiffViewer-DTdDcKZC.css} +1 -1
  29. package/src/client/dist/spa/assets/HealthPage-xZ0PP4F-.js +1 -0
  30. package/src/client/dist/spa/assets/{MainLayout-B9i06p7n.js → MainLayout-DdkKM2ba.js} +17 -17
  31. package/src/client/dist/spa/assets/MainLayout-drolsINz.css +1 -0
  32. package/src/client/dist/spa/assets/{QBadge-DWH42dbo.js → QBadge-C7r6oPSi.js} +1 -1
  33. package/src/client/dist/spa/assets/{QBtn-a6jxWjmW.js → QBtn-DEuWKHbR.js} +1 -1
  34. package/src/client/dist/spa/assets/{QCheckbox-D5jfsxLV.js → QCheckbox-BvHfXBFY.js} +1 -1
  35. package/src/client/dist/spa/assets/{QChip-ByxK0Tuf.js → QChip-erWIZgxW.js} +1 -1
  36. package/src/client/dist/spa/assets/{QExpansionItem-CH1ipL9n.js → QExpansionItem-BGg74no1.js} +1 -1
  37. package/src/client/dist/spa/assets/QIcon-qfJNZLIW.js +1 -0
  38. package/src/client/dist/spa/assets/{QInput-Cm5-AGQ4.js → QInput-DCJEwE8V.js} +1 -1
  39. package/src/client/dist/spa/assets/{QItemLabel-DrTxqTqV.js → QItemLabel-CHkgkZVj.js} +1 -1
  40. package/src/client/dist/spa/assets/{QItemSection-5YpFpPDm.js → QItemSection-CQUDd0Vg.js} +1 -1
  41. package/src/client/dist/spa/assets/{QList-D0FtnQJI.js → QList-BbnN_oNX.js} +1 -1
  42. package/src/client/dist/spa/assets/{QMenu-B4xMxMGd.js → QMenu-D6uqosRg.js} +1 -1
  43. package/src/client/dist/spa/assets/{QPage-DFi3K093.js → QPage-Co2h9wd_.js} +1 -1
  44. package/src/client/dist/spa/assets/{QRadio-B3aKjCVu.js → QRadio-DJxOyOA3.js} +1 -1
  45. package/src/client/dist/spa/assets/QSpace-DKIph84L.js +1 -0
  46. package/src/client/dist/spa/assets/{QSpinnerDots-CszPQQ9J.js → QSpinnerDots-Bfl2RMy4.js} +1 -1
  47. package/src/client/dist/spa/assets/{QTabPanels-D2ks0UIA.js → QTabPanels-ClPY9y4T.js} +1 -1
  48. package/src/client/dist/spa/assets/{QToggle-1-N9qWq4.js → QToggle-DNOTC_3a.js} +1 -1
  49. package/src/client/dist/spa/assets/{QTooltip-fDNzBEfN.js → QTooltip-DUGPNNeQ.js} +1 -1
  50. package/src/client/dist/spa/assets/{SearchPage-DdX7JZCD.js → SearchPage-C07dgzT9.js} +1 -1
  51. package/src/client/dist/spa/assets/SettingsPage-CLNmI0Rr.css +1 -0
  52. package/src/client/dist/spa/assets/SettingsPage-D0CZNqkA.js +9 -0
  53. package/src/client/dist/spa/assets/{TouchPan-DoE24Io3.js → TouchPan-DvVlszwO.js} +1 -1
  54. package/src/client/dist/spa/assets/WorkspacePage-CKeCLPi0.js +4 -0
  55. package/src/client/dist/spa/assets/WorkspacePage-CRIcsASQ.css +1 -0
  56. package/src/client/dist/spa/assets/{build-path-tree-B1Lvvqto.js → build-path-tree-CCMckvpr.js} +1 -1
  57. package/src/client/dist/spa/assets/{cssMode-DSB5jkRt.js → cssMode-D6XTTdwy.js} +1 -1
  58. package/src/client/dist/spa/assets/{documents-kx0vLfSG.js → documents-soWtna0O.js} +1 -1
  59. package/src/client/dist/spa/assets/{editor.api-Bcw50eFD.js → editor.api-6hDVHddO.js} +1 -1
  60. package/src/client/dist/spa/assets/{editor.main-D9piVGaH.js → editor.main-DsLU1RWu.js} +3 -3
  61. package/src/client/dist/spa/assets/{expand-template-BIPuNAYV.js → expand-template-Crz1uiBt.js} +1 -1
  62. package/src/client/dist/spa/assets/{formatters-DCAQ6ANJ.js → formatters-guwb-rzl.js} +1 -1
  63. package/src/client/dist/spa/assets/{freemarker2-CVh_Zh8H.js → freemarker2-Bn1f0t2U.js} +1 -1
  64. package/src/client/dist/spa/assets/{handlebars-CpCgELpu.js → handlebars-O92Cbq66.js} +1 -1
  65. package/src/client/dist/spa/assets/{html-ikWDpvWk.js → html-Ck95BMBU.js} +1 -1
  66. package/src/client/dist/spa/assets/{htmlMode-C9TTCKih.js → htmlMode-DDYhH2FJ.js} +1 -1
  67. package/src/client/dist/spa/assets/i18n-BLgknHpf.js +1 -0
  68. package/src/client/dist/spa/assets/index-CdHDdk1y.js +2 -0
  69. package/src/client/dist/spa/assets/{javascript-C4OlkNeA.js → javascript-Cy2ddqHg.js} +1 -1
  70. package/src/client/dist/spa/assets/{jsonMode-BiD34_86.js → jsonMode-BIfVcp5z.js} +1 -1
  71. package/src/client/dist/spa/assets/{liquid-Dty0Ui2c.js → liquid-B287eegh.js} +1 -1
  72. package/src/client/dist/spa/assets/{mdx-yiUjOVv6.js → mdx-B8HSzGai.js} +1 -1
  73. package/src/client/dist/spa/assets/{models-BDkLiht9.js → models-Bd_v3W7Q.js} +1 -1
  74. package/src/client/dist/spa/assets/{monaco.contribution-Bz9yFPWR.js → monaco.contribution-CofcHzEf.js} +2 -2
  75. package/src/client/dist/spa/assets/{notifications-OnPq4FrH.js → notifications-BPnKFW60.js} +1 -1
  76. package/src/client/dist/spa/assets/{purify.es-BIY760fF.js → purify.es-BCEwTYRx.js} +1 -1
  77. package/src/client/dist/spa/assets/{python-7SPSWQoD.js → python-csaKR6_U.js} +1 -1
  78. package/src/client/dist/spa/assets/{razor-eagZawXK.js → razor-C2wEv-nX.js} +1 -1
  79. package/src/client/dist/spa/assets/{render-chat-markdown-TvAqpDih.js → render-chat-markdown-Bjcei0vn.js} +1 -1
  80. package/src/client/dist/spa/assets/runtime-core.esm-bundler-9Z0QAO_7.js +1 -0
  81. package/src/client/dist/spa/assets/{tsMode-CLYG2xeJ.js → tsMode-DGLVs57K.js} +1 -1
  82. package/src/client/dist/spa/assets/{typescript-CzOXM8yS.js → typescript-w0GWHzZ3.js} +1 -1
  83. package/src/client/dist/spa/assets/{use-checkbox-D7zmRxGI.js → use-checkbox-y_fOkYZN.js} +1 -1
  84. package/src/client/dist/spa/assets/{use-id-CuaR1RiE.js → use-id-_7wiRcgb.js} +1 -1
  85. package/src/client/dist/spa/assets/{use-panel-D-8nAQns.js → use-panel-CbJ44rqY.js} +1 -1
  86. package/src/client/dist/spa/assets/use-quasar-DQYS47mh.js +1 -0
  87. package/src/client/dist/spa/assets/{vue-i18n-BcfTCFFS.js → vue-i18n-DI-gS-CC.js} +1 -1
  88. package/src/client/dist/spa/assets/{xml-2_0_6RAX.js → xml-CTn-vnEd.js} +1 -1
  89. package/src/client/dist/spa/assets/{yaml-CtpgNyXs.js → yaml-CTyUSvLZ.js} +1 -1
  90. package/src/client/dist/spa/index.html +12 -12
  91. package/src/mcp-server/kobo-tasks-handlers.ts +20 -0
  92. package/src/mcp-server/kobo-tasks-server.ts +123 -7
  93. package/src/client/dist/spa/assets/CreatePage-CuD7sMR7.js +0 -2
  94. package/src/client/dist/spa/assets/CreatePage-DssmsAsV.css +0 -1
  95. package/src/client/dist/spa/assets/HealthPage-Dz0yGGMB.js +0 -1
  96. package/src/client/dist/spa/assets/MainLayout-DDa3rGKA.css +0 -1
  97. package/src/client/dist/spa/assets/QIcon-BJuyqdsT.js +0 -1
  98. package/src/client/dist/spa/assets/QSpace-CLtL3aPy.js +0 -1
  99. package/src/client/dist/spa/assets/SettingsPage-CMyeQ9_u.css +0 -1
  100. package/src/client/dist/spa/assets/SettingsPage-Dnj1CWc3.js +0 -1
  101. package/src/client/dist/spa/assets/WorkspacePage-CCtIrBiR.css +0 -1
  102. package/src/client/dist/spa/assets/WorkspacePage-DHp20nl-.js +0 -4
  103. package/src/client/dist/spa/assets/i18n-DZCb8dnb.js +0 -1
  104. package/src/client/dist/spa/assets/index-DuK38XN5.js +0 -2
  105. package/src/client/dist/spa/assets/runtime-core.esm-bundler-C3IgBgY5.js +0 -1
  106. package/src/client/dist/spa/assets/use-quasar-Sdcq6zzV.js +0 -1
@@ -0,0 +1,279 @@
1
+ import { CronExpressionParser } from 'cron-parser';
2
+ import { nanoid } from 'nanoid';
3
+ import { getDb } from '../db/index.js';
4
+ import { slugifyProjectName } from '../utils/project-slug.js';
5
+ import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
6
+ import * as orchestrator from './agent/orchestrator.js';
7
+ import * as settingsService from './settings-service.js';
8
+ import { emitEphemeral } from './websocket-service.js';
9
+ export const MIN_DELAY_BETWEEN_FIRES_SECONDS = 60;
10
+ // Node `setTimeout` stores the delay as a 32-bit signed int. Anything above
11
+ // 2^31-1 ms (~24.8 days) triggers `TimeoutOverflowWarning` and silently
12
+ // truncates to 1ms — which causes the timer to fire instantly and loop
13
+ // when the real target is years away (e.g. `@yearly` or `0 0 1 1 *`).
14
+ // We cap each setTimeout at this max and re-arm in the callback if the
15
+ // real fire time hasn't been reached yet.
16
+ const MAX_SETTIMEOUT_MS = 2_000_000_000; // ~23 days, with margin under 2^31-1
17
+ const timers = new Map();
18
+ /**
19
+ * Arm a setTimeout that fires `fireOrSkip(id)` when `fireAt` is reached.
20
+ * Replaces any existing timer for this id. Handles long horizons by
21
+ * chaining capped timeouts, since Node's setTimeout overflows past ~24.8
22
+ * days.
23
+ */
24
+ function scheduleAt(id, fireAt) {
25
+ const previous = timers.get(id);
26
+ if (previous)
27
+ clearTimeout(previous);
28
+ const deltaMs = Math.max(0, fireAt.getTime() - Date.now());
29
+ const cappedMs = Math.min(deltaMs, MAX_SETTIMEOUT_MS);
30
+ const timer = setTimeout(() => {
31
+ timers.delete(id);
32
+ if (Date.now() >= fireAt.getTime()) {
33
+ fireOrSkip(id);
34
+ }
35
+ else {
36
+ // Long-horizon cron: hop forward another chunk and re-check.
37
+ scheduleAt(id, fireAt);
38
+ }
39
+ }, cappedMs);
40
+ timer.unref?.();
41
+ timers.set(id, timer);
42
+ }
43
+ function rowToCron(row) {
44
+ return {
45
+ id: row.id,
46
+ workspaceId: row.workspace_id,
47
+ expression: row.expression,
48
+ prompt: row.prompt,
49
+ label: row.label,
50
+ agentSessionId: row.agent_session_id,
51
+ nextFireAt: row.next_fire_at,
52
+ lastFiredAt: row.last_fired_at,
53
+ oneShot: row.one_shot === 1,
54
+ createdAt: row.created_at,
55
+ };
56
+ }
57
+ /**
58
+ * Compute the next fire time for an expression strictly after `from`.
59
+ * Uses cron-parser 5.x API. Throws with a descriptive error if the
60
+ * expression is invalid. Helpers `@hourly` / `@daily` / `@weekly` /
61
+ * `@monthly` / `@yearly` are accepted natively.
62
+ */
63
+ function nextAfter(expression, from) {
64
+ try {
65
+ const it = CronExpressionParser.parse(expression, { currentDate: from });
66
+ return it.next().toDate();
67
+ }
68
+ catch (err) {
69
+ const msg = err instanceof Error ? err.message : String(err);
70
+ throw new Error(`Invalid cron expression: ${expression} — ${msg}`);
71
+ }
72
+ }
73
+ /**
74
+ * Validate the expression, persist the row, arm a setTimeout for the next
75
+ * fire, emit `cron:created`. Throws on invalid expression OR when the next
76
+ * fire is < MIN_DELAY_BETWEEN_FIRES_SECONDS seconds in the future.
77
+ */
78
+ export function arm(workspaceId, args) {
79
+ const now = new Date();
80
+ const next = nextAfter(args.expression, now);
81
+ const deltaMs = next.getTime() - now.getTime();
82
+ if (deltaMs < MIN_DELAY_BETWEEN_FIRES_SECONDS * 1000) {
83
+ throw new Error(`Cron expression resolves too close to now (minimum ${MIN_DELAY_BETWEEN_FIRES_SECONDS}s); use a longer interval`);
84
+ }
85
+ const id = nanoid();
86
+ const db = getDb();
87
+ db.prepare(`INSERT INTO pending_crons (id, workspace_id, expression, prompt, label, agent_session_id, next_fire_at, last_fired_at, one_shot, created_at)
88
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(id, workspaceId, args.expression, args.prompt, args.label ?? null, args.agentSessionId ?? null, next.toISOString(), args.oneShot ? 1 : 0, now.toISOString());
89
+ scheduleAt(id, next);
90
+ const cron = rowToCron(db.prepare('SELECT * FROM pending_crons WHERE id = ?').get(id));
91
+ emitEphemeral(workspaceId, 'cron:created', { cron });
92
+ return cron;
93
+ }
94
+ /**
95
+ * Remove a single cron by id. Idempotent — returns false if no row matched.
96
+ * Emits `cron:cancelled` only when a row was actually deleted.
97
+ */
98
+ export function cancel(id, reason) {
99
+ const db = getDb();
100
+ const row = db.prepare('SELECT workspace_id FROM pending_crons WHERE id = ?').get(id);
101
+ if (!row)
102
+ return false;
103
+ db.prepare('DELETE FROM pending_crons WHERE id = ?').run(id);
104
+ const previous = timers.get(id);
105
+ if (previous) {
106
+ clearTimeout(previous);
107
+ timers.delete(id);
108
+ }
109
+ emitEphemeral(row.workspace_id, 'cron:cancelled', { id, reason });
110
+ return true;
111
+ }
112
+ /**
113
+ * Remove every cron for a workspace. Returns the number of rows deleted.
114
+ * Used by archive + delete cascades.
115
+ */
116
+ export function cancelAllForWorkspace(workspaceId, reason) {
117
+ const db = getDb();
118
+ const rows = db.prepare('SELECT id FROM pending_crons WHERE workspace_id = ?').all(workspaceId);
119
+ let deleted = 0;
120
+ for (const r of rows) {
121
+ if (cancel(r.id, reason))
122
+ deleted++;
123
+ }
124
+ return deleted;
125
+ }
126
+ export function getCron(id) {
127
+ const db = getDb();
128
+ const row = db.prepare('SELECT * FROM pending_crons WHERE id = ?').get(id);
129
+ return row ? rowToCron(row) : null;
130
+ }
131
+ export function listForWorkspace(workspaceId) {
132
+ const db = getDb();
133
+ const rows = db
134
+ .prepare('SELECT * FROM pending_crons WHERE workspace_id = ? ORDER BY next_fire_at ASC')
135
+ .all(workspaceId);
136
+ return rows.map(rowToCron);
137
+ }
138
+ export function listAll() {
139
+ const db = getDb();
140
+ const rows = db.prepare('SELECT * FROM pending_crons ORDER BY next_fire_at ASC').all();
141
+ return rows.map(rowToCron);
142
+ }
143
+ /**
144
+ * Internal — invoked by setTimeout when a cron's `next_fire_at` elapses.
145
+ * Either fires (calls orchestrator.startAgent with resume=true) or skips
146
+ * (when a controller is already active for the workspace), then recomputes
147
+ * the next occurrence and re-arms a fresh setTimeout. Best-effort: any
148
+ * unexpected error is logged and the cron is preserved when possible.
149
+ */
150
+ function fireOrSkip(id) {
151
+ try {
152
+ timers.delete(id);
153
+ const db = getDb();
154
+ const row = db.prepare('SELECT * FROM pending_crons WHERE id = ?').get(id);
155
+ if (!row)
156
+ return; // cancelled in flight
157
+ const wsRow = db
158
+ .prepare(`SELECT project_path, working_branch, worktree_path, model, agent_permission_mode, reasoning_effort, archived_at
159
+ FROM workspaces WHERE id = ?`)
160
+ .get(row.workspace_id);
161
+ if (!wsRow || wsRow.archived_at !== null) {
162
+ cancel(id, wsRow ? 'archive' : 'deleted');
163
+ return;
164
+ }
165
+ let status = 'skipped-active';
166
+ if (!orchestrator.hasController(row.workspace_id)) {
167
+ status = 'fired';
168
+ try {
169
+ const globalSettings = settingsService.getGlobalSettings();
170
+ const projectSettings = settingsService.getProjectSettings(wsRow.project_path);
171
+ const projectSlug = globalSettings.worktreesPrefixByProject
172
+ ? slugifyProjectName(projectSettings?.displayName ?? '', wsRow.project_path)
173
+ : undefined;
174
+ const worktreePath = wsRow.worktree_path ??
175
+ resolveWorkspaceWorktreePath(wsRow.project_path, wsRow.working_branch, globalSettings.worktreesPath, projectSlug);
176
+ const stored = wsRow.agent_permission_mode;
177
+ const agentPermissionMode = stored === 'plan' || stored === 'strict' || stored === 'interactive' ? stored : 'bypass';
178
+ // agent_session_id encodes the cron's mode: non-NULL means "resume
179
+ // that session" (pinned at create time); NULL means "fresh session
180
+ // every fire" (clean context, no conversation continuity).
181
+ const resumeMode = row.agent_session_id !== null;
182
+ orchestrator.startAgent(row.workspace_id, worktreePath, row.prompt, wsRow.model, resumeMode, agentPermissionMode, row.agent_session_id ?? undefined, wsRow.reasoning_effort);
183
+ }
184
+ catch (err) {
185
+ console.error(`[cron-service] startAgent at fire time failed for cron '${id}':`, err);
186
+ }
187
+ }
188
+ const now = new Date();
189
+ let nextFire;
190
+ try {
191
+ nextFire = nextAfter(row.expression, now);
192
+ }
193
+ catch (err) {
194
+ // Defensive — the expression validated at create time, so reaching here
195
+ // means cron-parser changed its acceptance rules between the original
196
+ // arm and this fire. Cancel as 'completed' (the cron self-terminates)
197
+ // rather than 'user' which would imply the user requested it.
198
+ console.error(`[cron-service] failed to recompute next fire for cron '${id}':`, err);
199
+ cancel(id, 'completed');
200
+ return;
201
+ }
202
+ // One-shot crons cancel themselves after a real fire (not on skip-active —
203
+ // the user expects the cron to actually run once, so a skipped tick must
204
+ // be retried at the next occurrence). Recurring crons re-arm normally.
205
+ if (status === 'fired' && row.one_shot === 1) {
206
+ db.prepare(`UPDATE pending_crons SET last_fired_at = ? WHERE id = ?`).run(now.toISOString(), id);
207
+ emitEphemeral(row.workspace_id, 'cron:fired', {
208
+ id,
209
+ status,
210
+ nextFireAt: null,
211
+ lastFiredAt: now.toISOString(),
212
+ oneShotConsumed: true,
213
+ });
214
+ cancel(id, 'completed');
215
+ return;
216
+ }
217
+ db.prepare(`UPDATE pending_crons SET next_fire_at = ?, last_fired_at = ? WHERE id = ?`).run(nextFire.toISOString(), now.toISOString(), id);
218
+ scheduleAt(id, nextFire);
219
+ emitEphemeral(row.workspace_id, 'cron:fired', {
220
+ id,
221
+ status,
222
+ nextFireAt: nextFire.toISOString(),
223
+ lastFiredAt: now.toISOString(),
224
+ });
225
+ }
226
+ catch (err) {
227
+ console.error(`[cron-service] fireOrSkip uncaught error for cron '${id}':`, err);
228
+ timers.delete(id);
229
+ }
230
+ }
231
+ /**
232
+ * Re-arm timers for rows persisted across restart. Skip-missed semantics:
233
+ * if the stored next_fire_at is in the past, recompute next() based on the
234
+ * current time and update the row before arming (mirror of POSIX crontab —
235
+ * no catchup spam after server downtime).
236
+ *
237
+ * Rows pointing at deleted/archived workspaces are removed without firing.
238
+ */
239
+ export function restoreOnBoot() {
240
+ try {
241
+ // Boot semantics: clear any existing in-memory timers before rearming.
242
+ for (const t of timers.values())
243
+ clearTimeout(t);
244
+ timers.clear();
245
+ const db = getDb();
246
+ const rows = db.prepare('SELECT * FROM pending_crons').all();
247
+ const now = new Date();
248
+ for (const row of rows) {
249
+ try {
250
+ const wsRow = db.prepare('SELECT archived_at FROM workspaces WHERE id = ?').get(row.workspace_id);
251
+ if (!wsRow || wsRow.archived_at !== null) {
252
+ db.prepare('DELETE FROM pending_crons WHERE id = ?').run(row.id);
253
+ continue;
254
+ }
255
+ const storedDate = new Date(row.next_fire_at);
256
+ let nextFireAt;
257
+ if (storedDate.getTime() <= now.getTime()) {
258
+ // Skip-missed semantics — no catchup spam after downtime.
259
+ const next = nextAfter(row.expression, now);
260
+ nextFireAt = next.toISOString();
261
+ }
262
+ else {
263
+ // Future row: normalise the persisted ISO format.
264
+ nextFireAt = storedDate.toISOString();
265
+ }
266
+ db.prepare('UPDATE pending_crons SET next_fire_at = ? WHERE id = ?').run(nextFireAt, row.id);
267
+ scheduleAt(row.id, new Date(nextFireAt));
268
+ }
269
+ catch (err) {
270
+ console.error(`[cron-service] restoreOnBoot row failed for cron '${row.id}':`, err);
271
+ }
272
+ }
273
+ }
274
+ catch (err) {
275
+ console.error('[cron-service] restoreOnBoot failed:', err);
276
+ }
277
+ }
278
+ /** @internal test-only */
279
+ export const _timers = timers;
@@ -266,6 +266,39 @@ const settingsMigrations = [
266
266
  }
267
267
  },
268
268
  },
269
+ {
270
+ version: 17,
271
+ name: 'add-voice-transcription-settings',
272
+ migrate({ global }) {
273
+ if (typeof global.voiceEnabled !== 'boolean')
274
+ global.voiceEnabled = false;
275
+ if (global.voicePttKey !== 'alt' && global.voicePttKey !== 'ctrl+space')
276
+ global.voicePttKey = 'alt';
277
+ if (typeof global.voiceLanguage !== 'string' || global.voiceLanguage.length === 0)
278
+ global.voiceLanguage = 'auto';
279
+ if (typeof global.voiceModel !== 'string' && global.voiceModel !== null)
280
+ global.voiceModel = null;
281
+ if (typeof global.voiceCommandPath !== 'string')
282
+ global.voiceCommandPath = '';
283
+ if (typeof global.voiceFfmpegPath !== 'string')
284
+ global.voiceFfmpegPath = '';
285
+ },
286
+ },
287
+ {
288
+ version: 18,
289
+ name: 'add-voice-advanced-settings',
290
+ migrate({ global }) {
291
+ const t = Number(global.voiceTemperature);
292
+ if (!Number.isFinite(t) || t < 0 || t > 1)
293
+ global.voiceTemperature = 0;
294
+ if (typeof global.voicePrompt !== 'string')
295
+ global.voicePrompt = '';
296
+ if (typeof global.voiceTranslateToEnglish !== 'boolean')
297
+ global.voiceTranslateToEnglish = false;
298
+ if (typeof global.voiceSuppressNonSpeechTokens !== 'boolean')
299
+ global.voiceSuppressNonSpeechTokens = true;
300
+ },
301
+ },
269
302
  ];
270
303
  /** Current settings schema version — always equals the highest migration version. */
271
304
  export const SETTINGS_SCHEMA_VERSION = settingsMigrations.length > 0 ? settingsMigrations[settingsMigrations.length - 1].version : 0;
@@ -313,6 +346,16 @@ function defaultSettings() {
313
346
  tags: [...DEFAULT_WORKSPACE_TAGS],
314
347
  worktreesPath: WORKTREES_PATH,
315
348
  worktreesPrefixByProject: false,
349
+ voiceEnabled: false,
350
+ voicePttKey: 'alt',
351
+ voiceLanguage: 'auto',
352
+ voiceModel: null,
353
+ voiceCommandPath: '',
354
+ voiceFfmpegPath: '',
355
+ voiceTemperature: 0,
356
+ voicePrompt: '',
357
+ voiceTranslateToEnglish: false,
358
+ voiceSuppressNonSpeechTokens: true,
316
359
  },
317
360
  projects: [],
318
361
  };
@@ -551,6 +594,16 @@ export function updateGlobalSettings(data) {
551
594
  'tags',
552
595
  'worktreesPath',
553
596
  'worktreesPrefixByProject',
597
+ 'voiceEnabled',
598
+ 'voicePttKey',
599
+ 'voiceLanguage',
600
+ 'voiceModel',
601
+ 'voiceCommandPath',
602
+ 'voiceFfmpegPath',
603
+ 'voiceTemperature',
604
+ 'voicePrompt',
605
+ 'voiceTranslateToEnglish',
606
+ 'voiceSuppressNonSpeechTokens',
554
607
  ];
555
608
  const filtered = pickKnownKeys(data, allowedGlobalKeys);
556
609
  if (filtered.tags !== undefined) {
@@ -564,6 +617,10 @@ export function updateGlobalSettings(data) {
564
617
  const v = Number(filtered.audioNotificationVolume);
565
618
  filtered.audioNotificationVolume = Number.isFinite(v) ? Math.max(0, Math.min(1, v)) : 1;
566
619
  }
620
+ if (filtered.voiceTemperature !== undefined) {
621
+ const t = Number(filtered.voiceTemperature);
622
+ filtered.voiceTemperature = Number.isFinite(t) ? Math.max(0, Math.min(1, t)) : settings.global.voiceTemperature;
623
+ }
567
624
  if (filtered.worktreesPath !== undefined) {
568
625
  filtered.worktreesPath = validateWorktreesPath(filtered.worktreesPath, { allowEmpty: false });
569
626
  ensureGlobalWorktreesRootExists(filtered.worktreesPath);
@@ -0,0 +1,206 @@
1
+ import { execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { getKoboHome } from '../utils/paths.js';
7
+ import { getGlobalSettings } from './settings-service.js';
8
+ const execFileAsync = promisify(execFile);
9
+ const MAX_LANG_LENGTH = 16;
10
+ export class VoiceError extends Error {
11
+ code;
12
+ status;
13
+ constructor(message, code, status = 400) {
14
+ super(message);
15
+ this.code = code;
16
+ this.status = status;
17
+ this.name = 'VoiceError';
18
+ }
19
+ }
20
+ export const VOICE_MODELS = [
21
+ {
22
+ name: 'tiny',
23
+ fileName: 'ggml-tiny.bin',
24
+ url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin?download=true',
25
+ },
26
+ {
27
+ name: 'base',
28
+ fileName: 'ggml-base.bin',
29
+ url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin?download=true',
30
+ },
31
+ {
32
+ name: 'small',
33
+ fileName: 'ggml-small.bin',
34
+ url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin?download=true',
35
+ },
36
+ {
37
+ name: 'medium',
38
+ fileName: 'ggml-medium.bin',
39
+ url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin?download=true',
40
+ },
41
+ {
42
+ name: 'large-v3',
43
+ fileName: 'ggml-large-v3.bin',
44
+ url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin?download=true',
45
+ },
46
+ ];
47
+ function voiceHome() {
48
+ return path.join(getKoboHome(), 'voice');
49
+ }
50
+ function modelsDir() {
51
+ return path.join(voiceHome(), 'models', 'whisper');
52
+ }
53
+ function resolveWhisperCommand() {
54
+ const global = getGlobalSettings();
55
+ const fromSettings = (global.voiceCommandPath ?? '').trim();
56
+ if (fromSettings.length > 0)
57
+ return fromSettings;
58
+ return process.env.WHISPER_CPP_COMMAND || 'whisper-cli';
59
+ }
60
+ function resolveFfmpegCommand() {
61
+ const global = getGlobalSettings();
62
+ const fromSettings = (global.voiceFfmpegPath ?? '').trim();
63
+ if (fromSettings.length > 0)
64
+ return fromSettings;
65
+ return 'ffmpeg';
66
+ }
67
+ function ensureVoiceDirs() {
68
+ fs.mkdirSync(modelsDir(), { recursive: true });
69
+ }
70
+ function resolveModel(name) {
71
+ const model = VOICE_MODELS.find((m) => m.name === name);
72
+ if (!model)
73
+ throw new VoiceError(`Unknown voice model '${name}'`, 'MODEL_UNKNOWN', 400);
74
+ return model;
75
+ }
76
+ export function listVoiceModels() {
77
+ ensureVoiceDirs();
78
+ const settings = getGlobalSettings();
79
+ const available = VOICE_MODELS.map((m) => ({
80
+ name: m.name,
81
+ fileName: m.fileName,
82
+ installed: fs.existsSync(path.join(modelsDir(), m.fileName)),
83
+ }));
84
+ return { available, activeModel: settings.voiceModel };
85
+ }
86
+ export async function getVoiceRuntimeStatus() {
87
+ const command = resolveWhisperCommand();
88
+ let ffmpegAvailable = true;
89
+ let ffmpegError;
90
+ try {
91
+ await execFileAsync(resolveFfmpegCommand(), ['-version'], { timeout: 5000 });
92
+ }
93
+ catch (err) {
94
+ ffmpegAvailable = false;
95
+ ffmpegError = err instanceof Error ? err.message : String(err);
96
+ }
97
+ try {
98
+ await execFileAsync(command, ['-h'], { timeout: 5000 });
99
+ return { available: ffmpegAvailable, command, ffmpegAvailable, ffmpegError };
100
+ }
101
+ catch (err) {
102
+ const message = err instanceof Error ? err.message : String(err);
103
+ return { available: false, command, error: message, ffmpegAvailable, ffmpegError };
104
+ }
105
+ }
106
+ export async function downloadVoiceModel(name) {
107
+ ensureVoiceDirs();
108
+ const model = resolveModel(name);
109
+ const res = await fetch(model.url);
110
+ if (!res.ok) {
111
+ throw new VoiceError(`Failed to download model '${name}' (HTTP ${res.status})`, 'MODEL_DOWNLOAD_FAILED', 500);
112
+ }
113
+ const filePath = path.join(modelsDir(), model.fileName);
114
+ const tmpPath = `${filePath}.tmp`;
115
+ try {
116
+ const bytes = Buffer.from(await res.arrayBuffer());
117
+ fs.writeFileSync(tmpPath, bytes);
118
+ fs.renameSync(tmpPath, filePath);
119
+ }
120
+ finally {
121
+ if (fs.existsSync(tmpPath))
122
+ fs.rmSync(tmpPath, { force: true });
123
+ }
124
+ return { name, filePath };
125
+ }
126
+ export function deleteVoiceModel(name) {
127
+ const model = resolveModel(name);
128
+ const filePath = path.join(modelsDir(), model.fileName);
129
+ if (fs.existsSync(filePath))
130
+ fs.unlinkSync(filePath);
131
+ }
132
+ function getInstalledModelPath(name) {
133
+ const model = resolveModel(name);
134
+ const fullPath = path.join(modelsDir(), model.fileName);
135
+ if (!fs.existsSync(fullPath)) {
136
+ throw new VoiceError(`Model '${name}' is not installed`, 'MODEL_NOT_INSTALLED', 400);
137
+ }
138
+ return fullPath;
139
+ }
140
+ export async function transcribeAudio(params) {
141
+ const { audioBuffer, modelName } = params;
142
+ const language = params.language && params.language.trim().length > 0 ? params.language : 'auto';
143
+ const temperature = Number.isFinite(Number(params.temperature))
144
+ ? Math.max(0, Math.min(1, Number(params.temperature)))
145
+ : 0;
146
+ const prompt = (params.prompt ?? '').trim();
147
+ const translateToEnglish = params.translateToEnglish === true;
148
+ const suppressNst = params.suppressNonSpeechTokens !== false;
149
+ if (language.length > MAX_LANG_LENGTH || !/^[a-z-]+$/i.test(language)) {
150
+ throw new VoiceError(`Invalid language '${language}'`, 'LANGUAGE_INVALID', 400);
151
+ }
152
+ const modelPath = getInstalledModelPath(modelName);
153
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kobo-voice-'));
154
+ const audioPath = path.join(tmpDir, 'input.webm');
155
+ const wavPath = path.join(tmpDir, 'input.wav');
156
+ try {
157
+ fs.writeFileSync(audioPath, audioBuffer);
158
+ // Normalize browser-recorded audio (webm/ogg/...) to a mono WAV file that
159
+ // whisper-cli can decode reliably across platforms.
160
+ await execFileAsync(resolveFfmpegCommand(), ['-y', '-i', audioPath, '-ar', '16000', '-ac', '1', wavPath], {
161
+ timeout: 60000,
162
+ });
163
+ const cmd = resolveWhisperCommand();
164
+ const args = [
165
+ '-m',
166
+ modelPath,
167
+ '-f',
168
+ wavPath,
169
+ '-otxt',
170
+ '-of',
171
+ path.join(tmpDir, 'out'),
172
+ '--temperature',
173
+ String(temperature),
174
+ ];
175
+ if (language !== 'auto')
176
+ args.push('-l', language);
177
+ if (translateToEnglish)
178
+ args.push('--translate');
179
+ if (suppressNst)
180
+ args.push('--suppress-nst');
181
+ if (prompt.length > 0)
182
+ args.push('--prompt', prompt);
183
+ const start = Date.now();
184
+ const { stderr } = await execFileAsync(cmd, args, { timeout: 120000 });
185
+ const durationMs = Date.now() - start;
186
+ const outTxt = path.join(tmpDir, 'out.txt');
187
+ if (!fs.existsSync(outTxt)) {
188
+ throw new VoiceError(`Transcription output missing (${stderr || 'no stderr'})`, 'TRANSCRIPTION_FAILED', 500);
189
+ }
190
+ const text = fs.readFileSync(outTxt, 'utf-8').trim();
191
+ return { text, durationMs, model: modelName, language };
192
+ }
193
+ catch (err) {
194
+ const message = err instanceof Error ? err.message : String(err);
195
+ if (message.includes('ENOENT')) {
196
+ throw new VoiceError('Voice runtime missing (whisper-cli or ffmpeg)', 'VOICE_RUNTIME_MISSING', 500);
197
+ }
198
+ if (message.includes('timed out')) {
199
+ throw new VoiceError('Whisper transcription timed out', 'TRANSCRIPTION_TIMEOUT', 500);
200
+ }
201
+ throw err;
202
+ }
203
+ finally {
204
+ fs.rmSync(tmpDir, { recursive: true, force: true });
205
+ }
206
+ }
@@ -5,7 +5,7 @@ import * as orchestrator from './agent/orchestrator.js';
5
5
  import * as settingsService from './settings-service.js';
6
6
  import { emitEphemeral } from './websocket-service.js';
7
7
  const MIN_DELAY_SECONDS = 60;
8
- const MAX_DELAY_SECONDS = 3600;
8
+ const MAX_DELAY_SECONDS = 21600;
9
9
  const STALE_WAKEUP_GRACE_MS = 5 * 60 * 1000;
10
10
  const AUTONOMOUS_LOOP_SENTINEL = '<<autonomous-loop-dynamic>>';
11
11
  const AUTONOMOUS_LOOP_FALLBACK_PROMPT = 'Continue where you left off.';
@@ -3,6 +3,7 @@ import { getDb } from '../db/index.js';
3
3
  import { resolveWorkspaceWorktreePath } from '../utils/worktree-paths.js';
4
4
  import * as orchestrator from './agent/orchestrator.js';
5
5
  import * as autoLoopService from './auto-loop-service.js';
6
+ import * as cronService from './cron-service.js';
6
7
  import * as quotaBackoffService from './quota-backoff-service.js';
7
8
  import * as wakeupService from './wakeup-service.js';
8
9
  import { emitEphemeral } from './websocket-service.js';
@@ -363,6 +364,15 @@ export function deleteWorkspace(id) {
363
364
  catch (err) {
364
365
  console.error('[workspace-service] cancel quota backoff on delete failed:', err);
365
366
  }
367
+ // Cancel every pending cron BEFORE the FK cascade removes the rows so
368
+ // in-memory setTimeout timers are cleared. Best-effort: failure must not
369
+ // block delete.
370
+ try {
371
+ cronService.cancelAllForWorkspace(id, 'deleted');
372
+ }
373
+ catch (err) {
374
+ console.error('[workspace-service] cancel crons on delete failed:', err);
375
+ }
366
376
  // Drop the cached rate_limit.info so memory doesn't leak on workspace
367
377
  // churn. The Map has no FK to clean up for it automatically.
368
378
  orchestrator.forgetRateLimitInfo(id);
@@ -463,6 +473,14 @@ export function archiveWorkspace(id) {
463
473
  catch (err) {
464
474
  console.error('[workspace-service] cancel quota backoff on archive failed:', err);
465
475
  }
476
+ // Cancel every pending cron — archived workspaces must not fire scheduled
477
+ // prompts. Best-effort: failure here must not block archive.
478
+ try {
479
+ cronService.cancelAllForWorkspace(id, 'archive');
480
+ }
481
+ catch (err) {
482
+ console.error('[workspace-service] cancel crons on archive failed:', err);
483
+ }
466
484
  // Disable auto-loop — archived workspaces should not keep looping.
467
485
  // Idempotent: no-op if auto_loop was already 0.
468
486
  autoLoopService.disable(id, 'user-action');
@@ -583,7 +583,14 @@ export function getUnpushedChangedFiles(repoPath, branchName, remote = 'origin')
583
583
  export function getFileAtRef(repoPath, ref, filePath) {
584
584
  const resolvedRef = resolveBase(repoPath, ref);
585
585
  try {
586
- return git(repoPath, ['show', `${resolvedRef}:${filePath}`]);
586
+ // Bypass the `git()` helper here: it `.trimEnd()`s the output, which would
587
+ // strip trailing newlines from the original file content and produce a
588
+ // false diff against `getFileContent`'s untrimmed `readFileSync` output
589
+ // (last line marked added/removed even when identical).
590
+ return execFileSync('git', ['show', `${resolvedRef}:${filePath}`], {
591
+ cwd: repoPath,
592
+ encoding: 'utf-8',
593
+ });
587
594
  }
588
595
  catch {
589
596
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loicngr/kobo",
3
- "version": "1.7.5",
3
+ "version": "1.7.7",
4
4
  "description": "Kōbō — multi-workspace agent manager for Claude Code. Orchestrates isolated git worktrees with dev servers, Notion integration, and MCP tools.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -66,24 +66,27 @@
66
66
  "prepublishOnly": "npm run build"
67
67
  },
68
68
  "dependencies": {
69
- "@anthropic-ai/claude-agent-sdk": "^0.2.126",
70
- "@hono/node-server": "^1.19.13",
69
+ "@anthropic-ai/claude-agent-sdk": "^0.2.90",
70
+ "@emnapi/core": "^1.10.0",
71
+ "@emnapi/runtime": "^1.10.0",
72
+ "@hono/node-server": "^2.0.2",
71
73
  "@modelcontextprotocol/sdk": "^1.29.0",
72
- "better-sqlite3": "^12.8.0",
73
- "hono": "^4.12.12",
74
- "nanoid": "^5.1.7",
74
+ "better-sqlite3": "^12.9.0",
75
+ "cron-parser": "^5.5.0",
76
+ "hono": "^4.12.18",
77
+ "nanoid": "^5.1.11",
75
78
  "node-pty": "^1.1.0",
76
79
  "ws": "^8.20.0"
77
80
  },
78
81
  "devDependencies": {
79
82
  "@biomejs/biome": "2.4.10",
80
83
  "@types/better-sqlite3": "^7.6.13",
81
- "@types/node": "^25.5.2",
84
+ "@types/node": "^25.6.2",
82
85
  "@types/ws": "^8.18.1",
83
- "@vitest/runner": "^4.1.2",
86
+ "@vitest/runner": "^4.1.5",
84
87
  "concurrently": "^9.2.1",
85
88
  "tsx": "^4.21.0",
86
- "typescript": "^6.0.2",
87
- "vitest": "^3.2.4"
89
+ "typescript": "^6.0.3",
90
+ "vitest": "^4.1.5"
88
91
  }
89
92
  }