@loicngr/kobo 1.5.6 → 1.6.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 (182) hide show
  1. package/README.md +2 -0
  2. package/dist/server/db/migrations.js +14 -0
  3. package/dist/server/db/schema.js +2 -0
  4. package/dist/server/index.js +17 -1
  5. package/dist/server/routes/dev-server.js +1 -1
  6. package/dist/server/routes/health.js +95 -0
  7. package/dist/server/routes/search.js +33 -0
  8. package/dist/server/routes/settings.js +30 -0
  9. package/dist/server/routes/workspaces.js +150 -0
  10. package/dist/server/services/db-backup-service.js +66 -0
  11. package/dist/server/services/dev-server-service.js +24 -1
  12. package/dist/server/services/search-service.js +102 -0
  13. package/dist/server/services/settings-service.js +73 -6
  14. package/dist/server/services/templates-service.js +31 -0
  15. package/dist/server/services/workspace-service.js +54 -0
  16. package/dist/server/utils/git-ops.js +78 -9
  17. package/package.json +1 -1
  18. package/src/client/dist/spa/assets/ActivityFeed-0GR1zPoc.js +10 -0
  19. package/src/client/dist/spa/assets/ClosePopup-CdSn7HO8.js +1 -0
  20. package/src/client/dist/spa/assets/CreatePage-dMi4xVYN.css +1 -0
  21. package/src/client/dist/spa/assets/CreatePage-je_7dC5I.js +2 -0
  22. package/src/client/dist/spa/assets/DiffViewer-DREYX-8k.js +2 -0
  23. package/src/client/dist/spa/assets/HealthPage-Do8QZdxw.js +1 -0
  24. package/src/client/dist/spa/assets/MainLayout-B5poKEy_.css +1 -0
  25. package/src/client/dist/spa/assets/MainLayout-_oPM07ln.js +37 -0
  26. package/src/client/dist/spa/assets/QBadge-Bvh-hQ8K.js +1 -0
  27. package/src/client/dist/spa/assets/QBtn-BsD8vrWq.js +1 -0
  28. package/src/client/dist/spa/assets/QDialog-CkbLS1If.js +1 -0
  29. package/src/client/dist/spa/assets/QExpansionItem-UgkE560c.js +1 -0
  30. package/src/client/dist/spa/assets/QList-D80ms7bw.js +1 -0
  31. package/src/client/dist/spa/assets/QMenu-DU-wiY_A.js +1 -0
  32. package/src/client/dist/spa/assets/QPage-BKY2-sf-.js +1 -0
  33. package/src/client/dist/spa/assets/QSpace-C5Ebr0vq.js +1 -0
  34. package/src/client/dist/spa/assets/QSpinner-CliSLjf8.js +1 -0
  35. package/src/client/dist/spa/assets/QSpinnerDots-Dp12eHrB.js +1 -0
  36. package/src/client/dist/spa/assets/QTabPanels-C7lWp1yU.js +1 -0
  37. package/src/client/dist/spa/assets/QToggle-B0HvuNEg.js +1 -0
  38. package/src/client/dist/spa/assets/QTooltip-kLXuUa_m.js +1 -0
  39. package/src/client/dist/spa/assets/SearchPage-CCfyqBKh.js +1 -0
  40. package/src/client/dist/spa/assets/SearchPage-DWglAeQv.css +1 -0
  41. package/src/client/dist/spa/assets/SettingsPage-CLMCHMpz.css +1 -0
  42. package/src/client/dist/spa/assets/SettingsPage-CmyIsV-S.js +1 -0
  43. package/src/client/dist/spa/assets/TouchPan-CVMnGs0y.js +1 -0
  44. package/src/client/dist/spa/assets/{WorkspacePage-Ck8G2Lhl.css → WorkspacePage-CWRMLYs-.css} +1 -1
  45. package/src/client/dist/spa/assets/WorkspacePage-Cl7YrG51.js +4 -0
  46. package/src/client/dist/spa/assets/_plugin-vue_export-helper-Cxt1D8wE.js +1 -0
  47. package/src/client/dist/spa/assets/{cssMode-TmSd3FtN.js → cssMode-DMX8jq8u.js} +1 -1
  48. package/src/client/dist/spa/assets/{editor.api-BYoDPXci.js → editor.api-DirOkGGg.js} +1 -1
  49. package/src/client/dist/spa/assets/{editor.main-D2_WTVNi.js → editor.main-DC4ezIu0.js} +3 -3
  50. package/src/client/dist/spa/assets/focus-manager-DYbz9jFW.js +1 -0
  51. package/src/client/dist/spa/assets/formatters-BzaS4w0I.js +1 -0
  52. package/src/client/dist/spa/assets/{freemarker2-BK0cJTj4.js → freemarker2-DI9xJfj0.js} +1 -1
  53. package/src/client/dist/spa/assets/{handlebars-BT6xKkcN.js → handlebars-B9F-pScn.js} +1 -1
  54. package/src/client/dist/spa/assets/{html-G6AG34m0.js → html-DTe2v8Q8.js} +1 -1
  55. package/src/client/dist/spa/assets/{htmlMode-Cal36mmb.js → htmlMode-F_XLjWfJ.js} +1 -1
  56. package/src/client/dist/spa/assets/i18n-B13zBh1H.js +1 -0
  57. package/src/client/dist/spa/assets/i18n-CCWLBc0p.js +1 -0
  58. package/src/client/dist/spa/assets/index-DoNZ_5QK.js +5 -0
  59. package/src/client/dist/spa/assets/{javascript-B-6wB4JQ.js → javascript-B9xJRPC6.js} +1 -1
  60. package/src/client/dist/spa/assets/{jsonMode-CT4pwl6t.js → jsonMode-DTZ6j6UO.js} +1 -1
  61. package/src/client/dist/spa/assets/{liquid-BneTrBrq.js → liquid-BjU5MtW6.js} +1 -1
  62. package/src/client/dist/spa/assets/{marked.esm-C01v3JRg.js → marked.esm-DCmk6NO8.js} +1 -1
  63. package/src/client/dist/spa/assets/{mdx-ByZYBp9K.js → mdx-BMUpG7Be.js} +1 -1
  64. package/src/client/dist/spa/assets/models-B8fzv7K4.js +1 -0
  65. package/src/client/dist/spa/assets/{monaco.contribution-B2_1EuRF.js → monaco.contribution-D7JUf8DP.js} +2 -2
  66. package/src/client/dist/spa/assets/pinia-C3JsrLkB.js +1 -0
  67. package/src/client/dist/spa/assets/private.use-form-BhKyDtO7.js +1 -0
  68. package/src/client/dist/spa/assets/{python-CqrolrIy.js → python-Dz0D4uSk.js} +1 -1
  69. package/src/client/dist/spa/assets/rate-limit-labels-dCPVjS61.js +6 -0
  70. package/src/client/dist/spa/assets/{razor-CPg4dghH.js → razor-D7CFxuwR.js} +1 -1
  71. package/src/client/dist/spa/assets/runtime-core.esm-bundler-C3IgBgY5.js +1 -0
  72. package/src/client/dist/spa/assets/scroll-CLibRGI-.js +1 -0
  73. package/src/client/dist/spa/assets/settings-B69lIVX0.js +1 -0
  74. package/src/client/dist/spa/assets/symbols-CAg-nBkV.js +1 -0
  75. package/src/client/dist/spa/assets/touch-ChrvzrnI.js +1 -0
  76. package/src/client/dist/spa/assets/{tsMode-CRHW8cKK.js → tsMode-DjscaxpS.js} +1 -1
  77. package/src/client/dist/spa/assets/{typescript-D2lNktM6.js → typescript-DozCWZl2.js} +1 -1
  78. package/src/client/dist/spa/assets/use-dark-DnuCB6tC.js +1 -0
  79. package/src/client/dist/spa/assets/use-quasar-DBoizHBW.js +1 -0
  80. package/src/client/dist/spa/assets/vue-i18n-eUDnMrPl.js +3 -0
  81. package/src/client/dist/spa/assets/{xml-C-qye9BU.js → xml-DFOJMT39.js} +1 -1
  82. package/src/client/dist/spa/assets/{yaml-DujvOKOb.js → yaml-yEefnsXm.js} +1 -1
  83. package/src/client/dist/spa/index.html +15 -7
  84. package/src/client/dist/spa/assets/ActivityFeed-BScp_ie9.js +0 -10
  85. package/src/client/dist/spa/assets/ClosePopup-BfANRQ7n.js +0 -1
  86. package/src/client/dist/spa/assets/CreatePage-Cam6EJCg.js +0 -2
  87. package/src/client/dist/spa/assets/CreatePage-Cy1V2bdx.css +0 -1
  88. package/src/client/dist/spa/assets/DiffViewer-DMa0FFUV.js +0 -2
  89. package/src/client/dist/spa/assets/MainLayout-1KXWlMYS.css +0 -1
  90. package/src/client/dist/spa/assets/MainLayout-DB5sQk0j.js +0 -37
  91. package/src/client/dist/spa/assets/QBadge-DENdn6Dv.js +0 -1
  92. package/src/client/dist/spa/assets/QExpansionItem-BBe4EJ8c.js +0 -1
  93. package/src/client/dist/spa/assets/QList-DJTvuGop.js +0 -1
  94. package/src/client/dist/spa/assets/QMenu-DnNUsPHb.js +0 -1
  95. package/src/client/dist/spa/assets/QSpinnerDots-DytntAbf.js +0 -1
  96. package/src/client/dist/spa/assets/SettingsPage-BedYmy7N.js +0 -1
  97. package/src/client/dist/spa/assets/SettingsPage-oWZ8sGFm.css +0 -1
  98. package/src/client/dist/spa/assets/TouchPan-D_gmnARo.js +0 -1
  99. package/src/client/dist/spa/assets/WorkspacePage-DcVj7f0F.js +0 -4
  100. package/src/client/dist/spa/assets/_plugin-vue_export-helper-DkL3SKZ8.js +0 -1
  101. package/src/client/dist/spa/assets/formatters-hnFdqDO2.js +0 -6
  102. package/src/client/dist/spa/assets/i18n-ByuXBoTq.js +0 -1
  103. package/src/client/dist/spa/assets/i18n-DyD-u9TB.js +0 -1
  104. package/src/client/dist/spa/assets/index-C9F8lzbI.js +0 -5
  105. package/src/client/dist/spa/assets/models-C4seT9_L.js +0 -1
  106. package/src/client/dist/spa/assets/private.use-form-BWfHn8G9.js +0 -1
  107. package/src/client/dist/spa/assets/scroll-CHtHNzvE.js +0 -1
  108. package/src/client/dist/spa/assets/settings-DNNmyYf-.js +0 -1
  109. package/src/client/dist/spa/assets/touch-5Jv2Ep3F.js +0 -1
  110. package/src/client/dist/spa/assets/use-checkbox-By4PLtaB.js +0 -1
  111. package/src/client/dist/spa/assets/vue-i18n-CFW7O-jl.js +0 -3
  112. /package/src/client/dist/spa/assets/{abap-Dvixoi-w.js → abap-CFuyUYKP.js} +0 -0
  113. /package/src/client/dist/spa/assets/{apex-DP7X7Z9Q.js → apex-Ctq_xcrv.js} +0 -0
  114. /package/src/client/dist/spa/assets/{azcli-BdzyZYsP.js → azcli-BBQSVn-C.js} +0 -0
  115. /package/src/client/dist/spa/assets/{bat-BskgK8bi.js → bat-DbnqAfvr.js} +0 -0
  116. /package/src/client/dist/spa/assets/{bicep-BB_erhjR.js → bicep-BtDlIXop.js} +0 -0
  117. /package/src/client/dist/spa/assets/{cameligo-PvLD8t4t.js → cameligo-BLeJgKTj.js} +0 -0
  118. /package/src/client/dist/spa/assets/{clojure-CjHVLzZR.js → clojure-aZUQIUKP.js} +0 -0
  119. /package/src/client/dist/spa/assets/{coffee-DCoMPIwW.js → coffee-Secadq9U.js} +0 -0
  120. /package/src/client/dist/spa/assets/{cpp-kftb9yup.js → cpp-JicRPTRv.js} +0 -0
  121. /package/src/client/dist/spa/assets/{csharp-CYKl40UV.js → csharp-C7NSOZyj.js} +0 -0
  122. /package/src/client/dist/spa/assets/{csp-d1_9kWqP.js → csp-CIje7830.js} +0 -0
  123. /package/src/client/dist/spa/assets/{css-CSa8r6i8.js → css-G0bm1q_M.js} +0 -0
  124. /package/src/client/dist/spa/assets/{cypher-FoBj7udD.js → cypher-CldD5D0u.js} +0 -0
  125. /package/src/client/dist/spa/assets/{dart-BoITdHw1.js → dart-DIK3l8YT.js} +0 -0
  126. /package/src/client/dist/spa/assets/{dockerfile-CRYieHSB.js → dockerfile-czxaGh2L.js} +0 -0
  127. /package/src/client/dist/spa/assets/{ecl-BisizQmF.js → ecl-BqdYhwmw.js} +0 -0
  128. /package/src/client/dist/spa/assets/{elixir-DMza7PS-.js → elixir-m52LePTW.js} +0 -0
  129. /package/src/client/dist/spa/assets/{flow9-DZyDUZ6t.js → flow9-B5QJ9GvZ.js} +0 -0
  130. /package/src/client/dist/spa/assets/{format-Ex4UmWdz.js → format-Cyg8IgRi.js} +0 -0
  131. /package/src/client/dist/spa/assets/{fsharp-CKv7fFew.js → fsharp-B15czHsH.js} +0 -0
  132. /package/src/client/dist/spa/assets/{go-DXZqPXQn.js → go-BkoQxDo1.js} +0 -0
  133. /package/src/client/dist/spa/assets/{graphql-DrGE7IlY.js → graphql-BnI6uRa_.js} +0 -0
  134. /package/src/client/dist/spa/assets/{hcl-DLEN_UIX.js → hcl-CAwwENT7.js} +0 -0
  135. /package/src/client/dist/spa/assets/{ini-mQpaJDpd.js → ini-BHM5zh1H.js} +0 -0
  136. /package/src/client/dist/spa/assets/{java-jjG-xxwG.js → java-B5i95QvQ.js} +0 -0
  137. /package/src/client/dist/spa/assets/{julia-C50bVRho.js → julia-DPDm885q.js} +0 -0
  138. /package/src/client/dist/spa/assets/{kotlin-DwU9Bxzl.js → kotlin-qoccd5BP.js} +0 -0
  139. /package/src/client/dist/spa/assets/{less-Duxayr-t.js → less-B6RU166D.js} +0 -0
  140. /package/src/client/dist/spa/assets/{lexon-D3Je_oKk.js → lexon-YfUeoL1V.js} +0 -0
  141. /package/src/client/dist/spa/assets/{lua-DqW2q2h7.js → lua-BIUI5y9b.js} +0 -0
  142. /package/src/client/dist/spa/assets/{m3-CO3sU7vD.js → m3-D5SAbSdU.js} +0 -0
  143. /package/src/client/dist/spa/assets/{markdown-CbqjBAOS.js → markdown-CVJLwHzJ.js} +0 -0
  144. /package/src/client/dist/spa/assets/{mips-Bj7AN96v.js → mips-R-FZ3zOR.js} +0 -0
  145. /package/src/client/dist/spa/assets/{msdax-aLgc6HYp.js → msdax-Blveyl9r.js} +0 -0
  146. /package/src/client/dist/spa/assets/{mysql-QJHu-LiC.js → mysql-D4mY1AFx.js} +0 -0
  147. /package/src/client/dist/spa/assets/{objective-c-BRcZj02O.js → objective-c-BmXrLr4h.js} +0 -0
  148. /package/src/client/dist/spa/assets/{pascal-j04gUgnY.js → pascal-yxckoyvV.js} +0 -0
  149. /package/src/client/dist/spa/assets/{pascaligo-6Lz1Pduc.js → pascaligo-Q5JCwXMI.js} +0 -0
  150. /package/src/client/dist/spa/assets/{perl-Ny9A9N4B.js → perl-BF1Rrs5h.js} +0 -0
  151. /package/src/client/dist/spa/assets/{pgsql-gEZ-9kd2.js → pgsql-CnYB97wm.js} +0 -0
  152. /package/src/client/dist/spa/assets/{php-B81GxKax.js → php-CdDfQfSg.js} +0 -0
  153. /package/src/client/dist/spa/assets/{pla-DS3E9Auu.js → pla-whj-d71F.js} +0 -0
  154. /package/src/client/dist/spa/assets/{postiats-CHuHziKk.js → postiats-ClfLr4I-.js} +0 -0
  155. /package/src/client/dist/spa/assets/{powerquery-DWZsw4AU.js → powerquery-iRaBhuuk.js} +0 -0
  156. /package/src/client/dist/spa/assets/{powershell-MAGzBvoK.js → powershell-DjiEt5xK.js} +0 -0
  157. /package/src/client/dist/spa/assets/{protobuf-CuqThPZN.js → protobuf-B6dcIEUr.js} +0 -0
  158. /package/src/client/dist/spa/assets/{pug-DpwILTue.js → pug-DtmHnjM9.js} +0 -0
  159. /package/src/client/dist/spa/assets/{qsharp-Clbrosr3.js → qsharp-CELCyd79.js} +0 -0
  160. /package/src/client/dist/spa/assets/{r-dNsdrNRn.js → r-ZpJXWV-o.js} +0 -0
  161. /package/src/client/dist/spa/assets/{redis-DRY-nYja.js → redis-BiHSNkAl.js} +0 -0
  162. /package/src/client/dist/spa/assets/{redshift-CGzDbZLe.js → redshift-DzuwYCHP.js} +0 -0
  163. /package/src/client/dist/spa/assets/{restructuredtext-DiI6OCjv.js → restructuredtext-YOT94bbS.js} +0 -0
  164. /package/src/client/dist/spa/assets/{ruby-DTTFtlqw.js → ruby-BfiHr6Uu.js} +0 -0
  165. /package/src/client/dist/spa/assets/{rust-Cv26V_Fa.js → rust-JZ-uOoYM.js} +0 -0
  166. /package/src/client/dist/spa/assets/{sb-WM3UPTLr.js → sb-CBglP1-t.js} +0 -0
  167. /package/src/client/dist/spa/assets/{scala-Brw-oZFc.js → scala-C9l41paw.js} +0 -0
  168. /package/src/client/dist/spa/assets/{scheme-B9_NJi2n.js → scheme-B-InQ6hy.js} +0 -0
  169. /package/src/client/dist/spa/assets/{scss-AJXcdTUI.js → scss-v6OmJRN9.js} +0 -0
  170. /package/src/client/dist/spa/assets/{shell-B8rXUKDl.js → shell-Dyp6iwB6.js} +0 -0
  171. /package/src/client/dist/spa/assets/{solidity-DBduMAba.js → solidity-D5epNWue.js} +0 -0
  172. /package/src/client/dist/spa/assets/{sophia-CLkSrv5f.js → sophia-Eva-79sB.js} +0 -0
  173. /package/src/client/dist/spa/assets/{sparql-8jfjvjhF.js → sparql-gvALLO1w.js} +0 -0
  174. /package/src/client/dist/spa/assets/{sql-DCI2HndY.js → sql-COdamZYI.js} +0 -0
  175. /package/src/client/dist/spa/assets/{st-YiBlEXuf.js → st-eMoImIwE.js} +0 -0
  176. /package/src/client/dist/spa/assets/{swift-BpRZ1CVX.js → swift-7R_T9RYH.js} +0 -0
  177. /package/src/client/dist/spa/assets/{systemverilog-2_XLvhYi.js → systemverilog-1pCEfaHU.js} +0 -0
  178. /package/src/client/dist/spa/assets/{tcl-6xehSJT3.js → tcl-B_KgnhfE.js} +0 -0
  179. /package/src/client/dist/spa/assets/{twig-B80I9c0D.js → twig-CFZUJxb9.js} +0 -0
  180. /package/src/client/dist/spa/assets/{typespec-BsAPJtGA.js → typespec-B1ZgHlud.js} +0 -0
  181. /package/src/client/dist/spa/assets/{vb-B6Q-99Bj.js → vb-DKdun5tL.js} +0 -0
  182. /package/src/client/dist/spa/assets/{wgsl-DPkCsCSS.js → wgsl-CzNaxTrn.js} +0 -0
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  > [!WARNING]
6
6
  > 🚧 **Work in progress** — This project is under active development. Breaking changes may occur at any time.
7
+ >
8
+ > **Engine refactor planned.** Kōbō currently drives the `claude` CLI via `spawn(..., ['-p', ...])` and parses stdout, which is brittle on edge cases (interrupts, long-running sessions, MCP lifecycle, tool-use streaming). A rewrite is planned to use the [Claude Agent SDK](https://docs.anthropic.com/en/docs/agents-and-tools/claude-agent-sdk/overview) directly so the agent runs in-process, with proper streaming primitives, structured tool-use events, and cleaner interrupt / resume semantics. Expect churn in `src/server/services/agent-manager.ts` and the WebSocket event shape during that migration.
7
9
 
8
10
  Kōbō lets you delegate multiple coding missions to Claude Code agents in parallel. Each workspace lives in its own isolated git worktree with its own branch, its own Claude session, optionally its own dev server, and a custom MCP tools server the agent uses to track progress. A Vue 3 dashboard shows live agent output, tasks, acceptance criteria, and git state across every workspace.
9
11
 
@@ -64,6 +64,20 @@ export const migrations = [
64
64
  db.exec("ALTER TABLE workspaces ADD COLUMN reasoning_effort TEXT NOT NULL DEFAULT 'auto'");
65
65
  },
66
66
  },
67
+ {
68
+ version: 8,
69
+ name: 'add-workspace-favorited-at',
70
+ migrate: (db) => {
71
+ db.prepare('ALTER TABLE workspaces ADD COLUMN favorited_at TEXT').run();
72
+ },
73
+ },
74
+ {
75
+ version: 9,
76
+ name: 'add-workspace-tags',
77
+ migrate: (db) => {
78
+ db.prepare("ALTER TABLE workspaces ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'").run();
79
+ },
80
+ },
67
81
  ];
68
82
  /** Current schema version — always equals the highest migration version. */
69
83
  export const SCHEMA_VERSION = migrations.length > 0 ? migrations[migrations.length - 1].version : 1;
@@ -16,6 +16,8 @@ export function initSchema(db) {
16
16
  dev_server_status TEXT NOT NULL DEFAULT 'stopped',
17
17
  has_unread INTEGER NOT NULL DEFAULT 0,
18
18
  archived_at TEXT,
19
+ favorited_at TEXT,
20
+ tags TEXT NOT NULL DEFAULT '[]',
19
21
  created_at TEXT NOT NULL,
20
22
  updated_at TEXT NOT NULL
21
23
  );
@@ -9,20 +9,23 @@ import { closeDb, getDb } from './db/index.js';
9
9
  import { runMigrations } from './db/migrations.js';
10
10
  import devServerRouter from './routes/dev-server.js';
11
11
  import gitRouter from './routes/git.js';
12
+ import healthRouter from './routes/health.js';
12
13
  import imagesRouter from './routes/images.js';
13
14
  import notionRouter from './routes/notion.js';
14
15
  import plansRouter from './routes/plans.js';
16
+ import searchRouter from './routes/search.js';
15
17
  import sentryRouter from './routes/sentry.js';
16
18
  import settingsRouter from './routes/settings.js';
17
19
  import templatesRouter from './routes/templates.js';
18
20
  import workspacesRouter from './routes/workspaces.js';
19
21
  import { getAvailableSkills, sendMessage, setBackendPort, startAgent, startWatchdog, stopAgent, stopWatchdog, } from './services/agent-manager.js';
22
+ import { createDailyDbBackupIfNeeded } from './services/db-backup-service.js';
20
23
  import { startDevServer, stopDevServer } from './services/dev-server-service.js';
21
24
  import { startPrWatcher, stopPrWatcher } from './services/pr-watcher-service.js';
22
25
  import { createTerminal, destroyAllTerminals, getTerminal } from './services/terminal-service.js';
23
26
  import { emit, handleConnection, setMessageHandler } from './services/websocket-service.js';
24
27
  import { getActiveSession, getWorkspace, updateWorkspaceStatus } from './services/workspace-service.js';
25
- import { getClientSpaPath, getKoboHome, getPackageVersion } from './utils/paths.js';
28
+ import { getClientSpaPath, getDbPath, getKoboHome, getPackageVersion } from './utils/paths.js';
26
29
  import { initProcessCleanup, killAll as killAllTrackedProcesses } from './utils/process-tracker.js';
27
30
  // Runtime prerequisite check — warn if the claude CLI is missing. Don't block
28
31
  // startup: the user may still want to configure settings or browse workspaces.
@@ -36,6 +39,17 @@ console.log(`[kobo] Kōbō home: ${getKoboHome()}`);
36
39
  // Initialize DB + run migrations
37
40
  const db = getDb();
38
41
  runMigrations(db);
42
+ // Daily DB backup (best-effort, fire-and-forget — never blocks boot).
43
+ // Creates a WAL-safe snapshot alongside kobo.db if no backup exists in the
44
+ // last 24h, and rotates out older backups beyond the retention window.
45
+ void createDailyDbBackupIfNeeded(db, getDbPath()).then((r) => {
46
+ if (r.created) {
47
+ console.log(`[kobo] Daily DB backup: ${r.created}`);
48
+ if (r.deleted.length > 0) {
49
+ console.log(`[kobo] Rotated ${r.deleted.length} old DB backup(s)`);
50
+ }
51
+ }
52
+ });
39
53
  // Initialize process cleanup, agent watchdog, and PR watcher
40
54
  initProcessCleanup();
41
55
  startWatchdog();
@@ -54,6 +68,8 @@ app.route('/api/settings', settingsRouter);
54
68
  app.route('/api/dev-server', devServerRouter);
55
69
  app.route('/api/templates', templatesRouter);
56
70
  app.route('/api/workspaces', plansRouter);
71
+ app.route('/api/search', searchRouter);
72
+ app.route('/api/health', healthRouter);
57
73
  // Skills endpoint
58
74
  app.get('/api/skills', (c) => c.json(getAvailableSkills()));
59
75
  const PORT = parseInt(process.env.SERVER_PORT || process.env.PORT || '3000', 10);
@@ -11,7 +11,7 @@ app.get('/:workspaceId/status', (c) => {
11
11
  if (!workspace) {
12
12
  return c.json({ error: `Workspace '${workspaceId}' not found` }, 404);
13
13
  }
14
- const status = getStatus(workspace.projectPath, workspace.workingBranch);
14
+ const status = getStatus(workspace.projectPath, workspace.workingBranch, workspaceId);
15
15
  // If runtime detection returns unknown, use persisted status from DB
16
16
  if (status.status === 'unknown' && workspace.devServerStatus && workspace.devServerStatus !== 'stopped') {
17
17
  status.status = workspace.devServerStatus;
@@ -0,0 +1,95 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { Hono } from 'hono';
5
+ import { getDb } from '../db/index.js';
6
+ import { SCHEMA_VERSION } from '../db/migrations.js';
7
+ import { getGlobalSettings, SETTINGS_SCHEMA_VERSION } from '../services/settings-service.js';
8
+ import { getDbPath, getKoboHome } from '../utils/paths.js';
9
+ const app = new Hono();
10
+ function checkClaudeCli() {
11
+ try {
12
+ const r = spawnSync('claude', ['--version'], { encoding: 'utf-8' });
13
+ if (r.error || r.status !== 0)
14
+ return { available: false, version: null };
15
+ return { available: true, version: (r.stdout ?? '').trim() || null };
16
+ }
17
+ catch {
18
+ return { available: false, version: null };
19
+ }
20
+ }
21
+ function isProcessAlive(pid) {
22
+ try {
23
+ process.kill(pid, 0);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ function safeFileSize(p) {
31
+ try {
32
+ return fs.statSync(p).size;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ // GET /api/health/report — detailed health diagnostics for the Health panel.
39
+ app.get('/report', (c) => {
40
+ const db = getDb();
41
+ const dbPath = getDbPath();
42
+ const home = getKoboHome();
43
+ // DB schema version
44
+ const row = db.prepare('SELECT MAX(version) as v FROM schema_migrations').get();
45
+ const dbSchemaVersion = row?.v ?? 0;
46
+ // Workspaces + worktrees on-disk check
47
+ const workspaces = db
48
+ .prepare('SELECT id, name, project_path, working_branch, archived_at FROM workspaces')
49
+ .all();
50
+ const worktreesMissing = [];
51
+ for (const ws of workspaces) {
52
+ if (ws.archived_at)
53
+ continue;
54
+ const wtPath = path.join(ws.project_path, '.worktrees', ws.working_branch);
55
+ if (!fs.existsSync(wtPath)) {
56
+ worktreesMissing.push({ workspaceId: ws.id, name: ws.name, path: wtPath, exists: false });
57
+ }
58
+ }
59
+ // Orphan agent sessions — marked running but PID no longer alive
60
+ const runningSessions = db
61
+ .prepare("SELECT pid FROM agent_sessions WHERE status = 'running' AND pid IS NOT NULL")
62
+ .all();
63
+ let orphaned = 0;
64
+ for (const s of runningSessions) {
65
+ if (s.pid && !isProcessAlive(s.pid))
66
+ orphaned++;
67
+ }
68
+ const globalSettings = getGlobalSettings();
69
+ const settingsRow = db.prepare('SELECT COUNT(*) as n FROM workspaces').get();
70
+ const archivedRow = db.prepare('SELECT COUNT(*) as n FROM workspaces WHERE archived_at IS NOT NULL').get();
71
+ const report = {
72
+ koboHome: home,
73
+ db: {
74
+ path: dbPath,
75
+ sizeBytes: safeFileSize(dbPath),
76
+ schemaVersion: dbSchemaVersion,
77
+ currentSchemaVersion: SCHEMA_VERSION,
78
+ },
79
+ settings: { schemaVersion: SETTINGS_SCHEMA_VERSION },
80
+ claudeCli: checkClaudeCli(),
81
+ workspaces: {
82
+ total: settingsRow.n,
83
+ archived: archivedRow.n,
84
+ worktreesMissing,
85
+ },
86
+ agentSessions: { orphaned },
87
+ integrations: {
88
+ notion: { configured: Boolean(globalSettings.notionMcpKey) },
89
+ sentry: { configured: Boolean(globalSettings.sentryMcpKey) },
90
+ editor: { configured: Boolean(globalSettings.editorCommand) },
91
+ },
92
+ };
93
+ return c.json(report);
94
+ });
95
+ export default app;
@@ -0,0 +1,33 @@
1
+ import { Hono } from 'hono';
2
+ import { searchEvents } from '../services/search-service.js';
3
+ const DEFAULT_LIMIT = 50;
4
+ const MAX_LIMIT = 200;
5
+ const app = new Hono();
6
+ // GET /api/search?q=...&limit=50&includeArchived=true
7
+ // Search readable text across ws_events (user messages + agent outputs),
8
+ // joined with workspaces. Returns up to `limit` snippets, most recent first.
9
+ app.get('/', (c) => {
10
+ const qRaw = c.req.query('q') ?? '';
11
+ const q = qRaw.trim();
12
+ if (!q) {
13
+ return c.json({ error: "Missing or empty 'q' query parameter" }, 400);
14
+ }
15
+ let limit = DEFAULT_LIMIT;
16
+ const limitRaw = c.req.query('limit');
17
+ if (limitRaw) {
18
+ const parsed = parseInt(limitRaw, 10);
19
+ if (Number.isFinite(parsed) && parsed > 0) {
20
+ limit = Math.min(parsed, MAX_LIMIT);
21
+ }
22
+ }
23
+ const includeArchived = c.req.query('includeArchived') === 'true';
24
+ try {
25
+ const results = searchEvents(q, { limit, includeArchived });
26
+ return c.json(results);
27
+ }
28
+ catch (err) {
29
+ const message = err instanceof Error ? err.message : String(err);
30
+ return c.json({ error: message }, 500);
31
+ }
32
+ });
33
+ export default app;
@@ -1,5 +1,6 @@
1
1
  import { Hono } from 'hono';
2
2
  import * as settingsService from '../services/settings-service.js';
3
+ import { listTemplates, replaceAllTemplates } from '../services/templates-service.js';
3
4
  /** Hono sub-router for global and per-project settings CRUD. */
4
5
  const app = new Hono();
5
6
  // GET /api/settings — return full settings
@@ -101,4 +102,33 @@ app.delete('/projects/:encodedPath', (c) => {
101
102
  return c.json({ error: message }, 500);
102
103
  }
103
104
  });
105
+ // GET /api/settings/export — download a JSON bundle of settings + templates (MCP keys stripped)
106
+ app.get('/export', (c) => {
107
+ try {
108
+ const bundle = settingsService.exportConfigBundle(listTemplates());
109
+ return c.json(bundle);
110
+ }
111
+ catch (err) {
112
+ const message = err instanceof Error ? err.message : String(err);
113
+ return c.json({ error: message }, 500);
114
+ }
115
+ });
116
+ // POST /api/settings/import — replace settings + templates from an uploaded bundle
117
+ app.post('/import', async (c) => {
118
+ try {
119
+ const body = (await c.req.json());
120
+ // Validate settings first — throws on malformed payload before we touch disk.
121
+ settingsService.importConfigBundle(body);
122
+ if (body.templates !== undefined) {
123
+ // Accept missing templates (backward-compatible). Otherwise validate and replace.
124
+ replaceAllTemplates(body.templates);
125
+ }
126
+ return c.json({ ok: true });
127
+ }
128
+ catch (err) {
129
+ const message = err instanceof Error ? err.message : String(err);
130
+ const isValidation = message.includes('Invalid bundle') || message.includes('Invalid template');
131
+ return c.json({ error: message }, isValidation ? 400 : 500);
132
+ }
133
+ });
104
134
  export default app;
@@ -756,6 +756,52 @@ app.get('/:id', (c) => {
756
756
  return c.json({ error: message }, 500);
757
757
  }
758
758
  });
759
+ // POST /api/workspaces/:id/favorite — mark workspace as favorite
760
+ app.post('/:id/favorite', (c) => {
761
+ const { id } = c.req.param();
762
+ try {
763
+ const ws = workspaceService.setFavorite(id);
764
+ return c.json(ws);
765
+ }
766
+ catch (err) {
767
+ const msg = err instanceof Error ? err.message : 'Unknown error';
768
+ const status = msg.includes('not found') ? 404 : 500;
769
+ return c.json({ error: msg }, status);
770
+ }
771
+ });
772
+ // DELETE /api/workspaces/:id/favorite — remove favorite from workspace
773
+ app.delete('/:id/favorite', (c) => {
774
+ const { id } = c.req.param();
775
+ try {
776
+ const ws = workspaceService.unsetFavorite(id);
777
+ return c.json(ws);
778
+ }
779
+ catch (err) {
780
+ const msg = err instanceof Error ? err.message : 'Unknown error';
781
+ const status = msg.includes('not found') ? 404 : 500;
782
+ return c.json({ error: msg }, status);
783
+ }
784
+ });
785
+ // PUT /api/workspaces/:id/tags — replace the workspace's tag list
786
+ app.put('/:id/tags', async (c) => {
787
+ const { id } = c.req.param();
788
+ try {
789
+ const body = await c.req.json();
790
+ if (!Array.isArray(body.tags)) {
791
+ return c.json({ error: 'tags must be an array of strings' }, 400);
792
+ }
793
+ if (body.tags.some((t) => typeof t !== 'string')) {
794
+ return c.json({ error: 'tags must contain only strings' }, 400);
795
+ }
796
+ const ws = workspaceService.setWorkspaceTags(id, body.tags);
797
+ return c.json(ws);
798
+ }
799
+ catch (err) {
800
+ const msg = err instanceof Error ? err.message : 'Unknown error';
801
+ const status = msg.includes('not found') ? 404 : 500;
802
+ return c.json({ error: msg }, status);
803
+ }
804
+ });
759
805
  // PATCH /api/workspaces/:id — update workspace fields (status, model, permissionMode, name)
760
806
  app.patch('/:id', async (c) => {
761
807
  try {
@@ -1179,6 +1225,110 @@ app.post('/:id/rebase', (c) => {
1179
1225
  gitOps.rebaseBranch(worktreePath, workspace.sourceBranch);
1180
1226
  return c.json({ success: true });
1181
1227
  }
1228
+ catch (err) {
1229
+ if (err instanceof gitOps.GitConflictError) {
1230
+ return c.json({ error: err.message, conflict: true, operation: err.operation, files: err.files }, 409);
1231
+ }
1232
+ const message = err instanceof Error ? err.message : String(err);
1233
+ return c.json({ error: message }, 500);
1234
+ }
1235
+ });
1236
+ /** Merge the source branch into the workspace branch (non-fast-forward). */
1237
+ app.post('/:id/merge', (c) => {
1238
+ try {
1239
+ const id = c.req.param('id');
1240
+ const workspace = workspaceService.getWorkspace(id);
1241
+ if (!workspace)
1242
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1243
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1244
+ gitOps.mergeBranch(worktreePath, workspace.sourceBranch);
1245
+ return c.json({ success: true });
1246
+ }
1247
+ catch (err) {
1248
+ if (err instanceof gitOps.GitConflictError) {
1249
+ return c.json({ error: err.message, conflict: true, operation: err.operation, files: err.files }, 409);
1250
+ }
1251
+ const message = err instanceof Error ? err.message : String(err);
1252
+ return c.json({ error: message }, 500);
1253
+ }
1254
+ });
1255
+ /** Abort any in-progress merge or rebase in the worktree. */
1256
+ app.post('/:id/git/abort', (c) => {
1257
+ try {
1258
+ const id = c.req.param('id');
1259
+ const workspace = workspaceService.getWorkspace(id);
1260
+ if (!workspace)
1261
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1262
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1263
+ const aborted = gitOps.abortOngoingGitOperation(worktreePath);
1264
+ return c.json({ success: true, aborted });
1265
+ }
1266
+ catch (err) {
1267
+ const message = err instanceof Error ? err.message : String(err);
1268
+ return c.json({ error: message }, 500);
1269
+ }
1270
+ });
1271
+ /** Hand off merge/rebase conflicts to the workspace agent with an intelligent-resolution prompt. */
1272
+ app.post('/:id/git/resolve-with-agent', async (c) => {
1273
+ try {
1274
+ const id = c.req.param('id');
1275
+ const workspace = workspaceService.getWorkspace(id);
1276
+ if (!workspace)
1277
+ return c.json({ error: `Workspace '${id}' not found` }, 404);
1278
+ const body = (await c.req.json().catch(() => ({})));
1279
+ const worktreePath = path.join(workspace.projectPath, '.worktrees', workspace.workingBranch);
1280
+ const operation = body.operation ?? gitOps.getOngoingGitOperation(worktreePath) ?? 'merge';
1281
+ const files = body.files && body.files.length > 0 ? body.files : gitOps.getConflictedFiles(worktreePath);
1282
+ if (files.length === 0) {
1283
+ return c.json({ error: 'No conflicted files detected — nothing for the agent to resolve' }, 400);
1284
+ }
1285
+ const fileList = files.map((f) => `- ${f}`).join('\n');
1286
+ const continueCmd = operation === 'merge' ? 'git merge --continue' : 'git rebase --continue';
1287
+ const prompt = `I started a \`git ${operation}\` of \`origin/${workspace.sourceBranch}\` into our working branch \`${workspace.workingBranch}\` and it produced conflicts that I need your help to resolve INTELLIGENTLY.
1288
+
1289
+ Conflicted files (${files.length}):
1290
+ ${fileList}
1291
+
1292
+ ## Resolution rules — read carefully
1293
+
1294
+ 1. **Our branch is the source of truth for the feature we are building.** Its behavior must be preserved.
1295
+ 2. **The source branch (\`${workspace.sourceBranch}\`) carries legitimate upstream changes** (bug fixes, refactors, dependency bumps). Integrate these where they don't conflict with our intent.
1296
+ 3. **Do NOT blindly pick a side.** Neither \`--ours\` nor \`--theirs\` wholesale. Read each conflict hunk and reason about what the correct merged state is.
1297
+ 4. **Think semantically, not syntactically.** If our branch renamed \`foo\` to \`bar\` and the source branch added a new call to \`foo\`, the correct resolution is a new call to \`bar\`, not "keep ours and drop the new call".
1298
+ 5. **Preserve tests and contracts.** If both sides touched the same test, keep coverage from both.
1299
+ 6. **Imports, versions, lock files:** prefer the superset (union) unless they genuinely conflict — in which case use the more recent / more restrictive.
1300
+
1301
+ ## Steps
1302
+
1303
+ 1. For each conflicted file, open it and read both conflict markers.
1304
+ 2. Decide the merge intent. If unsure, investigate both sides' commit history (\`git log --oneline ours..HEAD <file>\` vs \`git log --oneline origin/${workspace.sourceBranch} <file>\`).
1305
+ 3. Edit the file to the correct merged state and remove the conflict markers.
1306
+ 4. Run the test suite to verify no regression (\`npm test\` or the project's equivalent).
1307
+ 5. \`git add <resolved-files>\` then \`${continueCmd}\`.
1308
+ 6. Report the summary: which files you touched, the key decisions you made, and the final test result.
1309
+
1310
+ Start now.`;
1311
+ // Persist the prompt in the chat feed so the user sees what was dispatched.
1312
+ const session = workspaceService.getActiveSession(workspace.id);
1313
+ wsService.emit(workspace.id, 'user:message', { content: prompt, sender: 'user' }, session?.id ?? undefined);
1314
+ let messageSent = false;
1315
+ try {
1316
+ agentManager.sendMessage(workspace.id, prompt);
1317
+ messageSent = true;
1318
+ }
1319
+ catch {
1320
+ try {
1321
+ agentManager.startAgent(workspace.id, worktreePath, prompt, workspace.model, true, workspace.permissionMode, undefined, workspace.reasoningEffort);
1322
+ workspaceService.updateWorkspaceStatus(workspace.id, 'executing');
1323
+ messageSent = true;
1324
+ }
1325
+ catch (resumeErr) {
1326
+ const resumeMsg = resumeErr instanceof Error ? resumeErr.message : String(resumeErr);
1327
+ console.warn(`[workspaces] resolve-with-agent: agent resume failed: ${resumeMsg}`);
1328
+ }
1329
+ }
1330
+ return c.json({ ok: true, operation, files, messageSent });
1331
+ }
1182
1332
  catch (err) {
1183
1333
  const message = err instanceof Error ? err.message : String(err);
1184
1334
  return c.json({ error: message }, 500);
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const BACKUP_PREFIX = 'kobo.db.backup-';
4
+ const DEFAULT_KEEP = 7;
5
+ const DAILY_MS = 24 * 60 * 60 * 1000;
6
+ let backupSequence = 0;
7
+ /** Test-only: reset the in-process monotonic sequence to 0. */
8
+ export function _resetBackupSequenceForTests() {
9
+ backupSequence = 0;
10
+ }
11
+ function listBackups(dir) {
12
+ if (!fs.existsSync(dir))
13
+ return [];
14
+ return fs
15
+ .readdirSync(dir)
16
+ .filter((f) => f.startsWith(BACKUP_PREFIX))
17
+ .map((f) => {
18
+ const full = path.join(dir, f);
19
+ const stat = fs.statSync(full);
20
+ return { path: full, mtimeMs: stat.mtimeMs };
21
+ })
22
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
23
+ }
24
+ /**
25
+ * Create a WAL-safe snapshot of the Kōbō DB if no backup has been made in the
26
+ * last 24h. Rotates old backups, keeping only the `keepCount` most recent.
27
+ *
28
+ * - Location: same directory as `dbPath`, named `kobo.db.backup-<ISO>-<seq>`
29
+ * (mirrors the settings.json backup convention).
30
+ * - Uses better-sqlite3's online `.backup()` API, which is safe while the DB
31
+ * is open and under write load (WAL mode).
32
+ * - Best-effort: never throws. On failure, logs to console.error and returns
33
+ * `{ created: null, deleted: [] }` so the boot path is never blocked.
34
+ */
35
+ export async function createDailyDbBackupIfNeeded(db, dbPath, keepCount = DEFAULT_KEEP, nowMs = Date.now()) {
36
+ const result = { created: null, deleted: [] };
37
+ const dir = path.dirname(dbPath);
38
+ try {
39
+ const existing = listBackups(dir);
40
+ const latestMtime = existing[0]?.mtimeMs ?? 0;
41
+ if (latestMtime && nowMs - latestMtime < DAILY_MS) {
42
+ return result;
43
+ }
44
+ const stamp = new Date(nowMs).toISOString().replace(/[:.]/g, '-');
45
+ backupSequence += 1;
46
+ const backupPath = path.join(dir, `${BACKUP_PREFIX}${stamp}-${backupSequence}`);
47
+ await db.backup(backupPath);
48
+ result.created = backupPath;
49
+ const all = listBackups(dir);
50
+ if (all.length > keepCount) {
51
+ for (const entry of all.slice(keepCount)) {
52
+ try {
53
+ fs.unlinkSync(entry.path);
54
+ result.deleted.push(entry.path);
55
+ }
56
+ catch (err) {
57
+ console.error(`[kobo] Failed to delete old DB backup ${entry.path}:`, err);
58
+ }
59
+ }
60
+ }
61
+ }
62
+ catch (err) {
63
+ console.error('[kobo] DB daily backup failed:', err);
64
+ }
65
+ return result;
66
+ }
@@ -16,6 +16,10 @@ function cleanEnv() {
16
16
  // ── State ──────────────────────────────────────────────────────────────────────
17
17
  /** workspaceId -> spawned dev-server process */
18
18
  const trackedProcesses = new Map();
19
+ /** Test-only: clear the tracked-processes map between tests. */
20
+ export function _resetTrackedProcessesForTests() {
21
+ trackedProcesses.clear();
22
+ }
19
23
  // ── Pure helpers ───────────────────────────────────────────────────────────────
20
24
  /**
21
25
  * Sanitize a branch name for use as a Docker instance name.
@@ -94,8 +98,14 @@ export function listRunningContainers() {
94
98
  // ── Status ─────────────────────────────────────────────────────────────────────
95
99
  /**
96
100
  * Get the dev-server status for a given project + branch.
101
+ *
102
+ * When `workspaceId` is provided and a start process for that workspace is
103
+ * still running (e.g. `docker compose up -d` is pulling/building images), the
104
+ * status is reported as `'starting'` even if no matching container is visible
105
+ * in `docker ps` yet. This prevents the UI from flashing to `'stopped'` during
106
+ * long build phases.
97
107
  */
98
- export function getStatus(projectPath, workingBranch) {
108
+ export function getStatus(projectPath, workingBranch, workspaceId) {
99
109
  const config = resolveInstance(projectPath, workingBranch);
100
110
  if (!config) {
101
111
  return {
@@ -119,6 +129,19 @@ export function getStatus(projectPath, workingBranch) {
119
129
  containers: matching,
120
130
  };
121
131
  }
132
+ // No matching container yet — but is a start process still in flight?
133
+ // This covers the long `docker compose up -d` build/pull phase where the
134
+ // CLI hasn't exited yet and containers haven't appeared in `docker ps`.
135
+ if (workspaceId && trackedProcesses.has(workspaceId)) {
136
+ return {
137
+ status: 'starting',
138
+ instanceName: config.instanceName,
139
+ projectName: config.projectName,
140
+ httpPort: config.httpPort,
141
+ url: '',
142
+ containers: [],
143
+ };
144
+ }
122
145
  return {
123
146
  status: 'stopped',
124
147
  instanceName: config.instanceName,
@@ -0,0 +1,102 @@
1
+ import { getDb } from '../db/index.js';
2
+ /** Event types that carry user-authored or assistant-authored readable text. */
3
+ const SEARCHABLE_TYPES = ['user:message', 'agent:output'];
4
+ const SNIPPET_CONTEXT = 100; // chars on each side of the match
5
+ /**
6
+ * Extract the readable text content of a ws_events payload, or `null` when
7
+ * the event carries no natural-language content (system events, rate-limit
8
+ * pings, etc.).
9
+ */
10
+ function extractReadableText(type, payload) {
11
+ if (!payload || typeof payload !== 'object')
12
+ return null;
13
+ const p = payload;
14
+ if (type === 'user:message') {
15
+ return typeof p.content === 'string' ? p.content : null;
16
+ }
17
+ if (type === 'agent:output') {
18
+ // Claude Code streams `{type: 'assistant', message: {content: [{type: 'text', text: '...'}, ...]}}`
19
+ const msg = p.message;
20
+ if (msg && Array.isArray(msg.content)) {
21
+ const parts = [];
22
+ for (const block of msg.content) {
23
+ if (block && typeof block === 'object') {
24
+ const b = block;
25
+ if (b.type === 'text' && typeof b.text === 'string')
26
+ parts.push(b.text);
27
+ }
28
+ }
29
+ return parts.length > 0 ? parts.join('\n') : null;
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+ function buildSnippet(text, matchIndex, query) {
35
+ const start = Math.max(0, matchIndex - SNIPPET_CONTEXT);
36
+ const end = Math.min(text.length, matchIndex + query.length + SNIPPET_CONTEXT);
37
+ const prefix = start > 0 ? '…' : '';
38
+ const suffix = end < text.length ? '…' : '';
39
+ return `${prefix}${text.slice(start, end).trim()}${suffix}`;
40
+ }
41
+ /**
42
+ * Full-text-ish search across `ws_events.payload` joined with `workspaces`.
43
+ *
44
+ * - Trimmed empty queries return `[]` without hitting the database.
45
+ * - SQLite does the first filter via `LIKE '%q%'` on the raw payload JSON.
46
+ * Results are then post-filtered in JS against the **readable** text
47
+ * (extracted from the JSON) to avoid false positives like matches inside
48
+ * field names or schema strings.
49
+ * - Snippets are 100 chars of context on either side of the first match.
50
+ */
51
+ export function searchEvents(query, options = {}) {
52
+ const trimmed = query.trim();
53
+ if (trimmed.length === 0)
54
+ return [];
55
+ const { limit = 50, includeArchived = false } = options;
56
+ const db = getDb();
57
+ const typePlaceholders = SEARCHABLE_TYPES.map(() => '?').join(', ');
58
+ const archiveFilter = includeArchived ? '' : 'AND w.archived_at IS NULL';
59
+ const likePattern = `%${trimmed}%`;
60
+ // Over-fetch a bit so post-filter rejects don't shrink us below `limit` on
61
+ // realistic datasets, without scanning the entire table.
62
+ const dbLimit = Math.max(limit * 3, 100);
63
+ const rows = db
64
+ .prepare(`SELECT e.workspace_id, e.type, e.created_at, e.payload,
65
+ w.name AS workspace_name, w.archived_at
66
+ FROM ws_events e
67
+ JOIN workspaces w ON e.workspace_id = w.id
68
+ WHERE e.type IN (${typePlaceholders})
69
+ AND e.payload LIKE ?
70
+ ${archiveFilter}
71
+ ORDER BY e.created_at DESC
72
+ LIMIT ?`)
73
+ .all(...SEARCHABLE_TYPES, likePattern, dbLimit);
74
+ const needle = trimmed.toLowerCase();
75
+ const results = [];
76
+ for (const row of rows) {
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(row.payload);
80
+ }
81
+ catch {
82
+ continue;
83
+ }
84
+ const text = extractReadableText(row.type, parsed);
85
+ if (!text)
86
+ continue;
87
+ const idx = text.toLowerCase().indexOf(needle);
88
+ if (idx < 0)
89
+ continue;
90
+ results.push({
91
+ workspaceId: row.workspace_id,
92
+ workspaceName: row.workspace_name,
93
+ archived: row.archived_at !== null,
94
+ type: row.type,
95
+ timestamp: row.created_at,
96
+ snippet: buildSnippet(text, idx, trimmed),
97
+ });
98
+ if (results.length >= limit)
99
+ break;
100
+ }
101
+ return results;
102
+ }