@startsimpli/integrations 0.2.13 → 0.2.14

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.
@@ -48,6 +48,7 @@ import type {
48
48
  ConnectionTarget,
49
49
  ConnectorAction,
50
50
  ConnectorCatalogEntry,
51
+ ConnectorFieldSpec,
51
52
  ConnectionsApi,
52
53
  } from './types';
53
54
 
@@ -267,7 +268,7 @@ function CatalogCard({
267
268
  onConnected: () => void;
268
269
  }) {
269
270
  const [open, setOpen] = React.useState(false);
270
- const [values, setValues] = React.useState<Record<string, string>>({});
271
+ const [values, setValues] = React.useState<Record<string, unknown>>({});
271
272
  const [busy, setBusy] = React.useState(false);
272
273
  const [error, setError] = React.useState('');
273
274
  // Every declared capability starts ON. Not because "on" is a safe default in
@@ -296,7 +297,11 @@ function CatalogCard({
296
297
  }
297
298
  for (const spec of configFields) {
298
299
  const v = values[isSecret(spec) ? `${spec.key}__draft` : spec.key];
299
- if (v) config[spec.key] = v;
300
+ // `if (v)` DROPPED A FALSE, which for a checkbox makes "off" and
301
+ // "untouched" the same request. Harmless while every config field was a
302
+ // string; not harmless once one of them decides whether verbatim
303
+ // recordings are stored.
304
+ if (typeof v === 'boolean' || typeof v === 'number' || v) config[spec.key] = v;
300
305
  }
301
306
  await api.connect(foundrySlug, {
302
307
  connector_key: entry.key, name: entry.name, credentials, config,
@@ -428,7 +433,7 @@ function CatalogCard({
428
433
  * a secret never means re-typing the settings around it.
429
434
  *
430
435
  * Only the CREDENTIAL fields render. `config` is not a credential and is edited
431
- * through the connection's own settings; mixing them here would make a
436
+ * through `ConnectionSettingsForm` below; mixing them here would make a
432
437
  * rotation quietly a config change.
433
438
  */
434
439
  function ReplaceKeyForm({
@@ -441,7 +446,7 @@ function ReplaceKeyForm({
441
446
  classNames: typeof DEFAULTS;
442
447
  onDone: () => void;
443
448
  }) {
444
- const [values, setValues] = React.useState<Record<string, string>>({});
449
+ const [values, setValues] = React.useState<Record<string, unknown>>({});
445
450
  const [busy, setBusy] = React.useState(false);
446
451
  const [error, setError] = React.useState('');
447
452
 
@@ -514,6 +519,101 @@ function ReplaceKeyForm({
514
519
  // a connected account: discover -> wire -> confirm -> fire
515
520
  // --------------------------------------------------------------------------
516
521
 
522
+ /**
523
+ * The connection's own settings — its connector's `config_schema`, for an
524
+ * account that already exists.
525
+ *
526
+ * WHY IT IS A SEPARATE FORM FROM THE CONNECT CARD. Connecting is "here are my
527
+ * credentials, does this work"; this is "change how it behaves". Rotating a key
528
+ * must not quietly change a setting, and changing a setting must not ask for a
529
+ * key. `ReplaceKeyForm` has said as much in its own docstring since it shipped,
530
+ * pointing at a surface that did not exist.
531
+ *
532
+ * IT SENDS ONLY WHAT CHANGED, and the API merges. Two reasons, and the second is
533
+ * the one that bites: resending untouched fields turns "I ticked one box" into
534
+ * "I reassert every setting", which silently overwrites an edit made in another
535
+ * tab — and it is exactly how the retention bound gets reset to whatever this
536
+ * form happened to be showing.
537
+ *
538
+ * A FALSE IS SENT, not omitted. The connect card drops falsy values, which for a
539
+ * checkbox makes "off" indistinguishable from "untouched" — and against a
540
+ * merging API that would make the off position literally unreachable. For
541
+ * `ingest_transcripts` the off position is the whole privacy posture.
542
+ */
543
+ function ConnectionSettingsForm({
544
+ api, foundrySlug, connection, fields, classNames: cn, onDone,
545
+ }: {
546
+ api: ConnectionsApi;
547
+ foundrySlug: string;
548
+ connection: Connection;
549
+ fields: ConnectorFieldSpec[];
550
+ classNames: typeof DEFAULTS;
551
+ onDone: () => void;
552
+ }) {
553
+ const stored = React.useMemo(
554
+ () => (connection.config ?? {}) as Record<string, unknown>,
555
+ [connection.config],
556
+ );
557
+ const [edits, setEdits] = React.useState<Record<string, string | boolean>>({});
558
+ const [busy, setBusy] = React.useState(false);
559
+ const [error, setError] = React.useState('');
560
+
561
+ const values = React.useMemo(() => ({ ...stored, ...edits }), [stored, edits]);
562
+ const warnings = patternWarnings(fields, values);
563
+ const changed = Object.keys(edits);
564
+
565
+ async function save() {
566
+ setBusy(true);
567
+ setError('');
568
+ try {
569
+ // Numbers go back as numbers. A `number` input hands back a string, and a
570
+ // retention bound stored as "0" rather than 0 is a comparison that quietly
571
+ // stops being true.
572
+ const config: Record<string, unknown> = {};
573
+ for (const key of changed) {
574
+ const spec = fields.find((field) => field.key === key);
575
+ const value = edits[key];
576
+ config[key] =
577
+ spec?.type === 'number' && typeof value === 'string' && value.trim() !== ''
578
+ ? Number(value)
579
+ : value;
580
+ }
581
+ await api.updateConnection(foundrySlug, connection.id, { config });
582
+ onDone();
583
+ } catch (err) {
584
+ setError((err as { message?: string })?.message || 'Those settings were not saved.');
585
+ } finally {
586
+ setBusy(false);
587
+ }
588
+ }
589
+
590
+ return (
591
+ <div className="mt-3 space-y-3 border-t pt-3" role="group" aria-label="Connection settings">
592
+ <CredentialSchemaForm
593
+ fields={fields}
594
+ values={values}
595
+ onChange={(key, value) => setEdits((current) => ({ ...current, [key]: value }))}
596
+ warnings={warnings}
597
+ idPrefix={`settings-${connection.id}`}
598
+ />
599
+ {error ? <p className={cn.error} role="alert">{error}</p> : null}
600
+ <div className="flex items-center gap-2">
601
+ <button
602
+ type="button"
603
+ className={cn.button}
604
+ disabled={busy || changed.length === 0}
605
+ onClick={() => void save()}
606
+ >
607
+ {busy ? 'Saving…' : 'Save settings'}
608
+ </button>
609
+ {changed.length === 0 ? (
610
+ <span className={cn.muted}>Nothing changed yet.</span>
611
+ ) : null}
612
+ </div>
613
+ </div>
614
+ );
615
+ }
616
+
517
617
  function ConnectionCard({
518
618
  api, foundrySlug, connection, entry, canManage, classNames: cn, onChanged,
519
619
  }: {
@@ -533,6 +633,7 @@ function ConnectionCard({
533
633
  const [error, setError] = React.useState('');
534
634
  const [notice, setNotice] = React.useState('');
535
635
  const [replacing, setReplacing] = React.useState(false);
636
+ const [settingsOpen, setSettingsOpen] = React.useState(false);
536
637
  const [confirmingDisconnect, setConfirmingDisconnect] = React.useState(false);
537
638
 
538
639
  const declared = entry?.capabilities;
@@ -568,8 +669,10 @@ function ConnectionCard({
568
669
  }
569
670
  }
570
671
 
672
+ const configFields = schemaFields(entry?.config_schema);
673
+
571
674
  return (
572
- <div className={cn.card}>
675
+ <div className={cn.card} data-connection={connection.id}>
573
676
  <div className="flex items-start justify-between gap-4">
574
677
  <div>
575
678
  <p className="font-medium text-gray-900">{connection.name}</p>
@@ -624,12 +727,35 @@ function ConnectionCard({
624
727
  >
625
728
  {busy === 'discover' ? 'Loading…' : 'Workflows'}
626
729
  </button>
730
+ {/* SETTINGS, which this card has never had. A connector's
731
+ `config_schema` ships on every catalog row and was rendered
732
+ exactly once — on the connect form, before the account existed.
733
+ After that a setting could not be changed from the product at
734
+ all, which for Granola means the switch deciding whether verbatim
735
+ recordings of people's conversations are stored was reachable
736
+ only by editing the database. `ReplaceKeyForm` already pointed at
737
+ "the connection's own settings" as if this existed. */}
738
+ {configFields.length ? (
739
+ <button
740
+ type="button"
741
+ className={cn.button}
742
+ disabled={Boolean(busy) || confirmingDisconnect}
743
+ onClick={() => {
744
+ setReplacing(false);
745
+ setError('');
746
+ setSettingsOpen((v) => !v);
747
+ }}
748
+ >
749
+ {settingsOpen ? 'Close settings' : 'Settings'}
750
+ </button>
751
+ ) : null}
627
752
  <button
628
753
  type="button"
629
754
  className={cn.button}
630
755
  disabled={Boolean(busy) || confirmingDisconnect}
631
756
  onClick={() => {
632
757
  setReplacing(false);
758
+ setSettingsOpen(false);
633
759
  setError('');
634
760
  setConfirmingDisconnect(true);
635
761
  }}
@@ -640,6 +766,17 @@ function ConnectionCard({
640
766
  ) : null}
641
767
  </div>
642
768
 
769
+ {settingsOpen ? (
770
+ <ConnectionSettingsForm
771
+ api={api}
772
+ foundrySlug={foundrySlug}
773
+ connection={connection}
774
+ fields={configFields}
775
+ classNames={cn}
776
+ onDone={() => { setSettingsOpen(false); setNotice('Settings saved.'); onChanged(); }}
777
+ />
778
+ ) : null}
779
+
643
780
  {replacing ? (
644
781
  <ReplaceKeyForm
645
782
  api={api}
@@ -114,11 +114,31 @@ function relative(value: string | null | undefined, now: number): string {
114
114
  * and the row is a summary, not the document.
115
115
  */
116
116
  function preview(document: SourceDocument, length = 180): string {
117
- const text = stripMarkdown(document.body || document.sections[0]?.text || '');
117
+ const text = stripMarkdown(
118
+ summaryOf(document) || document.body || document.sections[0]?.text || '',
119
+ );
118
120
  if (!text) return '';
119
121
  return text.length > length ? `${text.slice(0, length).trim()}…` : text;
120
122
  }
121
123
 
124
+ /**
125
+ * The short form, when the source wrote one.
126
+ *
127
+ * `body` falls back to the summary on a corpus that has never delivered a full
128
+ * text, so on today's tenant these are the same string and this returns it. When
129
+ * both exist they are different, and only this one belongs under the heading
130
+ * "Summary".
131
+ */
132
+ function summaryOf(document: SourceDocument): string {
133
+ return document.summary || (document.body ?? '');
134
+ }
135
+
136
+ /** The verbatim text, when it is genuinely different from the summary. */
137
+ function fullTextOf(document: SourceDocument): string {
138
+ const body = document.body ?? '';
139
+ return body && body !== summaryOf(document) ? body : '';
140
+ }
141
+
122
142
  // ---------------------------------------------------------------------------
123
143
 
124
144
  export function LibraryPage({
@@ -589,15 +609,36 @@ function DocumentDetail({
589
609
  </div>
590
610
  ) : null}
591
611
 
592
- {document.body ? (
612
+ {/* THE SUMMARY, UNDER THE WORD SUMMARY. This used to render `document.body`
613
+ here — which was the summary only for as long as nothing had ever
614
+ delivered a full text. The moment `ingest_transcripts` lands, `body` is
615
+ forty minutes of verbatim speech, and it would have appeared on this
616
+ screen labelled "Summary" while the actual summary appeared nowhere. */}
617
+ {summaryOf(document) ? (
593
618
  <div className="mt-3">
594
619
  <p className="text-sm font-medium text-gray-900">Summary</p>
595
620
  {/* Sources write Markdown — a literal "## Decisions" on the screen
596
621
  reads as a broken product, not as the source's punctuation. */}
597
- <Markdown text={document.body} className="mt-1 text-sm text-gray-700" />
622
+ <Markdown text={summaryOf(document)} className="mt-1 text-sm text-gray-700" />
598
623
  </div>
599
624
  ) : null}
600
625
 
626
+ {/* The FULL text, collapsed, next to the source's other long-form. Behind
627
+ a disclosure and not open by default, for the same reason a transcript
628
+ is opt-in at all: verbatim speech is a different data class from a
629
+ summary, and a screen that puts it in front of whoever opened the
630
+ record has quietly decided that on the reader's behalf. */}
631
+ {fullTextOf(document) ? (
632
+ <details className="mt-3">
633
+ <summary className="cursor-pointer text-sm font-medium text-gray-900">
634
+ Full text
635
+ </summary>
636
+ <pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap rounded bg-gray-50 p-2 text-xs text-gray-700">
637
+ {fullTextOf(document)}
638
+ </pre>
639
+ </details>
640
+ ) : null}
641
+
601
642
  {/* Long-form under the SOURCE's own name for it — "Transcript" for a
602
643
  meeting, "Extracted text" for a PDF. This component does not know
603
644
  which is which, and must not. */}
@@ -407,6 +407,23 @@ function RunSummary({
407
407
  out of the notes by {result.provider.live ? `${result.provider.provider} · ${result.provider.model}` : 'no live model'}.
408
408
  </p>
409
409
 
410
+ {/* WHAT THE CHECK THREW AWAY, on the screen.
411
+ Measured over the sixteen real meetings: the model proposes about
412
+ ninety items and roughly two thirds of them do not survive. That is not
413
+ a bug to hide — it is the number that keeps the list believable, and
414
+ hiding it would leave "the model read 30 out of the notes" looking like
415
+ the model is simply better than it is. If `verified` is ever false the
416
+ run says so rather than implying a check that did not happen. */}
417
+ {result.fromModel || result.ungrounded || result.unconfirmed ? (
418
+ <p className={cn.muted}>
419
+ {result.verified
420
+ ? `Checked against the meetings themselves: ${result.unconfirmed + result.ungrounded} more were proposed and dropped — ` +
421
+ `${result.ungrounded} quoted something the record does not say, ` +
422
+ `${result.unconfirmed} could not be confirmed as something anyone actually took on.`
423
+ : 'These were NOT checked against the meetings — the verification step did not run, so treat them as suggestions.'}
424
+ </p>
425
+ ) : null}
426
+
410
427
  {result.needsReview.length ? (
411
428
  // Weak resolution matches are shown, never applied. A to-do list that
412
429
  // quietly empties itself is the one failure with no symptom.
package/src/corpus/ask.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { moneyEventAsDocument } from './entities';
22
22
  import { retrieveForQuestion } from './retrieval';
23
+ import { documentText } from './text';
23
24
  import type {
24
25
  AnswerRequest,
25
26
  AskRequest,
@@ -103,15 +104,18 @@ export function buildAnswerRequest(
103
104
  }
104
105
 
105
106
  /**
106
- * A document's text for the prompt: body first, then each named section, cut at
107
- * a budget. Sections are labelled with the source's own words, so the model sees
108
- * "Transcript" or "Extracted text" without this package knowing what either is.
107
+ * A document's text for the prompt: the summary first, then the full text, then
108
+ * each named section, cut at a budget. Sections are labelled with the source's
109
+ * own words, so the model sees "Transcript" or "Extracted text" without this
110
+ * package knowing what either is.
111
+ *
112
+ * ONE IMPLEMENTATION, in `./text`, shared with extraction. It used to be two
113
+ * near-identical private copies with different budgets, which is how the Ask
114
+ * surface came within one config flag of spending its entire 6,000-character
115
+ * per-document budget on the opening minutes of a transcript and never showing
116
+ * the model the summary at all.
109
117
  */
110
- export function documentText(document: SourceDocument, maxChars: number): string {
111
- const parts = [document.body, ...document.sections.map((s) => `## ${s.label}\n${s.text}`)];
112
- const joined = parts.filter(Boolean).join('\n\n');
113
- return joined.length > maxChars ? `${joined.slice(0, maxChars)}\n…[truncated]` : joined;
114
- }
118
+ export { documentText };
115
119
 
116
120
  /** Render the batched request as the single user message. */
117
121
  export function renderAnswerPrompt(input: AnswerRequest): string {
@@ -391,6 +391,17 @@ export function toSourceDocument(
391
391
  const data = bag(entity);
392
392
  const source = asText(pick(data, 'source', 'source_key', 'sourceKey')) || 'unknown';
393
393
 
394
+ // TWO TEXT FIELDS, NOT ONE, because the canonical schema declares two and says
395
+ // why: "Collapsing them means the transcript clobbers the summary the first
396
+ // time both arrive". This mapper collapsed them anyway —
397
+ // `pick(data,'body',…,'summary')` returns the first present key — so a
398
+ // document carrying both would have surfaced ONLY the transcript, and the
399
+ // `## Next Steps` block that the entire deterministic rules pass reads lives
400
+ // in the summary. Switching `ingest_transcripts` on would have taken a
401
+ // 95.8%-precision, 100%-owner-accuracy pass to zero, silently, on deploy.
402
+ const summary = asText(pick(data, 'summary', 'description'));
403
+ const fullText = asText(pick(data, 'body', 'text', 'content'));
404
+
394
405
  const sections = toSections(pick(data, 'sections'));
395
406
  // A source that stored its long-form under a bare attribute rather than in
396
407
  // `sections` still contributes one. Listed keys are storage spellings, not
@@ -407,7 +418,13 @@ export function toSourceDocument(
407
418
  asText(pick(data, 'title', 'subject', 'headline')) ||
408
419
  entity.name ||
409
420
  `Untitled ${source} record`,
410
- body: asText(pick(data, 'body', 'text', 'summary', 'description', 'content')),
421
+ // `body` still ANSWERS for the summary when the source has never delivered a
422
+ // full text, which is every row on the live tenant today. Every existing
423
+ // consumer of `.body` — retrieval, the Ask prompt, the Library preview —
424
+ // keeps working unchanged on a corpus of summaries; only a corpus that has
425
+ // both sees a difference, and that is the corpus this distinction is for.
426
+ body: fullText || summary,
427
+ ...(summary ? { summary } : {}),
411
428
  sections,
412
429
  occurredAt: asIso(pick(data, 'occurred_at', 'occurredAt', 'happened_at', 'date')),
413
430
  source,