@startsimpli/integrations 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The small amount of Markdown that actually arrives in a corpus, rendered.
3
+ *
4
+ * WHY THIS EXISTS. Sources write Markdown and the corpus stores it verbatim —
5
+ * Granola's summary is `## Decisions` and `**$4,800 per month**`, and a model
6
+ * asked for prose answers in the same dialect. Rendering that as plain text puts
7
+ * literal hashes and asterisks on the screen, which reads as a bug in the
8
+ * product rather than as punctuation from the source.
9
+ *
10
+ * WHY IT IS ~100 LINES AND NOT A DEPENDENCY. A Markdown library is a peer
11
+ * dependency every consuming app has to install and a sanitiser decision every
12
+ * consuming app has to get right. This renders to REACT ELEMENTS — there is no
13
+ * `dangerouslySetInnerHTML` anywhere below, so untrusted document text cannot
14
+ * become markup, which is the property that actually matters for a surface whose
15
+ * whole job is displaying text somebody else wrote.
16
+ *
17
+ * WHAT IT DELIBERATELY DOES NOT DO. No tables, no block quotes, no images, no
18
+ * single-asterisk emphasis (`*` appears in real prose far more often than it
19
+ * means italics, and a false positive silently eats characters out of a
20
+ * contract). Anything unrecognised falls through as its own literal text, which
21
+ * is the same thing the screen showed before — never worse.
22
+ */
23
+ import * as React from 'react';
24
+
25
+ /**
26
+ * Markdown reduced to the text a human would read aloud.
27
+ *
28
+ * For places that need a STRING rather than elements — a truncated one-line
29
+ * preview, a title, an `aria-label`. Same syntax coverage as the renderer.
30
+ */
31
+ export function stripMarkdown(text: string): string {
32
+ if (!text) return '';
33
+ return text
34
+ .replace(/```[\s\S]*?```/g, (block) => block.replace(/```[^\n]*\n?/g, ''))
35
+ .replace(/^\s{0,3}#{1,6}\s+/gm, '')
36
+ .replace(/^\s{0,3}([-*_])\s*\1\s*\1[-*_\s]*$/gm, '')
37
+ .replace(/^\s{0,3}[-*+]\s+/gm, '')
38
+ .replace(/^\s{0,3}>\s?/gm, '')
39
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
40
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
41
+ .replace(/__([^_]+)__/g, '$1')
42
+ .replace(/`([^`]+)`/g, '$1')
43
+ .replace(/[ \t]+\n/g, '\n')
44
+ .trim();
45
+ }
46
+
47
+ /** `**bold**`, `` `code` `` and `[text](url)` inside one line of text. */
48
+ function inline(text: string, keyPrefix: string): React.ReactNode[] {
49
+ const nodes: React.ReactNode[] = [];
50
+ // One pass, one regex: whichever construct starts first wins, so a link whose
51
+ // label contains asterisks is still a link.
52
+ const pattern = /(\*\*|__)([\s\S]+?)\1|`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)[^)]*\)/g;
53
+ let cursor = 0;
54
+ let match: RegExpExecArray | null;
55
+ let index = 0;
56
+
57
+ while ((match = pattern.exec(text)) !== null) {
58
+ if (match.index > cursor) nodes.push(text.slice(cursor, match.index));
59
+ index += 1;
60
+ const key = `${keyPrefix}-${index}`;
61
+ if (match[2] !== undefined) {
62
+ nodes.push(<strong key={key}>{match[2]}</strong>);
63
+ } else if (match[3] !== undefined) {
64
+ nodes.push(
65
+ <code key={key} className="rounded bg-black/5 px-1 py-0.5 text-[0.9em]">
66
+ {match[3]}
67
+ </code>,
68
+ );
69
+ } else if (match[4] !== undefined) {
70
+ const href = match[5] ?? '';
71
+ const safe = /^(https?:|mailto:)/i.test(href);
72
+ nodes.push(
73
+ safe ? (
74
+ <a key={key} href={href} target="_blank" rel="noreferrer" className="underline">
75
+ {match[4]}
76
+ </a>
77
+ ) : (
78
+ // A `javascript:` or otherwise unrecognised scheme keeps its LABEL and
79
+ // loses its link. Silently dropping the text would edit the document.
80
+ <React.Fragment key={key}>{match[4]}</React.Fragment>
81
+ ),
82
+ );
83
+ }
84
+ cursor = pattern.lastIndex;
85
+ }
86
+ if (cursor < text.length) nodes.push(text.slice(cursor));
87
+ return nodes.length ? nodes : [text];
88
+ }
89
+
90
+ const HEADING_CLASS = ['font-semibold', 'font-semibold', 'font-medium'];
91
+
92
+ /**
93
+ * Block-level Markdown as React elements.
94
+ *
95
+ * `className` lands on the wrapper so an app's theme slot still styles the whole
96
+ * block exactly as it did when this was a `<p>` of raw text.
97
+ */
98
+ export function Markdown({ text, className }: { text: string; className?: string }) {
99
+ const blocks = React.useMemo(() => parseBlocks(text ?? ''), [text]);
100
+ if (!text) return null;
101
+ return (
102
+ <div className={className}>
103
+ {blocks.map((block, blockIndex) => {
104
+ const key = `b${blockIndex}`;
105
+ if (block.kind === 'heading') {
106
+ const level = Math.min(block.level ?? 3, 3);
107
+ return (
108
+ <p
109
+ key={key}
110
+ className={`${HEADING_CLASS[level - 1] ?? 'font-medium'} ${blockIndex ? 'mt-3' : ''}`}
111
+ >
112
+ {inline(block.lines[0] ?? '', key)}
113
+ </p>
114
+ );
115
+ }
116
+ if (block.kind === 'list') {
117
+ return (
118
+ <ul key={key} className={`list-disc space-y-1 pl-5 ${blockIndex ? 'mt-2' : ''}`}>
119
+ {block.lines.map((line, lineIndex) => (
120
+ <li key={`${key}-${lineIndex}`}>{inline(line, `${key}-${lineIndex}`)}</li>
121
+ ))}
122
+ </ul>
123
+ );
124
+ }
125
+ if (block.kind === 'rule') {
126
+ return <hr key={key} className="my-3 border-black/10" />;
127
+ }
128
+ return (
129
+ <p key={key} className={`whitespace-pre-wrap ${blockIndex ? 'mt-2' : ''}`}>
130
+ {inline(block.lines.join('\n'), key)}
131
+ </p>
132
+ );
133
+ })}
134
+ </div>
135
+ );
136
+ }
137
+
138
+ interface Block {
139
+ kind: 'paragraph' | 'heading' | 'list' | 'rule';
140
+ lines: string[];
141
+ level?: number;
142
+ }
143
+
144
+ function parseBlocks(text: string): Block[] {
145
+ const blocks: Block[] = [];
146
+ let paragraph: string[] = [];
147
+ let list: string[] = [];
148
+
149
+ const flushParagraph = () => {
150
+ if (paragraph.length) blocks.push({ kind: 'paragraph', lines: paragraph });
151
+ paragraph = [];
152
+ };
153
+ const flushList = () => {
154
+ if (list.length) blocks.push({ kind: 'list', lines: list });
155
+ list = [];
156
+ };
157
+
158
+ for (const raw of text.replace(/\r\n/g, '\n').split('\n')) {
159
+ const line = raw.replace(/\s+$/, '');
160
+ const heading = /^\s{0,3}(#{1,6})\s+(.*)$/.exec(line);
161
+ const bullet = /^\s{0,3}[-*+]\s+(.*)$/.exec(line);
162
+ const numbered = /^\s{0,3}\d+[.)]\s+(.*)$/.exec(line);
163
+ const rule = /^\s{0,3}([-*_])\s*\1\s*\1[-*_\s]*$/.test(line);
164
+
165
+ if (!line.trim()) {
166
+ flushList();
167
+ flushParagraph();
168
+ continue;
169
+ }
170
+ if (rule) {
171
+ flushList();
172
+ flushParagraph();
173
+ blocks.push({ kind: 'rule', lines: [] });
174
+ continue;
175
+ }
176
+ if (heading) {
177
+ flushList();
178
+ flushParagraph();
179
+ blocks.push({ kind: 'heading', lines: [heading[2] ?? ''], level: heading[1].length });
180
+ continue;
181
+ }
182
+ if (bullet || numbered) {
183
+ flushParagraph();
184
+ list.push((bullet ? bullet[1] : numbered?.[1]) ?? '');
185
+ continue;
186
+ }
187
+ flushList();
188
+ paragraph.push(line);
189
+ }
190
+ flushList();
191
+ flushParagraph();
192
+ return blocks;
193
+ }
@@ -3,6 +3,7 @@ export * from './ConnectAccountCard';
3
3
  export * from './EmailList';
4
4
  export * from './EventList';
5
5
  export * from './EventListItem';
6
+ export * from './Markdown';
6
7
  export * from './ReconsentPromptCard';
7
8
 
8
9
  // The connections layer (bd startsim-768w.22.3.4): ONE schema-driven form and
@@ -29,3 +30,4 @@ export type {
29
30
  // connections. Same rule as above — the page composers ship, not just the parts.
30
31
  export * from './pages/LibraryPage';
31
32
  export * from './pages/AskPage';
33
+ export * from './pages/TrackerPage';
@@ -31,6 +31,7 @@
31
31
  */
32
32
  import * as React from 'react';
33
33
 
34
+ import { Markdown, stripMarkdown } from '../Markdown';
34
35
  import type {
35
36
  AskMode,
36
37
  AskRequest,
@@ -259,7 +260,10 @@ function AnswerPanel({
259
260
  </p>
260
261
  ) : null}
261
262
 
262
- <div className={`mt-3 ${cn.answer}`}>{result.answer}</div>
263
+ {/* A model asked for prose answers in Markdown. Rendering it as literal
264
+ text puts "**$4,800**" on the screen, which reads as the product
265
+ failing rather than as the model emphasising the number. */}
266
+ <Markdown text={result.answer} className={`mt-3 ${cn.answer}`} />
263
267
 
264
268
  {canSave ? (
265
269
  <button
@@ -300,7 +304,10 @@ function AnswerPanel({
300
304
  {/* The model's own quote, so the claim can be checked against the
301
305
  record without opening it. */}
302
306
  {citation.quote ? (
303
- <p className="mt-1 text-sm italic text-gray-600">“{citation.quote}”</p>
307
+ // Flattened, not rendered: a quotation is a span of the record's
308
+ // own words, and it should read as one line inside the quote
309
+ // marks rather than re-opening as a heading or a bullet.
310
+ <p className="mt-1 text-sm italic text-gray-600">“{stripMarkdown(citation.quote)}”</p>
304
311
  ) : null}
305
312
  </div>
306
313
  ))}
@@ -337,7 +344,7 @@ function ConsideredList({
337
344
  return (
338
345
  <details className="mt-3">
339
346
  <summary className="cursor-pointer text-sm text-gray-600">
340
- {unused.length} more record{unused.length === 1 ? '' : 's'} were searched but not cited
347
+ {unused.length} more record{unused.length === 1 ? ' was' : 's were'} searched but not cited
341
348
  </summary>
342
349
  <div className="mt-2 space-y-1">
343
350
  {unused.map((item) => (
@@ -360,16 +367,34 @@ function ConsideredList({
360
367
  );
361
368
  }
362
369
 
370
+ /**
371
+ * Who answered, said plainly.
372
+ *
373
+ * THREE CASES, not two (bd startsim-768w.38.21). A live model names itself. A
374
+ * STUB says it is a stub. And a workspace with NO provider configured is not a
375
+ * stub at all — "Answered by a none stub ((provider default))" was neither
376
+ * English nor true. Every branch keeps the `live: false` honesty; the point is
377
+ * to say the true thing clearly, not to soften it.
378
+ */
379
+ function providerSentence(provider: AskResult['provider']): string {
380
+ const model = provider.model?.trim() ?? '';
381
+ if (provider.live) {
382
+ return model ? `Answered by ${provider.provider} · ${model}` : `Answered by ${provider.provider}`;
383
+ }
384
+ if (!provider.provider || provider.provider === 'none') {
385
+ return 'No model is configured for this workspace — no live model was called.';
386
+ }
387
+ return model
388
+ ? `Answered by a ${provider.provider} stub (${model}) — no live model was called`
389
+ : `Answered by a ${provider.provider} stub — no live model was called`;
390
+ }
391
+
363
392
  function ProvenanceFooter({
364
393
  result, classNames: cn,
365
394
  }: { result: AskResult; classNames: Record<AskSlot, string> }) {
366
395
  return (
367
396
  <div className="mt-4 border-t pt-2">
368
- <p className={cn.muted}>
369
- {result.provider.live
370
- ? `Answered by ${result.provider.provider} · ${result.provider.model}`
371
- : `Answered by a ${result.provider.provider} stub (${result.provider.model}) — no live model was called`}
372
- </p>
397
+ <p className={cn.muted}>{providerSentence(result.provider)}</p>
373
398
  {/* A non-zero drop means something upstream returned rows from another
374
399
  workspace. It is stated rather than swallowed, because the guard
375
400
  catching it is good news about the guard and bad news about the API. */}
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import * as React from 'react';
29
29
 
30
+ import { Markdown, stripMarkdown } from '../Markdown';
30
31
  import { enforceScope } from '../../corpus/retrieval';
31
32
  import type {
32
33
  CorpusApi,
@@ -107,8 +108,13 @@ function relative(value: string | null | undefined, now: number): string {
107
108
  return `${Math.round(hours / 24)}d ago`;
108
109
  }
109
110
 
111
+ /**
112
+ * A row's one-line preview. The Markdown is FLATTENED rather than rendered
113
+ * here — a truncated heading and half a bold span are worse than plain prose,
114
+ * and the row is a summary, not the document.
115
+ */
110
116
  function preview(document: SourceDocument, length = 180): string {
111
- const text = document.body || document.sections[0]?.text || '';
117
+ const text = stripMarkdown(document.body || document.sections[0]?.text || '');
112
118
  if (!text) return '';
113
119
  return text.length > length ? `${text.slice(0, length).trim()}…` : text;
114
120
  }
@@ -586,7 +592,9 @@ function DocumentDetail({
586
592
  {document.body ? (
587
593
  <div className="mt-3">
588
594
  <p className="text-sm font-medium text-gray-900">Summary</p>
589
- <p className="mt-1 whitespace-pre-wrap text-sm text-gray-700">{document.body}</p>
595
+ {/* Sources write Markdown — a literal "## Decisions" on the screen
596
+ reads as a broken product, not as the source's punctuation. */}
597
+ <Markdown text={document.body} className="mt-1 text-sm text-gray-700" />
590
598
  </div>
591
599
  ) : null}
592
600