@startsimpli/integrations 0.2.25 → 0.2.26

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.
@@ -39,6 +39,14 @@ import {
39
39
  toSourceDocument,
40
40
  } from './entities';
41
41
  import type { CorpusTypeKeys, TenantEntity, TenantRelationship } from './entities';
42
+ import {
43
+ FOCUS_TYPE,
44
+ IN_FOCUS,
45
+ SUB_FOCUS,
46
+ authoredItemToEntity,
47
+ focusToEntity,
48
+ toFocus,
49
+ } from './planning';
42
50
  import type {
43
51
  Commitment,
44
52
  CommitmentApi,
@@ -49,9 +57,15 @@ import type {
49
57
  DocumentQuery,
50
58
  ExtractionRun,
51
59
  ExtractionRunStore,
60
+ AuthoredItem,
61
+ Focus,
62
+ FocusDraft,
63
+ FocusQuery,
64
+ FocusStanding,
52
65
  MoneyEvent,
53
66
  Party,
54
67
  PartyRef,
68
+ PlanningApi,
55
69
  SourceFreshness,
56
70
  } from './types';
57
71
 
@@ -66,6 +80,18 @@ import type {
66
80
  export interface CorpusHttp {
67
81
  get<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T>;
68
82
  post?<T>(path: string, body?: unknown, options?: { params?: Record<string, unknown> }): Promise<T>;
83
+ /**
84
+ * OPTIONAL, and needed by exactly one operation: UNFILING an item.
85
+ *
86
+ * Every other write in this file is a shallow-merging upsert, which is the
87
+ * only tenant verb that preserves keys the caller did not send. Removing an
88
+ * `in_focus` edge cannot be expressed that way — an edge is a row, not a
89
+ * field — and marking it dead instead would leave a tombstone every other
90
+ * reader has to know to ignore. A client without this can file and re-file;
91
+ * `fileCommitment(item, null)` reports that it cannot unfile rather than
92
+ * appearing to succeed.
93
+ */
94
+ delete?<T>(path: string, options?: { params?: Record<string, unknown> }): Promise<T>;
69
95
  }
70
96
 
71
97
  export interface CreateCorpusApiOptions {
@@ -82,7 +108,21 @@ export interface CreateCorpusApiOptions {
82
108
  */
83
109
  supportedFilters?: Array<keyof DocumentQuery | keyof CommitmentQuery>;
84
110
  /** Endpoint bases, without leading/trailing slashes (the client convention). */
85
- paths?: { entities?: string; connections?: string; relationships?: string };
111
+ paths?: {
112
+ entities?: string;
113
+ connections?: string;
114
+ relationships?: string;
115
+ /**
116
+ * The fork's own same-origin roster, for the owner picker.
117
+ *
118
+ * Threaded through here rather than hardcoded because `listMembers` fails
119
+ * SOFT: a fork whose roster lives elsewhere would swallow the 404 into "no
120
+ * members", the picker would quietly disappear, and owners would fall back
121
+ * to free text — which is precisely the gap (bd startsim-768w.38.29) the
122
+ * picker exists to close, reappearing as an empty dropdown with no error.
123
+ */
124
+ members?: string;
125
+ };
86
126
  /** How old a source's last sync may be before the Library calls it stale. */
87
127
  staleAfterHours?: number;
88
128
  /**
@@ -181,7 +221,18 @@ function commitmentParams(query: CommitmentQuery, type: string): Record<string,
181
221
  if (query.owner) out['attr.owner_name__icontains'] = query.owner.slice(0, TEXT_FILTER_MAX);
182
222
  if (query.dueBefore) out['attr.due_at__lte'] = query.dueBefore;
183
223
  if (query.changedAfter) out['attr.last_seen_at__gte'] = query.changedAfter;
224
+ if (query.origin) out['attr._origin'] = query.origin;
184
225
  if (query.search) out.search = query.search.slice(0, TEXT_FILTER_MAX);
226
+ if (query.focus) {
227
+ // FILING IS AN EDGE, so "the items in this focus" is an edge walk and not an
228
+ // attribute match. `related_direction` describes the RETURNED ROW's end of
229
+ // the edge (apps/api/filters.py::_one_hop) and an item is the SOURCE of its
230
+ // own `in_focus` edge — so `out` is correct here, and `in` would silently
231
+ // return an empty page rather than an error.
232
+ out.related_to = query.focus;
233
+ out.related_via = IN_FOCUS;
234
+ out.related_direction = 'out';
235
+ }
185
236
  return out;
186
237
  }
187
238
 
@@ -192,6 +243,14 @@ const FILTER_PARAMS: Array<[keyof CommitmentQuery, string[]]> = [
192
243
  ['owner', ['attr.owner_name__icontains']],
193
244
  ['dueBefore', ['attr.due_at__lte']],
194
245
  ['changedAfter', ['attr.last_seen_at__gte']],
246
+ ['origin', ['attr._origin']],
247
+ // THE CLIENT ASKS FOR `focus`; THE SERVER CONFIRMS `related_to`. Without this
248
+ // row the reconciliation below never matches, and the screen announces that
249
+ // this workspace's data API "did not filter by focus" while the server
250
+ // verifiably did — the same lie, from the same cause, as reading only one
251
+ // spelling of `applied_filters`. `partyId -> related_to` on the document side
252
+ // is the same shape, and the precedent for it.
253
+ ['focus', ['related_to']],
195
254
  ['search', ['search']],
196
255
  ];
197
256
 
@@ -581,3 +640,311 @@ export function createCorpusApi(
581
640
  },
582
641
  };
583
642
  }
643
+
644
+ /**
645
+ * `data` key -> the `FocusDraft` key that carries it.
646
+ *
647
+ * THE POINT IS PARTIAL UPDATES. `focusToEntity` is a CREATE mapper: it always
648
+ * emits a `status` (defaulting to `active`) and always stamps `_origin`, because
649
+ * a new row needs both. Merging that whole object on every edit would mean
650
+ * renaming a focus silently resurrected a `cancelled` one — the upsert
651
+ * shallow-merges, so every key present in the body is a write.
652
+ *
653
+ * So an update sends a key only when the DRAFT field behind it was actually in
654
+ * the patch. The two names differ (`time_frame` vs `timeFrame`), which is the
655
+ * whole reason this is a map rather than a list, and `_origin` is deliberately
656
+ * absent from it so an edit can never relabel where a row came from.
657
+ */
658
+ const FOCUS_PATCH_KEYS: Record<string, keyof FocusDraft> = {
659
+ detail: 'detail',
660
+ status: 'status',
661
+ category: 'category',
662
+ time_frame: 'timeFrame',
663
+ year: 'year',
664
+ start_date: 'startDate',
665
+ end_date: 'endDate',
666
+ target_value: 'targetValue',
667
+ current_value: 'currentValue',
668
+ unit: 'unit',
669
+ owner_sub: 'ownerSub',
670
+ owner_name: 'ownerName',
671
+ };
672
+
673
+ // ---------------------------------------------------------------------------
674
+ // the planning surface (bd startsim-8l6ef)
675
+ // ---------------------------------------------------------------------------
676
+
677
+ /**
678
+ * The tenant client for focuses and items.
679
+ *
680
+ * COMPOSED FROM `createCorpusApi`, not written beside it: an item IS a
681
+ * commitment, so `listCommitments` and — more importantly — its applied-filter
682
+ * reconciliation are reused wholesale rather than reimplemented against the same
683
+ * endpoint. What is added here is the focus type, the two edge writes, the
684
+ * authoring path and counting.
685
+ *
686
+ * EVERY WRITE IS THE SHALLOW-MERGING UPSERT, keyed on `external_id`. That is the
687
+ * only tenant verb that preserves keys the caller did not send, which matters
688
+ * more here than anywhere else in the package: the extractor and a person write
689
+ * the SAME rows, and a full-object PUT from an edit dialog would silently drop
690
+ * whatever the last extraction pass had just added.
691
+ *
692
+ * NOTHING AGGREGATES SERVER-SIDE, because the tenant plane deliberately offers
693
+ * no group_by. `focusStanding` asks for `page_size=1` three times and reads the
694
+ * paginator's own `count`, so a focus card costs three empty pages rather than
695
+ * three pages of bodies.
696
+ */
697
+ export function createPlanningApi(options: CreateCorpusApiOptions): PlanningApi {
698
+ const base = createCorpusApi(options);
699
+ const { http, scope } = options;
700
+ const sourceLabels = options.sourceLabels ?? {};
701
+ const types: CorpusTypeKeys = { ...DEFAULT_TYPE_KEYS, ...options.typeKeys };
702
+ const entities = options.paths?.entities ?? 'api/v1/entities';
703
+ const relationships = options.paths?.relationships ?? 'api/v1/relationships';
704
+ // tenant-starter/config/urls.py: path("api/v1/org/members/", OrgMembersView…)
705
+ const membersPath = options.paths?.members ?? 'api/v1/org/members';
706
+
707
+ function writer(): NonNullable<CorpusHttp['post']> {
708
+ if (!http.post) {
709
+ throw new Error('This planning client is read-only — no write method was supplied.');
710
+ }
711
+ return http.post.bind(http);
712
+ }
713
+
714
+ function focusParams(query: FocusQuery): Record<string, unknown> {
715
+ const statuses = Array.isArray(query.status) ? query.status : query.status ? [query.status] : [];
716
+ const out: Record<string, unknown> = {
717
+ type: FOCUS_TYPE,
718
+ page: query.page ?? 1,
719
+ ...(query.pageSize ? { page_size: Math.min(query.pageSize, 200) } : {}),
720
+ // Worst-off first by default. A STORED `progress` is what makes this a
721
+ // server order at all — see the field's note for why nothing recomputes
722
+ // it on the way past.
723
+ order: ({
724
+ progress: 'attr.progress',
725
+ '-progress': '-attr.progress',
726
+ sortOrder: 'attr.sort_order',
727
+ title: 'attr.title',
728
+ } as const)[query.orderBy ?? 'progress'],
729
+ };
730
+ if (statuses.length === 1) out['attr.status'] = statuses[0];
731
+ else if (statuses.length > 1) out['attr.status__in'] = statuses.join(',');
732
+ if (query.category) out['attr.category'] = query.category;
733
+ if (query.timeFrame) out['attr.time_frame'] = query.timeFrame;
734
+ if (query.year !== undefined) out['attr.year'] = query.year;
735
+ if (query.owner) out['attr.owner_sub'] = query.owner;
736
+ if (query.search) out.search = query.search.slice(0, TEXT_FILTER_MAX);
737
+ return out;
738
+ }
739
+
740
+ async function focusPage(params: Record<string, unknown>): Promise<CorpusPage<Focus>> {
741
+ const response = await http.get<DrfPage<TenantEntity> & ServerFilterReport>(
742
+ entities, { params });
743
+ return {
744
+ count: response.count ?? response.results?.length ?? 0,
745
+ next: response.next ?? null,
746
+ previous: response.previous ?? null,
747
+ results: (response.results ?? []).map((entity) => toFocus(entity, scope)),
748
+ appliedFilters: response.applied_filters ?? response.appliedFilters ?? [],
749
+ };
750
+ }
751
+
752
+ /** A count with no rows: ask for one, read `count`, throw the row away. */
753
+ async function countOnly(params: Record<string, unknown>): Promise<number> {
754
+ const response = await http.get<DrfPage<TenantEntity>>(entities, {
755
+ params: { ...params, page_size: 1 },
756
+ });
757
+ return response.count ?? response.results?.length ?? 0;
758
+ }
759
+
760
+ /** The `in_focus` scope for one focus, as query params. */
761
+ function inFocus(focusId: string): Record<string, unknown> {
762
+ return {
763
+ type: types.commitment,
764
+ related_to: focusId,
765
+ related_via: IN_FOCUS,
766
+ // The direction names the RETURNED ROW's end of the edge, and an item is
767
+ // the SOURCE of its own `in_focus` edge. `in` here returns an empty page
768
+ // rather than an error, which is why it is spelled out.
769
+ related_direction: 'out',
770
+ };
771
+ }
772
+
773
+ return {
774
+ ...base,
775
+
776
+ listFocuses: (query: FocusQuery = {}) => focusPage(focusParams(query)),
777
+
778
+ /**
779
+ * The children of a focus, one level.
780
+ *
781
+ * A level at a time because the query layer caps a traversal at
782
+ * `related_depth=2`; a deep tree is several cheap reads rather than one
783
+ * request that cannot be expressed.
784
+ */
785
+ listChildFocuses: (focusId: string) => focusPage({
786
+ type: FOCUS_TYPE,
787
+ related_to: focusId,
788
+ related_via: SUB_FOCUS,
789
+ related_direction: 'out',
790
+ order: 'attr.sort_order',
791
+ }),
792
+
793
+ async createFocus(draft: FocusDraft): Promise<Focus> {
794
+ const post = writer();
795
+ const created = await post<TenantEntity>(`${entities}/upsert`, focusToEntity(draft));
796
+ const focus = toFocus(created, scope);
797
+ // Nesting is an EDGE, so it is a second write with no transaction around
798
+ // it. Done after the row exists rather than smuggled into its payload: a
799
+ // failure here leaves a real, visible, unparented focus instead of half a
800
+ // record and no way to tell which half.
801
+ if (draft.parentId) {
802
+ await post(relationships, {
803
+ rel_type: SUB_FOCUS, source: focus.id, target: draft.parentId,
804
+ });
805
+ return { ...focus, parentId: draft.parentId };
806
+ }
807
+ return focus;
808
+ },
809
+
810
+ async updateFocus(focus: Focus, patch: Partial<FocusDraft>): Promise<Focus> {
811
+ const post = writer();
812
+ // Only the keys the caller actually sent. `focusToEntity` would helpfully
813
+ // default `status` to 'active', and merging that in would resurrect a
814
+ // cancelled focus every time somebody edited its title.
815
+ const { data } = focusToEntity({ ...patch, title: patch.title ?? focus.title });
816
+ const sent: Record<string, unknown> = { title: patch.title ?? focus.title };
817
+ for (const [key, value] of Object.entries(data)) {
818
+ if (key === 'title') continue;
819
+ if (key in FOCUS_PATCH_KEYS && FOCUS_PATCH_KEYS[key] in patch) sent[key] = value;
820
+ }
821
+ const updated = await post<TenantEntity>(`${entities}/upsert`, {
822
+ entity_type: FOCUS_TYPE,
823
+ external_id: focus.externalId,
824
+ ...(patch.title ? { name: patch.title.slice(0, 200) } : {}),
825
+ data: sent,
826
+ });
827
+ return toFocus(updated, scope);
828
+ },
829
+
830
+ async createCommitment(draft: AuthoredItem): Promise<Commitment> {
831
+ const post = writer();
832
+ const created = await post<TenantEntity>(
833
+ `${entities}/upsert`,
834
+ authoredItemToEntity(draft, { itemType: types.commitment }),
835
+ );
836
+ const item = toCommitment(created, scope, sourceLabels);
837
+ if (draft.focusId) {
838
+ await post(relationships, {
839
+ rel_type: IN_FOCUS, source: item.id, target: draft.focusId,
840
+ });
841
+ return { ...item, focusId: draft.focusId };
842
+ }
843
+ return item;
844
+ },
845
+
846
+ /**
847
+ * A person's edit, merged over whatever the extractor last wrote.
848
+ *
849
+ * Sends ONLY the keys in `patch`. The upsert shallow-merges, so an edit to
850
+ * an owner cannot drop a due date the last extraction pass added — and
851
+ * `last_seen_at` is deliberately NOT touched, because a person editing a row
852
+ * is not a meeting mentioning it, and pretending otherwise would move the
853
+ * item into "changed this week" for the wrong reason.
854
+ */
855
+ async updateCommitment(
856
+ item: Commitment,
857
+ patch: Parameters<NonNullable<CommitmentApi['updateCommitment']>>[1],
858
+ ): Promise<Commitment> {
859
+ const post = writer();
860
+ const wire: Record<string, unknown> = {};
861
+ if (patch.title !== undefined) wire.title = patch.title;
862
+ if (patch.detail !== undefined) wire.detail = patch.detail;
863
+ if (patch.status !== undefined) wire.status = patch.status;
864
+ if (patch.statusReason !== undefined) wire.status_reason = patch.statusReason;
865
+ if (patch.ownerName !== undefined) wire.owner_name = patch.ownerName;
866
+ if (patch.ownerSub !== undefined) wire.owner_sub = patch.ownerSub;
867
+ if (patch.dueAt !== undefined) wire.due_at = patch.dueAt;
868
+ if (patch.subtype !== undefined) wire.subtype = patch.subtype;
869
+ if (patch.priority !== undefined) wire.priority = patch.priority;
870
+ const updated = await post<TenantEntity>(`${entities}/upsert`, {
871
+ entity_type: types.commitment,
872
+ external_id: item.externalId,
873
+ ...(patch.title !== undefined ? { name: patch.title.slice(0, 200) } : {}),
874
+ data: wire,
875
+ });
876
+ return toCommitment(updated, scope, sourceLabels);
877
+ },
878
+
879
+ async fileCommitment(item: Commitment, focusId: string | null): Promise<void> {
880
+ const post = writer();
881
+ if (focusId !== null) {
882
+ await post(relationships, { rel_type: IN_FOCUS, source: item.id, target: focusId });
883
+ return;
884
+ }
885
+ if (!http.delete) {
886
+ // Said out loud rather than resolving. A control that appears to unfile
887
+ // and does not is worse than one that is disabled.
888
+ throw new Error('This planning client cannot unfile — no delete method was supplied.');
889
+ }
890
+ const del = http.delete.bind(http);
891
+ const page = await http.get<DrfPage<TenantRelationship>>(relationships, {
892
+ params: { rel_type: IN_FOCUS, source: item.id },
893
+ });
894
+ for (const edge of page.results ?? []) {
895
+ await del(`${relationships}/${edge.id}`);
896
+ }
897
+ },
898
+
899
+ /**
900
+ * "12 of 40 done, 3 overdue" for one focus — three counts, no row bodies.
901
+ *
902
+ * `now` is a PARAMETER and not a `Date.now()` read inside, for the reason
903
+ * `TrackerPageProps.now` records: a clock read on the way past is a new
904
+ * value every call, which makes it a new memo dependency, which is how a
905
+ * screen ends up issuing 917 requests in ten minutes on a live tenant.
906
+ */
907
+ async focusStanding(focusId: string, now?: number): Promise<FocusStanding> {
908
+ const scopeParams = inFocus(focusId);
909
+ const asOf = new Date(now ?? Date.now()).toISOString();
910
+ const [total, done, overdue] = await Promise.all([
911
+ countOnly(scopeParams),
912
+ countOnly({ ...scopeParams, 'attr.status': 'done' }),
913
+ countOnly({ ...scopeParams, 'attr.status': 'open', 'attr.due_at__lte': asOf }),
914
+ ]);
915
+ return { focusId, total, done, overdue };
916
+ },
917
+
918
+ /**
919
+ * The tenant's real members, for the owner picker — what closes the
920
+ * free-text-owner gap (bd startsim-768w.38.29).
921
+ *
922
+ * Read through the fork's OWN same-origin roster, never central directly:
923
+ * pointing a browser at api.startsimpli.com with a bearer from a foreign
924
+ * apex is the cross-origin/refresh-cookie trap the proxy convention exists
925
+ * to avoid.
926
+ *
927
+ * FAILS SOFT TO AN EMPTY ROSTER. A fork with no central wiring has no
928
+ * members to offer, and the picker degrades to the free-text owner name
929
+ * rather than the screen erroring — which is exactly why `ownerName` survives
930
+ * alongside `ownerSub` instead of being replaced by it.
931
+ */
932
+ async listMembers(): Promise<Array<{ sub: string; email: string; name?: string }>> {
933
+ try {
934
+ const page = await http.get<DrfPage<Record<string, unknown>>>(membersPath, {});
935
+ return (page.results ?? [])
936
+ .map((row) => {
937
+ const user = (row.user ?? {}) as Record<string, unknown>;
938
+ return {
939
+ sub: String(user.sub ?? row.sub ?? ''),
940
+ email: String(user.email ?? row.email ?? ''),
941
+ ...(user.name ? { name: String(user.name) } : {}),
942
+ };
943
+ })
944
+ .filter((member) => member.sub);
945
+ } catch {
946
+ return [];
947
+ }
948
+ },
949
+ };
950
+ }
@@ -189,6 +189,25 @@ function asIso(value: unknown): string | null {
189
189
  return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
190
190
  }
191
191
 
192
+ /**
193
+ * A finite number, or `undefined`.
194
+ *
195
+ * `undefined` RATHER THAN 0, and that distinction is the whole reason this is
196
+ * not a one-liner: an unset `target_value` means "this focus is qualitative"
197
+ * (`Goal.is_measurable`) and an unset `priority` means "nobody has triaged this
198
+ * yet". Coercing either to 0 turns a missing judgement into a confident one —
199
+ * every qualitative focus becomes fully achieved, and every untriaged item
200
+ * becomes critical.
201
+ */
202
+ function asNumber(value: unknown): number | undefined {
203
+ if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
204
+ if (typeof value === 'string' && value.trim()) {
205
+ const parsed = Number(value);
206
+ return Number.isFinite(parsed) ? parsed : undefined;
207
+ }
208
+ return undefined;
209
+ }
210
+
192
211
  function asList(value: unknown): unknown[] {
193
212
  if (Array.isArray(value)) return value;
194
213
  if (value === undefined || value === null || value === '') return [];
@@ -593,6 +612,33 @@ export function moneyEventAsDocument(event: MoneyEvent): SourceDocument {
593
612
  * interesting; the thirty-first quote is not, and the row has to stay small. */
594
613
  const MAX_MENTIONS = 20;
595
614
 
615
+ /**
616
+ * WHERE A ROW CAME FROM, under the key the platform already uses.
617
+ *
618
+ * THE LEADING UNDERSCORE IS LOAD-BEARING. `_origin` is written as a flat `data`
619
+ * key by three writers — the central corpus delivery, the foundry-tenant
620
+ * destination handler and the extractor below — and the tenant declares an
621
+ * `AttributeDef` of exactly that name, so it is the only spelling with an index
622
+ * behind it. A field called `origin` would snake_case to `origin`, a DIFFERENT
623
+ * key that nothing has ever written and nothing indexes: every filter over it
624
+ * would return an empty page, with no error to explain why.
625
+ *
626
+ * READS ARRIVE UNDER `Origin`. `@startsimpli/api`'s `transformKeys` camelCases
627
+ * response keys recursively into `data`, and `snakeToCamelKey('_origin')` is
628
+ * `Origin` — while a plain server-side `fetch` sees `_origin` untouched. So the
629
+ * write side emits the literal key and the read side accepts both, the same
630
+ * tolerance `toCommitment` already applies to `status_reason`/`statusReason`.
631
+ */
632
+ export const ORIGIN_KEY = '_origin';
633
+ export const ORIGIN = {
634
+ /** Read out of a meeting by the tenant-side extractor. */
635
+ extracted: 'tenant-extraction',
636
+ /** Typed by a person on the planning surface. */
637
+ authored: 'human',
638
+ /** Delivered by the central corpus pipeline. */
639
+ central: 'central-pipeline',
640
+ } as const;
641
+
596
642
  export function toCommitment(
597
643
  entity: TenantEntity,
598
644
  fallbackScope: CorpusScope,
@@ -624,6 +670,15 @@ export function toCommitment(
624
670
  subjectKey: asText(pick(data, 'subject_key', 'subjectKey')),
625
671
  actionKey: asText(pick(data, 'action_key', 'actionKey')),
626
672
  dueAt: asIso(pick(data, 'due_at', 'dueAt')),
673
+ ...(asNumber(pick(data, 'priority')) !== undefined
674
+ ? { priority: asNumber(pick(data, 'priority')) } : {}),
675
+ ...(pick(data, 'owner_sub', 'ownerSub')
676
+ ? { ownerSub: asText(pick(data, 'owner_sub', 'ownerSub')) } : {}),
677
+ // Both spellings, for the reason spelled out on ORIGIN_KEY above: the
678
+ // browser client camelCases `_origin` into `Origin` on the way in, and a
679
+ // server-side fetch does not.
680
+ ...(pick(data, ORIGIN_KEY, 'Origin')
681
+ ? { origin: asText(pick(data, ORIGIN_KEY, 'Origin')) } : {}),
627
682
  mentions,
628
683
  firstSeenAt: asIso(pick(data, 'first_seen_at', 'firstSeenAt')),
629
684
  lastSeenAt: asIso(pick(data, 'last_seen_at', 'lastSeenAt')),
@@ -675,6 +730,8 @@ export function commitmentToEntity(commitment: Commitment): {
675
730
  action_key: commitment.actionKey,
676
731
  dedupe_key: commitment.dedupeKey,
677
732
  ...(commitment.dueAt ? { due_at: commitment.dueAt } : {}),
733
+ ...(commitment.priority !== undefined ? { priority: commitment.priority } : {}),
734
+ ...(commitment.ownerSub ? { owner_sub: commitment.ownerSub } : {}),
678
735
  first_seen_at: commitment.firstSeenAt,
679
736
  last_seen_at: commitment.lastSeenAt,
680
737
  mention_count: commitment.mentions.length,
@@ -690,7 +747,12 @@ export function commitmentToEntity(commitment: Commitment): {
690
747
  // Marks the row as pipeline-written, the same way the corpus delivery
691
748
  // marks its own — so a human-created item is distinguishable from a
692
749
  // derived one, and a re-derivation knows what it is allowed to rewrite.
693
- _origin: 'tenant-extraction',
750
+ //
751
+ // PRESERVED WHEN THE ROW ALREADY HAS ONE. A human-authored item that
752
+ // passes back through the extractor's bulk upsert must not be relabelled
753
+ // as extracted — that would quietly claim a meeting said something nobody
754
+ // said, and the row has no mention to contradict it.
755
+ [ORIGIN_KEY]: commitment.origin || ORIGIN.extracted,
694
756
  },
695
757
  };
696
758
  }
@@ -9,6 +9,7 @@ export type {
9
9
  AnswerDraft,
10
10
  AnswerRequest,
11
11
  AskMode,
12
+ AuthoredItem,
12
13
  Commitment,
13
14
  CommitmentApi,
14
15
  CommitmentDraft,
@@ -39,10 +40,18 @@ export type {
39
40
  CorpusScope,
40
41
  DocumentQuery,
41
42
  DocumentSection,
43
+ Focus,
44
+ FocusCategory,
45
+ FocusDraft,
46
+ FocusQuery,
47
+ FocusStanding,
48
+ FocusStatus,
49
+ FocusTimeFrame,
42
50
  LlmDescription,
43
51
  MoneyEvent,
44
52
  Party,
45
53
  PartyRef,
54
+ PlanningApi,
46
55
  PromptDocument,
47
56
  RetrievedDocument,
48
57
  SourceDocument,
@@ -136,10 +145,26 @@ export type {
136
145
  ResolutionResult,
137
146
  } from './commitments';
138
147
 
148
+ // Focuses and authored items — the planning toolset (bd startsim-8l6ef).
149
+ export {
150
+ FOCUS_TYPE,
151
+ IN_FOCUS,
152
+ SUB_FOCUS,
153
+ authoredId,
154
+ authoredItemToEntity,
155
+ displayProgress,
156
+ focusProgress,
157
+ focusSlug,
158
+ focusToEntity,
159
+ isAuthored,
160
+ toFocus,
161
+ } from './planning';
162
+ export { ORIGIN, ORIGIN_KEY } from './entities';
163
+
139
164
  export { jaccard, outcomeSections, splitSections, stem, words } from './text';
140
165
  export type { TextSection } from './text';
141
166
 
142
- export { createCorpusApi } from './client';
167
+ export { createCorpusApi, createPlanningApi } from './client';
143
168
  export type { CorpusHttp, CreateCorpusApiOptions } from './client';
144
169
 
145
170
  export {