@polderlabs/bizar 4.9.0 → 5.0.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 (107) hide show
  1. package/bizar-dash/dist/assets/icons-CFqu2M-c.js +656 -0
  2. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +1 -0
  3. package/bizar-dash/dist/assets/{index-DU61awG3.js → index-DmpSFPJY.js} +1 -1
  4. package/bizar-dash/dist/assets/{index-DU61awG3.js.map → index-DmpSFPJY.js.map} +1 -1
  5. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +16 -0
  6. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-DfmIfOUS.css → main-ZAfGKENE.css} +1 -1
  8. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +29 -0
  9. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +1 -0
  10. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +2 -0
  11. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile-DHXXbn1A.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-CL5uUQEC.js.map → mobile-DHXXbn1A.js.map} +1 -1
  14. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js +40 -0
  15. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +1 -0
  16. package/bizar-dash/dist/index.html +6 -3
  17. package/bizar-dash/dist/mobile.html +5 -2
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/skills/eval/SKILL.md +237 -0
  20. package/bizar-dash/src/server/api.mjs +28 -0
  21. package/bizar-dash/src/server/auth.mjs +155 -1
  22. package/bizar-dash/src/server/eval-store.mjs +226 -0
  23. package/bizar-dash/src/server/eval.mjs +347 -0
  24. package/bizar-dash/src/server/ocr.mjs +55 -0
  25. package/bizar-dash/src/server/plugins/registry.mjs +363 -0
  26. package/bizar-dash/src/server/plugins/sandbox.mjs +655 -0
  27. package/bizar-dash/src/server/plugins/store.mjs +659 -0
  28. package/bizar-dash/src/server/routes/clipboard.mjs +173 -0
  29. package/bizar-dash/src/server/routes/eval.mjs +147 -0
  30. package/bizar-dash/src/server/routes/ocr.mjs +182 -0
  31. package/bizar-dash/src/server/routes/plugins.mjs +220 -0
  32. package/bizar-dash/src/server/routes/users.mjs +84 -0
  33. package/bizar-dash/src/server/routes/voice.mjs +131 -0
  34. package/bizar-dash/src/server/routes/workspaces.mjs +204 -0
  35. package/bizar-dash/src/server/voice-store.mjs +202 -0
  36. package/bizar-dash/src/server/voice-transcribe.mjs +72 -0
  37. package/bizar-dash/src/server/workspaces.mjs +626 -0
  38. package/bizar-dash/src/web/components/InviteDialog.tsx +205 -0
  39. package/bizar-dash/src/web/components/ScreenshotCapture.tsx +138 -0
  40. package/bizar-dash/src/web/components/ScreenshotOCR.tsx +42 -0
  41. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +182 -0
  42. package/bizar-dash/src/web/components/VoiceRecorder.tsx +104 -0
  43. package/bizar-dash/src/web/components/WorkspaceSelector.tsx +158 -0
  44. package/bizar-dash/src/web/styles/memory.css +82 -0
  45. package/bizar-dash/src/web/views/Memory.tsx +17 -2
  46. package/bizar-dash/src/web/views/Settings.tsx +3 -0
  47. package/bizar-dash/src/web/views/Workspace.tsx +294 -0
  48. package/bizar-dash/src/web/views/memory/FromScreenshotPanel.tsx +23 -0
  49. package/bizar-dash/src/web/views/memory/VaultFromClipboardPanel.tsx +101 -0
  50. package/bizar-dash/src/web/views/settings/WorkspacesSection.tsx +189 -0
  51. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -2
  52. package/bizar-dash/tests/clipboard.test.mjs +147 -0
  53. package/bizar-dash/tests/components/screenshot-ocr.test.tsx +75 -0
  54. package/bizar-dash/tests/components/workspace-selector.test.tsx +73 -0
  55. package/bizar-dash/tests/deploy-templates.test.mjs +100 -0
  56. package/bizar-dash/tests/eval/fixtures.test.mjs +141 -0
  57. package/bizar-dash/tests/eval/report.test.mjs +284 -0
  58. package/bizar-dash/tests/eval/runner.test.mjs +471 -0
  59. package/bizar-dash/tests/ocr.test.mjs +87 -0
  60. package/bizar-dash/tests/plugins-registry.test.mjs +387 -0
  61. package/bizar-dash/tests/plugins-sandbox.test.mjs +374 -0
  62. package/bizar-dash/tests/plugins-store.test.mjs +455 -0
  63. package/bizar-dash/tests/users.test.mjs +108 -0
  64. package/bizar-dash/tests/voice-recorder.test.tsx +95 -0
  65. package/bizar-dash/tests/voice-store.test.mjs +148 -0
  66. package/bizar-dash/tests/voice-transcribe.test.mjs +87 -0
  67. package/bizar-dash/tests/workspaces.test.mjs +527 -0
  68. package/cli/bin.mjs +72 -2
  69. package/cli/commands/clip.mjs +146 -0
  70. package/cli/commands/deploy/cloudflare.mjs +250 -0
  71. package/cli/commands/deploy/docker.mjs +221 -0
  72. package/cli/commands/deploy/fly.mjs +161 -0
  73. package/cli/commands/deploy/vercel.mjs +225 -0
  74. package/cli/commands/deploy.mjs +240 -0
  75. package/cli/commands/eval.mjs +378 -0
  76. package/cli/commands/marketplace.mjs +64 -0
  77. package/cli/commands/ocr.mjs +165 -0
  78. package/cli/commands/plugin.mjs +358 -0
  79. package/cli/commands/voice.mjs +211 -0
  80. package/cli/commands/workspace.mjs +247 -0
  81. package/package.json +12 -8
  82. package/templates/deploy/cloudflare/README.md +32 -0
  83. package/templates/deploy/cloudflare/functions-index.template.js +15 -0
  84. package/templates/deploy/cloudflare/wrangler.toml.template +9 -0
  85. package/templates/deploy/docker/.env.template +16 -0
  86. package/templates/deploy/docker/README.md +58 -0
  87. package/templates/deploy/docker/docker-compose.template.yml +23 -0
  88. package/templates/deploy/fly/README.md +35 -0
  89. package/templates/deploy/fly/fly.toml.template +28 -0
  90. package/templates/deploy/vercel/README.md +29 -0
  91. package/templates/deploy/vercel/api-index.template.js +18 -0
  92. package/templates/deploy/vercel/vercel.json.template +16 -0
  93. package/templates/eval-fixtures/README.md +58 -0
  94. package/templates/eval-fixtures/code-search-basic.json +28 -0
  95. package/templates/eval-fixtures/latency-bounds.json +16 -0
  96. package/templates/eval-fixtures/regression-suite.json +79 -0
  97. package/templates/eval-fixtures/response-format.json +30 -0
  98. package/templates/eval-fixtures/tool-call-correctness.json +24 -0
  99. package/templates/plugin-template/README.md +121 -0
  100. package/templates/plugin-template/index.js +66 -0
  101. package/templates/plugin-template/plugin.json +42 -0
  102. package/templates/plugin-template/tests/plugin.test.js +83 -0
  103. package/bizar-dash/dist/assets/main-DaC1Lc6q.js +0 -366
  104. package/bizar-dash/dist/assets/main-DaC1Lc6q.js.map +0 -1
  105. package/bizar-dash/dist/assets/mobile-CL5uUQEC.js +0 -1
  106. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js +0 -338
  107. package/bizar-dash/dist/assets/mobile-D5WTWvuh.js.map +0 -1
@@ -0,0 +1,173 @@
1
+ /**
2
+ * src/server/routes/clipboard.mjs
3
+ *
4
+ * v5.0.0 — Web Clipper endpoints.
5
+ *
6
+ * Accepts content pasted from the browser extension or bookmarklet,
7
+ * saves it as a markdown note in the vault with YAML frontmatter.
8
+ */
9
+
10
+ import { Router } from 'express';
11
+ import { join } from 'node:path';
12
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from 'node:fs';
13
+
14
+ let _wrap = null;
15
+ async function getShared() {
16
+ if (!_wrap) {
17
+ const mod = await import('./_shared.mjs');
18
+ _wrap = mod.wrap;
19
+ }
20
+ return { wrap: _wrap };
21
+ }
22
+
23
+ /**
24
+ * In-memory logs for recent clips. Persisted to a JSON file for reboot survival.
25
+ */
26
+ function getLogPath(projectRoot) {
27
+ return join(projectRoot, '.bizar', 'clipboard-log.json');
28
+ }
29
+
30
+ function loadLog(projectRoot) {
31
+ try {
32
+ const p = getLogPath(projectRoot);
33
+ if (!existsSync(p)) return [];
34
+ return JSON.parse(readFileSync(p, 'utf8'));
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ function saveLog(projectRoot, log) {
41
+ const p = getLogPath(projectRoot);
42
+ mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
43
+ writeFileSync(p, JSON.stringify(log, null, 2) + '\n', 'utf8');
44
+ }
45
+
46
+ function appendLog(projectRoot, entry) {
47
+ const log = loadLog(projectRoot);
48
+ log.unshift(entry);
49
+ if (log.length > 100) log.length = 100; // keep last 100
50
+ saveLog(projectRoot, log);
51
+ }
52
+
53
+ function deleteLogEntry(projectRoot, id) {
54
+ const log = loadLog(projectRoot);
55
+ const idx = log.findIndex((e) => e.id === id);
56
+ if (idx === -1) return false;
57
+ log.splice(idx, 1);
58
+ saveLog(projectRoot, log);
59
+ return true;
60
+ }
61
+
62
+ export async function createClipboardRouter({ projectRoot }) {
63
+ const router = Router();
64
+ const { wrap } = await getShared();
65
+
66
+ /**
67
+ * POST /api/clipboard/save
68
+ *
69
+ * Accepts { url, title, content, selection?, savedAt? }
70
+ * Saves a markdown note to the vault at clips/<slug>.md
71
+ */
72
+ router.post('/clipboard/save', wrap(async (req, res) => {
73
+ const { url, title, content, selection, savedAt } = req.body || {};
74
+ if (!url && !content) {
75
+ res.status(400).json({ error: 'bad_request', message: 'url or content is required' });
76
+ return;
77
+ }
78
+
79
+ // Build a safe slug from the title or URL
80
+ const slugBase = (title || url || 'clip')
81
+ .replace(/^https?:\/\//, '')
82
+ .replace(/[^a-zA-Z0-9_\- ]/g, '')
83
+ .trim()
84
+ .substring(0, 60)
85
+ .replace(/\s+/g, '_')
86
+ .toLowerCase() || 'clip';
87
+
88
+ const timestamp = Date.now();
89
+ const slug = `${slugBase}_${timestamp}`;
90
+ const relPath = `clips/${slug}.md`;
91
+
92
+ // Build frontmatter
93
+ const noteTitle = title || 'Web Clip';
94
+ const fm = {
95
+ title: noteTitle,
96
+ url: url || '',
97
+ savedAt: savedAt || new Date().toISOString(),
98
+ type: 'webclip',
99
+ tags: ['webclip'],
100
+ };
101
+
102
+ // Build body
103
+ const body = [
104
+ selection ? `> ${selection.replace(/\n/g, '\n> ')}` : '',
105
+ '',
106
+ '## Source',
107
+ '',
108
+ `- **URL**: ${url || '(no URL)'}`,
109
+ `- **Date**: ${fm.savedAt}`,
110
+ '',
111
+ '## Content',
112
+ '',
113
+ '```html',
114
+ (content || '').substring(0, 50000),
115
+ '```',
116
+ '',
117
+ ].join('\n');
118
+
119
+ // Write to vault
120
+ const vaultDir = join(projectRoot, '.obsidian');
121
+ mkdirSync(join(vaultDir, 'clips'), { recursive: true });
122
+ const notePath = join(vaultDir, relPath);
123
+
124
+ const noteContent = [
125
+ '---',
126
+ ...Object.entries(fm).map(([k, v]) => `${k}: ${JSON.stringify(v)}`),
127
+ '---',
128
+ '',
129
+ body,
130
+ ].join('\n');
131
+
132
+ writeFileSync(notePath, noteContent, 'utf8');
133
+
134
+ // Log for recent-clips listing
135
+ const entry = {
136
+ id: slug,
137
+ title: noteTitle,
138
+ url,
139
+ notePath: relPath,
140
+ savedAt: fm.savedAt,
141
+ };
142
+ appendLog(projectRoot, entry);
143
+
144
+ res.status(201).json({ ok: true, notePath: relPath, id: slug });
145
+ }));
146
+
147
+ /**
148
+ * GET /api/clipboard/list
149
+ *
150
+ * Returns recent clips from the log.
151
+ */
152
+ router.get('/clipboard/list', wrap(async (_req, res) => {
153
+ const clips = loadLog(projectRoot);
154
+ res.json({ clips });
155
+ }));
156
+
157
+ /**
158
+ * DELETE /api/clipboard/:id
159
+ *
160
+ * Remove a clip from the log (does not delete the note file).
161
+ */
162
+ router.delete('/clipboard/:id', wrap(async (req, res) => {
163
+ const { id } = req.params;
164
+ const ok = deleteLogEntry(projectRoot, id);
165
+ if (!ok) {
166
+ res.status(404).json({ error: 'not_found', message: `clip ${id} not found` });
167
+ return;
168
+ }
169
+ res.json({ ok: true });
170
+ }));
171
+
172
+ return router;
173
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * src/server/routes/eval.mjs
3
+ *
4
+ * v5.0.0 — REST surface for the eval framework.
5
+ *
6
+ * Endpoints:
7
+ * GET /api/eval/runs — list recent runs
8
+ * GET /api/eval/runs/:id — get run details
9
+ * POST /api/eval/run — run a suite
10
+ * GET /api/eval/runs/:id/compare/:otherId — diff two runs
11
+ * GET /api/eval/fixtures — list fixtures in a suite path
12
+ *
13
+ * The llmCall defaults to minimax.chatCompletion but can be overridden
14
+ * via the state for custom providers.
15
+ */
16
+ import { Router } from 'express';
17
+ import { loadFixtures, runFixture, runSuite } from '../eval.mjs';
18
+ import { saveRun, listRuns, getRun, compareRuns, buildRunId } from '../eval-store.mjs';
19
+ import { chatCompletion } from '../minimax.mjs';
20
+ import { wrap } from './_shared.mjs';
21
+
22
+ /**
23
+ * @param {object} deps
24
+ * @param {object} deps.state
25
+ * @param {Function} deps.broadcast
26
+ * @returns {import('express').Router}
27
+ */
28
+ export function createEvalRouter({ state, broadcast }) {
29
+ const router = Router();
30
+
31
+ // GET /api/eval/runs — list recent runs
32
+ router.get('/eval/runs', wrap(async (req, res) => {
33
+ const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
34
+ const runs = await listRuns({ limit });
35
+ res.json({ runs });
36
+ }));
37
+
38
+ // GET /api/eval/runs/:id — get run details
39
+ router.get('/eval/runs/:id', wrap(async (req, res) => {
40
+ const run = await getRun(req.params.id);
41
+ if (!run) {
42
+ res.status(404).json({ error: 'not_found', message: `run ${req.params.id} not found` });
43
+ return;
44
+ }
45
+ res.json(run);
46
+ }));
47
+
48
+ // GET /api/eval/fixtures — list fixtures in a suite path
49
+ router.get('/eval/fixtures', wrap(async (req, res) => {
50
+ const { path: suitePath } = req.query;
51
+ if (!suitePath || typeof suitePath !== 'string') {
52
+ res.status(400).json({ error: 'bad_request', message: 'query.path is required' });
53
+ return;
54
+ }
55
+ const fixtures = loadFixtures(suitePath);
56
+ res.json({ fixtures });
57
+ }));
58
+
59
+ // POST /api/eval/run — run a suite
60
+ router.post('/eval/run', wrap(async (req, res) => {
61
+ const { suitePath, fixtures: fixtureIds, concurrency = 5, agent = 'thor' } = req.body || {};
62
+
63
+ if (!suitePath || typeof suitePath !== 'string') {
64
+ res.status(400).json({ error: 'bad_request', message: 'suitePath is required' });
65
+ return;
66
+ }
67
+
68
+ // Build the llmCall — use minimax by default, allow state override
69
+ const llmCall = async (prompt, opts = {}) => {
70
+ // Check if state has a custom eval LLM provider
71
+ const customCall = state?.getSettings?.()?.eval?.llmCall;
72
+ if (customCall) {
73
+ return customCall(prompt, opts);
74
+ }
75
+ // Default: minimax chatCompletion
76
+ const result = await chatCompletion({ prompt, model: 'MiniMax-M3' });
77
+ if (!result.ok) {
78
+ throw new Error(result.message || 'llm call failed');
79
+ }
80
+ return {
81
+ content: result.content,
82
+ usage: {
83
+ inputTokens: result.usage?.prompt_tokens ?? 0,
84
+ outputTokens: result.usage?.completion_tokens ?? 0,
85
+ totalTokens: result.usage?.total_tokens ?? 0,
86
+ },
87
+ };
88
+ };
89
+
90
+ const runId = buildRunId();
91
+ const startedAt = new Date().toISOString();
92
+
93
+ // Load fixtures
94
+ let fixtures = loadFixtures(suitePath);
95
+ if (fixtureIds?.length) {
96
+ fixtures = fixtures.filter((f) => fixtureIds.includes(f.id));
97
+ }
98
+
99
+ if (fixtures.length === 0) {
100
+ res.status(400).json({ error: 'bad_request', message: 'no fixtures found in suite' });
101
+ return;
102
+ }
103
+
104
+ // Broadcast start
105
+ broadcast({ type: 'eval:run:start', runId, total: fixtures.length });
106
+
107
+ // Run the suite
108
+ const suiteResult = await runSuite(suitePath, {
109
+ llmCall,
110
+ concurrency,
111
+ timeoutMs: 120_000,
112
+ });
113
+
114
+ const finishedAt = new Date().toISOString();
115
+ const run = {
116
+ id: runId,
117
+ startedAt,
118
+ finishedAt,
119
+ suitePath,
120
+ total: suiteResult.total,
121
+ passed: suiteResult.passed,
122
+ failed: suiteResult.failed,
123
+ results: suiteResult.results,
124
+ };
125
+
126
+ // Persist
127
+ await saveRun(run);
128
+
129
+ // Broadcast complete
130
+ broadcast({ type: 'eval:run:complete', runId, passed: run.passed, failed: run.failed });
131
+
132
+ res.status(201).json(run);
133
+ }));
134
+
135
+ // GET /api/eval/runs/:id/compare/:otherId — diff two runs
136
+ router.get('/eval/runs/:id/compare/:otherId', wrap(async (req, res) => {
137
+ const { id, otherId } = req.params;
138
+ const diff = await compareRuns(id, otherId);
139
+ if (diff.error) {
140
+ res.status(404).json({ error: 'not_found', message: diff.error });
141
+ return;
142
+ }
143
+ res.json(diff);
144
+ }));
145
+
146
+ return router;
147
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * src/server/routes/ocr.mjs
3
+ *
4
+ * v5.0.0 — Screenshot OCR endpoints.
5
+ *
6
+ * Accepts image uploads, extracts text via Tesseract.js, and saves
7
+ * the result as a markdown note in the vault.
8
+ */
9
+
10
+ import { Router } from 'express';
11
+ import { join } from 'node:path';
12
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync } from 'node:fs';
13
+
14
+ let _wrap = null;
15
+ let _extractText = null;
16
+ async function getShared() {
17
+ if (!_wrap) {
18
+ const mod = await import('./_shared.mjs');
19
+ _wrap = mod.wrap;
20
+ }
21
+ return { wrap: _wrap };
22
+ }
23
+
24
+ async function getOcr() {
25
+ if (!_extractText) {
26
+ const mod = await import('../ocr.mjs');
27
+ _extractText = mod.extractText;
28
+ }
29
+ return { extractText: _extractText };
30
+ }
31
+
32
+ function getLogPath(projectRoot) {
33
+ return join(projectRoot, '.bizar', 'ocr-log.json');
34
+ }
35
+
36
+ function loadLog(projectRoot) {
37
+ try {
38
+ const p = getLogPath(projectRoot);
39
+ if (!existsSync(p)) return [];
40
+ return JSON.parse(readFileSync(p, 'utf8'));
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+
46
+ function saveLog(projectRoot, log) {
47
+ const p = getLogPath(projectRoot);
48
+ mkdirSync(join(projectRoot, '.bizar'), { recursive: true });
49
+ writeFileSync(p, JSON.stringify(log, null, 2) + '\n', 'utf8');
50
+ }
51
+
52
+ function appendLog(projectRoot, entry) {
53
+ const log = loadLog(projectRoot);
54
+ log.unshift(entry);
55
+ if (log.length > 100) log.length = 100;
56
+ saveLog(projectRoot, log);
57
+ }
58
+
59
+ export async function createOcrRouter({ projectRoot }) {
60
+ const router = Router();
61
+ const { wrap } = await getShared();
62
+
63
+ /**
64
+ * POST /api/ocr/process
65
+ *
66
+ * Accepts a multipart image upload, runs OCR, saves the result
67
+ * as a markdown note under ocr/<slug>.md.
68
+ *
69
+ * Expects: multipart/form-data with field "image"
70
+ * Returns: { ok, text, notePath }
71
+ */
72
+ router.post('/ocr/process', wrap(async (req, res) => {
73
+ let extractText;
74
+ try {
75
+ ({ extractText } = await getOcr());
76
+ } catch (err) {
77
+ res.status(503).json({
78
+ error: 'ocr_unavailable',
79
+ message: 'OCR engine is not available. Install tesseract.js: npm install tesseract.js',
80
+ });
81
+ return;
82
+ }
83
+
84
+ // Read image from body (binary or base64) or multipart
85
+ let imageBuffer = null;
86
+
87
+ if (Buffer.isBuffer(req.body)) {
88
+ imageBuffer = req.body;
89
+ } else if (req.body && req.body.image) {
90
+ // Base64-encoded image
91
+ const raw = req.body.image;
92
+ const matches = raw.match(/^data:image\/(png|jpeg|jpg|gif|webp);base64,(.+)$/);
93
+ if (matches) {
94
+ imageBuffer = Buffer.from(matches[2], 'base64');
95
+ } else {
96
+ // Assume raw base64 without prefix
97
+ imageBuffer = Buffer.from(raw, 'base64');
98
+ }
99
+ }
100
+
101
+ if (!imageBuffer || imageBuffer.length === 0) {
102
+ res.status(400).json({
103
+ error: 'bad_request',
104
+ message: 'No image data provided. Send multipart/form-data with "image" field, or JSON with base64 "image" field.',
105
+ });
106
+ return;
107
+ }
108
+
109
+ // Validate size (max 20MB)
110
+ if (imageBuffer.length > 20 * 1024 * 1024) {
111
+ res.status(413).json({ error: 'too_large', message: 'Image exceeds 20MB limit' });
112
+ return;
113
+ }
114
+
115
+ // Run OCR
116
+ let text;
117
+ try {
118
+ text = await extractText(imageBuffer);
119
+ } catch (err) {
120
+ res.status(500).json({
121
+ error: 'ocr_error',
122
+ message: `OCR processing failed: ${err instanceof Error ? err.message : String(err)}`,
123
+ });
124
+ return;
125
+ }
126
+
127
+ // Build note
128
+ const lang = req.body?.lang || 'eng';
129
+ const timestamp = Date.now();
130
+ const slug = `screenshot_ocr_${timestamp}`;
131
+ const relPath = `ocr/${slug}.md`;
132
+ const savedAt = new Date().toISOString();
133
+
134
+ const fm = {
135
+ title: `OCR Screenshot — ${savedAt}`,
136
+ savedAt,
137
+ type: 'ocr',
138
+ lang,
139
+ tags: ['ocr', 'screenshot'],
140
+ };
141
+
142
+ const noteBody = [
143
+ '## Extracted Text',
144
+ '',
145
+ '```text',
146
+ text || '(no text found)',
147
+ '```',
148
+ '',
149
+ ].join('\n');
150
+
151
+ const vaultDir = join(projectRoot, '.obsidian');
152
+ mkdirSync(join(vaultDir, 'ocr'), { recursive: true });
153
+ const notePath = join(vaultDir, relPath);
154
+
155
+ const noteContent = [
156
+ '---',
157
+ ...Object.entries(fm).map(([k, v]) => `${k}: ${JSON.stringify(v)}`),
158
+ '---',
159
+ '',
160
+ noteBody,
161
+ ].join('\n');
162
+
163
+ writeFileSync(notePath, noteContent, 'utf8');
164
+
165
+ // Log
166
+ appendLog(projectRoot, { id: slug, notePath: relPath, savedAt, textLength: text.length });
167
+
168
+ res.status(201).json({ ok: true, text, notePath: relPath, id: slug });
169
+ }));
170
+
171
+ /**
172
+ * GET /api/ocr/list
173
+ *
174
+ * Returns recent OCR entries from the log.
175
+ */
176
+ router.get('/ocr/list', wrap(async (_req, res) => {
177
+ const entries = loadLog(projectRoot);
178
+ res.json({ entries });
179
+ }));
180
+
181
+ return router;
182
+ }