@hraness/sponge 0.3.5 → 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.
- package/README.md +16 -7
- package/dist/migrations/011.d.ts +3 -0
- package/dist/migrations/011.js +9 -0
- package/dist/migrations/index.js +2 -0
- package/dist/src/cli/commands.js +51 -6
- package/dist/src/cli/runtimes.js +6 -2
- package/dist/src/cli/worker.js +5 -1
- package/dist/src/digest/endpoint.d.ts +3 -2
- package/dist/src/digest/endpoint.js +3 -2
- package/dist/src/digest/runner.d.ts +33 -0
- package/dist/src/digest/runner.js +82 -0
- package/dist/src/digest/server.d.ts +40 -0
- package/dist/src/digest/server.js +166 -0
- package/dist/src/errors.d.ts +1 -0
- package/dist/src/errors.js +2 -0
- package/dist/src/importers/custody.d.ts +2 -5
- package/dist/src/importers/custody.js +3 -51
- package/dist/src/models/artifacts.d.ts +5 -0
- package/dist/src/models/artifacts.js +92 -17
- package/dist/src/models/digest.d.ts +64 -0
- package/dist/src/models/digest.js +122 -0
- package/dist/src/portability/database.js +2 -0
- package/dist/src/search/query.js +7 -54
- package/dist/src/store/digest-jobs.d.ts +55 -0
- package/dist/src/store/digest-jobs.js +164 -0
- package/dist/src/store/library.d.ts +3 -0
- package/dist/src/store/library.js +5 -1
- package/dist/src/store/queue.d.ts +9 -5
- package/dist/src/store/queue.js +14 -9
- package/dist/src/store/revisions.js +4 -0
- package/dist/src/worker/custody.d.ts +5 -0
- package/dist/src/worker/custody.js +7 -4
- package/dist/src/worker/transient.d.ts +17 -0
- package/dist/src/worker/transient.js +60 -0
- package/native/runtime-manifest.json +4 -4
- 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
|
|
125
|
-
|
|
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
|
-
|
|
129
|
-
sponge digest ITEM --
|
|
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
|
|
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
|
+
`;
|
package/dist/migrations/index.js
CHANGED
|
@@ -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)
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -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: [
|
|
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: {
|
|
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: (
|
|
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)
|
package/dist/src/cli/runtimes.js
CHANGED
|
@@ -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
|
|
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: [
|
|
13
|
+
fingerprints: [
|
|
14
|
+
...['asr', 'embedding'].map((kind) => modelSetupPlan(nativeModelPackage(catalog, kind)).fingerprint),
|
|
15
|
+
...digestFingerprints(),
|
|
16
|
+
],
|
|
13
17
|
};
|
|
14
18
|
}
|
|
15
19
|
catch {
|
package/dist/src/cli/worker.js
CHANGED
|
@@ -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
|
|
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
|
|
4
|
-
* leave the machine, so only literal loopback HTTP origins are accepted
|
|
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
|
|
4
|
-
* leave the machine, so only literal loopback HTTP origins are accepted
|
|
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;
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/src/errors.d.ts
CHANGED
|
@@ -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
|
};
|
package/dist/src/errors.js
CHANGED
|
@@ -32,6 +32,7 @@ const parents = {
|
|
|
32
32
|
'no-capture': 'invalid-input',
|
|
33
33
|
'draft-rejected': 'unavailable',
|
|
34
34
|
'model-unavailable': 'unavailable',
|
|
35
|
+
'model-busy': 'unavailable',
|
|
35
36
|
'parent-missing': 'invalid-input',
|
|
36
37
|
'target-exists': 'invalid-input',
|
|
37
38
|
};
|
|
@@ -83,6 +84,7 @@ export const hints = {
|
|
|
83
84
|
export const detailHints = {
|
|
84
85
|
'draft-rejected': 'The model draft failed the digest checks listed above, even after one repair. Drafting is deterministic, so the same capture and model fail again; refresh the item or use another model.',
|
|
85
86
|
'model-unavailable': 'The model server did not answer or returned an error. Check that it is still running and has free GPU memory (another loaded model can exhaust it), then retry.',
|
|
87
|
+
'model-busy': 'Another llama-server is running, and two loaded models can exhaust GPU memory. Wait for it to exit, then retry.',
|
|
86
88
|
};
|
|
87
89
|
/** The one error type. `message` is the code, so bare `String(error)` never carries detail, paths or causes. */
|
|
88
90
|
export class SpongeError extends Error {
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type TransientAttempt } from '../worker/transient.js';
|
|
2
2
|
/** Read-only verification still owns subprocesses, including during an import preview. */
|
|
3
|
-
export declare function withImportVerification<T>(libraryRoot: string, verify: (context:
|
|
4
|
-
scratchRoot: string;
|
|
5
|
-
runProcess: ReturnType<typeof supervisedRunner>;
|
|
6
|
-
}) => Promise<T>): Promise<T>;
|
|
3
|
+
export declare function withImportVerification<T>(libraryRoot: string, verify: (context: Pick<TransientAttempt, 'scratchRoot' | 'runProcess'>) => Promise<T>): Promise<T>;
|