nixamp 0.24.1 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +47 -2
  2. package/dist/captions.d.ts +12 -0
  3. package/dist/captions.js +29 -2
  4. package/dist/hash.d.ts +12 -0
  5. package/dist/hash.js +139 -0
  6. package/dist/main.js +27 -3
  7. package/dist/mcp.d.ts +3 -0
  8. package/dist/mcp.js +49 -1
  9. package/dist/media-index.d.ts +67 -0
  10. package/dist/media-index.js +114 -0
  11. package/dist/media-local.d.ts +43 -0
  12. package/dist/media-local.js +88 -0
  13. package/dist/media-page.d.ts +18 -0
  14. package/dist/media-page.js +146 -0
  15. package/dist/media.d.ts +145 -0
  16. package/dist/media.js +364 -0
  17. package/dist/server.d.ts +3 -0
  18. package/dist/server.js +200 -35
  19. package/dist/transcribe.js +20 -0
  20. package/dist/transcript-client.d.ts +19 -0
  21. package/dist/transcript-client.js +24 -0
  22. package/dist/trollbox.d.ts +5 -6
  23. package/dist/trollbox.js +0 -0
  24. package/package.json +1 -1
  25. package/src/captions.ts +32 -2
  26. package/src/hash.ts +139 -0
  27. package/src/main.ts +27 -3
  28. package/src/mcp.ts +49 -1
  29. package/src/media-index.ts +145 -0
  30. package/src/media-local.ts +117 -0
  31. package/src/media-page.ts +159 -0
  32. package/src/media.ts +433 -0
  33. package/src/server.ts +192 -34
  34. package/src/transcribe.ts +17 -0
  35. package/src/transcript-client.ts +34 -0
  36. package/src/trollbox.ts +0 -0
  37. package/web/dist/assets/{hls-3VKVEQE3-Dtl-3mpW.js → hls-3VKVEQE3-B4ltbKDh.js} +1 -1
  38. package/web/dist/assets/index-d7TvpeFZ.js +1 -0
  39. package/web/dist/assets/index-oyp61Kly.css +1 -0
  40. package/web/dist/assets/{mpegts-BJC48bFV.js → mpegts-Byy3EkfT.js} +1 -1
  41. package/web/dist/assets/{mpegts-LO6RVLD6-CUIAB9k3.js → mpegts-LO6RVLD6-C9qqolrW.js} +1 -1
  42. package/web/dist/index.html +2 -2
  43. package/web/dist/sw.js +6 -6
  44. package/web/dist/assets/index-B0h4Nexr.js +0 -1
  45. package/web/dist/assets/index-BTGV3Pi5.css +0 -1
package/README.md CHANGED
@@ -147,6 +147,12 @@ site. In the **Parties** panel, enter an **Invite code** and select **Join party
147
147
  to open its room. A listed party's **Join party** link opens the film on the
148
148
  site hosting it; **Open room** opens its nixamp room.
149
149
 
150
+ A live party also shows the current file's source and folder path, so a course
151
+ can read **Course › Section › Lecture**, with its position in that folder's
152
+ playlist. Numbered download filenames become readable lecture titles. Share
153
+ sheets, browser tabs and device media information follow the current lecture;
154
+ course names come from the server's metadata, never a guess from the filename.
155
+
150
156
  A watch party lives on the site that has the film. bittorrented.com has them:
151
157
  a six-character code, a host, and everybody at the same second. nixamp has
152
158
  rooms, chat, invitations, a directory, and five clients that can already open
@@ -331,8 +337,9 @@ is tap, talk, tap: the page records, brings the sound to 16 kHz mono itself,
331
337
  and sends nixamp.com a small WAV with the room's name on it; the ear posts
332
338
  the words to the room in the same request, and the line appears. Said is
333
339
  sent, unless the **Edit before sending** switch under the box is on, in
334
- which case the words wait in the box for Send. A wrong line is taken down
335
- with its ✕.
340
+ which case the words wait in the box for Send. A line, once sent, is public
341
+ record: there is no taking it down, not by its author and not by the
342
+ server's owner.
336
343
 
337
344
  **On the phone, too.** Every live room has a six-digit code on the party
338
345
  line (see below), and only when somebody is on the phone in a room, each
@@ -452,6 +459,44 @@ and a page that asks for it reads what there is so far. An agent has
452
459
  `transcript_get` and `transcripts_list`, and `transcribe_audio` keeps a film
453
460
  the same way.
454
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
+
455
500
  ### In another language
456
501
 
457
502
  Ask for a language and the lines come translated, by an open-source model on
@@ -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). And who
252
- you are in the rooms and how you sound on the phone (profile_get,
253
- profile_set, voices_list). It acts as whoever this machine is signed in as,
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
+ }