@hraness/sponge 0.3.5 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) 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/cli/commands.js +59 -7
  6. package/dist/src/cli/runtimes.js +6 -2
  7. package/dist/src/cli/worker.js +5 -1
  8. package/dist/src/digest/endpoint.d.ts +3 -2
  9. package/dist/src/digest/endpoint.js +3 -2
  10. package/dist/src/digest/pipeline.d.ts +29 -4
  11. package/dist/src/digest/pipeline.js +125 -21
  12. package/dist/src/digest/runner.d.ts +33 -0
  13. package/dist/src/digest/runner.js +82 -0
  14. package/dist/src/digest/server.d.ts +40 -0
  15. package/dist/src/digest/server.js +166 -0
  16. package/dist/src/errors.d.ts +2 -0
  17. package/dist/src/errors.js +4 -0
  18. package/dist/src/importers/custody.d.ts +2 -5
  19. package/dist/src/importers/custody.js +3 -51
  20. package/dist/src/models/artifacts.d.ts +5 -0
  21. package/dist/src/models/artifacts.js +92 -17
  22. package/dist/src/models/digest.d.ts +64 -0
  23. package/dist/src/models/digest.js +122 -0
  24. package/dist/src/portability/database.js +2 -0
  25. package/dist/src/search/query.js +7 -54
  26. package/dist/src/store/digest-jobs.d.ts +58 -0
  27. package/dist/src/store/digest-jobs.js +168 -0
  28. package/dist/src/store/library.d.ts +3 -0
  29. package/dist/src/store/library.js +5 -1
  30. package/dist/src/store/queue.d.ts +9 -5
  31. package/dist/src/store/queue.js +14 -9
  32. package/dist/src/store/revisions.js +4 -0
  33. package/dist/src/worker/custody.d.ts +5 -0
  34. package/dist/src/worker/custody.js +7 -4
  35. package/dist/src/worker/transient.d.ts +17 -0
  36. package/dist/src/worker/transient.js +60 -0
  37. package/native/runtime-manifest.json +4 -4
  38. 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)
@@ -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';
@@ -16,7 +18,7 @@ import { pruneText } from '../indexing/prune-run.js';
16
18
  import { importAction, jungleAction, jungleText } from './import.js';
17
19
  import { digestText, reviewFromSpec, reviewSpec } from './digest.js';
18
20
  import { connectEndpoint } from '../digest/endpoint.js';
19
- import { createDigest } from '../digest/pipeline.js';
21
+ import { createDigest, undigestible } from '../digest/pipeline.js';
20
22
  import { createQueryEmbedder, createTextEmbedder } from '../search/query.js';
21
23
  import { runtimeGcAction, runtimeGcText, setupRuntimeGc } from './runtimes.js';
22
24
  import { drainLibrary } from '../worker/basic.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,43 @@ 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
+ // Refuse an unknown item or a thin capture before the model server spends its startup on it.
746
+ const item = store.read(args.positional[0]);
747
+ if (!item)
748
+ throw new SpongeError('unavailable');
749
+ const thin = undigestible(item.markdown);
750
+ if (thin)
751
+ throw new SpongeError(thin);
752
+ 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 });
753
+ c.emit({ digest: result.derivation, duplicate: result.duplicate, ...(result.trace ? { trace: result.trace } : {}) }, digestText(result.derivation));
754
+ return 0;
755
+ }
704
756
  if (!args.flags.endpoint) {
705
757
  const digest = store.derivations.current('digest', args.positional[0]);
706
758
  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;
@@ -4,7 +4,7 @@ import type { Derivation, DigestCandidate } from './derivation.js';
4
4
  * Model-agnostic Digest v2 drafting. The model sees numbered passages and cites a passage id plus a short exact
5
5
  * span; code snaps, prunes and checks. Nothing leaves this module except through the injected model.
6
6
  */
7
- export declare const PROMPT_VERSION = "sponge-digest-prompt-v5";
7
+ export declare const PROMPT_VERSION = "sponge-digest-prompt-v6";
8
8
  export type ChatMessage = {
9
9
  role: 'system' | 'user' | 'assistant';
10
10
  content: string;
@@ -56,8 +56,30 @@ interface Highlight {
56
56
  * change digest input hashes.
57
57
  */
58
58
  export declare function canonicalJson(value: unknown): string;
59
- /** Paragraph-level passages with raw offsets. Headings label passages; code fences and image-only blocks are not quotable. */
60
- export declare function passages(markdown: string, maxChars?: number): Passage[];
59
+ /** Where a post starts in a captured thread, and who wrote it as the model is told. */
60
+ export interface Post {
61
+ start: number;
62
+ label: string;
63
+ }
64
+ /**
65
+ * The posts of an X thread capture: top-level list items that carry X's post links. The poster's handle comes from
66
+ * the item's avatar or name link; the thread's author from the title (`Name (@handle) on X`), else the first post. A
67
+ * later post without a handle is still a reply. Other list items (an X article's bullets) are not posts, and a thread
68
+ * with no reply has none.
69
+ */
70
+ export declare function threadPosts(markdown: string): Post[];
71
+ /** A thread's passages without its replies, which are readers' comments rather than the source; renumbered from 1. */
72
+ export declare function authorPassages(markdown: string): Passage[];
73
+ /**
74
+ * Paragraph-level passages with raw offsets. Headings label passages; code fences and image-only blocks are not
75
+ * quotable. `posts` (a thread's) also break blocks, and their labels head the passages that follow.
76
+ */
77
+ export declare function passages(markdown: string, maxChars?: number, posts?: readonly Post[]): Passage[];
78
+ /**
79
+ * Why a capture cannot be digested, as a detail code, or undefined when it can: its body (past the frontmatter, images
80
+ * dropped, link text kept) has under `minProseWords` words with letters, or only an archive's metadata was saved.
81
+ */
82
+ export declare function undigestible(markdown: string): 'source-too-thin' | undefined;
61
83
  /** Pack passages into sections of at most `budget` display characters, preferring heading boundaries past half a budget. */
62
84
  export declare function sectionsOf(list: Passage[], budget: number): Passage[][];
63
85
  /** JSON Schema for the model's draft. Tags become an enum when a vocabulary is given. */
@@ -160,8 +182,11 @@ export interface DigestStore {
160
182
  read(kind: 'digest', id: string): Derivation | undefined;
161
183
  };
162
184
  }
185
+ type DraftOptions = Omit<DraftInput, 'revisionId' | 'markdown' | 'title' | 'form' | 'model' | 'highlights'>;
186
+ /** What the item's current revision is drafted from: its Markdown, title, form and anchored highlights. */
187
+ export declare function digestInputFor(store: Pick<DigestStore, 'read' | 'annotations'>, itemId: string, model: DigestModel, options?: DraftOptions): DraftInput;
163
188
  /** Draft and store a digest for the item's current revision. A matching input hash returns the stored draft without calling the model. */
164
- export declare function createDigest(store: DigestStore, itemId: string, model: DigestModel, options?: Omit<DraftInput, 'revisionId' | 'markdown' | 'title' | 'form' | 'model' | 'highlights'>): Promise<{
189
+ export declare function createDigest(store: DigestStore, itemId: string, model: DigestModel, options?: DraftOptions): Promise<{
165
190
  derivation: Derivation;
166
191
  duplicate: boolean;
167
192
  trace?: DraftTrace;
@@ -6,7 +6,7 @@ import { sha256 } from '../util/hash.js';
6
6
  * Model-agnostic Digest v2 drafting. The model sees numbered passages and cites a passage id plus a short exact
7
7
  * span; code snaps, prunes and checks. Nothing leaves this module except through the injected model.
8
8
  */
9
- export const PROMPT_VERSION = 'sponge-digest-prompt-v5';
9
+ export const PROMPT_VERSION = 'sponge-digest-prompt-v6';
10
10
  /** 24k characters is roughly 6k tokens of passages, leaving room for instructions and output in an 8k context on an 8 GB Mac. */
11
11
  const defaultDigestParams = {
12
12
  temperature: 0,
@@ -40,14 +40,62 @@ export function canonicalJson(value) {
40
40
  throw new SpongeError('invalid-input');
41
41
  return JSON.stringify(value ?? null);
42
42
  }
43
- /** Paragraph-level passages with raw offsets. Headings label passages; code fences and image-only blocks are not quotable. */
44
- export function passages(markdown, maxChars = 1200) {
43
+ const postHandle = [
44
+ /^- \[!\[@(\w+)\]/,
45
+ /^- \[\[remote image: @(\w+)\]/,
46
+ /^-\s+\[[^\]]+\]\(https:\/\/x\.com\/(\w+)\)\s*$/,
47
+ ];
48
+ /**
49
+ * The posts of an X thread capture: top-level list items that carry X's post links. The poster's handle comes from
50
+ * the item's avatar or name link; the thread's author from the title (`Name (@handle) on X`), else the first post. A
51
+ * later post without a handle is still a reply. Other list items (an X article's bullets) are not posts, and a thread
52
+ * with no reply has none.
53
+ */
54
+ export function threadPosts(markdown) {
55
+ const author = /^title: ".*\(@(\w+)\) on X"/m.exec(frontmatter(markdown))?.[1], items = [];
56
+ let at = 0;
57
+ for (const line of markdown.split('\n')) {
58
+ if (/^- /.test(line) || /^-\s+\[/.test(line))
59
+ items.push({ start: at, handle: postHandle.map((r) => r.exec(line)?.[1]).find(Boolean), post: false });
60
+ const item = items.at(-1);
61
+ if (item && /x\.com\/(compose\/post\?in_reply_to=|\w+\/status\/\d)/.test(line))
62
+ item.post = true;
63
+ at += line.length + 1;
64
+ }
65
+ const posts = items.filter((i) => i.post || i.handle), by = author ?? posts[0]?.handle, same = (h) => h.toLowerCase() === by?.toLowerCase(), labelled = posts.map((p, i) => ({
66
+ start: p.start,
67
+ label: p.handle && !same(p.handle)
68
+ ? `Reply by @${p.handle}`
69
+ : p.handle || i === 0
70
+ ? `Post by ${by ? `@${by}, ` : ''}the author`
71
+ : 'Reply (poster not captured)',
72
+ }));
73
+ // With no reply there is nobody to tell apart, so the passages stay as they are.
74
+ return labelled.some((p) => p.label !== labelled[0].label) ? labelled : [];
75
+ }
76
+ /** A thread's passages without its replies, which are readers' comments rather than the source; renumbered from 1. */
77
+ export function authorPassages(markdown) {
78
+ return passages(markdown, undefined, threadPosts(markdown))
79
+ .filter((p) => !p.heading?.startsWith('Reply'))
80
+ .map((p, i) => ({ ...p, id: i + 1 }));
81
+ }
82
+ /**
83
+ * Paragraph-level passages with raw offsets. Headings label passages; code fences and image-only blocks are not
84
+ * quotable. `posts` (a thread's) also break blocks, and their labels head the passages that follow.
85
+ */
86
+ export function passages(markdown, maxChars = 1200, posts = []) {
45
87
  const out = [], blocks = [];
46
88
  let at = 0, start = -1, fence = false, fenced = false;
89
+ const breaks = new Set(posts.map((p) => p.start));
47
90
  for (const line of markdown.split('\n')) {
48
91
  const end = at + line.length, blank = !line.trim(), marker = /^\s{0,3}(```|~~~)/.test(line);
49
92
  if (marker)
50
93
  fence = !fence;
94
+ if (!fence && !marker && start >= 0 && breaks.has(at)) {
95
+ blocks.push({ start, end: at - 1, fence: fenced });
96
+ start = -1;
97
+ fenced = false;
98
+ }
51
99
  if (blank && !fence && !marker) {
52
100
  if (start >= 0)
53
101
  blocks.push({ start, end: at - 1, fence: fenced });
@@ -64,14 +112,17 @@ export function passages(markdown, maxChars = 1200) {
64
112
  }
65
113
  if (start >= 0)
66
114
  blocks.push({ start, end: markdown.length, fence: fenced });
67
- let heading = '';
115
+ let heading = '', post = '';
116
+ const label = (start) => posts.findLast((p) => p.start <= start)?.label ?? '';
68
117
  for (const b of blocks) {
69
118
  const raw = markdown.slice(b.start, b.end).trimEnd(), end = b.start + raw.length;
70
119
  if (b.fence || !raw.trim())
71
120
  continue;
121
+ if (label(b.start) !== post)
122
+ heading = post = label(b.start);
72
123
  const h = /^\s{0,3}#{1,6}\s+(.+)$/.exec(raw);
73
124
  if (h && !raw.includes('\n')) {
74
- heading = collapse(h[1]).slice(0, 200);
125
+ heading = (post ? `${post}: ` : '') + collapse(h[1]).slice(0, 200);
75
126
  continue;
76
127
  }
77
128
  if (/^(\s*!\[[^\]]*\]\([^)]*\)\s*)+$/.test(raw))
@@ -91,6 +142,32 @@ export function passages(markdown, maxChars = 1200) {
91
142
  }
92
143
  return out;
93
144
  }
145
+ const frontmatter = (markdown) => /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/u.exec(markdown)?.[0] ?? '';
146
+ /** A `key: "value"` frontmatter field, as captures write them. */
147
+ function captureField(markdown, key) {
148
+ const match = new RegExp(`^${key}: (".*")\\r?$`, 'm').exec(frontmatter(markdown));
149
+ try {
150
+ const value = match ? JSON.parse(match[1]) : undefined;
151
+ return typeof value === 'string' ? value : undefined;
152
+ }
153
+ catch {
154
+ return undefined;
155
+ }
156
+ }
157
+ /** Prose words under which a capture holds nothing to digest: a gallery of numbered images, a bare link list. */
158
+ const minProseWords = 20;
159
+ /**
160
+ * Why a capture cannot be digested, as a detail code, or undefined when it can: its body (past the frontmatter, images
161
+ * dropped, link text kept) has under `minProseWords` words with letters, or only an archive's metadata was saved.
162
+ */
163
+ export function undigestible(markdown) {
164
+ if (captureField(markdown, 'capture_method') === 'archive-metadata')
165
+ return 'source-too-thin';
166
+ const prose = passages(markdown.slice(frontmatter(markdown).length))
167
+ .map((p) => p.text.replace(/(!?)\[([^\]]*)\]\([^)]*\)/g, (_, image, text) => (image ? ' ' : text)))
168
+ .join(' ');
169
+ return (prose.match(/\p{L}[\p{L}\p{M}'’-]*/gu) ?? []).length < minProseWords ? 'source-too-thin' : undefined;
170
+ }
94
171
  function lastSentenceEnd(window) {
95
172
  let best = -1;
96
173
  for (const m of window.matchAll(/[.!?]["')\]]*(?=\s)/g))
@@ -196,7 +273,12 @@ Source: an essay arguing that taste is a skill.
196
273
  claim: Volume beats polish early. | detail: Side-by-side comparison of many rough drafts sharpens judgment faster than finishing one.
197
274
  Bad claim: The essay explores how practice over time shapes a person's sense of taste.
198
275
  Good claim: Taste is trained.`;
199
- const system = (budget, vocabulary) => `You write a Digest v2 of one source for a private reading library, in the owner's voice: terse, declarative, specific. Reply with JSON only, matching the schema.
276
+ /** Who speaks in each form, so claims are credited to the person who made them. */
277
+ const voices = {
278
+ thread: 'This source is an X thread. Its replies are left out. When the author quotes or answers someone, that view stays theirs.',
279
+ video: 'This source is a recording: credit each claim to the person who says it. In an interview or podcast, the guest’s views are the guest’s, not the host’s.',
280
+ };
281
+ const system = (budget, form, vocabulary) => `You write a Digest v2 of one source for a private reading library, in the owner's voice: terse, declarative, specific. Reply with JSON only, matching the schema.
200
282
  - lead: one sentence of at most ${L.leadChars} characters stating this source's specific thesis or result: who (author, organization or product by name) shows or argues what, with the number, name or mechanism that makes it this source. Do not restate the title, describe the source ("This article discusses"), or tease.
201
283
  - context: 1 or 2 sentences, at most 45 words: the evidence, method, numbers or limits the lead could not hold. Lead and context together stay within ${L.gistWords} words and ${L.gistSentences} sentences.
202
284
  - ideas: ${L.minIdeas} to ${L.maxIdeas} (prefer ${L.maxIdeas} when the source has them), in the order the author makes them, covering the whole source including its caveats and limits. Each idea is a claim with detail:
@@ -205,7 +287,12 @@ const system = (budget, vocabulary) => `You write a Digest v2 of one source for
205
287
  - quotes: 0 to ${L.maxQuotesPerIdea} that support this idea, each a contiguous span copied character for character from one numbered passage, ${minQuoteWords + 2} to 20 words, with that passage number. A quote is a fragment, not a whole long sentence: when a sentence runs past 20 words, copy only its strongest part. No added quotation marks or ellipses. Give attribution (who said it) when it is not the author. Prefer reader highlights.
206
288
  - Attach at least ${L.minQuotes} and at most ${L.maxQuotes} quotes in all; ${L.minQuotes + 1} to 3 is typical. All quotes together stay within ${budget} words and never overlap.
207
289
  - tags: 0 to ${L.maxTags} ${vocabulary?.length ? `from: ${vocabulary.join(', ')}` : 'lowercase kebab-case topics'}; usually one subject tag.
208
- Plain words. Name things and use numbers. No hedges or filler: may, might, potentially, various, significantly, key, crucial, notably, highlights, explores, discusses, "the author argues", "the article".
290
+ Faithfulness comes before style:
291
+ - Credit each view to who holds it. When the source quotes, interviews or reports someone, their claims are theirs: name them in the detail and the quote attribution, never pass them off as the author's.${voices[form] ? `\n- ${voices[form]}` : ''}
292
+ - Keep each number with its own subject, unit and meaning (an area is not a height, a share is not a count, a cost is not a price) and never compute one the source does not give.
293
+ - Keep the source's direction: never invert a claim, comparison or cause.
294
+ - Keep the source's certainty: a forecast, plan, proposal, rumor or question stays one ("plans to", "expects", "asks whether"); never state it as done or settled. Hedge only where the source does.
295
+ Plain words. Name things and use numbers. No filler: various, significantly, key, crucial, notably, highlights, explores, discusses, "the author argues", "the article".
209
296
  Use only what the source says.
210
297
 
211
298
  ${styleExamples}`;
@@ -255,7 +342,9 @@ function fromModel(text, form) {
255
342
  }
256
343
  if (!object(v) || !Array.isArray(v.ideas) || !Array.isArray(v.tags))
257
344
  return { issues: [{ path: '', code: 'shape' }] };
258
- const trim = (s) => (typeof s === 'string' ? s.trim() : s), cites = [];
345
+ const trim = (s) => (typeof s === 'string' ? s.trim() : s),
346
+ // Models sometimes end a sentence with the passage it came from ("… market. [2]").
347
+ prose = (s) => (typeof s === 'string' ? s.replace(/(\s*\[\d+(?:,\s*\d+)*\])+$/, '').trim() : s), cites = [];
259
348
  const ideas = v.ideas.map((idea) => {
260
349
  if (!object(idea)) {
261
350
  cites.push([]);
@@ -264,19 +353,20 @@ function fromModel(text, form) {
264
353
  const quotes = Array.isArray(idea.quotes) ? idea.quotes : [];
265
354
  cites.push(quotes.map((q) => (Number.isSafeInteger(q?.passage) ? q.passage : undefined)));
266
355
  return {
267
- claim: trim(idea.claim),
268
- detail: trim(idea.detail),
356
+ claim: prose(idea.claim),
357
+ detail: prose(idea.detail),
269
358
  quotes: quotes.map((q) => {
270
359
  const attribution = trim(q?.attribution);
271
360
  return { text: trim(q?.text), ...(attribution ? { attribution } : {}) };
272
361
  }),
273
362
  };
274
363
  });
275
- const context = trim(v.context);
364
+ // A context the model stopped mid-sentence is dropped; context is optional.
365
+ const context = ((c) => (typeof c === 'string' && !/[.!?…。!?)"”’]$/.test(c) ? undefined : c))(prose(v.context));
276
366
  const parsed = parseDigest({
277
367
  schemaVersion: 2,
278
368
  form,
279
- gist: { lead: trim(v.lead), ...(context ? { context } : {}) },
369
+ gist: { lead: prose(v.lead), ...(context ? { context } : {}) },
280
370
  ideas,
281
371
  tags: v.tags.map(trim),
282
372
  });
@@ -453,7 +543,10 @@ export async function draftDigest(input) {
453
543
  input.vocabulary.length > 256 ||
454
544
  input.vocabulary.some((t) => !/^[a-z0-9][a-z0-9-]{0,63}$/.test(t))))
455
545
  throw new SpongeError('invalid-input');
456
- const inputHash = digestInputHash({ ...input, model: identity }), source = input.markdown, list = passages(source);
546
+ const thin = undigestible(input.markdown);
547
+ if (thin)
548
+ throw new SpongeError(thin);
549
+ const inputHash = digestInputHash({ ...input, model: identity }), source = input.markdown, list = input.form === 'thread' ? authorPassages(source) : passages(source);
457
550
  if (list.length === 0)
458
551
  throw new SpongeError('invalid-input');
459
552
  const userSignal = input.signal ?? new AbortController().signal, overall = AbortSignal.any([userSignal, AbortSignal.timeout(p.deadlineMs)]);
@@ -564,7 +657,7 @@ export async function draftDigest(input) {
564
657
  }
565
658
  const budget = quoteBudget(source), schema = draftSchema(list.length, input.vocabulary);
566
659
  const messages = [
567
- { role: 'system', content: system(budget, input.vocabulary) },
660
+ { role: 'system', content: system(budget, input.form, input.vocabulary) },
568
661
  {
569
662
  role: 'user',
570
663
  content: (notesText ? `Notes from each part of the source, in order:\n${notesText}\n\n` : '') +
@@ -637,9 +730,16 @@ export async function draftDigest(input) {
637
730
  };
638
731
  return { output: result.draft.digest, inputHash, producer, options, trace };
639
732
  }
640
- const formFor = (kind) => (kind === 'pdf' ? 'pdf' : kind === 'media' ? 'video' : 'article');
641
- /** Draft and store a digest for the item's current revision. A matching input hash returns the stored draft without calling the model. */
642
- export async function createDigest(store, itemId, model, options = {}) {
733
+ /** X thread captures draft as `thread`, whose prompt rules keep replies apart from the author's posts. */
734
+ const formFor = (kind, markdown) => kind === 'pdf'
735
+ ? 'pdf'
736
+ : kind === 'media'
737
+ ? 'video'
738
+ : captureField(markdown, 'platform') === 'x' && captureField(markdown, 'capture_scope') === 'thread'
739
+ ? 'thread'
740
+ : 'article';
741
+ /** What the item's current revision is drafted from: its Markdown, title, form and anchored highlights. */
742
+ export function digestInputFor(store, itemId, model, options = {}) {
643
743
  const item = store.read(itemId);
644
744
  if (!item)
645
745
  throw new SpongeError('unavailable');
@@ -647,21 +747,25 @@ export async function createDigest(store, itemId, model, options = {}) {
647
747
  .list(itemId)
648
748
  .filter((a) => a.kind === 'highlight' && a.revisionId === item.revisionId && a.anchored !== false)
649
749
  .map((a) => ({ text: a.anchor.exact, start: a.anchor.start }));
650
- const input = {
750
+ return {
651
751
  ...options,
652
752
  revisionId: item.revisionId,
653
753
  markdown: item.markdown,
654
754
  title: item.title,
655
- form: formFor(item.kind),
755
+ form: formFor(item.kind, item.markdown),
656
756
  model,
657
757
  highlights,
658
758
  };
659
- const prior = store.derivations.find('digest', item.revisionId, digestInputHash({ ...input, model: model.identity }));
759
+ }
760
+ /** Draft and store a digest for the item's current revision. A matching input hash returns the stored draft without calling the model. */
761
+ export async function createDigest(store, itemId, model, options = {}) {
762
+ const input = digestInputFor(store, itemId, model, options);
763
+ const prior = store.derivations.find('digest', input.revisionId, digestInputHash({ ...input, model: model.identity }));
660
764
  if (prior)
661
765
  return { derivation: prior, duplicate: true };
662
766
  const draft = await draftDigest(input), stored = store.derivations.put('digest', {
663
767
  itemId,
664
- revisionId: item.revisionId,
768
+ revisionId: input.revisionId,
665
769
  producer: draft.producer,
666
770
  inputHash: draft.inputHash,
667
771
  output: draft.output,
@@ -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[]>;