instar 1.3.1621 → 1.3.1623

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 (58) hide show
  1. package/dist/commands/server.d.ts.map +1 -1
  2. package/dist/commands/server.js +4 -2
  3. package/dist/commands/server.js.map +1 -1
  4. package/dist/core/AppConversationService.d.ts +89 -4
  5. package/dist/core/AppConversationService.d.ts.map +1 -1
  6. package/dist/core/AppConversationService.js +312 -23
  7. package/dist/core/AppConversationService.js.map +1 -1
  8. package/dist/core/AppConversationStore.d.ts +21 -2
  9. package/dist/core/AppConversationStore.d.ts.map +1 -1
  10. package/dist/core/AppConversationStore.js +38 -1
  11. package/dist/core/AppConversationStore.js.map +1 -1
  12. package/dist/core/AppConversationsReplicatedStore.js +1 -1
  13. package/dist/core/AppConversationsReplicatedStore.js.map +1 -1
  14. package/dist/core/AppMediaStore.d.ts.map +1 -1
  15. package/dist/core/AppMediaStore.js +7 -0
  16. package/dist/core/AppMediaStore.js.map +1 -1
  17. package/dist/core/PostUpdateMigrator.d.ts +3 -1
  18. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  19. package/dist/core/PostUpdateMigrator.js +28 -7
  20. package/dist/core/PostUpdateMigrator.js.map +1 -1
  21. package/dist/data/standards-guard-index.json +1 -1
  22. package/dist/data/standards-guard-index.meta.json +2 -2
  23. package/dist/data/standards-registry.meta.json +1 -1
  24. package/dist/feedback-factory/execute/FeedbackExecutorService.d.ts.map +1 -1
  25. package/dist/feedback-factory/execute/FeedbackExecutorService.js +13 -3
  26. package/dist/feedback-factory/execute/FeedbackExecutorService.js.map +1 -1
  27. package/dist/feedback-factory/execute/reviewGate.d.ts +3 -2
  28. package/dist/feedback-factory/execute/reviewGate.d.ts.map +1 -1
  29. package/dist/feedback-factory/execute/reviewGate.js +3 -2
  30. package/dist/feedback-factory/execute/reviewGate.js.map +1 -1
  31. package/dist/monitoring/GreenPrAutoMerger.d.ts.map +1 -1
  32. package/dist/monitoring/GreenPrAutoMerger.js +11 -1
  33. package/dist/monitoring/GreenPrAutoMerger.js.map +1 -1
  34. package/dist/monitoring/MergeRunner.d.ts +4 -5
  35. package/dist/monitoring/MergeRunner.d.ts.map +1 -1
  36. package/dist/monitoring/MergeRunner.js +2 -2
  37. package/dist/monitoring/MergeRunner.js.map +1 -1
  38. package/dist/native/instar-desktop-host +0 -0
  39. package/dist/scaffold/templates.js +1 -1
  40. package/dist/server/AgentServer.d.ts.map +1 -1
  41. package/dist/server/AgentServer.js +8 -3
  42. package/dist/server/AgentServer.js.map +1 -1
  43. package/dist/server/mobileConversationRoutes.d.ts +6 -1
  44. package/dist/server/mobileConversationRoutes.d.ts.map +1 -1
  45. package/dist/server/mobileConversationRoutes.js +16 -2
  46. package/dist/server/mobileConversationRoutes.js.map +1 -1
  47. package/package.json +1 -1
  48. package/scripts/safe-merge.mjs +65 -67
  49. package/src/data/builtin-manifest.json +20 -20
  50. package/src/data/standards-guard-index.json +1 -1
  51. package/src/data/standards-guard-index.meta.json +2 -2
  52. package/src/data/standards-registry.meta.json +1 -1
  53. package/src/scaffold/templates.ts +1 -1
  54. package/upgrades/1.3.1622.md +21 -0
  55. package/upgrades/1.3.1623.md +34 -0
  56. package/upgrades/side-effects/feedback-ci-floor-merge.md +82 -0
  57. package/upgrades/side-effects/voice-transcript-event.eli16.md +17 -0
  58. package/upgrades/side-effects/voice-transcript-event.md +136 -0
@@ -68,9 +68,18 @@ export interface AppConversationServiceDeps {
68
68
  * `TranscriptionUnavailableError` when no provider is configured. Absent ⇒ every voice note
69
69
  * without a phone transcript reads "transcription unavailable".
70
70
  */
71
- transcribeVoice?: (filePath: string, mediaType: string) => Promise<string>;
71
+ transcribeVoice?: (filePath: string, mediaType: string, timeoutMs?: number) => Promise<string>;
72
72
  /** How long one dispatch waits for a server transcript (default 60 s, the spec's fixed deadline). */
73
73
  transcribeDeadlineMs?: number;
74
+ /** At most this many provider calls run at once in this process (default 3). */
75
+ transcribeConcurrency?: number;
76
+ /**
77
+ * The ceiling on one transcription, queueing included (default 10 minutes): a call still queued
78
+ * at the ceiling ends "timed out waiting"; a running call gets only what is left of it.
79
+ */
80
+ transcribeCeilingMs?: number;
81
+ /** The provider name stored on a transcript event (e.g. `groq`); absent ⇒ `server`. */
82
+ voiceProviderName?: () => string | undefined;
74
83
  }
75
84
  /** §4.5.1: server transcription has a 60-second limit. */
76
85
  export declare const VOICE_TRANSCRIBE_DEADLINE_MS = 60000;
@@ -79,7 +88,47 @@ export declare const VOICE_TRANSCRIBE_DEADLINE_MS = 60000;
79
88
  * server transcript on one line, or an honest note when there is none.
80
89
  */
81
90
  export declare function voiceLineWithTranscript(filePath: string, transcript: string): string;
91
+ /** A transcript on one line for a session: control characters removed; a long one says it was cut. */
92
+ export declare function transcriptOneLine(transcript: string): string;
82
93
  export declare function voiceLineUnavailable(filePath: string, reason: string): string;
94
+ /**
95
+ * The line for a voice note whose server transcript was not ready by the deadline (§4.5.1). An app
96
+ * conversation gets the transcript later as one follow-up line; a Telegram or Slack conversation
97
+ * continued from the app keeps it with the message only (no follow-up path reaches those sessions).
98
+ */
99
+ export declare function voiceLinePending(filePath: string, followUp: boolean): string;
100
+ /** The follow-up line a late server transcript produces (§4.5.1), once per attachment. */
101
+ export declare function lateTranscriptLine(filePath: string, outcome: VoiceOutcome, caption: string): string;
102
+ /** What one server transcription produced. A failure carries a plain reason, never silence. */
103
+ export type VoiceOutcome = {
104
+ ok: true;
105
+ text: string;
106
+ } | {
107
+ ok: false;
108
+ reason: string;
109
+ };
110
+ /**
111
+ * How long the provider itself may take (§4.5.1): past the 60 s dispatch deadline the transcript
112
+ * is still wanted, as a late transcript. Bounded: one call per attachment, aborted at this limit.
113
+ */
114
+ export declare const VOICE_TRANSCRIBE_PROVIDER_TIMEOUT_MS: number;
115
+ /**
116
+ * A server transcript gets the phone transcript's treatment before it is stored or delivered
117
+ * (§4.3, §4.5.1): `scanInbound` first (so a dictated pending pairing code declines that pairing),
118
+ * then exact-token removal. Text past the store's limit is cut with a note, never refused.
119
+ */
120
+ export declare function scrubServerTranscript(text: string, scan?: ((t: string) => void) | null): {
121
+ text: string;
122
+ kinds: string[];
123
+ };
124
+ /** At most this many server transcriptions call the provider at once, per process (§4.5.1). */
125
+ export declare const VOICE_TRANSCRIBE_CONCURRENCY = 3;
126
+ /**
127
+ * A stored transcript as it may be handed to a session or a device: the exact-token removal runs
128
+ * again on READ, so a transcript a peer stored without scrubbing (an older or misbehaving build)
129
+ * can never carry a pairing invite or a desktop code anywhere (second-pass review, finding 2).
130
+ */
131
+ export declare function transcriptForDelivery(text: string): string;
83
132
  export declare class AppConversationService {
84
133
  readonly deps: AppConversationServiceDeps;
85
134
  readonly store: AppConversationStore;
@@ -100,6 +149,12 @@ export declare class AppConversationService {
100
149
  /** Conditions whose notice is raised: the latch that makes it one notice per episode. */
101
150
  private readonly raisedNotices;
102
151
  private readonly now;
152
+ /** Server transcriptions running in this process, by attachment (one per attachment, §4.5.1). */
153
+ private readonly transcriptions;
154
+ /** Provider slots in use, and the calls queued for one (a bounded, per-process limit). */
155
+ private transcribeActive;
156
+ private readonly transcribeWaiters;
157
+ private inboundScan;
103
158
  /** Increment 3 fills this in (push without Telegram); increment 1 records the hook only. */
104
159
  pushHook: ((conversationKey: string, messageId: string) => void) | null;
105
160
  constructor(deps: AppConversationServiceDeps);
@@ -134,11 +189,41 @@ export declare class AppConversationService {
134
189
  * `[file now available: <path>]` line when the blob lands.
135
190
  */
136
191
  sessionLinesFor(conversationKey: string, messageId: string, attachments: ReadonlyArray<AppAttachment>, caption: string, waitMs?: number): Promise<string[]>;
192
+ /** Wired where the pairing service is: declines a pending pairing whose code a transcript dictates. */
193
+ setInboundScan(fn: ((text: string) => void) | null): void;
194
+ /** The session line for a voice note the phone did not transcribe (§4.5.1). Never rejects. */
195
+ private voiceLine;
137
196
  /**
138
- * Transcribe one voice note with the shared Telegram transcriber, bounded by the 60 s deadline.
139
- * Never rejects: no provider, a failure or the deadline all become an honest line with the path.
197
+ * The server transcript of one voice attachment, waiting at most the 60 s dispatch deadline.
198
+ * `pending` means it is still running: when it settles, a late transcript event is stored and
199
+ * one follow-up line is delivered (app conversations). Never rejects.
140
200
  */
141
- private voiceLine;
201
+ private voiceResult;
202
+ /**
203
+ * One server transcription per attachment on this machine, ever (§4.5.1, bounded): a stored
204
+ * transcript (made here or replicated from a peer) is reused; one already running is joined; a
205
+ * recorded failure is reported with its reason and never retried; a run a restart interrupted
206
+ * reads as a failure. Only an attachment with no row starts the provider.
207
+ */
208
+ private transcription;
209
+ /**
210
+ * One provider call under the per-process limit (§4.5.1, second-pass finding 1). Queue time counts
211
+ * toward the ceiling: still queued at the ceiling ⇒ `TranscriptionQueueTimeout`; a running call
212
+ * gets the remaining time (passed to the provider as its own timeout) and is abandoned at the
213
+ * ceiling. The slot is held until the provider call itself settles, so at most N ever run.
214
+ */
215
+ private limitedTranscribe;
216
+ /** A provider slot, waiting at most `waitMs`; false when the wait ran out. */
217
+ private acquireTranscribeSlot;
218
+ /** Hand the slot to the next queued call, or free it. */
219
+ private releaseTranscribeSlot;
220
+ /** Scrub, then store the transcript as its own unsigned event (§4.5.1). The session gets it either way. */
221
+ private storeTranscript;
222
+ /** A late transcript (or its failure): one follow-up line, or a note for the next delivery. */
223
+ private deliverLate;
224
+ /** The transcript reached the session already: no later line may repeat it (second-pass finding 3). */
225
+ private markTranscriptDelivered;
226
+ private transcriptDelivered;
142
227
  /** A blob landed: each waiting row gets its session copy and one follow-up line. */
143
228
  resolvePending(sha: string): Promise<void>;
144
229
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"AppConversationService.d.ts","sourceRoot":"","sources":["../../src/core/AppConversationService.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AppConversationService.d.ts","sourceRoot":"","sources":["../../src/core/AppConversationService.ts"],"names":[],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import { SessionChannelRegistry } from '../messaging/shared/SessionChannelRegistry.js';
2
- import { AppConversationStore } from './AppConversationStore.js';
3
- import { derivedEventId, isAppConversationKey } from './appConversationText.js';
2
+ import { AppConversationStore, MAX_STORED_TEXT, transcriptEventId } from './AppConversationStore.js';
3
+ import { derivedEventId, isAppConversationKey, removeAuthorityTokens } from './appConversationText.js';
4
4
  import { appInjectionTag } from './appInboundDispatch.js';
5
5
  import { ledgerViewKey, verifyRecordedOrigin } from './AppRecordSignatures.js';
6
6
  import { AppMediaStore, DEFAULT_MAX_STORE_BYTES } from './AppMediaStore.js';
@@ -23,12 +23,78 @@ export const VOICE_TRANSCRIBE_DEADLINE_MS = 60_000;
23
23
  * server transcript on one line, or an honest note when there is none.
24
24
  */
25
25
  export function voiceLineWithTranscript(filePath, transcript) {
26
+ return `[voice:${filePath}] ${transcriptOneLine(transcript)}`;
27
+ }
28
+ /** A transcript on one line for a session: control characters removed; a long one says it was cut. */
29
+ export function transcriptOneLine(transcript) {
26
30
  const oneLine = transcript.replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim();
27
- return `[voice:${filePath}] ${oneLine ? Array.from(oneLine).slice(0, MAX_TRANSCRIPT_CHARS).join('') : '(no speech recognized)'}`;
31
+ if (!oneLine)
32
+ return '(no speech recognized)';
33
+ const chars = Array.from(oneLine);
34
+ if (chars.length <= MAX_TRANSCRIPT_CHARS)
35
+ return oneLine;
36
+ return `${chars.slice(0, MAX_TRANSCRIPT_CHARS).join('')} (transcript cut here; the full transcript is saved with the message)`;
28
37
  }
29
38
  export function voiceLineUnavailable(filePath, reason) {
30
39
  return `[voice:${filePath}] (transcription unavailable: ${reason}; the audio file is at that path)`;
31
40
  }
41
+ /**
42
+ * The line for a voice note whose server transcript was not ready by the deadline (§4.5.1). An app
43
+ * conversation gets the transcript later as one follow-up line; a Telegram or Slack conversation
44
+ * continued from the app keeps it with the message only (no follow-up path reaches those sessions).
45
+ */
46
+ export function voiceLinePending(filePath, followUp) {
47
+ return followUp
48
+ ? `[voice:${filePath}] (transcript not ready after 60 s; it follows in a separate line, and the audio file is at that path)`
49
+ : `[voice:${filePath}] (transcript not ready after 60 s; it is saved with the message if it finishes, and the audio file is at that path)`;
50
+ }
51
+ /** The follow-up line a late server transcript produces (§4.5.1), once per attachment. */
52
+ export function lateTranscriptLine(filePath, outcome, caption) {
53
+ const forMsg = caption ? ` (for the message "${caption}")` : '';
54
+ return outcome.ok
55
+ ? `[voice transcript ready: ${filePath}]${forMsg} ${transcriptOneLine(transcriptForDelivery(outcome.text))}`
56
+ : `[voice transcript unavailable: ${filePath}]${forMsg} (transcription unavailable: ${outcome.reason}; the audio file is at that path)`;
57
+ }
58
+ /**
59
+ * How long the provider itself may take (§4.5.1): past the 60 s dispatch deadline the transcript
60
+ * is still wanted, as a late transcript. Bounded: one call per attachment, aborted at this limit.
61
+ */
62
+ export const VOICE_TRANSCRIBE_PROVIDER_TIMEOUT_MS = 10 * 60_000;
63
+ const CUT_NOTE = ' … (transcript cut here; the audio file has the rest)';
64
+ /**
65
+ * A server transcript gets the phone transcript's treatment before it is stored or delivered
66
+ * (§4.3, §4.5.1): `scanInbound` first (so a dictated pending pairing code declines that pairing),
67
+ * then exact-token removal. Text past the store's limit is cut with a note, never refused.
68
+ */
69
+ export function scrubServerTranscript(text, scan) {
70
+ try {
71
+ scan?.(text);
72
+ }
73
+ catch { /* @silent-fallback-ok — the scan only declines leaked pairings; token removal still runs */ }
74
+ const removed = removeAuthorityTokens(text);
75
+ let out = removed.text;
76
+ if (out.length > MAX_STORED_TEXT)
77
+ out = out.slice(0, MAX_STORED_TEXT - CUT_NOTE.length).replace(/[\uD800-\uDBFF]$/, '') + CUT_NOTE;
78
+ return { text: out, kinds: removed.kinds };
79
+ }
80
+ /** At most this many server transcriptions call the provider at once, per process (§4.5.1). */
81
+ export const VOICE_TRANSCRIBE_CONCURRENCY = 3;
82
+ /** A transcription still queued for a provider slot when its ceiling passed. */
83
+ class TranscriptionQueueTimeout extends Error {
84
+ constructor() { super('timed out waiting for a transcription slot'); }
85
+ }
86
+ /** A provider call still running when the ceiling (queue time included) passed. */
87
+ class TranscriptionCeilingTimeout extends Error {
88
+ constructor() { super('the transcription provider ran past the ceiling'); }
89
+ }
90
+ /**
91
+ * A stored transcript as it may be handed to a session or a device: the exact-token removal runs
92
+ * again on READ, so a transcript a peer stored without scrubbing (an older or misbehaving build)
93
+ * can never carry a pairing invite or a desktop code anywhere (second-pass review, finding 2).
94
+ */
95
+ export function transcriptForDelivery(text) {
96
+ return removeAuthorityTokens(text).text;
97
+ }
32
98
  const MEDIA_SWEEP_MS = 60 * 60_000;
33
99
  const MEDIA_REFUSAL_SWEEP_GAP_MS = 5 * 60_000;
34
100
  export class AppConversationService {
@@ -52,6 +118,12 @@ export class AppConversationService {
52
118
  /** Conditions whose notice is raised: the latch that makes it one notice per episode. */
53
119
  raisedNotices = new Set();
54
120
  now;
121
+ /** Server transcriptions running in this process, by attachment (one per attachment, §4.5.1). */
122
+ transcriptions = new Map();
123
+ /** Provider slots in use, and the calls queued for one (a bounded, per-process limit). */
124
+ transcribeActive = 0;
125
+ transcribeWaiters = [];
126
+ inboundScan = null;
55
127
  /** Increment 3 fills this in (push without Telegram); increment 1 records the hook only. */
56
128
  pushHook = null;
57
129
  constructor(deps) {
@@ -227,7 +299,7 @@ export class AppConversationService {
227
299
  return null;
228
300
  });
229
301
  if (copy) {
230
- lines.push(a.kind === 'voice' && !a.transcript ? this.voiceLine(copy, a.mediaType) : sessionLine(a, copy));
302
+ lines.push(a.kind === 'voice' && !a.transcript ? this.voiceLine(conversationKey, messageId, a, copy, caption) : sessionLine(a, copy));
231
303
  continue;
232
304
  }
233
305
  // Held here but the copy failed: not a copying problem (reported above).
@@ -247,36 +319,207 @@ export class AppConversationService {
247
319
  }
248
320
  return Promise.all(lines);
249
321
  }
322
+ /** Wired where the pairing service is: declines a pending pairing whose code a transcript dictates. */
323
+ setInboundScan(fn) { this.inboundScan = fn; }
324
+ /** The session line for a voice note the phone did not transcribe (§4.5.1). Never rejects. */
325
+ async voiceLine(conversationKey, messageId, a, copy, caption) {
326
+ const r = await this.voiceResult(conversationKey, messageId, a, copy, caption);
327
+ if (r === 'pending')
328
+ return voiceLinePending(copy, isAppConversationKey(conversationKey));
329
+ return r.ok ? voiceLineWithTranscript(copy, r.text) : voiceLineUnavailable(copy, r.reason);
330
+ }
250
331
  /**
251
- * Transcribe one voice note with the shared Telegram transcriber, bounded by the 60 s deadline.
252
- * Never rejects: no provider, a failure or the deadline all become an honest line with the path.
332
+ * The server transcript of one voice attachment, waiting at most the 60 s dispatch deadline.
333
+ * `pending` means it is still running: when it settles, a late transcript event is stored and
334
+ * one follow-up line is delivered (app conversations). Never rejects.
253
335
  */
254
- async voiceLine(copy, mediaType) {
255
- const transcribe = this.deps.transcribeVoice;
256
- if (!transcribe)
257
- return voiceLineUnavailable(copy, 'no voice transcription on this machine');
336
+ async voiceResult(conversationKey, messageId, a, copy, caption) {
337
+ const t = this.transcription(conversationKey, messageId, a, copy);
338
+ if (!('entry' in t))
339
+ return t.now;
340
+ const entry = t.entry;
258
341
  const deadlineMs = this.deps.transcribeDeadlineMs ?? VOICE_TRANSCRIBE_DEADLINE_MS;
259
342
  let timer = null;
260
343
  const deadline = new Promise((resolve) => { timer = setTimeout(() => resolve('deadline'), deadlineMs); });
261
344
  try {
262
- const out = await Promise.race([transcribe(copy, mediaType), deadline]);
263
- if (out === 'deadline') {
264
- this.report('voice transcription', new Error(`no transcript within ${Math.round(deadlineMs / 1000)} s`));
265
- return voiceLineUnavailable(copy, 'transcription took too long');
345
+ const out = await Promise.race([entry.done, deadline]);
346
+ if (out !== 'deadline')
347
+ return out;
348
+ entry.late = true;
349
+ this.report('voice transcription', new Error(`no transcript within ${Math.round(deadlineMs / 1000)} s; it is kept as a late transcript when it finishes`));
350
+ if (!entry.lateArmed) {
351
+ entry.lateArmed = true;
352
+ void entry.done.then((o) => this.deliverLate(conversationKey, a, copy, captionSnippet(caption), o));
266
353
  }
267
- return voiceLineWithTranscript(copy, out);
354
+ return 'pending';
268
355
  }
269
- catch (err) {
356
+ finally {
357
+ if (timer)
358
+ clearTimeout(timer);
359
+ }
360
+ }
361
+ /**
362
+ * One server transcription per attachment on this machine, ever (§4.5.1, bounded): a stored
363
+ * transcript (made here or replicated from a peer) is reused; one already running is joined; a
364
+ * recorded failure is reported with its reason and never retried; a run a restart interrupted
365
+ * reads as a failure. Only an attachment with no row starts the provider.
366
+ */
367
+ transcription(conversationKey, messageId, a, copy) {
368
+ const stored = this.store.transcriptFor(a.attachmentId);
369
+ if (stored)
370
+ return { now: { ok: true, text: transcriptForDelivery(stored.text) } };
371
+ const live = this.transcriptions.get(a.attachmentId);
372
+ if (live)
373
+ return { entry: live };
374
+ const transcribe = this.deps.transcribeVoice;
375
+ if (!transcribe)
376
+ return { now: { ok: false, reason: 'no voice transcription on this machine' } };
377
+ const db = this.media.indexDb;
378
+ const prior = db.prepare('SELECT state, reason FROM voice_transcriptions WHERE attachmentId = ?').get(a.attachmentId);
379
+ if (prior) {
380
+ if (prior.state === 'running') {
381
+ const reason = 'the server restarted before the transcript finished';
382
+ db.prepare(`UPDATE voice_transcriptions SET state = 'failed', reason = ?, finishedAt = ? WHERE attachmentId = ?`).run(reason, this.now(), a.attachmentId);
383
+ return { now: { ok: false, reason } };
384
+ }
385
+ return { now: { ok: false, reason: prior.reason ?? 'the transcription service failed' } };
386
+ }
387
+ const claimed = db.prepare(`INSERT OR IGNORE INTO voice_transcriptions (attachmentId, state, reason, startedAt, finishedAt) VALUES (?, 'running', NULL, ?, NULL)`).run(a.attachmentId, this.now());
388
+ if (claimed.changes !== 1)
389
+ return { now: { ok: false, reason: 'the transcription service failed' } };
390
+ const entry = { late: false, lateArmed: false, done: Promise.resolve({ ok: false, reason: 'not started' }) };
391
+ entry.done = this.limitedTranscribe(transcribe, copy, a.mediaType)
392
+ .then((raw) => this.storeTranscript(conversationKey, messageId, a, raw, entry.late), (err) => {
270
393
  if (err instanceof TranscriptionUnavailableError)
271
- return voiceLineUnavailable(copy, 'no transcription provider is configured');
394
+ return { ok: false, reason: 'no transcription provider is configured' };
272
395
  this.report('voice transcription', err);
273
- return voiceLineUnavailable(copy, 'the transcription service failed');
396
+ if (err instanceof TranscriptionQueueTimeout)
397
+ return { ok: false, reason: 'timed out waiting' };
398
+ if (err instanceof TranscriptionCeilingTimeout)
399
+ return { ok: false, reason: 'the transcription service timed out' };
400
+ return { ok: false, reason: 'the transcription service failed' };
401
+ })
402
+ .then((o) => {
403
+ try {
404
+ db.prepare('UPDATE voice_transcriptions SET state = ?, reason = ?, finishedAt = ? WHERE attachmentId = ?')
405
+ .run(o.ok ? 'done' : 'failed', o.ok ? null : o.reason, this.now(), a.attachmentId);
406
+ }
407
+ catch (err) {
408
+ this.report('voice transcription record', err);
409
+ } // @silent-fallback-ok — reported; a restart reads the row as interrupted, never retries
410
+ this.transcriptions.delete(a.attachmentId);
411
+ return o;
412
+ });
413
+ this.transcriptions.set(a.attachmentId, entry);
414
+ return { entry };
415
+ }
416
+ /**
417
+ * One provider call under the per-process limit (§4.5.1, second-pass finding 1). Queue time counts
418
+ * toward the ceiling: still queued at the ceiling ⇒ `TranscriptionQueueTimeout`; a running call
419
+ * gets the remaining time (passed to the provider as its own timeout) and is abandoned at the
420
+ * ceiling. The slot is held until the provider call itself settles, so at most N ever run.
421
+ */
422
+ async limitedTranscribe(transcribe, copy, mediaType) {
423
+ const ceilingMs = this.deps.transcribeCeilingMs ?? VOICE_TRANSCRIBE_PROVIDER_TIMEOUT_MS;
424
+ const startedAt = Date.now();
425
+ if (!(await this.acquireTranscribeSlot(ceilingMs)))
426
+ throw new TranscriptionQueueTimeout();
427
+ const remaining = Math.max(1, ceilingMs - (Date.now() - startedAt));
428
+ const run = Promise.resolve().then(() => transcribe(copy, mediaType, remaining));
429
+ void run.then(() => this.releaseTranscribeSlot(), () => this.releaseTranscribeSlot());
430
+ let timer = null;
431
+ const ceiling = new Promise((_r, reject) => { timer = setTimeout(() => reject(new TranscriptionCeilingTimeout()), remaining); });
432
+ try {
433
+ return await Promise.race([run, ceiling]);
274
434
  }
275
435
  finally {
276
436
  if (timer)
277
437
  clearTimeout(timer);
278
438
  }
279
439
  }
440
+ /** A provider slot, waiting at most `waitMs`; false when the wait ran out. */
441
+ acquireTranscribeSlot(waitMs) {
442
+ const limit = Math.max(1, this.deps.transcribeConcurrency ?? VOICE_TRANSCRIBE_CONCURRENCY);
443
+ if (this.transcribeActive < limit) {
444
+ this.transcribeActive++;
445
+ return Promise.resolve(true);
446
+ }
447
+ return new Promise((resolve) => {
448
+ const waiter = () => { clearTimeout(timer); resolve(true); };
449
+ const timer = setTimeout(() => {
450
+ const i = this.transcribeWaiters.indexOf(waiter);
451
+ if (i >= 0)
452
+ this.transcribeWaiters.splice(i, 1);
453
+ resolve(false);
454
+ }, waitMs);
455
+ timer.unref?.();
456
+ this.transcribeWaiters.push(waiter);
457
+ });
458
+ }
459
+ /** Hand the slot to the next queued call, or free it. */
460
+ releaseTranscribeSlot() {
461
+ const next = this.transcribeWaiters.shift();
462
+ if (next) {
463
+ next();
464
+ return;
465
+ } // the slot passes on; the active count is unchanged
466
+ this.transcribeActive = Math.max(0, this.transcribeActive - 1);
467
+ }
468
+ /** Scrub, then store the transcript as its own unsigned event (§4.5.1). The session gets it either way. */
469
+ storeTranscript(conversationKey, messageId, a, raw, late) {
470
+ const scrubbed = scrubServerTranscript(raw, this.inboundScan);
471
+ const machineId = this.deps.machineId();
472
+ const provider = (this.deps.voiceProviderName?.() ?? 'server').toLowerCase();
473
+ try {
474
+ this.store.append({
475
+ eventId: transcriptEventId(a.attachmentId, machineId), kind: 'transcript', conversationKey, messageId,
476
+ attachmentId: a.attachmentId, text: scrubbed.text, provider: /^[a-z0-9][a-z0-9._-]{0,31}$/.test(provider) ? provider : 'server',
477
+ at: new Date(this.now()).toISOString(), machineId, ...(late ? { late: true } : {}),
478
+ });
479
+ }
480
+ catch (err) {
481
+ this.report('voice transcript store', err);
482
+ } // @silent-fallback-ok — reported; the session still receives the transcript
483
+ return { ok: true, text: scrubbed.text };
484
+ }
485
+ /** A late transcript (or its failure): one follow-up line, or a note for the next delivery. */
486
+ deliverLate(conversationKey, a, copy, caption, outcome) {
487
+ try {
488
+ if (!isAppConversationKey(conversationKey))
489
+ return; // kept with the message; no follow-up path reaches platform sessions
490
+ const line = lateTranscriptLine(copy, outcome, caption);
491
+ const alias = this.deps.registry.appAliasFor(conversationKey);
492
+ const title = this.store.getConversation(conversationKey)?.title ?? 'Untitled';
493
+ const sent = alias !== null && this.ownsAlias(alias)
494
+ && this.followUp?.(alias, `${appInjectionTag(alias, title, this.ownerName(), 'none')} ${line}`) === true;
495
+ // A "now available" row still waiting for a delivery will carry the stored transcript itself
496
+ // (pendingContext), so a separate note would say it twice (second-pass finding 3).
497
+ const carried = !!this.media.indexDb.prepare(`SELECT 1 FROM pending_deliveries WHERE attachmentId = ? AND state = 'ready'`).get(a.attachmentId);
498
+ if (sent && outcome.ok)
499
+ this.markTranscriptDelivered(a.attachmentId);
500
+ if (!sent && !(outcome.ok && carried)) {
501
+ this.media.indexDb.prepare('INSERT OR IGNORE INTO transcript_notes (attachmentId, conversationKey, line, createdAt) VALUES (?, ?, ?, ?)')
502
+ .run(a.attachmentId, conversationKey, line, this.now());
503
+ }
504
+ }
505
+ catch (err) {
506
+ this.report('late transcript', err);
507
+ }
508
+ }
509
+ /** The transcript reached the session already: no later line may repeat it (second-pass finding 3). */
510
+ markTranscriptDelivered(attachmentId) {
511
+ try {
512
+ this.media.indexDb.prepare(`INSERT INTO voice_transcriptions (attachmentId, state, reason, startedAt, finishedAt, delivered) VALUES (?, 'done', NULL, ?, ?, 1) ON CONFLICT(attachmentId) DO UPDATE SET delivered = 1`)
513
+ .run(attachmentId, this.now(), this.now());
514
+ }
515
+ catch (err) {
516
+ this.report('transcript delivered mark', err);
517
+ } // @silent-fallback-ok — reported; at worst the transcript is repeated once
518
+ }
519
+ transcriptDelivered(attachmentId) {
520
+ const r = this.media.indexDb.prepare('SELECT delivered FROM voice_transcriptions WHERE attachmentId = ?').get(attachmentId);
521
+ return r?.delivered === 1;
522
+ }
280
523
  /** A blob landed: each waiting row gets its session copy and one follow-up line. */
281
524
  async resolvePending(sha) {
282
525
  const rows = this.media.indexDb.prepare(`SELECT * FROM pending_deliveries WHERE sha256 = ? AND state = 'waiting'`).all(sha);
@@ -290,12 +533,28 @@ export class AppConversationService {
290
533
  const claimed = this.media.indexDb.prepare(`UPDATE pending_deliveries SET state = 'ready', path = ? WHERE attachmentId = ? AND state = 'waiting'`).run(copy, r.attachmentId);
291
534
  if (claimed.changes !== 1)
292
535
  continue;
536
+ // A voice note the phone did not transcribe is transcribed now that it is here (§4.5.1):
537
+ // the transcript (or an honest reason) rides on the same line; past 60 s it follows later.
538
+ let tail = '';
539
+ let carriesTranscript = false;
540
+ if (a && a.kind === 'voice' && !a.transcript) {
541
+ const v = await this.voiceResult(r.conversationKey, r.messageId, a, copy, r.caption);
542
+ carriesTranscript = v !== 'pending' && v.ok;
543
+ tail = v === 'pending' ? ' (transcript not ready after 60 s; it follows in a separate line)'
544
+ : v.ok ? ` ${transcriptOneLine(transcriptForDelivery(v.text))}` : ` (transcription unavailable: ${v.reason})`;
545
+ }
293
546
  const alias = this.deps.registry.appAliasFor(r.conversationKey);
294
547
  const title = this.store.getConversation(r.conversationKey)?.title ?? 'Untitled';
295
548
  const sent = alias !== null && this.ownsAlias(alias)
296
- && this.followUp?.(alias, `${appInjectionTag(alias, title, this.ownerName(), 'none')} ${availableLine(copy, r.caption)}`) === true;
297
- if (sent)
549
+ && this.followUp?.(alias, `${appInjectionTag(alias, title, this.ownerName(), 'none')} ${availableLine(copy, r.caption)}${tail}`) === true;
550
+ if (sent) {
298
551
  this.media.indexDb.prepare('DELETE FROM pending_deliveries WHERE attachmentId = ?').run(r.attachmentId);
552
+ // The transcript rode on this line: a note for it would repeat it (second-pass finding 3).
553
+ if (carriesTranscript)
554
+ this.markTranscriptDelivered(r.attachmentId);
555
+ if (carriesTranscript)
556
+ this.media.indexDb.prepare('DELETE FROM transcript_notes WHERE attachmentId = ?').run(r.attachmentId);
557
+ }
299
558
  }
300
559
  catch (err) {
301
560
  this.report('pending resolve', err);
@@ -308,11 +567,31 @@ export class AppConversationService {
308
567
  */
309
568
  pendingContext(conversationKey) {
310
569
  const rows = this.media.indexDb.prepare(`SELECT * FROM pending_deliveries WHERE conversationKey = ? AND state IN ('ready','expired') ORDER BY createdAt`).all(conversationKey);
311
- const lines = rows.map((r) => (r.state === 'ready' && r.path ? availableLine(r.path, r.caption) : `(a file from ${new Date(r.createdAt).toISOString().slice(0, 10)}, ${r.displayName}, could not be copied to this machine)`));
570
+ const carried = new Set();
571
+ const lines = rows.map((r) => {
572
+ if (!(r.state === 'ready' && r.path))
573
+ return `(a file from ${new Date(r.createdAt).toISOString().slice(0, 10)}, ${r.displayName}, could not be copied to this machine)`;
574
+ // A voice note's stored server transcript rides on its line (§4.5.1), scrubbed on read.
575
+ const stored = this.store.transcriptFor(r.attachmentId);
576
+ const t = stored && !this.transcriptDelivered(r.attachmentId) ? stored : null;
577
+ if (stored)
578
+ carried.add(r.attachmentId);
579
+ return `${availableLine(r.path, r.caption)}${t ? ` ${transcriptOneLine(transcriptForDelivery(t.text))}` : ''}`;
580
+ });
581
+ // Late transcripts that found no live session (§4.5.1), oldest first — except one whose
582
+ // transcript the "now available" line above already carries (said once, never twice).
583
+ const notes = this.media.indexDb.prepare('SELECT attachmentId, line FROM transcript_notes WHERE conversationKey = ? ORDER BY createdAt').all(conversationKey);
584
+ for (const n of notes)
585
+ if (!carried.has(n.attachmentId))
586
+ lines.push(transcriptForDelivery(n.line));
312
587
  return {
313
588
  lines,
314
- ack: () => { for (const r of rows)
315
- this.media.indexDb.prepare('DELETE FROM pending_deliveries WHERE attachmentId = ? AND state = ?').run(r.attachmentId, r.state); },
589
+ ack: () => {
590
+ for (const r of rows)
591
+ this.media.indexDb.prepare('DELETE FROM pending_deliveries WHERE attachmentId = ? AND state = ?').run(r.attachmentId, r.state);
592
+ for (const n of notes)
593
+ this.media.indexDb.prepare('DELETE FROM transcript_notes WHERE attachmentId = ?').run(n.attachmentId);
594
+ },
316
595
  };
317
596
  }
318
597
  /** Waiting rows older than 7 days become a line in the session's next context (§4.12). */
@@ -323,6 +602,16 @@ export class AppConversationService {
323
602
  catch (err) {
324
603
  this.report('pending expiry', err);
325
604
  }
605
+ // Bounded bookkeeping (§4.5.1): late-transcript notes no delivery picked up within 7 days (the
606
+ // transcript itself stays in the store and in history), and finished attempt rows after 30
607
+ // days (by then no dispatch re-reads them; a stored transcript is found in the store first).
608
+ try {
609
+ this.media.indexDb.prepare('DELETE FROM transcript_notes WHERE createdAt <= ?').run(this.now() - PENDING_DELIVERY_TTL_MS);
610
+ this.media.indexDb.prepare(`DELETE FROM voice_transcriptions WHERE state IN ('done','failed') AND COALESCE(finishedAt, startedAt) <= ?`).run(this.now() - 30 * 24 * 60 * 60_000);
611
+ }
612
+ catch (err) {
613
+ this.report('transcript bookkeeping', err);
614
+ }
326
615
  }
327
616
  /**
328
617
  * The §4.6 verdict for a stored message, against THIS machine's ledger and key history.