@kabmo-labs/kabmo 0.5.3 → 0.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 (117) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +359 -350
  3. package/dist/client/static/js/{index-9js9mrmj.js → index-ae9z0yvm.js} +704 -704
  4. package/dist/client/static/js/index-nzyjf92m.css +1 -0
  5. package/marketplace/plugins/stats/README.md +26 -4
  6. package/marketplace/plugins/stats/package.json +2 -2
  7. package/marketplace/plugins/stats/src/HeaderReadout.test.tsx +157 -0
  8. package/marketplace/plugins/stats/src/HeaderReadout.tsx +115 -0
  9. package/marketplace/plugins/stats/src/app.tsx +11 -0
  10. package/marketplace/plugins/stats/src/format.test.ts +27 -1
  11. package/marketplace/plugins/stats/src/format.ts +41 -0
  12. package/package.json +2 -2
  13. package/src/client/bootstrap.d.ts +11 -0
  14. package/src/client/components/common/header-controls/MoreMenu.tsx +38 -1
  15. package/src/client/components/common/header-controls/index.tsx +9 -2
  16. package/src/client/components/common/header-controls/plugin-entry.test.tsx +229 -0
  17. package/src/client/components/common/header-panel-buttons/entries.test.ts +46 -0
  18. package/src/client/components/common/header-panel-buttons/entries.ts +109 -0
  19. package/src/client/components/common/header-panel-buttons/index.tsx +71 -38
  20. package/src/client/components/common/server-switcher/index.test.tsx +150 -0
  21. package/src/client/components/common/server-switcher/index.tsx +281 -0
  22. package/src/client/components/common/window-controls/index.test.tsx +113 -0
  23. package/src/client/components/common/window-controls/index.tsx +66 -0
  24. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +3 -6
  25. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +59 -3
  26. package/src/client/components/layout/desktop-layout/index.tsx +34 -26
  27. package/src/client/components/layout/session-sidebar/Footer.tsx +12 -12
  28. package/src/client/components/layout/session-sidebar/Header.tsx +4 -2
  29. package/src/client/components/mobile/LayoutWrapper.tsx +36 -0
  30. package/src/client/components/mobile/RightSidebar.tsx +2 -2
  31. package/src/client/components/mobile/SessionSidebar.tsx +5 -8
  32. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +15 -12
  33. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +9 -2
  34. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.test.ts +41 -0
  35. package/src/client/components/mobile/mobile-session-sidebar/folder-gap.ts +39 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +6 -2
  37. package/src/client/components/settings/categories/OmpSettings.tsx +25 -29
  38. package/src/client/components/settings/categories/omp-settings/PresetBar.tsx +187 -0
  39. package/src/client/components/settings/categories/omp-settings/RoleEditor.test.ts +260 -32
  40. package/src/client/components/settings/categories/omp-settings/RoleEditor.tsx +198 -102
  41. package/src/client/components/settings/categories/omp-settings/RoleRow.tsx +79 -0
  42. package/src/client/data/settings/omp-schema.json +2 -2
  43. package/src/client/hooks/desktop/servers.test.ts +73 -0
  44. package/src/client/hooks/desktop/servers.ts +126 -0
  45. package/src/client/hooks/settings/model-roles.ts +156 -0
  46. package/src/client/hooks/ui/updates/shell-download.test.ts +157 -0
  47. package/src/client/hooks/ui/updates/shell-download.ts +220 -0
  48. package/src/client/hooks/workspace/keybindings.ts +44 -0
  49. package/src/client/hooks/workspace/open-panel.ts +42 -0
  50. package/src/client/lib/desktop/bridge.ts +158 -0
  51. package/src/client/lib/desktop/titlebar.test.ts +99 -0
  52. package/src/client/lib/desktop/titlebar.ts +97 -0
  53. package/src/client/lib/plugins/slots.test.ts +54 -0
  54. package/src/client/lib/plugins/slots.ts +37 -1
  55. package/src/server/db.server.test.ts +44 -3
  56. package/src/server/db.server.ts +26 -2
  57. package/src/server/lib/bundler/titlebar-css.test.ts +75 -0
  58. package/src/server/lib/db/schema.ts +3 -0
  59. package/src/server/lib/model-roles/catalog.server.test.ts +75 -0
  60. package/src/server/lib/model-roles/catalog.server.ts +112 -0
  61. package/src/server/lib/model-roles/mock.server.ts +40 -0
  62. package/src/server/lib/model-roles/schema.server.ts +36 -0
  63. package/src/server/lib/model-roles/store.server.test.ts +218 -0
  64. package/src/server/lib/model-roles/store.server.ts +188 -0
  65. package/src/server/lib/omp/rpc/destroy-stream-row.test.ts +4 -4
  66. package/src/server/lib/omp/rpc/frame-fold.test.ts +30 -1
  67. package/src/server/lib/omp/rpc/frame-fold.ts +6 -1
  68. package/src/server/lib/omp/rpc/manager.ts +18 -13
  69. package/src/server/lib/omp/rpc/session-command-guards.ts +4 -0
  70. package/src/server/lib/omp/rpc/session-commands.test.ts +24 -0
  71. package/src/server/lib/omp/rpc/session-commands.ts +7 -0
  72. package/src/server/lib/omp/session/auto-title.server.test.ts +147 -93
  73. package/src/server/lib/omp/session/auto-title.server.ts +150 -120
  74. package/src/server/lib/omp/session/rename-with-ai.server.test.ts +18 -6
  75. package/src/server/lib/omp/session/rename-with-ai.server.ts +36 -20
  76. package/src/server/lib/omp/session/title-model-overlay.server.test.ts +29 -14
  77. package/src/server/lib/omp/session/title-model-overlay.server.ts +46 -25
  78. package/src/server/lib/omp/session/title-request.server.test.ts +183 -0
  79. package/src/server/lib/omp/session/title-request.server.ts +213 -0
  80. package/src/server/lib/panels/baseline.server.test.ts +4 -4
  81. package/src/server/lib/panels/compat.server.test.ts +4 -4
  82. package/src/server/lib/panels/registry-bundled.server.test.ts +4 -4
  83. package/src/server/lib/panels/registry.server.test.ts +4 -4
  84. package/src/server/lib/panels/state.server.test.ts +4 -4
  85. package/src/server/lib/panels/sync.server.test.ts +4 -4
  86. package/src/server/lib/plugins/background.test.ts +2 -2
  87. package/src/server/lib/plugins/e2e.test.ts +2 -2
  88. package/src/server/lib/plugins/platform.test.ts +2 -2
  89. package/src/server/lib/queue/delivery-dispatch.server.test.ts +34 -0
  90. package/src/server/lib/queue/delivery.server.ts +11 -2
  91. package/src/server/lib/updates/shell-release.test.ts +184 -0
  92. package/src/server/lib/updates/shell-release.ts +215 -0
  93. package/src/server/lib/waiting/store.server.test.ts +2 -2
  94. package/src/server/plugins/ssr.ts +13 -10
  95. package/src/server/routes/chat/stream.test.ts +4 -4
  96. package/src/server/routes/models/roles.ts +73 -14
  97. package/src/server/routes/panels/css.test.ts +4 -4
  98. package/src/server/routes/panels/panel-routes.test-util.ts +3 -3
  99. package/src/server/routes/plugins/routes.test.ts +4 -4
  100. package/src/server/routes/sessions/batch-delete.test.ts +4 -4
  101. package/src/server/routes/updates/android.ts +2 -2
  102. package/src/server/routes/updates/index.ts +2 -0
  103. package/src/server/routes/updates/ios.ts +26 -0
  104. package/src/server/routes/waiting/routes.test.ts +2 -2
  105. package/src/shared/lib/models/server-cache.ts +11 -0
  106. package/src/shared/lib/omp/config/model-roles.ts +129 -0
  107. package/src/shared/lib/updates/shell-ua.test.ts +56 -24
  108. package/src/shared/lib/updates/shell-ua.ts +41 -20
  109. package/src/shared/lib/workspace/panel-open.test.ts +56 -0
  110. package/src/shared/lib/workspace/panel-open.ts +76 -0
  111. package/src/shared/types/model-roles.ts +54 -0
  112. package/src/test-support/isolated-db.ts +17 -5
  113. package/dist/client/static/js/index-3qamd33d.css +0 -1
  114. package/src/client/hooks/ui/updates/android-download.test.ts +0 -112
  115. package/src/client/hooks/ui/updates/android-download.ts +0 -143
  116. package/src/server/lib/updates/android-release.test.ts +0 -138
  117. package/src/server/lib/updates/android-release.ts +0 -183
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The window-drag contract, at the two places it can silently break.
8
+ *
9
+ * The macOS shell does not read CSS to decide a drag: a WKWebView swallows mouse
10
+ * events, so `ShellWindow.mouseDown` asks the page whether the press landed on
11
+ * `[data-drag-region]`, walking up and stopping at any interactive element. That
12
+ * makes two things load-bearing, and neither fails loudly:
13
+ *
14
+ * 1. **the header must carry the attribute** — without it the window cannot be
15
+ * moved by its own title bar, which is what the previous shell shipped;
16
+ * 2. **the PWA's `-webkit-app-region` must survive the bundle** — it is what the
17
+ * browser-installed app reads for its Window Controls Overlay, and Tailwind's
18
+ * minifier is the last step that could drop it.
19
+ */
20
+
21
+ import { describe, expect, test } from 'bun:test';
22
+ import { readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import { mkdtemp, rm } from 'node:fs/promises';
25
+ import { tmpdir } from 'node:os';
26
+
27
+ import cssPlugin from '@/server/lib/bundler/css';
28
+
29
+ async function build(css: string): Promise<string> {
30
+ const dir = await mkdtemp(join(tmpdir(), 'kabmo-titlebar-'));
31
+ try {
32
+ await Bun.write(join(dir, 'probe.css'), css);
33
+ const result = await Bun.build({
34
+ entrypoints: [join(dir, 'probe.css')],
35
+ outdir: join(dir, 'out'),
36
+ target: 'bun',
37
+ minify: true,
38
+ plugins: [cssPlugin],
39
+ });
40
+ if (!result.success) throw new Error(result.logs.map((log) => log.message).join('\n'));
41
+ return await Bun.file(join(dir, 'out', 'probe.css')).text();
42
+ } finally {
43
+ await rm(dir, { recursive: true, force: true });
44
+ }
45
+ }
46
+
47
+ const source = (path: string) => readFileSync(join(process.cwd(), path), 'utf8');
48
+
49
+ describe('window-drag contract', () => {
50
+ /**
51
+ * The shell's hit test starts at the element under the pointer and walks up.
52
+ * A header without the attribute is a header that cannot move the window.
53
+ */
54
+ test('both title-bar headers carry data-drag-region', () => {
55
+ for (const path of [
56
+ 'src/client/components/layout/desktop-layout/TopNavbar.tsx',
57
+ 'src/client/components/layout/session-sidebar/Header.tsx',
58
+ ]) {
59
+ const file = source(path);
60
+ expect(file).toContain('dragRegionProps()');
61
+ }
62
+ // The attribute name is stated once, in the module the headers import.
63
+ expect(source('src/client/lib/desktop/titlebar.ts')).toContain("'data-drag-region'");
64
+ });
65
+
66
+ /** The PWA keeps its drag region: the property has to reach the browser. */
67
+ test('the webkit property survives the bundle', async () => {
68
+ const out = await build(`
69
+ .titlebar-drag-region { -webkit-app-region: drag; app-region: drag; }
70
+ .titlebar-no-drag { -webkit-app-region: no-drag; app-region: no-drag; }
71
+ `);
72
+ expect(out).toContain('-webkit-app-region:drag');
73
+ expect(out).toContain('-webkit-app-region:no-drag');
74
+ });
75
+ });
@@ -17,6 +17,7 @@ import { ensureBtwSchema } from '@/server/lib/btw/schema.server';
17
17
  import { ensureScheduleSchema } from '@/server/lib/schedule/schema.server';
18
18
  import { ensureWaitingSchema } from '@/server/lib/waiting/schema.server';
19
19
  import { ensurePluginSchema } from '@/server/lib/plugins/schema.server';
20
+ import { ensureModelRolePresetSchema } from '@/server/lib/model-roles/schema.server';
20
21
  import { migrateWorkspaceFolderColumns } from '@/shared/lib/workspace/schema-migrations';
21
22
  import { migrateSessionStreamStateColumns } from '@/shared/lib/omp/session/schema-migrations';
22
23
 
@@ -109,6 +110,8 @@ export async function initSchema(db: DbClient): Promise<void> {
109
110
  await ensureWaitingSchema(db);
110
111
  // The plugin platform's own tables (kv, records, settings).
111
112
  await ensurePluginSchema(db);
113
+ // Named model-role presets (the role editor's preset picker).
114
+ await ensureModelRolePresetSchema(db);
112
115
 
113
116
  await db.exec(`
114
117
  CREATE TABLE IF NOT EXISTS files (
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The role dropdowns' candidate catalog.
8
+ *
9
+ * Two properties matter and both are about failure, not the happy path:
10
+ *
11
+ * - **A catalog failure is REPORTED, never thrown.** The roles live in
12
+ * config.yml and stay editable without a catalog; an empty dropdown with no
13
+ * explanation reads as "no models exist", which is a different and false
14
+ * claim. The message is omp's own sentence where there is one.
15
+ * - **The listing is cached, and dropped by the same signal that drops the
16
+ * chat model cache.** A model removed from models.yml must leave the role
17
+ * pickers too, or they keep offering a selector omp cannot resolve.
18
+ *
19
+ * `omp` is never spawned: the failure case points the binary override at a path
20
+ * that does not exist (which `resolveOmpBin` answers with `null`, by design —
21
+ * it does not fall through to a PATH lookup), and the mock case never reaches a
22
+ * spawn at all.
23
+ */
24
+
25
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
26
+ import { readModelRoleCandidates } from '@/server/lib/model-roles/catalog.server';
27
+ import { invalidateModelsCaches } from '@/shared/lib/models/server-cache';
28
+
29
+ const originalMock = Bun.env.MOCK;
30
+ const originalBin = Bun.env.KABMO_OMP_BIN;
31
+
32
+ beforeEach(() => {
33
+ globalThis.__kabmoRoleCandidatesCache = undefined;
34
+ });
35
+
36
+ afterEach(() => {
37
+ if (originalMock === undefined) delete Bun.env.MOCK;
38
+ else Bun.env.MOCK = originalMock;
39
+ if (originalBin === undefined) delete Bun.env.KABMO_OMP_BIN;
40
+ else Bun.env.KABMO_OMP_BIN = originalBin;
41
+ globalThis.__kabmoRoleCandidatesCache = undefined;
42
+ });
43
+
44
+ describe('role candidate catalog', () => {
45
+ test('mock mode answers a demo catalog covering every role kind', async () => {
46
+ Bun.env.MOCK = 'true';
47
+ const { candidates, error } = await readModelRoleCandidates();
48
+ expect(error).toBeNull();
49
+ const kinds = new Set(candidates.map((candidate) => candidate.kind));
50
+ // The five kind roles need a model of their own kind, or their dropdown is empty.
51
+ for (const kind of ['chat', 'search', 'stt', 'tts', 'tiny', 'image']) {
52
+ expect(kinds.has(kind), `${kind} candidates`).toBe(true);
53
+ }
54
+ });
55
+
56
+ test('a missing omp binary is reported, not thrown', async () => {
57
+ delete Bun.env.MOCK;
58
+ Bun.env.KABMO_OMP_BIN = '/nonexistent/omp-binary';
59
+ const { candidates, error } = await readModelRoleCandidates();
60
+ expect(candidates).toEqual([]);
61
+ expect(error).toContain('omp binary not found');
62
+ });
63
+
64
+ test('a successful listing is cached until the models cache is invalidated', async () => {
65
+ Bun.env.MOCK = 'true';
66
+ await readModelRoleCandidates();
67
+ expect(globalThis.__kabmoRoleCandidatesCache).toBeUndefined();
68
+
69
+ // Seed a cache the way a real read would, then prove the shared invalidation
70
+ // clears it — the same call every provider/model mutation already makes.
71
+ globalThis.__kabmoRoleCandidatesCache = { data: [], expiresAt: Date.now() + 60_000 };
72
+ invalidateModelsCaches();
73
+ expect(globalThis.__kabmoRoleCandidatesCache).toBeUndefined();
74
+ });
75
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The model catalog behind the role dropdowns.
8
+ *
9
+ * Read from `omp models --json --kind all` — the CLI's own listing, so the
10
+ * candidates are exactly what omp can resolve (a model removed from
11
+ * `models.yml`, or one whose provider lost its credential, does not appear).
12
+ * `get_available_models` over RPC was the alternative and was rejected: it
13
+ * answers CHAT models only (`modelRegistry.getAvailable()` defaults to kind
14
+ * `chat`), so the five kind roles would have had nothing to offer.
15
+ *
16
+ * One limit is worth naming: the CLI's JSON does not carry `webSearch`, so a
17
+ * CHAT model that omp's `web` role would also accept cannot be identified
18
+ * here. `web` therefore offers search-kind rows; a chat model already assigned
19
+ * to it stays selectable through the dropdown's current-value option.
20
+ *
21
+ * Cached for a minute on `globalThis` — a listing spawn costs ~1.3 s and the
22
+ * settings panel reads it on open and on Refresh.
23
+ */
24
+
25
+ import { childEnv } from '@/server/lib/fs/child-env';
26
+ import { resolveOmpBin } from '@/server/lib/omp/core/cli';
27
+ import { isMockMode } from '@/server/mock.server';
28
+ import { mockRoleCandidates } from '@/server/lib/model-roles/mock.server';
29
+ import type { ModelRoleCandidate } from '@/shared/types/model-roles';
30
+
31
+ const CANDIDATES_TTL_MS = 60_000;
32
+ const LIST_TIMEOUT_MS = 30_000;
33
+
34
+ /**
35
+ * One `omp models --json` row → a candidate; null when the row is unusable.
36
+ *
37
+ * The row is read as an untyped record and every field is narrowed: the JSON is
38
+ * produced by a binary this module does not compile against, so a missing or
39
+ * retyped field must degrade the row, not the listing.
40
+ */
41
+ function toCandidate(raw: unknown): ModelRoleCandidate | null {
42
+ if (typeof raw !== 'object' || raw === null) return null;
43
+ const row: Record<string, unknown> = { ...raw };
44
+ const provider = row.provider;
45
+ const id = row.id;
46
+ if (typeof provider !== 'string' || typeof id !== 'string') return null;
47
+ const name = row.name;
48
+ const kind = row.kind;
49
+ const selector = row.selector;
50
+ return {
51
+ selector: typeof selector === 'string' ? selector : `${provider}/${id}`,
52
+ id,
53
+ name: typeof name === 'string' && name.trim() ? name : id,
54
+ provider,
55
+ kind: typeof kind === 'string' ? kind : 'chat',
56
+ };
57
+ }
58
+
59
+ /** `omp models --json --kind all` → candidates; throws with omp's own sentence. */
60
+ async function readCandidatesFromCli(): Promise<ModelRoleCandidate[]> {
61
+ const bin = resolveOmpBin();
62
+ if (!bin) throw new Error('omp binary not found — install omp to pick role models');
63
+ const proc = Bun.spawn({
64
+ env: childEnv(),
65
+ cmd: [bin, 'models', '--json', '--kind', 'all'],
66
+ stdout: 'pipe',
67
+ stderr: 'pipe',
68
+ timeout: LIST_TIMEOUT_MS,
69
+ maxBuffer: 32 * 1024 * 1024,
70
+ windowsHide: true,
71
+ });
72
+ const [stdout, stderr] = await Promise.all([
73
+ new Response(proc.stdout).text(),
74
+ new Response(proc.stderr).text(),
75
+ ]);
76
+ const exitCode = await proc.exited;
77
+ if (exitCode !== 0) {
78
+ const line = (stderr.trim() || stdout.trim()).split('\n').map((row) => row.trim()).find(Boolean);
79
+ throw new Error(line ? line.replace(/^[^\w@/]*/, '') : `omp models exited with code ${exitCode}`);
80
+ }
81
+ let parsed: unknown;
82
+ try {
83
+ parsed = JSON.parse(stdout);
84
+ } catch {
85
+ throw new Error('omp models returned unexpected JSON');
86
+ }
87
+ const rows = typeof parsed === 'object' && parsed !== null && 'models' in parsed ? parsed.models : undefined;
88
+ if (!Array.isArray(rows)) throw new Error('omp models returned no model list');
89
+ return rows.flatMap((raw) => {
90
+ const candidate = toCandidate(raw);
91
+ return candidate ? [candidate] : [];
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Candidates for the role dropdowns. A catalog failure is REPORTED rather than
97
+ * thrown: the roles themselves live in config.yml and stay editable without it,
98
+ * and an empty dropdown with no explanation reads as "no models exist".
99
+ */
100
+ export async function readModelRoleCandidates(): Promise<{ candidates: ModelRoleCandidate[]; error: string | null }> {
101
+ if (isMockMode()) return { candidates: mockRoleCandidates(), error: null };
102
+ const cached = globalThis.__kabmoRoleCandidatesCache;
103
+ if (cached && cached.expiresAt > Date.now()) return { candidates: cached.data, error: null };
104
+ try {
105
+ const candidates = await readCandidatesFromCli();
106
+ globalThis.__kabmoRoleCandidatesCache = { data: candidates, expiresAt: Date.now() + CANDIDATES_TTL_MS };
107
+ return { candidates, error: null };
108
+ } catch (error) {
109
+ return { candidates: [], error: error instanceof Error ? error.message : String(error) };
110
+ }
111
+ }
112
+
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Demo catalog for the role dropdowns under `MOCK=true`.
8
+ *
9
+ * A demo install spawns no omp child, so `omp models` answers nothing and every
10
+ * role dropdown would be empty. The chat rows come from the SAME demo catalog
11
+ * `/api/models` serves in mock mode, so the role picker and the composer picker
12
+ * name the same models; the non-chat rows exist because the five kind roles
13
+ * accept a model of that kind only and would otherwise have no option at all.
14
+ */
15
+
16
+ import { INITIAL_MODELS_CATALOG } from '@/client/data/models/catalog';
17
+ import type { ModelRoleCandidate } from '@/shared/types/model-roles';
18
+
19
+ const DEMO_KIND_MODELS: Array<Pick<ModelRoleCandidate, 'id' | 'name' | 'provider' | 'kind'>> = [
20
+ { id: 'brave', name: 'Brave', provider: 'web', kind: 'search' },
21
+ { id: 'deepseek', name: 'DeepSeek Search', provider: 'web', kind: 'search' },
22
+ { id: 'gemma-3-1b', name: 'Gemma 3 1B', provider: 'local', kind: 'tiny' },
23
+ { id: 'whisper-base', name: 'Whisper Base', provider: 'local', kind: 'stt' },
24
+ { id: 'kokoro', name: 'Kokoro-82M', provider: 'local', kind: 'tts' },
25
+ { id: 'flux-schnell', name: 'FLUX.1 Schnell', provider: 'local', kind: 'image' },
26
+ ];
27
+
28
+ export function mockRoleCandidates(): ModelRoleCandidate[] {
29
+ const chat: ModelRoleCandidate[] = INITIAL_MODELS_CATALOG.map((model) => ({
30
+ selector: `${model.provider}/${model.id}`,
31
+ id: model.id,
32
+ name: model.name,
33
+ provider: model.provider,
34
+ kind: 'chat',
35
+ }));
36
+ return [
37
+ ...chat,
38
+ ...DEMO_KIND_MODELS.map((model) => ({ ...model, selector: `${model.provider}/${model.id}` })),
39
+ ];
40
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Model-role preset schema, in its own module for the reason
8
+ * `schedule/schema.server.ts` and `btw/schema.server.ts` are: it is created on
9
+ * every database open, and the feature that reads it owns the DDL.
10
+ *
11
+ * The preset's whole `modelRoles` map is ONE JSON column rather than a row per
12
+ * role. A preset is a SNAPSHOT applied as a unit — the same semantics as omp's
13
+ * own `/modelpreset switch`, which re-applies the saved roles "as a whole" — so
14
+ * a per-role table would have to express an operation that does not exist.
15
+ *
16
+ * `name` is UNIQUE with `COLLATE NOCASE`: a user who saves "Fast" twice means
17
+ * the same preset, and two rows differing only in case would be two entries the
18
+ * picker cannot tell apart.
19
+ */
20
+
21
+ import type { DbClient } from '@/server/lib/db/client';
22
+
23
+ const MODEL_ROLE_PRESET_SCHEMA_SQL = `
24
+ CREATE TABLE IF NOT EXISTS model_role_presets (
25
+ id TEXT PRIMARY KEY,
26
+ name TEXT NOT NULL COLLATE NOCASE UNIQUE,
27
+ roles TEXT NOT NULL DEFAULT '{}',
28
+ created_at INTEGER NOT NULL,
29
+ updated_at INTEGER NOT NULL
30
+ );
31
+ `;
32
+
33
+ /** Idempotent: the role-preset table, created on every database open. */
34
+ export async function ensureModelRolePresetSchema(db: DbClient): Promise<void> {
35
+ await db.exec(MODEL_ROLE_PRESET_SCHEMA_SQL);
36
+ }
@@ -0,0 +1,218 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Model-role presets, end to end over the real route and a temp database.
8
+ *
9
+ * The properties pinned here are the ones a preset picker depends on:
10
+ *
11
+ * - A preset is stored in Kabmo's OWN table (`model_role_presets`), so it
12
+ * survives a restart and does not depend on omp's config.yml.
13
+ * - A name is unique case-insensitively — saving "Cheap" twice is one preset,
14
+ * and the second answer names the collision instead of the SQLite sentence.
15
+ * - `PUT` writes the WHOLE map, which is what applying a preset is; an empty
16
+ * selector removes a role rather than storing `''`.
17
+ * - A custom role id survives, because `modelRoles` accepts them and the map
18
+ * is written back whole — filtering would silently delete one.
19
+ *
20
+ * `omp` is never spawned: the loader's catalog read is pointed at MOCK mode by
21
+ * the candidate test's own fixture (see `mock.server.ts`), and the rest of the
22
+ * route does not touch it.
23
+ */
24
+
25
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
26
+ import { mkdtempSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+ import { isolateDb, releaseDb } from '@/test-support/isolated-db';
30
+ import { removeDir } from '@/test-support/remove-dir';
31
+ import { getDb } from '@/server/db.server';
32
+ import { initSchema } from '@/server/lib/db/schema';
33
+ import { action, loader } from '@/server/routes/models/roles';
34
+ import {
35
+ SEEDED_PRESET_NAME,
36
+ deleteModelRolePreset,
37
+ listModelRolePresets,
38
+ saveModelRolePreset,
39
+ seedPresetFromCurrentRoles,
40
+ } from '@/server/lib/model-roles/store.server';
41
+
42
+ type Handler = (args: never) => unknown;
43
+
44
+ const originalAgentDir = Bun.env.PI_CODING_AGENT_DIR;
45
+ const originalMock = Bun.env.MOCK;
46
+ let agentDir = '';
47
+
48
+ beforeEach(async () => {
49
+ // A temp agent dir, not the developer's: `PUT` writes `config.yml`, and a
50
+ // suite that pointed at `~/.omp/agent` would rewrite a live install's roles.
51
+ agentDir = mkdtempSync(join(tmpdir(), 'omc-role-presets-'));
52
+ Bun.env.PI_CODING_AGENT_DIR = agentDir;
53
+ await isolateDb();
54
+ await initSchema(await getDb());
55
+ Bun.env.MOCK = 'true';
56
+ });
57
+
58
+ afterEach(async () => {
59
+ await releaseDb();
60
+ removeDir(agentDir);
61
+ if (originalAgentDir === undefined) delete Bun.env.PI_CODING_AGENT_DIR;
62
+ else Bun.env.PI_CODING_AGENT_DIR = originalAgentDir;
63
+ if (originalMock === undefined) delete Bun.env.MOCK;
64
+ else Bun.env.MOCK = originalMock;
65
+ });
66
+
67
+ function call(handler: Handler, request: Request): Promise<Response> {
68
+ return handler({ request, params: {} } as never) as Promise<Response>;
69
+ }
70
+
71
+ function send(verb: string, body?: unknown, query = ''): Request {
72
+ return new Request(`http://localhost/api/model-roles${query}`, {
73
+ method: verb,
74
+ ...(body === undefined ? {} : { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } }),
75
+ });
76
+ }
77
+
78
+ describe('preset store', () => {
79
+ test('a preset round-trips its whole role map', async () => {
80
+ const saved = await saveModelRolePreset({
81
+ id: null,
82
+ name: 'cheap',
83
+ roles: { default: 'kenari/deepseek-v4-flash', tiny: 'local/gemma-3-1b' },
84
+ });
85
+ expect(saved.name).toBe('cheap');
86
+ expect(saved.roles).toEqual({ default: 'kenari/deepseek-v4-flash', tiny: 'local/gemma-3-1b' });
87
+ expect(await listModelRolePresets()).toEqual([saved]);
88
+ });
89
+
90
+ test('a duplicate name is refused with a sentence, case-insensitively', async () => {
91
+ await saveModelRolePreset({ id: null, name: 'Cheap', roles: { default: 'a/b' } });
92
+ await expect(saveModelRolePreset({ id: null, name: 'cheap', roles: { default: 'c/d' } }))
93
+ .rejects.toThrow('A preset named “cheap” already exists');
94
+ });
95
+
96
+ test('an update keeps created_at and moves updated_at', async () => {
97
+ const first = await saveModelRolePreset({ id: null, name: 'p', roles: { default: 'a/b' } });
98
+ const second = await saveModelRolePreset({ id: first.id, name: 'p2', roles: { default: 'c/d' } });
99
+ expect(second.id).toBe(first.id);
100
+ expect(second.name).toBe('p2');
101
+ expect(second.createdAt).toBe(first.createdAt);
102
+ expect(second.updatedAt).toBeGreaterThanOrEqual(first.updatedAt);
103
+ });
104
+
105
+ test('an empty selector is dropped and a custom role id survives', async () => {
106
+ const saved = await saveModelRolePreset({
107
+ id: null,
108
+ name: 'mixed',
109
+ roles: { default: 'a/b', tiny: ' ', 'my-role': 'x/y', 'bad role': 'z/z' },
110
+ });
111
+ expect(saved.roles).toEqual({ default: 'a/b', 'my-role': 'x/y' });
112
+ });
113
+
114
+ test('a preset with no usable role is refused', async () => {
115
+ await expect(saveModelRolePreset({ id: null, name: 'empty', roles: { default: '' } }))
116
+ .rejects.toThrow('A preset needs at least one role assigned');
117
+ });
118
+
119
+ test('a blank name is refused', async () => {
120
+ await expect(saveModelRolePreset({ id: null, name: ' ', roles: { default: 'a/b' } }))
121
+ .rejects.toThrow('Preset name is required');
122
+ });
123
+ });
124
+
125
+ describe('one-shot seed from the install’s current roles', () => {
126
+ test('an install with roles and no presets gets one preset named Default', async () => {
127
+ Bun.env.MOCK = 'false';
128
+ await seedPresetFromCurrentRoles({ default: 'kenari/deepseek-v4-flash', plan: 'kenari/deepseek-v4-pro' });
129
+ const presets = await listModelRolePresets();
130
+ expect(presets.length).toBe(1);
131
+ expect(presets[0].name).toBe(SEEDED_PRESET_NAME);
132
+ expect(presets[0].roles).toEqual({ default: 'kenari/deepseek-v4-flash', plan: 'kenari/deepseek-v4-pro' });
133
+ });
134
+
135
+ test('a deleted seed is NOT resurrected on the next read', async () => {
136
+ Bun.env.MOCK = 'false';
137
+ await seedPresetFromCurrentRoles({ default: 'a/b' });
138
+ const [seeded] = await listModelRolePresets();
139
+ await deleteModelRolePreset(seeded.id);
140
+
141
+ await seedPresetFromCurrentRoles({ default: 'a/b' });
142
+ expect(await listModelRolePresets()).toEqual([]);
143
+ });
144
+
145
+ test('a table that already holds a preset is left alone', async () => {
146
+ Bun.env.MOCK = 'false';
147
+ await saveModelRolePreset({ id: null, name: 'mine', roles: { default: 'x/y' } });
148
+ await seedPresetFromCurrentRoles({ default: 'a/b' });
149
+ expect((await listModelRolePresets()).map((preset) => preset.name)).toEqual(['mine']);
150
+ });
151
+
152
+ test('a map with no roles seeds nothing but still spends the run', async () => {
153
+ Bun.env.MOCK = 'false';
154
+ await seedPresetFromCurrentRoles({});
155
+ expect(await listModelRolePresets()).toEqual([]);
156
+ // The run is spent: a later non-empty map does not seed retroactively.
157
+ await seedPresetFromCurrentRoles({ default: 'a/b' });
158
+ expect(await listModelRolePresets()).toEqual([]);
159
+ });
160
+
161
+ test('mock mode writes no demo preset into the database', async () => {
162
+ Bun.env.MOCK = 'true';
163
+ await seedPresetFromCurrentRoles({ default: 'a/b' });
164
+ expect(await listModelRolePresets()).toEqual([]);
165
+ });
166
+ });
167
+
168
+ describe('model-roles route', () => {
169
+ test('GET answers roles, presets and candidates together', async () => {
170
+ const res = await call(loader, send('GET'));
171
+ const body = await res.json() as Record<string, unknown>;
172
+ expect(res.status).toBe(200);
173
+ expect(body.roles).toEqual({});
174
+ expect(body.presets).toEqual([]);
175
+ // Mock mode answers the demo catalog — never a spawn, never a network read.
176
+ expect(Array.isArray(body.candidates)).toBe(true);
177
+ expect((body.candidates as unknown[]).length).toBeGreaterThan(0);
178
+ });
179
+
180
+ test('POST saves a preset and answers the whole list', async () => {
181
+ const res = await call(action, send('POST', { preset: { name: 'fast', roles: { default: 'a/b' } } }));
182
+ const body = await res.json() as { success: boolean; preset: { id: string }; presets: unknown[] };
183
+ expect(res.status).toBe(200);
184
+ expect(body.success).toBe(true);
185
+ expect(body.presets.length).toBe(1);
186
+ });
187
+
188
+ test('a duplicate name answers 400 with the refusal sentence', async () => {
189
+ await call(action, send('POST', { preset: { name: 'dup', roles: { default: 'a/b' } } }));
190
+ const res = await call(action, send('POST', { preset: { name: 'dup', roles: { default: 'c/d' } } }));
191
+ const body = await res.json() as { error: string };
192
+ expect(res.status).toBe(400);
193
+ expect(body.error).toContain('already exists');
194
+ });
195
+
196
+ test('DELETE removes by id and 404s an unknown one', async () => {
197
+ const created = await call(action, send('POST', { preset: { name: 'gone', roles: { default: 'a/b' } } }));
198
+ const { preset } = await created.json() as { preset: { id: string } };
199
+ const removed = await call(action, send('DELETE', undefined, `?id=${preset.id}`));
200
+ expect(removed.status).toBe(200);
201
+ expect(await listModelRolePresets()).toEqual([]);
202
+
203
+ const missing = await call(action, send('DELETE', undefined, '?id=nope'));
204
+ expect(missing.status).toBe(404);
205
+ });
206
+
207
+ test('PUT writes the whole map through config.yml, dropping blank selectors', async () => {
208
+ const res = await call(action, send('PUT', { roles: { default: 'kenari/deepseek-v4-flash', tiny: '' } }));
209
+ const body = await res.json() as { success: boolean; roles: Record<string, string> };
210
+ expect(res.status).toBe(200);
211
+ expect(body.roles).toEqual({ default: 'kenari/deepseek-v4-flash' });
212
+
213
+ // Read back through the loader: the map really landed in the file.
214
+ const reloaded = await call(loader, send('GET'));
215
+ expect((await reloaded.json() as { roles: Record<string, string> }).roles)
216
+ .toEqual({ default: 'kenari/deepseek-v4-flash' });
217
+ });
218
+ });