nixamp 0.24.2 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -0
- package/dist/captions.d.ts +12 -0
- package/dist/captions.js +29 -2
- package/dist/hash.d.ts +12 -0
- package/dist/hash.js +139 -0
- package/dist/main.js +27 -3
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +49 -1
- package/dist/media-index.d.ts +67 -0
- package/dist/media-index.js +114 -0
- package/dist/media-local.d.ts +43 -0
- package/dist/media-local.js +88 -0
- package/dist/media-page.d.ts +18 -0
- package/dist/media-page.js +146 -0
- package/dist/media.d.ts +145 -0
- package/dist/media.js +364 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +186 -1
- package/dist/transcribe.js +20 -0
- package/dist/transcript-client.d.ts +19 -0
- package/dist/transcript-client.js +24 -0
- package/package.json +1 -1
- package/src/captions.ts +32 -2
- package/src/hash.ts +139 -0
- package/src/main.ts +27 -3
- package/src/mcp.ts +49 -1
- package/src/media-index.ts +145 -0
- package/src/media-local.ts +117 -0
- package/src/media-page.ts +159 -0
- package/src/media.ts +433 -0
- package/src/server.ts +178 -1
- package/src/transcribe.ts +17 -0
- package/src/transcript-client.ts +34 -0
- package/web/dist/assets/{hls-3VKVEQE3-B3PGV4wK.js → hls-3VKVEQE3-GlzSYi0P.js} +1 -1
- package/web/dist/assets/index-DJHHB_63.js +1 -0
- package/web/dist/assets/{mpegts-CtxeYR5-.js → mpegts-KIbyW_RL.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CBHUeoME.js → mpegts-LO6RVLD6-Fs_vbGcB.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-DOOjAq1w.js +0 -1
package/README.md
CHANGED
|
@@ -459,6 +459,44 @@ and a page that asks for it reads what there is so far. An agent has
|
|
|
459
459
|
`transcript_get` and `transcripts_list`, and `transcribe_audio` keeps a film
|
|
460
460
|
the same way.
|
|
461
461
|
|
|
462
|
+
### One address per file: nixamp.com/hash/ID
|
|
463
|
+
|
|
464
|
+
Every file nixamp meets gets a page at `/hash/<sha256>`, the SHA-256 of its
|
|
465
|
+
bytes, the way OpenFile (logicsrc.com/docs/openfile) names a file, so the same
|
|
466
|
+
film on two machines is one page. The page, and the OpenFile descriptor beside
|
|
467
|
+
it, carry the size, the type, when the file last changed, what ffprobe found
|
|
468
|
+
inside, what nichedb.dev says it is, which servers have carried it and as which
|
|
469
|
+
channel, and its transcripts in every language, as subtitle files. Whoever
|
|
470
|
+
meets the file fills it in: `nixamp hash`, `nixamp transcribe`, and a server
|
|
471
|
+
that puts the file on the air.
|
|
472
|
+
|
|
473
|
+
```
|
|
474
|
+
nixamp hash FILE the hash, the address, and what is known, kept
|
|
475
|
+
nixamp hash FILE --no-keep the hash and the address only
|
|
476
|
+
nixamp hash --get ID what nixamp.com knows, by hash or fingerprint
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
```
|
|
480
|
+
GET /hash/ID the page; JSON when Accept says so
|
|
481
|
+
GET /hash/ID.json the OpenFile file object with nixamp's facts under `nixamp`
|
|
482
|
+
GET /hash/ID.openfile.json the same, as a descriptor
|
|
483
|
+
GET /hash/ID.srt the transcript as subtitles; .vtt, .txt; ?language=de
|
|
484
|
+
GET /api/v1/media/ID the record; PUT it, signed in, with what you know
|
|
485
|
+
GET /.well-known/openfile.json every file nixamp.com knows, as a publisher's listing
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
ID is the hash with or without `sha256:`, the transcript store's fingerprint,
|
|
489
|
+
or a transcript id. Reading is open, since the hash of the bytes is the file;
|
|
490
|
+
keeping is signed in.
|
|
491
|
+
|
|
492
|
+
A file changes. The machine holding it keeps an index of what it has told
|
|
493
|
+
nixamp.com and looks at each file again on a schedule set by how recently it
|
|
494
|
+
changed: a quarter of the time since its last change, between a quarter of an
|
|
495
|
+
hour and a month, so a file being edited is checked often and a film from 2019
|
|
496
|
+
once a month. A stat is all it costs until something moved; then the file is
|
|
497
|
+
hashed again, the new record says what it was and the old one what it became.
|
|
498
|
+
An agent has `media_hash` and `media_get`.
|
|
499
|
+
|
|
462
500
|
### In another language
|
|
463
501
|
|
|
464
502
|
Ask for a language and the lines come translated, by an open-source model on
|
package/dist/captions.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Listener } from "./channels.ts";
|
|
2
|
+
import { type MediaToKeep } from "./transcript-client.ts";
|
|
2
3
|
import { lineAt } from "./transcripts.ts";
|
|
3
4
|
export interface CaptionLine {
|
|
4
5
|
/** The channel's id. */
|
|
@@ -31,6 +32,15 @@ export interface ChannelMedia {
|
|
|
31
32
|
startedAt: number;
|
|
32
33
|
/** How many seconds behind the live edge a new listener's sound starts. */
|
|
33
34
|
backlog: number;
|
|
35
|
+
/**
|
|
36
|
+
* The file itself, described for the record at nixamp.com/hash/<id>:
|
|
37
|
+
* its SHA-256 and what is known about it. Absent for anything that is
|
|
38
|
+
* not a file on this machine. Asked once when the captioner starts.
|
|
39
|
+
*/
|
|
40
|
+
describe?: () => Promise<{
|
|
41
|
+
id: string;
|
|
42
|
+
keep: MediaToKeep;
|
|
43
|
+
} | null>;
|
|
34
44
|
}
|
|
35
45
|
export interface CaptionsOptions {
|
|
36
46
|
/** A listener on a channel, or null when there is no such channel. */
|
|
@@ -99,6 +109,8 @@ export interface CaptionStatus {
|
|
|
99
109
|
known: number;
|
|
100
110
|
/** The languages lines are being given in besides the original. */
|
|
101
111
|
languages: string[];
|
|
112
|
+
/** The file's SHA-256, once it has been described for nixamp.com/hash/<id>; "" until then, or for a live. */
|
|
113
|
+
hash: string;
|
|
102
114
|
}
|
|
103
115
|
export declare class Captions {
|
|
104
116
|
private readonly options;
|
package/dist/captions.js
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import { spawn } from "node:child_process";
|
|
34
34
|
import { RATE } from "./speech.js";
|
|
35
|
-
import { fetchTranscript, keepLines, translateTexts } from "./transcript-client.js";
|
|
35
|
+
import { fetchTranscript, keepLines, keepMedia, translateTexts } from "./transcript-client.js";
|
|
36
36
|
import { covered, lineAt, transcriptIdOf } from "./transcripts.js";
|
|
37
37
|
export const WINDOW_MS = 5000;
|
|
38
38
|
/** Lines kept per channel for whoever arrives late. */
|
|
@@ -162,6 +162,8 @@ class Captioner {
|
|
|
162
162
|
error = "";
|
|
163
163
|
/** What the ear says the sound is in, or the store said it was; "" until one of them has. */
|
|
164
164
|
language = "";
|
|
165
|
+
/** The file's SHA-256, once described. */
|
|
166
|
+
hash = "";
|
|
165
167
|
model = "";
|
|
166
168
|
decoder = null;
|
|
167
169
|
detach = null;
|
|
@@ -212,8 +214,32 @@ class Captioner {
|
|
|
212
214
|
return false;
|
|
213
215
|
}
|
|
214
216
|
void this.consult("");
|
|
217
|
+
void this.record();
|
|
215
218
|
return true;
|
|
216
219
|
}
|
|
220
|
+
/** Tell nixamp.com about the file itself, once: its hash and what this server knows of it. */
|
|
221
|
+
async record() {
|
|
222
|
+
const describe = this.media?.describe;
|
|
223
|
+
if (!describe)
|
|
224
|
+
return;
|
|
225
|
+
const session = this.options.session();
|
|
226
|
+
if (session === null)
|
|
227
|
+
return;
|
|
228
|
+
try {
|
|
229
|
+
const described = await describe();
|
|
230
|
+
if (!described || this.stopped)
|
|
231
|
+
return;
|
|
232
|
+
this.hash = described.id;
|
|
233
|
+
const got = await keepMedia(session, described.id, described.keep, this.options.fetcher ?? fetch);
|
|
234
|
+
if (!got.ok)
|
|
235
|
+
this.complain(`the record was not kept: ${got.error}`);
|
|
236
|
+
else
|
|
237
|
+
this.options.onEvent?.(`captions for "${this.id}": nixamp.com/hash/${described.id.slice(0, 12)}… knows this file`);
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
this.complain(`could not describe the file: ${error.message}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
217
243
|
/** Ask the store what it already knows of this media in a language, once. */
|
|
218
244
|
async consult(language) {
|
|
219
245
|
if (!this.transcriptId || this.asked.has(language))
|
|
@@ -463,6 +489,7 @@ class Captioner {
|
|
|
463
489
|
language: this.language,
|
|
464
490
|
known: this.known.get("")?.length ?? 0,
|
|
465
491
|
languages: [...this.linesBy.keys()],
|
|
492
|
+
hash: this.hash,
|
|
466
493
|
};
|
|
467
494
|
}
|
|
468
495
|
stop() {
|
|
@@ -523,7 +550,7 @@ export class Captions {
|
|
|
523
550
|
/** Whether a channel is being captioned, and what last went wrong if the lines are not coming. */
|
|
524
551
|
status(id) {
|
|
525
552
|
const captioner = this.running.get(id);
|
|
526
|
-
return captioner ? captioner.status() : { on: false, lines: 0, error: "", language: "", known: 0, languages: [] };
|
|
553
|
+
return captioner ? captioner.status() : { on: false, lines: 0, error: "", language: "", known: 0, languages: [], hash: "" };
|
|
527
554
|
}
|
|
528
555
|
stopAll() {
|
|
529
556
|
for (const captioner of [...this.running.values()])
|
package/dist/hash.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type Described } from "./media-local.ts";
|
|
2
|
+
import { type Session } from "./session.ts";
|
|
3
|
+
export interface HashDeps {
|
|
4
|
+
fetcher?: typeof fetch;
|
|
5
|
+
session?: Pick<Session, "site" | "token"> | null;
|
|
6
|
+
describe?: (path: string) => Promise<Described>;
|
|
7
|
+
index?: string;
|
|
8
|
+
now?: () => number;
|
|
9
|
+
}
|
|
10
|
+
export declare function hash(argv: string[], deps?: HashDeps): Promise<number>;
|
|
11
|
+
/** A record as the terminal prints it. */
|
|
12
|
+
export declare function describeRecord(record: Record<string, unknown>): string;
|
package/dist/hash.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `nixamp hash` -- one address for a file: nixamp.com/hash/<sha256>.
|
|
3
|
+
*
|
|
4
|
+
* Hashes every byte, says the address, and unless told not to tells
|
|
5
|
+
* nixamp.com what this machine knows about the file (media-local.ts) so
|
|
6
|
+
* the address answers with it: what is inside, what nichedb says it is,
|
|
7
|
+
* and, once `nixamp transcribe` or a server has been at it, the
|
|
8
|
+
* transcript in every language. The file goes into this machine's index
|
|
9
|
+
* too, so the daemon looks at it again on a schedule and a changed file
|
|
10
|
+
* gets a new address that points back at this one.
|
|
11
|
+
*/
|
|
12
|
+
import { detectTools } from "./audio.js";
|
|
13
|
+
import { Enricher } from "./enrich.js";
|
|
14
|
+
import { describeFile, keepFile } from "./media-local.js";
|
|
15
|
+
import { readSession } from "./session.js";
|
|
16
|
+
import { fetchMedia } from "./transcript-client.js";
|
|
17
|
+
const HELP = `nixamp hash — one address for a file.
|
|
18
|
+
|
|
19
|
+
nixamp hash FILE [FILE...] sha256, and nixamp.com/hash/<sha256>, kept there with what is known
|
|
20
|
+
nixamp hash FILE --no-keep the hash and the address only; nothing sent
|
|
21
|
+
nixamp hash FILE --json the record as nixamp.com keeps it
|
|
22
|
+
nixamp hash --get ID what nixamp.com knows about a file, by hash or fingerprint
|
|
23
|
+
|
|
24
|
+
The address is the SHA-256 of the file's bytes, the way OpenFile
|
|
25
|
+
(logicsrc.com/docs/openfile) names a file, so the same file on two
|
|
26
|
+
machines is one page. What is kept: the size, the type, when the file last
|
|
27
|
+
changed, what ffprobe found inside, what nichedb.dev says it is, where it
|
|
28
|
+
has been carried, and its transcripts. Keeping needs a sign-in
|
|
29
|
+
(\`nixamp login\`); the hash needs nothing.
|
|
30
|
+
`;
|
|
31
|
+
function flag(argv, name) {
|
|
32
|
+
const at = argv.indexOf(name);
|
|
33
|
+
return at === -1 ? undefined : argv[at + 1];
|
|
34
|
+
}
|
|
35
|
+
/** The site an address is printed for: the session's, or nixamp.com. */
|
|
36
|
+
function siteOf(session) {
|
|
37
|
+
return (session?.site ?? "https://nixamp.com").replace(/\/+$/, "");
|
|
38
|
+
}
|
|
39
|
+
export async function hash(argv, deps = {}) {
|
|
40
|
+
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
|
|
41
|
+
console.log(HELP);
|
|
42
|
+
return argv.length === 0 ? 64 : 0;
|
|
43
|
+
}
|
|
44
|
+
const session = deps.session === undefined ? readSession() : deps.session;
|
|
45
|
+
const fetcher = deps.fetcher ?? fetch;
|
|
46
|
+
const asJson = argv.includes("--json");
|
|
47
|
+
const site = siteOf(session);
|
|
48
|
+
const get = flag(argv, "--get");
|
|
49
|
+
if (argv.includes("--get")) {
|
|
50
|
+
if (!get) {
|
|
51
|
+
console.error("nixamp: --get needs the file's hash, or its fingerprint.");
|
|
52
|
+
return 64;
|
|
53
|
+
}
|
|
54
|
+
const got = await fetchMedia(site, get, fetcher);
|
|
55
|
+
if (!got.ok) {
|
|
56
|
+
console.error(`nixamp: ${got.error}`);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
if (asJson)
|
|
60
|
+
console.log(JSON.stringify(got.body, null, 2));
|
|
61
|
+
else
|
|
62
|
+
console.log(describeRecord(got.body));
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
const files = argv.filter((one, at) => !one.startsWith("-") && !(at > 0 && argv[at - 1] === "--get"));
|
|
66
|
+
if (files.length === 0) {
|
|
67
|
+
console.error("nixamp: which file? `nixamp hash film.mkv`.");
|
|
68
|
+
return 64;
|
|
69
|
+
}
|
|
70
|
+
const keep = !argv.includes("--no-keep");
|
|
71
|
+
if (keep && session === null)
|
|
72
|
+
console.error("nixamp: not signed in, so nothing is kept; `nixamp login` to keep the record. The hash still is:");
|
|
73
|
+
let tools = null;
|
|
74
|
+
let enricher = null;
|
|
75
|
+
const describe = deps.describe ?? (async (path) => {
|
|
76
|
+
tools ??= detectTools();
|
|
77
|
+
enricher ??= new Enricher();
|
|
78
|
+
return describeFile(path, { tools, enricher, now: deps.now });
|
|
79
|
+
});
|
|
80
|
+
let failed = 0;
|
|
81
|
+
const records = [];
|
|
82
|
+
for (const file of files) {
|
|
83
|
+
let described;
|
|
84
|
+
try {
|
|
85
|
+
described = await describe(file);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
console.error(`nixamp: ${file}: ${error.message}`);
|
|
89
|
+
failed += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
let record = null;
|
|
93
|
+
if (keep && session) {
|
|
94
|
+
const refused = await keepFile({ site: session.site, token: session.token }, file, described, { fetcher, ...(deps.index ? { index: deps.index } : {}), ...(deps.now ? { now: deps.now } : {}) });
|
|
95
|
+
if (refused) {
|
|
96
|
+
console.error(`nixamp: ${file}: not kept: ${refused}`);
|
|
97
|
+
failed += 1;
|
|
98
|
+
}
|
|
99
|
+
else if (asJson) {
|
|
100
|
+
const got = await fetchMedia(site, described.id, fetcher);
|
|
101
|
+
record = got.ok ? got.body : null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (asJson) {
|
|
105
|
+
records.push(record ?? { id: `sha256:${described.id}`, name: described.keep.name, url: `${site}/hash/${described.id}`, nixamp: described.keep.facts });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
console.log(`sha256:${described.id} ${described.keep.name ?? file}\n ${site}/hash/${described.id}${keep && session ? "" : " (not kept)"}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (asJson)
|
|
112
|
+
console.log(JSON.stringify(records.length === 1 ? records[0] : records, null, 2));
|
|
113
|
+
return failed > 0 ? 1 : 0;
|
|
114
|
+
}
|
|
115
|
+
/** A record as the terminal prints it. */
|
|
116
|
+
export function describeRecord(record) {
|
|
117
|
+
const nixamp = (record["nixamp"] ?? {});
|
|
118
|
+
const lines = [`${record["name"] ?? ""} ${record["id"] ?? ""}`, ` ${record["url"] ?? ""}`];
|
|
119
|
+
if (typeof record["size"] === "number")
|
|
120
|
+
lines.push(` size: ${record["size"]} bytes${record["contentType"] ? `, ${record["contentType"]}` : ""}`);
|
|
121
|
+
if (typeof nixamp["duration"] === "number")
|
|
122
|
+
lines.push(` length: ${Math.round(nixamp["duration"])} s`);
|
|
123
|
+
const enrichment = nixamp["enrichment"];
|
|
124
|
+
if (enrichment?.title)
|
|
125
|
+
lines.push(` nichedb: ${enrichment.title}${enrichment.year ? ` (${enrichment.year})` : ""}`);
|
|
126
|
+
if (record["updated"])
|
|
127
|
+
lines.push(` file changed: ${record["updated"]}`);
|
|
128
|
+
if (nixamp["checkAfter"])
|
|
129
|
+
lines.push(` next check: ${nixamp["checkAfter"]}`);
|
|
130
|
+
const holders = (record["holders"] ?? []);
|
|
131
|
+
for (const holder of holders)
|
|
132
|
+
lines.push(` carried by ${holder.url}${holder.channel ? ` as ${holder.channel}` : ""} at ${holder.seenAt}`);
|
|
133
|
+
const transcripts = (nixamp["transcripts"] ?? []);
|
|
134
|
+
for (const one of transcripts)
|
|
135
|
+
lines.push(` transcript ${one.language || "as spoken"}${one.translatedFrom ? ` (from ${one.translatedFrom})` : ""}: ${one.lines} lines${one.complete ? "" : " so far"} ${one.url}`);
|
|
136
|
+
if (nixamp["supersededBy"])
|
|
137
|
+
lines.push(` became: ${nixamp["supersededBy"]}`);
|
|
138
|
+
return lines.join("\n");
|
|
139
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -63,6 +63,7 @@ const HELP = `nixamp — it really whips the terminal's ass.
|
|
|
63
63
|
nixamp transcribe FILE [--translate sv] a recording or a whole film written down, kept, and in other languages
|
|
64
64
|
nixamp transcript --channel ID [--follow] what a channel is saying, as it says it; --kept for what nixamp.com keeps
|
|
65
65
|
nixamp translate --to sv TEXT say it in another language
|
|
66
|
+
nixamp hash FILE one address for a file: nixamp.com/hash/<sha256>, with all that is known
|
|
66
67
|
nixamp profile [--handle H] [--voice V] [--profile URL] who the rooms know you as
|
|
67
68
|
nixamp voices the voices a line is read in on the phone
|
|
68
69
|
nixamp opendir list|add|remove folders found on the web, published for everyone
|
|
@@ -248,9 +249,10 @@ posted straight into a trollbox), say a line in a room (trollbox_say), read
|
|
|
248
249
|
a room (trollbox_read), read what a channel is saying (transcript_read, in
|
|
249
250
|
any language). The transcript tools: a kept transcript by its id or its
|
|
250
251
|
media (transcript_get), what this account has had written down
|
|
251
|
-
(transcripts_list), and text in another language (translate_text).
|
|
252
|
-
|
|
253
|
-
|
|
252
|
+
(transcripts_list), and text in another language (translate_text). The file
|
|
253
|
+
tools: one address for a file (media_hash) and what nixamp.com knows about
|
|
254
|
+
one (media_get). And who you are in the rooms and how you sound on the
|
|
255
|
+
phone (profile_get, profile_set, voices_list). It acts as whoever this machine is signed in as,
|
|
254
256
|
so \`nixamp login\` (or NIXAMP_TOKEN) comes first.
|
|
255
257
|
|
|
256
258
|
Point an MCP client at it as a stdio server running \`nixamp mcp\`. The same
|
|
@@ -283,6 +285,23 @@ with an open-source model (OPUS-MT), and kept beside the original.
|
|
|
283
285
|
|
|
284
286
|
The same ear is behind the microphone button in every nixamp.com trollbox,
|
|
285
287
|
and behind the transcribe_audio tool of \`nixamp mcp\`.
|
|
288
|
+
`,
|
|
289
|
+
hash: `nixamp hash — one address for a file.
|
|
290
|
+
|
|
291
|
+
nixamp hash FILE [FILE...] sha256, and nixamp.com/hash/<sha256>, kept there with what is known
|
|
292
|
+
nixamp hash FILE --no-keep the hash and the address only; nothing sent
|
|
293
|
+
nixamp hash FILE --json the record as nixamp.com keeps it
|
|
294
|
+
nixamp hash --get ID what nixamp.com knows about a file, by hash or fingerprint
|
|
295
|
+
|
|
296
|
+
The address is the SHA-256 of the file's bytes, the way OpenFile
|
|
297
|
+
(logicsrc.com/docs/openfile) names a file, so the same file on two machines
|
|
298
|
+
is one page. The page, and /hash/<id>.openfile.json beside it, carry the
|
|
299
|
+
size, the type, when the file last changed, what ffprobe found inside, what
|
|
300
|
+
nichedb.dev says it is, which servers have carried it, and its transcripts
|
|
301
|
+
in every language, as .srt, .vtt or text. \`nixamp transcribe\` and a server
|
|
302
|
+
captioning a file keep the same record. The daemon looks at a kept file
|
|
303
|
+
again on a schedule set by how recently it changed, and a changed file
|
|
304
|
+
gets a new address that points back at the old one.
|
|
286
305
|
`,
|
|
287
306
|
translate: `nixamp translate — say it in another language.
|
|
288
307
|
|
|
@@ -559,6 +578,11 @@ export async function main() {
|
|
|
559
578
|
process.exitCode = await translate(rest);
|
|
560
579
|
return;
|
|
561
580
|
}
|
|
581
|
+
if (first === "hash") {
|
|
582
|
+
const { hash } = await import("./hash.js");
|
|
583
|
+
process.exitCode = await hash(rest);
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
562
586
|
if (first === "token" || first === "tokens") {
|
|
563
587
|
const { tokens } = await import("./session.js");
|
|
564
588
|
process.exitCode = await tokens(rest);
|
package/dist/mcp.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { wavOf, type Window } from "./transcribe.ts";
|
|
2
|
+
import { type Described } from "./media-local.ts";
|
|
2
3
|
export declare const PROTOCOL_VERSION = "2025-06-18";
|
|
3
4
|
interface Request {
|
|
4
5
|
jsonrpc: "2.0";
|
|
@@ -27,6 +28,8 @@ export interface McpOptions {
|
|
|
27
28
|
sleep?: (ms: number) => Promise<void>;
|
|
28
29
|
/** How many times a translation is asked about before answering with its progress. */
|
|
29
30
|
polls?: number;
|
|
31
|
+
/** How a file is described for its record; the tests hand in a fake. */
|
|
32
|
+
describe?: (path: string) => Promise<Described>;
|
|
30
33
|
say?: (line: string) => void;
|
|
31
34
|
}
|
|
32
35
|
/** A tool answer, in the shape MCP wants: content blocks, and a flag for failure. */
|
package/dist/mcp.js
CHANGED
|
@@ -23,7 +23,11 @@ import { clock } from "./party.js";
|
|
|
23
23
|
import { readSession } from "./session.js";
|
|
24
24
|
import { askToHear, awaitTranscript, hearWhole, rendered, wavOf } from "./transcribe.js";
|
|
25
25
|
import { readTranscript } from "./transcript.js";
|
|
26
|
-
import { fetchTranscript, listTranscripts, translateTexts } from "./transcript-client.js";
|
|
26
|
+
import { fetchMedia, fetchTranscript, listTranscripts, translateTexts } from "./transcript-client.js";
|
|
27
|
+
import { detectTools } from "./audio.js";
|
|
28
|
+
import { Enricher } from "./enrich.js";
|
|
29
|
+
import { describeRecord } from "./hash.js";
|
|
30
|
+
import { describeFile, keepFile } from "./media-local.js";
|
|
27
31
|
import { fileFingerprint, idFrom, languageCode, mediaOfUrl, transcriptIdOf } from "./transcripts.js";
|
|
28
32
|
import { personaLines, readPersona, readVoices, writePersona } from "./profile.js";
|
|
29
33
|
export const PROTOCOL_VERSION = "2025-06-18";
|
|
@@ -112,6 +116,23 @@ export const TOOLS = [
|
|
|
112
116
|
required: ["media"],
|
|
113
117
|
},
|
|
114
118
|
},
|
|
119
|
+
{
|
|
120
|
+
name: "media_hash",
|
|
121
|
+
description: "One address for a file on this machine: its SHA-256 and nixamp.com/hash/<sha256>, kept there with what this machine knows (size, type, when it changed, what is inside, what nichedb says it is). The same file anywhere is the same page.",
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: {
|
|
125
|
+
path: { ...STRING, description: "The file's path on this machine." },
|
|
126
|
+
keep: { type: "boolean", description: "Tell nixamp.com about it (default true)." },
|
|
127
|
+
},
|
|
128
|
+
required: ["path"],
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
name: "media_get",
|
|
133
|
+
description: "What nixamp.com knows about a file, by its SHA-256 (with or without sha256:), its transcript fingerprint, or a transcript id: the OpenFile record with nixamp's facts, holders and transcripts.",
|
|
134
|
+
inputSchema: { type: "object", properties: { id: { ...STRING, description: "The hash, fingerprint or transcript id." } }, required: ["id"] },
|
|
135
|
+
},
|
|
115
136
|
{
|
|
116
137
|
name: "transcripts_list",
|
|
117
138
|
description: "What this account has had written down on nixamp.com: each transcript's id, what it is, its language, how many lines, and when.",
|
|
@@ -394,6 +415,33 @@ export async function callTool(name, args, options = {}) {
|
|
|
394
415
|
}
|
|
395
416
|
return text(transcriptText(got.body, typeof args["format"] === "string" ? args["format"] : "lines"));
|
|
396
417
|
}
|
|
418
|
+
if (name === "media_hash") {
|
|
419
|
+
const path = typeof args["path"] === "string" ? args["path"] : "";
|
|
420
|
+
if (!path)
|
|
421
|
+
return failed("Which file? Pass its path.");
|
|
422
|
+
let described;
|
|
423
|
+
try {
|
|
424
|
+
described = await (options.describe ?? ((file) => describeFile(file, { tools: detectTools(), enricher: new Enricher() })))(path);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
return failed(`${path}: ${error.message}`);
|
|
428
|
+
}
|
|
429
|
+
if (args["keep"] !== false) {
|
|
430
|
+
const refused = await keepFile({ site, token: session.token }, path, described, { fetcher: send });
|
|
431
|
+
if (refused)
|
|
432
|
+
return failed(`hashed, but not kept: ${refused}`);
|
|
433
|
+
}
|
|
434
|
+
return text(`sha256:${described.id}\n${site}/hash/${described.id}${args["keep"] === false ? "\n(not kept)" : ""}`);
|
|
435
|
+
}
|
|
436
|
+
if (name === "media_get") {
|
|
437
|
+
const id = typeof args["id"] === "string" ? args["id"].trim() : "";
|
|
438
|
+
if (!id)
|
|
439
|
+
return failed("Which file? Pass its hash, fingerprint or transcript id.");
|
|
440
|
+
const got = await fetchMedia(site, id, send);
|
|
441
|
+
if (!got.ok)
|
|
442
|
+
return failed(got.error);
|
|
443
|
+
return text(describeRecord(got.body));
|
|
444
|
+
}
|
|
397
445
|
if (name === "transcripts_list") {
|
|
398
446
|
const got = await listTranscripts({ site, token: session.token }, send);
|
|
399
447
|
if (!got.ok)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export interface IndexedFile {
|
|
2
|
+
/** The SHA-256 hex nixamp.com knows it by. */
|
|
3
|
+
id: string;
|
|
4
|
+
fingerprint: string;
|
|
5
|
+
size: number;
|
|
6
|
+
mtimeMs: number;
|
|
7
|
+
checkedAt: number;
|
|
8
|
+
checkAfter: number;
|
|
9
|
+
}
|
|
10
|
+
export interface IndexFile {
|
|
11
|
+
version: 1;
|
|
12
|
+
files: Record<string, IndexedFile>;
|
|
13
|
+
}
|
|
14
|
+
export declare function indexPath(): string;
|
|
15
|
+
export declare function readIndex(path?: string): IndexFile;
|
|
16
|
+
export declare function writeIndex(index: IndexFile, path?: string): void;
|
|
17
|
+
/** Note a file this machine has told nixamp.com about, with when to look at it next. */
|
|
18
|
+
export declare function remember(file: string, entry: {
|
|
19
|
+
id: string;
|
|
20
|
+
fingerprint: string;
|
|
21
|
+
size: number;
|
|
22
|
+
mtimeMs: number;
|
|
23
|
+
}, now?: number, path?: string): IndexedFile;
|
|
24
|
+
/** The files whose turn it is. */
|
|
25
|
+
export declare function due(index: IndexFile, now?: number): string[];
|
|
26
|
+
export type Looked = {
|
|
27
|
+
state: "same";
|
|
28
|
+
entry: IndexedFile;
|
|
29
|
+
} | {
|
|
30
|
+
state: "changed";
|
|
31
|
+
entry: IndexedFile;
|
|
32
|
+
size: number;
|
|
33
|
+
mtimeMs: number;
|
|
34
|
+
} | {
|
|
35
|
+
state: "gone";
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* One look at a file: the same as it was, changed, or gone. Nothing is
|
|
39
|
+
* hashed here; the caller hashes a changed file, since that is the part
|
|
40
|
+
* that costs, and then remembers the new record.
|
|
41
|
+
*/
|
|
42
|
+
export declare function look(file: string, entry: IndexedFile, now?: number): Looked;
|
|
43
|
+
/** How often the watcher reads the index for what is due. */
|
|
44
|
+
export declare const WATCH_EVERY_MS: number;
|
|
45
|
+
export interface WatchOptions {
|
|
46
|
+
path?: string;
|
|
47
|
+
now?: () => number;
|
|
48
|
+
/** Hash the changed file, tell nixamp.com, and answer the new entry; null when it could not. */
|
|
49
|
+
refresh: (file: string, before: IndexedFile, stat: {
|
|
50
|
+
size: number;
|
|
51
|
+
mtimeMs: number;
|
|
52
|
+
}) => Promise<{
|
|
53
|
+
id: string;
|
|
54
|
+
fingerprint: string;
|
|
55
|
+
} | null>;
|
|
56
|
+
onEvent?: (message: string) => void;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One pass over what is due. Files that are the same are pushed out to
|
|
60
|
+
* their next check; changed ones are refreshed; gone ones are forgotten.
|
|
61
|
+
* Answers how many of each, for the log.
|
|
62
|
+
*/
|
|
63
|
+
export declare function watchOnce(options: WatchOptions): Promise<{
|
|
64
|
+
same: number;
|
|
65
|
+
changed: number;
|
|
66
|
+
gone: number;
|
|
67
|
+
}>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The files this machine has told nixamp.com about, and when to look again.
|
|
3
|
+
*
|
|
4
|
+
* nixamp.com keeps the record (media.ts); the bytes stay here. So whether a
|
|
5
|
+
* file has changed is a question only this machine can answer, and it
|
|
6
|
+
* answers it on a schedule that follows the file: a quarter of the time
|
|
7
|
+
* since it last changed, never more often than every quarter of an hour
|
|
8
|
+
* and never less often than monthly (see checkInterval). Looking is one
|
|
9
|
+
* stat. Only a file whose size or modification time moved is hashed
|
|
10
|
+
* again, and a new hash is a new record on nixamp.com, with the old one
|
|
11
|
+
* told what it became.
|
|
12
|
+
*
|
|
13
|
+
* The index is a JSON file beside the daemon's other state. The CLI adds
|
|
14
|
+
* to it when it hashes or transcribes a file, the server when it captions
|
|
15
|
+
* one, and the server's watcher reads it every few minutes for what is
|
|
16
|
+
* due. Written whole and renamed into place, so two of them writing at
|
|
17
|
+
* once lose an entry at worst, never the file.
|
|
18
|
+
*/
|
|
19
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { stateDir } from "./daemon.js";
|
|
22
|
+
import { checkInterval } from "./media.js";
|
|
23
|
+
export function indexPath() {
|
|
24
|
+
return process.env["NIXAMP_MEDIA_INDEX"] ?? join(stateDir(), "media.json");
|
|
25
|
+
}
|
|
26
|
+
export function readIndex(path = indexPath()) {
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
29
|
+
if (parsed && parsed.version === 1 && parsed.files && typeof parsed.files === "object")
|
|
30
|
+
return { version: 1, files: parsed.files };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// No index yet, or one this cannot read: start again.
|
|
34
|
+
}
|
|
35
|
+
return { version: 1, files: {} };
|
|
36
|
+
}
|
|
37
|
+
export function writeIndex(index, path = indexPath()) {
|
|
38
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
39
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
40
|
+
writeFileSync(temp, JSON.stringify(index, null, 2));
|
|
41
|
+
renameSync(temp, path);
|
|
42
|
+
}
|
|
43
|
+
/** Note a file this machine has told nixamp.com about, with when to look at it next. */
|
|
44
|
+
export function remember(file, entry, now = Date.now(), path = indexPath()) {
|
|
45
|
+
const index = readIndex(path);
|
|
46
|
+
const kept = { ...entry, checkedAt: now, checkAfter: now + checkInterval(entry.mtimeMs, now) };
|
|
47
|
+
index.files[file] = kept;
|
|
48
|
+
writeIndex(index, path);
|
|
49
|
+
return kept;
|
|
50
|
+
}
|
|
51
|
+
/** The files whose turn it is. */
|
|
52
|
+
export function due(index, now = Date.now()) {
|
|
53
|
+
return Object.entries(index.files).filter(([, one]) => one.checkAfter <= now).map(([file]) => file);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* One look at a file: the same as it was, changed, or gone. Nothing is
|
|
57
|
+
* hashed here; the caller hashes a changed file, since that is the part
|
|
58
|
+
* that costs, and then remembers the new record.
|
|
59
|
+
*/
|
|
60
|
+
export function look(file, entry, now = Date.now()) {
|
|
61
|
+
let stat;
|
|
62
|
+
try {
|
|
63
|
+
stat = statSync(file);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return { state: "gone" };
|
|
67
|
+
}
|
|
68
|
+
if (stat.size === entry.size && Math.floor(stat.mtimeMs) === Math.floor(entry.mtimeMs)) {
|
|
69
|
+
return { state: "same", entry: { ...entry, checkedAt: now, checkAfter: now + checkInterval(entry.mtimeMs, now) } };
|
|
70
|
+
}
|
|
71
|
+
return { state: "changed", entry, size: stat.size, mtimeMs: stat.mtimeMs };
|
|
72
|
+
}
|
|
73
|
+
/** How often the watcher reads the index for what is due. */
|
|
74
|
+
export const WATCH_EVERY_MS = 10 * 60 * 1000;
|
|
75
|
+
/**
|
|
76
|
+
* One pass over what is due. Files that are the same are pushed out to
|
|
77
|
+
* their next check; changed ones are refreshed; gone ones are forgotten.
|
|
78
|
+
* Answers how many of each, for the log.
|
|
79
|
+
*/
|
|
80
|
+
export async function watchOnce(options) {
|
|
81
|
+
const now = options.now ?? Date.now;
|
|
82
|
+
const path = options.path ?? indexPath();
|
|
83
|
+
const index = readIndex(path);
|
|
84
|
+
const counts = { same: 0, changed: 0, gone: 0 };
|
|
85
|
+
for (const file of due(index, now())) {
|
|
86
|
+
const entry = index.files[file];
|
|
87
|
+
if (!entry)
|
|
88
|
+
continue;
|
|
89
|
+
const looked = look(file, entry, now());
|
|
90
|
+
if (looked.state === "gone") {
|
|
91
|
+
delete index.files[file];
|
|
92
|
+
counts.gone += 1;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (looked.state === "same") {
|
|
96
|
+
index.files[file] = looked.entry;
|
|
97
|
+
counts.same += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const fresh = await options.refresh(file, entry, { size: looked.size, mtimeMs: looked.mtimeMs });
|
|
101
|
+
const at = now();
|
|
102
|
+
if (fresh) {
|
|
103
|
+
index.files[file] = { ...fresh, size: looked.size, mtimeMs: looked.mtimeMs, checkedAt: at, checkAfter: at + checkInterval(looked.mtimeMs, at) };
|
|
104
|
+
counts.changed += 1;
|
|
105
|
+
options.onEvent?.(`${file} changed: now sha256:${fresh.id.slice(0, 12)} (was ${entry.id.slice(0, 12)})`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
// Could not say; ask again in a quarter of an hour rather than never.
|
|
109
|
+
index.files[file] = { ...entry, checkedAt: at, checkAfter: at + 15 * 60 * 1000 };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
writeIndex(index, path);
|
|
113
|
+
return counts;
|
|
114
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type Tools } from "./audio.ts";
|
|
2
|
+
import type { Enricher } from "./enrich.ts";
|
|
3
|
+
import { type Holder } from "./media.ts";
|
|
4
|
+
import { type IndexedFile } from "./media-index.ts";
|
|
5
|
+
import { type MediaToKeep, type Signed } from "./transcript-client.ts";
|
|
6
|
+
export interface Described {
|
|
7
|
+
id: string;
|
|
8
|
+
fingerprint: string;
|
|
9
|
+
size: number;
|
|
10
|
+
mtimeMs: number;
|
|
11
|
+
keep: MediaToKeep;
|
|
12
|
+
}
|
|
13
|
+
export interface DescribeDeps {
|
|
14
|
+
/** ffprobe, for what is inside. Absent: only what the filesystem says. */
|
|
15
|
+
tools?: Tools | null;
|
|
16
|
+
enricher?: Pick<Enricher, "lookup"> | null;
|
|
17
|
+
holder?: Holder | null;
|
|
18
|
+
now?: () => number;
|
|
19
|
+
/** The tests hand in fakes for the two things that cost. */
|
|
20
|
+
hash?: (path: string) => Promise<string>;
|
|
21
|
+
fingerprint?: (path: string) => string;
|
|
22
|
+
}
|
|
23
|
+
/** Everything this machine can say about a file, ready to keep. */
|
|
24
|
+
export declare function describeFile(path: string, deps?: DescribeDeps): Promise<Described>;
|
|
25
|
+
/** Keep a described file on nixamp.com and in this machine's index. Answers the store's sentence when it refused. */
|
|
26
|
+
export declare function keepFile(signed: Signed, path: string, described: Described, deps?: {
|
|
27
|
+
fetcher?: typeof fetch;
|
|
28
|
+
index?: string;
|
|
29
|
+
now?: () => number;
|
|
30
|
+
}): Promise<string | null>;
|
|
31
|
+
/**
|
|
32
|
+
* A file that changed since it was last described: described again as a
|
|
33
|
+
* new record that says what it was, while the old record says what it
|
|
34
|
+
* became. Answers the new identity, or null when nixamp.com would not
|
|
35
|
+
* keep it.
|
|
36
|
+
*/
|
|
37
|
+
export declare function refreshChanged(signed: Signed, path: string, before: Pick<IndexedFile, "id">, deps?: DescribeDeps & {
|
|
38
|
+
fetcher?: typeof fetch;
|
|
39
|
+
index?: string;
|
|
40
|
+
}): Promise<{
|
|
41
|
+
id: string;
|
|
42
|
+
fingerprint: string;
|
|
43
|
+
} | null>;
|