@hraness/sponge 0.3.0 → 0.3.2

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.
@@ -7,6 +7,8 @@ export interface TextQuote {
7
7
  start: number;
8
8
  end: number;
9
9
  }
10
+ /** Spoken text without transcript cues, whitespace collapsed: what a reader quotes from a transcript. */
11
+ export declare function stripCues(raw: string): string;
10
12
  export declare function foldText(value: string): string;
11
13
  /** Every occurrence of `quote` in `source`, compared after folding. */
12
14
  export declare function locateAll(source: string, quote: string): {
@@ -1,9 +1,20 @@
1
1
  const context = 32;
2
2
  const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
3
3
  const escapable = /^[\\`*_{}[\]()#+\-.!|>~<"']$/;
4
+ /** A transcript cue at a line start: `- [00:02] ` (clipped captions) or `[00:00:02] ` (Sponge transcripts). */
5
+ const cue = /^[ \t]*(?:[-*+][ \t]+)?\[(?:\d{1,2}:)?\d{1,2}:\d{2}\][ \t]+/;
6
+ /** Spoken text without transcript cues, whitespace collapsed: what a reader quotes from a transcript. */
7
+ export function stripCues(raw) {
8
+ return raw
9
+ .split('\n')
10
+ .map((line) => line.replace(cue, ''))
11
+ .join('\n')
12
+ .replace(/\s+/g, ' ')
13
+ .trim();
14
+ }
4
15
  /**
5
16
  * Fold the differences a reader cannot see: Markdown escapes, curly quotes, dashes, runs of
6
- * whitespace and Unicode normalization. Folding works on grapheme clusters, each NFC-normalized;
17
+ * whitespace, line-start transcript cues and Unicode normalization. Folding works on grapheme clusters, each NFC-normalized;
7
18
  * every folded code unit of a cluster maps to the cluster's raw bounds, so `map[i]` and `ends[i]`
8
19
  * bound the raw text of folded unit i and matches map back exactly.
9
20
  */
@@ -19,6 +30,17 @@ function fold(raw) {
19
30
  };
20
31
  for (let c = 0; c < clusters.length; c++) {
21
32
  const { segment, index } = clusters[c];
33
+ // A transcript cue reads as the space between caption lines, so a quote of the speech spans them.
34
+ const marker = (index === 0 || raw[index - 1] === '\n') && cue.exec(raw.slice(index, index + 32));
35
+ if (marker) {
36
+ const stop = index + marker[0].length;
37
+ if (!space && text.length)
38
+ emit(' ', index, stop);
39
+ space = true;
40
+ while (c + 1 < clusters.length && clusters[c + 1].index < stop)
41
+ c++;
42
+ continue;
43
+ }
22
44
  // An escaped character maps to its backslash, so a match starting there keeps the escape.
23
45
  let ch = segment, end = index + segment.length;
24
46
  if (segment === '\\' && c + 1 < clusters.length && escapable.test(clusters[c + 1].segment)) {
@@ -98,6 +120,12 @@ function rendered(raw) {
98
120
  for (let i = 0; i < raw.length; i++) {
99
121
  let ch = raw[i], at = i;
100
122
  if (lineStart) {
123
+ const c = cue.exec(raw.slice(i, i + 32));
124
+ if (c) {
125
+ i += c[0].length - 1;
126
+ lineStart = false;
127
+ continue;
128
+ }
101
129
  const m = /^[ \t]*(?:>[ \t]?|#{1,6}[ \t]+|[-*+][ \t]+|\d{1,3}[.)][ \t]+)*/.exec(raw.slice(i, i + 64));
102
130
  if (m[0].length) {
103
131
  i += m[0].length - 1;
@@ -591,7 +591,7 @@ export const commands = [
591
591
  summary: "Set or reset an item's title",
592
592
  usage: [
593
593
  ['title set ITEM TEXT', 'Use TEXT as the title; refresh and import never change it'],
594
- ['title reset ITEM', 'Restore the default: the import note title, else the captured title'],
594
+ ['title reset ITEM', 'Restore the default: the captured title (a local scan takes its import note title)'],
595
595
  ],
596
596
  positional: (p) => (p[0] === 'set' && p.length === 3) || (p[0] === 'reset' && p.length === 2),
597
597
  flags: {},
@@ -791,5 +791,5 @@ export const usageOf = (name) => command(name)
791
791
  .join('\n');
792
792
  export function commandHelp(name) {
793
793
  const c = command(name), flags = Object.keys(c.flags);
794
- return `sponge ${c.name} — ${c.summary}\n\nUsage:\n${c.usage.map(row).join('').replace(/^ /gm, ' sponge ')}${flags.length ? `\nFlags: ${flags.map((f) => '--' + f).join(' ')}\n` : ''}\nGlobal flags: --library /absolute/path --json --debug\n`;
794
+ return `sponge ${c.name} — ${c.summary}\n\nUsage:\n${c.usage.map(([synopsis, description]) => row([`sponge ${synopsis}`, description])).join('')}${flags.length ? `\nFlags: ${flags.map((f) => '--' + f).join(' ')}\n` : ''}\nGlobal flags: --library /absolute/path --json --debug\n`;
795
795
  }
@@ -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-v4";
7
+ export declare const PROMPT_VERSION = "sponge-digest-prompt-v5";
8
8
  export type ChatMessage = {
9
9
  role: 'system' | 'user' | 'assistant';
10
10
  content: string;
@@ -1,4 +1,4 @@
1
- import { foldText, locateAll, locateLoose, words } from '../annotations/quote.js';
1
+ import { foldText, locateAll, locateLoose, stripCues, words } from '../annotations/quote.js';
2
2
  import { checkDigest, digestLimits as L, forms, parseDigest, } from './schema.js';
3
3
  import { SpongeError } from '../errors.js';
4
4
  import { sha256 } from '../util/hash.js';
@@ -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-v4';
9
+ export const PROMPT_VERSION = 'sponge-digest-prompt-v5';
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,
@@ -97,7 +97,9 @@ function lastSentenceEnd(window) {
97
97
  best = m.index + m[0].length;
98
98
  return best;
99
99
  }
100
- const display = (p) => `[${p.id}] ${collapse(p.text)}`;
100
+ const display = (p) => `[${p.id}] ${stripCues(p.text)}`;
101
+ /** A raw source span as a quote: transcript cues inside it are dropped, so the quote reads as the speech. */
102
+ const spoken = (raw) => (raw.includes('\n') && stripCues(raw) !== collapse(raw) ? stripCues(raw) : raw);
101
103
  /** Pack passages into sections of at most `budget` display characters, preferring heading boundaries past half a budget. */
102
104
  export function sectionsOf(list, budget) {
103
105
  const out = [];
@@ -148,7 +150,7 @@ export function draftSchema(passageCount, vocabulary) {
148
150
  return {
149
151
  type: 'object',
150
152
  additionalProperties: false,
151
- required: ['lead', 'ideas', 'tags'],
153
+ required: ['lead', 'context', 'ideas', 'tags'],
152
154
  properties: {
153
155
  lead: str(L.leadChars),
154
156
  context: str(600),
@@ -177,16 +179,36 @@ function notesSchema(first, last) {
177
179
  },
178
180
  };
179
181
  }
180
- const system = (budget, vocabulary) => `You write a Digest v2 of one source for a private reading library. Reply with JSON only, matching the schema.
181
- - lead: one dense sentence of at most ${L.leadChars} characters: who (author, organization or product by name) did or argues what, with the concrete specifics that make it this source (names, numbers, mechanism). Not a teaser.
182
- - context: optional, at most ${L.contextWords} words: the key evidence, numbers or limits the lead could not hold. Lead and context together stay within ${L.gistWords} words and ${L.gistSentences} sentences.
182
+ /**
183
+ * Style examples are invented sources shaped like reviewed digests (short verdict claims, dense details); no library
184
+ * text. They show voice only: the model must still draw every claim and number from the source it is given.
185
+ */
186
+ const styleExamples = `Style examples (invented sources; copy the voice, never the content):
187
+ Source: a database company's post on moving its write path to object storage.
188
+ lead: Acme moves Ledger's write path onto an append-only log in S3 and cuts p99 commit latency from 40 ms to 9 ms.
189
+ context: Postgres becomes a read replica rebuilt from the log. Batching makes object storage affordable; single-row workloads still get slower.
190
+ claim: The log is the database. | detail: Every write appends one immutable segment; Postgres is rebuilt from the log and serves reads only.
191
+ claim: Batching pays for the bucket. | detail: Commits wait up to 5 ms, so one PUT carries about 300 writes and request cost falls 40-fold.
192
+ claim: Small writers lose. | detail: Under ten writes per second, latency is higher than before the move.
193
+ Source: an essay arguing that taste is a skill.
194
+ lead: Mara Lind argues taste is trained by making and comparing many small things, not inherited, and that teams can schedule it.
195
+ claim: Taste is trained. | detail: Lind's students improved most after drawing fifty quick versions, not after studying masters.
196
+ claim: Volume beats polish early. | detail: Side-by-side comparison of many rough drafts sharpens judgment faster than finishing one.
197
+ Bad claim: The essay explores how practice over time shapes a person's sense of taste.
198
+ 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.
200
+ - 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
+ - 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.
183
202
  - 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:
184
- - claim: one short sentence of at most ${L.claimWords} words (often 4 to 10) that asserts something; never a topic label.
185
- - detail: at most ${L.detailWords} words in at most ${L.detailSentences} sentences: the specific evidence, mechanism, numbers or limits from the source.
203
+ - claim: a verdict of 4 to 8 words (never more than 12): subject, verb, point, naming this source's own subject ("Batching pays for the bucket", not "Costs drop"). Something a reader could disagree with; never a topic label or a description of the source.
204
+ - detail: one or two sentences, 15 to 30 words, dense with the source's own names, numbers, mechanism or limits behind the claim. Do not repeat the claim.
186
205
  - 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.
187
206
  - 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.
188
207
  - tags: 0 to ${L.maxTags} ${vocabulary?.length ? `from: ${vocabulary.join(', ')}` : 'lowercase kebab-case topics'}; usually one subject tag.
189
- Use only what the source says.`;
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".
209
+ Use only what the source says.
210
+
211
+ ${styleExamples}`;
190
212
  function sourceBlock(title, form, list, highlights) {
191
213
  let heading;
192
214
  const lines = [];
@@ -275,7 +297,7 @@ export function snapQuote(source, text, within) {
275
297
  const folded = locateAll(source, text);
276
298
  if (folded.length) {
277
299
  const m = pick(folded);
278
- return { text: m ? source.slice(m.start, m.end) : text, match: 'folded' };
300
+ return { text: m ? spoken(source.slice(m.start, m.end)) : text, match: 'folded' };
279
301
  }
280
302
  const loose = (within && pick(locateLoose(source, text, within))) || pick(locateLoose(source, text));
281
303
  if (!loose)
@@ -286,7 +308,7 @@ export function snapQuote(source, text, within) {
286
308
  start--;
287
309
  while (end < source.length && /[*_`]/.test(source[end]))
288
310
  end++;
289
- return { text: source.slice(start, end), match: 'snapped' };
311
+ return { text: spoken(source.slice(start, end)), match: 'snapped' };
290
312
  }
291
313
  /**
292
314
  * A located quote over the word limit keeps its leading words: a prefix of a source span is still verbatim. Trailing
@@ -332,7 +354,11 @@ const pruneable = new Set(['not-verbatim', 'overlap', 'unsupported', 'too-long',
332
354
  * budget (second quotes first, longest first), never the last quote. Quoteless ideas stay: v2 allows them.
333
355
  */
334
356
  function prune(draft, issues, source, options) {
335
- const dropped = { quotes: 0, ideas: 0, tags: 0 }, d = draft.digest, bad = new Set(issues
357
+ const dropped = { quotes: 0, ideas: 0, tags: 0 }, d = draft.digest;
358
+ // Context is optional in Digest v2: an over-long gist loses its context rather than the draft.
359
+ if (d.gist.context !== undefined && issues.some((i) => i.path === 'gist' || i.path === 'gist.context'))
360
+ delete d.gist.context;
361
+ const bad = new Set(issues
336
362
  .filter((i) => /^ideas\.\d+\.quotes\.\d+(?:\.attribution)?$/.test(i.path) && pruneable.has(i.code))
337
363
  .map((i) => i.path.replace(/\.attribution$/, '')));
338
364
  d.ideas.forEach((idea, i) => {
@@ -16,6 +16,8 @@ declare const parents: {
16
16
  readonly 'not-digest': "unsupported";
17
17
  readonly malformed: "invalid-input";
18
18
  readonly 'no-capture': "invalid-input";
19
+ readonly 'parent-missing': "invalid-input";
20
+ readonly 'target-exists': "invalid-input";
19
21
  };
20
22
  export type DetailCode = keyof typeof parents;
21
23
  export type SpongeCode = ErrorCode | DetailCode;
@@ -30,6 +30,8 @@ const parents = {
30
30
  'not-digest': 'unsupported',
31
31
  malformed: 'invalid-input',
32
32
  'no-capture': 'invalid-input',
33
+ 'parent-missing': 'invalid-input',
34
+ 'target-exists': 'invalid-input',
33
35
  };
34
36
  export const indexCodes = [
35
37
  'invalid-index',
@@ -12,8 +12,12 @@ export type DigestAction = 'new' | 'unchanged' | 'not-convertible' | 'not-digest
12
12
  export type VisibilityAction = 'set-public' | 'set-private' | 'restore-default' | 'unchanged' | 'kept-user-edit' | 'guarded' | 'default-policy';
13
13
  /** Local captures only (no web URI of their own): `uri` records the note's `source_url`. Later notes on one capture leave it to the first (`other-note`). */
14
14
  export type UriAction = 'new' | 'unchanged' | 'web-capture' | 'other-note' | 'no-source-url' | 'capability';
15
- /** The first note's title is the item's default title (`noteTitle`); `kept-user-edit` records it while the user's override stays shown. */
16
- export type TitleAction = 'set' | 'unchanged' | 'other-note' | 'kept-user-edit';
15
+ /**
16
+ * Local captures only, like `uri`: the first note's title becomes the default title (`noteTitle`) of a capture whose own
17
+ * title is a file name; `kept-user-edit` records it while the user's override stays shown. A web capture keeps its
18
+ * captured title (`web-capture`).
19
+ */
20
+ export type TitleAction = 'set' | 'unchanged' | 'web-capture' | 'other-note' | 'kept-user-edit';
17
21
  /** `firstNote` is false for a later note on a capture an earlier note in the same run already used; `firstNoteUri` is the URI that note records (a dry run has not written it). */
18
22
  export interface JungleNoteInput {
19
23
  itemId: string;
@@ -45,9 +45,11 @@ export function uriAction(local, firstNote, sourceUrl, recorded) {
45
45
  const { uri, refused } = noteUri(sourceUrl);
46
46
  return uri ? { action: uri === recorded ? 'unchanged' : 'new', uri } : { action: refused };
47
47
  }
48
- function titleAction(db, input) {
48
+ function titleAction(db, input, local) {
49
49
  if (input.firstNote === false)
50
50
  return 'other-note';
51
+ if (!local)
52
+ return 'web-capture';
51
53
  const title = input.editorial.title.trim(), item = db.prepare('SELECT title_note,title_override FROM items WHERE id=?').get(input.itemId);
52
54
  if (item?.title_override !== null && item?.title_override !== undefined)
53
55
  return 'kept-user-edit';
@@ -138,7 +140,7 @@ export function planJungleNote(db, input) {
138
140
  ...(digestId ? { digestId } : {}),
139
141
  digest: action,
140
142
  uri: uri.action,
141
- title: titleAction(db, input),
143
+ title: titleAction(db, input, local),
142
144
  visibility,
143
145
  };
144
146
  }
@@ -162,9 +164,9 @@ export function applyJungleNote(db, input) {
162
164
  // A local capture takes the note's web address and title before visibility is decided, so the published state applies to it.
163
165
  if (plan.uri === 'new')
164
166
  setMetadataUri(db, input.itemId, uriAction(true, true, input.editorial.sourceUrl).uri);
165
- // The note's title is the item's default; a refresh changes only the captured title, and the user's override wins over both.
167
+ // A local capture's default title is the note's; a refresh changes only the captured title, and the user's override wins over both.
166
168
  const title = input.editorial.title.trim();
167
- if (input.firstNote !== false &&
169
+ if ((plan.title === 'set' || plan.title === 'kept-user-edit') &&
168
170
  title &&
169
171
  Buffer.byteLength(title) <= 4096 &&
170
172
  title !== db.prepare('SELECT title_note FROM items WHERE id=?').get(input.itemId)?.title_note)
@@ -6,8 +6,8 @@ import type { BundleVerifier } from './inspect.js';
6
6
  * `sponge import jungle DIR`: every Jungle Reading note (`kb/notes/reading/<slug>.md`) onto the item holding its capture.
7
7
  * The capture goes through the verified bundle import (always as a private association); the note becomes an accepted
8
8
  * editorial derivation, plus an accepted Digest v2 when it converts without altering owner text (non-verbatim, short and
9
- * overflowing quotes are dropped and reported); the note's title becomes the item's default title (a user override still wins), and a
10
- * local capture (no web URI of its own) takes the note's `source_url`;
9
+ * overflowing quotes are dropped and reported); a local capture (no web URI of its own) takes the note's `source_url` and its
10
+ * title as the default title (a user override still wins), while a web capture keeps its captured title;
11
11
  * `reading.status` becomes the item's visibility. Jungle is only read. A dry run reads the notes, captures and library and writes nothing.
12
12
  */
13
13
  export interface JungleImportOptions {
@@ -139,7 +139,7 @@ export async function importJungle(store, jungle, options = {}) {
139
139
  throw entry;
140
140
  }
141
141
  // Two notes on one capture share its item, in a dry run too.
142
- // The first note on a capture decides its default title and a local capture's URI; later notes see the URI it records.
142
+ // The first note on a capture decides a local capture's URI and default title; later notes see the URI it records.
143
143
  const firstNote = !seen.has(link), firstNoteUri = seen.get(link);
144
144
  entry.item = prior || !firstNote ? 'existing' : 'new';
145
145
  if (firstNote)
@@ -174,7 +174,13 @@ export async function importJungle(store, jungle, options = {}) {
174
174
  ...(issues.length ? { digestIssues: issues } : {}),
175
175
  ...(quotes ? { quotes } : {}),
176
176
  uri: uri.action,
177
- title: !firstNote ? 'other-note' : noteTitle && Buffer.byteLength(noteTitle) <= 4096 ? 'set' : 'unchanged',
177
+ title: !firstNote
178
+ ? 'other-note'
179
+ : !local
180
+ ? 'web-capture'
181
+ : noteTitle && Buffer.byteLength(noteTitle) <= 4096
182
+ ? 'set'
183
+ : 'unchanged',
178
184
  visibility: visibilityAction({ access, default: defaultVisibility(access, 'private'), override: null }, editorial.status, false).action,
179
185
  });
180
186
  }
@@ -112,8 +112,18 @@ export function copyVerified(source, target, record) {
112
112
  export function newDirectory(path) {
113
113
  if (resolve(path) !== path)
114
114
  throw new SpongeError('invalid-input');
115
- safeDirectory(dirname(path));
116
- mkdirSync(path, { mode: 0o700 });
115
+ try {
116
+ safeDirectory(dirname(path));
117
+ mkdirSync(path, { mode: 0o700 });
118
+ }
119
+ catch (error) {
120
+ const code = error.code;
121
+ if (code === 'ENOENT')
122
+ throw new SpongeError('parent-missing', { cause: error });
123
+ if (code === 'EEXIST')
124
+ throw new SpongeError('target-exists', { cause: error });
125
+ throw error;
126
+ }
117
127
  const stat = lstatSync(path);
118
128
  return { root: path, device: stat.dev, inode: stat.ino };
119
129
  }
@@ -27,14 +27,14 @@
27
27
  }
28
28
  ],
29
29
  "build": {
30
- "asrSourceSha256": "2862d5869808e2f048767f075ddb234aa7535bc7242550365efcfb718cc7f7b8",
30
+ "asrSourceSha256": "8bd77ebffabae786902304eacb983fc950f70c981fb1538696517d85052c9407",
31
31
  "clangVersion": "Apple clang version 21.0.0 (clang-2100.1.1.101)",
32
- "embeddingSourceSha256": "a584f43c8af9743612984804f3fa2df0d204f13481c3a2d6d06b521e7661bc2e",
32
+ "embeddingSourceSha256": "2b0cd2b9c52c9dee578a3be50a443d528d5204d47f3f9ba750f6a1f37a191e78",
33
33
  "swiftVersion": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)"
34
34
  },
35
35
  "minimumMacOS": "26.0",
36
- "packageVersion": "0.3.0",
36
+ "packageVersion": "0.3.2",
37
37
  "platform": "darwin",
38
38
  "schemaVersion": 1,
39
- "sourceCommit": "8b1dc263364669190b4bec2ed5d7141a838e0841"
39
+ "sourceCommit": "e9eb0613365859c5809a05e8ad909504ac6e8d66"
40
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/sponge",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "A local library for captured articles and verified offline assets.",
5
5
  "repository": {
6
6
  "type": "git",