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,186 @@
1
+ /**
2
+ * lens-quotes.ts — the DETERMINISTIC anti-hallucination guard (no model).
3
+ *
4
+ * Every quote the model returns must be an exact (whitespace-normalized)
5
+ * substring of a sanitized session.db conversation row carrying the SAME
6
+ * timestamp (rows the model actually saw). Items (angles or capabilities) with
7
+ * any unverifiable quote are dropped wholesale. The speaker and row id written
8
+ * to disk are taken from the matched row, never from the model.
9
+ */
10
+ const MIN_QUOTE_CHARS = 12;
11
+ const MAX_QUOTE_CHARS = 400;
12
+ export const normalizeWs = (s) => s.replace(/\s+/g, ' ').trim();
13
+ /** Strip wrapping quote marks the model may add; nothing else is altered. */
14
+ function unwrapQuote(s) {
15
+ return s.trim().replace(/^["“”'`]+/, '').replace(/["“”'`]+$/, '').trim();
16
+ }
17
+ /**
18
+ * Formatting-only normalization applied identically to quote AND row: markdown
19
+ * emphasis/code marks, typographic quotes/ellipsis, whitespace, letter case. Words, digits and
20
+ * punctuation that carry meaning are untouched, so this is still a substring check.
21
+ */
22
+ export const looseText = (s) => normalizeWs(s
23
+ .replace(/\*\*|__|`+/g, '')
24
+ .replace(/(^|[\s(])\*(?=\S)|(?<=\S)\*(?=[\s).,;:!?]|$)/g, '$1')
25
+ .replace(/[‘’]/g, "'")
26
+ .replace(/[“”]/g, '"')
27
+ .replace(/…/g, '...')).toLowerCase();
28
+ /** A cited timestamp may slip (neighbouring row, one digit); beyond this it's a different moment. */
29
+ export const TS_TOLERANCE_MS = 3 * 60 * 60_000;
30
+ /** timestamp → rows carrying it (normalized + loose text, row id, parsed time). */
31
+ export function buildRecordIndex(records) {
32
+ const idx = new Map();
33
+ for (const r of records) {
34
+ const list = idx.get(r.timestamp) ?? [];
35
+ const text = normalizeWs(r.text);
36
+ list.push({ text, loose: looseText(text), speaker: r.speaker, row: r.id ?? 0, ts: r.timestamp, ms: Date.parse(r.timestamp) });
37
+ idx.set(r.timestamp, list);
38
+ }
39
+ return idx;
40
+ }
41
+ /**
42
+ * Verified quote or null. Pure + deterministic. The quote text must be a
43
+ * substring (whitespace/markdown-normalized) of a SOURCE row. Exact timestamp
44
+ * match is preferred; otherwise the nearest row containing the text is accepted
45
+ * only if the cited timestamp is within TS_TOLERANCE_MS, or the cited row id is
46
+ * that row. The timestamp, speaker and row written out are ALWAYS the source
47
+ * row's, never the model's (tsCorrected marks a fixed citation).
48
+ */
49
+ export function verifyQuote(q, idx) {
50
+ if (typeof q?.text !== 'string' || typeof q?.timestamp !== 'string')
51
+ return null;
52
+ const text = normalizeWs(unwrapQuote(q.text));
53
+ if (text.length < MIN_QUOTE_CHARS || text.length > MAX_QUOTE_CHARS)
54
+ return null;
55
+ const loose = looseText(text);
56
+ if (loose.length < MIN_QUOTE_CHARS)
57
+ return null;
58
+ const ts = q.timestamp.trim();
59
+ const hint = Number(String(q.row ?? '').replace(/^#/, ''));
60
+ const hit = (c) => c.text.includes(text) || c.loose.includes(loose);
61
+ const exact = (idx.get(ts) ?? []).filter(hit).sort((a, b) => Number(b.row === hint) - Number(a.row === hint));
62
+ if (exact.length)
63
+ return { text, timestamp: exact[0].ts, speaker: exact[0].speaker, row: exact[0].row };
64
+ const cited = Date.parse(ts);
65
+ let best = null;
66
+ let bestDiff = Infinity;
67
+ for (const list of idx.values()) {
68
+ for (const c of list) {
69
+ if (!hit(c))
70
+ continue;
71
+ const diff = c.row === hint ? 0 : Number.isFinite(cited) && Number.isFinite(c.ms) ? Math.abs(c.ms - cited) : Infinity;
72
+ if (diff < bestDiff) {
73
+ best = c;
74
+ bestDiff = diff;
75
+ }
76
+ }
77
+ }
78
+ if (!best || bestDiff > TS_TOLERANCE_MS)
79
+ return null;
80
+ return { text, timestamp: best.ts, speaker: best.speaker, row: best.row, tsCorrected: true };
81
+ }
82
+ /** Why a quote failed (debug only; same rules as verifyQuote). */
83
+ export function explainQuote(q, idx) {
84
+ if (verifyQuote(q, idx))
85
+ return 'ok';
86
+ if (typeof q?.text !== 'string' || typeof q?.timestamp !== 'string')
87
+ return 'malformed';
88
+ const text = normalizeWs(unwrapQuote(q.text));
89
+ if (text.length < MIN_QUOTE_CHARS || text.length > MAX_QUOTE_CHARS)
90
+ return `length ${text.length}`;
91
+ const cands = idx.get(q.timestamp.trim());
92
+ if (!cands) {
93
+ for (const [ts, list] of idx)
94
+ if (list.some(c => c.text.includes(text)))
95
+ return `wrong-timestamp (text is in ${ts})`;
96
+ return 'unknown-timestamp, text not found anywhere';
97
+ }
98
+ for (const [ts, list] of idx)
99
+ if (ts !== q.timestamp && list.some(c => c.text.includes(text)))
100
+ return `text belongs to ${ts}`;
101
+ return 'timestamp ok, text not a substring (paraphrased/edited)';
102
+ }
103
+ /**
104
+ * Keep items whose quotes ALL verify (1..maxQuotes required). Returns the kept
105
+ * items with verified quotes substituted, plus the drop count.
106
+ */
107
+ export function verifyItems(items, idx, maxQuotes = 3) {
108
+ const kept = [];
109
+ let dropped = 0;
110
+ for (const it of items) {
111
+ const qs = Array.isArray(it.quotes) ? it.quotes.slice(0, maxQuotes) : [];
112
+ const verified = qs.map(q => verifyQuote(q, idx));
113
+ if (qs.length === 0 || verified.some(v => v === null)) {
114
+ dropped++;
115
+ continue;
116
+ }
117
+ kept.push({ ...it, quotes: verified });
118
+ }
119
+ return { kept, dropped };
120
+ }
121
+ /** Tolerant JSON-object parse: accepts fences or leading prose. */
122
+ export function parseJsonObject(raw) {
123
+ if (!raw)
124
+ return null;
125
+ const s = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim();
126
+ const start = s.indexOf('{');
127
+ const end = s.lastIndexOf('}');
128
+ if (start === -1 || end <= start)
129
+ return null;
130
+ try {
131
+ const o = JSON.parse(s.slice(start, end + 1));
132
+ return o && typeof o === 'object' && !Array.isArray(o) ? o : null;
133
+ }
134
+ catch {
135
+ return null;
136
+ }
137
+ }
138
+ const str = (v) => (typeof v === 'string' ? v.trim() : '');
139
+ /** Done-vs-planned gate: only shipped/verified or proven-root-cause items count. */
140
+ export function normalizeEvidence(v) {
141
+ const s = str(v).toLowerCase().replace(/[\s_]+/g, '-');
142
+ if (s === 'shipped' || s === 'verified' || s === 'done')
143
+ return 'shipped';
144
+ if (s === 'root-caused' || s === 'rootcaused' || s === 'root-cause')
145
+ return 'root-caused';
146
+ return '';
147
+ }
148
+ export function splitByEvidence(caps) {
149
+ const done = caps.filter(c => c.evidence !== '');
150
+ return { done, notDone: caps.length - done.length };
151
+ }
152
+ export function readAngles(v, max) {
153
+ if (!Array.isArray(v))
154
+ return [];
155
+ const out = [];
156
+ for (const o of v) {
157
+ const title = str(o?.title);
158
+ if (!title)
159
+ continue;
160
+ const queries = Array.isArray(o?.queries) ? o.queries.map(str).filter(Boolean).slice(0, 2) : [];
161
+ out.push({ title, why: str(o?.why), quotes: Array.isArray(o?.quotes) ? o.quotes : [], queries });
162
+ if (out.length >= max)
163
+ break;
164
+ }
165
+ return out;
166
+ }
167
+ export function readCapabilities(v, max) {
168
+ if (!Array.isArray(v))
169
+ return [];
170
+ const out = [];
171
+ for (const o of v) {
172
+ const name = str(o?.name);
173
+ if (!name)
174
+ continue;
175
+ out.push({
176
+ name,
177
+ did: str(o?.did),
178
+ evidence: normalizeEvidence(o?.evidence),
179
+ proof: str(o?.proof),
180
+ quotes: Array.isArray(o?.quotes) ? o.quotes : [],
181
+ });
182
+ if (out.length >= max)
183
+ break;
184
+ }
185
+ return out;
186
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * lens-redact.ts — CLIENT/CUSTOMER redaction for the content lens.
3
+ *
4
+ * Content may draw on client work (ads-API lessons etc.) but never on who the
5
+ * client is. Applied twice: (1) to every conversation row BEFORE it is sent to
6
+ * the model (after the secret sanitizer), (2) to the final entry written to the
7
+ * profile / sample file. Replacements are generic role tags ([client],
8
+ * [founder], [ad account], [amount] …) so the technical lesson stays readable.
9
+ *
10
+ * Configurable, no names in source:
11
+ * - DEFAULT_PATTERNS below: shape-based (emails, act_…, platform IDs, UUIDs,
12
+ * phones, billing amounts).
13
+ * - <projectDir>/content-lens-denylist.json (or OSBORN_LENS_DENYLIST=<path>):
14
+ * { "terms": { "[founder]": ["Name", ...], "[client]": [...], "[brand]": [...] },
15
+ * "patterns": [ { "re": "regex source", "flags": "gi", "replace": "[tag]" } ] }
16
+ * terms match whole words, case-insensitive (incl. possessive 's).
17
+ * Client company names are redacted BY DEFAULT (denylist "[client]" terms +
18
+ * any legal-suffixed company name). There is no per-project allowlist: the
19
+ * only override is the per-piece `reveal` option at draft/render time.
20
+ */
21
+ export declare const DENYLIST_FILE = "content-lens-denylist.json";
22
+ export interface RedactRule {
23
+ re: RegExp;
24
+ replace: string | ((m: string, ...a: any[]) => string);
25
+ }
26
+ /** Shape-based defaults. Order matters: specific shapes first. */
27
+ export declare const DEFAULT_PATTERNS: RedactRule[];
28
+ export interface ClientRedactor {
29
+ rules: RedactRule[];
30
+ terms: number;
31
+ source: string | null;
32
+ redact: (text: string) => string;
33
+ }
34
+ export interface RedactorOptions {
35
+ /**
36
+ * PER-PIECE opt-in un-redaction: denylisted terms to leave visible for ONE
37
+ * named draft/render (e.g. reveal: ["Audos"]). Never used by extraction —
38
+ * the profile and samples are always stored fully redacted. A renderer that
39
+ * reveals re-pulls the quote rows from session.db by row # and redacts them
40
+ * with this option, in memory, for that piece only.
41
+ */
42
+ reveal?: string[];
43
+ }
44
+ /** Load the denylist (project dir or OSBORN_LENS_DENYLIST) + defaults. Never throws. */
45
+ export declare function loadClientRedactor(projectDir: string | null, opts?: RedactorOptions): ClientRedactor;
46
+ /** True for personal billing talk; ambiguous billing words pass only with technical context (in `text` or `context`). */
47
+ export declare function isBillingText(text: string, context?: string): boolean;
48
+ export declare const isBillingAngle: (a: {
49
+ title: string;
50
+ why: string;
51
+ }) => boolean;
52
+ /** Same billing gate for capabilities: name, did and proof. */
53
+ export declare const isBillingCapability: (c: {
54
+ name: string;
55
+ did: string;
56
+ proof: string;
57
+ }) => boolean;
58
+ /** True when any (verified) quote's text talks billing (the item's own fields count as technical context). */
59
+ export declare const hasBillingQuote: (it: {
60
+ quotes: unknown[];
61
+ }) => boolean;
@@ -0,0 +1,187 @@
1
+ /**
2
+ * lens-redact.ts — CLIENT/CUSTOMER redaction for the content lens.
3
+ *
4
+ * Content may draw on client work (ads-API lessons etc.) but never on who the
5
+ * client is. Applied twice: (1) to every conversation row BEFORE it is sent to
6
+ * the model (after the secret sanitizer), (2) to the final entry written to the
7
+ * profile / sample file. Replacements are generic role tags ([client],
8
+ * [founder], [ad account], [amount] …) so the technical lesson stays readable.
9
+ *
10
+ * Configurable, no names in source:
11
+ * - DEFAULT_PATTERNS below: shape-based (emails, act_…, platform IDs, UUIDs,
12
+ * phones, billing amounts).
13
+ * - <projectDir>/content-lens-denylist.json (or OSBORN_LENS_DENYLIST=<path>):
14
+ * { "terms": { "[founder]": ["Name", ...], "[client]": [...], "[brand]": [...] },
15
+ * "patterns": [ { "re": "regex source", "flags": "gi", "replace": "[tag]" } ] }
16
+ * terms match whole words, case-insensitive (incl. possessive 's).
17
+ * Client company names are redacted BY DEFAULT (denylist "[client]" terms +
18
+ * any legal-suffixed company name). There is no per-project allowlist: the
19
+ * only override is the per-piece `reveal` option at draft/render time.
20
+ */
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ export const DENYLIST_FILE = 'content-lens-denylist.json';
24
+ const BILLING_WORDS = /\b(invoice[ds]?|invoicing|billing|billed|bill|retainer|payment|paid|refund(?:ed)?|deposit|owe[ds]?|payout|receivable|net[- ]?30)\b/i;
25
+ const ID_CONTEXT = '(?:act_|ad[ _-]?account|account|customer|client[ _-]?customer|mcc|cid|business(?:[ _-]?manager)?|bm|page|ig|instagram|' +
26
+ 'pixel|dataset|catalog|app|sponsoredAccount|organization|campaign|adset|ad[ _-]?set|creative|login[ _-]?customer)';
27
+ /** Shape-based defaults. Order matters: specific shapes first. */
28
+ export const DEFAULT_PATTERNS = [
29
+ { re: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, replace: '[email]' },
30
+ { re: /\bact_\d{5,}\b/g, replace: '[ad account]' },
31
+ { re: /\burn:li:(?:sponsoredAccount|sponsoredCampaign|organization|person|company):[A-Za-z0-9_-]+/g, replace: '[linkedin account]' },
32
+ { re: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, replace: '[workspace id]' },
33
+ // Google Ads customer / MCC ids (123-456-7890) when an account word is nearby; otherwise a phone.
34
+ {
35
+ re: new RegExp(`(${ID_CONTEXT}[^\\n]{0,30}?)\\b\\d{3}-\\d{3}-\\d{4}\\b`, 'gi'),
36
+ replace: (_m, pre) => `${pre}[ad account]`,
37
+ },
38
+ // Labelled platform ids: "page id 1234567890", "pixel: 123…", "IG 1784…", "customer_id=…".
39
+ {
40
+ re: new RegExp(`(${ID_CONTEXT}[\\s_-]*(?:id|ids|#|number)?["'\`]?\\s*[:=#]?\\s*["'\`]?)\\d{6,20}\\b`, 'gi'),
41
+ replace: (_m, pre) => `${pre}[account id]`,
42
+ },
43
+ // Bare long numeric ids (Meta business/page/IG/pixel ids are 15-17 digits).
44
+ { re: /(?<![\d.:-])\d{15,20}(?![\d.])/g, replace: '[account id]' },
45
+ // Phone numbers: +country forms and (555) 555-5555 / 555-555-5555 / 555.555.5555.
46
+ { re: /\+\d{1,3}[\s.-]?\(?\d{2,4}\)?[\s.-]?\d{3,4}[\s.-]?\d{3,4}\b/g, replace: '[phone]' },
47
+ { re: /(?<![\d-])\(?\b\d{3}\)?[\s.-]\d{3}[.-]\d{4}\b(?![\d-])/g, replace: '[phone]' },
48
+ ];
49
+ /** $ amounts only on lines that talk about invoices/billing/payments (API pricing stays). */
50
+ function redactBillingAmounts(text) {
51
+ if (!BILLING_WORDS.test(text))
52
+ return text;
53
+ return text
54
+ .split('\n')
55
+ .map(line => (BILLING_WORDS.test(line) ? line.replace(/(?:US)?\$\s?\d[\d,]*(?:\.\d+)?(?:[kKmM]\b)?/g, '[amount]') : line))
56
+ .join('\n');
57
+ }
58
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
59
+ function termRule(term, tag) {
60
+ const t = term.trim();
61
+ if (t.length < 2)
62
+ return null;
63
+ return { re: new RegExp(`(?<![A-Za-z0-9_])${escapeRe(t)}(?:'s|’s)?(?![A-Za-z0-9_])`, 'gi'), replace: tag };
64
+ }
65
+ /** Load the denylist (project dir or OSBORN_LENS_DENYLIST) + defaults. Never throws. */
66
+ export function loadClientRedactor(projectDir, opts = {}) {
67
+ const reveal = new Set((opts.reveal ?? []).map(s => s.trim().toLowerCase()).filter(Boolean));
68
+ const rules = [];
69
+ let terms = 0;
70
+ let source = null;
71
+ const path = process.env.OSBORN_LENS_DENYLIST || (projectDir ? join(projectDir, DENYLIST_FILE) : '');
72
+ if (path && existsSync(path)) {
73
+ try {
74
+ const cfg = JSON.parse(readFileSync(path, 'utf-8'));
75
+ source = path;
76
+ // Longest terms first so "Acme Labs" wins over "Acme".
77
+ const pairs = [];
78
+ for (const [tag, list] of Object.entries(cfg?.terms ?? {})) {
79
+ if (!Array.isArray(list))
80
+ continue;
81
+ const label = /^\[.*\]$/.test(tag) ? tag : `[${tag}]`;
82
+ for (const term of list)
83
+ if (typeof term === 'string')
84
+ pairs.push([term, label]);
85
+ }
86
+ pairs.sort((a, b) => b[0].length - a[0].length);
87
+ for (const [term, label] of pairs) {
88
+ if (reveal.has(term.trim().toLowerCase()))
89
+ continue;
90
+ const r = termRule(term, label);
91
+ if (r) {
92
+ rules.push(r);
93
+ terms++;
94
+ }
95
+ }
96
+ for (const p of Array.isArray(cfg?.patterns) ? cfg.patterns : []) {
97
+ try {
98
+ const flags = String(p?.flags ?? 'gi');
99
+ rules.push({ re: new RegExp(String(p.re), flags.includes('g') ? flags : flags + 'g'), replace: String(p?.replace ?? '[redacted]') });
100
+ }
101
+ catch {
102
+ /* skip a bad pattern, keep the rest */
103
+ }
104
+ }
105
+ }
106
+ catch {
107
+ /* unreadable denylist → defaults only */
108
+ }
109
+ }
110
+ rules.push(...DEFAULT_PATTERNS);
111
+ // Default-on: legal-suffixed company names ("Acme Labs Inc", "Foo LLC") are clients
112
+ // unless revealed for this piece. Named clients without a suffix live in the denylist.
113
+ rules.push({
114
+ re: /\b(?:[A-Z][A-Za-z0-9&'-]+\s){1,3}(?:Inc|LLC|L\.L\.C|Ltd|Limited|Corp|Corporation|GmbH|S\.A|SAS|BV|PLC|Pty)\b\.?/g,
115
+ replace: (m) => (reveal.has(m.replace(/\s+(Inc|LLC|L\.L\.C|Ltd|Limited|Corp|Corporation|GmbH|S\.A|SAS|BV|PLC|Pty)\.?$/, '').trim().toLowerCase()) ? m : '[client]'),
116
+ });
117
+ const redact = (text) => {
118
+ let out = text;
119
+ for (const r of rules)
120
+ out = out.replace(r.re, r.replace);
121
+ return redactBillingAmounts(out);
122
+ };
123
+ return { rules, terms, source, redact };
124
+ }
125
+ /**
126
+ * Billing gate. PERSONAL money talk is never content material; billing as an
127
+ * ENGINEERING topic is.
128
+ * - BILLING_PERSONAL: always dropped, whatever the context: personal invoicing,
129
+ * retainers, refunds owed, overdue / unpaid / late fees, contract or rate
130
+ * negotiation, money owed to or by the user.
131
+ * - BILLING_AMBIGUOUS words (invoice, billing, payment, refund …): dropped UNLESS
132
+ * the same item carries technical context (BILLING_TECH): Stripe/payment
133
+ * webhook or API integration, billing API, payment gateway, ads-API billed
134
+ * spend / budget / spend caps / pricing, token pricing, rate limits, wallet holds.
135
+ */
136
+ const BILLING_PERSONAL = new RegExp([
137
+ 'retainers?',
138
+ 'accounts? receivable',
139
+ 'late fees?',
140
+ 'overdue',
141
+ 'unpaid',
142
+ 'past[- ]due',
143
+ 'outstanding (?:balance|invoices?|payments?)',
144
+ 'net[- ]?(?:15|30|45|60)\\b',
145
+ 'refunds? (?:owed|due)',
146
+ '(?:owe[sd]?|owing) (?:me|us|you|him|her|them)\\b',
147
+ '(?:i|we|they|he|she|you) (?:still )?owe\\b',
148
+ 'money (?:owed|due)',
149
+ '(?:get|gets|getting|got) paid',
150
+ '(?:pay|pays|paying|paid) (?:me|us)\\b(?! back)',
151
+ "(?:hasn['’]t|haven['’]t|didn['’]t|never|not) (?:been )?paid",
152
+ 'send(?:s|ing)? (?:the |an |my |our |this )?invoices? to\\b',
153
+ '(?:invoic\\w*|bill(?:ed|ing)?|charg(?:e|ed|ing)) (?:the |my |our |a |this |that |each )?clients?\\b',
154
+ 'invoic\\w* (?:them|him|her)\\b',
155
+ 'client (?:billing|invoices?|invoicing|payments?)\\b',
156
+ '(?:hourly|day|daily|consulting|freelance|my|our) rates?\\b(?![- ]?limit)',
157
+ 'rate card',
158
+ '(?:my|our|consulting|freelance) fees?\\b',
159
+ 'negotiat\\w* (?:the |my |our |a |their |his |her )?(?:rates?|contract|fees?|price|pricing|deal|retainer|terms|salary)\\b(?![- ]?limit)',
160
+ '(?:rate|contract|fee|price|pricing|salary) negotiations?',
161
+ 'contract (?:rate|terms|renewal|value)',
162
+ 'statement of work',
163
+ ].map(s => `\\b${s}`).join('|'), 'i');
164
+ const BILLING_AMBIGUOUS = /\b(invoice[ds]?|invoicing|billing|billed|bill|payments?|paid|refunds?|refunded|chargebacks?|payouts?)\b/i;
165
+ const BILLING_TECH = new RegExp([
166
+ 'stripe', 'webhooks?', 'apis?', 'sdk', 'endpoints?', 'gateway', 'integrations?', 'idempoten\\w*',
167
+ 'spend(?: caps?)?', 'spending limits?', 'budgets?', 'pricing', 'per[- ]million', 'tokens?', 'rate[- ]limit\\w*',
168
+ 'quotas?', 'wallets?', 'meta ads', 'google ads', 'linkedin ads', 'ads? accounts?', 'campaigns?',
169
+ 'openrouter', 'schema', 'migrations?', 'cron', 'queues?',
170
+ ].map(s => `\\b${s}\\b`).join('|'), 'i');
171
+ /** True for personal billing talk; ambiguous billing words pass only with technical context (in `text` or `context`). */
172
+ export function isBillingText(text, context = '') {
173
+ if (BILLING_PERSONAL.test(text))
174
+ return true;
175
+ return BILLING_AMBIGUOUS.test(text) && !BILLING_TECH.test(`${text} ${context}`);
176
+ }
177
+ export const isBillingAngle = (a) => isBillingText(`${a.title} ${a.why}`);
178
+ /** Same billing gate for capabilities: name, did and proof. */
179
+ export const isBillingCapability = (c) => isBillingText(`${c.name} ${c.did} ${c.proof}`);
180
+ /** True when any (verified) quote's text talks billing (the item's own fields count as technical context). */
181
+ export const hasBillingQuote = (it) => {
182
+ if (!Array.isArray(it.quotes))
183
+ return false;
184
+ const o = it;
185
+ const ctx = ['title', 'why', 'name', 'did', 'proof'].map(k => (typeof o[k] === 'string' ? o[k] : '')).join(' ');
186
+ return it.quotes.some(q => typeof q?.text === 'string' && isBillingText(q.text, ctx));
187
+ };
@@ -0,0 +1,33 @@
1
+ /**
2
+ * lens-strip.ts — strip every INJECTED / SUMMARISED context from a raw
3
+ * session.db conversation row before it can reach the content lens. Pure.
4
+ *
5
+ * The lens must see only what the person and the assistant actually said.
6
+ * Harness injections live in USER-type rows (the SDK/pipeline prepends or
7
+ * appends them to the real utterance), so the block strippers run on user rows
8
+ * only — assistant text is never altered here (it may legitimately mention a
9
+ * tag name; secret/client redaction still applies to it downstream).
10
+ *
11
+ * Kinds (counted per affected row):
12
+ * compaction-summary "This session is being continued from a previous conversation…",
13
+ * or a line-start "=== HANDOFF_STATE ===" / "=== DECISIONS ===" style section header → row dropped
14
+ * (a bare mention of HANDOFF_STATE in real speech is kept)
15
+ * silent-control "[SYSTEM — SILENT CONTROL MESSAGE…]" orchestrator nudges → row dropped
16
+ * session-tail <session_tail>…</session_tail> blocks (post-compaction replay)
17
+ * recalled-context <recalled_context>…</recalled_context> blocks (recall injection)
18
+ * system-reminder <system-reminder…>…</system-reminder> blocks
19
+ * turn-shape the turn-shape reminder text (prompts/turn-shape-reminder.md)
20
+ * task-notification <task-notification>…</task-notification> blocks
21
+ * hook-context UserPromptSubmit hook additional context (<user-prompt-submit-hook>, labelled lines)
22
+ * [INTERRUPTED]/[CONTEXT] wrappers are unwrapped afterwards by sanitizeRecord
23
+ * (transcript-sanitizer.ts), which keeps only the user's own quoted words.
24
+ */
25
+ export declare const STRIP_KINDS: readonly ["compaction-summary", "silent-control", "session-tail", "recalled-context", "system-reminder", "turn-shape", "task-notification", "hook-context"];
26
+ export type StripKind = (typeof STRIP_KINDS)[number];
27
+ export interface StripResult {
28
+ /** Remaining text (trimmed), or null when the whole row was injected content. */
29
+ text: string | null;
30
+ kinds: StripKind[];
31
+ }
32
+ /** Strip injected context from one row. Assistant rows pass through unchanged. */
33
+ export declare function stripInjected(raw: string, speaker: 'user' | 'assistant'): StripResult;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * lens-strip.ts — strip every INJECTED / SUMMARISED context from a raw
3
+ * session.db conversation row before it can reach the content lens. Pure.
4
+ *
5
+ * The lens must see only what the person and the assistant actually said.
6
+ * Harness injections live in USER-type rows (the SDK/pipeline prepends or
7
+ * appends them to the real utterance), so the block strippers run on user rows
8
+ * only — assistant text is never altered here (it may legitimately mention a
9
+ * tag name; secret/client redaction still applies to it downstream).
10
+ *
11
+ * Kinds (counted per affected row):
12
+ * compaction-summary "This session is being continued from a previous conversation…",
13
+ * or a line-start "=== HANDOFF_STATE ===" / "=== DECISIONS ===" style section header → row dropped
14
+ * (a bare mention of HANDOFF_STATE in real speech is kept)
15
+ * silent-control "[SYSTEM — SILENT CONTROL MESSAGE…]" orchestrator nudges → row dropped
16
+ * session-tail <session_tail>…</session_tail> blocks (post-compaction replay)
17
+ * recalled-context <recalled_context>…</recalled_context> blocks (recall injection)
18
+ * system-reminder <system-reminder…>…</system-reminder> blocks
19
+ * turn-shape the turn-shape reminder text (prompts/turn-shape-reminder.md)
20
+ * task-notification <task-notification>…</task-notification> blocks
21
+ * hook-context UserPromptSubmit hook additional context (<user-prompt-submit-hook>, labelled lines)
22
+ * [INTERRUPTED]/[CONTEXT] wrappers are unwrapped afterwards by sanitizeRecord
23
+ * (transcript-sanitizer.ts), which keeps only the user's own quoted words.
24
+ */
25
+ export const STRIP_KINDS = [
26
+ 'compaction-summary',
27
+ 'silent-control',
28
+ 'session-tail',
29
+ 'recalled-context',
30
+ 'system-reminder',
31
+ 'turn-shape',
32
+ 'task-notification',
33
+ 'hook-context',
34
+ ];
35
+ const COMPACTION_LEAD_RE = /^\s*(?:\[time:[^\]]*\]\s*)?(?:This session is being continued from a previous conversation|Conversation compacted\b)/i;
36
+ /** Only a line-start `=== SECTION ===` header is compaction evidence; a bare mention of a section name is speech. */
37
+ const COMPACTION_SECTION_RE = /^[ \t]*===[ \t]*(?:DECISIONS|GOTCHAS|LEARNINGS|HANDOFF[_ ]STATE|OPEN QUESTIONS|NEXT STEPS)[ \t]*===[ \t]*$/m;
38
+ const SILENT_CONTROL_RE = /^\s*(?:\[time:[^\]]*\]\s*)?\[SYSTEM\s*[—–-]+\s*SILENT CONTROL MESSAGE/i;
39
+ /**
40
+ * Paired blocks; open tags may carry attributes. An UNTERMINATED opening tag swallows
41
+ * the rest of the row only when it starts the row or a line — an inline mention
42
+ * ("the <system-reminder> tag is noisy") is the person's own words and is kept.
43
+ */
44
+ const BLOCKS = [
45
+ { kind: 'session-tail', open: /<session_tail\b[^>]*>/g, close: /<\/session_tail>/ },
46
+ { kind: 'recalled-context', open: /<recalled_context\b[^>]*>/g, close: /<\/recalled_context>/ },
47
+ { kind: 'system-reminder', open: /<system-reminder\b[^>]*>/g, close: /<\/system-reminder>/ },
48
+ { kind: 'task-notification', open: /<task-notification\b[^>]*>/g, close: /<\/task-notification>/ },
49
+ { kind: 'hook-context', open: /<user-prompt-submit-hook\b[^>]*>/g, close: /<\/user-prompt-submit-hook>/ },
50
+ ];
51
+ /** What may precede an unterminated open tag on its line for it to count as line-start. */
52
+ const LINE_START_PREFIX_RE = /^\s*(?:\[time:[^\]]*\]\s*)?$/;
53
+ /** Turn-shape reminder: first line of prompts/turn-shape-reminder.md (or its header) → its last line. */
54
+ const TURN_SHAPE_START = /You are thinking with this person, not for them\.|\[TURN-SHAPE REMINDER\b[^\]]*\]/;
55
+ const TURN_SHAPE_END = /The user is a peer thinking with you, not pressing buttons\.?/;
56
+ /** Labelled hook-context paragraphs as Claude Code renders them in transcripts. */
57
+ const HOOK_LINE_RE = /^[ \t]*(?:UserPromptSubmit|SessionStart|PreToolUse|PostToolUse)(?::[^\n]*)? hook additional context:[^\n]*(?:\n(?![ \t]*\n)[^\n]*)*/gim;
58
+ function stripBlock(text, open, close) {
59
+ let out = text;
60
+ let hit = false;
61
+ let from = 0;
62
+ for (let guard = 0; guard < 50; guard++) {
63
+ open.lastIndex = from;
64
+ const o = open.exec(out);
65
+ if (!o)
66
+ break;
67
+ const after = o.index + o[0].length;
68
+ const c = close.exec(out.slice(after));
69
+ const lineStart = out.lastIndexOf('\n', o.index - 1) + 1;
70
+ const atLineStart = LINE_START_PREFIX_RE.test(out.slice(lineStart, o.index));
71
+ // An inline open whose "close" belongs to a later, re-opened block is a mention, not a block.
72
+ const reopened = c ? new RegExp(open.source).test(out.slice(after, after + c.index)) : false;
73
+ let end;
74
+ if (c && (atLineStart || !reopened)) {
75
+ end = after + c.index + c[0].length;
76
+ }
77
+ else if (!c && atLineStart) {
78
+ end = out.length;
79
+ }
80
+ else {
81
+ from = after; // inline mention: keep it
82
+ continue;
83
+ }
84
+ hit = true;
85
+ out = out.slice(0, o.index) + '\n' + out.slice(end);
86
+ from = o.index;
87
+ }
88
+ open.lastIndex = 0;
89
+ return { text: out, hit };
90
+ }
91
+ /** Strip injected context from one row. Assistant rows pass through unchanged. */
92
+ export function stripInjected(raw, speaker) {
93
+ const text0 = raw ?? '';
94
+ if (speaker !== 'user')
95
+ return { text: text0.trim() || null, kinds: [] };
96
+ const kinds = [];
97
+ // Whole-row injections first (no genuine user words in them).
98
+ if (SILENT_CONTROL_RE.test(text0))
99
+ return { text: null, kinds: ['silent-control'] };
100
+ let text = text0;
101
+ for (const b of BLOCKS) {
102
+ const r = stripBlock(text, b.open, b.close);
103
+ if (r.hit) {
104
+ text = r.text;
105
+ kinds.push(b.kind);
106
+ }
107
+ }
108
+ // A compaction summary is judged on what is left once replay blocks are gone.
109
+ // [INTERRUPTED]/[CONTEXT] wrappers quote the assistant's own recent messages, which may
110
+ // mention these markers — those rows are unwrapped by sanitizeRecord instead, never dropped here.
111
+ const wrapper = /^\s*(?:\[time:[^\]]*\]\s*)?\[(?:INTERRUPTED|CONTEXT)\]/.test(text);
112
+ if (COMPACTION_LEAD_RE.test(text) || (!wrapper && COMPACTION_SECTION_RE.test(text)))
113
+ return { text: null, kinds: [...kinds, 'compaction-summary'] };
114
+ if (SILENT_CONTROL_RE.test(text))
115
+ return { text: null, kinds: [...kinds, 'silent-control'] };
116
+ const ts = TURN_SHAPE_START.exec(text);
117
+ if (ts) {
118
+ const rest = text.slice(ts.index);
119
+ const te = TURN_SHAPE_END.exec(rest);
120
+ const end = te ? ts.index + te.index + te[0].length : text.length;
121
+ text = text.slice(0, ts.index) + '\n' + text.slice(end);
122
+ kinds.push('turn-shape');
123
+ }
124
+ const hooked = text.replace(HOOK_LINE_RE, '');
125
+ if (hooked !== text) {
126
+ text = hooked;
127
+ if (!kinds.includes('hook-context'))
128
+ kinds.push('hook-context');
129
+ }
130
+ const out = text.replace(/\n{3,}/g, '\n\n').trim();
131
+ return { text: out || null, kinds };
132
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * period-run.ts — run the PERIOD MAP for one session. Stages (each its own module,
3
+ * swappable / rerunnable alone):
4
+ * 1. block loader lens-db.loadConversationRows + lens-period-block.selectBlock
5
+ * 2. strip/redact lens-strip (inside loadConversationRows) + secret/client redaction
6
+ * 3. period map lens-period (one OpenRouter call, quotes verified deterministically)
7
+ * 4. audio (opt.) lens-audio AudioAdapter — offsets only when a recording exists
8
+ * 5. render lens-period-render → review .md (redacted again)
9
+ * OpenRouter only. Hard cap: 1 call, $0.50. Never writes content-profile.md or the HWM.
10
+ *
11
+ * CLI: npx tsx agent/src/content/period-run.ts --session <id> --out <file.md>
12
+ * [--since 24h|ISO] [--until ISO] [--from-row N] [--dry]
13
+ */
14
+ import { type AudioAdapter } from './lens-audio.js';
15
+ import { type PeriodRunReport } from './lens-period-render.js';
16
+ export declare const PERIOD_CAP_USD = 0.5;
17
+ export interface PeriodOptions {
18
+ sessionId: string;
19
+ outPath: string;
20
+ since?: string;
21
+ until?: string;
22
+ fromRowId?: number;
23
+ /** Inclusive end row (with fromRowId) — the library step's closed period. */
24
+ toRowId?: number;
25
+ /** Block stats only — no model call. */
26
+ dry?: boolean;
27
+ audio?: AudioAdapter;
28
+ log?: (m: string) => void;
29
+ }
30
+ export declare function runPeriodMap(o: PeriodOptions): Promise<PeriodRunReport>;