@alta-foundation/plaud-extractor 1.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 (163) hide show
  1. package/.env.example +9 -0
  2. package/.github/workflows/ci.yml +33 -0
  3. package/.github/workflows/publish.yml +46 -0
  4. package/CLAUDE.md +53 -0
  5. package/README.md +318 -0
  6. package/dist/PlaudExtractor.d.ts +61 -0
  7. package/dist/PlaudExtractor.d.ts.map +1 -0
  8. package/dist/PlaudExtractor.js +236 -0
  9. package/dist/PlaudExtractor.js.map +1 -0
  10. package/dist/auth/browser-auth.d.ts +10 -0
  11. package/dist/auth/browser-auth.d.ts.map +1 -0
  12. package/dist/auth/browser-auth.js +220 -0
  13. package/dist/auth/browser-auth.js.map +1 -0
  14. package/dist/auth/token-store.d.ts +9 -0
  15. package/dist/auth/token-store.d.ts.map +1 -0
  16. package/dist/auth/token-store.js +74 -0
  17. package/dist/auth/token-store.js.map +1 -0
  18. package/dist/auth/types.d.ts +266 -0
  19. package/dist/auth/types.d.ts.map +1 -0
  20. package/dist/auth/types.js +32 -0
  21. package/dist/auth/types.js.map +1 -0
  22. package/dist/cli/bin.d.ts +3 -0
  23. package/dist/cli/bin.d.ts.map +1 -0
  24. package/dist/cli/bin.js +30 -0
  25. package/dist/cli/bin.js.map +1 -0
  26. package/dist/cli/commands/auth.d.ts +3 -0
  27. package/dist/cli/commands/auth.d.ts.map +1 -0
  28. package/dist/cli/commands/auth.js +22 -0
  29. package/dist/cli/commands/auth.js.map +1 -0
  30. package/dist/cli/commands/backfill.d.ts +3 -0
  31. package/dist/cli/commands/backfill.d.ts.map +1 -0
  32. package/dist/cli/commands/backfill.js +59 -0
  33. package/dist/cli/commands/backfill.js.map +1 -0
  34. package/dist/cli/commands/sync.d.ts +3 -0
  35. package/dist/cli/commands/sync.d.ts.map +1 -0
  36. package/dist/cli/commands/sync.js +55 -0
  37. package/dist/cli/commands/sync.js.map +1 -0
  38. package/dist/cli/commands/verify.d.ts +3 -0
  39. package/dist/cli/commands/verify.d.ts.map +1 -0
  40. package/dist/cli/commands/verify.js +28 -0
  41. package/dist/cli/commands/verify.js.map +1 -0
  42. package/dist/cli/exit-codes.d.ts +8 -0
  43. package/dist/cli/exit-codes.d.ts.map +1 -0
  44. package/dist/cli/exit-codes.js +16 -0
  45. package/dist/cli/exit-codes.js.map +1 -0
  46. package/dist/cli/options.d.ts +31 -0
  47. package/dist/cli/options.d.ts.map +1 -0
  48. package/dist/cli/options.js +11 -0
  49. package/dist/cli/options.js.map +1 -0
  50. package/dist/client/endpoints.d.ts +26 -0
  51. package/dist/client/endpoints.d.ts.map +1 -0
  52. package/dist/client/endpoints.js +54 -0
  53. package/dist/client/endpoints.js.map +1 -0
  54. package/dist/client/http.d.ts +17 -0
  55. package/dist/client/http.d.ts.map +1 -0
  56. package/dist/client/http.js +92 -0
  57. package/dist/client/http.js.map +1 -0
  58. package/dist/client/plaud-client.d.ts +14 -0
  59. package/dist/client/plaud-client.d.ts.map +1 -0
  60. package/dist/client/plaud-client.js +216 -0
  61. package/dist/client/plaud-client.js.map +1 -0
  62. package/dist/client/types.d.ts +154 -0
  63. package/dist/client/types.d.ts.map +1 -0
  64. package/dist/client/types.js +41 -0
  65. package/dist/client/types.js.map +1 -0
  66. package/dist/errors.d.ts +24 -0
  67. package/dist/errors.d.ts.map +1 -0
  68. package/dist/errors.js +51 -0
  69. package/dist/errors.js.map +1 -0
  70. package/dist/index.d.ts +7 -0
  71. package/dist/index.d.ts.map +1 -0
  72. package/dist/index.js +5 -0
  73. package/dist/index.js.map +1 -0
  74. package/dist/logger.d.ts +9 -0
  75. package/dist/logger.d.ts.map +1 -0
  76. package/dist/logger.js +37 -0
  77. package/dist/logger.js.map +1 -0
  78. package/dist/mcp/job-tools.d.ts +3 -0
  79. package/dist/mcp/job-tools.d.ts.map +1 -0
  80. package/dist/mcp/job-tools.js +108 -0
  81. package/dist/mcp/job-tools.js.map +1 -0
  82. package/dist/mcp/read-tools.d.ts +3 -0
  83. package/dist/mcp/read-tools.d.ts.map +1 -0
  84. package/dist/mcp/read-tools.js +173 -0
  85. package/dist/mcp/read-tools.js.map +1 -0
  86. package/dist/mcp/server.d.ts +3 -0
  87. package/dist/mcp/server.d.ts.map +1 -0
  88. package/dist/mcp/server.js +32 -0
  89. package/dist/mcp/server.js.map +1 -0
  90. package/dist/storage/atomic.d.ts +5 -0
  91. package/dist/storage/atomic.d.ts.map +1 -0
  92. package/dist/storage/atomic.js +51 -0
  93. package/dist/storage/atomic.js.map +1 -0
  94. package/dist/storage/checksums.d.ts +15 -0
  95. package/dist/storage/checksums.d.ts.map +1 -0
  96. package/dist/storage/checksums.js +56 -0
  97. package/dist/storage/checksums.js.map +1 -0
  98. package/dist/storage/dataset-writer.d.ts +21 -0
  99. package/dist/storage/dataset-writer.d.ts.map +1 -0
  100. package/dist/storage/dataset-writer.js +52 -0
  101. package/dist/storage/dataset-writer.js.map +1 -0
  102. package/dist/storage/paths.d.ts +9 -0
  103. package/dist/storage/paths.d.ts.map +1 -0
  104. package/dist/storage/paths.js +38 -0
  105. package/dist/storage/paths.js.map +1 -0
  106. package/dist/storage/recording-store.d.ts +24 -0
  107. package/dist/storage/recording-store.d.ts.map +1 -0
  108. package/dist/storage/recording-store.js +161 -0
  109. package/dist/storage/recording-store.js.map +1 -0
  110. package/dist/sync/download-queue.d.ts +21 -0
  111. package/dist/sync/download-queue.d.ts.map +1 -0
  112. package/dist/sync/download-queue.js +82 -0
  113. package/dist/sync/download-queue.js.map +1 -0
  114. package/dist/sync/incremental.d.ts +21 -0
  115. package/dist/sync/incremental.d.ts.map +1 -0
  116. package/dist/sync/incremental.js +96 -0
  117. package/dist/sync/incremental.js.map +1 -0
  118. package/dist/sync/sync-engine.d.ts +6 -0
  119. package/dist/sync/sync-engine.d.ts.map +1 -0
  120. package/dist/sync/sync-engine.js +135 -0
  121. package/dist/sync/sync-engine.js.map +1 -0
  122. package/dist/sync/types.d.ts +130 -0
  123. package/dist/sync/types.d.ts.map +1 -0
  124. package/dist/sync/types.js +17 -0
  125. package/dist/sync/types.js.map +1 -0
  126. package/dist/transcript/formatter.d.ts +4 -0
  127. package/dist/transcript/formatter.d.ts.map +1 -0
  128. package/dist/transcript/formatter.js +88 -0
  129. package/dist/transcript/formatter.js.map +1 -0
  130. package/package.json +41 -0
  131. package/src/PlaudExtractor.ts +275 -0
  132. package/src/auth/browser-auth.ts +248 -0
  133. package/src/auth/token-store.ts +79 -0
  134. package/src/auth/types.ts +41 -0
  135. package/src/cli/bin.ts +30 -0
  136. package/src/cli/commands/auth.ts +27 -0
  137. package/src/cli/commands/backfill.ts +77 -0
  138. package/src/cli/commands/sync.ts +71 -0
  139. package/src/cli/commands/verify.ts +31 -0
  140. package/src/cli/exit-codes.ts +14 -0
  141. package/src/cli/options.ts +10 -0
  142. package/src/client/endpoints.ts +62 -0
  143. package/src/client/http.ts +110 -0
  144. package/src/client/plaud-client.ts +268 -0
  145. package/src/client/types.ts +62 -0
  146. package/src/errors.ts +57 -0
  147. package/src/index.ts +17 -0
  148. package/src/logger.ts +49 -0
  149. package/src/mcp/job-tools.ts +156 -0
  150. package/src/mcp/read-tools.ts +204 -0
  151. package/src/mcp/server.ts +39 -0
  152. package/src/storage/atomic.ts +51 -0
  153. package/src/storage/checksums.ts +76 -0
  154. package/src/storage/dataset-writer.ts +74 -0
  155. package/src/storage/paths.ts +44 -0
  156. package/src/storage/recording-store.ts +182 -0
  157. package/src/sync/download-queue.ts +102 -0
  158. package/src/sync/incremental.ts +111 -0
  159. package/src/sync/sync-engine.ts +183 -0
  160. package/src/sync/types.ts +64 -0
  161. package/src/transcript/formatter.ts +91 -0
  162. package/tsconfig.build.json +8 -0
  163. package/tsconfig.json +19 -0
@@ -0,0 +1,62 @@
1
+ import { z } from 'zod'
2
+
3
+ export const TranscriptSegmentSchema = z.object({
4
+ index: z.number(),
5
+ startMs: z.number(),
6
+ endMs: z.number(),
7
+ speaker: z.string().optional(),
8
+ text: z.string(),
9
+ confidence: z.number().min(0).max(1).optional(),
10
+ })
11
+
12
+ export type TranscriptSegment = z.infer<typeof TranscriptSegmentSchema>
13
+
14
+ export const PlaudRecordingSchema = z.object({
15
+ id: z.string(),
16
+ title: z.string().optional(),
17
+ /** Duration in seconds */
18
+ duration: z.number(),
19
+ recordedAt: z.string().datetime(),
20
+ createdAt: z.string().datetime(),
21
+ updatedAt: z.string().datetime(),
22
+ fileSize: z.number().optional(),
23
+ mimeType: z.string().default('audio/mp4'),
24
+ hasTranscript: z.boolean(),
25
+ transcriptStatus: z.enum(['pending', 'processing', 'completed', 'failed']).optional(),
26
+ language: z.string().optional(),
27
+ deviceId: z.string().optional(),
28
+ tags: z.array(z.string()).optional(),
29
+ folderId: z.string().optional(),
30
+ summary: z.string().optional(),
31
+ /** Raw API payload preserved verbatim for forward-compatibility */
32
+ _raw: z.record(z.unknown()),
33
+ })
34
+
35
+ export type PlaudRecording = z.infer<typeof PlaudRecordingSchema>
36
+
37
+ export const PlaudTranscriptSchema = z.object({
38
+ recordingId: z.string(),
39
+ language: z.string().optional(),
40
+ /** Duration in seconds */
41
+ duration: z.number(),
42
+ segments: z.array(TranscriptSegmentSchema),
43
+ /** Concatenated full text for convenience */
44
+ fullText: z.string(),
45
+ createdAt: z.string().datetime().optional(),
46
+ _raw: z.record(z.unknown()),
47
+ })
48
+
49
+ export type PlaudTranscript = z.infer<typeof PlaudTranscriptSchema>
50
+
51
+ export interface ListOptions {
52
+ since?: Date
53
+ limit?: number
54
+ cursor?: string
55
+ }
56
+
57
+ export interface PlaudClient {
58
+ isAuthenticated(): Promise<boolean>
59
+ listRecordings(options?: ListOptions): AsyncGenerator<PlaudRecording>
60
+ getTranscript(recordingId: string): Promise<PlaudTranscript>
61
+ getAudioDownloadUrl(recordingId: string): Promise<string | null>
62
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,57 @@
1
+ export class PlaudError extends Error {
2
+ constructor(
3
+ message: string,
4
+ public readonly cause?: unknown,
5
+ ) {
6
+ super(message)
7
+ this.name = 'PlaudError'
8
+ if (cause instanceof Error && cause.stack) {
9
+ this.stack = `${this.stack}\nCaused by: ${cause.stack}`
10
+ }
11
+ }
12
+ }
13
+
14
+ export class AuthError extends PlaudError {
15
+ constructor(message: string, cause?: unknown) {
16
+ super(message, cause)
17
+ this.name = 'AuthError'
18
+ }
19
+ }
20
+
21
+ export class ApiError extends PlaudError {
22
+ constructor(
23
+ message: string,
24
+ public readonly statusCode: number,
25
+ public readonly recordingId?: string,
26
+ cause?: unknown,
27
+ ) {
28
+ super(message, cause)
29
+ this.name = 'ApiError'
30
+ }
31
+
32
+ get isRetryable(): boolean {
33
+ return this.statusCode >= 500 || this.statusCode === 429
34
+ }
35
+ }
36
+
37
+ export class StorageError extends PlaudError {
38
+ constructor(
39
+ message: string,
40
+ public readonly path: string,
41
+ cause?: unknown,
42
+ ) {
43
+ super(message, cause)
44
+ this.name = 'StorageError'
45
+ }
46
+ }
47
+
48
+ export class ChecksumMismatchError extends PlaudError {
49
+ constructor(
50
+ public readonly filePath: string,
51
+ public readonly expected: string,
52
+ public readonly actual: string,
53
+ ) {
54
+ super(`Checksum mismatch for ${filePath}: expected ${expected}, got ${actual}`)
55
+ this.name = 'ChecksumMismatchError'
56
+ }
57
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ // SDK public API — what Alta CORE and other consumers import
2
+ export { PlaudExtractor } from './PlaudExtractor.js'
3
+ export type { PlaudExtractorConfig } from './PlaudExtractor.js'
4
+
5
+ // Types
6
+ export type { SyncOptions, SyncResult, BackfillOptions, VerifyResult } from './sync/types.js'
7
+ export type { PlaudRecording, PlaudTranscript, TranscriptSegment, ListOptions } from './client/types.js'
8
+ export type { AuthSession, StoredCredentials } from './auth/types.js'
9
+
10
+ // Errors — consumers may need to catch these
11
+ export {
12
+ PlaudError,
13
+ AuthError,
14
+ ApiError,
15
+ StorageError,
16
+ ChecksumMismatchError,
17
+ } from './errors.js'
package/src/logger.ts ADDED
@@ -0,0 +1,49 @@
1
+ import pino from 'pino'
2
+ import { runLogsPath } from './storage/paths.js'
3
+
4
+ export type Logger = pino.Logger
5
+
6
+ let _logger: pino.Logger | null = null
7
+
8
+ export function createLogger(outDir: string, opts?: { verbose?: boolean; redact?: boolean }): pino.Logger {
9
+ const level = process.env['LOG_LEVEL'] ?? (opts?.verbose ? 'debug' : 'info')
10
+
11
+ const targets: pino.TransportTargetOptions[] = [
12
+ {
13
+ target: 'pino/file',
14
+ options: { destination: runLogsPath(outDir), mkdir: true },
15
+ level: 'debug',
16
+ },
17
+ {
18
+ target: 'pino-pretty',
19
+ options: { colorize: true, translateTime: 'SYS:standard' },
20
+ level,
21
+ },
22
+ ]
23
+
24
+ const redactPaths = opts?.redact
25
+ ? ['authToken', 'cookies', '*.value', 'Authorization', '*.Authorization']
26
+ : []
27
+
28
+ _logger = pino(
29
+ {
30
+ level: 'debug',
31
+ redact: redactPaths.length > 0 ? { paths: redactPaths, censor: '[REDACTED]' } : undefined,
32
+ },
33
+ pino.transport({ targets }),
34
+ )
35
+
36
+ return _logger
37
+ }
38
+
39
+ export function getLogger(): pino.Logger {
40
+ if (!_logger) {
41
+ // Fallback: stdout-only logger for when SDK is used without init
42
+ _logger = pino({ level: process.env['LOG_LEVEL'] ?? 'info' })
43
+ }
44
+ return _logger
45
+ }
46
+
47
+ export function setLogger(logger: pino.Logger): void {
48
+ _logger = logger
49
+ }
@@ -0,0 +1,156 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
+ import { z } from 'zod'
6
+ import { stateDir } from '../storage/paths.js'
7
+ import { PlaudExtractor } from '../PlaudExtractor.js'
8
+ import { getLogger } from '../logger.js'
9
+ import type { SyncResult } from '../sync/types.js'
10
+
11
+ // ─── Job state ─────────────────────────────────────────────────────────────────
12
+
13
+ type JobStatus = 'running' | 'completed' | 'failed'
14
+
15
+ interface JobState {
16
+ id: string
17
+ type: 'sync' | 'backfill'
18
+ status: JobStatus
19
+ startedAt: string
20
+ completedAt?: string
21
+ result?: Partial<SyncResult>
22
+ error?: string
23
+ }
24
+
25
+ function jobsDir(outDir: string): string {
26
+ return path.join(stateDir(outDir), 'jobs')
27
+ }
28
+
29
+ async function writeJob(outDir: string, state: JobState): Promise<void> {
30
+ await fs.mkdir(jobsDir(outDir), { recursive: true })
31
+ await fs.writeFile(
32
+ path.join(jobsDir(outDir), `${state.id}.json`),
33
+ JSON.stringify(state, null, 2),
34
+ )
35
+ }
36
+
37
+ async function readJob(outDir: string, jobId: string): Promise<JobState | null> {
38
+ try {
39
+ const raw = await fs.readFile(path.join(jobsDir(outDir), `${jobId}.json`), 'utf8')
40
+ return JSON.parse(raw) as JobState
41
+ } catch {
42
+ return null
43
+ }
44
+ }
45
+
46
+ function newJobId(type: string): string {
47
+ const ts = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)
48
+ const rand = crypto.randomBytes(3).toString('hex')
49
+ return `${type}_${ts}_${rand}`
50
+ }
51
+
52
+ // ─── Async job runner ──────────────────────────────────────────────────────────
53
+
54
+ function runAsync(fn: () => Promise<void>): void {
55
+ fn().catch(err => getLogger().error({ err }, 'Unhandled job error'))
56
+ }
57
+
58
+ // ─── Tool registration ─────────────────────────────────────────────────────────
59
+
60
+ export function registerJobTools(server: McpServer, outDir: string): void {
61
+
62
+ // ── plaud_sync ─────────────────────────────────────────────────────────────
63
+
64
+ server.tool(
65
+ 'plaud_sync',
66
+ 'Start an incremental sync (new/changed recordings only) in the background. Returns a jobId immediately — poll with plaud_job_status to check progress.',
67
+ {
68
+ since: z.string().optional().describe('ISO date — only sync recordings after this date'),
69
+ limit: z.number().int().min(1).optional().describe('Max recordings to sync'),
70
+ dryRun: z.boolean().default(false).describe('Preview without downloading'),
71
+ },
72
+ async ({ since, limit, dryRun }) => {
73
+ const jobId = newJobId('sync')
74
+ const job: JobState = { id: jobId, type: 'sync', status: 'running', startedAt: new Date().toISOString() }
75
+ await writeJob(outDir, job)
76
+
77
+ runAsync(async () => {
78
+ try {
79
+ const extractor = new PlaudExtractor({ outDir, logger: getLogger() })
80
+ const result = await extractor.sync({
81
+ since: since ? new Date(since) : undefined,
82
+ limit,
83
+ dryRun,
84
+ })
85
+ await writeJob(outDir, { ...job, status: 'completed', completedAt: new Date().toISOString(), result })
86
+ } catch (err) {
87
+ await writeJob(outDir, { ...job, status: 'failed', completedAt: new Date().toISOString(), error: String(err) })
88
+ }
89
+ })
90
+
91
+ return {
92
+ content: [{
93
+ type: 'text' as const,
94
+ text: JSON.stringify({
95
+ jobId,
96
+ status: 'running',
97
+ message: `Sync started. Poll with: plaud_job_status({ jobId: "${jobId}" })`,
98
+ }, null, 2),
99
+ }],
100
+ }
101
+ },
102
+ )
103
+
104
+ // ── plaud_backfill ─────────────────────────────────────────────────────────
105
+
106
+ server.tool(
107
+ 'plaud_backfill',
108
+ 'Re-evaluate and re-download all recordings in the background. Returns a jobId immediately — poll with plaud_job_status to check progress.',
109
+ {
110
+ limit: z.number().int().min(1).optional().describe('Max recordings to process'),
111
+ },
112
+ async ({ limit }) => {
113
+ const jobId = newJobId('backfill')
114
+ const job: JobState = { id: jobId, type: 'backfill', status: 'running', startedAt: new Date().toISOString() }
115
+ await writeJob(outDir, job)
116
+
117
+ runAsync(async () => {
118
+ try {
119
+ const extractor = new PlaudExtractor({ outDir, logger: getLogger() })
120
+ const result = await extractor.backfill({ limit })
121
+ await writeJob(outDir, { ...job, status: 'completed', completedAt: new Date().toISOString(), result })
122
+ } catch (err) {
123
+ await writeJob(outDir, { ...job, status: 'failed', completedAt: new Date().toISOString(), error: String(err) })
124
+ }
125
+ })
126
+
127
+ return {
128
+ content: [{
129
+ type: 'text' as const,
130
+ text: JSON.stringify({
131
+ jobId,
132
+ status: 'running',
133
+ message: `Backfill started. Poll with: plaud_job_status({ jobId: "${jobId}" })`,
134
+ }, null, 2),
135
+ }],
136
+ }
137
+ },
138
+ )
139
+
140
+ // ── plaud_job_status ───────────────────────────────────────────────────────
141
+
142
+ server.tool(
143
+ 'plaud_job_status',
144
+ 'Check the status of a background sync or backfill job.',
145
+ {
146
+ jobId: z.string().describe('The jobId returned by plaud_sync or plaud_backfill'),
147
+ },
148
+ async ({ jobId }) => {
149
+ const job = await readJob(outDir, jobId)
150
+ if (!job) {
151
+ return { content: [{ type: 'text' as const, text: `Job not found: ${jobId}` }] }
152
+ }
153
+ return { content: [{ type: 'text' as const, text: JSON.stringify(job, null, 2) }] }
154
+ },
155
+ )
156
+ }
@@ -0,0 +1,204 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
4
+ import { z } from 'zod'
5
+ import { loadCredentials, isExpired } from '../auth/token-store.js'
6
+ import { syncStatePath } from '../storage/paths.js'
7
+ import { SyncStateSchema } from '../sync/types.js'
8
+
9
+ export function registerReadTools(server: McpServer, outDir: string): void {
10
+
11
+ // ── plaud_status ──────────────────────────────────────────────────────────
12
+
13
+ server.tool(
14
+ 'plaud_status',
15
+ 'Check Plaud connection status, last sync time, and local recording count.',
16
+ {},
17
+ async () => {
18
+ const creds = await loadCredentials().catch(() => null)
19
+
20
+ let auth: string
21
+ if (!creds) {
22
+ auth = 'not authenticated — run: alta-plaud auth'
23
+ } else if (isExpired(creds)) {
24
+ auth = 'token expired — run: alta-plaud auth'
25
+ } else {
26
+ auth = 'authenticated'
27
+ }
28
+
29
+ let lastSync = 'never'
30
+ let recordingCount = 0
31
+ try {
32
+ const raw = await fs.readFile(syncStatePath(outDir), 'utf8')
33
+ const state = SyncStateSchema.parse(JSON.parse(raw))
34
+ if (state.lastSuccessfulSyncAt) lastSync = state.lastSuccessfulSyncAt
35
+ recordingCount = Object.keys(state.recordings).length
36
+ } catch { /* no state file yet */ }
37
+
38
+ return {
39
+ content: [{
40
+ type: 'text' as const,
41
+ text: JSON.stringify({ auth, lastSync, recordingCount, outDir }, null, 2),
42
+ }],
43
+ }
44
+ },
45
+ )
46
+
47
+ // ── plaud_list_recordings ─────────────────────────────────────────────────
48
+
49
+ server.tool(
50
+ 'plaud_list_recordings',
51
+ 'List locally synced Plaud recordings. Filter by date or search title.',
52
+ {
53
+ limit: z.number().int().min(1).max(200).default(20).describe('Max results (default 20)'),
54
+ since: z.string().optional().describe('ISO date — only recordings after this date'),
55
+ search: z.string().optional().describe('Case-insensitive title filter'),
56
+ },
57
+ async ({ limit, since, search }) => {
58
+ let recordings = await walkRecordingMeta(outDir)
59
+
60
+ if (since) {
61
+ const sinceDate = new Date(since)
62
+ recordings = recordings.filter(r => new Date(r.recorded_at ?? '') >= sinceDate)
63
+ }
64
+ if (search) {
65
+ const q = search.toLowerCase()
66
+ recordings = recordings.filter(r => (r.title ?? '').toLowerCase().includes(q))
67
+ }
68
+
69
+ recordings.sort((a, b) => (b.recorded_at ?? '').localeCompare(a.recorded_at ?? ''))
70
+ const page = recordings.slice(0, limit)
71
+
72
+ return {
73
+ content: [{
74
+ type: 'text' as const,
75
+ text: JSON.stringify({
76
+ total: recordings.length,
77
+ returned: page.length,
78
+ recordings: page.map(r => ({
79
+ id: r.source_recording_id,
80
+ title: r.title,
81
+ recorded_at: r.recorded_at,
82
+ duration_seconds: r.duration_seconds,
83
+ has_transcript: r.has_transcript,
84
+ })),
85
+ }, null, 2),
86
+ }],
87
+ }
88
+ },
89
+ )
90
+
91
+ // ── plaud_get_transcript ──────────────────────────────────────────────────
92
+
93
+ server.tool(
94
+ 'plaud_get_transcript',
95
+ 'Get the full transcript of a recording by ID or partial title match.',
96
+ {
97
+ recordingId: z.string().optional().describe('Exact recording ID'),
98
+ title: z.string().optional().describe('Partial title match (case-insensitive)'),
99
+ },
100
+ async ({ recordingId, title }) => {
101
+ if (!recordingId && !title) {
102
+ return { content: [{ type: 'text' as const, text: 'Error: provide recordingId or title' }] }
103
+ }
104
+
105
+ const recordings = await walkRecordingMeta(outDir)
106
+ let match: RecordingMeta | undefined
107
+
108
+ if (recordingId) {
109
+ match = recordings.find(r => r.source_recording_id === recordingId)
110
+ } else if (title) {
111
+ const q = title.toLowerCase()
112
+ match = recordings.find(r => (r.title ?? '').toLowerCase().includes(q))
113
+ }
114
+
115
+ if (!match) {
116
+ return {
117
+ content: [{
118
+ type: 'text' as const,
119
+ text: `No recording found matching: ${recordingId ?? title}`,
120
+ }],
121
+ }
122
+ }
123
+
124
+ let transcript = ''
125
+ try {
126
+ transcript = await fs.readFile(path.join(match._dir, 'transcript.txt'), 'utf8')
127
+ } catch {
128
+ try {
129
+ const raw = await fs.readFile(path.join(match._dir, 'transcript.json'), 'utf8')
130
+ const data = JSON.parse(raw) as { segments?: Array<{ text?: string }> }
131
+ transcript = (data.segments ?? []).map(s => s.text ?? '').filter(Boolean).join('\n\n')
132
+ } catch {
133
+ transcript = '(no transcript available)'
134
+ }
135
+ }
136
+
137
+ return {
138
+ content: [{
139
+ type: 'text' as const,
140
+ text: JSON.stringify({
141
+ id: match.source_recording_id,
142
+ title: match.title,
143
+ recorded_at: match.recorded_at,
144
+ duration_seconds: match.duration_seconds,
145
+ transcript,
146
+ }, null, 2),
147
+ }],
148
+ }
149
+ },
150
+ )
151
+ }
152
+
153
+ // ─── Filesystem helpers ────────────────────────────────────────────────────────
154
+
155
+ interface RecordingMeta {
156
+ source_recording_id: string
157
+ title?: string
158
+ recorded_at?: string
159
+ duration_seconds?: number
160
+ has_transcript?: boolean
161
+ _dir: string
162
+ }
163
+
164
+ async function walkRecordingMeta(outDir: string): Promise<RecordingMeta[]> {
165
+ const recordingsBase = path.join(outDir, 'recordings')
166
+ const results: RecordingMeta[] = []
167
+
168
+ let yearDirs: string[]
169
+ try {
170
+ yearDirs = await fs.readdir(recordingsBase)
171
+ } catch {
172
+ return results
173
+ }
174
+
175
+ for (const year of yearDirs) {
176
+ let monthDirs: string[]
177
+ try { monthDirs = await fs.readdir(path.join(recordingsBase, year)) }
178
+ catch { continue }
179
+
180
+ for (const month of monthDirs) {
181
+ let recDirs: string[]
182
+ try { recDirs = await fs.readdir(path.join(recordingsBase, year, month)) }
183
+ catch { continue }
184
+
185
+ for (const recDir of recDirs) {
186
+ const dirPath = path.join(recordingsBase, year, month, recDir)
187
+ try {
188
+ const raw = await fs.readFile(path.join(dirPath, 'meta.json'), 'utf8')
189
+ const meta = JSON.parse(raw) as Record<string, unknown>
190
+ results.push({
191
+ source_recording_id: String(meta['source_recording_id'] ?? ''),
192
+ title: meta['title'] as string | undefined,
193
+ recorded_at: meta['recorded_at'] as string | undefined,
194
+ duration_seconds: meta['duration_seconds'] as number | undefined,
195
+ has_transcript: meta['has_transcript'] as boolean | undefined,
196
+ _dir: dirPath,
197
+ })
198
+ } catch { continue }
199
+ }
200
+ }
201
+ }
202
+
203
+ return results
204
+ }
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import pino from 'pino'
5
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
+ import { defaultOutDir, runLogsPath } from '../storage/paths.js'
8
+ import { setLogger } from '../logger.js'
9
+ import { registerReadTools } from './read-tools.js'
10
+ import { registerJobTools } from './job-tools.js'
11
+
12
+ const rawDir = process.env['ALTA_DATA_DIR']
13
+ const outDir = rawDir
14
+ ? path.resolve(rawDir.replace(/^~/, os.homedir()))
15
+ : defaultOutDir()
16
+
17
+ // MCP communicates over stdio — logs must go to file only, never stdout
18
+ const logger = pino(
19
+ { level: 'debug' },
20
+ pino.transport({
21
+ targets: [{
22
+ target: 'pino/file',
23
+ options: { destination: runLogsPath(outDir), mkdir: true },
24
+ level: 'debug',
25
+ }],
26
+ }),
27
+ )
28
+ setLogger(logger)
29
+
30
+ const server = new McpServer({
31
+ name: 'alta-plaud',
32
+ version: '1.0.0',
33
+ })
34
+
35
+ registerReadTools(server, outDir)
36
+ registerJobTools(server, outDir)
37
+
38
+ const transport = new StdioServerTransport()
39
+ await server.connect(transport)
@@ -0,0 +1,51 @@
1
+ import fs from 'node:fs/promises'
2
+ import { createWriteStream } from 'node:fs'
3
+ import path from 'node:path'
4
+ import crypto from 'node:crypto'
5
+ import { StorageError } from '../errors.js'
6
+
7
+ /** Write a file atomically: write to .tmp-<rand>, then rename. */
8
+ export async function writeFileAtomic(filePath: string, data: string | Buffer): Promise<void> {
9
+ const tmpPath = `${filePath}.tmp-${crypto.randomBytes(4).toString('hex')}`
10
+ try {
11
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
12
+ await fs.writeFile(tmpPath, data, { encoding: typeof data === 'string' ? 'utf8' : undefined })
13
+ await fs.rename(tmpPath, filePath)
14
+ } catch (err) {
15
+ // Clean up tmp file on failure
16
+ await fs.unlink(tmpPath).catch(() => undefined)
17
+ throw new StorageError(`Failed to write ${filePath}`, filePath, err)
18
+ }
19
+ }
20
+
21
+ /** Stream a ReadableStream to a file atomically. */
22
+ export async function writeStreamAtomic(
23
+ filePath: string,
24
+ stream: AsyncIterable<Uint8Array> | NodeJS.ReadableStream,
25
+ ): Promise<void> {
26
+ const tmpPath = `${filePath}.tmp-${crypto.randomBytes(4).toString('hex')}`
27
+ try {
28
+ await fs.mkdir(path.dirname(filePath), { recursive: true })
29
+ await new Promise<void>((resolve, reject) => {
30
+ const out = createWriteStream(tmpPath)
31
+ out.on('finish', resolve)
32
+ out.on('error', reject)
33
+
34
+ if (Symbol.asyncIterator in stream) {
35
+ ;(async () => {
36
+ for await (const chunk of stream as AsyncIterable<Uint8Array>) {
37
+ out.write(chunk)
38
+ }
39
+ out.end()
40
+ })().catch(reject)
41
+ } else {
42
+ ;(stream as NodeJS.ReadableStream).pipe(out)
43
+ ;(stream as NodeJS.ReadableStream).on('error', reject)
44
+ }
45
+ })
46
+ await fs.rename(tmpPath, filePath)
47
+ } catch (err) {
48
+ await fs.unlink(tmpPath).catch(() => undefined)
49
+ throw new StorageError(`Failed to write stream to ${filePath}`, filePath, err)
50
+ }
51
+ }