@ailoud/providers 1.0.0-dev.1

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 (43) hide show
  1. package/LICENSE +224 -0
  2. package/dist/.tsbuildinfo +1 -0
  3. package/dist/audio/ffmpeg.d.ts +12 -0
  4. package/dist/audio/ffmpeg.js +81 -0
  5. package/dist/diarize/sherpaDiarizer.d.ts +26 -0
  6. package/dist/diarize/sherpaDiarizer.js +64 -0
  7. package/dist/index.d.ts +33 -0
  8. package/dist/index.js +19 -0
  9. package/dist/llm/anthropic.d.ts +36 -0
  10. package/dist/llm/anthropic.js +92 -0
  11. package/dist/llm/claudeCli.d.ts +32 -0
  12. package/dist/llm/claudeCli.js +63 -0
  13. package/dist/llm/llamaCpp.d.ts +38 -0
  14. package/dist/llm/llamaCpp.js +90 -0
  15. package/dist/llm/models.d.ts +23 -0
  16. package/dist/llm/models.js +97 -0
  17. package/dist/llm/openAiCompatible.d.ts +35 -0
  18. package/dist/llm/openAiCompatible.js +84 -0
  19. package/dist/process/pager.d.ts +36 -0
  20. package/dist/process/pager.js +69 -0
  21. package/dist/process/run.d.ts +35 -0
  22. package/dist/process/run.js +101 -0
  23. package/dist/provision/download.d.ts +19 -0
  24. package/dist/provision/download.js +80 -0
  25. package/dist/provision/llamaInstall.d.ts +38 -0
  26. package/dist/provision/llamaInstall.js +75 -0
  27. package/dist/provision/packageManager.d.ts +50 -0
  28. package/dist/provision/packageManager.js +66 -0
  29. package/dist/provision/sherpaInstall.d.ts +34 -0
  30. package/dist/provision/sherpaInstall.js +78 -0
  31. package/dist/provision/whisperInstall.d.ts +61 -0
  32. package/dist/provision/whisperInstall.js +89 -0
  33. package/dist/store/sqliteStore.d.ts +64 -0
  34. package/dist/store/sqliteStore.js +433 -0
  35. package/dist/stt/whisperCpp.d.ts +50 -0
  36. package/dist/stt/whisperCpp.js +116 -0
  37. package/dist/system/nodeFs.d.ts +14 -0
  38. package/dist/system/nodeFs.js +80 -0
  39. package/dist/system/systemClock.d.ts +20 -0
  40. package/dist/system/systemClock.js +55 -0
  41. package/dist/vad/whisperVad.d.ts +14 -0
  42. package/dist/vad/whisperVad.js +58 -0
  43. package/package.json +48 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The whisper.cpp release ailoud installs. Pinned, never "latest": two people
3
+ * running the same `ailoud setup` a week apart must get the same binaries, and
4
+ * an upstream change must not be able to break installation for everyone at
5
+ * once. Bumping this is a reviewable commit.
6
+ */
7
+ export declare const WHISPER_TAG = "b4938";
8
+ /**
9
+ * The prebuilt tarball for this platform and CPU.
10
+ *
11
+ * Linux only. macOS installs through brew, which has a whisper-cpp formula;
12
+ * Linux has no apt package, so ailoud uses the project's own release assets.
13
+ * Both x64 and arm64 Ubuntu builds are published.
14
+ */
15
+ export declare function whisperTarballUrl(platform: NodeJS.Platform, arch: string): string;
16
+ export interface InstallWhisperOptions {
17
+ readonly platform: NodeJS.Platform;
18
+ readonly arch: string;
19
+ readonly dataDir: string;
20
+ /**
21
+ * Whether a real terminal is attached. The macOS route shells out to brew
22
+ * through `runInteractive`, which has no timeout by design (a password or
23
+ * a "install the Xcode command line tools?" prompt must be allowed to
24
+ * wait), so with nothing on stdin it would wait forever. False means
25
+ * report the command instead of running it -- refusing beats hanging a CI
26
+ * job until it times out.
27
+ */
28
+ readonly interactive: boolean;
29
+ readonly onProgress?: (received: number, total: number | null) => void;
30
+ }
31
+ export interface WhisperPaths {
32
+ readonly binary: string;
33
+ readonly vadBinary: string;
34
+ }
35
+ /**
36
+ * `paths` is null when whisper.cpp landed on PATH and ailoud therefore records
37
+ * nothing (the macOS/brew route). `skipped` carries the exact commands a
38
+ * human has to run, for the non-interactive case where ailoud refuses to spawn
39
+ * something that could block on a prompt.
40
+ */
41
+ export type InstallWhisperResult = {
42
+ readonly kind: 'installed';
43
+ readonly paths: WhisperPaths | null;
44
+ } | {
45
+ readonly kind: 'skipped';
46
+ readonly commands: readonly string[];
47
+ };
48
+ /**
49
+ * Installs whisper.cpp and reports where its binaries ended up.
50
+ *
51
+ * Reports `paths: null` on macOS: brew puts `whisper-cli` on PATH, and ailoud's
52
+ * existing config defaults already resolve it. Writing an absolute Cellar
53
+ * path into the config there would break on the next `brew upgrade`.
54
+ *
55
+ * On Linux it returns absolute paths, because the extracted tree lives
56
+ * somewhere only ailoud knows. The tree is kept intact: the binaries embed
57
+ * `$ORIGIN` and load `libwhisper.so` and `libggml*.so` from their own
58
+ * directory, so moving or symlinking a single binary out of it would break
59
+ * the loader.
60
+ */
61
+ export declare function installWhisper(options: InstallWhisperOptions): Promise<InstallWhisperResult>;
@@ -0,0 +1,89 @@
1
+ import { mkdir, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { FailureError } from '@ailoud/core';
4
+ import { run, runInteractive } from '../process/run.js';
5
+ import { formatInstallCommand, whisperInstallCommands } from './packageManager.js';
6
+ import { downloadFile } from './download.js';
7
+ /**
8
+ * The whisper.cpp release ailoud installs. Pinned, never "latest": two people
9
+ * running the same `ailoud setup` a week apart must get the same binaries, and
10
+ * an upstream change must not be able to break installation for everyone at
11
+ * once. Bumping this is a reviewable commit.
12
+ */
13
+ export const WHISPER_TAG = 'b4938';
14
+ const RELEASES = 'https://github.com/ggml-org/whisper.cpp/releases/download';
15
+ /**
16
+ * The prebuilt tarball for this platform and CPU.
17
+ *
18
+ * Linux only. macOS installs through brew, which has a whisper-cpp formula;
19
+ * Linux has no apt package, so ailoud uses the project's own release assets.
20
+ * Both x64 and arm64 Ubuntu builds are published.
21
+ */
22
+ export function whisperTarballUrl(platform, arch) {
23
+ if (platform !== 'linux') {
24
+ throw new FailureError(`no prebuilt whisper.cpp tarball is published for ${platform}`);
25
+ }
26
+ if (arch !== 'x64' && arch !== 'arm64') {
27
+ // Name the way out, not just the obstacle: only x64 and arm64 Linux
28
+ // builds are published, so a user on anything else has no automated
29
+ // route at all and needs to know that building it themselves is the
30
+ // supported answer rather than a workaround.
31
+ throw new FailureError(`no prebuilt whisper.cpp tarball is published for ${arch}; build whisper.cpp from ` +
32
+ 'source and set "stt.whisperCpp.binary" and "stt.whisperCpp.vadBinary" to the ' +
33
+ 'resulting binaries');
34
+ }
35
+ return `${RELEASES}/${WHISPER_TAG}/whisper-bin-ubuntu-${arch}.tar.gz`;
36
+ }
37
+ /**
38
+ * Installs whisper.cpp and reports where its binaries ended up.
39
+ *
40
+ * Reports `paths: null` on macOS: brew puts `whisper-cli` on PATH, and ailoud's
41
+ * existing config defaults already resolve it. Writing an absolute Cellar
42
+ * path into the config there would break on the next `brew upgrade`.
43
+ *
44
+ * On Linux it returns absolute paths, because the extracted tree lives
45
+ * somewhere only ailoud knows. The tree is kept intact: the binaries embed
46
+ * `$ORIGIN` and load `libwhisper.so` and `libggml*.so` from their own
47
+ * directory, so moving or symlinking a single binary out of it would break
48
+ * the loader.
49
+ */
50
+ export async function installWhisper(options) {
51
+ const { platform, arch, dataDir } = options;
52
+ if (platform === 'darwin') {
53
+ const commands = whisperInstallCommands('brew');
54
+ if (!options.interactive) {
55
+ return { kind: 'skipped', commands: commands.map(formatInstallCommand) };
56
+ }
57
+ for (const command of commands) {
58
+ const code = await runInteractive(command.command, command.args);
59
+ if (code !== 0) {
60
+ throw new FailureError(`"${formatInstallCommand(command)}" exited with code ${code}`);
61
+ }
62
+ }
63
+ return { kind: 'installed', paths: null };
64
+ }
65
+ if (platform !== 'linux') {
66
+ throw new FailureError(`ailoud cannot install whisper.cpp on ${platform} automatically; see README.md for the ` +
67
+ 'manual steps');
68
+ }
69
+ const url = whisperTarballUrl(platform, arch);
70
+ const root = join(dataDir, 'whisper', WHISPER_TAG);
71
+ const archive = join(dataDir, 'whisper', `whisper-${WHISPER_TAG}-${arch}.tar.gz`);
72
+ await mkdir(root, { recursive: true });
73
+ await downloadFile(url, archive, { onProgress: options.onProgress });
74
+ // --strip-components=1 drops the "whisper-bin-ubuntu-<arch>/" wrapper so the
75
+ // binaries and their shared libraries land directly in `root`, side by side,
76
+ // which is what $ORIGIN resolution needs.
77
+ const extract = await run('tar', ['-xzf', archive, '-C', root, '--strip-components=1']);
78
+ if (extract.code !== 0) {
79
+ throw new FailureError(`extracting ${archive} failed: ${extract.stderr.trim()}`);
80
+ }
81
+ await rm(archive, { force: true });
82
+ return {
83
+ kind: 'installed',
84
+ paths: {
85
+ binary: join(root, 'whisper-cli'),
86
+ vadBinary: join(root, 'whisper-vad-speech-segments'),
87
+ },
88
+ };
89
+ }
@@ -0,0 +1,64 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import type { ManagedRecordingStore, Recording, RecordingListFilter, Segment, SpeakerName, SegmentHit, SegmentSearchFilter, Summary, Transcript } from '@ailoud/core';
3
+ export declare class SqliteStore implements ManagedRecordingStore {
4
+ private readonly db;
5
+ constructor(db: DatabaseSync);
6
+ static open(path: string): SqliteStore;
7
+ private migrate;
8
+ schemaVersion(): number;
9
+ integrityCheck(): string;
10
+ close(): void;
11
+ insertRecording(r: Recording): Promise<void>;
12
+ getRecording(id: string): Promise<Recording | null>;
13
+ findRecordingBySha(sha256: string): Promise<Recording | null>;
14
+ listRecordings(filter: RecordingListFilter): Promise<Recording[]>;
15
+ insertTranscript(t: Transcript, segments: readonly Segment[]): Promise<void>;
16
+ latestTranscript(recordingId: string): Promise<Transcript | null>;
17
+ getTranscript(id: string): Promise<Transcript | null>;
18
+ listSegments(transcriptId: string): Promise<Segment[]>;
19
+ findRecordingsByIdPrefix(prefix: string): Promise<Recording[]>;
20
+ findTranscriptsByIdPrefix(prefix: string): Promise<Transcript[]>;
21
+ addTags(recordingId: string, tags: readonly string[]): Promise<void>;
22
+ listTags(recordingId: string): Promise<string[]>;
23
+ listAllTags(): Promise<{
24
+ tag: string;
25
+ count: number;
26
+ }[]>;
27
+ setSpeakerName(recordingId: string, label: string, name: string): Promise<void>;
28
+ /**
29
+ * Full-text search over segments.
30
+ *
31
+ * `bm25` rather than raw `rank` in the order-by so the sort is explicit
32
+ * about what it means, and the newest recording wins ties -- of two equally
33
+ * relevant hits, the recent one is almost always the one being looked for.
34
+ *
35
+ * The newest-transcript restriction is a correlated subquery rather than a
36
+ * join on a grouped query: a recording re-transcribed with --force has
37
+ * several transcripts holding the same words, and without this the same
38
+ * sentence comes back two or three times, reading as several occurrences.
39
+ */
40
+ searchSegments(match: string, filter: SegmentSearchFilter): Promise<SegmentHit[]>;
41
+ insertSummary(summary: Summary): Promise<void>;
42
+ /**
43
+ * The newest summary covering this recording ALONE.
44
+ *
45
+ * The `NOT EXISTS` is the whole point: a group summary of ten meetings also
46
+ * has a row here for each of them, and handing it back as "the summary of
47
+ * the third meeting" would answer a question about one recording with nine
48
+ * others mixed in.
49
+ */
50
+ latestSummaryOf(recordingId: string): Promise<Summary | null>;
51
+ listSummaries(recordingId: string): Promise<Summary[]>;
52
+ listAllSummaries(): Promise<Summary[]>;
53
+ findSummariesByIdPrefix(prefix: string): Promise<Summary[]>;
54
+ deleteSummary(id: string): Promise<boolean>;
55
+ private toSummary;
56
+ listSpeakerNames(recordingId: string): Promise<SpeakerName[]>;
57
+ annotateRecording(id: string, fields: {
58
+ readonly title?: string;
59
+ readonly notes?: string;
60
+ }): Promise<void>;
61
+ deleteRecording(id: string): Promise<boolean>;
62
+ languagesByTranscript(transcriptIds: readonly string[]): Promise<Map<string, readonly string[]>>;
63
+ }
64
+ export declare const openStore: (path: string) => SqliteStore;
@@ -0,0 +1,433 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { orderLanguages, pendingMigrations } from '@ailoud/core';
3
+ /**
4
+ * Whether a prefix is safe to put in front of a LIKE.
5
+ *
6
+ * Checked HERE rather than trusted from the caller, which is the difference
7
+ * between an invariant and a hope. The CLI does validate -- `resolveId.ts`
8
+ * enforces the ULID alphabet -- but the MCP resource handler passes a raw URI
9
+ * variable straight through, so `ailoud://report/_Z` reached this line with a
10
+ * LIKE wildcard in it and returned a report's body for a URI that identifies
11
+ * no report. An empty prefix would have matched the whole table.
12
+ *
13
+ * Ids are Crockford base32, which contains neither % nor _, so requiring that
14
+ * alphabet also removes every wildcard by construction.
15
+ */
16
+ function usableIdPrefix(prefix) {
17
+ return prefix !== '' && /^[0-9A-Za-z]+$/.test(prefix);
18
+ }
19
+ const toRecording = (row) => ({
20
+ id: row.id,
21
+ sha256: row.sha256,
22
+ sourcePath: row.source_path,
23
+ mediaPath: row.media_path,
24
+ durationMs: row.duration_ms,
25
+ mime: row.mime,
26
+ title: row.title,
27
+ notes: row.notes,
28
+ recordedAt: row.recorded_at,
29
+ importedAt: row.imported_at,
30
+ });
31
+ const toTranscript = (row) => ({
32
+ id: row.id,
33
+ recordingId: row.recording_id,
34
+ provider: row.provider,
35
+ model: row.model,
36
+ language: row.language,
37
+ text: row.text,
38
+ createdAt: row.created_at,
39
+ });
40
+ const toSegment = (row) => ({
41
+ id: row.id,
42
+ transcriptId: row.transcript_id,
43
+ idx: row.idx,
44
+ startMs: row.start_ms,
45
+ endMs: row.end_ms,
46
+ text: row.text,
47
+ speaker: row.speaker,
48
+ language: row.language,
49
+ });
50
+ export class SqliteStore {
51
+ db;
52
+ constructor(db) {
53
+ this.db = db;
54
+ }
55
+ static open(path) {
56
+ const db = new DatabaseSync(path);
57
+ db.exec('PRAGMA journal_mode = WAL');
58
+ db.exec('PRAGMA foreign_keys = ON');
59
+ const store = new SqliteStore(db);
60
+ store.migrate();
61
+ return store;
62
+ }
63
+ migrate() {
64
+ const row = this.db.prepare('PRAGMA user_version').get();
65
+ const pending = pendingMigrations(row.user_version);
66
+ if (pending.length === 0)
67
+ return;
68
+ this.db.exec('BEGIN');
69
+ try {
70
+ for (const migration of pending) {
71
+ for (const statement of migration.statements)
72
+ this.db.exec(statement);
73
+ // PRAGMA does not accept a bound parameter, and the value is an integer
74
+ // from our own migration list, never user input.
75
+ this.db.exec(`PRAGMA user_version = ${migration.version}`);
76
+ }
77
+ this.db.exec('COMMIT');
78
+ }
79
+ catch (error) {
80
+ this.db.exec('ROLLBACK');
81
+ throw error;
82
+ }
83
+ }
84
+ schemaVersion() {
85
+ return this.db.prepare('PRAGMA user_version').get().user_version;
86
+ }
87
+ integrityCheck() {
88
+ return this.db.prepare('PRAGMA integrity_check').get()
89
+ .integrity_check;
90
+ }
91
+ close() {
92
+ this.db.close();
93
+ }
94
+ async insertRecording(r) {
95
+ this.db
96
+ .prepare(`INSERT INTO recording
97
+ (id, sha256, source_path, media_path, duration_ms, mime, title, notes, recorded_at,
98
+ imported_at)
99
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
100
+ .run(r.id, r.sha256, r.sourcePath, r.mediaPath, r.durationMs, r.mime, r.title, r.notes, r.recordedAt, r.importedAt);
101
+ }
102
+ async getRecording(id) {
103
+ const row = this.db.prepare('SELECT * FROM recording WHERE id = ?').get(id);
104
+ return row ? toRecording(row) : null;
105
+ }
106
+ async findRecordingBySha(sha256) {
107
+ const row = this.db.prepare('SELECT * FROM recording WHERE sha256 = ?').get(sha256);
108
+ return row ? toRecording(row) : null;
109
+ }
110
+ async listRecordings(filter) {
111
+ const where = [];
112
+ const params = [];
113
+ if (filter.ids && filter.ids.length > 0) {
114
+ where.push(`id IN (${filter.ids.map(() => '?').join(', ')})`);
115
+ params.push(...filter.ids);
116
+ }
117
+ if (filter.withoutTranscript === true) {
118
+ where.push('NOT EXISTS (SELECT 1 FROM transcript t WHERE t.recording_id = recording.id)');
119
+ }
120
+ if (filter.tags && filter.tags.length > 0) {
121
+ // One EXISTS per tag, so several tags mean "carries all of them"
122
+ // rather than "carries any". Narrowing is what a second tag is for; a
123
+ // filter that widened as you added terms would be a surprise.
124
+ for (const tag of filter.tags) {
125
+ where.push('EXISTS (SELECT 1 FROM tag g WHERE g.recording_id = recording.id AND g.tag = ?)');
126
+ params.push(tag);
127
+ }
128
+ }
129
+ const clause = where.length > 0 ? ` WHERE ${where.join(' AND ')}` : '';
130
+ // node:sqlite types .all() as Record<string, SQLOutputValue>[], an index
131
+ // signature type the compiler will not cast directly to our named-field
132
+ // row type as `X[]` (TS2352). The single-row `.get()` casts below compile
133
+ // only because their target type is `X | undefined`, not because arrays
134
+ // are treated differently in general. Routing through `unknown` is the
135
+ // standard, non-`any` escape for that specific TypeScript limitation.
136
+ const rows = this.db
137
+ .prepare(`SELECT * FROM recording${clause} ORDER BY imported_at, id`)
138
+ .all(...params);
139
+ return rows.map(toRecording);
140
+ }
141
+ async insertTranscript(t, segments) {
142
+ this.db.exec('BEGIN');
143
+ try {
144
+ this.db
145
+ .prepare(`INSERT INTO transcript
146
+ (id, recording_id, provider, model, language, text, created_at)
147
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
148
+ .run(t.id, t.recordingId, t.provider, t.model, t.language, t.text, t.createdAt);
149
+ const insertSegment = this.db.prepare(`INSERT INTO segment
150
+ (id, transcript_id, idx, start_ms, end_ms, text, speaker, language)
151
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
152
+ for (const s of segments) {
153
+ insertSegment.run(s.id, s.transcriptId, s.idx, s.startMs, s.endMs, s.text, s.speaker, s.language);
154
+ }
155
+ this.db.exec('COMMIT');
156
+ }
157
+ catch (error) {
158
+ this.db.exec('ROLLBACK');
159
+ throw error;
160
+ }
161
+ }
162
+ async latestTranscript(recordingId) {
163
+ const row = this.db
164
+ .prepare(`SELECT * FROM transcript WHERE recording_id = ?
165
+ ORDER BY created_at DESC, id DESC LIMIT 1`)
166
+ .get(recordingId);
167
+ return row ? toTranscript(row) : null;
168
+ }
169
+ async getTranscript(id) {
170
+ const row = this.db.prepare('SELECT * FROM transcript WHERE id = ?').get(id);
171
+ return row ? toTranscript(row) : null;
172
+ }
173
+ async listSegments(transcriptId) {
174
+ // See the comment in listRecordings: .all() returns an index signature
175
+ // type, which needs the `unknown` step before it can become our
176
+ // named-field row type.
177
+ const rows = this.db
178
+ .prepare('SELECT * FROM segment WHERE transcript_id = ? ORDER BY idx')
179
+ .all(transcriptId);
180
+ return rows.map(toSegment);
181
+ }
182
+ async findRecordingsByIdPrefix(prefix) {
183
+ if (!usableIdPrefix(prefix))
184
+ return [];
185
+ const rows = this.db
186
+ .prepare(`SELECT * FROM recording WHERE id LIKE ? || '%' ORDER BY id`)
187
+ .all(prefix);
188
+ return rows.map(toRecording);
189
+ }
190
+ async findTranscriptsByIdPrefix(prefix) {
191
+ if (!usableIdPrefix(prefix))
192
+ return [];
193
+ const rows = this.db
194
+ .prepare(`SELECT * FROM transcript WHERE id LIKE ? || '%' ORDER BY id`)
195
+ .all(prefix);
196
+ return rows.map(toTranscript);
197
+ }
198
+ async addTags(recordingId, tags) {
199
+ // OR IGNORE: re-tagging is how a user makes sure a tag is there, not an
200
+ // error to report at them.
201
+ const insert = this.db.prepare('INSERT OR IGNORE INTO tag (recording_id, tag) VALUES (?, ?)');
202
+ for (const tag of tags)
203
+ insert.run(recordingId, tag);
204
+ }
205
+ async listTags(recordingId) {
206
+ const rows = this.db
207
+ .prepare('SELECT tag FROM tag WHERE recording_id = ? ORDER BY tag')
208
+ .all(recordingId);
209
+ return rows.map((row) => row.tag);
210
+ }
211
+ async listAllTags() {
212
+ const rows = this.db
213
+ .prepare('SELECT tag, count(*) AS n FROM tag GROUP BY tag ORDER BY n DESC, tag')
214
+ .all();
215
+ return rows.map((row) => ({ tag: row.tag, count: Number(row.n) }));
216
+ }
217
+ async setSpeakerName(recordingId, label, name) {
218
+ // Upsert: naming the same speaker twice is a correction, not an error.
219
+ this.db
220
+ .prepare(`INSERT INTO speaker (recording_id, label, name) VALUES (?, ?, ?)
221
+ ON CONFLICT (recording_id, label) DO UPDATE SET name = excluded.name`)
222
+ .run(recordingId, label, name);
223
+ }
224
+ /**
225
+ * Full-text search over segments.
226
+ *
227
+ * `bm25` rather than raw `rank` in the order-by so the sort is explicit
228
+ * about what it means, and the newest recording wins ties -- of two equally
229
+ * relevant hits, the recent one is almost always the one being looked for.
230
+ *
231
+ * The newest-transcript restriction is a correlated subquery rather than a
232
+ * join on a grouped query: a recording re-transcribed with --force has
233
+ * several transcripts holding the same words, and without this the same
234
+ * sentence comes back two or three times, reading as several occurrences.
235
+ */
236
+ async searchSegments(match, filter) {
237
+ const where = ['segment_fts MATCH ?'];
238
+ const params = [match];
239
+ if (filter.allTranscripts !== true) {
240
+ where.push(`t.id = (
241
+ SELECT inner_t.id FROM transcript inner_t
242
+ WHERE inner_t.recording_id = r.id
243
+ ORDER BY inner_t.created_at DESC, inner_t.id DESC LIMIT 1
244
+ )`);
245
+ }
246
+ if (filter.language !== undefined) {
247
+ where.push('s.language = ?');
248
+ params.push(filter.language);
249
+ }
250
+ const ids = filter.recordingIds ?? [];
251
+ if (ids.length > 0) {
252
+ where.push(`r.id IN (${ids.map(() => '?').join(', ')})`);
253
+ params.push(...ids);
254
+ }
255
+ for (const tag of filter.tags ?? []) {
256
+ // One EXISTS per tag: several tags narrow rather than widen, so a
257
+ // recording must carry all of them -- the same rule `ls --tag` follows.
258
+ where.push('EXISTS (SELECT 1 FROM tag WHERE tag.recording_id = r.id AND tag.tag = ?)');
259
+ params.push(tag);
260
+ }
261
+ const limit = filter.limit ?? 50;
262
+ const rows = this.db
263
+ .prepare(`SELECT s.id AS segment_id, s.transcript_id, s.start_ms, s.end_ms, s.text,
264
+ s.speaker, s.language,
265
+ r.id AS recording_id, r.title, r.recorded_at, r.imported_at
266
+ FROM segment_fts f
267
+ JOIN segment s ON s.rowid = f.rowid
268
+ JOIN transcript t ON t.id = s.transcript_id
269
+ JOIN recording r ON r.id = t.recording_id
270
+ WHERE ${where.join('\n AND ')}
271
+ ORDER BY bm25(segment_fts), r.imported_at DESC, s.start_ms
272
+ LIMIT ?`)
273
+ .all(...params, limit);
274
+ const tagsOf = this.db.prepare('SELECT tag FROM tag WHERE recording_id = ? ORDER BY tag');
275
+ return rows.map((row) => ({
276
+ recordingId: row.recording_id,
277
+ recordingTitle: row.title,
278
+ recordedAt: row.recorded_at ?? row.imported_at,
279
+ tags: tagsOf.all(row.recording_id).map((t) => t.tag),
280
+ transcriptId: row.transcript_id,
281
+ segmentId: row.segment_id,
282
+ startMs: row.start_ms,
283
+ endMs: row.end_ms,
284
+ speaker: row.speaker,
285
+ language: row.language,
286
+ text: row.text,
287
+ }));
288
+ }
289
+ async insertSummary(summary) {
290
+ this.db.exec('BEGIN');
291
+ try {
292
+ this.db
293
+ .prepare(`INSERT INTO summary
294
+ (id, created_at, language, provider, model, body, template, context)
295
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
296
+ .run(summary.id, summary.createdAt, summary.language, summary.provider, summary.model, summary.body, summary.template, summary.context);
297
+ const link = this.db.prepare('INSERT INTO summary_recording (summary_id, recording_id) VALUES (?, ?)');
298
+ for (const recordingId of summary.recordingIds)
299
+ link.run(summary.id, recordingId);
300
+ this.db.exec('COMMIT');
301
+ }
302
+ catch (error) {
303
+ this.db.exec('ROLLBACK');
304
+ throw error;
305
+ }
306
+ }
307
+ /**
308
+ * The newest summary covering this recording ALONE.
309
+ *
310
+ * The `NOT EXISTS` is the whole point: a group summary of ten meetings also
311
+ * has a row here for each of them, and handing it back as "the summary of
312
+ * the third meeting" would answer a question about one recording with nine
313
+ * others mixed in.
314
+ */
315
+ async latestSummaryOf(recordingId) {
316
+ const row = this.db
317
+ .prepare(`SELECT s.* FROM summary s
318
+ JOIN summary_recording sr ON sr.summary_id = s.id
319
+ WHERE sr.recording_id = ?
320
+ AND NOT EXISTS (
321
+ SELECT 1 FROM summary_recording other
322
+ WHERE other.summary_id = s.id AND other.recording_id <> ?
323
+ )
324
+ ORDER BY s.created_at DESC, s.id DESC LIMIT 1`)
325
+ .get(recordingId, recordingId);
326
+ return row ? this.toSummary(row) : null;
327
+ }
328
+ async listSummaries(recordingId) {
329
+ const rows = this.db
330
+ .prepare(`SELECT s.* FROM summary s
331
+ JOIN summary_recording sr ON sr.summary_id = s.id
332
+ WHERE sr.recording_id = ?
333
+ ORDER BY s.created_at DESC, s.id DESC`)
334
+ .all(recordingId);
335
+ return rows.map((row) => this.toSummary(row));
336
+ }
337
+ async listAllSummaries() {
338
+ const rows = this.db
339
+ .prepare('SELECT * FROM summary ORDER BY created_at DESC, id DESC')
340
+ .all();
341
+ return rows.map((row) => this.toSummary(row));
342
+ }
343
+ async findSummariesByIdPrefix(prefix) {
344
+ if (!usableIdPrefix(prefix))
345
+ return [];
346
+ const rows = this.db
347
+ .prepare(`SELECT * FROM summary WHERE id LIKE ? || '%' ORDER BY id`)
348
+ .all(prefix);
349
+ return rows.map((row) => this.toSummary(row));
350
+ }
351
+ async deleteSummary(id) {
352
+ // The join rows go with it through ON DELETE CASCADE, which the store
353
+ // enables with PRAGMA foreign_keys = ON. Recordings are untouched: the
354
+ // cascade runs from summary to summary_recording, never the other way.
355
+ const result = this.db.prepare('DELETE FROM summary WHERE id = ?').run(id);
356
+ return Number(result.changes) > 0;
357
+ }
358
+ toSummary(row) {
359
+ const ids = this.db
360
+ .prepare('SELECT recording_id FROM summary_recording WHERE summary_id = ? ORDER BY recording_id')
361
+ .all(row.id);
362
+ return {
363
+ id: row.id,
364
+ createdAt: row.created_at,
365
+ language: row.language,
366
+ provider: row.provider,
367
+ model: row.model,
368
+ body: row.body,
369
+ template: row.template,
370
+ context: row.context,
371
+ recordingIds: ids.map((entry) => entry.recording_id),
372
+ };
373
+ }
374
+ async listSpeakerNames(recordingId) {
375
+ const rows = this.db
376
+ .prepare('SELECT label, name FROM speaker WHERE recording_id = ? ORDER BY label')
377
+ .all(recordingId);
378
+ return rows.map((row) => ({ label: row.label, name: row.name }));
379
+ }
380
+ async annotateRecording(id, fields) {
381
+ // COALESCE with a null parameter leaves the stored value alone, so one
382
+ // statement covers "set the title", "set the notes", and "set both"
383
+ // without three of them drifting apart.
384
+ this.db
385
+ .prepare(`UPDATE recording SET title = COALESCE(?, title), notes = COALESCE(?, notes)
386
+ WHERE id = ?`)
387
+ .run(fields.title ?? null, fields.notes ?? null, id);
388
+ }
389
+ async deleteRecording(id) {
390
+ // One statement: transcript and segment rows go through ON DELETE
391
+ // CASCADE, which works because open() sets PRAGMA foreign_keys = ON.
392
+ // The segment_fts index is kept in step by the AFTER DELETE trigger on
393
+ // segment, which a cascade fires like any other delete -- there is a
394
+ // test for exactly that, because a stale search index would corrupt
395
+ // results silently rather than failing.
396
+ const result = this.db.prepare('DELETE FROM recording WHERE id = ?').run(id);
397
+ return Number(result.changes) > 0;
398
+ }
399
+ async languagesByTranscript(transcriptIds) {
400
+ // `IN ()` is a syntax error, and there is nothing to ask about anyway.
401
+ if (transcriptIds.length === 0)
402
+ return new Map();
403
+ // One aggregate for the whole list, not one query per transcript: this
404
+ // exists so that `ls` can show a language per row without loading the
405
+ // segments of every recording in the library.
406
+ //
407
+ // max(x, 0) clamps a segment whose end precedes its start, matching what
408
+ // summarizeLanguages does in memory -- a negative span must not subtract
409
+ // from a language's total and reorder the result. Rows with no language
410
+ // are excluded here rather than filtered later, so a transcript with no
411
+ // languages recorded produces no rows and stays absent from the map.
412
+ const placeholders = transcriptIds.map(() => '?').join(',');
413
+ const rows = this.db
414
+ .prepare(`SELECT transcript_id, language,
415
+ SUM(max(end_ms - start_ms, 0)) AS spoken_ms,
416
+ MIN(idx) AS first_idx
417
+ FROM segment
418
+ WHERE transcript_id IN (${placeholders})
419
+ AND language IS NOT NULL AND language <> ''
420
+ GROUP BY transcript_id, language`)
421
+ .all(...transcriptIds);
422
+ const totalsByTranscript = new Map();
423
+ for (const row of rows) {
424
+ const totals = totalsByTranscript.get(row.transcript_id) ?? [];
425
+ totals.push({ language: row.language, spokenMs: row.spoken_ms, firstIdx: row.first_idx });
426
+ totalsByTranscript.set(row.transcript_id, totals);
427
+ }
428
+ // The ordering rule itself lives in core (orderLanguages), shared with
429
+ // summarizeLanguages, so SQL and TypeScript cannot drift apart on it.
430
+ return new Map([...totalsByTranscript.entries()].map(([id, totals]) => [id, orderLanguages(totals)]));
431
+ }
432
+ }
433
+ export const openStore = (path) => SqliteStore.open(path);