@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.
- package/LICENSE +224 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/audio/ffmpeg.d.ts +12 -0
- package/dist/audio/ffmpeg.js +81 -0
- package/dist/diarize/sherpaDiarizer.d.ts +26 -0
- package/dist/diarize/sherpaDiarizer.js +64 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +19 -0
- package/dist/llm/anthropic.d.ts +36 -0
- package/dist/llm/anthropic.js +92 -0
- package/dist/llm/claudeCli.d.ts +32 -0
- package/dist/llm/claudeCli.js +63 -0
- package/dist/llm/llamaCpp.d.ts +38 -0
- package/dist/llm/llamaCpp.js +90 -0
- package/dist/llm/models.d.ts +23 -0
- package/dist/llm/models.js +97 -0
- package/dist/llm/openAiCompatible.d.ts +35 -0
- package/dist/llm/openAiCompatible.js +84 -0
- package/dist/process/pager.d.ts +36 -0
- package/dist/process/pager.js +69 -0
- package/dist/process/run.d.ts +35 -0
- package/dist/process/run.js +101 -0
- package/dist/provision/download.d.ts +19 -0
- package/dist/provision/download.js +80 -0
- package/dist/provision/llamaInstall.d.ts +38 -0
- package/dist/provision/llamaInstall.js +75 -0
- package/dist/provision/packageManager.d.ts +50 -0
- package/dist/provision/packageManager.js +66 -0
- package/dist/provision/sherpaInstall.d.ts +34 -0
- package/dist/provision/sherpaInstall.js +78 -0
- package/dist/provision/whisperInstall.d.ts +61 -0
- package/dist/provision/whisperInstall.js +89 -0
- package/dist/store/sqliteStore.d.ts +64 -0
- package/dist/store/sqliteStore.js +433 -0
- package/dist/stt/whisperCpp.d.ts +50 -0
- package/dist/stt/whisperCpp.js +116 -0
- package/dist/system/nodeFs.d.ts +14 -0
- package/dist/system/nodeFs.js +80 -0
- package/dist/system/systemClock.d.ts +20 -0
- package/dist/system/systemClock.js +55 -0
- package/dist/vad/whisperVad.d.ts +14 -0
- package/dist/vad/whisperVad.js +58 -0
- 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);
|