@hraness/sponge 0.3.4 → 0.4.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 (40) hide show
  1. package/README.md +16 -7
  2. package/dist/migrations/011.d.ts +3 -0
  3. package/dist/migrations/011.js +9 -0
  4. package/dist/migrations/index.js +2 -0
  5. package/dist/src/annotations/quote.d.ts +1 -0
  6. package/dist/src/annotations/quote.js +11 -1
  7. package/dist/src/cli/commands.js +51 -6
  8. package/dist/src/cli/runtimes.js +6 -2
  9. package/dist/src/cli/worker.js +5 -1
  10. package/dist/src/digest/endpoint.d.ts +3 -2
  11. package/dist/src/digest/endpoint.js +3 -2
  12. package/dist/src/digest/pipeline.js +6 -3
  13. package/dist/src/digest/runner.d.ts +33 -0
  14. package/dist/src/digest/runner.js +82 -0
  15. package/dist/src/digest/schema.js +6 -2
  16. package/dist/src/digest/server.d.ts +40 -0
  17. package/dist/src/digest/server.js +166 -0
  18. package/dist/src/errors.d.ts +1 -0
  19. package/dist/src/errors.js +2 -0
  20. package/dist/src/importers/custody.d.ts +2 -5
  21. package/dist/src/importers/custody.js +3 -51
  22. package/dist/src/models/artifacts.d.ts +5 -0
  23. package/dist/src/models/artifacts.js +92 -17
  24. package/dist/src/models/digest.d.ts +64 -0
  25. package/dist/src/models/digest.js +122 -0
  26. package/dist/src/portability/database.js +2 -0
  27. package/dist/src/search/query.js +7 -54
  28. package/dist/src/store/digest-jobs.d.ts +55 -0
  29. package/dist/src/store/digest-jobs.js +164 -0
  30. package/dist/src/store/library.d.ts +3 -0
  31. package/dist/src/store/library.js +5 -1
  32. package/dist/src/store/queue.d.ts +9 -5
  33. package/dist/src/store/queue.js +14 -9
  34. package/dist/src/store/revisions.js +4 -0
  35. package/dist/src/worker/custody.d.ts +5 -0
  36. package/dist/src/worker/custody.js +7 -4
  37. package/dist/src/worker/transient.d.ts +17 -0
  38. package/dist/src/worker/transient.js +60 -0
  39. package/native/runtime-manifest.json +4 -4
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -121,21 +121,30 @@ pruned generation needs a rebuild.
121
121
  A digest (Digest v2) is a short gist led by one sentence and 3–5 ideas, each a
122
122
  claim with detail and 0–2 verbatim quotes, with 1–6 quotes in all.
123
123
  A local model proposes it; Sponge checks every rule against the captured text
124
- and stores only a passing draft. Drafting is opt-in and needs a model server you
125
- start yourself, such as llama.cpp `llama-server`, LM Studio or Ollama:
124
+ and stores only a passing draft. Drafting is opt-in. `sponge setup --digest`
125
+ installs a pinned llama.cpp and model (Gemma 4 26B-A4B with 32 GB of memory or
126
+ more, Qwen3.5 9B with 16 GB; about 17 or 6 GB of disk), and from then on the
127
+ background worker digests each new capture:
126
128
 
127
129
  ```sh
128
- llama-server -m gemma-4-12b-it-Q4_K_M.gguf --port 8080
129
- sponge digest ITEM --endpoint http://127.0.0.1:8080 --model NAME
130
+ sponge setup --digest # or --model-file GGUF to reuse a download
131
+ sponge digest ITEM --draft # draft one item now
132
+ sponge digest --auto off # stop background digests (on to resume)
130
133
  sponge digest ITEM
131
134
  sponge review DIGEST --accept all --reject quote:1.0
132
135
  ```
133
136
 
137
+ Sponge runs its model server sandboxed on loopback, only while drafting, and
138
+ waits while another `llama-server` holds the GPU. To use a server you started
139
+ yourself (llama.cpp `llama-server`, LM Studio or Ollama), pass its endpoint:
140
+
141
+ ```sh
142
+ sponge digest ITEM --endpoint http://127.0.0.1:8080 --model NAME
143
+ ```
144
+
134
145
  `--model` must match an id the server lists at `/v1/models`. Endpoints must be
135
146
  literal loopback HTTP (`127.0.0.1` or `[::1]`); Sponge refuses anything else so
136
- captured text never leaves the machine, and never starts or downloads a model.
137
- Suggested models are Gemma 4 12B on 16 GB Macs and Gemma 4 E4B on 8 GB, with
138
- Qwen3.5 9B or 4B as alternatives. Rerunning with the same inputs returns the stored
147
+ captured text never leaves the machine. Rerunning with the same inputs returns the stored
139
148
  draft. Review targets are `all`, `gist`, `idea:N` and `quote:N.M` (0-based); only
140
149
  accepted fields can be published. `--check-support` also requires each quote to
141
150
  support its claim by local embedding similarity. See [digest drafting](src/digest/README.md).
@@ -0,0 +1,3 @@
1
+ /** Digest jobs: one per captured revision, drafted by the worker with the model from `sponge setup --digest`. Local to this device (never in the change log); the server's process custody is the worker's filesystem journal, so a job has no attempt rows or staging. `digest_settings` holds the auto-digest switch and the chosen model alias. Additive. */
2
+ export declare const version = 11;
3
+ export declare const sql = "\nCREATE TABLE digest_jobs(id TEXT PRIMARY KEY,item_id TEXT NOT NULL REFERENCES items(id),revision_id TEXT NOT NULL UNIQUE REFERENCES revisions(id),state TEXT NOT NULL CHECK(state IN ('queued','delayed','running','failed','cancelled','succeeded')),cancel_requested INTEGER NOT NULL DEFAULT 0 CHECK(cancel_requested IN (0,1)),attempt_count INTEGER NOT NULL DEFAULT 0,reason TEXT,retryable INTEGER NOT NULL DEFAULT 0 CHECK(retryable IN (0,1)),not_before TEXT,owner_incarnation TEXT,fence INTEGER,derivation_id TEXT,created_at TEXT NOT NULL,finished_at TEXT) STRICT;\nCREATE INDEX digest_jobs_ready ON digest_jobs(state,not_before,created_at);\nCREATE INDEX digest_jobs_item ON digest_jobs(item_id);\nCREATE TABLE digest_settings(id INTEGER PRIMARY KEY CHECK(id=1),auto INTEGER NOT NULL DEFAULT 0 CHECK(auto IN (0,1)),model TEXT) STRICT;\nINSERT INTO digest_settings(id) VALUES(1);\n";
@@ -0,0 +1,9 @@
1
+ /** Digest jobs: one per captured revision, drafted by the worker with the model from `sponge setup --digest`. Local to this device (never in the change log); the server's process custody is the worker's filesystem journal, so a job has no attempt rows or staging. `digest_settings` holds the auto-digest switch and the chosen model alias. Additive. */
2
+ export const version = 11;
3
+ export const sql = `
4
+ CREATE TABLE digest_jobs(id TEXT PRIMARY KEY,item_id TEXT NOT NULL REFERENCES items(id),revision_id TEXT NOT NULL UNIQUE REFERENCES revisions(id),state TEXT NOT NULL CHECK(state IN ('queued','delayed','running','failed','cancelled','succeeded')),cancel_requested INTEGER NOT NULL DEFAULT 0 CHECK(cancel_requested IN (0,1)),attempt_count INTEGER NOT NULL DEFAULT 0,reason TEXT,retryable INTEGER NOT NULL DEFAULT 0 CHECK(retryable IN (0,1)),not_before TEXT,owner_incarnation TEXT,fence INTEGER,derivation_id TEXT,created_at TEXT NOT NULL,finished_at TEXT) STRICT;
5
+ CREATE INDEX digest_jobs_ready ON digest_jobs(state,not_before,created_at);
6
+ CREATE INDEX digest_jobs_item ON digest_jobs(item_id);
7
+ CREATE TABLE digest_settings(id INTEGER PRIMARY KEY CHECK(id=1),auto INTEGER NOT NULL DEFAULT 0 CHECK(auto IN (0,1)),model TEXT) STRICT;
8
+ INSERT INTO digest_settings(id) VALUES(1);
9
+ `;
@@ -10,6 +10,7 @@ import * as m7 from './007.js';
10
10
  import * as m8 from './008.js';
11
11
  import * as m9 from './009.js';
12
12
  import * as m10 from './010.js';
13
+ import * as m11 from './011.js';
13
14
  import { syncReady } from '../src/store/sync-ready.js';
14
15
  import { incrementalIndex } from '../src/indexing/upgrade.js';
15
16
  /** Every migration, in order. Add a new numbered file and append it here; never edit or reorder a released entry. */
@@ -30,6 +31,7 @@ export const migrations = Object.freeze([
30
31
  m8,
31
32
  { ...m9, step: syncReady },
32
33
  { ...m10, step: incrementalIndex },
34
+ m11,
33
35
  ].map((m) => Object.freeze({ ...m })));
34
36
  migrations.forEach((m, i) => {
35
37
  if (m.version !== i + 1)
@@ -20,6 +20,7 @@ export declare function textQuote(source: string, start: number, end: number): T
20
20
  export declare function locateQuote(source: string, quote: string): TextQuote | undefined;
21
21
  /** A stored selector still names its exact text in `source`. */
22
22
  export declare function anchorHolds(source: string, anchor: TextQuote): boolean;
23
+ /** Word count: space-separated tokens, with each run of Han or kana counted as half its characters, rounded up. */
23
24
  export declare function words(value: string): number;
24
25
  /**
25
26
  * Every occurrence of `quote` in `source` ignoring inline Markdown and case, within optional raw bounds.
@@ -107,9 +107,19 @@ export function anchorHolds(source, anchor) {
107
107
  anchor.end > anchor.start &&
108
108
  source.slice(anchor.start, anchor.end) === anchor.exact);
109
109
  }
110
+ /** Han and kana are written without spaces; about two characters make a word. */
111
+ const unspaced = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/gu;
112
+ /** Word count: space-separated tokens, with each run of Han or kana counted as half its characters, rounded up. */
110
113
  export function words(value) {
111
114
  const t = foldText(value);
112
- return t ? t.split(' ').length : 0;
115
+ if (!t)
116
+ return 0;
117
+ let n = 0;
118
+ for (const token of t.split(' ')) {
119
+ const cjk = token.match(unspaced)?.length ?? 0;
120
+ n += cjk ? Math.ceil(cjk / 2) + (token.replace(unspaced, '').match(/[\p{L}\p{N}]/u) ? 1 : 0) : 1;
121
+ }
122
+ return n;
113
123
  }
114
124
  /**
115
125
  * Rendered-text projection for snapping a near-verbatim quote: drops inline Markdown syntax (link targets, brackets,
@@ -9,6 +9,8 @@ import { portabilityAction } from './portability.js';
9
9
  import { restoreLibrary } from '../portability/backup.js';
10
10
  import { workerAction, serveWorker, hasDueAcquisition, workerDoctor } from './worker.js';
11
11
  import { nativeSetupPlan, provisionNative, inspectNative } from '../models/runtime.js';
12
+ import { digestModel, digestSetupPlan, inspectDigest, provisionDigest } from '../models/digest.js';
13
+ import { withDigestServer } from '../digest/server.js';
12
14
  import { nativeDownloadAuthGuidance } from '../models/download.js';
13
15
  import { drainIndexQueue } from '../indexing/runner.js';
14
16
  import { indexAction, overviewText } from './indexing.js';
@@ -123,12 +125,35 @@ export const commands = [
123
125
  {
124
126
  name: 'setup',
125
127
  summary: 'Download one pinned runtime',
126
- usage: [['setup --capture|--asr|--embedding', 'Download one pinned runtime']],
128
+ usage: [
129
+ ['setup --capture|--asr|--embedding', 'Download one pinned runtime'],
130
+ [
131
+ 'setup --digest [--digest-model ALIAS] [--model-file GGUF]',
132
+ 'Install the local digest model; --model-file copies a GGUF you already have',
133
+ ],
134
+ ],
127
135
  positional: 0,
128
- flags: { capture: 'boolean', asr: 'boolean', embedding: 'boolean' },
136
+ flags: {
137
+ capture: 'boolean',
138
+ asr: 'boolean',
139
+ embedding: 'boolean',
140
+ digest: 'boolean',
141
+ 'digest-model': 'value',
142
+ 'model-file': 'value',
143
+ },
129
144
  library: 'open',
130
- check: (_, f) => [f.capture, f.asr, f.embedding].filter(Boolean).length === 1,
145
+ check: (_, f) => [f.capture, f.asr, f.embedding, f.digest].filter(Boolean).length === 1 &&
146
+ (!!f.digest || (f['digest-model'] === undefined && f['model-file'] === undefined)) &&
147
+ (f['model-file'] === undefined || abs(f['model-file'])),
131
148
  handler: async (c) => {
149
+ if (c.args.flags.digest) {
150
+ const flags = c.args.flags, pin = digestModel(typeof flags['digest-model'] === 'string' ? flags['digest-model'] : undefined), modelFile = typeof flags['model-file'] === 'string' ? flags['model-file'] : undefined;
151
+ process.stderr.write(JSON.stringify({ setup: 'digest', adopt: !!modelFile, ...digestSetupPlan(pin) }) + '\n');
152
+ await provisionDigest(c.nativeRoot, pin, { signal: c.signal, modelFile });
153
+ const gc = await setupRuntimeGc(c.store, c.version), settings = c.store.digests.configure({ auto: true, model: pin.alias });
154
+ c.emit({ digest: 'ready', model: pin.alias, auto: settings.auto, gc }, `Digest model ready: ${pin.alias}. New captures are digested in the background; turn that off with \`sponge digest --auto off\`.`);
155
+ return 0;
156
+ }
132
157
  if (!c.args.flags.capture) {
133
158
  const kind = c.args.flags.asr ? 'asr' : 'embedding', plan = await nativeSetupPlan(kind, c.version);
134
159
  process.stderr.write(JSON.stringify({ setup: kind, ...plan }) + '\n');
@@ -691,16 +716,36 @@ export const commands = [
691
716
  'digest ITEM --endpoint http://127.0.0.1:PORT --model NAME [--check-support]',
692
717
  'Draft a digest with a local model server you started',
693
718
  ],
719
+ ['digest ITEM --draft [--model ALIAS]', 'Draft a digest with the model from `sponge setup --digest`'],
694
720
  ['digest ITEM', 'Show the current digest and its review state'],
721
+ ['digest --auto on|off', 'Digest new captures in the background with the worker, or stop'],
695
722
  ],
696
- positional: 1,
697
- flags: { endpoint: 'value', model: 'value', 'check-support': 'boolean' },
723
+ positional: (p) => p.length <= 1,
724
+ flags: { endpoint: 'value', model: 'value', 'check-support': 'boolean', draft: 'boolean', auto: 'value' },
698
725
  library: 'open',
699
- check: (_, f) => Boolean(f.endpoint) === Boolean(f.model) &&
726
+ check: (p, f) => (f.auto === undefined
727
+ ? p.length === 1
728
+ : p.length === 0 &&
729
+ ['on', 'off'].includes(String(f.auto)) &&
730
+ !f.draft &&
731
+ !f.endpoint &&
732
+ !f.model &&
733
+ !f['check-support']) &&
734
+ (f.draft ? !f.endpoint && !f['check-support'] : Boolean(f.endpoint) === Boolean(f.model)) &&
700
735
  !(f['check-support'] && !f.endpoint) &&
701
736
  (f.model === undefined || /^[\w.:/@+-]{1,256}$/.test(String(f.model))),
702
737
  handler: async (c) => {
703
738
  const { store, args, signal } = c;
739
+ if (args.flags.auto !== undefined) {
740
+ const settings = store.digests.configure({ auto: args.flags.auto === 'on' });
741
+ c.emit({ auto: settings.auto }, `Auto-digest ${settings.auto ? 'on' : 'off'}`);
742
+ return 0;
743
+ }
744
+ if (args.flags.draft) {
745
+ const runtime = await inspectDigest(c.nativeRoot, args.flags.model ?? store.digests.settings().model ?? undefined), result = await withDigestServer(store.root, runtime, (model) => createDigest(store, args.positional[0], model, { signal }), { signal });
746
+ c.emit({ digest: result.derivation, duplicate: result.duplicate, ...(result.trace ? { trace: result.trace } : {}) }, digestText(result.derivation));
747
+ return 0;
748
+ }
704
749
  if (!args.flags.endpoint) {
705
750
  const digest = store.derivations.current('digest', args.positional[0]);
706
751
  if (!digest)
@@ -1,15 +1,19 @@
1
1
  import { defaultCapturePins } from '../acquisition/index.js';
2
2
  import { loadNativeCatalog, nativeModelPackage } from '../models/catalog.js';
3
3
  import { modelSetupPlan } from '../models/artifacts.js';
4
+ import { digestFingerprints } from '../models/digest.js';
4
5
  import { collectRuntimes } from '../worker/runtime-gc.js';
5
6
  import { codeOf } from '../errors.js';
6
- /** References of this installed Sponge: its capture pin and its catalog's model packages. */
7
+ /** References of this installed Sponge: its capture pin, its catalog's model packages and every digest pin. */
7
8
  async function installedReferences(version) {
8
9
  let models = null;
9
10
  try {
10
11
  const catalog = await loadNativeCatalog(version);
11
12
  models = {
12
- fingerprints: ['asr', 'embedding'].map((kind) => modelSetupPlan(nativeModelPackage(catalog, kind)).fingerprint),
13
+ fingerprints: [
14
+ ...['asr', 'embedding'].map((kind) => modelSetupPlan(nativeModelPackage(catalog, kind)).fingerprint),
15
+ ...digestFingerprints(),
16
+ ],
13
17
  };
14
18
  }
15
19
  catch {
@@ -1,6 +1,7 @@
1
1
  import { join } from 'node:path';
2
2
  import { inspectNative } from '../models/runtime.js';
3
3
  import { drainIndexQueue } from '../indexing/runner.js';
4
+ import { drainDigestQueue } from '../digest/runner.js';
4
5
  import { drainLibrary } from '../worker/basic.js';
5
6
  import { recoverServiceLifetime } from '../service/lifetime.js';
6
7
  import { runWorkerService } from '../service/loop.js';
@@ -47,7 +48,7 @@ export function hasDueAcquisition(store) {
47
48
  const now = new Date().toISOString();
48
49
  return store.queue
49
50
  .list()
50
- .some((job) => job.operation !== 'index' &&
51
+ .some((job) => !['index', 'digest'].includes(String(job.operation)) &&
51
52
  ['queued', 'delayed'].includes(String(job.state)) &&
52
53
  job.cancel_requested !== 1 &&
53
54
  (job.not_before === null || job.not_before === undefined || String(job.not_before) <= now));
@@ -83,6 +84,9 @@ export async function drainServiceQueues(store, options, version, signal) {
83
84
  const runtime = await inspectNative('embedding', join(store.root, 'runtime', 'models'), version);
84
85
  results.push(...(await drainIndexQueue(store, { runtime, signal })));
85
86
  }
87
+ // Last, under its own time budget: drafting holds the GPU and can take minutes per item.
88
+ if (!signal.aborted && store.queue.pendingCleanup() === 0 && store.digests.due())
89
+ results.push(...(await drainDigestQueue(store, { nativeRoot: join(store.root, 'runtime', 'models'), signal })));
86
90
  if (acquisitionError)
87
91
  throw acquisitionError;
88
92
  return results;
@@ -1,7 +1,8 @@
1
1
  import { type DigestModel } from './pipeline.js';
2
2
  /**
3
- * A user-started local OpenAI-compatible server (llama.cpp `llama-server`, LM Studio, Ollama). Captured text must not
4
- * leave the machine, so only literal loopback HTTP origins are accepted, redirects are refused, and Sponge never starts a server.
3
+ * A local OpenAI-compatible server: one the user started (llama.cpp `llama-server`, LM Studio, Ollama), or Sponge's own
4
+ * (src/digest/server.ts). Captured text must not leave the machine, so only literal loopback HTTP origins are accepted
5
+ * and redirects are refused.
5
6
  */
6
7
  export declare function loopbackEndpoint(value: string): URL;
7
8
  /** GGUF quantization from a model filename, e.g. `gemma-4-12b-it-Q4_K_M.gguf` → `Q4_K_M`. */
@@ -1,7 +1,8 @@
1
1
  import { SpongeError } from '../errors.js';
2
2
  /**
3
- * A user-started local OpenAI-compatible server (llama.cpp `llama-server`, LM Studio, Ollama). Captured text must not
4
- * leave the machine, so only literal loopback HTTP origins are accepted, redirects are refused, and Sponge never starts a server.
3
+ * A local OpenAI-compatible server: one the user started (llama.cpp `llama-server`, LM Studio, Ollama), or Sponge's own
4
+ * (src/digest/server.ts). Captured text must not leave the machine, so only literal loopback HTTP origins are accepted
5
+ * and redirects are refused.
5
6
  */
6
7
  export function loopbackEndpoint(value) {
7
8
  let url;
@@ -317,10 +317,13 @@ export function snapQuote(source, text, within) {
317
317
  export function trimQuote(text, max = L.quoteWords) {
318
318
  if (words(text) <= max)
319
319
  return text;
320
- const ends = [...text.matchAll(/\S+/g)].map((m) => m.index + m[0].length);
321
- let n = Math.min(max, ends.length), out = text;
320
+ // Cut after a space-separated token or, inside unspaced Han and kana, after any character.
321
+ const ends = [
322
+ ...new Set([...text.matchAll(/\S+/g), ...text.matchAll(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]/gu)].map((m) => m.index + m[0].length)),
323
+ ].sort((a, b) => a - b);
324
+ let n = ends.length, out = text;
322
325
  do {
323
- out = text.slice(0, ends[n - 1]).replace(/[\s,;:(\[\u2014\u2013-]+$/, '');
326
+ out = text.slice(0, ends[n - 1]).replace(/[\s,;:(\[\u2014\u2013\u3001\uff0c-]+$/, '');
324
327
  n--;
325
328
  } while (n > 0 && words(out) > max);
326
329
  return out;
@@ -0,0 +1,33 @@
1
+ import type { LibraryStore } from '../store/index.js';
2
+ import { inspectDigest } from '../models/digest.js';
3
+ import { type DigestServerOptions } from './server.js';
4
+ import { createDigest } from './pipeline.js';
5
+ export type DigestRunResult = {
6
+ ok: true;
7
+ jobId: string;
8
+ digestId: string;
9
+ duplicate: boolean;
10
+ } | {
11
+ ok: false;
12
+ jobId?: string;
13
+ code: string;
14
+ postponed?: number;
15
+ };
16
+ export interface DigestRunOptions {
17
+ /** `<library>/runtime/models`, where `sponge setup --digest` installed the runtime. */
18
+ nativeRoot: string;
19
+ signal?: AbortSignal;
20
+ /** No new job is claimed after this long; the one drafting finishes. Default 10 minutes. */
21
+ budgetMs?: number;
22
+ /** Test seams. */
23
+ inspect?: typeof inspectDigest;
24
+ create?: typeof createDigest;
25
+ server?: Partial<Omit<DigestServerOptions, 'runProcess' | 'scratchRoot' | 'signal'>>;
26
+ }
27
+ /**
28
+ * Draft queued digests with one Sponge-owned server per drain (`withDigestServer`), claiming jobs under the worker lease
29
+ * until the queue is empty, the budget is spent or the signal aborts. Never throws for a missing runtime, a busy GPU or
30
+ * a server that would not start: the ready jobs are postponed with that reason, so capture and indexing keep running.
31
+ * `cleanup-required` and `owner-busy` still throw, as the service loop expects.
32
+ */
33
+ export declare function drainDigestQueue(store: LibraryStore, options: DigestRunOptions): Promise<DigestRunResult[]>;
@@ -0,0 +1,82 @@
1
+ import { inspectDigest } from '../models/digest.js';
2
+ import { withDigestServer } from './server.js';
3
+ import { createDigest } from './pipeline.js';
4
+ import { codeOf } from '../errors.js';
5
+ const minute = 60000;
6
+ /** How long ready jobs wait when drafting cannot start, by cause. Nothing reached the model, so no attempt is counted. */
7
+ const postponeMs = {
8
+ 'model-busy': 5 * minute,
9
+ 'model-unavailable': 30 * minute,
10
+ 'runtime-missing': 60 * minute,
11
+ unsupported: 60 * minute,
12
+ };
13
+ const at = (ms) => new Date(Date.now() + ms).toISOString();
14
+ /**
15
+ * Draft queued digests with one Sponge-owned server per drain (`withDigestServer`), claiming jobs under the worker lease
16
+ * until the queue is empty, the budget is spent or the signal aborts. Never throws for a missing runtime, a busy GPU or
17
+ * a server that would not start: the ready jobs are postponed with that reason, so capture and indexing keep running.
18
+ * `cleanup-required` and `owner-busy` still throw, as the service loop expects.
19
+ */
20
+ export async function drainDigestQueue(store, options) {
21
+ if (options.signal?.aborted || !store.digests.due())
22
+ return [];
23
+ const settings = store.digests.settings(), results = [], deadline = Date.now() + (options.budgetMs ?? 10 * minute), create = options.create ?? createDigest;
24
+ const postpone = (code) => [
25
+ { ok: false, code, postponed: store.digests.postpone(code, at(postponeMs[code] ?? 6 * 60 * minute)) },
26
+ ];
27
+ let runtime;
28
+ try {
29
+ runtime = await (options.inspect ?? inspectDigest)(options.nativeRoot, settings.model ?? undefined);
30
+ }
31
+ catch (error) {
32
+ return postpone(codeOf(error));
33
+ }
34
+ try {
35
+ await withDigestServer(store.root, runtime, async (model, { incarnation }) => {
36
+ const owner = store.queue.acquireOwner(incarnation);
37
+ try {
38
+ while (!options.signal?.aborted && Date.now() < deadline) {
39
+ const claim = store.digests.claim(owner);
40
+ if (!claim)
41
+ break;
42
+ try {
43
+ const { derivation, duplicate } = await create(store, claim.itemId, model, { signal: options.signal });
44
+ store.digests.finish(claim, { derivationId: derivation.id });
45
+ results.push({ ok: true, jobId: claim.jobId, digestId: derivation.id, duplicate });
46
+ }
47
+ catch (error) {
48
+ const code = codeOf(error);
49
+ if (code === 'cleanup-required')
50
+ throw error;
51
+ // A cancelled draft goes back to the queue as it was; a server that stopped answering ends the drain.
52
+ if (options.signal?.aborted)
53
+ store.digests.finish(claim, {
54
+ reason: 'cancelled',
55
+ retryable: true,
56
+ countAttempt: false,
57
+ notBefore: at(0),
58
+ });
59
+ else
60
+ store.digests.finish(claim, { reason: code, retryable: code === 'model-unavailable' });
61
+ results.push({ ok: false, jobId: claim.jobId, code });
62
+ if (options.signal?.aborted || code === 'model-unavailable')
63
+ break;
64
+ }
65
+ }
66
+ }
67
+ finally {
68
+ store.queue.releaseOwner(owner);
69
+ }
70
+ }, { ...options.server, signal: options.signal });
71
+ }
72
+ catch (error) {
73
+ const code = codeOf(error);
74
+ if (code === 'cleanup-required' || code === 'owner-busy')
75
+ throw error;
76
+ // Before any claim, the server would not start or verify. After one, every job is settled already.
77
+ if (!results.length)
78
+ return postpone(code);
79
+ results.push({ ok: false, code });
80
+ }
81
+ return results;
82
+ }
@@ -49,8 +49,12 @@ const keys = (v, allowed) => Object.keys(v).every((k) => allowed.includes(k));
49
49
  const str = (v, max = 2000) => typeof v === 'string' && v.trim().length > 0 && v.length <= max && !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(v);
50
50
  /** v1: sentence terminators followed by more text; abbreviations are rare enough in a 155-character lead to reject. */
51
51
  const sentencesV1 = (v) => foldText(v).split(/(?<=[.!?])\s+(?=\S)/).length;
52
- /** v2: a terminator (and closing quotes) followed by a capital or digit, the same split Jungle's first-sentence lead uses, so `e.g. this` stays one sentence. */
53
- const sentences = (v) => foldText(v).split(/(?<=[.!?]["')]*)\s+(?=["(]?[A-Z0-9])/).length;
52
+ /**
53
+ * v2: a terminator (and closing quotes) followed by a capital or digit, the split Jungle's first-sentence lead uses, so
54
+ * `e.g. this` stays one sentence. A period after an initial or a common title does not end a sentence: `Lucas C.
55
+ * Cordeiro`, `F. Zakaria`, `U.S. Senate`, `Dr. Lind`.
56
+ */
57
+ const sentences = (v) => foldText(v).split(/(?<=(?:(?<!\b(?:[A-Z]|Mr|Mrs|Ms|Dr|Prof|St|Jr|Sr|vs|No|Inc|Ltd|Co))\.|[!?])["')]*)\s+(?=["(]?[A-Z0-9])/).length;
54
58
  /** Parse an untrusted digest (v1 or v2) into its shape, or report why not. */
55
59
  export function parseDigest(value) {
56
60
  const issues = [], bad = (path, code = 'shape') => {
@@ -0,0 +1,40 @@
1
+ import type { ProcessRunner } from '../worker/supervisor.js';
2
+ import { type DigestRuntime } from '../models/digest.js';
3
+ import type { DigestModel } from './pipeline.js';
4
+ /**
5
+ * The Sponge-owned digest server: the verified llama.cpp archive is unpacked into the attempt's scratch root and
6
+ * `llama-server` runs under `sandbox-exec` with outbound connections other than loopback denied, bound to 127.0.0.1
7
+ * on a free port, through the caller's process runner (the worker supervisor), so the server's process group is
8
+ * collected and proved like any helper even if this process dies. Captured text never leaves the machine.
9
+ */
10
+ export declare const sandboxProfile = "(version 1) (allow default) (deny network-outbound (remote ip \"*:*\")) (allow network-outbound (remote ip \"localhost:*\"))";
11
+ /** Other `llama-server` processes in `ps -axo pid=,command=` output, with the model each loaded when it names one. */
12
+ export declare function otherServers(ps: string, self?: number): {
13
+ pid: number;
14
+ model?: string;
15
+ }[];
16
+ /** An ephemeral loopback port; the kernel picks one that is free now. */
17
+ export declare function freePort(): Promise<number>;
18
+ export interface DigestServerOptions {
19
+ runProcess: ProcessRunner;
20
+ /** A private directory owned by the caller's attempt; removed by the caller after `stop`. */
21
+ scratchRoot: string;
22
+ signal?: AbortSignal;
23
+ ctx?: number;
24
+ readyTimeoutMs?: number;
25
+ /** Longest a server may run before the runner stops it. */
26
+ lifetimeMs?: number;
27
+ processes?: () => Promise<string>;
28
+ /** The executable inside the archive's directory; tests substitute a fake. */
29
+ executable?: string;
30
+ sandbox?: boolean;
31
+ }
32
+ /**
33
+ * Run `fn` against a Sponge-owned server for `runtime`, held as a `digest-server` attempt under worker custody: the
34
+ * server is stopped and its process group proved collected before custody is released.
35
+ */
36
+ export declare function withDigestServer<T>(libraryRoot: string, runtime: DigestRuntime, fn: (model: DigestModel, custody: {
37
+ incarnation: string;
38
+ }) => Promise<T>, options?: {
39
+ signal?: AbortSignal;
40
+ } & Partial<Omit<DigestServerOptions, 'runProcess' | 'scratchRoot'>>): Promise<T>;
@@ -0,0 +1,166 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { lstat, readdir, realpath } from 'node:fs/promises';
3
+ import { createServer } from 'node:net';
4
+ import { join } from 'node:path';
5
+ import { directory } from '../worker/journal.js';
6
+ import { llamaRuntimePin } from '../models/digest.js';
7
+ import { withTransientAttempt } from '../worker/transient.js';
8
+ import { connectEndpoint } from './endpoint.js';
9
+ import { SpongeError } from '../errors.js';
10
+ /**
11
+ * The Sponge-owned digest server: the verified llama.cpp archive is unpacked into the attempt's scratch root and
12
+ * `llama-server` runs under `sandbox-exec` with outbound connections other than loopback denied, bound to 127.0.0.1
13
+ * on a free port, through the caller's process runner (the worker supervisor), so the server's process group is
14
+ * collected and proved like any helper even if this process dies. Captured text never leaves the machine.
15
+ */
16
+ export const sandboxProfile = '(version 1) (allow default) (deny network-outbound (remote ip "*:*")) (allow network-outbound (remote ip "localhost:*"))';
17
+ /** Other `llama-server` processes in `ps -axo pid=,command=` output, with the model each loaded when it names one. */
18
+ export function otherServers(ps, self = process.pid) {
19
+ return ps.split('\n').flatMap((line) => {
20
+ const m = /^\s*(\d+)\s+(\S*llama-server)(\s.*)?$/.exec(line);
21
+ if (!m || Number(m[1]) === self)
22
+ return [];
23
+ const model = /\s(?:-m|--model)\s+(\S+)/.exec(m[3] ?? '')?.[1];
24
+ return [{ pid: Number(m[1]), ...(model ? { model: model.split('/').pop() } : {}) }];
25
+ });
26
+ }
27
+ const listProcesses = () => new Promise((ok, fail) => execFile('/bin/ps', ['-axo', 'pid=,command='], { maxBuffer: 16 * 1024 ** 2 }, (e, out) => (e ? fail(e) : ok(out))));
28
+ /** An ephemeral loopback port; the kernel picks one that is free now. */
29
+ export function freePort() {
30
+ return new Promise((ok, fail) => {
31
+ const s = createServer();
32
+ s.once('error', fail);
33
+ s.listen(0, '127.0.0.1', () => {
34
+ const port = s.address().port;
35
+ s.close(() => ok(port));
36
+ });
37
+ });
38
+ }
39
+ /** Every unpacked entry is a file, directory or a symlink resolving inside `root`. */
40
+ async function confined(root, dir = root) {
41
+ for (const name of await readdir(dir)) {
42
+ const path = join(dir, name), s = await lstat(path);
43
+ if (s.isDirectory())
44
+ await confined(root, path);
45
+ else if (s.isSymbolicLink()) {
46
+ if (!(await realpath(path)).startsWith(root + '/'))
47
+ throw new SpongeError('integrity');
48
+ }
49
+ else if (!s.isFile())
50
+ throw new SpongeError('integrity');
51
+ }
52
+ }
53
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
54
+ async function startDigestServer(runtime, o) {
55
+ const [other] = otherServers(await (o.processes ?? listProcesses)());
56
+ // Two loaded models exhaust GPU memory and every request fails; wait for the other server instead.
57
+ if (other)
58
+ throw new SpongeError('model-busy', { detail: { pid: other.pid } });
59
+ const unpacked = await directory(join(o.scratchRoot, 'llama')), env = { PATH: '/usr/bin:/bin', HOME: o.scratchRoot, TMPDIR: o.scratchRoot, LANG: 'en_US.UTF-8' };
60
+ const tar = await o.runProcess('/usr/bin/tar', ['-xzf', runtime.archive, '-C', unpacked, '--no-same-owner'], {
61
+ env,
62
+ signal: o.signal,
63
+ deadlineMs: 120000,
64
+ maxOutputBytes: 65536,
65
+ });
66
+ if (tar.stopped === 'cancelled')
67
+ throw new SpongeError('cancelled');
68
+ if (tar.cleanup !== 'settled')
69
+ throw new SpongeError('cleanup-required');
70
+ if (tar.code !== 0 || tar.stopped)
71
+ throw new SpongeError('integrity');
72
+ await confined(await realpath(unpacked));
73
+ const server = join(unpacked, llamaRuntimePin.directory, o.executable ?? 'llama-server'), s = await lstat(server).catch(() => undefined);
74
+ if (!s?.isFile() || !(s.mode & 0o100))
75
+ throw new SpongeError('integrity');
76
+ const port = await freePort(), args = [
77
+ '-m',
78
+ runtime.model,
79
+ '--alias',
80
+ runtime.pin.alias,
81
+ '--host',
82
+ '127.0.0.1',
83
+ '--port',
84
+ String(port),
85
+ '-c',
86
+ String(o.ctx ?? 16384),
87
+ '-np',
88
+ '1',
89
+ '--jinja',
90
+ '--reasoning',
91
+ 'off',
92
+ '--offline',
93
+ '--no-webui',
94
+ '--log-disable',
95
+ ];
96
+ const own = new AbortController(), signal = o.signal ? AbortSignal.any([o.signal, own.signal]) : own.signal, sandbox = o.sandbox ?? true;
97
+ let result;
98
+ const run = o
99
+ .runProcess(sandbox ? '/usr/bin/sandbox-exec' : server, sandbox ? ['-p', sandboxProfile, server, ...args] : args, {
100
+ env,
101
+ signal,
102
+ deadlineMs: o.lifetimeMs ?? 6 * 3600 * 1000,
103
+ maxOutputBytes: 4 * 1024 ** 2,
104
+ graceMs: 5000,
105
+ })
106
+ .then((r) => (result = r));
107
+ let stopping;
108
+ const endpoint = `http://127.0.0.1:${port}`, handle = {
109
+ endpoint,
110
+ model: runtime.pin.alias,
111
+ stop: () => (stopping ??= (async () => {
112
+ own.abort();
113
+ return (await run).cleanup;
114
+ })()),
115
+ };
116
+ const deadline = Date.now() + (o.readyTimeoutMs ?? 180000);
117
+ try {
118
+ while (Date.now() < deadline) {
119
+ if (o.signal?.aborted)
120
+ throw new SpongeError('cancelled');
121
+ if (result)
122
+ throw new SpongeError('model-unavailable', { detail: { exited: result.code } });
123
+ // `/health` can answer before the model is listed; ready means both.
124
+ const models = await fetch(`${endpoint}/health`, { redirect: 'error', signal: AbortSignal.timeout(2000) })
125
+ .then(async (r) => {
126
+ await r.body?.cancel().catch(() => undefined);
127
+ if (!r.ok)
128
+ return undefined;
129
+ const m = await fetch(`${endpoint}/v1/models`, { redirect: 'error', signal: AbortSignal.timeout(2000) });
130
+ return m.ok ? (await m.json()) : undefined;
131
+ })
132
+ .catch(() => undefined);
133
+ if (models?.data?.some((m) => m?.id === runtime.pin.alias))
134
+ return handle;
135
+ await sleep(200);
136
+ }
137
+ throw new SpongeError('model-unavailable', { detail: { ready: false } });
138
+ }
139
+ catch (error) {
140
+ if ((await handle.stop()) === 'pending')
141
+ throw new SpongeError('cleanup-required', { cause: error });
142
+ throw error;
143
+ }
144
+ }
145
+ /**
146
+ * Run `fn` against a Sponge-owned server for `runtime`, held as a `digest-server` attempt under worker custody: the
147
+ * server is stopped and its process group proved collected before custody is released.
148
+ */
149
+ export function withDigestServer(libraryRoot, runtime, fn, options = {}) {
150
+ return withTransientAttempt(libraryRoot, 'digest-server', async ({ scratchRoot, runProcess, incarnation }) => {
151
+ const server = await startDigestServer(runtime, { ...options, runProcess, scratchRoot });
152
+ let result;
153
+ try {
154
+ const model = await connectEndpoint({ endpoint: server.endpoint, model: server.model, signal: options.signal });
155
+ // The identity must be the pinned build, so a digest's input hash names what drafted it.
156
+ if (!model.identity.runtime.includes(String(llamaRuntimePin.build)))
157
+ throw new SpongeError('integrity');
158
+ result = await fn(model, { incarnation });
159
+ }
160
+ finally {
161
+ if ((await server.stop()) === 'pending')
162
+ throw new SpongeError('cleanup-required');
163
+ }
164
+ return result;
165
+ });
166
+ }
@@ -18,6 +18,7 @@ declare const parents: {
18
18
  readonly 'no-capture': "invalid-input";
19
19
  readonly 'draft-rejected': "unavailable";
20
20
  readonly 'model-unavailable': "unavailable";
21
+ readonly 'model-busy': "unavailable";
21
22
  readonly 'parent-missing': "invalid-input";
22
23
  readonly 'target-exists': "invalid-input";
23
24
  };