@lanes-sh/link 0.6.11 → 0.7.0

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 (201) hide show
  1. package/README.md +62 -45
  2. package/instructions/skills/lanes-link/SKILL.md +25 -0
  3. package/package.json +3 -2
  4. package/src/audit/conformance.ts +1 -1
  5. package/src/cli/argv.ts +14 -1
  6. package/src/cli/commands/connect/acquire.ts +100 -0
  7. package/src/cli/commands/connect/custom/index.ts +1 -1
  8. package/src/cli/commands/connect/declare.ts +19 -0
  9. package/src/cli/commands/connect/index.ts +54 -56
  10. package/src/cli/commands/connect/requirements.ts +23 -1
  11. package/src/cli/commands/connect/setup.ts +1 -1
  12. package/src/cli/commands/connect/variables.ts +220 -0
  13. package/src/cli/commands/knowledge/index.ts +22 -4
  14. package/src/cli/commands/knowledge/migrate.ts +61 -43
  15. package/src/cli/commands/operate/tools.ts +1 -1
  16. package/src/cli/commands/owner/entities.ts +244 -0
  17. package/src/cli/commands/owner/shared.ts +14 -0
  18. package/src/cli/commands/owner/vault.ts +2 -2
  19. package/src/cli/commands/owner.ts +13 -3
  20. package/src/cli/commands/setup.ts +12 -0
  21. package/src/cli/config-edit.ts +16 -6
  22. package/src/cli/config-repair.ts +10 -0
  23. package/src/cli/dispatch-owner.ts +30 -1
  24. package/src/cli/main.ts +2 -1
  25. package/src/cli/runtime/open.ts +14 -7
  26. package/src/cli/runtime/registry.ts +6 -1
  27. package/src/cli/selection.ts +11 -0
  28. package/src/cli/usage.ts +13 -2
  29. package/src/connectivity/auth/strategy/index.ts +1 -1
  30. package/src/connectivity/capability.ts +1 -1
  31. package/src/connectivity/context.ts +1 -1
  32. package/src/connectivity/index.ts +1 -1
  33. package/src/connectivity/mail/attachments.ts +42 -73
  34. package/src/connectivity/mail/content-type.ts +71 -0
  35. package/src/connectivity/mail/index.ts +1 -1
  36. package/src/connectivity/manifest/provider.ts +50 -0
  37. package/src/connectivity/manifest/variables.ts +173 -0
  38. package/src/connectivity/transports/factory.ts +74 -1
  39. package/src/connectivity/transports/http/index.ts +8 -2
  40. package/src/connectivity/transports/http/keys.ts +83 -0
  41. package/src/connectivity/transports/imap/attachment.ts +53 -10
  42. package/src/connectivity/transports/imap/capabilities.ts +28 -0
  43. package/src/connectivity/transports/imap/download.ts +111 -0
  44. package/src/connectivity/transports/imap/index.ts +13 -0
  45. package/src/connectivity/transports/imap/operations.ts +1 -0
  46. package/src/deployments/adapters/s3.ts +1 -1
  47. package/src/deployments/gcp/provision.ts +1 -1
  48. package/src/deployments/knowledge.ts +44 -18
  49. package/src/deployments/prepare.ts +1 -1
  50. package/src/deployments/serving.ts +1 -1
  51. package/src/dispatch/dispatch.ts +17 -10
  52. package/src/dispatch/staging.ts +76 -1
  53. package/src/profile/knowledge.ts +19 -11
  54. package/src/profile/schema.ts +2 -2
  55. package/src/providers/README.md +189 -0
  56. package/src/providers/airtable/index.ts +10 -0
  57. package/src/providers/algolia/index.ts +10 -0
  58. package/src/providers/amplitude/index.ts +10 -0
  59. package/src/providers/apify/index.ts +10 -0
  60. package/src/providers/asana/index.ts +10 -0
  61. package/src/providers/assets/provider.ts +11 -4
  62. package/src/providers/atlassian/index.ts +19 -0
  63. package/src/providers/attio/index.ts +10 -0
  64. package/src/providers/betterstack/index.ts +10 -0
  65. package/src/providers/box/index.ts +64 -0
  66. package/src/providers/brightdata/index.ts +10 -0
  67. package/src/providers/buildkite/index.ts +10 -0
  68. package/src/providers/bunq/index.ts +2 -2
  69. package/src/providers/calendly/index.ts +10 -0
  70. package/src/providers/canva/index.ts +10 -0
  71. package/src/providers/circleci/index.ts +10 -0
  72. package/src/providers/clickup/index.ts +10 -0
  73. package/src/providers/close/index.ts +10 -0
  74. package/src/providers/cloudflare_bindings/index.ts +14 -0
  75. package/src/providers/cloudflare_observability/index.ts +10 -0
  76. package/src/providers/contentful/index.ts +10 -0
  77. package/src/providers/custom/load.ts +1 -1
  78. package/src/providers/datadog/index.ts +10 -0
  79. package/src/providers/discord/index.ts +1 -1
  80. package/src/providers/discord/specs/discord.v10.json +0 -88
  81. package/src/providers/discord/specs/vendor.ts +1 -1
  82. package/src/providers/dropbox/index.ts +10 -0
  83. package/src/providers/entities/catalogue.ts +366 -0
  84. package/src/providers/entities/find.ts +279 -0
  85. package/src/providers/entities/provider.ts +334 -0
  86. package/src/providers/entities/render.ts +142 -0
  87. package/src/providers/entities/store.ts +276 -0
  88. package/src/providers/entities/writes.ts +279 -0
  89. package/src/providers/example/provider.ts +1 -1
  90. package/src/providers/expensify/index.ts +10 -0
  91. package/src/providers/fastmail/calendar/index.ts +21 -0
  92. package/src/providers/fastmail/contacts/index.ts +17 -0
  93. package/src/providers/fastmail/index.ts +4 -0
  94. package/src/providers/fastmail/mail/index.ts +38 -0
  95. package/src/providers/fastmail/shared/setup.ts +52 -0
  96. package/src/providers/figma/index.ts +10 -0
  97. package/src/providers/fireflies/index.ts +10 -0
  98. package/src/providers/flagsmith/index.ts +10 -0
  99. package/src/providers/gamma/index.ts +10 -0
  100. package/src/providers/github/index.ts +2 -2
  101. package/src/providers/google/gmail/attachment.ts +16 -1
  102. package/src/providers/google/gmail-imap/index.ts +7 -2
  103. package/src/providers/google/shared/oauth.ts +1 -1
  104. package/src/providers/google/shared/service-account.ts +1 -1
  105. package/src/providers/google/shared/setup.ts +1 -1
  106. package/src/providers/google/specs/calendar.v3.json +0 -32
  107. package/src/providers/google/specs/docs.v1.json +0 -76
  108. package/src/providers/google/specs/drive.v3.json +0 -32
  109. package/src/providers/google/specs/gmail.v1.json +0 -76
  110. package/src/providers/google/specs/people.v1.json +0 -77
  111. package/src/providers/google/specs/sheets.v4.json +0 -76
  112. package/src/providers/google/specs/tasks.v1.json +0 -76
  113. package/src/providers/grafana/index.ts +10 -0
  114. package/src/providers/heroku/index.ts +10 -0
  115. package/src/providers/hubspot/index.ts +75 -0
  116. package/src/providers/hygraph/index.ts +10 -0
  117. package/src/providers/icloud/mail/index.ts +5 -0
  118. package/src/providers/icloud/shared/setup.ts +1 -1
  119. package/src/providers/index.ts +255 -0
  120. package/src/providers/insightly/index.ts +10 -0
  121. package/src/providers/jam/index.ts +10 -0
  122. package/src/providers/klaviyo/index.ts +10 -0
  123. package/src/providers/mailbox/index.ts +87 -0
  124. package/src/providers/memory/provider.ts +3 -8
  125. package/src/providers/mercury/index.ts +10 -0
  126. package/src/providers/microsoft/calendar/index.ts +49 -0
  127. package/src/providers/microsoft/contacts/index.ts +50 -0
  128. package/src/providers/microsoft/drive/index.ts +62 -0
  129. package/src/providers/microsoft/index.ts +12 -0
  130. package/src/providers/microsoft/mail/index.ts +76 -0
  131. package/src/providers/microsoft/shared/oauth.ts +58 -0
  132. package/src/providers/microsoft/shared/scopes.ts +56 -0
  133. package/src/providers/microsoft/shared/setup.ts +45 -0
  134. package/src/providers/microsoft/specs/microsoft-todo.v1.json +755 -0
  135. package/src/providers/microsoft/specs/onedrive.v1.json +993 -0
  136. package/src/providers/microsoft/specs/outlook-calendar.v1.json +1743 -0
  137. package/src/providers/microsoft/specs/outlook-contacts.v1.json +430 -0
  138. package/src/providers/microsoft/specs/outlook-mail.v1.json +901 -0
  139. package/src/providers/microsoft/specs/vendor.ts +232 -0
  140. package/src/providers/microsoft/todo/index.ts +54 -0
  141. package/src/providers/miro/index.ts +10 -0
  142. package/src/providers/mixpanel/index.ts +10 -0
  143. package/src/providers/monday/index.ts +10 -0
  144. package/src/providers/mux/index.ts +10 -0
  145. package/src/providers/navan/index.ts +10 -0
  146. package/src/providers/neon/index.ts +10 -0
  147. package/src/providers/netlify/index.ts +10 -0
  148. package/src/providers/nextcloud/calendar/index.ts +28 -0
  149. package/src/providers/nextcloud/contacts/index.ts +18 -0
  150. package/src/providers/nextcloud/index.ts +3 -0
  151. package/src/providers/nextcloud/shared/setup.ts +59 -0
  152. package/src/providers/owner.ts +15 -3
  153. package/src/providers/paddle/index.ts +10 -0
  154. package/src/providers/paypal/index.ts +10 -0
  155. package/src/providers/posthog/index.ts +10 -0
  156. package/src/providers/prisma/index.ts +10 -0
  157. package/src/providers/ramp/index.ts +10 -0
  158. package/src/providers/recurly/index.ts +10 -0
  159. package/src/providers/reddit/index.ts +1 -1
  160. package/src/providers/remote/index.ts +10 -0
  161. package/src/providers/render/index.ts +35 -0
  162. package/src/providers/replicate/index.ts +10 -0
  163. package/src/providers/resend/index.ts +10 -0
  164. package/src/providers/riverside/index.ts +10 -0
  165. package/src/providers/rootly/index.ts +10 -0
  166. package/src/providers/rudderstack/index.ts +10 -0
  167. package/src/providers/salesloft/index.ts +10 -0
  168. package/src/providers/sanity/index.ts +10 -0
  169. package/src/providers/scopes.ts +2 -0
  170. package/src/providers/sentry/index.ts +10 -0
  171. package/src/providers/setup/plan.ts +11 -0
  172. package/src/providers/setup/provider.ts +12 -0
  173. package/src/providers/shared/openapi.ts +70 -0
  174. package/src/providers/shared/slug.ts +28 -0
  175. package/src/providers/shared/vendor-operations.ts +43 -1
  176. package/src/providers/shared/vendor-spec.ts +98 -17
  177. package/src/providers/shortcut/index.ts +10 -0
  178. package/src/providers/slack/index.ts +2 -2
  179. package/src/providers/square/index.ts +10 -0
  180. package/src/providers/storyblok/index.ts +10 -0
  181. package/src/providers/stripe/index.ts +10 -0
  182. package/src/providers/supabase/index.ts +10 -0
  183. package/src/providers/tasks/store.ts +2 -7
  184. package/src/providers/tavily/index.ts +10 -0
  185. package/src/providers/todoist/index.ts +10 -0
  186. package/src/providers/untested.ts +105 -0
  187. package/src/providers/vault/provider.ts +1 -1
  188. package/src/providers/vercel/index.ts +10 -0
  189. package/src/providers/vimeo/index.ts +10 -0
  190. package/src/providers/webflow/index.ts +10 -0
  191. package/src/providers/whimsical/index.ts +10 -0
  192. package/src/providers/wix/index.ts +10 -0
  193. package/src/providers/workable/index.ts +10 -0
  194. package/src/providers/yahoo_mail/index.ts +73 -0
  195. package/src/providers/zapier/index.ts +10 -0
  196. package/src/providers/zoho_mail/index.ts +79 -0
  197. package/src/secrets/document.ts +1 -1
  198. package/src/server/attachments.ts +129 -5
  199. package/src/server/index.ts +3 -3
  200. package/src/server/mcp/instructions.ts +88 -18
  201. package/src/server/stdio.ts +1 -1
@@ -0,0 +1,279 @@
1
+ import type { Backlink, Catalogue, CatalogueEntity } from './catalogue.ts';
2
+
3
+ /**
4
+ * What a name means, and what happens when it means more than one thing.
5
+ *
6
+ * This is the whole reason the component exists, so the rules are stated here
7
+ * rather than distributed through the handlers:
8
+ *
9
+ * **Several matches is a normal answer, not an error.** An assistant handed two
10
+ * people called Jan asks which one; it does not fail. So `find` returns every
11
+ * candidate and sets no error, and what it refuses to do is *choose*.
12
+ *
13
+ * **Ordering is not selection.** Candidates are ranked so the list is legible —
14
+ * an exact id above a substring — and nothing in the response presents the
15
+ * first as the answer. There is deliberately no scoring inside a rank: no
16
+ * most-recently-updated tiebreak that would quietly promote one of two exact
17
+ * alias matches. `updatedAt` orders *within* a rank and never across. This is
18
+ * the one thing a later contributor will be tempted to improve into a silent
19
+ * pick.
20
+ *
21
+ * **Exact suppresses approximate.** If anything matched exactly, prefix and
22
+ * substring candidates are dropped. That is a boundary between *kinds* of
23
+ * match, not a precedence among equals: two exact alias matches both survive,
24
+ * and both are returned.
25
+ *
26
+ * **A tag is never matched by `query`.** A tag is a category, not an identity,
27
+ * so `find("client")` must not surface one of eleven clients as if it were a
28
+ * name. Filter by `tag` to mean the category.
29
+ *
30
+ * Everything here is pure and synchronous over a catalogue already in memory,
31
+ * which is what lets the interesting cases — ambiguity, dangling edges,
32
+ * preference order — be tested with literal arrays and no I/O.
33
+ */
34
+
35
+ export type Direction = 'out' | 'in' | 'any';
36
+
37
+ export interface AttributeCriterion {
38
+ readonly kind: string;
39
+ /** Omitted means "has this kind at all", which is a useful browse. */
40
+ readonly value?: string | undefined;
41
+ }
42
+
43
+ export interface RelationCriterion {
44
+ /** Omitted means any predicate. */
45
+ readonly predicate?: string | undefined;
46
+ readonly entity: string;
47
+ /**
48
+ * `out` — the candidate declares an edge *to* `entity`. The default, because
49
+ * it is what the natural phrasing means: "who works at Acme" wants Acme's
50
+ * employees and not Acme.
51
+ */
52
+ readonly direction?: Direction | undefined;
53
+ }
54
+
55
+ /**
56
+ * Every field optional, and explicitly `| undefined`.
57
+ *
58
+ * The handler destructures its validated input and hands the pieces straight
59
+ * here, so a criterion the caller omitted arrives as `undefined` rather than
60
+ * being absent — writing that out is what lets the surface stay a one-line
61
+ * pass-through instead of six conditional spreads.
62
+ */
63
+ export interface Criteria {
64
+ readonly query?: string | undefined;
65
+ readonly type?: string | undefined;
66
+ readonly tag?: string | undefined;
67
+ readonly attr?: readonly AttributeCriterion[] | undefined;
68
+ readonly related?: readonly RelationCriterion[] | undefined;
69
+ readonly limit?: number | undefined;
70
+ }
71
+
72
+ export const DEFAULT_LIMIT = 10;
73
+ export const MAX_LIMIT = 50;
74
+
75
+ /**
76
+ * How a candidate matched, in the order a list shows them.
77
+ *
78
+ * Ranks 1-4 are what a lookup means. 5 and 6 exist so a browse is useful, and
79
+ * are dropped entirely whenever anything above them matched.
80
+ */
81
+ const RANKS = ['id', 'name', 'alias', 'attribute', 'prefix', 'substring'] as const;
82
+ export type Rank = (typeof RANKS)[number];
83
+
84
+ const APPROXIMATE: readonly Rank[] = ['prefix', 'substring'];
85
+
86
+ export interface Candidate {
87
+ readonly entity: CatalogueEntity;
88
+ readonly rank: Rank;
89
+ /** The field that matched, as a person would say it: `alias "Jan"`, `email`. */
90
+ readonly matched: string;
91
+ }
92
+
93
+ export interface Matches {
94
+ readonly candidates: readonly Candidate[];
95
+ /** How many matched before `limit` cut the list. */
96
+ readonly total: number;
97
+ /** How many entities were considered, for the audit annotation. */
98
+ readonly scanned: number;
99
+ }
100
+
101
+ /**
102
+ * Normalised for comparison: trimmed, NFC, lowercased.
103
+ *
104
+ * NFC because a name typed with a combining accent in one place and precomposed
105
+ * in another is the same person, and this is the only comparison in the
106
+ * provider where that difference can decide an identity.
107
+ */
108
+ function fold(text: string): string {
109
+ return text.trim().normalize('NFC').toLowerCase();
110
+ }
111
+
112
+ export function matchEntities(catalogue: Catalogue, criteria: Criteria): Matches {
113
+ const filtered = catalogue.entities.filter((entity) => passesFilters(catalogue, entity, criteria));
114
+
115
+ const query = criteria.query === undefined ? '' : fold(criteria.query);
116
+ const matched: Candidate[] = [];
117
+
118
+ for (const entity of filtered) {
119
+ if (query.length === 0) {
120
+ matched.push({ entity, rank: 'name', matched: 'listed' });
121
+ continue;
122
+ }
123
+ const candidate = rankOf(entity, query);
124
+ if (candidate !== null) matched.push(candidate);
125
+ }
126
+
127
+ const exact = matched.filter((one) => !APPROXIMATE.includes(one.rank));
128
+ const kept = exact.length > 0 ? exact : matched;
129
+
130
+ kept.sort(
131
+ (a, b) =>
132
+ RANKS.indexOf(a.rank) - RANKS.indexOf(b.rank) ||
133
+ b.entity.updatedAt.localeCompare(a.entity.updatedAt),
134
+ );
135
+
136
+ const limit = Math.min(Math.max(criteria.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
137
+
138
+ return { candidates: kept.slice(0, limit), total: kept.length, scanned: catalogue.entities.length };
139
+ }
140
+
141
+ /** All criteria present are AND-ed. None of them looks at `query`. */
142
+ function passesFilters(
143
+ catalogue: Catalogue,
144
+ entity: CatalogueEntity,
145
+ criteria: Criteria,
146
+ ): boolean {
147
+ if (criteria.type !== undefined && fold(entity.type) !== fold(criteria.type)) return false;
148
+
149
+ if (criteria.tag !== undefined && !entity.tags.some((tag) => fold(tag) === fold(criteria.tag!))) {
150
+ return false;
151
+ }
152
+
153
+ for (const want of criteria.attr ?? []) {
154
+ const present = entity.attributes.some(
155
+ (attribute) =>
156
+ fold(attribute.kind) === fold(want.kind) &&
157
+ (want.value === undefined || fold(attribute.value) === fold(want.value)),
158
+ );
159
+ if (!present) return false;
160
+ }
161
+
162
+ for (const want of criteria.related ?? []) {
163
+ if (!relatedTo(catalogue, entity, want)) return false;
164
+ }
165
+
166
+ return true;
167
+ }
168
+
169
+ function relatedTo(
170
+ catalogue: Catalogue,
171
+ entity: CatalogueEntity,
172
+ want: RelationCriterion,
173
+ ): boolean {
174
+ const direction = want.direction ?? 'out';
175
+ const target = fold(want.entity);
176
+ const predicate = want.predicate === undefined ? null : fold(want.predicate);
177
+
178
+ const out = entity.relations.some(
179
+ (relation) =>
180
+ fold(relation.entity) === target &&
181
+ (predicate === null || fold(relation.predicate) === predicate),
182
+ );
183
+ if (direction === 'out') return out;
184
+
185
+ const into = (catalogue.backlinks.get(entity.id) ?? []).some(
186
+ (backlink) =>
187
+ fold(backlink.from) === target &&
188
+ (predicate === null || fold(backlink.predicate) === predicate),
189
+ );
190
+
191
+ return direction === 'in' ? into : out || into;
192
+ }
193
+
194
+ /** The strongest way this entity matches the query, or null. */
195
+ function rankOf(entity: CatalogueEntity, query: string): Candidate | null {
196
+ if (fold(entity.id) === query) return { entity, rank: 'id', matched: 'id' };
197
+ if (fold(entity.name) === query) return { entity, rank: 'name', matched: 'name' };
198
+
199
+ const alias = entity.aliases.find((one) => fold(one) === query);
200
+ if (alias !== undefined) return { entity, rank: 'alias', matched: `alias "${alias}"` };
201
+
202
+ const attribute = entity.attributes.find((one) => fold(one.value) === query);
203
+ if (attribute !== undefined) {
204
+ return { entity, rank: 'attribute', matched: attribute.kind };
205
+ }
206
+
207
+ const prefixed = [entity.name, ...entity.aliases].find((one) => fold(one).startsWith(query));
208
+ if (prefixed !== undefined) return { entity, rank: 'prefix', matched: `starts with "${query}"` };
209
+
210
+ const haystack = [entity.name, ...entity.aliases, ...entity.attributes.map((one) => one.value)];
211
+ if (haystack.some((one) => fold(one).includes(query))) {
212
+ return { entity, rank: 'substring', matched: `contains "${query}"` };
213
+ }
214
+
215
+ return null;
216
+ }
217
+
218
+ /**
219
+ * The fields that actually differ across a set of candidates.
220
+ *
221
+ * This is what makes several matches a usable answer rather than a dump. Two
222
+ * people called Jan separated by their employer is a question the surrounding
223
+ * context usually settles; two rows of identical detail is not, and printing
224
+ * everything about both buries the one column that would have decided it.
225
+ *
226
+ * Fields shared by every candidate are omitted for that reason, not to save
227
+ * space: they cannot disambiguate, and they crowd out what can.
228
+ *
229
+ * Values *are* rendered, and that is a deliberate reversal of an earlier draft
230
+ * which withheld them. An address is usually the most distinguishing thing
231
+ * about a person, so withholding it leaves a row that says two people differ by
232
+ * their email without saying how — which is the ambiguity again, one level
233
+ * down. It discloses nothing either: `find` and `get` are both in the default
234
+ * read bundle, so anything that can see this list can already open either
235
+ * record. What it costs is verbosity, and only for fields that actually differ.
236
+ *
237
+ * The audit log is the separate question and is answered differently: the query
238
+ * is withheld there and only the ids that matched are recorded. A response goes
239
+ * to a caller that already has the read grant; the log outlives the call.
240
+ */
241
+ export function distinguish(candidates: readonly Candidate[]): string[][] {
242
+ const facets = candidates.map(facetsOf);
243
+ const keys = [...new Set(facets.flatMap((one) => [...one.keys()]))];
244
+
245
+ const differing = keys.filter((key) => {
246
+ const values = facets.map((one) => one.get(key) ?? '');
247
+ return values.some((value) => value !== values[0]);
248
+ });
249
+
250
+ return facets.map((one) =>
251
+ differing.flatMap((key) => {
252
+ const value = one.get(key);
253
+ return value === undefined ? [] : [`${key} ${value}`];
254
+ }),
255
+ );
256
+ }
257
+
258
+ /** One candidate's comparable surface: type, tags, attribute kinds, edges. */
259
+ function facetsOf(candidate: Candidate): Map<string, string> {
260
+ const facets = new Map<string, string>();
261
+ const { entity } = candidate;
262
+
263
+ if (entity.type) facets.set('type', entity.type);
264
+ if (entity.tags.length > 0) facets.set('tags', [...entity.tags].sort().join(', '));
265
+
266
+ for (const kind of new Set(entity.attributes.map((one) => one.kind))) {
267
+ const values = entity.attributes.filter((one) => one.kind === kind).map((one) => one.value);
268
+ facets.set(kind, values.join(', '));
269
+ }
270
+
271
+ for (const predicate of new Set(entity.relations.map((one) => one.predicate))) {
272
+ const targets = entity.relations
273
+ .filter((one) => one.predicate === predicate)
274
+ .map((one) => one.entity);
275
+ facets.set(`${predicate} →`, targets.join(', '));
276
+ }
277
+
278
+ return facets;
279
+ }
@@ -0,0 +1,334 @@
1
+ import { z } from 'zod';
2
+ import {
3
+ defineLocalProvider,
4
+ keepKeys,
5
+ type BlobStore,
6
+ type ProviderDefinition,
7
+ } from '#connectivity';
8
+ import {
9
+ openCatalogue,
10
+ rebuildCatalogue,
11
+ type Catalogue,
12
+ type CatalogueEntity,
13
+ } from './catalogue.ts';
14
+ import { DEFAULT_LIMIT, MAX_LIMIT, matchEntities, type Criteria } from './find.ts';
15
+ import { describe, renderCandidates, renderEntity } from './render.ts';
16
+ import { readEntity, type Entity } from './store.ts';
17
+ import { writeCapabilities } from './writes.ts';
18
+
19
+ /**
20
+ * `entities` — who and what everyone else is.
21
+ *
22
+ * `identity` declares who the *owner* is, because an agent writing as them was
23
+ * otherwise inferring a name from whatever was in the conversation (ADR-042).
24
+ * The same failure happens one step outward and nothing catches it: asked to
25
+ * "email Jan about the invoice", an agent reaches for an address it saw in a
26
+ * thread. The information is knowable; there was nowhere to write it down.
27
+ *
28
+ * **`find` answers with everything that matches and never chooses.** One match
29
+ * is an answer; several is a question for the owner, and the response shows
30
+ * what *separates* the candidates so that question can usually be settled from
31
+ * context rather than asked. Several matches is a normal outcome and carries no
32
+ * error — an assistant handed two people called Jan asks which one, it does not
33
+ * fail. What is refused is silently picking one.
34
+ *
35
+ * **Reading and writing are separate capabilities**, following `memory` and
36
+ * ADR-012 §2. A writable store persists injections: an instruction written once
37
+ * is re-served to every later session, including to a different agent. That
38
+ * argument applies here with a twist worth naming — an entity is *acted on*, so
39
+ * a bad write is not only re-served, it is used to address a message. The write
40
+ * bundle is not default, and `deny` takes three rules to close
41
+ * (`entities.write`, `entities.link`, `entities.forget`), not one.
42
+ *
43
+ * Unlike `identity`, this **is** agent-writable, and the two are not
44
+ * inconsistent. ADR-050's test for a default grant is not "is it empty" but
45
+ * "can it be filled in from here": identity is configuration and changed in the
46
+ * CLI (ADR-007), so an agent able to edit it could edit the one fact that stops
47
+ * it signing as the wrong person. Everyone else's details are ordinary owner
48
+ * material, accumulated in conversation, on the same surface that reads them.
49
+ *
50
+ * Storage is `store.ts`, the derived index is `catalogue.ts`, matching is
51
+ * `find.ts` and wording is `render.ts`. This file is only the surface: schemas,
52
+ * bundles, redaction, and what reaches the audit log.
53
+ */
54
+
55
+ const DESCRIPTION =
56
+ 'The people, companies and projects the owner deals with, with their canonical addresses and ' +
57
+ 'handles. Call entities.find before acting on a name: it returns every match and never chooses, ' +
58
+ 'so more than one means ask rather than take the first.';
59
+
60
+ export const entitiesProvider: ProviderDefinition = defineLocalProvider({
61
+ id: 'entities',
62
+ name: 'Entities',
63
+ version: '1.0.0',
64
+ description: DESCRIPTION,
65
+
66
+ configSchema: z.object({}),
67
+ connectionSchema: z.object({}),
68
+
69
+ bundles: [
70
+ {
71
+ name: 'read',
72
+ description: 'Look entities up and read them.',
73
+ oauth_scopes: [],
74
+ capabilities: ['entity', 'find', 'get'],
75
+ default: true,
76
+ },
77
+ {
78
+ // Not in the default bundle — see the provider docstring. Denying it
79
+ // takes all three names; there is no single rule that covers them.
80
+ name: 'write',
81
+ description: 'Declare, relate and remove entities.',
82
+ oauth_scopes: [],
83
+ capabilities: ['write', 'link', 'forget'],
84
+ },
85
+ ],
86
+
87
+ capabilities: [
88
+ /**
89
+ * Retrieval by address — a resource, not a tool (ADR-006).
90
+ *
91
+ * Not optional, and not decoration: `resourceLinkRouter` builds its set of
92
+ * routable origins from this provider's own `uriTemplate`s and returns the
93
+ * identity function when there are none. Without it every `resource_link`
94
+ * below would reach the client naming no profile and no connection, which
95
+ * no client can read.
96
+ */
97
+ {
98
+ kind: 'resource',
99
+ name: 'entity',
100
+ title: 'Entity',
101
+ description: 'One declared entity, addressed by its id.',
102
+ uriTemplate: 'entities://entity/{id}',
103
+ mimeType: 'text/markdown',
104
+ redact: keepKeys('uri'),
105
+
106
+ async list(context) {
107
+ const catalogue = await openCatalogue(context.storage);
108
+ return catalogue.entities.map((entity) => ({
109
+ uri: `entities://entity/${encodeURIComponent(entity.id)}`,
110
+ name: entity.name,
111
+ }));
112
+ },
113
+
114
+ async read(uri, params, context) {
115
+ const raw = params['id'];
116
+ if (!raw) throw new Error(`Malformed entities URI: ${uri}`);
117
+
118
+ const id = decodeURIComponent(raw);
119
+ const entity = await readEntity(context.storage, id);
120
+ if (entity === null) throw new Error(`No entity "${id}" on ${context.connection.key}`);
121
+
122
+ const catalogue = await openCatalogue(context.storage);
123
+ return { uri, mimeType: 'text/markdown', text: renderEntity(entity, catalogue, entity.body) };
124
+ },
125
+ },
126
+
127
+ {
128
+ kind: 'tool',
129
+ name: 'find',
130
+ title: 'Look up an entity',
131
+ description:
132
+ 'Find entities by name, alias, address, type, tag, attribute or relationship. All criteria ' +
133
+ 'given are combined. Returns every match: exactly one is an answer, several means ask which ' +
134
+ 'is meant rather than taking the first, and the order is not a ranking. Call this before ' +
135
+ 'using anyone’s address rather than recalling one.',
136
+ inputSchema: z.object({
137
+ query: z
138
+ .string()
139
+ .optional()
140
+ .describe('A name, alias, id or address. Matched exactly first, then by prefix and substring.'),
141
+ type: z.string().optional().describe('Restrict to one type, e.g. person, company, project'),
142
+ tag: z.string().optional().describe('Restrict to entities carrying this tag'),
143
+ attr: z
144
+ .array(
145
+ z.object({
146
+ kind: z.string().min(1).describe('Attribute kind, e.g. email, github'),
147
+ value: z.string().optional().describe('Omit to mean "has this kind at all"'),
148
+ }),
149
+ )
150
+ .optional()
151
+ .describe('Restrict to entities carrying these attributes'),
152
+ related: z
153
+ .array(
154
+ z.object({
155
+ predicate: z.string().optional().describe('Edge name, e.g. works_at. Omit for any.'),
156
+ entity: z.string().min(1).describe('The entity id on the other end'),
157
+ direction: z
158
+ .enum(['out', 'in', 'any'])
159
+ .optional()
160
+ .describe('out (default): this entity points at the named one'),
161
+ }),
162
+ )
163
+ .optional()
164
+ .describe('Restrict by relationship, one hop'),
165
+ limit: z
166
+ .number()
167
+ .int()
168
+ .min(1)
169
+ .max(MAX_LIMIT)
170
+ .optional()
171
+ .describe(`Maximum results (default ${DEFAULT_LIMIT})`),
172
+ }),
173
+ /**
174
+ * The query is withheld; the outcome is annotated instead.
175
+ *
176
+ * A lookup string here is frequently a third party's address, typed by
177
+ * someone who never wrote it down — the same class of thing
178
+ * `memory.search` withholds its query for. The ids that came back are the
179
+ * more useful record anyway: they are stable across spellings, and now
180
+ * that several matches is a normal outcome the log should show that two
181
+ * candidates were offered and which, so a later wrong message is
182
+ * traceable to the moment the choice was made.
183
+ */
184
+ redact: keepKeys('type', 'tag', 'limit'),
185
+ async handler({ query, type, tag, attr, related, limit }, context) {
186
+ const criteria: Criteria = { query, type, tag, attr, related, limit };
187
+
188
+ let catalogue = await openCatalogue(context.storage);
189
+ let matches = matchEntities(catalogue, criteria);
190
+
191
+ // Confirm-on-read: one candidate, served from an index, is the only
192
+ // shape where a wrong answer gets acted on. See `reconcile`.
193
+ const only = matches.candidates.length === 1 ? matches.candidates[0] : undefined;
194
+ if (catalogue.fromIndex && only !== undefined) {
195
+ const rebuilt = await reconcile(context.storage, catalogue, only.entity);
196
+ if (rebuilt !== null) {
197
+ context.log.debug('entities: index disagreed with a matched file, rebuilt');
198
+ catalogue = rebuilt;
199
+ matches = matchEntities(catalogue, criteria);
200
+ }
201
+ }
202
+
203
+ context.audit.annotate({
204
+ scanned: matches.scanned,
205
+ matched: matches.total,
206
+ candidates: matches.candidates.map((one) => one.entity.id),
207
+ });
208
+
209
+ if (matches.candidates.length === 0) {
210
+ return {
211
+ content: [
212
+ {
213
+ type: 'text',
214
+ text:
215
+ `Nothing on ${context.connection.key} matches ${describe(criteria)}. ` +
216
+ 'Do not use an address that is not here — `entities.write` declares a new one, ' +
217
+ 'or ask the owner.',
218
+ },
219
+ ],
220
+ };
221
+ }
222
+
223
+ if (matches.candidates.length > 1) {
224
+ return {
225
+ content: [
226
+ { type: 'text', text: renderCandidates(matches, criteria, context.connection.key) },
227
+ ...matches.candidates.map((one) => ({
228
+ type: 'resource_link' as const,
229
+ uri: `entities://entity/${encodeURIComponent(one.entity.id)}`,
230
+ name: one.entity.name,
231
+ })),
232
+ ],
233
+ };
234
+ }
235
+
236
+ const found = matches.candidates[0]!.entity;
237
+ const file = await readEntity(context.storage, found.id);
238
+
239
+ return {
240
+ content: [
241
+ {
242
+ type: 'resource_link' as const,
243
+ uri: `entities://entity/${encodeURIComponent(found.id)}`,
244
+ name: found.name,
245
+ },
246
+ { type: 'text', text: renderEntity(found, catalogue, file?.body ?? '') },
247
+ ],
248
+ };
249
+ },
250
+ },
251
+
252
+ {
253
+ kind: 'tool',
254
+ name: 'get',
255
+ title: 'Read one entity',
256
+ description:
257
+ 'Return one entity by id, with its relationships and everything pointing at it. The resource ' +
258
+ 'entities://entity/{id} is the same content; this exists for clients that do not read resources.',
259
+ inputSchema: z.object({ id: z.string().min(1).describe('Entity id') }),
260
+ redact: keepKeys('id'),
261
+ async handler({ id }, context) {
262
+ const entity = await readEntity(context.storage, id);
263
+ if (entity === null) {
264
+ return {
265
+ content: [{ type: 'text', text: `No entity "${id}" on ${context.connection.key}.` }],
266
+ isError: true,
267
+ };
268
+ }
269
+
270
+ const catalogue = await openCatalogue(context.storage);
271
+ return { content: [{ type: 'text', text: renderEntity(entity, catalogue, entity.body) }] };
272
+ },
273
+ },
274
+
275
+ ...writeCapabilities,
276
+ ],
277
+ });
278
+
279
+ /**
280
+ * Re-check a single match against its own file before an agent acts on it.
281
+ *
282
+ * The index's fingerprint stamps the *entity files*, so an index edited on its
283
+ * own — by hand, or corrupted in a way that still parses — passes it and is
284
+ * served. That is the accepted cost of keeping the index beside the documents,
285
+ * and this is where it is paid back: on the one path where an answer is about
286
+ * to be used to address something, the file is opened and compared. One extra
287
+ * read, which `get` would have cost anyway.
288
+ *
289
+ * On any disagreement the whole catalogue is rebuilt and the search re-run,
290
+ * rather than the one row being patched. Patching would answer with an entity
291
+ * that no longer matches what was asked for — the index may have been the only
292
+ * reason it matched — and "here is your one result" is exactly the wrong thing
293
+ * to say then. Re-running can legitimately return none, or several; both are
294
+ * better answers than a confident wrong one.
295
+ *
296
+ * Not done when there are several candidates: nothing is being acted on yet,
297
+ * and `get` reads the file.
298
+ */
299
+ async function reconcile(
300
+ storage: BlobStore,
301
+ catalogue: Catalogue,
302
+ candidate: CatalogueEntity,
303
+ ): Promise<Catalogue | null> {
304
+ const file = await readEntity(storage, candidate.id);
305
+ if (file !== null && canonical(strip(file)) === canonical(candidate)) return null;
306
+
307
+ return rebuildCatalogue(storage);
308
+ }
309
+
310
+ function strip(entity: Entity): CatalogueEntity {
311
+ const { body: _body, bytes: _bytes, ...row } = entity;
312
+ return row;
313
+ }
314
+
315
+ /**
316
+ * Everything about a row that could have decided the match, as one string.
317
+ *
318
+ * Written out field by field rather than `JSON.stringify(row)` so the
319
+ * comparison does not depend on two parsers inserting keys in the same order —
320
+ * a difference that would read as a disagreement and rebuild on every call.
321
+ */
322
+ function canonical(row: CatalogueEntity): string {
323
+ return JSON.stringify([
324
+ row.type,
325
+ row.name,
326
+ [...row.aliases],
327
+ [...row.tags],
328
+ row.attributes.map((one) => [one.kind, one.value, one.note ?? '']),
329
+ row.relations.map((one) => [one.predicate, one.entity, one.note ?? '']),
330
+ row.updatedAt,
331
+ ]);
332
+ }
333
+
334
+ export default entitiesProvider;