dictawhisper 0.0.13 → 0.0.14

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 (60) hide show
  1. package/README.md +10 -2
  2. package/bin/dictawhisper.mjs +14 -0
  3. package/config.example.json +41 -0
  4. package/dist/ui/_app/immutable/assets/0.C6M3gHo0.css +1 -0
  5. package/dist/ui/_app/immutable/assets/2.C96vgj_4.css +1 -0
  6. package/dist/ui/_app/immutable/chunks/BSs9MmST.js +3 -0
  7. package/dist/ui/_app/immutable/chunks/Bjy-W4x2.js +81 -0
  8. package/dist/ui/_app/immutable/chunks/BslYtab-.js +1 -0
  9. package/dist/ui/_app/immutable/chunks/xihTtKlq.js +1 -0
  10. package/dist/ui/_app/immutable/entry/app.BJlRju4c.js +2 -0
  11. package/dist/ui/_app/immutable/entry/start.C5XBbXTT.js +1 -0
  12. package/dist/ui/_app/immutable/nodes/0.D23pkrHr.js +1 -0
  13. package/dist/ui/_app/immutable/nodes/1.C5T-U4uV.js +1 -0
  14. package/dist/ui/_app/immutable/nodes/2.59uAq_ko.js +5 -0
  15. package/dist/ui/_app/version.json +1 -0
  16. package/dist/ui/favicon.png +0 -0
  17. package/dist/ui/favicon.svg +3 -0
  18. package/dist/ui/index.html +38 -0
  19. package/dist/ui/logo.png +0 -0
  20. package/package.json +12 -3
  21. package/scripts/transcribe_faster_whisper.py +428 -0
  22. package/src/apiRoutes.ts +464 -0
  23. package/src/classes/Queue.ts +77 -0
  24. package/src/classes/Watcher.ts +117 -0
  25. package/src/cli.ts +132 -0
  26. package/src/config.ts +238 -0
  27. package/src/dev.ts +63 -0
  28. package/src/doctor.ts +12 -0
  29. package/src/lib/alignLib.ts +154 -0
  30. package/src/lib/audioLib.ts +202 -0
  31. package/src/lib/audioProbeLib.ts +112 -0
  32. package/src/lib/cleanupProvenanceLib.ts +80 -0
  33. package/src/lib/downloadLib.ts +8 -0
  34. package/src/lib/fileSettleLib.ts +213 -0
  35. package/src/lib/fsLib.ts +37 -0
  36. package/src/lib/healthLib.ts +377 -0
  37. package/src/lib/inboxUrl.ts +123 -0
  38. package/src/lib/journalEmbedLib.ts +127 -0
  39. package/src/lib/journalIndexLib.ts +923 -0
  40. package/src/lib/journalQueryLib.ts +269 -0
  41. package/src/lib/journalService.ts +287 -0
  42. package/src/lib/jsonLib.ts +71 -0
  43. package/src/lib/localberthGet.ts +14 -0
  44. package/src/lib/ollanetLib.ts +117 -0
  45. package/src/lib/ollanetReadyLib.ts +7 -0
  46. package/src/lib/organizationLib.ts +182 -0
  47. package/src/lib/pathAllowLib.ts +60 -0
  48. package/src/lib/queueLib.ts +30 -0
  49. package/src/lib/speechCleanupLib.ts +14 -0
  50. package/src/lib/tagConsolidateLib.ts +431 -0
  51. package/src/lib/tailscaleLib.ts +78 -0
  52. package/src/lib/transcriptionLib.ts +484 -0
  53. package/src/lib/whisperLib.ts +409 -0
  54. package/src/mcp.ts +151 -0
  55. package/src/prompts/cleanTranscription.ts +62 -0
  56. package/src/prompts/consolidateTags.ts +38 -0
  57. package/src/prompts/whisperInitial.ts +11 -0
  58. package/src/server.ts +220 -0
  59. package/src/socketEvents.ts +72 -0
  60. package/src/types/transcription.ts +56 -0
@@ -0,0 +1,269 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import Fuse from 'fuse.js';
4
+ import { isSkippedWatchPath } from './fileSettleLib.ts';
5
+
6
+ const PREVIEW_LIMIT = 200;
7
+ const FUSE_OPTS = {
8
+ keys: [
9
+ { name: 'tags', weight: 2 },
10
+ { name: 'searchBody', weight: 1.5 },
11
+ { name: 'basename', weight: 1 },
12
+ { name: 'searchRaw', weight: 0.6 },
13
+ ],
14
+ ignoreLocation: true,
15
+ threshold: 0.4,
16
+ includeScore: true,
17
+ };
18
+
19
+ export type JournalNote = {
20
+ jsonFile: string;
21
+ basename: string;
22
+ tags: string[];
23
+ preview: string;
24
+ searchBody: string;
25
+ searchRaw: string;
26
+ cleaned: string;
27
+ raw: string;
28
+ elapsed: string | null;
29
+ hasCleaned: boolean;
30
+ mtimeMs: number;
31
+ day: string;
32
+ cleanup: {
33
+ createdAt?: string;
34
+ model?: string;
35
+ host?: string;
36
+ promptVersion?: number;
37
+ } | null;
38
+ };
39
+
40
+ export type JournalHit = {
41
+ jsonFile: string;
42
+ basename: string;
43
+ day: string;
44
+ tags: string[];
45
+ preview: string;
46
+ score: number;
47
+ };
48
+
49
+ function previewOf(text: string): string {
50
+ const oneLine = text.replace(/\s+/g, ' ').trim();
51
+ if (oneLine.length <= PREVIEW_LIMIT) return oneLine;
52
+ return `${oneLine.slice(0, PREVIEW_LIMIT)}…`;
53
+ }
54
+
55
+ export function dayOf(jsonFile: string, basename: string, mtimeMs: number): string {
56
+ const named = basename.match(/^(\d{4}-\d{2}-\d{2})/);
57
+ if (named) return named[1];
58
+ const folded = jsonFile.replace(/\\/g, '/').match(/\/(\d{4})\/(\d{2})(?:\/|$)/);
59
+ if (folded) return `${folded[1]}-${folded[2]}-01`;
60
+ return new Date(mtimeMs).toISOString().slice(0, 10);
61
+ }
62
+
63
+ function isSidecarName(name: string): boolean {
64
+ return name.toLowerCase().endsWith('.json');
65
+ }
66
+
67
+ function looksLikeNote(json: unknown): json is Record<string, unknown> {
68
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return false;
69
+ const row = json as Record<string, unknown>;
70
+ return Boolean(row.text || row.cleanedTranscription || row.audioError || Array.isArray(row.segments));
71
+ }
72
+
73
+ export function loadJournalNotes(roots: string[]): JournalNote[] {
74
+ const notes: JournalNote[] = [];
75
+ const seen = new Set<string>();
76
+
77
+ const walk = (dir: string) => {
78
+ if (!fs.existsSync(dir)) return;
79
+ let entries: fs.Dirent[];
80
+ try {
81
+ entries = fs.readdirSync(dir, { withFileTypes: true });
82
+ } catch {
83
+ return;
84
+ }
85
+ for (const entry of entries) {
86
+ const full = path.resolve(path.join(dir, entry.name));
87
+ if (entry.isDirectory()) {
88
+ if (isSkippedWatchPath(full)) continue;
89
+ walk(full);
90
+ continue;
91
+ }
92
+ if (!isSidecarName(entry.name)) continue;
93
+ if (isSkippedWatchPath(full) || full.includes('_original') || full.includes('_clean')) continue;
94
+ if (seen.has(full)) continue;
95
+ try {
96
+ const json = JSON.parse(fs.readFileSync(full, 'utf8'));
97
+ if (!looksLikeNote(json)) continue;
98
+ const cleaned = String(json.cleanedTranscription || '').trim();
99
+ const raw = String(json.text || '').trim();
100
+ const source = cleaned || raw;
101
+ const tags = Array.isArray(json.tags)
102
+ ? json.tags.map((tag: unknown) => String(tag).trim()).filter(Boolean)
103
+ : [];
104
+ let mtimeMs = 0;
105
+ try {
106
+ mtimeMs = fs.statSync(full).mtimeMs;
107
+ } catch {
108
+ mtimeMs = 0;
109
+ }
110
+ const basename = path.basename(full);
111
+ const rawCleanup = json.cleanup;
112
+ const cleanupRow =
113
+ rawCleanup && typeof rawCleanup === 'object' && !Array.isArray(rawCleanup)
114
+ ? (rawCleanup as Record<string, unknown>)
115
+ : null;
116
+ const cleanup = cleanupRow
117
+ ? {
118
+ createdAt: typeof cleanupRow.createdAt === 'string' ? cleanupRow.createdAt : undefined,
119
+ model: typeof cleanupRow.model === 'string' ? cleanupRow.model : undefined,
120
+ host: typeof cleanupRow.host === 'string' ? cleanupRow.host : undefined,
121
+ promptVersion:
122
+ typeof cleanupRow.promptVersion === 'number' ? cleanupRow.promptVersion : undefined,
123
+ }
124
+ : null;
125
+ notes.push({
126
+ jsonFile: full,
127
+ basename,
128
+ tags,
129
+ preview: previewOf(source),
130
+ searchBody: source,
131
+ searchRaw: raw,
132
+ cleaned,
133
+ raw,
134
+ elapsed: json.elapsed != null ? String(json.elapsed) : null,
135
+ hasCleaned: Boolean(cleaned),
136
+ mtimeMs,
137
+ day: dayOf(full, basename, mtimeMs),
138
+ cleanup,
139
+ });
140
+ seen.add(full);
141
+ } catch {
142
+ // skip unreadable sidecars
143
+ }
144
+ }
145
+ };
146
+
147
+ for (const root of roots) walk(path.resolve(root));
148
+ return notes;
149
+ }
150
+
151
+ export function inDayRange(note: JournalNote, since?: string, until?: string): boolean {
152
+ if (since && note.day < since) return false;
153
+ if (until && note.day > until) return false;
154
+ return true;
155
+ }
156
+
157
+ function hasAllTags(note: JournalNote, tags: string[]): boolean {
158
+ if (!tags.length) return true;
159
+ const have = new Set(note.tags.map((tag) => tag.toLowerCase()));
160
+ return tags.every((tag) => have.has(tag.toLowerCase()));
161
+ }
162
+
163
+ function toHit(note: JournalNote, score = 0): JournalHit {
164
+ return {
165
+ jsonFile: note.jsonFile,
166
+ basename: note.basename,
167
+ day: note.day,
168
+ tags: note.tags,
169
+ preview: note.preview,
170
+ score,
171
+ };
172
+ }
173
+
174
+ export function searchJournal(
175
+ notes: JournalNote[],
176
+ options: { query?: string; tags?: string[]; since?: string; until?: string; limit?: number }
177
+ ): JournalHit[] {
178
+ const tags = (options.tags || []).map((tag) => String(tag).trim()).filter(Boolean);
179
+ const query = String(options.query || '').trim();
180
+ const limit = Math.min(50, Math.max(1, options.limit || 10));
181
+ const pool = notes.filter(
182
+ (note) => hasAllTags(note, tags) && inDayRange(note, options.since, options.until)
183
+ );
184
+ if (!query) {
185
+ return sortRecent(pool)
186
+ .slice(0, limit)
187
+ .map((note) => toHit(note));
188
+ }
189
+ const fuse = new Fuse(pool, FUSE_OPTS);
190
+ return fuse
191
+ .search(query)
192
+ .slice(0, limit)
193
+ .map((result) => toHit(result.item, result.score ?? 0));
194
+ }
195
+
196
+ export function sortRecent(notes: JournalNote[]): JournalNote[] {
197
+ return [...notes].sort((a, b) => {
198
+ if (a.day !== b.day) return b.day.localeCompare(a.day);
199
+ if (a.basename !== b.basename) return b.basename.localeCompare(a.basename);
200
+ return b.mtimeMs - a.mtimeMs;
201
+ });
202
+ }
203
+
204
+ export function recentJournal(
205
+ notes: JournalNote[],
206
+ options: { limit?: number; since?: string; until?: string; tags?: string[] } = {}
207
+ ): JournalHit[] {
208
+ const limit = Math.min(50, Math.max(1, options.limit || 20));
209
+ const tags = (options.tags || []).map((tag) => String(tag).trim()).filter(Boolean);
210
+ return sortRecent(
211
+ notes.filter((note) => hasAllTags(note, tags) && inDayRange(note, options.since, options.until))
212
+ )
213
+ .slice(0, limit)
214
+ .map((note) => toHit(note));
215
+ }
216
+
217
+ export function listJournalTags(
218
+ notes: JournalNote[],
219
+ options: { includeSingletons?: boolean; limit?: number } = {}
220
+ ): { tag: string; count: number }[] {
221
+ const counts = new Map<string, number>();
222
+ for (const note of notes) {
223
+ for (const tag of note.tags) {
224
+ counts.set(tag, (counts.get(tag) || 0) + 1);
225
+ }
226
+ }
227
+ const min = options.includeSingletons ? 1 : 2;
228
+ const limit = Math.min(500, Math.max(1, options.limit || 200));
229
+ return [...counts.entries()]
230
+ .filter(([, count]) => count >= min)
231
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
232
+ .slice(0, limit)
233
+ .map(([tag, count]) => ({ tag, count }));
234
+ }
235
+
236
+ export function findJournalNote(notes: JournalNote[], file: string): JournalNote | null {
237
+ const wanted = String(file || '').trim();
238
+ if (!wanted) return null;
239
+ const norm = wanted.replace(/\\/g, '/').toLowerCase();
240
+ const exact = notes.find((note) => note.jsonFile.replace(/\\/g, '/').toLowerCase() === norm);
241
+ if (exact) return exact;
242
+ const base = path.basename(wanted).toLowerCase();
243
+ const matches = notes.filter((note) => note.basename.toLowerCase() === base);
244
+ if (matches.length === 1) return matches[0];
245
+ return null;
246
+ }
247
+
248
+ export function getJournalNote(
249
+ notes: JournalNote[],
250
+ file: string,
251
+ options: { includeRaw?: boolean } = {}
252
+ ):
253
+ | { ok: true; note: Record<string, unknown> }
254
+ | { ok: false; error: string } {
255
+ const found = findJournalNote(notes, file);
256
+ if (!found) return { ok: false, error: 'note not found under configured watch roots' };
257
+ const note: Record<string, unknown> = {
258
+ jsonFile: found.jsonFile,
259
+ basename: found.basename,
260
+ day: found.day,
261
+ tags: found.tags,
262
+ elapsed: found.elapsed,
263
+ hasCleaned: found.hasCleaned,
264
+ text: found.cleaned || found.raw,
265
+ cleanup: found.cleanup,
266
+ };
267
+ if (options.includeRaw) note.raw = found.raw;
268
+ return { ok: true, note };
269
+ }
@@ -0,0 +1,287 @@
1
+ import path from 'node:path';
2
+ import { config } from '../config.ts';
3
+ import {
4
+ getJournalIndex,
5
+ JournalIndex,
6
+ openJournalIndex,
7
+ setJournalIndex,
8
+ type IndexListOptions,
9
+ type IndexSearchHit,
10
+ type SearchMode,
11
+ type SearchSort,
12
+ isFilenameQuery,
13
+ } from './journalIndexLib.ts';
14
+ import { createEmbedClient, type EmbedClient } from './journalEmbedLib.ts';
15
+
16
+ let embedClient: EmbedClient | null = null;
17
+ let embedChain: Promise<void> = Promise.resolve();
18
+ let rebuilding = false;
19
+
20
+ export function journalIndexPath(): string {
21
+ return config.journal.index;
22
+ }
23
+
24
+ export function journalReady(): boolean {
25
+ return Boolean(getJournalIndex());
26
+ }
27
+
28
+ export function journalRebuilding(): boolean {
29
+ return rebuilding;
30
+ }
31
+
32
+ export function initJournalIndex(): JournalIndex {
33
+ const existing = getJournalIndex();
34
+ if (existing) return existing;
35
+ const index = openJournalIndex(journalIndexPath());
36
+ setJournalIndex(index);
37
+ return index;
38
+ }
39
+
40
+ export function requireJournal(): JournalIndex {
41
+ return getJournalIndex() || initJournalIndex();
42
+ }
43
+
44
+ export { getJournalIndex };
45
+
46
+ export async function startJournalIndex(roots: string[]): Promise<void> {
47
+ const index = initJournalIndex();
48
+ const stats = index.stats();
49
+ if (stats.notes === 0) {
50
+ console.log('[journal-index] empty; rebuilding FTS from disk');
51
+ await rebuildJournalIndex(roots, { embed: false });
52
+ void ensureEmbeddings(index);
53
+ return;
54
+ }
55
+ console.log(
56
+ `[journal-index] ${stats.notes} notes (${stats.embedded} embedded) at ${stats.path}`,
57
+ );
58
+ void ensureEmbeddings(index);
59
+ }
60
+
61
+ export async function rebuildJournalIndex(
62
+ roots: string[],
63
+ options: { embed?: boolean } = {},
64
+ ): Promise<{ upserted: number; removed: number; embedded: number }> {
65
+ const index = requireJournal();
66
+ rebuilding = true;
67
+ try {
68
+ const result = index.rebuildFromRoots(roots);
69
+ console.log(`[journal-index] rebuilt upserted=${result.upserted} removed=${result.removed}`);
70
+ let embedded = 0;
71
+ if (options.embed !== false) {
72
+ embedded = await ensureEmbeddings(index);
73
+ }
74
+ return { ...result, embedded };
75
+ } finally {
76
+ rebuilding = false;
77
+ }
78
+ }
79
+
80
+ export function indexSidecar(jsonFile: string) {
81
+ try {
82
+ const index = getJournalIndex();
83
+ if (!index) return;
84
+ const result = index.upsertSidecar(jsonFile);
85
+ if (result?.changed && !result.skipEmbed) {
86
+ queueEmbed(index, result.rowid, jsonFile);
87
+ }
88
+ } catch (error) {
89
+ console.warn(`[journal-index] upsert failed: ${error instanceof Error ? error.message : error}`);
90
+ }
91
+ }
92
+
93
+ export function dropSidecar(jsonFile: string) {
94
+ try {
95
+ getJournalIndex()?.removeSidecar(jsonFile);
96
+ } catch (error) {
97
+ console.warn(`[journal-index] remove failed: ${error instanceof Error ? error.message : error}`);
98
+ }
99
+ }
100
+
101
+ function queueEmbed(index: JournalIndex, rowid: number, jsonFile: string) {
102
+ embedChain = embedChain
103
+ .then(async () => {
104
+ const client = await getEmbedClient();
105
+ if (!client) return;
106
+ const row = index.rowsNeedingEmbed().find((item) => item.rowid === rowid);
107
+ const text = row?.body;
108
+ if (!text) return;
109
+ await embedOne(index, client, rowid, text);
110
+ })
111
+ .catch((error) => {
112
+ console.warn(`[journal-index] embed ${jsonFile}: ${error instanceof Error ? error.message : error}`);
113
+ });
114
+ }
115
+
116
+ async function getEmbedClient(): Promise<EmbedClient | null> {
117
+ if (embedClient) return embedClient;
118
+ embedClient = await createEmbedClient();
119
+ if (embedClient) {
120
+ console.log(`[journal-index] ollama embed model ${embedClient.model} on ${embedClient.host}`);
121
+ }
122
+ return embedClient;
123
+ }
124
+
125
+ async function embedOne(index: JournalIndex, client: EmbedClient, rowid: number, text: string) {
126
+ const vector = await client.embed(text);
127
+ index.ensureVec(vector.length, client.model);
128
+ index.putEmbedding(rowid, vector);
129
+ }
130
+
131
+ export async function ensureEmbeddings(index: JournalIndex = requireJournal()): Promise<number> {
132
+ const client = await getEmbedClient();
133
+ if (!client) {
134
+ console.log('[journal-index] no ollama embedding model; FTS-only');
135
+ return 0;
136
+ }
137
+ const pending = index.rowsNeedingEmbed();
138
+ if (!pending.length) {
139
+ const stats = index.stats();
140
+ console.log(
141
+ `[journal-index] embeddings up to date (${stats.embedded} vectors, model ${stats.embedModel || 'none'})`,
142
+ );
143
+ return 0;
144
+ }
145
+ console.log(`[journal-index] embedding ${pending.length} notes with ${client.model}`);
146
+ let done = 0;
147
+ for (const row of pending) {
148
+ try {
149
+ await embedOneWithRetry(index, client, row.rowid, row.body, row.json_file);
150
+ done += 1;
151
+ if (done % 25 === 0) console.log(`[journal-index] embedded ${done}/${pending.length}`);
152
+ } catch (error) {
153
+ console.warn(
154
+ `[journal-index] embed failed ${row.json_file}: ${error instanceof Error ? error.message : error}`,
155
+ );
156
+ if (!isSqliteBusy(error)) break;
157
+ }
158
+ }
159
+ return done;
160
+ }
161
+
162
+ function isSqliteBusy(error: unknown): boolean {
163
+ const message = error instanceof Error ? error.message : String(error);
164
+ return /database is locked|SQLITE_BUSY/i.test(message);
165
+ }
166
+
167
+ async function embedOneWithRetry(
168
+ index: JournalIndex,
169
+ client: EmbedClient,
170
+ rowid: number,
171
+ text: string,
172
+ jsonFile: string,
173
+ ) {
174
+ let lastError: unknown;
175
+ for (let attempt = 1; attempt <= 6; attempt += 1) {
176
+ try {
177
+ await embedOne(index, client, rowid, text);
178
+ return;
179
+ } catch (error) {
180
+ lastError = error;
181
+ if (!isSqliteBusy(error) || attempt === 6) throw error;
182
+ const waitMs = 200 * attempt;
183
+ console.warn(
184
+ `[journal-index] sqlite busy on ${path.basename(jsonFile)}; retry ${attempt}/6 in ${waitMs}ms`,
185
+ );
186
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
187
+ }
188
+ }
189
+ throw lastError;
190
+ }
191
+
192
+ export async function searchJournalIndex(options: {
193
+ query?: string;
194
+ tags?: string[];
195
+ since?: string;
196
+ until?: string;
197
+ year?: string;
198
+ month?: string;
199
+ mode?: SearchMode;
200
+ sort?: SearchSort;
201
+ limit?: number;
202
+ unreadable?: boolean;
203
+ starred?: boolean;
204
+ }): Promise<IndexSearchHit[]> {
205
+ const index = requireJournal();
206
+ const query = String(options.query || '').trim();
207
+ const requested =
208
+ isFilenameQuery(query) ? 'lex' : options.mode || config.journal.search;
209
+ let queryEmbedding: number[] | null = null;
210
+ if (requested !== 'lex' && query) {
211
+ try {
212
+ const client = await getEmbedClient();
213
+ if (client) queryEmbedding = await client.embed(String(options.query));
214
+ } catch (error) {
215
+ console.warn(`[journal-index] query embed failed: ${error instanceof Error ? error.message : error}`);
216
+ }
217
+ }
218
+ return index.search({
219
+ ...options,
220
+ mode: requested,
221
+ queryEmbedding,
222
+ synonyms: config.whisper.promptTerms,
223
+ });
224
+ }
225
+
226
+ export function notesIndexReload() {
227
+ return { paged: true, reload: true as const, indexing: rebuilding };
228
+ }
229
+
230
+ export function listInboxNotes(options: IndexListOptions = {}) {
231
+ return requireJournal().list(options).map(toInboxSummary);
232
+ }
233
+
234
+ export function notesIndexPayload(fallback: () => unknown[]) {
235
+ const index = getJournalIndex();
236
+ if (index && index.stats().notes > 0) {
237
+ return { notes: index.list().map(toInboxSummary), paged: true, indexing: rebuilding };
238
+ }
239
+ return { notes: fallback(), paged: false, indexing: rebuilding };
240
+ }
241
+
242
+ export function journalYears() {
243
+ return requireJournal().years();
244
+ }
245
+
246
+ export function journalTags(options: { includeSingletons?: boolean; limit?: number } = {}) {
247
+ return requireJournal().tags(options);
248
+ }
249
+
250
+ export function journalStats() {
251
+ const index = requireJournal();
252
+ return { ...index.stats(), indexing: rebuilding, vec: index.hasVec() };
253
+ }
254
+
255
+ export function recentJournalIndex(options: {
256
+ limit?: number;
257
+ tags?: string[];
258
+ since?: string;
259
+ until?: string;
260
+ } = {}) {
261
+ return requireJournal().recent(options);
262
+ }
263
+
264
+ export function toInboxSummary(hit: {
265
+ jsonFile: string;
266
+ basename: string;
267
+ day?: string;
268
+ tags: string[];
269
+ preview: string;
270
+ hasCleaned: boolean;
271
+ audioError: string | null;
272
+ starred?: boolean;
273
+ }) {
274
+ return {
275
+ jsonFile: hit.jsonFile,
276
+ basename: hit.basename,
277
+ day: hit.day || '',
278
+ transcriptionJson: {
279
+ tags: hit.tags,
280
+ preview: hit.preview,
281
+ hasCleaned: hit.hasCleaned,
282
+ audioError: hit.audioError,
283
+ starred: Boolean(hit.starred),
284
+ _partial: true,
285
+ },
286
+ };
287
+ }
@@ -0,0 +1,71 @@
1
+ export async function parseJSON(rawJSON: unknown = '') {
2
+ if (rawJSON && typeof rawJSON === 'object') {
3
+ return rawJSON as Record<string, any>;
4
+ }
5
+
6
+ const rawJson = String(rawJSON ?? '')
7
+ .replaceAll(`\n",`, `",`)
8
+ .replace(/^\s*```[\w]*\s*\n?([\s\S]*?)\s*```/gm, '$1')
9
+ .trim();
10
+
11
+ try {
12
+ return JSON.parse(rawJson);
13
+ } catch (error: any) {
14
+ const firstObject = extractFirstJsonObject(rawJson);
15
+ if (firstObject && firstObject !== rawJson) {
16
+ try {
17
+ const parsed = JSON.parse(firstObject);
18
+ console.warn('[parse-json] ignored trailing content after the first JSON object');
19
+ return parsed;
20
+ } catch {
21
+ // fall through
22
+ }
23
+ }
24
+
25
+ if (String(error.message).includes(`"Expected ',' or '}`) || String(error.message).includes('Unterminated')) {
26
+ const lastBraceIndex = rawJson.lastIndexOf('}');
27
+ if (lastBraceIndex !== -1) {
28
+ return JSON.parse(rawJson.substring(0, lastBraceIndex + 1));
29
+ }
30
+ }
31
+
32
+ throw new Error(`Error parsing JSON response: ${error.message}`);
33
+ }
34
+ }
35
+
36
+ /** First balanced `{...}`, ignoring braces inside strings. */
37
+ function extractFirstJsonObject(text: string): string | null {
38
+ const start = text.indexOf('{');
39
+ if (start === -1) return null;
40
+
41
+ let depth = 0;
42
+ let inString = false;
43
+ let escape = false;
44
+
45
+ for (let i = start; i < text.length; i += 1) {
46
+ const char = text[i];
47
+ if (inString) {
48
+ if (escape) {
49
+ escape = false;
50
+ continue;
51
+ }
52
+ if (char === '\\') {
53
+ escape = true;
54
+ continue;
55
+ }
56
+ if (char === '"') inString = false;
57
+ continue;
58
+ }
59
+ if (char === '"') {
60
+ inString = true;
61
+ continue;
62
+ }
63
+ if (char === '{') depth += 1;
64
+ else if (char === '}') {
65
+ depth -= 1;
66
+ if (depth === 0) return text.slice(start, i + 1);
67
+ }
68
+ }
69
+
70
+ return null;
71
+ }
@@ -0,0 +1,14 @@
1
+ import { spawnSync } from 'child_process';
2
+
3
+ /** `localberth get <name>` — undefined if the CLI or lease is missing. */
4
+ export function localberthGet(name: string): number | undefined {
5
+ const result = spawnSync('localberth', ['get', name], {
6
+ encoding: 'utf8',
7
+ timeout: 5000,
8
+ windowsHide: true,
9
+ shell: process.platform === 'win32',
10
+ });
11
+ if (result.status !== 0) return undefined;
12
+ const n = Number((result.stdout || '').trim());
13
+ return Number.isInteger(n) && n > 0 && n <= 65535 ? n : undefined;
14
+ }