@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,142 @@
1
+ import type { Catalogue, CatalogueEntity } from './catalogue.ts';
2
+ import { distinguish, type Candidate, type Criteria, type Matches } from './find.ts';
3
+
4
+ /**
5
+ * How an answer reads, kept apart from what an answer is.
6
+ *
7
+ * `find.ts` decides which entities match; this decides what a person or a model
8
+ * sees, and the two are separated because the interesting failures are
9
+ * different in kind. A matching bug returns the wrong entity. A rendering bug
10
+ * returns the right entity in a form that invites the wrong next move — a list
11
+ * of two that reads like a recommendation of one, or an edge to something
12
+ * undeclared silently omitted so the graph looks complete.
13
+ *
14
+ * Every function here is pure over a catalogue already in memory.
15
+ */
16
+
17
+ /** One entity in full, for the answer an agent is about to act on. */
18
+ export function renderEntity(
19
+ entity: CatalogueEntity,
20
+ catalogue: Catalogue,
21
+ body: string,
22
+ ): string {
23
+ const head = entity.type ? `${entity.name} — ${entity.type} (${entity.id})` : `${entity.name} (${entity.id})`;
24
+ const lines: string[] = [head];
25
+
26
+ if (entity.aliases.length > 0) lines.push(`Also known as ${entity.aliases.join(', ')}.`);
27
+ if (entity.tags.length > 0) lines.push(`Tagged ${entity.tags.join(', ')}.`);
28
+
29
+ if (entity.attributes.length > 0) {
30
+ const kinds = [...new Set(entity.attributes.map((one) => one.kind))];
31
+ const ordered = kinds.flatMap((kind) => entity.attributes.filter((one) => one.kind === kind));
32
+ const width = Math.max(...ordered.map((one) => one.kind.length));
33
+
34
+ lines.push('');
35
+ // The kind is repeated on every line rather than written once as a heading,
36
+ // for identity's reason: a line lifted out of this block on its own still
37
+ // says what it is, and a model quoting one line is what happens next.
38
+ for (const one of ordered) {
39
+ const head = ` ${one.kind.padEnd(width)} ${one.value}`;
40
+ lines.push(one.note ? `${head} — ${one.note}` : head);
41
+ }
42
+
43
+ if (kinds.some((kind) => entity.attributes.filter((one) => one.kind === kind).length > 1)) {
44
+ lines.push('');
45
+ lines.push(
46
+ 'Where a kind holds more than one, the first is the default and the notes say when to ' +
47
+ 'prefer another. If none of them fits what you are doing, ask rather than combining them.',
48
+ );
49
+ }
50
+ }
51
+
52
+ const edges = renderEdges(entity, catalogue);
53
+ if (edges.length > 0) lines.push('', ...edges);
54
+
55
+ if (body.trim().length > 0) lines.push('', body.trim());
56
+
57
+ return lines.join('\n');
58
+ }
59
+
60
+ function renderEdges(entity: CatalogueEntity, catalogue: Catalogue): string[] {
61
+ const lines: string[] = [];
62
+
63
+ for (const relation of entity.relations) {
64
+ lines.push(` ${relation.predicate} → ${nameOf(catalogue, relation.entity)}${note(relation.note)}`);
65
+ }
66
+
67
+ for (const backlink of catalogue.backlinks.get(entity.id) ?? []) {
68
+ lines.push(` ← ${backlink.predicate} ${nameOf(catalogue, backlink.from)}${note(backlink.note)}`);
69
+ }
70
+
71
+ return lines;
72
+ }
73
+
74
+ /**
75
+ * A declared entity by name and id; an undeclared one by id, said so.
76
+ *
77
+ * Never an error and never a dropped line: an edge to something not written
78
+ * down yet is a fact the owner recorded, and hiding it would make the graph
79
+ * quietly wrong rather than visibly incomplete.
80
+ */
81
+ function nameOf(catalogue: Catalogue, id: string): string {
82
+ const entity = catalogue.byId.get(id);
83
+ return entity === undefined ? `${id} (not a declared entity)` : `${entity.name} (${id})`;
84
+ }
85
+
86
+ function note(text: string | undefined): string {
87
+ return text === undefined ? '' : ` — ${text}`;
88
+ }
89
+
90
+ /** Several candidates: the count first, then only what tells them apart. */
91
+ export function renderCandidates(
92
+ matches: Matches,
93
+ criteria: Criteria,
94
+ connection: string,
95
+ ): string {
96
+ const { candidates, total } = matches;
97
+ const differing = distinguish(candidates);
98
+ const idWidth = Math.max(...candidates.map((one) => one.entity.id.length));
99
+ const nameWidth = Math.max(...candidates.map((one) => one.entity.name.length));
100
+
101
+ const rows = candidates.map((candidate, index) => {
102
+ const facets = differing[index] ?? [];
103
+ const head = ` ${candidate.entity.id.padEnd(idWidth)} ${candidate.entity.name.padEnd(nameWidth)} ${candidate.matched}`;
104
+ return facets.length > 0 ? `${head} · ${facets.join(' · ')}` : head;
105
+ });
106
+
107
+ const nothing = differing.every((one) => one.length === 0);
108
+
109
+ return [
110
+ // The count leads, before any candidate: a client that truncates the
111
+ // response must still have seen that there was more than one.
112
+ `${total} entities match ${describe(criteria)} on \`${connection}\`.` +
113
+ (total > candidates.length ? ` The first ${candidates.length} are below; raise \`limit\` for more.` : ''),
114
+ '',
115
+ ...rows,
116
+ '',
117
+ nothing
118
+ ? 'Nothing here tells these apart beyond their ids — which usually means the owner has a ' +
119
+ 'duplicate to merge. Ask which is meant; do not pick one.'
120
+ : 'If the context does not make it clear which is meant, ask before acting. Nothing here ' +
121
+ 'chooses between them, and the order is not a ranking.',
122
+ ].join('\n');
123
+ }
124
+
125
+ /** What was asked for, in the words the caller used. */
126
+ export function describe(criteria: Criteria): string {
127
+ const parts: string[] = [];
128
+
129
+ if (criteria.query !== undefined) parts.push(`"${criteria.query}"`);
130
+ if (criteria.type !== undefined) parts.push(`type ${criteria.type}`);
131
+ if (criteria.tag !== undefined) parts.push(`tag ${criteria.tag}`);
132
+
133
+ for (const one of criteria.attr ?? []) {
134
+ parts.push(one.value === undefined ? `with a ${one.kind}` : `${one.kind} ${one.value}`);
135
+ }
136
+ for (const one of criteria.related ?? []) {
137
+ const arrow = (one.direction ?? 'out') === 'in' ? '←' : '→';
138
+ parts.push(`${one.predicate ?? 'related'} ${arrow} ${one.entity}`);
139
+ }
140
+
141
+ return parts.length === 0 ? 'no criteria' : parts.join(', ');
142
+ }
@@ -0,0 +1,276 @@
1
+ import type { BlobStore } from '#connectivity';
2
+ import {
3
+ splitOptionalFrontmatter,
4
+ stringList,
5
+ withFrontmatter,
6
+ } from '#providers/shared/frontmatter.ts';
7
+ import { slugify as slugifyText } from '#providers/shared/slug.ts';
8
+
9
+ /**
10
+ * How an entity is stored, and the only place that knows.
11
+ *
12
+ * **One entity is one Markdown file**, exactly as a memory entry and a task
13
+ * are, and for the same reason (ADR-014): `lanes link entities` and a text
14
+ * editor reach the same bytes, and there is no row anywhere that can disagree
15
+ * with the file it describes. The document is frontmatter — type, name,
16
+ * aliases, tags, attributes, relations, timestamp — above a body that is the
17
+ * owner's notes on this person or thing.
18
+ *
19
+ * The `_index.json` beside these files is a *derived* artefact and is not a
20
+ * counter-example: it carries a fingerprint of the files it was built from and
21
+ * is rebuilt from them whenever that fingerprint disagrees. See `catalogue.ts`.
22
+ *
23
+ * **`attributes` is a list, not a map**, and the argument is identity's,
24
+ * verbatim (`#profile/identity.ts`): a map cannot say *when* to use which, and
25
+ * a map silently forbids two email addresses — the case that matters most.
26
+ * Order is preference order, so the first of a kind is the default.
27
+ *
28
+ * **`relations` is one-sided.** An edge is written into the entity that
29
+ * declares it and nowhere else; the reverse direction is derived when the
30
+ * catalogue is built. Writing both sides would be two files for one fact, and
31
+ * nothing in this codebase locks — an interrupted write would leave a half-edge
32
+ * that nothing detects.
33
+ *
34
+ * The store arrives scoped to `entities/<connection>` by core, so nothing here
35
+ * prefixes a key or thinks about isolation.
36
+ */
37
+
38
+ /** One addressable fact about an entity: an address, a handle, a number. */
39
+ export interface Attribute {
40
+ readonly kind: string;
41
+ readonly value: string;
42
+ /** When this one applies, in the owner's words. See `identityEntrySchema`. */
43
+ readonly note?: string;
44
+ }
45
+
46
+ /** A typed edge to another entity, as written on the entity that declares it. */
47
+ export interface Relation {
48
+ readonly predicate: string;
49
+ /** An entity id. Not required to exist — see `parseEntity`. */
50
+ readonly entity: string;
51
+ readonly note?: string;
52
+ }
53
+
54
+ export interface Entity {
55
+ readonly id: string;
56
+ /** `person`, `company`, `project` — or anything else. Never a closed list. */
57
+ readonly type: string;
58
+ /** Falls back to the id, as a memory entry's title does. */
59
+ readonly name: string;
60
+ readonly aliases: readonly string[];
61
+ readonly tags: readonly string[];
62
+ /** Preference order: the first of a kind is the default. */
63
+ readonly attributes: readonly Attribute[];
64
+ readonly relations: readonly Relation[];
65
+ readonly updatedAt: string;
66
+ readonly body: string;
67
+ readonly bytes: number;
68
+ }
69
+
70
+ const ENTITY_ID = /^[a-z0-9][a-z0-9_-]*$/;
71
+
72
+ /**
73
+ * What a `kind` or a `predicate` may be, enforced on write only.
74
+ *
75
+ * The same shape `identityEntrySchema.kind` holds, so an agent that has read
76
+ * `identity_list` recognises an entity's attributes without learning a second
77
+ * format. Free-form within that shape: `signal` and `bluesky` must not need a
78
+ * release, which is why this is a character class and not an enum.
79
+ */
80
+ const IDENTIFIER = /^[a-z][a-z0-9_]*$/;
81
+
82
+ export function entityKey(id: string): string {
83
+ return `${id}.md`;
84
+ }
85
+
86
+ /**
87
+ * The id a key holds, or null for a key that is not an entity.
88
+ *
89
+ * This one predicate is what keeps `_index.json` out of the entity listing, out
90
+ * of the fingerprint, out of the resource listing and out of the CLI listing —
91
+ * four places, no special case in any of them.
92
+ */
93
+ export function idFromKey(key: string): string | null {
94
+ if (!key.endsWith('.md')) return null;
95
+ const id = key.slice(0, -'.md'.length);
96
+ return ENTITY_ID.test(id) ? id : null;
97
+ }
98
+
99
+ export function assertEntityId(id: string): void {
100
+ if (!ENTITY_ID.test(id)) {
101
+ throw new Error(
102
+ `Entity id ${JSON.stringify(id)} must be lowercase letters, digits, "_" or "-".`,
103
+ );
104
+ }
105
+ }
106
+
107
+ export function assertKind(kind: string, label: 'kind' | 'predicate'): void {
108
+ if (!IDENTIFIER.test(kind)) {
109
+ throw new Error(
110
+ `Attribute ${label} ${JSON.stringify(kind)} must be lowercase letters, digits and ` +
111
+ `underscores, starting with a letter — "email", "github", "works_at".`,
112
+ );
113
+ }
114
+ }
115
+
116
+ /** A stable id from a name, so writing does not demand one be invented. */
117
+ export function slugify(name: string): string {
118
+ return slugifyText(name, 'entity');
119
+ }
120
+
121
+ /**
122
+ * Parse one stored entity, tolerating anything.
123
+ *
124
+ * Every field falls back and no fallback is an error, because this reads a
125
+ * directory the owner is invited to edit. A plain Markdown file dropped in
126
+ * there is an entity named after its filename, which is a better answer than an
127
+ * exception that hides every other entity behind it.
128
+ *
129
+ * A malformed row inside `attributes` or `relations` is **skipped rather than
130
+ * thrown**, and that is the same argument one level down: one bad list item
131
+ * must not cost the whole directory. The visible failure is an attribute that
132
+ * did not appear, which is diagnosable by opening the file; the alternative is
133
+ * a listing that will not render and does not say which file broke it.
134
+ */
135
+ export function parseEntity(id: string, text: string, fallbackUpdatedAt: string): Entity {
136
+ const { frontmatter, body } = splitOptionalFrontmatter(text);
137
+
138
+ const type = frontmatter['type'];
139
+ const name = frontmatter['name'];
140
+ const updatedAt = frontmatter['updated_at'];
141
+
142
+ return {
143
+ id,
144
+ type: typeof type === 'string' && type.trim().length > 0 ? type.trim() : '',
145
+ name: typeof name === 'string' && name.trim().length > 0 ? name.trim() : id,
146
+ aliases: stringList(frontmatter['aliases']),
147
+ tags: stringList(frontmatter['tags']),
148
+ attributes: parseAttributes(frontmatter['attributes']),
149
+ relations: parseRelations(frontmatter['relations']),
150
+ updatedAt: typeof updatedAt === 'string' ? updatedAt : fallbackUpdatedAt,
151
+ body: body.trimEnd(),
152
+ bytes: new TextEncoder().encode(text).byteLength,
153
+ };
154
+ }
155
+
156
+ function parseAttributes(raw: unknown): Attribute[] {
157
+ if (!Array.isArray(raw)) return [];
158
+
159
+ return raw.flatMap((item) => {
160
+ const kind = field(item, 'kind');
161
+ const value = field(item, 'value');
162
+ if (kind === null || value === null) return [];
163
+
164
+ const note = field(item, 'note');
165
+ return [{ kind, value, ...(note === null ? {} : { note }) }];
166
+ });
167
+ }
168
+
169
+ function parseRelations(raw: unknown): Relation[] {
170
+ if (!Array.isArray(raw)) return [];
171
+
172
+ return raw.flatMap((item) => {
173
+ const predicate = field(item, 'predicate');
174
+ const entity = field(item, 'entity');
175
+ if (predicate === null || entity === null) return [];
176
+
177
+ const note = field(item, 'note');
178
+ return [{ predicate, entity, ...(note === null ? {} : { note }) }];
179
+ });
180
+ }
181
+
182
+ /** One non-empty string field off a frontmatter row, or null. */
183
+ function field(item: unknown, key: string): string | null {
184
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) return null;
185
+ const raw = (item as Record<string, unknown>)[key];
186
+ if (typeof raw !== 'string') return null;
187
+ const trimmed = raw.trim();
188
+ return trimmed.length > 0 ? trimmed : null;
189
+ }
190
+
191
+ export function serialiseEntity(entity: Omit<Entity, 'id' | 'bytes'>): string {
192
+ return withFrontmatter(
193
+ {
194
+ ...(entity.type ? { type: entity.type } : {}),
195
+ name: entity.name,
196
+ ...(entity.aliases.length > 0 ? { aliases: [...entity.aliases] } : {}),
197
+ ...(entity.tags.length > 0 ? { tags: [...entity.tags] } : {}),
198
+ ...(entity.attributes.length > 0
199
+ ? { attributes: entity.attributes.map((one) => ({ ...one })) }
200
+ : {}),
201
+ ...(entity.relations.length > 0
202
+ ? { relations: entity.relations.map((one) => ({ ...one })) }
203
+ : {}),
204
+ updated_at: entity.updatedAt,
205
+ },
206
+ `${entity.body.trimEnd()}\n`,
207
+ );
208
+ }
209
+
210
+ export async function readEntity(storage: BlobStore, id: string): Promise<Entity | null> {
211
+ const bytes = await storage.get(entityKey(id));
212
+ if (bytes === null) return null;
213
+
214
+ return parseEntity(id, new TextDecoder().decode(bytes), new Date(0).toISOString());
215
+ }
216
+
217
+ /** Write one entity, returning the byte length the catalogue's fingerprint needs. */
218
+ export async function writeEntity(storage: BlobStore, entity: Entity): Promise<number> {
219
+ const { id: _id, bytes: _bytes, ...rest } = entity;
220
+ const encoded = new TextEncoder().encode(serialiseEntity(rest));
221
+
222
+ await storage.put(entityKey(entity.id), encoded, { contentType: 'text/markdown' });
223
+ return encoded.byteLength;
224
+ }
225
+
226
+ /**
227
+ * How many entities are read at once.
228
+ *
229
+ * The bound memory and tasks use, for the reason memory gives: against a bucket
230
+ * each read is an HTTPS request, and firing a thousand at once trades a slow
231
+ * rebuild for a rate-limited one.
232
+ */
233
+ const READ_CONCURRENCY = 16;
234
+
235
+ /**
236
+ * Every entity, newest first.
237
+ *
238
+ * **This reads every file**, and is the expensive path this provider exists to
239
+ * avoid taking twice: it runs when the catalogue's index is absent, stale or
240
+ * unreadable, and never on a read that found a valid one. `catalogue.ts` has
241
+ * the arithmetic for what that costs at a thousand entities on a bucket.
242
+ */
243
+ export async function allEntities(storage: BlobStore): Promise<Entity[]> {
244
+ const blobs = (await storage.list()).flatMap((blob) => {
245
+ const id = idFromKey(blob.key);
246
+ return id === null ? [] : [{ blob, id }];
247
+ });
248
+
249
+ const entities: Entity[] = [];
250
+
251
+ for (let start = 0; start < blobs.length; start += READ_CONCURRENCY) {
252
+ const batch = await Promise.all(
253
+ blobs.slice(start, start + READ_CONCURRENCY).map(async ({ blob, id }) => {
254
+ const bytes = await storage.get(blob.key);
255
+ return bytes === null
256
+ ? null
257
+ : parseEntity(id, new TextDecoder().decode(bytes), blob.modifiedAt.toISOString());
258
+ }),
259
+ );
260
+ for (const entity of batch) if (entity) entities.push(entity);
261
+ }
262
+
263
+ return entities.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
264
+ }
265
+
266
+ /** The pieces `lanes link entities` needs to reach the same bytes the provider does. */
267
+ export const entityStorage = {
268
+ key: entityKey,
269
+ idFromKey,
270
+ parse: parseEntity,
271
+ serialise: serialiseEntity,
272
+ read: readEntity,
273
+ write: writeEntity,
274
+ all: allEntities,
275
+ slugify,
276
+ };
@@ -0,0 +1,279 @@
1
+ import { z } from 'zod';
2
+ import { keepKeys, type BlobStore, type Capability } from '#connectivity';
3
+ import { fingerprintAfter, openCatalogue, writeCatalogue, type Catalogue } from './catalogue.ts';
4
+ import {
5
+ assertEntityId,
6
+ assertKind,
7
+ entityKey,
8
+ readEntity,
9
+ slugify,
10
+ writeEntity,
11
+ type Attribute,
12
+ type Entity,
13
+ type Relation,
14
+ } from './store.ts';
15
+
16
+ /**
17
+ * The three capabilities that change something, and the index maintenance they
18
+ * share.
19
+ *
20
+ * Their own file because the file boundary is the *bundle* boundary, and the
21
+ * bundle boundary is the security argument: `read` is default and this is not
22
+ * (ADR-012 §2). A reader deciding whether an agent should hold `entities.write`
23
+ * has one file to read, and a grep for what an agent with only the default
24
+ * bundle can do does not have to distinguish handlers inside one long list.
25
+ *
26
+ * All three go through `persist`, so there is one implementation of "write an
27
+ * entity and leave the index describing what is now there".
28
+ */
29
+
30
+ export const writeCapabilities: readonly Capability[] = [
31
+ {
32
+ kind: 'tool',
33
+ name: 'write',
34
+ title: 'Declare or update an entity',
35
+ description:
36
+ 'Create an entity, or update one that exists. A field you do not supply is left as it is — ' +
37
+ 'send attributes: [] to clear them deliberately. Attribute order is preference order: the ' +
38
+ 'first of a kind is the default. Separate from reading, because what is written here is used ' +
39
+ 'to address messages in every later session.',
40
+ inputSchema: z.object({
41
+ name: z.string().min(1).describe('How the owner refers to this entity'),
42
+ id: z
43
+ .string()
44
+ .optional()
45
+ .describe('Entity id. Derived from the name when omitted; naming an existing one updates it.'),
46
+ type: z.string().optional().describe('person, company, project — or anything else'),
47
+ aliases: z.array(z.string()).optional().describe('Other names this is known by'),
48
+ tags: z.array(z.string()).optional().describe('Labels for filtering'),
49
+ attributes: z
50
+ .array(
51
+ z.object({
52
+ kind: z.string().min(1).describe('email, github, phone — lowercase, no spaces'),
53
+ value: z.string().min(1),
54
+ note: z.string().optional().describe('When to use this one rather than another'),
55
+ }),
56
+ )
57
+ .optional()
58
+ .describe('Addresses and handles, most-preferred first'),
59
+ relations: z
60
+ .array(
61
+ z.object({
62
+ predicate: z.string().min(1).describe('works_at, owns, part_of'),
63
+ entity: z.string().min(1).describe('The entity id on the other end'),
64
+ note: z.string().optional(),
65
+ }),
66
+ )
67
+ .optional()
68
+ .describe('Edges from this entity. Replaces the existing set; entities.link appends one.'),
69
+ notes: z.string().optional().describe('Free prose about this entity, as Markdown'),
70
+ }),
71
+ // The id, type and name are addresses — and `id` is `slugify(name)`, so
72
+ // keeping one while withholding the other would be theatre. Every value
73
+ // is withheld; `annotate` records the shape instead.
74
+ redact: keepKeys('id', 'type', 'name', 'tags'),
75
+ async handler(
76
+ { name, id: given, type, aliases, tags, attributes, relations, notes },
77
+ context,
78
+ ) {
79
+ const id = given ?? slugify(name);
80
+ assertEntityId(id);
81
+ for (const one of attributes ?? []) assertKind(one.kind, 'kind');
82
+ for (const one of relations ?? []) assertKind(one.predicate, 'predicate');
83
+
84
+ const existing = await readEntity(context.storage, id);
85
+ // A field that was not supplied keeps what is on disk. Spelled out
86
+ // rather than merged generically, because the alternative — a model
87
+ // resending an entity minus an attribute it forgot — is a lost update
88
+ // with a language model holding the pen.
89
+ const next: Entity = {
90
+ id,
91
+ name,
92
+ type: type ?? existing?.type ?? '',
93
+ aliases: aliases ?? existing?.aliases ?? [],
94
+ tags: tags ?? existing?.tags ?? [],
95
+ attributes: (attributes ?? existing?.attributes ?? []) as readonly Attribute[],
96
+ relations: (relations ?? existing?.relations ?? []) as readonly Relation[],
97
+ updatedAt: new Date().toISOString(),
98
+ body: notes ?? existing?.body ?? '',
99
+ bytes: 0,
100
+ };
101
+
102
+ await persistEntity(context.storage, next);
103
+
104
+ // `redact` maps keys to keep-or-typemark and cannot express "keep the
105
+ // shape of this list, drop its values" — left to it alone, the log
106
+ // would record `<array:3>` and say nothing about what changed. The
107
+ // kinds are the reviewable fact; the addresses stay out of an
108
+ // append-only hash-chained log. ADR-017.
109
+ context.audit.annotate({
110
+ entity: id,
111
+ created: existing === null,
112
+ attributes: next.attributes.length,
113
+ kinds: [...new Set(next.attributes.map((one) => one.kind))],
114
+ relations: next.relations.length,
115
+ });
116
+
117
+ return {
118
+ content: [
119
+ {
120
+ type: 'text',
121
+ text: `${existing === null ? 'Declared' : 'Updated'} "${id}" on ${context.connection.key}.`,
122
+ },
123
+ { type: 'resource_link', uri: `entities://entity/${id}`, name },
124
+ ],
125
+ };
126
+ },
127
+ },
128
+
129
+ {
130
+ kind: 'tool',
131
+ name: 'link',
132
+ title: 'Relate one entity to another',
133
+ description:
134
+ 'Add one edge to an entity, leaving everything else on it alone. The edge is written only on ' +
135
+ 'the entity it comes from; the reverse direction is derived, so do not also write it the other ' +
136
+ 'way. The other end need not exist yet.',
137
+ inputSchema: z.object({
138
+ from: z.string().min(1).describe('Entity id the edge comes from'),
139
+ predicate: z.string().min(1).describe('works_at, owns, part_of'),
140
+ to: z.string().min(1).describe('Entity id the edge points at'),
141
+ note: z.string().optional(),
142
+ }),
143
+ redact: keepKeys('from', 'predicate', 'to'),
144
+ async handler({ from, predicate, to, note }, context) {
145
+ assertKind(predicate, 'predicate');
146
+
147
+ const entity = await readEntity(context.storage, from);
148
+ if (entity === null) {
149
+ return {
150
+ content: [{ type: 'text', text: `No entity "${from}" on ${context.connection.key}.` }],
151
+ isError: true,
152
+ };
153
+ }
154
+
155
+ const already = entity.relations.some(
156
+ (one) => one.predicate === predicate && one.entity === to,
157
+ );
158
+ if (already) {
159
+ return {
160
+ content: [{ type: 'text', text: `"${from}" already ${predicate} "${to}".` }],
161
+ };
162
+ }
163
+
164
+ const relation: Relation = { predicate, entity: to, ...(note === undefined ? {} : { note }) };
165
+ const next: Entity = {
166
+ ...entity,
167
+ relations: [...entity.relations, relation],
168
+ updatedAt: new Date().toISOString(),
169
+ };
170
+
171
+ const catalogue = await persistEntity(context.storage, next);
172
+
173
+ // Recorded because a dangling edge is legal and useful, and is also how
174
+ // a typo in an id looks. The log is where that becomes visible.
175
+ const dangling = !catalogue.byId.has(to);
176
+ context.audit.annotate({ dangling });
177
+
178
+ return {
179
+ content: [
180
+ {
181
+ type: 'text',
182
+ text:
183
+ `"${from}" ${predicate} "${to}".` +
184
+ (dangling ? ` "${to}" is not declared yet — the edge is kept as written.` : ''),
185
+ },
186
+ ],
187
+ };
188
+ },
189
+ },
190
+
191
+ {
192
+ kind: 'tool',
193
+ name: 'forget',
194
+ title: 'Remove an entity',
195
+ description:
196
+ 'Delete an entity. Edges pointing at it from other entities are left alone and are reported, ' +
197
+ 'so nothing else is rewritten behind your back — clean them up deliberately if you mean to.',
198
+ inputSchema: z.object({ id: z.string().min(1).describe('Entity id') }),
199
+ redact: keepKeys('id'),
200
+ async handler({ id }, context) {
201
+ const catalogue = await openCatalogue(context.storage);
202
+ const existed = catalogue.byId.has(id);
203
+ const referencedBy = (catalogue.backlinks.get(id) ?? []).map((one) => one.from);
204
+
205
+ context.audit.annotate({ existed, referenced_by: referencedBy.length });
206
+
207
+ if (!existed) {
208
+ return {
209
+ content: [{ type: 'text', text: `No entity "${id}" on ${context.connection.key}.` }],
210
+ isError: true,
211
+ };
212
+ }
213
+
214
+ await forgetEntity(context.storage, catalogue, id, new Date().toISOString());
215
+
216
+ // Deliberately not a cascade: a delete that rewrote five other people's
217
+ // files could not be reviewed as one change. Saying who still points
218
+ // here is what keeps the breakage from being silent.
219
+ return {
220
+ content: [
221
+ {
222
+ type: 'text',
223
+ text:
224
+ `Removed "${id}" from ${context.connection.key}.` +
225
+ (referencedBy.length > 0
226
+ ? ` Still referenced by ${referencedBy.join(', ')} — those edges now dangle.`
227
+ : ''),
228
+ },
229
+ ],
230
+ };
231
+ },
232
+ },
233
+ ];
234
+
235
+ /**
236
+ * Write one entity and the index that describes the store afterwards.
237
+ *
238
+ * The fingerprint is computed from the listing taken *before* the put plus the
239
+ * byte length being written, so there is no second `list()` and no read-back —
240
+ * see `catalogue.ts` for why `key:size` is what makes that possible.
241
+ *
242
+ * Exported because `lanes link entities write` needs exactly this and an
243
+ * earlier version of it had its own: a rebuild-then-write that read every file
244
+ * on every call, which is one extra pass by hand and a quadratic bulk load from
245
+ * a script. Two implementations of index maintenance is the same drift the
246
+ * shared `entityStorage` exists to prevent, one layer up.
247
+ */
248
+ export async function persistEntity(storage: BlobStore, entity: Entity): Promise<Catalogue> {
249
+ const catalogue = await openCatalogue(storage);
250
+ const bytes = await writeEntity(storage, entity);
251
+
252
+ const { body: _body, bytes: _bytes, ...row } = entity;
253
+ const rows = [...catalogue.entities.filter((one) => one.id !== entity.id), row];
254
+
255
+ await writeCatalogue(
256
+ storage,
257
+ rows,
258
+ fingerprintAfter(catalogue.listing, { key: entityKey(entity.id), size: bytes }),
259
+ entity.updatedAt,
260
+ );
261
+
262
+ return catalogue;
263
+ }
264
+
265
+ /** Remove one entity and leave the index describing what is left. */
266
+ export async function forgetEntity(
267
+ storage: BlobStore,
268
+ catalogue: Catalogue,
269
+ id: string,
270
+ now: string,
271
+ ): Promise<void> {
272
+ await storage.delete(entityKey(id));
273
+ await writeCatalogue(
274
+ storage,
275
+ catalogue.entities.filter((one) => one.id !== id),
276
+ fingerprintAfter(catalogue.listing, { key: entityKey(id), deleted: true }),
277
+ now,
278
+ );
279
+ }