@aithos/sdk 0.1.0-alpha.42 → 0.1.0-alpha.44
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/README.md +34 -0
- package/dist/src/compute.d.ts +218 -0
- package/dist/src/compute.js +457 -0
- package/dist/src/ethos.js +72 -20
- package/dist/src/index.d.ts +4 -2
- package/dist/src/index.js +2 -1
- package/dist/src/react/index.d.ts +1 -0
- package/dist/src/react/index.js +1 -0
- package/dist/src/react/use-transcribe-pending.d.ts +21 -0
- package/dist/src/react/use-transcribe-pending.js +47 -0
- package/dist/src/transcribe-resilience.d.ts +57 -0
- package/dist/src/transcribe-resilience.js +203 -0
- package/dist/test/ethos-first-edition.test.js +125 -2
- package/dist/test/transcribe-invoke.test.d.ts +2 -0
- package/dist/test/transcribe-invoke.test.js +204 -0
- package/dist/test/transcribe.test.d.ts +2 -0
- package/dist/test/transcribe.test.js +186 -0
- package/package.json +14 -12
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ComputeNamespace, InvokeTranscribeResult, TranscribeProgressState } from "../compute.js";
|
|
2
|
+
import type { LocalPendingEntry } from "../transcribe-resilience.js";
|
|
3
|
+
export interface UseAithosTranscribePendingJobs {
|
|
4
|
+
/** Locally-tracked in-flight jobs (survives reloads via localStorage). */
|
|
5
|
+
readonly pending: readonly LocalPendingEntry[];
|
|
6
|
+
/** Resume polling a job by id; resolves with the final transcript. */
|
|
7
|
+
readonly resume: (jobId: string, opts?: {
|
|
8
|
+
readonly mandateId?: string;
|
|
9
|
+
readonly onProgress?: (state: TranscribeProgressState) => void;
|
|
10
|
+
readonly signal?: AbortSignal;
|
|
11
|
+
readonly pollIntervalMs?: number;
|
|
12
|
+
}) => Promise<InvokeTranscribeResult>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Subscribe a React component to the SDK's local pending-transcription
|
|
16
|
+
* registry. Re-renders whenever a job is added, advances, or clears.
|
|
17
|
+
*
|
|
18
|
+
* @param compute the SDK compute namespace (`sdk.compute`).
|
|
19
|
+
*/
|
|
20
|
+
export declare function useAithosTranscribePendingJobs(compute: ComputeNamespace): UseAithosTranscribePendingJobs;
|
|
21
|
+
//# sourceMappingURL=use-transcribe-pending.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
/**
|
|
4
|
+
* `useAithosTranscribePendingJobs(sdk.compute)` — a thin React adapter over
|
|
5
|
+
* the framework-agnostic local pending-jobs tracker exposed by the compute
|
|
6
|
+
* namespace. The tracker itself (subscribe/getSnapshot) is plain vanilla JS
|
|
7
|
+
* and works with any framework; this hook just wires it into React's
|
|
8
|
+
* `useSyncExternalStore`. Vue/Svelte/vanilla users can call
|
|
9
|
+
* `sdk.compute.subscribeLocalPendingTranscribes` directly.
|
|
10
|
+
*
|
|
11
|
+
* function PendingBanner({ sdk }) {
|
|
12
|
+
* const { pending, resume } = useAithosTranscribePendingJobs(sdk.compute);
|
|
13
|
+
* if (pending.length === 0) return null;
|
|
14
|
+
* return (
|
|
15
|
+
* <div>
|
|
16
|
+
* {pending.map((p) => (
|
|
17
|
+
* <button key={p.jobId} onClick={() => resume(p.jobId)}>
|
|
18
|
+
* Resume {p.jobId} ({p.status})
|
|
19
|
+
* </button>
|
|
20
|
+
* ))}
|
|
21
|
+
* </div>
|
|
22
|
+
* );
|
|
23
|
+
* }
|
|
24
|
+
*/
|
|
25
|
+
import { useCallback, useEffect, useState } from "react";
|
|
26
|
+
/**
|
|
27
|
+
* Subscribe a React component to the SDK's local pending-transcription
|
|
28
|
+
* registry. Re-renders whenever a job is added, advances, or clears.
|
|
29
|
+
*
|
|
30
|
+
* @param compute the SDK compute namespace (`sdk.compute`).
|
|
31
|
+
*/
|
|
32
|
+
export function useAithosTranscribePendingJobs(compute) {
|
|
33
|
+
const [pending, setPending] = useState(() => compute.getLocalPendingTranscribesSnapshot());
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
// Sync immediately (the snapshot may have changed before mount) then
|
|
36
|
+
// subscribe. getSnapshot returns a stable reference between mutations,
|
|
37
|
+
// so identical states bail out of a re-render.
|
|
38
|
+
setPending(compute.getLocalPendingTranscribesSnapshot());
|
|
39
|
+
const unsubscribe = compute.subscribeLocalPendingTranscribes(() => {
|
|
40
|
+
setPending(compute.getLocalPendingTranscribesSnapshot());
|
|
41
|
+
});
|
|
42
|
+
return unsubscribe;
|
|
43
|
+
}, [compute]);
|
|
44
|
+
const resume = useCallback((jobId, opts) => compute.resumeTranscribe(jobId, opts), [compute]);
|
|
45
|
+
return { pending, resume };
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=use-transcribe-pending.js.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type LocalPendingStatus = "uploading" | "running" | "completed" | "failed";
|
|
2
|
+
export interface LocalPendingEntry {
|
|
3
|
+
readonly jobId: string;
|
|
4
|
+
readonly status: LocalPendingStatus;
|
|
5
|
+
readonly createdAt: number;
|
|
6
|
+
readonly updatedAt: number;
|
|
7
|
+
readonly meta?: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Framework-agnostic observable registry of in-flight transcription jobs.
|
|
11
|
+
* Persisted to localStorage when available (so it survives reloads), with
|
|
12
|
+
* an in-memory fallback otherwise. Subscribe with `subscribe(listener)`;
|
|
13
|
+
* read with `getSnapshot()` (stable reference between mutations, so it
|
|
14
|
+
* plugs directly into React's `useSyncExternalStore`).
|
|
15
|
+
*/
|
|
16
|
+
export declare class LocalPendingTranscribeTracker {
|
|
17
|
+
#private;
|
|
18
|
+
constructor();
|
|
19
|
+
/** Current entries. Stable reference until the next mutation. */
|
|
20
|
+
getSnapshot(): readonly LocalPendingEntry[];
|
|
21
|
+
list(): readonly LocalPendingEntry[];
|
|
22
|
+
/** Subscribe to changes. Returns an unsubscribe function. */
|
|
23
|
+
subscribe(listener: () => void): () => void;
|
|
24
|
+
upsert(jobId: string, status: LocalPendingStatus, meta?: Record<string, unknown>): void;
|
|
25
|
+
remove(jobId: string): void;
|
|
26
|
+
clear(): void;
|
|
27
|
+
}
|
|
28
|
+
export interface TranscribeDraftMeta {
|
|
29
|
+
readonly title?: string;
|
|
30
|
+
readonly tag?: string;
|
|
31
|
+
readonly contentType?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface TranscribeDraftRecord {
|
|
34
|
+
readonly draftId: string;
|
|
35
|
+
readonly blob: Blob;
|
|
36
|
+
readonly metadata: TranscribeDraftMeta;
|
|
37
|
+
readonly createdAt: number;
|
|
38
|
+
}
|
|
39
|
+
export declare class TranscribeDraftUnavailableError extends Error {
|
|
40
|
+
constructor();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* IndexedDB-backed queue of recorded audio Blobs. Save a recording the
|
|
44
|
+
* instant it finishes (before any network), then `upload` it when the
|
|
45
|
+
* user confirms — so a flaky network or a closed tab never loses audio.
|
|
46
|
+
* Browser-only: methods reject with {@link TranscribeDraftUnavailableError}
|
|
47
|
+
* when IndexedDB is absent.
|
|
48
|
+
*/
|
|
49
|
+
export declare class TranscribeDraftStore {
|
|
50
|
+
save(blob: Blob, meta?: TranscribeDraftMeta): Promise<{
|
|
51
|
+
readonly draftId: string;
|
|
52
|
+
}>;
|
|
53
|
+
list(): Promise<readonly TranscribeDraftRecord[]>;
|
|
54
|
+
get(draftId: string): Promise<TranscribeDraftRecord | null>;
|
|
55
|
+
delete(draftId: string): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=transcribe-resilience.d.ts.map
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
const LS_KEY = "aithos:transcribe:pending";
|
|
4
|
+
/**
|
|
5
|
+
* Framework-agnostic observable registry of in-flight transcription jobs.
|
|
6
|
+
* Persisted to localStorage when available (so it survives reloads), with
|
|
7
|
+
* an in-memory fallback otherwise. Subscribe with `subscribe(listener)`;
|
|
8
|
+
* read with `getSnapshot()` (stable reference between mutations, so it
|
|
9
|
+
* plugs directly into React's `useSyncExternalStore`).
|
|
10
|
+
*/
|
|
11
|
+
export class LocalPendingTranscribeTracker {
|
|
12
|
+
#listeners = new Set();
|
|
13
|
+
#mem = [];
|
|
14
|
+
#snapshot = [];
|
|
15
|
+
constructor() {
|
|
16
|
+
this.#snapshot = this.#read();
|
|
17
|
+
// Cross-tab sync: when another tab updates the key, re-emit.
|
|
18
|
+
try {
|
|
19
|
+
if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
|
|
20
|
+
window.addEventListener("storage", (e) => {
|
|
21
|
+
if (e.key === LS_KEY) {
|
|
22
|
+
this.#snapshot = this.#read();
|
|
23
|
+
this.#emit();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* no window — fine */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
#ls() {
|
|
33
|
+
try {
|
|
34
|
+
return typeof localStorage !== "undefined" ? localStorage : null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
#read() {
|
|
41
|
+
const ls = this.#ls();
|
|
42
|
+
if (!ls)
|
|
43
|
+
return [...this.#mem];
|
|
44
|
+
try {
|
|
45
|
+
const raw = ls.getItem(LS_KEY);
|
|
46
|
+
return raw ? JSON.parse(raw) : [];
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
#write(list) {
|
|
53
|
+
const ls = this.#ls();
|
|
54
|
+
if (ls) {
|
|
55
|
+
try {
|
|
56
|
+
ls.setItem(LS_KEY, JSON.stringify(list));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* quota / private mode — keep in-memory copy authoritative */
|
|
60
|
+
this.#mem = list;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
this.#mem = list;
|
|
65
|
+
}
|
|
66
|
+
this.#snapshot = list;
|
|
67
|
+
this.#emit();
|
|
68
|
+
}
|
|
69
|
+
#emit() {
|
|
70
|
+
for (const l of this.#listeners)
|
|
71
|
+
l();
|
|
72
|
+
}
|
|
73
|
+
/** Current entries. Stable reference until the next mutation. */
|
|
74
|
+
getSnapshot() {
|
|
75
|
+
return this.#snapshot;
|
|
76
|
+
}
|
|
77
|
+
list() {
|
|
78
|
+
this.#snapshot = this.#read();
|
|
79
|
+
return this.#snapshot;
|
|
80
|
+
}
|
|
81
|
+
/** Subscribe to changes. Returns an unsubscribe function. */
|
|
82
|
+
subscribe(listener) {
|
|
83
|
+
this.#listeners.add(listener);
|
|
84
|
+
return () => {
|
|
85
|
+
this.#listeners.delete(listener);
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
upsert(jobId, status, meta) {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
const list = this.#read();
|
|
91
|
+
const idx = list.findIndex((e) => e.jobId === jobId);
|
|
92
|
+
if (idx >= 0) {
|
|
93
|
+
const prev = list[idx];
|
|
94
|
+
list[idx] = {
|
|
95
|
+
...prev,
|
|
96
|
+
status,
|
|
97
|
+
updatedAt: now,
|
|
98
|
+
...(meta ? { meta: { ...prev.meta, ...meta } } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
list.push({
|
|
103
|
+
jobId,
|
|
104
|
+
status,
|
|
105
|
+
createdAt: now,
|
|
106
|
+
updatedAt: now,
|
|
107
|
+
...(meta ? { meta } : {}),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
this.#write(list);
|
|
111
|
+
}
|
|
112
|
+
remove(jobId) {
|
|
113
|
+
const list = this.#read().filter((e) => e.jobId !== jobId);
|
|
114
|
+
this.#write(list);
|
|
115
|
+
}
|
|
116
|
+
clear() {
|
|
117
|
+
this.#write([]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const DB_NAME = "aithos-transcribe";
|
|
121
|
+
const DB_VERSION = 1;
|
|
122
|
+
const STORE = "drafts";
|
|
123
|
+
export class TranscribeDraftUnavailableError extends Error {
|
|
124
|
+
constructor() {
|
|
125
|
+
super("IndexedDB is not available in this environment — the transcription draft queue is browser-only.");
|
|
126
|
+
this.name = "TranscribeDraftUnavailableError";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function getIndexedDB() {
|
|
130
|
+
try {
|
|
131
|
+
if (typeof indexedDB !== "undefined")
|
|
132
|
+
return indexedDB;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* fall through */
|
|
136
|
+
}
|
|
137
|
+
throw new TranscribeDraftUnavailableError();
|
|
138
|
+
}
|
|
139
|
+
function openDb() {
|
|
140
|
+
const idb = getIndexedDB();
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const req = idb.open(DB_NAME, DB_VERSION);
|
|
143
|
+
req.onupgradeneeded = () => {
|
|
144
|
+
const db = req.result;
|
|
145
|
+
if (!db.objectStoreNames.contains(STORE)) {
|
|
146
|
+
db.createObjectStore(STORE, { keyPath: "draftId" });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
req.onsuccess = () => resolve(req.result);
|
|
150
|
+
req.onerror = () => reject(req.error ?? new Error("indexedDB open failed"));
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function tx(db, mode, fn) {
|
|
154
|
+
return new Promise((resolve, reject) => {
|
|
155
|
+
const t = db.transaction(STORE, mode);
|
|
156
|
+
const req = fn(t.objectStore(STORE));
|
|
157
|
+
req.onsuccess = () => resolve(req.result);
|
|
158
|
+
req.onerror = () => reject(req.error ?? new Error("indexedDB request failed"));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* IndexedDB-backed queue of recorded audio Blobs. Save a recording the
|
|
163
|
+
* instant it finishes (before any network), then `upload` it when the
|
|
164
|
+
* user confirms — so a flaky network or a closed tab never loses audio.
|
|
165
|
+
* Browser-only: methods reject with {@link TranscribeDraftUnavailableError}
|
|
166
|
+
* when IndexedDB is absent.
|
|
167
|
+
*/
|
|
168
|
+
export class TranscribeDraftStore {
|
|
169
|
+
async save(blob, meta) {
|
|
170
|
+
const db = await openDb();
|
|
171
|
+
const draftId = `draft_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
172
|
+
const record = {
|
|
173
|
+
draftId,
|
|
174
|
+
blob,
|
|
175
|
+
metadata: {
|
|
176
|
+
...(meta ?? {}),
|
|
177
|
+
...(meta?.contentType ? {} : { contentType: blob.type || "audio/webm" }),
|
|
178
|
+
},
|
|
179
|
+
createdAt: Date.now(),
|
|
180
|
+
};
|
|
181
|
+
await tx(db, "readwrite", (s) => s.put(record));
|
|
182
|
+
db.close();
|
|
183
|
+
return { draftId };
|
|
184
|
+
}
|
|
185
|
+
async list() {
|
|
186
|
+
const db = await openDb();
|
|
187
|
+
const out = await tx(db, "readonly", (s) => s.getAll());
|
|
188
|
+
db.close();
|
|
189
|
+
return out.sort((a, b) => a.createdAt - b.createdAt);
|
|
190
|
+
}
|
|
191
|
+
async get(draftId) {
|
|
192
|
+
const db = await openDb();
|
|
193
|
+
const rec = await tx(db, "readonly", (s) => s.get(draftId));
|
|
194
|
+
db.close();
|
|
195
|
+
return rec ?? null;
|
|
196
|
+
}
|
|
197
|
+
async delete(draftId) {
|
|
198
|
+
const db = await openDb();
|
|
199
|
+
await tx(db, "readwrite", (s) => s.delete(draftId));
|
|
200
|
+
db.close();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
//# sourceMappingURL=transcribe-resilience.js.map
|
|
@@ -185,19 +185,142 @@ describe("EthosClient — fresh Ethos (no edition published yet)", () => {
|
|
|
185
185
|
assert.equal(env.method, "aithos.publish_ethos_edition");
|
|
186
186
|
assert.match(env.proof.verificationMethod, /#public$/);
|
|
187
187
|
});
|
|
188
|
-
it("publish()
|
|
188
|
+
it("publish() accepts circle mutations on first edition with auto-injected public sentinel", async () => {
|
|
189
|
+
// Regression target: this used to throw `ethos_first_edition_public_only`.
|
|
190
|
+
// Since aithos-sdk@0.1.0-alpha.43 + protocol-client@0.1.0-alpha.14, the
|
|
191
|
+
// SDK auto-injects an `aithos-init` public section and seals the circle
|
|
192
|
+
// sections in the same height=1 manifest.
|
|
193
|
+
let publishBody = null;
|
|
189
194
|
installFetchMock([
|
|
190
195
|
{
|
|
191
196
|
url: "/mcp/primitives/read",
|
|
192
197
|
rpcMethod: "aithos.get_ethos_manifest",
|
|
193
198
|
respond: noEditionYetResponse,
|
|
194
199
|
},
|
|
200
|
+
{
|
|
201
|
+
url: "/mcp/primitives/write",
|
|
202
|
+
rpcMethod: "aithos.publish_ethos_edition",
|
|
203
|
+
respond: (call) => {
|
|
204
|
+
publishBody = call.body;
|
|
205
|
+
return publishOkResponse();
|
|
206
|
+
},
|
|
207
|
+
},
|
|
195
208
|
]);
|
|
196
209
|
const auth = makeAuth();
|
|
197
210
|
await signInAsAlice(auth);
|
|
198
211
|
const me = makeNamespace(auth).me();
|
|
199
212
|
me.zone("circle").addSection({ title: "Private", body: "..." });
|
|
200
|
-
|
|
213
|
+
const r = await me.publish();
|
|
214
|
+
assert.equal(r.editionHeight, 1);
|
|
215
|
+
assert.deepEqual(r.zonesPublished, ["public", "circle"]);
|
|
216
|
+
const manifest = publishBody.params.manifest;
|
|
217
|
+
assert.equal(manifest.edition.height, 1);
|
|
218
|
+
assert.equal(manifest.edition.prev_hash, null);
|
|
219
|
+
// Auto-injected sentinel.
|
|
220
|
+
assert.deepEqual(manifest.zones.public.section_titles, ["aithos-init"]);
|
|
221
|
+
assert.equal(manifest.zones.public.encrypted, false);
|
|
222
|
+
// Sealed circle zone.
|
|
223
|
+
assert.deepEqual(manifest.zones.circle.section_titles, ["Private"]);
|
|
224
|
+
assert.equal(manifest.zones.circle.encrypted, true);
|
|
225
|
+
assert.ok(manifest.zones.circle.cipher, "circle cipher must be present");
|
|
226
|
+
// Both zones uploaded.
|
|
227
|
+
assert.ok(publishBody.params.zones.public?.bytes_base64);
|
|
228
|
+
assert.ok(publishBody.params.zones.circle?.bytes_base64);
|
|
229
|
+
assert.equal(publishBody.params.zones.self, undefined);
|
|
230
|
+
});
|
|
231
|
+
it("publish() preserves the caller's explicit public section when mixed with circle", async () => {
|
|
232
|
+
// When the caller stages BOTH a public and a circle add, the SDK
|
|
233
|
+
// must NOT inject a sentinel — the user's public section is enough.
|
|
234
|
+
let publishBody = null;
|
|
235
|
+
installFetchMock([
|
|
236
|
+
{
|
|
237
|
+
url: "/mcp/primitives/read",
|
|
238
|
+
rpcMethod: "aithos.get_ethos_manifest",
|
|
239
|
+
respond: noEditionYetResponse,
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
url: "/mcp/primitives/write",
|
|
243
|
+
rpcMethod: "aithos.publish_ethos_edition",
|
|
244
|
+
respond: (call) => {
|
|
245
|
+
publishBody = call.body;
|
|
246
|
+
return publishOkResponse();
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
]);
|
|
250
|
+
const auth = makeAuth();
|
|
251
|
+
await signInAsAlice(auth);
|
|
252
|
+
const me = makeNamespace(auth).me();
|
|
253
|
+
me.zone("public").addSection({ title: "About", body: "Public bio." });
|
|
254
|
+
me.zone("circle").addSection({ title: "Notes", body: "Private notes." });
|
|
255
|
+
const r = await me.publish();
|
|
256
|
+
assert.deepEqual(r.zonesPublished, ["public", "circle"]);
|
|
257
|
+
const manifest = publishBody.params.manifest;
|
|
258
|
+
// No sentinel — the user's section is the public titles entry.
|
|
259
|
+
assert.deepEqual(manifest.zones.public.section_titles, ["About"]);
|
|
260
|
+
assert.deepEqual(manifest.zones.circle.section_titles, ["Notes"]);
|
|
261
|
+
});
|
|
262
|
+
it("publish() auto-injects public sentinel when only self mutations are staged", async () => {
|
|
263
|
+
let publishBody = null;
|
|
264
|
+
installFetchMock([
|
|
265
|
+
{
|
|
266
|
+
url: "/mcp/primitives/read",
|
|
267
|
+
rpcMethod: "aithos.get_ethos_manifest",
|
|
268
|
+
respond: noEditionYetResponse,
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
url: "/mcp/primitives/write",
|
|
272
|
+
rpcMethod: "aithos.publish_ethos_edition",
|
|
273
|
+
respond: (call) => {
|
|
274
|
+
publishBody = call.body;
|
|
275
|
+
return publishOkResponse();
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
]);
|
|
279
|
+
const auth = makeAuth();
|
|
280
|
+
await signInAsAlice(auth);
|
|
281
|
+
const me = makeNamespace(auth).me();
|
|
282
|
+
me.zone("self").addSection({ title: "Journal", body: "Private." });
|
|
283
|
+
const r = await me.publish();
|
|
284
|
+
assert.deepEqual(r.zonesPublished, ["public", "self"]);
|
|
285
|
+
const manifest = publishBody.params.manifest;
|
|
286
|
+
assert.deepEqual(manifest.zones.public.section_titles, ["aithos-init"]);
|
|
287
|
+
assert.deepEqual(manifest.zones.self.section_titles, ["Journal"]);
|
|
288
|
+
assert.equal(manifest.zones.self.encrypted, true);
|
|
289
|
+
assert.equal(manifest.zones.circle, undefined);
|
|
290
|
+
});
|
|
291
|
+
it("publish() lands public + circle + self in a single height=1 edition", async () => {
|
|
292
|
+
let publishBody = null;
|
|
293
|
+
installFetchMock([
|
|
294
|
+
{
|
|
295
|
+
url: "/mcp/primitives/read",
|
|
296
|
+
rpcMethod: "aithos.get_ethos_manifest",
|
|
297
|
+
respond: noEditionYetResponse,
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
url: "/mcp/primitives/write",
|
|
301
|
+
rpcMethod: "aithos.publish_ethos_edition",
|
|
302
|
+
respond: (call) => {
|
|
303
|
+
publishBody = call.body;
|
|
304
|
+
return publishOkResponse();
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
]);
|
|
308
|
+
const auth = makeAuth();
|
|
309
|
+
await signInAsAlice(auth);
|
|
310
|
+
const me = makeNamespace(auth).me();
|
|
311
|
+
me.zone("public").addSection({ title: "Bio", body: "Bio body." });
|
|
312
|
+
me.zone("circle").addSection({ title: "Circle", body: "Circle body." });
|
|
313
|
+
me.zone("self").addSection({ title: "Self", body: "Self body." });
|
|
314
|
+
const r = await me.publish();
|
|
315
|
+
assert.equal(r.editionHeight, 1);
|
|
316
|
+
assert.deepEqual(r.zonesPublished, ["public", "circle", "self"]);
|
|
317
|
+
const manifest = publishBody.params.manifest;
|
|
318
|
+
assert.deepEqual(manifest.zones.public.section_titles, ["Bio"]);
|
|
319
|
+
assert.deepEqual(manifest.zones.circle.section_titles, ["Circle"]);
|
|
320
|
+
assert.deepEqual(manifest.zones.self.section_titles, ["Self"]);
|
|
321
|
+
assert.ok(publishBody.params.zones.public?.bytes_base64);
|
|
322
|
+
assert.ok(publishBody.params.zones.circle?.bytes_base64);
|
|
323
|
+
assert.ok(publishBody.params.zones.self?.bytes_base64);
|
|
201
324
|
});
|
|
202
325
|
it("publish() rejects update/delete operations on a fresh Ethos", async () => {
|
|
203
326
|
installFetchMock([
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright 2026 Mathieu Colla
|
|
3
|
+
// Tests for the high-level invokeTranscribe flow (prepare -> upload -> start
|
|
4
|
+
// -> poll) and the framework-agnostic resilience helpers (local pending
|
|
5
|
+
// tracker + IndexedDB draft store). fake-indexeddb/auto installs an
|
|
6
|
+
// in-memory IndexedDB for the draft tests.
|
|
7
|
+
import "fake-indexeddb/auto";
|
|
8
|
+
import { strict as assert } from "node:assert";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import { createBrowserIdentity } from "@aithos/protocol-client";
|
|
11
|
+
import { AithosAuth, AithosSDK, LocalPendingTranscribeTracker, TranscribeDraftStore, memoryKeyStore, noopStore, } from "../src/index.js";
|
|
12
|
+
import { serializeRecoveryFile } from "../src/internal/recovery-file.js";
|
|
13
|
+
const APP_DID = "did:aithos:app:test";
|
|
14
|
+
const UPLOAD_URL = "https://s3.example.test/upload?sig=abc";
|
|
15
|
+
async function makeSdk(fetchImpl) {
|
|
16
|
+
const id = createBrowserIdentity("test-handle", "Test User");
|
|
17
|
+
const auth = new AithosAuth({
|
|
18
|
+
authBaseUrl: "https://auth.test",
|
|
19
|
+
fetch: (() => {
|
|
20
|
+
throw new Error("auth not used");
|
|
21
|
+
}),
|
|
22
|
+
sessionStore: noopStore(),
|
|
23
|
+
keyStore: memoryKeyStore(),
|
|
24
|
+
});
|
|
25
|
+
const { text } = serializeRecoveryFile(id);
|
|
26
|
+
await auth.signInWithRecovery({ file: text });
|
|
27
|
+
return new AithosSDK({
|
|
28
|
+
auth,
|
|
29
|
+
appDid: APP_DID,
|
|
30
|
+
endpoints: { compute: "https://compute.example.test" },
|
|
31
|
+
fetch: fetchImpl,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function jsonRpc(result) {
|
|
35
|
+
return new Response(JSON.stringify({ result }), {
|
|
36
|
+
status: 200,
|
|
37
|
+
headers: { "content-type": "application/json" },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
describe("compute.invokeTranscribe — full flow", () => {
|
|
41
|
+
it("prepares, uploads to S3, starts, polls and returns the transcript", async () => {
|
|
42
|
+
const calls = [];
|
|
43
|
+
let putBytes = 0;
|
|
44
|
+
let statusPolls = 0;
|
|
45
|
+
const fetchImpl = async (input, init) => {
|
|
46
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
47
|
+
if (init?.method === "PUT" && url.startsWith("https://s3.example.test/")) {
|
|
48
|
+
// S3 upload.
|
|
49
|
+
calls.push("s3-put");
|
|
50
|
+
putBytes = init.body.size;
|
|
51
|
+
return new Response("", { status: 200 });
|
|
52
|
+
}
|
|
53
|
+
const method = JSON.parse(init?.body).method;
|
|
54
|
+
calls.push(method);
|
|
55
|
+
if (method === "aithos.compute_transcribe_prepare") {
|
|
56
|
+
return jsonRpc({
|
|
57
|
+
job_id: "tj_FLOW",
|
|
58
|
+
upload_url: UPLOAD_URL,
|
|
59
|
+
s3_object_key: "uploads/2026/05/tj_FLOW.webm",
|
|
60
|
+
expires_at: 1717000000,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (method === "aithos.compute_transcribe_start") {
|
|
64
|
+
return jsonRpc({
|
|
65
|
+
job_id: "tj_FLOW",
|
|
66
|
+
status: "running",
|
|
67
|
+
estimated_credits: 60,
|
|
68
|
+
walletBalance: 49_940,
|
|
69
|
+
fundedBy: "purchase",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (method === "aithos.compute_transcribe_status") {
|
|
73
|
+
statusPolls += 1;
|
|
74
|
+
if (statusPolls < 2) {
|
|
75
|
+
return jsonRpc({ job_id: "tj_FLOW", status: "running", elapsed_sec: 1 });
|
|
76
|
+
}
|
|
77
|
+
return jsonRpc({
|
|
78
|
+
job_id: "tj_FLOW",
|
|
79
|
+
status: "completed",
|
|
80
|
+
text: "Bonjour le monde",
|
|
81
|
+
segments: [{ start_sec: 0, end_sec: 1.4, text: "Bonjour le monde" }],
|
|
82
|
+
words: [{ start_sec: 0, end_sec: 0.4, content: "Bonjour", confidence: 0.99 }],
|
|
83
|
+
duration_sec_actual: 30,
|
|
84
|
+
language_code: "fr-FR",
|
|
85
|
+
creditsCharged: 60,
|
|
86
|
+
walletBalance: 49_940,
|
|
87
|
+
auditId: "audit_flow",
|
|
88
|
+
fundedBy: "purchase",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
throw new Error(`unexpected method ${method}`);
|
|
92
|
+
};
|
|
93
|
+
const sdk = await makeSdk(fetchImpl);
|
|
94
|
+
const phases = [];
|
|
95
|
+
const audio = new Blob([new Uint8Array(2048)], { type: "audio/webm" });
|
|
96
|
+
const result = await sdk.compute.invokeTranscribe({
|
|
97
|
+
audio,
|
|
98
|
+
durationSecOverride: 30,
|
|
99
|
+
pollIntervalMs: 1,
|
|
100
|
+
onProgress: (s) => phases.push(s.phase),
|
|
101
|
+
});
|
|
102
|
+
assert.equal(result.text, "Bonjour le monde");
|
|
103
|
+
assert.equal(result.durationSec, 30);
|
|
104
|
+
assert.equal(result.creditsCharged, 60);
|
|
105
|
+
assert.equal(result.jobId, "tj_FLOW");
|
|
106
|
+
assert.equal(result.fundedBy, "purchase");
|
|
107
|
+
assert.equal(result.segments.length, 1);
|
|
108
|
+
// The flow visited every step in order.
|
|
109
|
+
assert.deepEqual(calls, [
|
|
110
|
+
"aithos.compute_transcribe_prepare",
|
|
111
|
+
"s3-put",
|
|
112
|
+
"aithos.compute_transcribe_start",
|
|
113
|
+
"aithos.compute_transcribe_status",
|
|
114
|
+
"aithos.compute_transcribe_status",
|
|
115
|
+
]);
|
|
116
|
+
assert.equal(putBytes, 2048);
|
|
117
|
+
assert.ok(phases.includes("queued"));
|
|
118
|
+
assert.ok(phases.includes("uploading"));
|
|
119
|
+
assert.ok(phases.includes("starting"));
|
|
120
|
+
assert.ok(phases.includes("completed"));
|
|
121
|
+
// Job cleared from the local pending tracker on success.
|
|
122
|
+
assert.equal(sdk.compute.listLocalPendingTranscribes().length, 0);
|
|
123
|
+
});
|
|
124
|
+
it("throws an AithosSDKError when the job fails, leaving the job flagged failed", async () => {
|
|
125
|
+
const fetchImpl = async (input, init) => {
|
|
126
|
+
const url = typeof input === "string" ? input : input.toString();
|
|
127
|
+
if (init?.method === "PUT")
|
|
128
|
+
return new Response("", { status: 200 });
|
|
129
|
+
const method = JSON.parse(init?.body).method;
|
|
130
|
+
if (method === "aithos.compute_transcribe_prepare") {
|
|
131
|
+
return jsonRpc({ job_id: "tj_F", upload_url: UPLOAD_URL, s3_object_key: "k", expires_at: 1 });
|
|
132
|
+
}
|
|
133
|
+
if (method === "aithos.compute_transcribe_start") {
|
|
134
|
+
return jsonRpc({ job_id: "tj_F", status: "running", estimated_credits: 60, walletBalance: 1 });
|
|
135
|
+
}
|
|
136
|
+
return jsonRpc({
|
|
137
|
+
job_id: "tj_F",
|
|
138
|
+
status: "failed",
|
|
139
|
+
error: { code: "transcribe_provider_error", message: "bad audio" },
|
|
140
|
+
});
|
|
141
|
+
};
|
|
142
|
+
const sdk = await makeSdk(fetchImpl);
|
|
143
|
+
const audio = new Blob([new Uint8Array(10)], { type: "audio/webm" });
|
|
144
|
+
await assert.rejects(() => sdk.compute.invokeTranscribe({ audio, durationSecOverride: 30, pollIntervalMs: 1 }), (e) => {
|
|
145
|
+
assert.ok(e instanceof Error);
|
|
146
|
+
assert.match(e.message, /bad audio/);
|
|
147
|
+
return true;
|
|
148
|
+
});
|
|
149
|
+
const pending = sdk.compute.listLocalPendingTranscribes();
|
|
150
|
+
assert.equal(pending.find((p) => p.jobId === "tj_F")?.status, "failed");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe("LocalPendingTranscribeTracker (vanilla, framework-agnostic)", () => {
|
|
154
|
+
it("upserts, lists, removes and notifies subscribers", () => {
|
|
155
|
+
const t = new LocalPendingTranscribeTracker();
|
|
156
|
+
t.clear();
|
|
157
|
+
let notifications = 0;
|
|
158
|
+
const unsub = t.subscribe(() => {
|
|
159
|
+
notifications += 1;
|
|
160
|
+
});
|
|
161
|
+
t.upsert("tj_1", "uploading", { model: "transcribe:aws-fr-standard" });
|
|
162
|
+
assert.equal(t.list().length, 1);
|
|
163
|
+
assert.equal(t.list()[0]?.status, "uploading");
|
|
164
|
+
t.upsert("tj_1", "running");
|
|
165
|
+
assert.equal(t.list().length, 1, "same job id updates in place");
|
|
166
|
+
assert.equal(t.list()[0]?.status, "running");
|
|
167
|
+
t.upsert("tj_2", "running");
|
|
168
|
+
assert.equal(t.list().length, 2);
|
|
169
|
+
t.remove("tj_1");
|
|
170
|
+
assert.equal(t.list().length, 1);
|
|
171
|
+
assert.equal(t.list()[0]?.jobId, "tj_2");
|
|
172
|
+
assert.ok(notifications >= 4, "subscriber fired on each mutation");
|
|
173
|
+
unsub();
|
|
174
|
+
const before = notifications;
|
|
175
|
+
t.upsert("tj_3", "running");
|
|
176
|
+
assert.equal(notifications, before, "no notification after unsubscribe");
|
|
177
|
+
});
|
|
178
|
+
it("getSnapshot returns a stable reference between mutations", () => {
|
|
179
|
+
const t = new LocalPendingTranscribeTracker();
|
|
180
|
+
t.clear();
|
|
181
|
+
const a = t.getSnapshot();
|
|
182
|
+
const b = t.getSnapshot();
|
|
183
|
+
assert.equal(a, b);
|
|
184
|
+
t.upsert("tj_x", "running");
|
|
185
|
+
assert.notEqual(t.getSnapshot(), a, "new reference after a mutation");
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
describe("TranscribeDraftStore (IndexedDB via fake-indexeddb)", () => {
|
|
189
|
+
it("saves, lists, gets and deletes a draft", async () => {
|
|
190
|
+
const store = new TranscribeDraftStore();
|
|
191
|
+
const blob = new Blob([new Uint8Array(64)], { type: "audio/webm" });
|
|
192
|
+
const { draftId } = await store.save(blob, { title: "séance 1" });
|
|
193
|
+
assert.ok(draftId.startsWith("draft_"));
|
|
194
|
+
const list = await store.list();
|
|
195
|
+
assert.ok(list.some((d) => d.draftId === draftId));
|
|
196
|
+
const got = await store.get(draftId);
|
|
197
|
+
assert.equal(got?.metadata.title, "séance 1");
|
|
198
|
+
assert.equal(got?.metadata.contentType, "audio/webm");
|
|
199
|
+
assert.equal(got?.blob.size, 64);
|
|
200
|
+
await store.delete(draftId);
|
|
201
|
+
assert.equal(await store.get(draftId), null);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
//# sourceMappingURL=transcribe-invoke.test.js.map
|