osborn 0.9.250 → 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 (61) 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/package.json +1 -1
  61. package/test-results/.last-run.json +0 -4
@@ -0,0 +1,138 @@
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 { openStore, recall } from '../session-store.js';
12
+ import { getEmbedder } from '../embedder.js';
13
+ import { clusterAnchors } from './lens-clip-window.js';
14
+ import { parseJsonObject } from './lens-quotes.js';
15
+ const FETCH_TIMEOUT_MS = 15_000;
16
+ async function getJson(url) {
17
+ const ctrl = new AbortController();
18
+ const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
19
+ try {
20
+ const r = await fetch(url, { signal: ctrl.signal, headers: { 'User-Agent': 'osborn-content-lens', Accept: 'application/json' } });
21
+ if (!r.ok)
22
+ throw new Error(`HTTP ${r.status} for ${url}`);
23
+ return await r.json();
24
+ }
25
+ finally {
26
+ clearTimeout(t);
27
+ }
28
+ }
29
+ /** Public thread → plain text. GitHub issues/PRs and HN items via their JSON APIs; anything else as stripped HTML. */
30
+ export async function fetchThread(url, maxComments = 15) {
31
+ const gh = url.match(/github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)/);
32
+ if (gh) {
33
+ const base = `https://api.github.com/repos/${gh[1]}/${gh[2]}/issues/${gh[3]}`;
34
+ const issue = await getJson(base);
35
+ const comments = issue?.comments ? await getJson(`${base}/comments?per_page=${maxComments}`).catch(() => []) : [];
36
+ const parts = [`# ${issue?.title ?? ''}`, String(issue?.body ?? ''), ...comments.map(c => `--- comment by ${c?.user?.login ?? '?'}:\n${c?.body ?? ''}`)];
37
+ return { title: String(issue?.title ?? ''), text: parts.join('\n\n').slice(0, 20_000) };
38
+ }
39
+ const hn = url.match(/news\.ycombinator\.com\/item\?id=(\d+)/);
40
+ if (hn) {
41
+ const it = await getJson(`https://hn.algolia.com/api/v1/items/${hn[1]}`);
42
+ const strip = (s) => String(s ?? '').replace(/<[^>]+>/g, ' ').replace(/&#x27;/g, "'").replace(/&quot;/g, '"').replace(/&amp;/g, '&');
43
+ const kids = (it?.children ?? []).slice(0, maxComments).map((c) => `--- ${c?.author ?? '?'}: ${strip(c?.text)}`);
44
+ return { title: String(it?.title ?? ''), text: [`# ${it?.title ?? ''}`, strip(it?.text), ...kids].join('\n\n').slice(0, 20_000) };
45
+ }
46
+ const ctrl = new AbortController();
47
+ const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
48
+ try {
49
+ const r = await fetch(url, { signal: ctrl.signal });
50
+ if (!r.ok)
51
+ throw new Error(`HTTP ${r.status} for ${url}`);
52
+ const html = await r.text();
53
+ const title = html.match(/<title>([^<]*)<\/title>/i)?.[1]?.trim() ?? '';
54
+ const body = html.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ');
55
+ return { title, text: body.slice(0, 20_000) };
56
+ }
57
+ finally {
58
+ clearTimeout(t);
59
+ }
60
+ }
61
+ /** Short prompt: demand thread / topic → core problem + 3-5 search queries (instructions after the material). */
62
+ export function queriesPrompt(material, kind) {
63
+ return [
64
+ kind === 'thread' ? `<demand_thread>\n${material.slice(0, 12_000)}\n</demand_thread>` : `<topic>\n${material}\n</topic>`,
65
+ 'TASK: state the core problem in one line, then write 3-5 short search queries (2-6 words each) to find where a developer ' +
66
+ 'talked through THIS problem with their AI coding assistant in a voice session. Mix symptom words (what they would ' +
67
+ 'have seen/complained about) and mechanism words (what the cause or fix would be called). Plain words, no operators.',
68
+ 'Output ONLY: {"problem": "...", "queries": ["...", "..."]}',
69
+ ].join('\n\n');
70
+ }
71
+ export function readQueries(raw) {
72
+ const o = parseJsonObject(raw);
73
+ if (!o || !Array.isArray(o.queries))
74
+ return null;
75
+ const queries = o.queries.map(q => String(q ?? '').trim()).filter(Boolean).slice(0, 5);
76
+ return queries.length ? { problem: String(o.problem ?? '').trim(), queries } : null;
77
+ }
78
+ /** Group hits into clusters of nearby conversation rows and rank them. Pure. */
79
+ export function rankClusters(records, hits, mergeRows = 40) {
80
+ const byId = new Map(records.map(r => [r.id, r]));
81
+ return clusterAnchors(records, [...hits.keys()], mergeRows)
82
+ .map(rows => {
83
+ const qs = new Set();
84
+ let score = 0;
85
+ for (const id of rows) {
86
+ const h = hits.get(id);
87
+ score += h.score;
88
+ for (const q of h.queries)
89
+ qs.add(q);
90
+ }
91
+ const sorted = [...rows].sort((a, b) => a - b);
92
+ return {
93
+ rows: sorted, score: +(score * qs.size).toFixed(4), hits: rows.length, queries: [...qs],
94
+ firstTs: byId.get(sorted[0])?.timestamp ?? '', lastTs: byId.get(sorted[sorted.length - 1])?.timestamp ?? '',
95
+ };
96
+ })
97
+ .sort((a, b) => b.score - a.score);
98
+ }
99
+ /** Weak = the best cluster is matched by < 2 distinct queries or has < 3 hits. */
100
+ export function isWeak(c, nQueries) {
101
+ if (!c)
102
+ return { weak: true, why: 'no conversation rows matched any query' };
103
+ const minQ = Math.min(2, nQueries);
104
+ if (c.queries.length < minQ)
105
+ return { weak: true, why: `best cluster matched only ${c.queries.length}/${nQueries} queries` };
106
+ if (c.hits < 3)
107
+ return { weak: true, why: `best cluster has only ${c.hits} matching row(s)` };
108
+ return { weak: false, why: '' };
109
+ }
110
+ /**
111
+ * Run each query through recall() (hybrid when an embedder is available, else
112
+ * keyword), keep only rows present in `records` (the sanitized conversation view).
113
+ */
114
+ export async function searchSession(dbPath, records, queries, topK = 60) {
115
+ const allowed = new Set(records.map(r => r.id));
116
+ const embed = await getEmbedder().catch(() => null);
117
+ const db = openStore(dbPath, { readonly: true });
118
+ const hits = new Map();
119
+ try {
120
+ for (const q of queries) {
121
+ const res = await recall(db, q, { mode: embed ? 'hybrid' : 'keyword', topK, embed: embed ?? undefined });
122
+ for (const h of res) {
123
+ if (!allowed.has(h.id))
124
+ continue;
125
+ const cur = hits.get(h.id) ?? { score: 0, queries: new Set() };
126
+ cur.score += h.score;
127
+ cur.queries.add(q);
128
+ hits.set(h.id, cur);
129
+ }
130
+ }
131
+ }
132
+ finally {
133
+ db.close();
134
+ }
135
+ const clusters = rankClusters(records, hits);
136
+ const w = isWeak(clusters[0], queries.length);
137
+ return { clusters, mode: embed ? 'hybrid' : 'keyword', weak: w.weak, weakWhy: w.why };
138
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * lens-clip-window.ts — pass two (the clipper), step 1-2: pick the anchor rows
3
+ * and pull the FULL surrounding stretch of conversation out of session.db.
4
+ *
5
+ * Pure + deterministic (no model): keyword-overlap topic heuristic. Starts from
6
+ * anchors ± seedRows, grows outward one block at a time, alternating sides; a
7
+ * block counts as on-topic when it shares enough weighted (idf) terms with the
8
+ * seed's topic profile. Short off-topic detours are bridged (gapBlocks); a longer
9
+ * one ends that side. Compaction seams and long silences (time gaps) are SOFT
10
+ * limits: crossed only by a strongly on-topic block. Hard cap = maxTokens
11
+ * (callers pass 25% of the model's context).
12
+ */
13
+ import { type DbRecord } from './lens-db.js';
14
+ export type StopReason = 'topic-shift' | 'soft-limit:compaction' | 'soft-limit:time-gap' | 'cap' | 'session-edge';
15
+ export interface ExpandOptions {
16
+ seedRows?: number;
17
+ blockRows?: number;
18
+ /** Block score (0..1) at/above which a block is on-topic. */
19
+ threshold?: number;
20
+ /** Mean score of the 2 blocks beyond a soft limit (compaction seam / time gap) needed to cross it. */
21
+ strongThreshold?: number;
22
+ /** Off-topic blocks bridged before a side stops. */
23
+ gapBlocks?: number;
24
+ maxTokens?: number;
25
+ /** Silence between adjacent rows treated as a soft limit. */
26
+ softGapMs?: number;
27
+ /** content.ids of compaction seams (readCompactionBoundaries). */
28
+ boundaries?: number[];
29
+ /** Anchors within this many conversation rows of each other are merged. */
30
+ mergeRows?: number;
31
+ }
32
+ export interface ClipWindow {
33
+ records: DbRecord[];
34
+ firstRowId: number;
35
+ lastRowId: number;
36
+ estTokens: number;
37
+ anchors: number[];
38
+ /** Anchor rows in other (far-away) clusters, not used for this window. */
39
+ otherAnchors: number[];
40
+ stop: {
41
+ before: StopReason;
42
+ after: StopReason;
43
+ };
44
+ profile: string[];
45
+ blocks: {
46
+ side: 'before' | 'after';
47
+ firstRowId: number;
48
+ lastRowId: number;
49
+ score: number;
50
+ kept: boolean;
51
+ }[];
52
+ }
53
+ /** Lower-cased content terms (3+ chars, no stopwords). Keeps tokens like x-forwarded-host, 1006, 0.9.153. */
54
+ export declare function terms(text: string): Set<string>;
55
+ /** Merge anchor row ids into clusters of nearby conversation rows (by record index). */
56
+ export declare function clusterAnchors(records: DbRecord[], anchors: number[], mergeRows?: number): number[][];
57
+ export declare function expandWindow(records: DbRecord[], anchorIds: number[], opts?: ExpandOptions): ClipWindow | null;
58
+ /** Compaction seams: moved to the period-owned lens-period-boundaries.ts (re-exported for the clipper). */
59
+ export { readCompactionBoundaries } from './lens-period-boundaries.js';
60
+ /** Row anchors from a lens output file: every `(row #N)` inside angles/capabilities whose title matches `query`. */
61
+ export declare function parseLensAnchors(md: string, query: string): {
62
+ title: string;
63
+ rows: number[];
64
+ }[];
65
+ /**
66
+ * Multi-stretch: every anchor cluster expanded on its own (each gets an equal share
67
+ * of maxTokens), overlapping windows merged, ordered chronologically. Used for
68
+ * montage clips and the polished compilation (same session, different stretches).
69
+ */
70
+ export declare function expandClusters(records: DbRecord[], anchorIds: number[], opts?: ExpandOptions & {
71
+ maxClusters?: number;
72
+ }): ClipWindow[];
@@ -0,0 +1,257 @@
1
+ /**
2
+ * lens-clip-window.ts — pass two (the clipper), step 1-2: pick the anchor rows
3
+ * and pull the FULL surrounding stretch of conversation out of session.db.
4
+ *
5
+ * Pure + deterministic (no model): keyword-overlap topic heuristic. Starts from
6
+ * anchors ± seedRows, grows outward one block at a time, alternating sides; a
7
+ * block counts as on-topic when it shares enough weighted (idf) terms with the
8
+ * seed's topic profile. Short off-topic detours are bridged (gapBlocks); a longer
9
+ * one ends that side. Compaction seams and long silences (time gaps) are SOFT
10
+ * limits: crossed only by a strongly on-topic block. Hard cap = maxTokens
11
+ * (callers pass 25% of the model's context).
12
+ */
13
+ import { recordView, estimateTokens } from './lens-db.js';
14
+ const STOP = new Set(('the and for that this with you your are was were have has had not but just like what when then them they there ' +
15
+ 'their about from into would could should will can its it\'s i\'m don\'t that\'s okay yeah yes so um uh know ' +
16
+ 'mean really actually basically right going gonna wanna want need let\'s lets thing things also well even still ' +
17
+ 'here more some only very much make made does did doing done been being which where while because since each ' +
18
+ 'other than over our out all any one two who how why way get got see say said sure think now back time ' +
19
+ 'kind sort something maybe stuff look looks good same work working').split(/\s+/));
20
+ /** Lower-cased content terms (3+ chars, no stopwords). Keeps tokens like x-forwarded-host, 1006, 0.9.153. */
21
+ export function terms(text) {
22
+ const out = new Set();
23
+ for (const m of text.toLowerCase().matchAll(/[a-z0-9][a-z0-9._'-]*[a-z0-9]/g)) {
24
+ const t = m[0];
25
+ if (t.length >= 3 && !STOP.has(t) && !/^\d{1,2}$/.test(t))
26
+ out.add(t);
27
+ }
28
+ return out;
29
+ }
30
+ /** Merge anchor row ids into clusters of nearby conversation rows (by record index). */
31
+ export function clusterAnchors(records, anchors, mergeRows = 60) {
32
+ const idxOf = (id) => {
33
+ let i = records.findIndex(r => r.id >= id);
34
+ if (i === -1)
35
+ i = records.length - 1;
36
+ return i;
37
+ };
38
+ const pts = [...new Set(anchors)].filter(n => Number.isFinite(n)).map(id => ({ id, i: idxOf(id) })).sort((a, b) => a.i - b.i);
39
+ const clusters = [];
40
+ for (const p of pts) {
41
+ const last = clusters[clusters.length - 1];
42
+ if (last && p.i - last[last.length - 1].i <= mergeRows)
43
+ last.push(p);
44
+ else
45
+ clusters.push([p]);
46
+ }
47
+ return clusters.map(c => c.map(p => p.id));
48
+ }
49
+ /** Soft limit between two adjacent records (earlier a, later b), or null. */
50
+ function softLimit(a, b, o) {
51
+ if (o.boundaries.some(x => x > a.id && x <= b.id))
52
+ return 'soft-limit:compaction';
53
+ const gap = Date.parse(b.timestamp) - Date.parse(a.timestamp);
54
+ if (Number.isFinite(gap) && gap >= o.softGapMs)
55
+ return 'soft-limit:time-gap';
56
+ return null;
57
+ }
58
+ export function expandWindow(records, anchorIds, opts = {}) {
59
+ const o = {
60
+ seedRows: 12, blockRows: 8, threshold: 0.1, strongThreshold: 0.1, gapBlocks: 4, maxTokens: 200_000,
61
+ softGapMs: 3 * 3600_000, boundaries: [], mergeRows: 60, ...opts,
62
+ };
63
+ if (!records.length || !anchorIds.length)
64
+ return null;
65
+ const clusters = clusterAnchors(records, anchorIds, o.mergeRows);
66
+ const main = [...clusters].sort((a, b) => b.length - a.length)[0];
67
+ const idx = main.map(id => Math.max(0, records.findIndex(r => r.id >= id))).map(i => (i === -1 ? records.length - 1 : i));
68
+ let lo = Math.max(0, Math.min(...idx) - o.seedRows);
69
+ let hi = Math.min(records.length - 1, Math.max(...idx) + o.seedRows);
70
+ // Seed never crosses a soft limit away from the anchors.
71
+ for (let i = Math.min(...idx); i > lo; i--)
72
+ if (softLimit(records[i - 1], records[i], o)) {
73
+ lo = i;
74
+ break;
75
+ }
76
+ for (let i = Math.max(...idx); i < hi; i++)
77
+ if (softLimit(records[i], records[i + 1], o)) {
78
+ hi = i;
79
+ break;
80
+ }
81
+ // Topic profile: seed terms weighted by session idf (rare-in-session terms weigh more).
82
+ const df = new Map();
83
+ const termCache = records.map(r => terms(r.text));
84
+ for (const ts of termCache)
85
+ for (const t of ts)
86
+ df.set(t, (df.get(t) ?? 0) + 1);
87
+ const N = records.length;
88
+ const tf = new Map();
89
+ for (let i = lo; i <= hi; i++)
90
+ for (const t of termCache[i])
91
+ tf.set(t, (tf.get(t) ?? 0) + 1);
92
+ const weighted = [...tf]
93
+ .map(([t, c]) => [t, (1 + Math.log(c)) * Math.log((N + 1) / (df.get(t) ?? 1))])
94
+ .filter(([, w]) => w > 0)
95
+ .sort((a, b) => b[1] - a[1])
96
+ .slice(0, 40);
97
+ const profile = new Map(weighted);
98
+ const total = weighted.reduce((a, [, w]) => a + w, 0) || 1;
99
+ const score = (from, to) => {
100
+ const seen = new Set();
101
+ for (let i = from; i <= to; i++)
102
+ for (const t of termCache[i])
103
+ if (profile.has(t))
104
+ seen.add(t);
105
+ let s = 0;
106
+ for (const t of seen)
107
+ s += profile.get(t);
108
+ return s / total;
109
+ };
110
+ const viewTok = records.map(r => estimateTokens(recordView(r).length + 2));
111
+ let tokens = 0;
112
+ for (let i = lo; i <= hi; i++)
113
+ tokens += viewTok[i];
114
+ /** Far index of a block starting at `start` going `dir`, never spanning a soft limit. */
115
+ const blockEnd = (start, dir) => {
116
+ let end = start;
117
+ for (let k = 1; k < o.blockRows; k++) {
118
+ const nx = end + dir;
119
+ if (nx < 0 || nx >= N)
120
+ break;
121
+ const p = dir < 0 ? [records[nx], records[end]] : [records[end], records[nx]];
122
+ if (softLimit(p[0], p[1], o))
123
+ break;
124
+ end = nx;
125
+ }
126
+ return end;
127
+ };
128
+ const blocks = [];
129
+ const sides = [
130
+ { dir: -1, edge: lo, pending: lo, misses: 0, done: null },
131
+ { dir: 1, edge: hi, pending: hi, misses: 0, done: null },
132
+ ];
133
+ while (sides.some(s => !s.done)) {
134
+ for (const s of sides) {
135
+ if (s.done)
136
+ continue;
137
+ const start = s.pending + s.dir;
138
+ if (start < 0 || start >= N) {
139
+ s.done = 'session-edge';
140
+ continue;
141
+ }
142
+ const pair = s.dir < 0 ? [records[start], records[s.pending]] : [records[s.pending], records[start]];
143
+ const limit = softLimit(pair[0], pair[1], o);
144
+ const end = blockEnd(start, s.dir);
145
+ const [from, to] = s.dir < 0 ? [end, start] : [start, end];
146
+ const sc = score(from, to);
147
+ if (limit) {
148
+ // Cross a seam/silence only if the story clearly continues: mean of this block and the next one beyond it.
149
+ const nStart = end + s.dir;
150
+ let ahead = sc;
151
+ if (nStart >= 0 && nStart < N) {
152
+ const nEnd = blockEnd(nStart, s.dir);
153
+ ahead = (sc + (s.dir < 0 ? score(nEnd, nStart) : score(nStart, nEnd))) / 2;
154
+ }
155
+ if (ahead < o.strongThreshold) {
156
+ s.done = limit;
157
+ continue;
158
+ }
159
+ }
160
+ // Tokens of everything between the kept edge and this block's far end.
161
+ let add = 0;
162
+ const [pf, pt] = s.dir < 0 ? [end, s.edge - 1] : [s.edge + 1, end];
163
+ for (let i = pf; i <= pt; i++)
164
+ add += viewTok[i];
165
+ const on = sc >= o.threshold;
166
+ if (on && tokens + add > o.maxTokens) {
167
+ s.done = 'cap';
168
+ continue;
169
+ }
170
+ blocks.push({ side: s.dir < 0 ? 'before' : 'after', firstRowId: records[from].id, lastRowId: records[to].id, score: +sc.toFixed(3), kept: on });
171
+ s.pending = end;
172
+ if (on) {
173
+ tokens += add;
174
+ s.edge = end;
175
+ s.misses = 0;
176
+ if (s.dir < 0)
177
+ lo = end;
178
+ else
179
+ hi = end;
180
+ }
181
+ else if (++s.misses > o.gapBlocks)
182
+ s.done = 'topic-shift';
183
+ }
184
+ }
185
+ // Bridged detours are kept only when a later block was accepted: mark trailing misses unkept.
186
+ const recs = records.slice(lo, hi + 1);
187
+ return {
188
+ records: recs,
189
+ firstRowId: recs[0].id,
190
+ lastRowId: recs[recs.length - 1].id,
191
+ estTokens: tokens,
192
+ anchors: main,
193
+ otherAnchors: clusters.filter(c => c !== main).flat(),
194
+ stop: { before: sides[0].done, after: sides[1].done },
195
+ profile: weighted.slice(0, 15).map(([t]) => t),
196
+ blocks: blocks.map(b => ({ ...b, kept: b.firstRowId >= recs[0].id && b.lastRowId <= recs[recs.length - 1].id })),
197
+ };
198
+ }
199
+ /** Compaction seams: moved to the period-owned lens-period-boundaries.ts (re-exported for the clipper). */
200
+ export { readCompactionBoundaries } from './lens-period-boundaries.js';
201
+ /** Row anchors from a lens output file: every `(row #N)` inside angles/capabilities whose title matches `query`. */
202
+ export function parseLensAnchors(md, query) {
203
+ const q = query.toLowerCase().split(/\s+/).filter(Boolean);
204
+ const items = [];
205
+ let cur = null;
206
+ for (const line of md.split('\n')) {
207
+ const angle = line.match(/^####\s+(.+)$/);
208
+ const cap = line.match(/^- \*\*(.+?)\*\*(?!:)/);
209
+ if (angle || (cap && !/^(Why postable|Verified quotes|Gap evidence)/.test(cap[1]))) {
210
+ cur = { title: (angle ? angle[1] : cap[1]).trim(), body: [] };
211
+ items.push(cur);
212
+ }
213
+ else if (/^#{1,3}\s/.test(line))
214
+ cur = null;
215
+ else if (cur)
216
+ cur.body.push(line);
217
+ }
218
+ return items
219
+ .filter(it => q.every(w => it.title.toLowerCase().includes(w)))
220
+ .map(it => ({ title: it.title, rows: [...it.body.join('\n').matchAll(/\(row #(\d+)\)/g)].map(m => Number(m[1])) }))
221
+ .filter(it => it.rows.length);
222
+ }
223
+ /**
224
+ * Multi-stretch: every anchor cluster expanded on its own (each gets an equal share
225
+ * of maxTokens), overlapping windows merged, ordered chronologically. Used for
226
+ * montage clips and the polished compilation (same session, different stretches).
227
+ */
228
+ export function expandClusters(records, anchorIds, opts = {}) {
229
+ const clusters = clusterAnchors(records, anchorIds, opts.mergeRows ?? 60).slice(0, opts.maxClusters ?? 8);
230
+ if (!clusters.length)
231
+ return [];
232
+ const share = Math.floor((opts.maxTokens ?? 200_000) / clusters.length);
233
+ const wins = clusters
234
+ .map(c => expandWindow(records, c, { ...opts, maxTokens: share, mergeRows: Number.MAX_SAFE_INTEGER }))
235
+ .filter((w) => !!w)
236
+ .sort((a, b) => a.firstRowId - b.firstRowId);
237
+ const out = [];
238
+ for (const w of wins) {
239
+ const last = out[out.length - 1];
240
+ if (last && w.firstRowId <= last.lastRowId) {
241
+ const ids = new Set(last.records.map(r => r.id));
242
+ const recs = [...last.records, ...w.records.filter(r => !ids.has(r.id))].sort((a, b) => a.id - b.id);
243
+ out[out.length - 1] = {
244
+ ...last,
245
+ records: recs,
246
+ lastRowId: recs[recs.length - 1].id,
247
+ estTokens: recs.reduce((s, r) => s + estimateTokens(recordView(r).length + 2), 0),
248
+ anchors: [...last.anchors, ...w.anchors],
249
+ stop: { before: last.stop.before, after: w.stop.after },
250
+ blocks: [...last.blocks, ...w.blocks],
251
+ };
252
+ }
253
+ else
254
+ out.push(w);
255
+ }
256
+ return out.map(w => ({ ...w, otherAnchors: [] }));
257
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * lens-db.ts — the content lens's SOURCE: the per-session recall store
3
+ * (`session.db`, the same SQLite file `osborn-recall` queries), keyed by session ID.
4
+ *
5
+ * <claudeDir>/projects/<slug>/osb/<sessionId>/session.db
6
+ * content(id INTEGER PK, source TEXT, line_num, byte_offset, ts TEXT,
7
+ * msg_type TEXT, model, git_branch, cwd, tool_name, blob BLOB)
8
+ * blob = brotli-compressed full text (session-store.ts compress()).
9
+ * id is append-only and chronological; source = 'main' | 'agent-<id8>'.
10
+ *
11
+ * CONVERSATION ONLY: msg_type IN ('user','assistant'); thinking / tool_use /
12
+ * tool_result rows are never read. Default source is the main thread only
13
+ * (sub-agent rows are the orchestrator's prompts + agent chatter, not the
14
+ * conversation); OSBORN_LENS_INCLUDE_SUBAGENTS=1 widens it.
15
+ *
16
+ * Every pulled row goes through the same sanitizer as before (drops harness
17
+ * wrappers like <session_tail>/[EMERGENCY STOP], unwraps [INTERRUPTED]/[CONTEXT]
18
+ * to the user's actual words) and then a second redaction pass in assistant
19
+ * mode ({assistant:true}, i.e. incl. the high-entropy fallback) for BOTH speakers,
20
+ * then the optional client/customer redactor (names, IDs, emails, billing amounts).
21
+ * Opened read-only; never writes to the store.
22
+ */
23
+ export interface DbRecord {
24
+ /** content.id in session.db — the row the quote is verified against. */
25
+ id: number;
26
+ timestamp: string;
27
+ speaker: 'user' | 'assistant';
28
+ text: string;
29
+ }
30
+ export interface DbLoad {
31
+ dbPath: string;
32
+ records: DbRecord[];
33
+ /** Highest content.id scanned (incl. rows the sanitizer dropped) — the next high-water mark. */
34
+ maxRowId: number;
35
+ stats: {
36
+ rowsRead: number;
37
+ dropped: number;
38
+ unwrapped: number;
39
+ chars: number;
40
+ /** Rows touched per injected-context kind (lens-strip.ts) + sanitizer actions. */
41
+ stripped: Record<string, number>;
42
+ };
43
+ }
44
+ export declare const CHARS_PER_TOKEN = 4;
45
+ /**
46
+ * Resolve session.db for a session id: <projectDir>/osb/<sid>/session.db, then
47
+ * the cwd slug, then a scan of every project's osb dir (same layout as
48
+ * session-store.ts getStorePath / recall-cli listStores).
49
+ */
50
+ export declare function resolveSessionDb(sessionId: string, projectDir?: string | null, cwd?: string): string | null;
51
+ export declare function includeSubagents(): boolean;
52
+ /**
53
+ * Read conversation rows with id > afterRowId, in id order, sanitized.
54
+ * Throws if the DB can't be opened (caller logs + exits).
55
+ */
56
+ export declare function loadConversationRows(dbPath: string, afterRowId?: number, clientRedact?: (s: string) => string, throughRowId?: number): DbLoad;
57
+ /** Record as the model sees it: header with row id, exact timestamp, speaker. */
58
+ export declare function recordView(r: DbRecord): string;
59
+ export declare const estimateTokens: (chars: number) => number;
60
+ export interface Window {
61
+ index: number;
62
+ text: string;
63
+ records: DbRecord[];
64
+ firstRowId: number;
65
+ lastRowId: number;
66
+ chars: number;
67
+ estTokens: number;
68
+ }
69
+ /** Pack records IN ORDER into windows of ≤ windowTokens (estimated at CHARS_PER_TOKEN). */
70
+ export declare function packWindows(records: DbRecord[], windowTokens: number): Window[];