osborn 0.9.249 → 0.9.251

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 (62) hide show
  1. package/dist/claude-llm.js +13 -0
  2. package/dist/content/compaction-lens-worker.d.ts +23 -0
  3. package/dist/content/compaction-lens-worker.js +188 -0
  4. package/dist/content/compaction-lens.d.ts +106 -0
  5. package/dist/content/compaction-lens.js +222 -0
  6. package/dist/content/lens-audio.d.ts +38 -0
  7. package/dist/content/lens-audio.js +105 -0
  8. package/dist/content/lens-clip-arc.d.ts +80 -0
  9. package/dist/content/lens-clip-arc.js +148 -0
  10. package/dist/content/lens-clip-formats.d.ts +58 -0
  11. package/dist/content/lens-clip-formats.js +175 -0
  12. package/dist/content/lens-clip-search.d.ts +51 -0
  13. package/dist/content/lens-clip-search.js +138 -0
  14. package/dist/content/lens-clip-window.d.ts +72 -0
  15. package/dist/content/lens-clip-window.js +257 -0
  16. package/dist/content/lens-db.d.ts +70 -0
  17. package/dist/content/lens-db.js +170 -0
  18. package/dist/content/lens-hn.d.ts +19 -0
  19. package/dist/content/lens-hn.js +69 -0
  20. package/dist/content/lens-ingest.d.ts +67 -0
  21. package/dist/content/lens-ingest.js +155 -0
  22. package/dist/content/lens-launch.d.ts +46 -0
  23. package/dist/content/lens-launch.js +111 -0
  24. package/dist/content/lens-library-index.d.ts +30 -0
  25. package/dist/content/lens-library-index.js +79 -0
  26. package/dist/content/lens-library-select.d.ts +59 -0
  27. package/dist/content/lens-library-select.js +77 -0
  28. package/dist/content/lens-library.d.ts +92 -0
  29. package/dist/content/lens-library.js +179 -0
  30. package/dist/content/lens-model.d.ts +64 -0
  31. package/dist/content/lens-model.js +193 -0
  32. package/dist/content/lens-paths.d.ts +43 -0
  33. package/dist/content/lens-paths.js +166 -0
  34. package/dist/content/lens-period-block.d.ts +63 -0
  35. package/dist/content/lens-period-block.js +99 -0
  36. package/dist/content/lens-period-boundaries.d.ts +13 -0
  37. package/dist/content/lens-period-boundaries.js +40 -0
  38. package/dist/content/lens-period-render.d.ts +28 -0
  39. package/dist/content/lens-period-render.js +70 -0
  40. package/dist/content/lens-period.d.ts +106 -0
  41. package/dist/content/lens-period.js +205 -0
  42. package/dist/content/lens-profile.d.ts +47 -0
  43. package/dist/content/lens-profile.js +95 -0
  44. package/dist/content/lens-quotes.d.ts +103 -0
  45. package/dist/content/lens-quotes.js +186 -0
  46. package/dist/content/lens-redact.d.ts +61 -0
  47. package/dist/content/lens-redact.js +187 -0
  48. package/dist/content/lens-strip.d.ts +33 -0
  49. package/dist/content/lens-strip.js +132 -0
  50. package/dist/content/period-run.d.ts +30 -0
  51. package/dist/content/period-run.js +135 -0
  52. package/dist/content/render-ws-data.d.ts +8 -0
  53. package/dist/content/render-ws-data.js +108 -0
  54. package/dist/content/render-ws-screens.d.ts +25 -0
  55. package/dist/content/render-ws-screens.js +189 -0
  56. package/dist/content/render-ws-story.d.ts +1 -0
  57. package/dist/content/render-ws-story.js +233 -0
  58. package/dist/content/transcript-sanitizer.d.ts +195 -0
  59. package/dist/content/transcript-sanitizer.js +515 -0
  60. package/dist/index.js +34 -0
  61. package/package.json +1 -1
  62. package/test-results/.last-run.json +0 -4
@@ -0,0 +1,105 @@
1
+ /**
2
+ * lens-audio.ts — OPTIONAL, pluggable session-recording lookup (no paid calls).
3
+ *
4
+ * LiveKit egress is not enabled today, so this is a convention-based local
5
+ * lookup. Checks, in order:
6
+ * 1. <projectDir>/recordings/<sessionId>.<media-ext>
7
+ * 2. $OSBORN_RECORDINGS_DIR/<sessionId>.<media-ext>
8
+ * An optional sidecar <sessionId>.json ({ "startedAt": ISO | epoch-ms | "started_at": ns })
9
+ * pins the recording start exactly; without it the start is approximated by the
10
+ * first transcript record's timestamp and offsets are marked approximate.
11
+ */
12
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ const MEDIA_EXT = ['.mp4', '.webm', '.ogg', '.opus', '.m4a', '.mp3', '.wav', '.mkv'];
15
+ function parseStart(meta) {
16
+ if (!meta || typeof meta !== 'object')
17
+ return null;
18
+ const v = meta.startedAt ?? meta.started_at ?? meta.startTime;
19
+ if (typeof v === 'string') {
20
+ if (/^\d+$/.test(v))
21
+ return parseStart({ startedAt: Number(v) });
22
+ const t = Date.parse(v);
23
+ return Number.isFinite(t) ? t : null;
24
+ }
25
+ if (typeof v === 'number' && Number.isFinite(v)) {
26
+ if (v > 1e17)
27
+ return Math.floor(v / 1e6); // ns (LiveKit egress started_at)
28
+ if (v > 1e14)
29
+ return Math.floor(v / 1e3); // µs
30
+ if (v > 1e11)
31
+ return v; // ms
32
+ return v * 1000; // s
33
+ }
34
+ return null;
35
+ }
36
+ function lookIn(dir, sessionId) {
37
+ if (!dir || !existsSync(dir))
38
+ return null;
39
+ let names;
40
+ try {
41
+ names = readdirSync(dir);
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ const media = names.find(n => n.startsWith(sessionId + '.') && MEDIA_EXT.some(e => n.toLowerCase().endsWith(e)));
47
+ if (!media)
48
+ return null;
49
+ let startedAtMs = null;
50
+ const side = join(dir, `${sessionId}.json`);
51
+ if (existsSync(side)) {
52
+ try {
53
+ startedAtMs = parseStart(JSON.parse(readFileSync(side, 'utf-8')));
54
+ }
55
+ catch {
56
+ /* bad sidecar → approximate */
57
+ }
58
+ }
59
+ return { path: join(dir, media), startedAtMs };
60
+ }
61
+ /** Pluggable lookup. Returns null when no recording exists (the normal case today). */
62
+ export function findSessionRecording(sessionId, projectDir) {
63
+ if (!sessionId || !/^[A-Za-z0-9._-]{1,128}$/.test(sessionId))
64
+ return null;
65
+ try {
66
+ if (projectDir) {
67
+ const r = lookIn(join(projectDir, 'recordings'), sessionId);
68
+ if (r)
69
+ return r;
70
+ }
71
+ const envDir = process.env.OSBORN_RECORDINGS_DIR;
72
+ return envDir ? lookIn(envDir, sessionId) : null;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ /** Choose the alignment origin: sidecar start (exact) or first transcript record (approximate). */
79
+ export function alignRecording(rec, firstRecordIso) {
80
+ if (rec.startedAtMs !== null) {
81
+ return { path: rec.path, startIso: new Date(rec.startedAtMs).toISOString(), approximate: false, basis: 'sidecar startedAt' };
82
+ }
83
+ if (firstRecordIso && Number.isFinite(Date.parse(firstRecordIso))) {
84
+ return { path: rec.path, startIso: firstRecordIso, approximate: true, basis: 'first session record timestamp' };
85
+ }
86
+ return null;
87
+ }
88
+ /** Default adapter: the local convention lookup above (null today — no egress). */
89
+ export const localRecordingAdapter = {
90
+ align(sessionId, projectDir, firstRecordIso) {
91
+ const rec = findSessionRecording(sessionId, projectDir);
92
+ return rec ? alignRecording(rec, firstRecordIso) : null;
93
+ },
94
+ };
95
+ /** Clip offset "H:MM:SS" of a quote relative to the recording start; null if before start. */
96
+ export function clipOffset(quoteIso, startIso) {
97
+ const d = Date.parse(quoteIso) - Date.parse(startIso);
98
+ if (!Number.isFinite(d) || d < 0)
99
+ return null;
100
+ const s = Math.floor(d / 1000);
101
+ const hh = Math.floor(s / 3600);
102
+ const mm = Math.floor((s % 3600) / 60);
103
+ const ss = s % 60;
104
+ return `${hh}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`;
105
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * lens-clip-arc.ts — pass two, step 3: "understand what really happened".
3
+ *
4
+ * One model call reads the expanded window(s) (transcript FIRST, instructions
5
+ * AFTER, as lens-model.ts) and returns a human arc. Every beat is carried by the
6
+ * USER'S OWN verbatim lines; facts only support. Each line is then verified
7
+ * deterministically with lens-quotes (same rules as pass one) and the speaker is
8
+ * taken from the matched row, never the model. A "user" line that is really a
9
+ * pasted review/log/doc (text after a paste marker in that row) is demoted to
10
+ * support. A beat left with no verified user line is marked "no user voice".
11
+ */
12
+ import { type DbRecord } from './lens-db.js';
13
+ import { type Quote } from './lens-quotes.js';
14
+ export declare const SINGLE_BEATS: readonly ["goal", "obstacle", "attempts", "turning_point", "fix", "payoff"];
15
+ export interface ArcLine extends Quote {
16
+ /** true = user's own spoken words; false = assistant or pasted text (support only). */
17
+ voice: boolean;
18
+ /** Compilation: which clip (A, B, …) the row belongs to. */
19
+ clip?: string;
20
+ }
21
+ export interface ArcBeat {
22
+ beat: string;
23
+ summary: string;
24
+ clip?: string;
25
+ userLines: ArcLine[];
26
+ supportLines: ArcLine[];
27
+ noUserVoice: boolean;
28
+ dropped: number;
29
+ }
30
+ export interface Arc {
31
+ title: string;
32
+ question: string;
33
+ beats: ArcBeat[];
34
+ genuine: {
35
+ answers: boolean;
36
+ why: string;
37
+ } | null;
38
+ cut: {
39
+ clip: string;
40
+ what: string;
41
+ why: string;
42
+ }[];
43
+ linesDropped: number;
44
+ demotedPasted: number;
45
+ }
46
+ /** True when `quote` sits inside pasted material of `rowText` (a review, log, doc) rather than the user's words. */
47
+ export declare function isPastedSpan(rowText: string, quote: string): boolean;
48
+ export interface RawLine {
49
+ text?: unknown;
50
+ timestamp?: unknown;
51
+ row?: unknown;
52
+ }
53
+ /**
54
+ * Verify every line of every beat against the window rows. Pure + deterministic.
55
+ * `clipOf` maps a row id to its clip label (compilation).
56
+ */
57
+ export declare function verifyBeats(raw: {
58
+ beat?: unknown;
59
+ summary?: unknown;
60
+ clip?: unknown;
61
+ user_lines?: unknown;
62
+ support_lines?: unknown;
63
+ }[], records: DbRecord[], clipOf?: (row: number) => string | undefined): {
64
+ beats: ArcBeat[];
65
+ dropped: number;
66
+ demoted: number;
67
+ };
68
+ export interface ArcPromptOpts {
69
+ mode: 'single' | 'compilation';
70
+ /** Reverse mode: the demand thread the clip must answer. */
71
+ thread?: string;
72
+ /** Compilation topic. */
73
+ topic?: string;
74
+ }
75
+ /** Windows labelled A, B, … with dates; transcript first, task after. */
76
+ export declare function arcPrompt(windows: {
77
+ label: string;
78
+ records: DbRecord[];
79
+ }[], o: ArcPromptOpts): string;
80
+ export declare function readArc(raw: string, records: DbRecord[], clipOf?: (row: number) => string | undefined): Arc | null;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * lens-clip-arc.ts — pass two, step 3: "understand what really happened".
3
+ *
4
+ * One model call reads the expanded window(s) (transcript FIRST, instructions
5
+ * AFTER, as lens-model.ts) and returns a human arc. Every beat is carried by the
6
+ * USER'S OWN verbatim lines; facts only support. Each line is then verified
7
+ * deterministically with lens-quotes (same rules as pass one) and the speaker is
8
+ * taken from the matched row, never the model. A "user" line that is really a
9
+ * pasted review/log/doc (text after a paste marker in that row) is demoted to
10
+ * support. A beat left with no verified user line is marked "no user voice".
11
+ */
12
+ import { recordView } from './lens-db.js';
13
+ import { buildRecordIndex, looseText, parseJsonObject, verifyQuote } from './lens-quotes.js';
14
+ export const SINGLE_BEATS = ['goal', 'obstacle', 'attempts', 'turning_point', 'fix', 'payoff'];
15
+ /** Paste markers: text at/after one of these in a user row is not the user's own voice. */
16
+ const PASTE_MARKER = /\[review\]|(?:^| )#{1,4} \S|\breject:|\bapprove:|\[liked\]|\[disliked\]|<\/?[a-z_]+>|^\[time: [^\]]*\] (?:#|\[)/;
17
+ /** True when `quote` sits inside pasted material of `rowText` (a review, log, doc) rather than the user's words. */
18
+ export function isPastedSpan(rowText, quote) {
19
+ const row = looseText(rowText);
20
+ const q = looseText(quote);
21
+ const at = row.indexOf(q);
22
+ if (at === -1)
23
+ return false;
24
+ const m = PASTE_MARKER.exec(row);
25
+ if (m && m.index <= at)
26
+ return true;
27
+ return row.length > 4000 && at > 600;
28
+ }
29
+ /**
30
+ * Verify every line of every beat against the window rows. Pure + deterministic.
31
+ * `clipOf` maps a row id to its clip label (compilation).
32
+ */
33
+ export function verifyBeats(raw, records, clipOf = () => undefined) {
34
+ const idx = buildRecordIndex(records);
35
+ const byId = new Map(records.map(r => [r.id, r]));
36
+ let dropped = 0;
37
+ let demoted = 0;
38
+ const beats = [];
39
+ for (const b of raw) {
40
+ const name = typeof b?.beat === 'string' ? b.beat.trim() : '';
41
+ if (!name)
42
+ continue;
43
+ const user = [];
44
+ const support = [];
45
+ let bd = 0;
46
+ const lines = [
47
+ ...(Array.isArray(b.user_lines) ? b.user_lines.slice(0, 2) : []),
48
+ ...(Array.isArray(b.support_lines) ? b.support_lines.slice(0, 2) : []),
49
+ ];
50
+ const seen = new Set();
51
+ for (const l of lines) {
52
+ const v = verifyQuote(l, idx);
53
+ if (!v) {
54
+ bd++;
55
+ continue;
56
+ }
57
+ const key = `${v.row}:${looseText(v.text)}`;
58
+ if (seen.has(key))
59
+ continue;
60
+ seen.add(key);
61
+ const src = byId.get(v.row);
62
+ const pasted = v.speaker === 'user' && !!src && isPastedSpan(src.text, v.text);
63
+ if (pasted)
64
+ demoted++;
65
+ const line = { ...v, voice: v.speaker === 'user' && !pasted, clip: clipOf(v.row) };
66
+ if (line.voice && user.length < 2)
67
+ user.push(line);
68
+ else if (!line.voice && support.length < 2)
69
+ support.push(line);
70
+ }
71
+ dropped += bd;
72
+ beats.push({
73
+ beat: name,
74
+ summary: typeof b.summary === 'string' ? b.summary.trim() : '',
75
+ clip: typeof b.clip === 'string' ? b.clip.trim() : user[0]?.clip ?? support[0]?.clip,
76
+ userLines: user,
77
+ supportLines: support,
78
+ noUserVoice: user.length === 0,
79
+ dropped: bd,
80
+ });
81
+ }
82
+ return { beats, dropped, demoted };
83
+ }
84
+ const LINE_RULES = 'LINES: each line = {"text": exact verbatim substring (12-300 chars) copied character-for-character from ONE record, ' +
85
+ '"timestamp": that record\'s exact timestamp from its [#row | timestamp | speaker] header, "row": its row number (digits only)}. ' +
86
+ 'ONE contiguous span: no ellipses, no skipped words, no joined sentences, no typo fixes — the person speaks by voice, so keep ' +
87
+ 'their disfluencies exactly. "user_lines": 1-2 lines from USER records that are the person\'s OWN spoken words at that moment ' +
88
+ '(their frustration, their clue, their reaction) — NOT text they pasted (reviews, logs, docs, links). "support_lines": 0-2 ' +
89
+ 'assistant lines that state the technical fact. Never quote secrets, keys, tokens, emails, client names, or personal details; ' +
90
+ 'keep masked tags like [client] as they are.';
91
+ /** Windows labelled A, B, … with dates; transcript first, task after. */
92
+ export function arcPrompt(windows, o) {
93
+ const blocks = windows.map(w => {
94
+ const d0 = w.records[0]?.timestamp.slice(0, 10);
95
+ const d1 = w.records[w.records.length - 1]?.timestamp.slice(0, 10);
96
+ return `<clip id="${w.label}" dates="${d0}${d1 !== d0 ? ` to ${d1}` : ''}">\n${w.records.map(recordView).join('\n\n')}\n</clip>`;
97
+ });
98
+ const montage = o.mode === 'single' && windows.length > 1;
99
+ const head = o.mode === 'single' && !montage
100
+ ? 'Below is one contiguous stretch of a raw, unedited voice work session between a person (user) and their AI coding ' +
101
+ 'assistant (assistant), pulled from a much longer session. Read all of it; your task follows.'
102
+ : `Below are ${windows.length} stretches from DIFFERENT points in time (possibly hours or days apart) of ONE long, raw, ` +
103
+ 'unedited voice work session between a person (user) and their AI coding assistant. Read all of them; your task follows.';
104
+ const thread = o.thread ? `\n<demand_thread>\n${o.thread.slice(0, 12_000)}\n</demand_thread>` : '';
105
+ const task = o.mode === 'single'
106
+ ? 'TASK: like a streamer clipping a highlight, find the ONE human story in this stretch and tell it answer-first. ' +
107
+ `Beats, in order: ${SINGLE_BEATS.join(', ')} — goal (what they were trying to do and why it mattered), obstacle ` +
108
+ '(the symptom), attempts (what was tried, dead ends), turning_point (the clue), fix, payoff. Build each beat around ' +
109
+ 'what the PERSON said at that moment; technical facts only support. If a beat truly has no user line, give an empty ' +
110
+ 'user_lines array — do not force one.' +
111
+ (montage
112
+ ? ' This is a short MONTAGE of steps ("did this -> did this -> did this") across the stretches: keep the beats in ' +
113
+ 'chronological order, give each beat its "clip" id, and make "summary" a one-line step label.'
114
+ : '')
115
+ : `TASK: stitch these clips into ONE coherent, edited episode about: ${o.topic || 'the recurring topic'}. Keep the full ` +
116
+ 'message, cut the noise (tangents, interruptions, retries, filler). Beats in chronological order showing how the ' +
117
+ 'understanding evolved: first understanding, what was tried first, what failed and why, the re-think, what finally ' +
118
+ 'worked, how it was verified. 5-8 beats; each beat names its "clip" id and carries only what serves the through-line. ' +
119
+ 'Also list what you cut as noise.';
120
+ const genuine = o.thread
121
+ ? ' The clip must answer the demand_thread above. Add "genuine": {"answers": true|false, "why": one line} — true ONLY if ' +
122
+ 'this stretch shows real, first-hand experience that actually addresses the thread\'s core problem (not merely the same keywords).'
123
+ : '';
124
+ const shape = o.mode === 'single'
125
+ ? '{"title": short topic title, "question": the one question this clip answers, "beats": [{"beat", ' +
126
+ (montage ? '"clip": "A", ' : '') + '"summary": one line, ' +
127
+ '"user_lines": [...], "support_lines": [...]}]' + (o.thread ? ', "genuine": {...}' : '') + '}'
128
+ : '{"title", "question": the question the episode answers, "beats": [{"beat": short name, "clip": "A", "summary", ' +
129
+ '"user_lines": [...], "support_lines": [...]}], "cut": [{"clip": "A", "what": short description, "why": "tangent|interruption|retry|filler|off-topic"}]}';
130
+ return [head, ...blocks, thread, task + genuine, LINE_RULES, `Output ONLY: ${shape}`].filter(Boolean).join('\n\n');
131
+ }
132
+ export function readArc(raw, records, clipOf) {
133
+ const o = parseJsonObject(raw);
134
+ if (!o || !Array.isArray(o.beats))
135
+ return null;
136
+ const v = verifyBeats(o.beats, records, clipOf);
137
+ const g = o.genuine;
138
+ const cut = Array.isArray(o.cut) ? o.cut : [];
139
+ return {
140
+ title: String(o.title ?? '').trim(),
141
+ question: String(o.question ?? '').trim(),
142
+ beats: v.beats,
143
+ genuine: g && typeof g === 'object' ? { answers: g.answers === true, why: String(g.why ?? '').trim() } : null,
144
+ cut: cut.map(c => ({ clip: String(c?.clip ?? ''), what: String(c?.what ?? ''), why: String(c?.why ?? '') })).filter(c => c.what),
145
+ linesDropped: v.dropped,
146
+ demotedPasted: v.demoted,
147
+ };
148
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * lens-clip-formats.ts — pass two, step 4-5: cut the verified arc into formats,
3
+ * then check them. Topic-only voice by default; first-person only in the post
4
+ * variants, flagged. The user's VERIFIED lines are the on-screen quotes.
5
+ *
6
+ * Checks never block, they FLAG: deterministic (on-screen quote must be a
7
+ * verified user line, asset paths must appear in the source rows, first person
8
+ * in topic-only formats, length targets) + the pipeline-e2e truth-check (draft +
9
+ * source rows → list of ungrounded claims) on every format.
10
+ */
11
+ import { type DbRecord } from './lens-db.js';
12
+ import type { Arc } from './lens-clip-arc.js';
13
+ export interface ScriptBeat {
14
+ label: string;
15
+ seconds: number;
16
+ voiceover: string;
17
+ onScreenQuote: {
18
+ row: number;
19
+ text: string;
20
+ } | null;
21
+ timeJump?: string;
22
+ assets: {
23
+ kind: string;
24
+ ref: string;
25
+ note: string;
26
+ }[];
27
+ }
28
+ export interface Formats {
29
+ hook: string;
30
+ script: ScriptBeat[];
31
+ /** Compilation only: derived cuts of the long script. */
32
+ medium: ScriptBeat[];
33
+ short: ScriptBeat[];
34
+ linkedin: string;
35
+ linkedinFirstPerson: string;
36
+ x: string;
37
+ xFirstPerson: string;
38
+ comment: string;
39
+ flags: string[];
40
+ }
41
+ export declare function arcForPrompt(arc: Arc): string;
42
+ export declare function formatsPrompt(sources: {
43
+ label: string;
44
+ records: DbRecord[];
45
+ }[], arc: Arc, o: {
46
+ mode: 'single' | 'compilation';
47
+ thread?: string;
48
+ }): string;
49
+ export declare function readFormats(raw: string): Formats | null;
50
+ /** First-person words outside quoted spans. */
51
+ export declare function hasFirstPerson(text: string): boolean;
52
+ /** Deterministic checks. Mutates nothing in place except removing unverified on-screen quotes; returns flags. */
53
+ export declare function checkFormats(f: Formats, arc: Arc, sourceText: string): string[];
54
+ /** Plain-text renderings fed to the truth-check, one per format. */
55
+ export declare function formatTexts(f: Formats): [string, string][];
56
+ /** The pipeline-e2e truth-check prompt (same wording), source FIRST, instructions after. */
57
+ export declare function truthCheckPrompt(sourceText: string, draft: string): string;
58
+ export declare function parseStringArray(raw: string): string[] | null;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * lens-clip-formats.ts — pass two, step 4-5: cut the verified arc into formats,
3
+ * then check them. Topic-only voice by default; first-person only in the post
4
+ * variants, flagged. The user's VERIFIED lines are the on-screen quotes.
5
+ *
6
+ * Checks never block, they FLAG: deterministic (on-screen quote must be a
7
+ * verified user line, asset paths must appear in the source rows, first person
8
+ * in topic-only formats, length targets) + the pipeline-e2e truth-check (draft +
9
+ * source rows → list of ungrounded claims) on every format.
10
+ */
11
+ import { recordView } from './lens-db.js';
12
+ import { looseText, parseJsonObject } from './lens-quotes.js';
13
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
14
+ const lineJson = (l) => ({ row: l.row, date: l.timestamp.slice(0, 10), text: l.text, ...(l.clip ? { clip: l.clip } : {}) });
15
+ export function arcForPrompt(arc) {
16
+ return JSON.stringify({
17
+ title: arc.title,
18
+ question: arc.question,
19
+ beats: arc.beats.map(b => ({
20
+ beat: b.beat,
21
+ clip: b.clip,
22
+ summary: b.summary,
23
+ user_lines: b.userLines.map(lineJson),
24
+ support_lines: b.supportLines.map(lineJson),
25
+ ...(b.noUserVoice ? { note: 'no user voice' } : {}),
26
+ })),
27
+ }, null, 1);
28
+ }
29
+ export function formatsPrompt(sources, arc, o) {
30
+ const src = sources.map(s => `<clip id="${s.label}">\n${s.records.map(recordView).join('\n\n')}\n</clip>`).join('\n\n');
31
+ const script = o.mode === 'single'
32
+ ? '"script": EXACTLY 4 beats, 30-50 seconds total, answer-first (the payoff/clue up front, then how). If the arc spans ' +
33
+ 'several clips it is a montage of steps: chronological, each beat "label" a one-line step label.'
34
+ : '"script": the LONG-FORM episode, 2-4 minutes total (6-10 beats, chronological); every beat whose clip is days/weeks after ' +
35
+ 'the previous one gets "time_jump" (e.g. "two weeks later") computed from the clip dates. "medium": 60-90s cut derived ' +
36
+ 'from the long script (4-6 beats); "short": 30-45s cut (3-4 beats). Medium/short reuse long-script beats and quotes.';
37
+ const beatShape = '{"label", "seconds", "voiceover": TOPIC-ONLY narration (no I/we/my), "on_screen_quote": {"row", "text"} copied EXACTLY from ' +
38
+ 'one of the arc user_lines (or null), ' +
39
+ (o.mode === 'compilation' ? '"time_jump": "" or e.g. "two weeks later", ' : '') +
40
+ '"assets": [{"kind": "code"|"diagram"|"screen"|"log"|"reenactment", "ref": real file path / log line / thing to draw that ' +
41
+ 'appears in the source, "note"}]}. Any reenactment MUST be kind "reenactment".';
42
+ return [
43
+ 'Below are the SOURCE rows (ground truth) and then a VERIFIED story arc built from them. Your task follows.',
44
+ src,
45
+ `<arc>\n${arcForPrompt(arc)}\n</arc>`,
46
+ o.thread ? `<demand_thread>\n${o.thread.slice(0, 12_000)}\n</demand_thread>` : '',
47
+ 'TASK: cut the arc into publishable formats. The person\'s own verified lines carry the story; technical facts support it. ' +
48
+ 'Use ONLY facts present in the source rows — no invented numbers, timings, names or outcomes. Client details stay masked ' +
49
+ '([client] etc.). No hype, no emoji, no clickbait.',
50
+ `FORMATS: "hook": one line, topic-only. ${script} Script beat = ${beatShape} ` +
51
+ '"linkedin": 120-200 words, topic-only voice (no I/we/my). "linkedin_first_person": the same post in first person ' +
52
+ '(it will be flagged for the person\'s approval). "x": <= 280 characters, topic-only. "x_first_person": <= 280, first person. ' +
53
+ (o.thread
54
+ ? '"comment": a GitHub/HN reply to the demand_thread: answer ONLY what the thread asks, from this grounded experience, ' +
55
+ 'concrete and short (80-180 words). No promotion, no product plug; if mentioning the tool is unavoidable, prefix that ' +
56
+ 'sentence with "[disclosure]".'
57
+ : '"comment": "".'),
58
+ 'Output ONLY: {"hook", "script": [...], ' + (o.mode === 'compilation' ? '"medium": [...], "short": [...], ' : '') +
59
+ '"linkedin", "linkedin_first_person", "x", "x_first_person", "comment"}',
60
+ ]
61
+ .filter(Boolean)
62
+ .join('\n\n');
63
+ }
64
+ function readBeats(v) {
65
+ if (!Array.isArray(v))
66
+ return [];
67
+ return v.map(b => ({
68
+ label: str(b?.label),
69
+ seconds: Number(b?.seconds) || 0,
70
+ voiceover: str(b?.voiceover),
71
+ onScreenQuote: b?.on_screen_quote && str(b.on_screen_quote.text) ? { row: Number(String(b.on_screen_quote.row).replace(/^#/, '')) || 0, text: str(b.on_screen_quote.text) } : null,
72
+ timeJump: str(b?.time_jump) || undefined,
73
+ assets: Array.isArray(b?.assets) ? b.assets.map((a) => ({ kind: str(a?.kind), ref: str(a?.ref), note: str(a?.note) })).filter((a) => a.ref) : [],
74
+ }));
75
+ }
76
+ export function readFormats(raw) {
77
+ const o = parseJsonObject(raw);
78
+ if (!o)
79
+ return null;
80
+ return {
81
+ hook: str(o.hook), script: readBeats(o.script), medium: readBeats(o.medium), short: readBeats(o.short),
82
+ linkedin: str(o.linkedin), linkedinFirstPerson: str(o.linkedin_first_person), x: str(o.x), xFirstPerson: str(o.x_first_person),
83
+ comment: str(o.comment), flags: [],
84
+ };
85
+ }
86
+ const FIRST_PERSON = /\b(I|I'm|I've|I'd|my|me|mine|we|we're|we've|our|ours|us)\b/;
87
+ /** First-person words outside quoted spans. */
88
+ export function hasFirstPerson(text) {
89
+ return FIRST_PERSON.test(text.replace(/"[^"]*"|“[^”]*”/g, ' '));
90
+ }
91
+ /** Deterministic checks. Mutates nothing in place except removing unverified on-screen quotes; returns flags. */
92
+ export function checkFormats(f, arc, sourceText) {
93
+ const flags = [];
94
+ const verified = arc.beats.flatMap(b => b.userLines);
95
+ const src = looseText(sourceText);
96
+ for (const [name, beats] of [['script', f.script], ['medium', f.medium], ['short', f.short]]) {
97
+ for (const b of beats) {
98
+ if (b.onScreenQuote) {
99
+ const q = looseText(b.onScreenQuote.text);
100
+ const hit = verified.find(l => looseText(l.text).includes(q) || q.includes(looseText(l.text)));
101
+ if (!hit || q.length < 12) {
102
+ flags.push(`${name} "${b.label}": on-screen quote is not a verified user line — removed`);
103
+ b.onScreenQuote = null;
104
+ }
105
+ else
106
+ b.onScreenQuote = { row: hit.row, text: hit.text };
107
+ }
108
+ if (hasFirstPerson(b.voiceover))
109
+ flags.push(`${name} "${b.label}": first person in topic-only narration`);
110
+ for (const a of b.assets) {
111
+ const pathLike = /[\w-]+\/[\w./-]+|\b[\w-]+\.(?:ts|js|tsx|json|md|sh|yml|yaml|toml|py)\b/.exec(a.ref);
112
+ if (pathLike && !src.includes(looseText(pathLike[0])))
113
+ flags.push(`${name} "${b.label}": asset "${pathLike[0]}" not found in source rows`);
114
+ if (/reenact|recreat|mock/i.test(`${a.ref} ${a.note}`) && a.kind !== 'reenactment')
115
+ flags.push(`${name} "${b.label}": unlabeled reenactment "${a.ref}"`);
116
+ }
117
+ }
118
+ if (beats.length) {
119
+ const secs = beats.reduce((s, b) => s + b.seconds, 0);
120
+ const words = beats.reduce((s, b) => s + b.voiceover.split(/\s+/).filter(Boolean).length, 0);
121
+ flags.push(`${name}: ${beats.length} beats, ${secs}s stated, ~${Math.round(words / 2.5)}s at 150 wpm`);
122
+ }
123
+ }
124
+ if (hasFirstPerson(f.hook))
125
+ flags.push('hook: first person in topic-only format');
126
+ if (hasFirstPerson(f.linkedin))
127
+ flags.push('linkedin: first person in topic-only post');
128
+ if (hasFirstPerson(f.x))
129
+ flags.push('x: first person in topic-only post');
130
+ const wc = f.linkedin.split(/\s+/).filter(Boolean).length;
131
+ if (wc && (wc < 120 || wc > 200))
132
+ flags.push(`linkedin: ${wc} words (target 120-200)`);
133
+ for (const [n, t] of [['x', f.x], ['x_first_person', f.xFirstPerson]])
134
+ if ([...t].length > 280)
135
+ flags.push(`${n}: ${[...t].length} chars (> 280)`);
136
+ if (f.linkedinFirstPerson)
137
+ flags.push('linkedin_first_person: FIRST-PERSON variant — needs the person\'s approval');
138
+ if (f.xFirstPerson)
139
+ flags.push('x_first_person: FIRST-PERSON variant — needs the person\'s approval');
140
+ if (/\[disclosure\]/i.test(f.comment))
141
+ flags.push('comment: contains a [disclosure] product mention — check it is directly relevant');
142
+ return flags;
143
+ }
144
+ /** Plain-text renderings fed to the truth-check, one per format. */
145
+ export function formatTexts(f) {
146
+ const beats = (bs) => bs.map(b => `${b.timeJump ? `(${b.timeJump}) ` : ''}${b.voiceover}${b.onScreenQuote ? ` [on screen: "${b.onScreenQuote.text}"]` : ''}`).join('\n');
147
+ const out = [['hook', f.hook], ['script', beats(f.script)], ['medium', beats(f.medium)], ['short', beats(f.short)],
148
+ ['linkedin', f.linkedin], ['linkedin_first_person', f.linkedinFirstPerson], ['x', f.x], ['x_first_person', f.xFirstPerson], ['comment', f.comment]];
149
+ return out.filter(([, t]) => t.trim());
150
+ }
151
+ /** The pipeline-e2e truth-check prompt (same wording), source FIRST, instructions after. */
152
+ export function truthCheckPrompt(sourceText, draft) {
153
+ return [
154
+ `SOURCE RECORDS (ground truth, speaker-tagged):\n${sourceText}`,
155
+ `DRAFT POST:\n${draft}`,
156
+ 'You are a strict fact-checking editor. You are given a DRAFT post and the SOURCE records (the ONLY ground truth). Identify ' +
157
+ 'every factual claim, event, metric, or specific detail in the draft that is NOT directly supported by the source records. ' +
158
+ 'Treat invented stories, numbers, or named mechanisms as unsupported. Do NOT rewrite or soften the post. Return ONLY a JSON ' +
159
+ 'array of strings — each string one specific ungrounded claim (quote or tight paraphrase from the draft). If every claim is grounded, return [].',
160
+ ].join('\n\n');
161
+ }
162
+ export function parseStringArray(raw) {
163
+ const s = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/i, '').trim();
164
+ const a = s.indexOf('[');
165
+ const b = s.lastIndexOf(']');
166
+ if (a === -1 || b < a)
167
+ return null;
168
+ try {
169
+ const arr = JSON.parse(s.slice(a, b + 1));
170
+ return Array.isArray(arr) ? arr.map(x => String(x ?? '').trim()).filter(Boolean) : null;
171
+ }
172
+ catch {
173
+ return null;
174
+ }
175
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * lens-clip-search.ts — REVERSE direction + compilation topic search.
3
+ *
4
+ * demand thread (or topic) → a few search queries (one cheap model call) → the
5
+ * recall store's OWN search (session-store.ts recall(): FTS5 BM25 + vector, RRF,
6
+ * read-only) over session.db → keep only conversation rows the lens may read
7
+ * (sanitized user/assistant main rows) → cluster nearby hits → anchors.
8
+ * A weak cluster (few distinct queries / few hits) is reported as such; the
9
+ * caller then refuses to force a draft.
10
+ */
11
+ import type { DbRecord } from './lens-db.js';
12
+ /** Public thread → plain text. GitHub issues/PRs and HN items via their JSON APIs; anything else as stripped HTML. */
13
+ export declare function fetchThread(url: string, maxComments?: number): Promise<{
14
+ title: string;
15
+ text: string;
16
+ }>;
17
+ /** Short prompt: demand thread / topic → core problem + 3-5 search queries (instructions after the material). */
18
+ export declare function queriesPrompt(material: string, kind: 'thread' | 'topic'): string;
19
+ export declare function readQueries(raw: string): {
20
+ problem: string;
21
+ queries: string[];
22
+ } | null;
23
+ export interface SearchCluster {
24
+ rows: number[];
25
+ score: number;
26
+ hits: number;
27
+ queries: string[];
28
+ firstTs: string;
29
+ lastTs: string;
30
+ }
31
+ export interface SearchResult {
32
+ clusters: SearchCluster[];
33
+ mode: 'hybrid' | 'keyword';
34
+ weak: boolean;
35
+ weakWhy: string;
36
+ }
37
+ /** Group hits into clusters of nearby conversation rows and rank them. Pure. */
38
+ export declare function rankClusters(records: DbRecord[], hits: Map<number, {
39
+ score: number;
40
+ queries: Set<string>;
41
+ }>, mergeRows?: number): SearchCluster[];
42
+ /** Weak = the best cluster is matched by < 2 distinct queries or has < 3 hits. */
43
+ export declare function isWeak(c: SearchCluster | undefined, nQueries: number): {
44
+ weak: boolean;
45
+ why: string;
46
+ };
47
+ /**
48
+ * Run each query through recall() (hybrid when an embedder is available, else
49
+ * keyword), keep only rows present in `records` (the sanitized conversation view).
50
+ */
51
+ export declare function searchSession(dbPath: string, records: DbRecord[], queries: string[], topK?: number): Promise<SearchResult>;