@lanes-sh/link 0.6.11 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -45
- package/instructions/skills/lanes-link/SKILL.md +25 -0
- package/package.json +3 -2
- package/src/audit/conformance.ts +1 -1
- package/src/cli/argv.ts +14 -1
- package/src/cli/commands/connect/acquire.ts +100 -0
- package/src/cli/commands/connect/custom/index.ts +1 -1
- package/src/cli/commands/connect/declare.ts +19 -0
- package/src/cli/commands/connect/index.ts +54 -56
- package/src/cli/commands/connect/requirements.ts +23 -1
- package/src/cli/commands/connect/setup.ts +1 -1
- package/src/cli/commands/connect/variables.ts +220 -0
- package/src/cli/commands/knowledge/index.ts +22 -4
- package/src/cli/commands/knowledge/migrate.ts +61 -43
- package/src/cli/commands/operate/tools.ts +1 -1
- package/src/cli/commands/owner/entities.ts +244 -0
- package/src/cli/commands/owner/shared.ts +14 -0
- package/src/cli/commands/owner/vault.ts +2 -2
- package/src/cli/commands/owner.ts +13 -3
- package/src/cli/commands/setup.ts +12 -0
- package/src/cli/config-edit.ts +16 -6
- package/src/cli/config-repair.ts +22 -3
- package/src/cli/dispatch-owner.ts +30 -1
- package/src/cli/main.ts +2 -1
- package/src/cli/runtime/open.ts +14 -7
- package/src/cli/runtime/registry.ts +6 -1
- package/src/cli/selection.ts +11 -0
- package/src/cli/usage.ts +13 -2
- package/src/connectivity/auth/strategy/index.ts +1 -1
- package/src/connectivity/capability.ts +1 -1
- package/src/connectivity/context.ts +1 -1
- package/src/connectivity/index.ts +1 -1
- package/src/connectivity/mail/attachments.ts +42 -73
- package/src/connectivity/mail/content-type.ts +71 -0
- package/src/connectivity/mail/index.ts +1 -1
- package/src/connectivity/manifest/provider.ts +50 -0
- package/src/connectivity/manifest/variables.ts +173 -0
- package/src/connectivity/transports/factory.ts +74 -1
- package/src/connectivity/transports/http/index.ts +8 -2
- package/src/connectivity/transports/http/keys.ts +83 -0
- package/src/connectivity/transports/imap/attachment.ts +53 -10
- package/src/connectivity/transports/imap/capabilities.ts +28 -0
- package/src/connectivity/transports/imap/download.ts +111 -0
- package/src/connectivity/transports/imap/index.ts +13 -0
- package/src/connectivity/transports/imap/operations.ts +1 -0
- package/src/deployments/adapters/s3.ts +1 -1
- package/src/deployments/gcp/provision.ts +1 -1
- package/src/deployments/knowledge.ts +44 -18
- package/src/deployments/prepare.ts +1 -1
- package/src/deployments/serving.ts +1 -1
- package/src/dispatch/dispatch.ts +17 -10
- package/src/dispatch/staging.ts +76 -1
- package/src/profile/knowledge.ts +19 -11
- package/src/profile/schema.ts +2 -2
- package/src/providers/README.md +189 -0
- package/src/providers/airtable/index.ts +10 -0
- package/src/providers/algolia/index.ts +10 -0
- package/src/providers/amplitude/index.ts +10 -0
- package/src/providers/apify/index.ts +10 -0
- package/src/providers/asana/index.ts +10 -0
- package/src/providers/assets/provider.ts +11 -4
- package/src/providers/atlassian/index.ts +19 -0
- package/src/providers/attio/index.ts +10 -0
- package/src/providers/betterstack/index.ts +10 -0
- package/src/providers/box/index.ts +64 -0
- package/src/providers/brightdata/index.ts +10 -0
- package/src/providers/buildkite/index.ts +10 -0
- package/src/providers/bunq/index.ts +2 -2
- package/src/providers/calendly/index.ts +10 -0
- package/src/providers/canva/index.ts +10 -0
- package/src/providers/circleci/index.ts +10 -0
- package/src/providers/clickup/index.ts +10 -0
- package/src/providers/close/index.ts +10 -0
- package/src/providers/cloudflare_bindings/index.ts +14 -0
- package/src/providers/cloudflare_observability/index.ts +10 -0
- package/src/providers/contentful/index.ts +10 -0
- package/src/providers/custom/load.ts +1 -1
- package/src/providers/datadog/index.ts +10 -0
- package/src/providers/discord/index.ts +1 -1
- package/src/providers/discord/specs/discord.v10.json +0 -88
- package/src/providers/discord/specs/vendor.ts +1 -1
- package/src/providers/dropbox/index.ts +10 -0
- package/src/providers/entities/catalogue.ts +366 -0
- package/src/providers/entities/find.ts +279 -0
- package/src/providers/entities/provider.ts +338 -0
- package/src/providers/entities/render.ts +142 -0
- package/src/providers/entities/store.ts +276 -0
- package/src/providers/entities/writes.ts +279 -0
- package/src/providers/example/provider.ts +1 -1
- package/src/providers/expensify/index.ts +10 -0
- package/src/providers/fastmail/calendar/index.ts +21 -0
- package/src/providers/fastmail/contacts/index.ts +17 -0
- package/src/providers/fastmail/index.ts +4 -0
- package/src/providers/fastmail/mail/index.ts +38 -0
- package/src/providers/fastmail/shared/setup.ts +52 -0
- package/src/providers/figma/index.ts +10 -0
- package/src/providers/fireflies/index.ts +10 -0
- package/src/providers/flagsmith/index.ts +10 -0
- package/src/providers/gamma/index.ts +10 -0
- package/src/providers/github/index.ts +2 -2
- package/src/providers/google/gmail/attachment.ts +16 -1
- package/src/providers/google/gmail-imap/index.ts +7 -2
- package/src/providers/google/shared/oauth.ts +1 -1
- package/src/providers/google/shared/service-account.ts +1 -1
- package/src/providers/google/shared/setup.ts +1 -1
- package/src/providers/google/specs/calendar.v3.json +0 -32
- package/src/providers/google/specs/docs.v1.json +0 -76
- package/src/providers/google/specs/drive.v3.json +0 -32
- package/src/providers/google/specs/gmail.v1.json +0 -76
- package/src/providers/google/specs/people.v1.json +0 -77
- package/src/providers/google/specs/sheets.v4.json +0 -76
- package/src/providers/google/specs/tasks.v1.json +0 -76
- package/src/providers/grafana/index.ts +10 -0
- package/src/providers/heroku/index.ts +10 -0
- package/src/providers/hubspot/index.ts +75 -0
- package/src/providers/hygraph/index.ts +10 -0
- package/src/providers/icloud/mail/index.ts +5 -0
- package/src/providers/icloud/shared/setup.ts +1 -1
- package/src/providers/index.ts +255 -0
- package/src/providers/insightly/index.ts +10 -0
- package/src/providers/jam/index.ts +10 -0
- package/src/providers/klaviyo/index.ts +10 -0
- package/src/providers/mailbox/index.ts +87 -0
- package/src/providers/memory/provider.ts +3 -8
- package/src/providers/mercury/index.ts +10 -0
- package/src/providers/microsoft/calendar/index.ts +49 -0
- package/src/providers/microsoft/contacts/index.ts +50 -0
- package/src/providers/microsoft/drive/index.ts +62 -0
- package/src/providers/microsoft/index.ts +12 -0
- package/src/providers/microsoft/mail/index.ts +76 -0
- package/src/providers/microsoft/shared/oauth.ts +58 -0
- package/src/providers/microsoft/shared/scopes.ts +56 -0
- package/src/providers/microsoft/shared/setup.ts +45 -0
- package/src/providers/microsoft/specs/microsoft-todo.v1.json +755 -0
- package/src/providers/microsoft/specs/onedrive.v1.json +993 -0
- package/src/providers/microsoft/specs/outlook-calendar.v1.json +1743 -0
- package/src/providers/microsoft/specs/outlook-contacts.v1.json +430 -0
- package/src/providers/microsoft/specs/outlook-mail.v1.json +901 -0
- package/src/providers/microsoft/specs/vendor.ts +232 -0
- package/src/providers/microsoft/todo/index.ts +54 -0
- package/src/providers/miro/index.ts +10 -0
- package/src/providers/mixpanel/index.ts +10 -0
- package/src/providers/monday/index.ts +10 -0
- package/src/providers/mux/index.ts +10 -0
- package/src/providers/navan/index.ts +10 -0
- package/src/providers/neon/index.ts +10 -0
- package/src/providers/netlify/index.ts +10 -0
- package/src/providers/nextcloud/calendar/index.ts +28 -0
- package/src/providers/nextcloud/contacts/index.ts +18 -0
- package/src/providers/nextcloud/index.ts +3 -0
- package/src/providers/nextcloud/shared/setup.ts +59 -0
- package/src/providers/owner.ts +15 -3
- package/src/providers/paddle/index.ts +10 -0
- package/src/providers/paypal/index.ts +10 -0
- package/src/providers/posthog/index.ts +10 -0
- package/src/providers/prisma/index.ts +10 -0
- package/src/providers/ramp/index.ts +10 -0
- package/src/providers/recurly/index.ts +10 -0
- package/src/providers/reddit/index.ts +1 -1
- package/src/providers/remote/index.ts +10 -0
- package/src/providers/render/index.ts +35 -0
- package/src/providers/replicate/index.ts +10 -0
- package/src/providers/resend/index.ts +10 -0
- package/src/providers/riverside/index.ts +10 -0
- package/src/providers/rootly/index.ts +10 -0
- package/src/providers/rudderstack/index.ts +10 -0
- package/src/providers/salesloft/index.ts +10 -0
- package/src/providers/sanity/index.ts +10 -0
- package/src/providers/scopes.ts +2 -0
- package/src/providers/sentry/index.ts +10 -0
- package/src/providers/setup/plan.ts +11 -0
- package/src/providers/setup/provider.ts +12 -0
- package/src/providers/shared/openapi.ts +70 -0
- package/src/providers/shared/slug.ts +28 -0
- package/src/providers/shared/vendor-operations.ts +43 -1
- package/src/providers/shared/vendor-spec.ts +98 -17
- package/src/providers/shortcut/index.ts +10 -0
- package/src/providers/slack/index.ts +2 -2
- package/src/providers/square/index.ts +10 -0
- package/src/providers/storyblok/index.ts +10 -0
- package/src/providers/stripe/index.ts +10 -0
- package/src/providers/supabase/index.ts +10 -0
- package/src/providers/tasks/store.ts +2 -7
- package/src/providers/tavily/index.ts +10 -0
- package/src/providers/todoist/index.ts +10 -0
- package/src/providers/untested.ts +105 -0
- package/src/providers/vault/provider.ts +1 -1
- package/src/providers/vercel/index.ts +10 -0
- package/src/providers/vimeo/index.ts +10 -0
- package/src/providers/webflow/index.ts +10 -0
- package/src/providers/whimsical/index.ts +10 -0
- package/src/providers/wix/index.ts +10 -0
- package/src/providers/workable/index.ts +10 -0
- package/src/providers/yahoo_mail/index.ts +73 -0
- package/src/providers/zapier/index.ts +10 -0
- package/src/providers/zoho_mail/index.ts +79 -0
- package/src/secrets/document.ts +1 -1
- package/src/server/attachments.ts +129 -5
- package/src/server/index.ts +3 -3
- package/src/server/mcp/instructions.ts +88 -18
- 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,338 @@
|
|
|
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
|
+
// An empty directory and a query that matched nothing are different
|
|
211
|
+
// answers and a caller does something different with each. Told
|
|
212
|
+
// "matches no criteria" it learns neither: that is what `describe({})`
|
|
213
|
+
// produces when a bare listing finds an empty store, and it reads like
|
|
214
|
+
// a parser error rather than an empty one.
|
|
215
|
+
const text =
|
|
216
|
+
matches.scanned === 0
|
|
217
|
+
? `No entities are declared on ${context.connection.key} yet. ` +
|
|
218
|
+
'`entities.write` declares one. Until then there is nothing here to address ' +
|
|
219
|
+
'anyone by, so ask rather than using an address from somewhere else.'
|
|
220
|
+
: `Nothing on ${context.connection.key} matches ${describe(criteria)}. ` +
|
|
221
|
+
'Do not use an address that is not here — `entities.write` declares a new one, ' +
|
|
222
|
+
'or ask the owner.';
|
|
223
|
+
|
|
224
|
+
return { content: [{ type: 'text', text }] };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (matches.candidates.length > 1) {
|
|
228
|
+
return {
|
|
229
|
+
content: [
|
|
230
|
+
{ type: 'text', text: renderCandidates(matches, criteria, context.connection.key) },
|
|
231
|
+
...matches.candidates.map((one) => ({
|
|
232
|
+
type: 'resource_link' as const,
|
|
233
|
+
uri: `entities://entity/${encodeURIComponent(one.entity.id)}`,
|
|
234
|
+
name: one.entity.name,
|
|
235
|
+
})),
|
|
236
|
+
],
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const found = matches.candidates[0]!.entity;
|
|
241
|
+
const file = await readEntity(context.storage, found.id);
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
content: [
|
|
245
|
+
{
|
|
246
|
+
type: 'resource_link' as const,
|
|
247
|
+
uri: `entities://entity/${encodeURIComponent(found.id)}`,
|
|
248
|
+
name: found.name,
|
|
249
|
+
},
|
|
250
|
+
{ type: 'text', text: renderEntity(found, catalogue, file?.body ?? '') },
|
|
251
|
+
],
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
{
|
|
257
|
+
kind: 'tool',
|
|
258
|
+
name: 'get',
|
|
259
|
+
title: 'Read one entity',
|
|
260
|
+
description:
|
|
261
|
+
'Return one entity by id, with its relationships and everything pointing at it. The resource ' +
|
|
262
|
+
'entities://entity/{id} is the same content; this exists for clients that do not read resources.',
|
|
263
|
+
inputSchema: z.object({ id: z.string().min(1).describe('Entity id') }),
|
|
264
|
+
redact: keepKeys('id'),
|
|
265
|
+
async handler({ id }, context) {
|
|
266
|
+
const entity = await readEntity(context.storage, id);
|
|
267
|
+
if (entity === null) {
|
|
268
|
+
return {
|
|
269
|
+
content: [{ type: 'text', text: `No entity "${id}" on ${context.connection.key}.` }],
|
|
270
|
+
isError: true,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const catalogue = await openCatalogue(context.storage);
|
|
275
|
+
return { content: [{ type: 'text', text: renderEntity(entity, catalogue, entity.body) }] };
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
|
|
279
|
+
...writeCapabilities,
|
|
280
|
+
],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Re-check a single match against its own file before an agent acts on it.
|
|
285
|
+
*
|
|
286
|
+
* The index's fingerprint stamps the *entity files*, so an index edited on its
|
|
287
|
+
* own — by hand, or corrupted in a way that still parses — passes it and is
|
|
288
|
+
* served. That is the accepted cost of keeping the index beside the documents,
|
|
289
|
+
* and this is where it is paid back: on the one path where an answer is about
|
|
290
|
+
* to be used to address something, the file is opened and compared. One extra
|
|
291
|
+
* read, which `get` would have cost anyway.
|
|
292
|
+
*
|
|
293
|
+
* On any disagreement the whole catalogue is rebuilt and the search re-run,
|
|
294
|
+
* rather than the one row being patched. Patching would answer with an entity
|
|
295
|
+
* that no longer matches what was asked for — the index may have been the only
|
|
296
|
+
* reason it matched — and "here is your one result" is exactly the wrong thing
|
|
297
|
+
* to say then. Re-running can legitimately return none, or several; both are
|
|
298
|
+
* better answers than a confident wrong one.
|
|
299
|
+
*
|
|
300
|
+
* Not done when there are several candidates: nothing is being acted on yet,
|
|
301
|
+
* and `get` reads the file.
|
|
302
|
+
*/
|
|
303
|
+
async function reconcile(
|
|
304
|
+
storage: BlobStore,
|
|
305
|
+
catalogue: Catalogue,
|
|
306
|
+
candidate: CatalogueEntity,
|
|
307
|
+
): Promise<Catalogue | null> {
|
|
308
|
+
const file = await readEntity(storage, candidate.id);
|
|
309
|
+
if (file !== null && canonical(strip(file)) === canonical(candidate)) return null;
|
|
310
|
+
|
|
311
|
+
return rebuildCatalogue(storage);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function strip(entity: Entity): CatalogueEntity {
|
|
315
|
+
const { body: _body, bytes: _bytes, ...row } = entity;
|
|
316
|
+
return row;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Everything about a row that could have decided the match, as one string.
|
|
321
|
+
*
|
|
322
|
+
* Written out field by field rather than `JSON.stringify(row)` so the
|
|
323
|
+
* comparison does not depend on two parsers inserting keys in the same order —
|
|
324
|
+
* a difference that would read as a disagreement and rebuild on every call.
|
|
325
|
+
*/
|
|
326
|
+
function canonical(row: CatalogueEntity): string {
|
|
327
|
+
return JSON.stringify([
|
|
328
|
+
row.type,
|
|
329
|
+
row.name,
|
|
330
|
+
[...row.aliases],
|
|
331
|
+
[...row.tags],
|
|
332
|
+
row.attributes.map((one) => [one.kind, one.value, one.note ?? '']),
|
|
333
|
+
row.relations.map((one) => [one.predicate, one.entity, one.note ?? '']),
|
|
334
|
+
row.updatedAt,
|
|
335
|
+
]);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export default entitiesProvider;
|