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.
- package/dist/commands/server.d.ts.map +1 -1
- package/dist/commands/server.js +4 -2
- package/dist/commands/server.js.map +1 -1
- package/dist/core/AppConversationService.d.ts +89 -4
- package/dist/core/AppConversationService.d.ts.map +1 -1
- package/dist/core/AppConversationService.js +312 -23
- package/dist/core/AppConversationService.js.map +1 -1
- package/dist/core/AppConversationStore.d.ts +21 -2
- package/dist/core/AppConversationStore.d.ts.map +1 -1
- package/dist/core/AppConversationStore.js +38 -1
- package/dist/core/AppConversationStore.js.map +1 -1
- package/dist/core/AppConversationsReplicatedStore.js +1 -1
- package/dist/core/AppConversationsReplicatedStore.js.map +1 -1
- package/dist/core/AppMediaStore.d.ts.map +1 -1
- package/dist/core/AppMediaStore.js +7 -0
- package/dist/core/AppMediaStore.js.map +1 -1
- package/dist/core/PostUpdateMigrator.d.ts +3 -1
- package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
- package/dist/core/PostUpdateMigrator.js +28 -7
- package/dist/core/PostUpdateMigrator.js.map +1 -1
- package/dist/data/standards-guard-index.json +1 -1
- package/dist/data/standards-guard-index.meta.json +2 -2
- package/dist/data/standards-registry.meta.json +1 -1
- package/dist/feedback-factory/execute/FeedbackExecutorService.d.ts.map +1 -1
- package/dist/feedback-factory/execute/FeedbackExecutorService.js +13 -3
- package/dist/feedback-factory/execute/FeedbackExecutorService.js.map +1 -1
- package/dist/feedback-factory/execute/reviewGate.d.ts +3 -2
- package/dist/feedback-factory/execute/reviewGate.d.ts.map +1 -1
- package/dist/feedback-factory/execute/reviewGate.js +3 -2
- package/dist/feedback-factory/execute/reviewGate.js.map +1 -1
- package/dist/monitoring/GreenPrAutoMerger.d.ts.map +1 -1
- package/dist/monitoring/GreenPrAutoMerger.js +11 -1
- package/dist/monitoring/GreenPrAutoMerger.js.map +1 -1
- package/dist/monitoring/MergeRunner.d.ts +4 -5
- package/dist/monitoring/MergeRunner.d.ts.map +1 -1
- package/dist/monitoring/MergeRunner.js +2 -2
- package/dist/monitoring/MergeRunner.js.map +1 -1
- package/dist/native/instar-desktop-host +0 -0
- package/dist/scaffold/templates.js +1 -1
- package/dist/server/AgentServer.d.ts.map +1 -1
- package/dist/server/AgentServer.js +8 -3
- package/dist/server/AgentServer.js.map +1 -1
- package/dist/server/mobileConversationRoutes.d.ts +6 -1
- package/dist/server/mobileConversationRoutes.d.ts.map +1 -1
- package/dist/server/mobileConversationRoutes.js +16 -2
- package/dist/server/mobileConversationRoutes.js.map +1 -1
- package/package.json +1 -1
- package/scripts/safe-merge.mjs +65 -67
- package/src/data/builtin-manifest.json +20 -20
- package/src/data/standards-guard-index.json +1 -1
- package/src/data/standards-guard-index.meta.json +2 -2
- package/src/data/standards-registry.meta.json +1 -1
- package/src/scaffold/templates.ts +1 -1
- package/upgrades/1.3.1622.md +21 -0
- package/upgrades/1.3.1623.md +34 -0
- package/upgrades/side-effects/feedback-ci-floor-merge.md +82 -0
- package/upgrades/side-effects/voice-transcript-event.eli16.md +17 -0
- 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
|
-
*
|
|
139
|
-
*
|
|
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
|
|
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":"AAAA;;;;;;;;;;;;;GAaG;AACH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAC;AACtE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,MAAM,+CAA+C,CAAC;AACvF,OAAO,EAAE,oBAAoB,
|
|
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
|
-
|
|
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,
|
|
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
|
-
*
|
|
252
|
-
*
|
|
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
|
|
255
|
-
const
|
|
256
|
-
if (!
|
|
257
|
-
return
|
|
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([
|
|
263
|
-
if (out
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
354
|
+
return 'pending';
|
|
268
355
|
}
|
|
269
|
-
|
|
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
|
|
394
|
+
return { ok: false, reason: 'no transcription provider is configured' };
|
|
272
395
|
this.report('voice transcription', err);
|
|
273
|
-
|
|
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
|
|
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: () => {
|
|
315
|
-
|
|
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.
|