@lanes-sh/link 0.6.10 → 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.
- package/.gcloudignore +25 -15
- 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 +10 -0
- 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 +334 -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,10 @@
|
|
|
1
|
+
import { defineProvider } from '#connectivity';
|
|
2
|
+
|
|
3
|
+
/** Close registers us at connect time — nothing for an operator to set up. */
|
|
4
|
+
export const close = defineProvider({
|
|
5
|
+
id: 'close',
|
|
6
|
+
name: 'Close',
|
|
7
|
+
description: 'Leads, contacts, opportunities, and activities in the CRM, via Close\'s official MCP server.',
|
|
8
|
+
connector: { kind: 'mcp', endpoint: 'https://mcp.close.com/mcp' },
|
|
9
|
+
auth: { kind: 'oauth', registration: 'dynamic' },
|
|
10
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineProvider } from '#connectivity';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cloudflare runs one MCP server per product area rather than one for the
|
|
5
|
+
* account, so this is a sibling of `cloudflare_observability` and not a
|
|
6
|
+
* duplicate of it — bindings and logs are separate grants.
|
|
7
|
+
*/
|
|
8
|
+
export const cloudflareBindings = defineProvider({
|
|
9
|
+
id: 'cloudflare_bindings',
|
|
10
|
+
name: 'Cloudflare Bindings',
|
|
11
|
+
description: 'Workers KV, R2, D1, and Durable Objects, via Cloudflare\'s official MCP server.',
|
|
12
|
+
connector: { kind: 'mcp', endpoint: 'https://bindings.mcp.cloudflare.com/mcp' },
|
|
13
|
+
auth: { kind: 'oauth', registration: 'dynamic' },
|
|
14
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineProvider } from '#connectivity';
|
|
2
|
+
|
|
3
|
+
/** Cloudflare registers us at connect time — nothing for an operator to set up. */
|
|
4
|
+
export const cloudflareObservability = defineProvider({
|
|
5
|
+
id: 'cloudflare_observability',
|
|
6
|
+
name: 'Cloudflare Observability',
|
|
7
|
+
description: 'Workers logs, analytics, and traces, via Cloudflare\'s official MCP server.',
|
|
8
|
+
connector: { kind: 'mcp', endpoint: 'https://observability.mcp.cloudflare.com/mcp' },
|
|
9
|
+
auth: { kind: 'oauth', registration: 'dynamic' },
|
|
10
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineProvider } from '#connectivity';
|
|
2
|
+
|
|
3
|
+
/** Contentful registers us at connect time — nothing for an operator to set up. */
|
|
4
|
+
export const contentful = defineProvider({
|
|
5
|
+
id: 'contentful',
|
|
6
|
+
name: 'Contentful',
|
|
7
|
+
description: 'Entries, assets, content types, and spaces, via Contentful\'s official MCP server.',
|
|
8
|
+
connector: { kind: 'mcp', endpoint: 'https://mcp.contentful.com/mcp' },
|
|
9
|
+
auth: { kind: 'oauth', registration: 'dynamic' },
|
|
10
|
+
});
|
|
@@ -86,7 +86,7 @@ function describe(workspaceRoot: string, key: string): string {
|
|
|
86
86
|
/**
|
|
87
87
|
* Make a relative `openapi` path mean what the operator meant.
|
|
88
88
|
*
|
|
89
|
-
* `docs/
|
|
89
|
+
* `https://lanes.sh/docs/link/creating-a-provider` tells people to write `openapi: ./mything.json`,
|
|
90
90
|
* and until now that resolved against the *process* working directory — so it
|
|
91
91
|
* worked when you happened to run `lanes` from the right folder and failed
|
|
92
92
|
* with a confusing ENOENT everywhere else. The built-ins never noticed because
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineProvider } from '#connectivity';
|
|
2
|
+
|
|
3
|
+
/** Datadog registers us at connect time — nothing for an operator to set up. */
|
|
4
|
+
export const datadog = defineProvider({
|
|
5
|
+
id: 'datadog',
|
|
6
|
+
name: 'Datadog',
|
|
7
|
+
description: 'Metrics, logs, monitors, incidents, and dashboards, via Datadog\'s official MCP server.',
|
|
8
|
+
connector: { kind: 'mcp', endpoint: 'https://mcp.datadoghq.com/api/unstable/mcp-server/mcp' },
|
|
9
|
+
auth: { kind: 'oauth', registration: 'dynamic' },
|
|
10
|
+
});
|
|
@@ -87,7 +87,7 @@ export const discord = defineProvider({
|
|
|
87
87
|
'their developer portal, copy its bot token, and invite it to the servers you want ' +
|
|
88
88
|
'reachable. Posts will carry an APP badge; the name and avatar on them are yours to set. ' +
|
|
89
89
|
'You are asked for the token once.',
|
|
90
|
-
docs: 'docs/
|
|
90
|
+
docs: 'https://lanes.sh/docs/link/discord',
|
|
91
91
|
docs_url: 'https://discord.com/developers/applications',
|
|
92
92
|
steps: [
|
|
93
93
|
'Open https://discord.com/developers/applications and choose "New Application". Name it whatever you want the posts to read as — this is the name people will see.',
|
|
@@ -2240,94 +2240,6 @@
|
|
|
2240
2240
|
}
|
|
2241
2241
|
}
|
|
2242
2242
|
}
|
|
2243
|
-
},
|
|
2244
|
-
"responses": {
|
|
2245
|
-
"ClientErrorResponse": {
|
|
2246
|
-
"description": "Client error response",
|
|
2247
|
-
"headers": {
|
|
2248
|
-
"X-RateLimit-Limit": {
|
|
2249
|
-
"$ref": "#/components/headers/X-RateLimit-Limit"
|
|
2250
|
-
},
|
|
2251
|
-
"X-RateLimit-Remaining": {
|
|
2252
|
-
"$ref": "#/components/headers/X-RateLimit-Remaining"
|
|
2253
|
-
},
|
|
2254
|
-
"X-RateLimit-Reset": {
|
|
2255
|
-
"$ref": "#/components/headers/X-RateLimit-Reset"
|
|
2256
|
-
},
|
|
2257
|
-
"X-RateLimit-Reset-After": {
|
|
2258
|
-
"$ref": "#/components/headers/X-RateLimit-Reset-After"
|
|
2259
|
-
},
|
|
2260
|
-
"X-RateLimit-Bucket": {
|
|
2261
|
-
"$ref": "#/components/headers/X-RateLimit-Bucket"
|
|
2262
|
-
}
|
|
2263
|
-
},
|
|
2264
|
-
"content": {
|
|
2265
|
-
"application/json": {
|
|
2266
|
-
"schema": {
|
|
2267
|
-
"$ref": "#/components/schemas/ErrorResponse"
|
|
2268
|
-
}
|
|
2269
|
-
}
|
|
2270
|
-
}
|
|
2271
|
-
},
|
|
2272
|
-
"ClientRatelimitedResponse": {
|
|
2273
|
-
"description": "Client ratelimited response",
|
|
2274
|
-
"headers": {
|
|
2275
|
-
"X-RateLimit-Limit": {
|
|
2276
|
-
"$ref": "#/components/headers/X-RateLimit-Limit"
|
|
2277
|
-
},
|
|
2278
|
-
"X-RateLimit-Remaining": {
|
|
2279
|
-
"$ref": "#/components/headers/X-RateLimit-Remaining"
|
|
2280
|
-
},
|
|
2281
|
-
"X-RateLimit-Reset": {
|
|
2282
|
-
"$ref": "#/components/headers/X-RateLimit-Reset"
|
|
2283
|
-
},
|
|
2284
|
-
"X-RateLimit-Reset-After": {
|
|
2285
|
-
"$ref": "#/components/headers/X-RateLimit-Reset-After"
|
|
2286
|
-
},
|
|
2287
|
-
"X-RateLimit-Bucket": {
|
|
2288
|
-
"$ref": "#/components/headers/X-RateLimit-Bucket"
|
|
2289
|
-
}
|
|
2290
|
-
},
|
|
2291
|
-
"content": {
|
|
2292
|
-
"application/json": {
|
|
2293
|
-
"schema": {
|
|
2294
|
-
"$ref": "#/components/schemas/RatelimitedResponse"
|
|
2295
|
-
}
|
|
2296
|
-
}
|
|
2297
|
-
}
|
|
2298
|
-
}
|
|
2299
|
-
},
|
|
2300
|
-
"headers": {
|
|
2301
|
-
"X-RateLimit-Limit": {
|
|
2302
|
-
"schema": {
|
|
2303
|
-
"type": "integer"
|
|
2304
|
-
},
|
|
2305
|
-
"description": "The maximum number of requests that can be made in the current ratelimit window"
|
|
2306
|
-
},
|
|
2307
|
-
"X-RateLimit-Remaining": {
|
|
2308
|
-
"schema": {
|
|
2309
|
-
"type": "integer"
|
|
2310
|
-
},
|
|
2311
|
-
"description": "The number of requests remaining in the current ratelimit window"
|
|
2312
|
-
},
|
|
2313
|
-
"X-RateLimit-Reset": {
|
|
2314
|
-
"schema": {
|
|
2315
|
-
"type": "number"
|
|
2316
|
-
},
|
|
2317
|
-
"description": "A unix timestamp in seconds at which the current ratelimit window resets"
|
|
2318
|
-
},
|
|
2319
|
-
"X-RateLimit-Reset-After": {
|
|
2320
|
-
"schema": {
|
|
2321
|
-
"type": "number"
|
|
2322
|
-
},
|
|
2323
|
-
"description": "The duration in seconds until the current ratelimit window resets"
|
|
2324
|
-
},
|
|
2325
|
-
"X-RateLimit-Bucket": {
|
|
2326
|
-
"schema": {
|
|
2327
|
-
"type": "string"
|
|
2328
|
-
},
|
|
2329
|
-
"description": "The bucket that the request belongs to"
|
|
2330
|
-
}
|
|
2331
2243
|
}
|
|
2332
2244
|
}
|
|
2333
2245
|
}
|
|
@@ -83,7 +83,7 @@ const WRITES = [
|
|
|
83
83
|
* announcement can read as the operator while an ordinary reply reads as the
|
|
84
84
|
* integration, from one bot token.
|
|
85
85
|
*
|
|
86
|
-
* The cost is stated in `docs/
|
|
86
|
+
* The cost is stated in `https://lanes.sh/docs/link/security`: `create_webhook` and
|
|
87
87
|
* `list_channel_webhooks` return the webhook's token in their response, and a
|
|
88
88
|
* webhook token is a standalone credential for posting to that channel.
|
|
89
89
|
*/
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineProvider } from '#connectivity';
|
|
2
|
+
|
|
3
|
+
/** Dropbox registers us at connect time — nothing for an operator to set up. */
|
|
4
|
+
export const dropbox = defineProvider({
|
|
5
|
+
id: 'dropbox',
|
|
6
|
+
name: 'Dropbox',
|
|
7
|
+
description: 'Files, folders, shared links, and file requests, via Dropbox\'s official MCP server.',
|
|
8
|
+
connector: { kind: 'mcp', endpoint: 'https://mcp.dropbox.com/mcp' },
|
|
9
|
+
auth: { kind: 'oauth', registration: 'dynamic' },
|
|
10
|
+
});
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { BlobMetadata, BlobStore } from '#connectivity';
|
|
3
|
+
import { allEntities, idFromKey, type Attribute, type Entity, type Relation } from './store.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The derived index, and the rules that keep it from becoming a second source
|
|
7
|
+
* of truth.
|
|
8
|
+
*
|
|
9
|
+
* `_index.json` sits beside the entity files and holds what a lookup needs, so
|
|
10
|
+
* a read that finds it valid opens no entity file at all. It is a **cache**:
|
|
11
|
+
* absent, truncated, wrong-version and fingerprint-mismatched are all one case
|
|
12
|
+
* here, and all four rebuild from the files without throwing. The precedent is
|
|
13
|
+
* `openRuntime`'s treatment of a corrupt discovery cache — never a reason to
|
|
14
|
+
* fail startup.
|
|
15
|
+
*
|
|
16
|
+
* ADR-014 removed an index from memory because it could disagree with the file
|
|
17
|
+
* it described. What makes this one different is the fingerprint: it is a stamp
|
|
18
|
+
* of the exact listing the index was built from, so a file edited in an editor
|
|
19
|
+
* or on GitHub invalidates it structurally rather than by anybody remembering
|
|
20
|
+
* to. The hole that remains — a hand-edited *index* whose fingerprint still
|
|
21
|
+
* matches untouched entity files — is closed on the path that matters by the
|
|
22
|
+
* confirm-on-read rule in `find.ts`, and is pinned by a test rather than
|
|
23
|
+
* pretended away.
|
|
24
|
+
*
|
|
25
|
+
* **Why the fingerprint is `key:size` and not `key:size:mtime`.**
|
|
26
|
+
* `skillFingerprint` includes mtime and is right to: it is a change detector
|
|
27
|
+
* for a two-second poll, where a false positive costs a reload. This is a
|
|
28
|
+
* validity stamp, where a false positive costs a full rebuild and, on a
|
|
29
|
+
* knowledge repository, a commit. The GitHub adapter reports the *branch tip*
|
|
30
|
+
* as `modifiedAt` for every file, so on the one backend where this index is
|
|
31
|
+
* worth the most, an mtime-bearing fingerprint would never match twice. Sizes
|
|
32
|
+
* are per-file on every adapter.
|
|
33
|
+
*
|
|
34
|
+
* That choice buys a second property the write path depends on: because size is
|
|
35
|
+
* known before a put, a writer can compute the fingerprint of the state it is
|
|
36
|
+
* *about to create* (`fingerprintAfter`) rather than listing the store again
|
|
37
|
+
* afterwards. No read-back, no second `list()`, no ordering hazard.
|
|
38
|
+
*
|
|
39
|
+
* **No read ever writes this file.** A read that finds it stale rebuilds in
|
|
40
|
+
* memory and serves the right answer. Only `write`, `link`, `forget` and
|
|
41
|
+
* `entities reindex` persist. So no read can move a branch tip, and therefore
|
|
42
|
+
* no read can invalidate the next read.
|
|
43
|
+
*
|
|
44
|
+
* **What it costs, at scale.** Steady state on a bucket is one `list()` and one
|
|
45
|
+
* GET, whatever the entity count — a listing carries key and size only, so no
|
|
46
|
+
* body is transferred on that path. A rebuild is one read per entity, bounded
|
|
47
|
+
* 16 at a time.
|
|
48
|
+
*
|
|
49
|
+
* entities list() requests index steady-state read rebuild
|
|
50
|
+
* 100 1 ~35 KB 2 requests 100 GETs
|
|
51
|
+
* 1,000 1-2 ~340 KB 3 requests 1,000 GETs
|
|
52
|
+
* 10,000 11 ~3.4 MB 12 requests 10,000 GETs
|
|
53
|
+
*
|
|
54
|
+
* A thousand is comfortably inside the design. Ten thousand is where it is the
|
|
55
|
+
* wrong design, and the answer then is not a larger index but a validity check
|
|
56
|
+
* that needs no listing — which needs an adapter reporting a per-object etag,
|
|
57
|
+
* and `BlobMetadata` carries none today. On a local directory none of this is
|
|
58
|
+
* worth anything measurable; it is carried for the deployed and repository
|
|
59
|
+
* paths.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
export const INDEX_KEY = '_index.json';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Bumping this invalidates every stored index without anyone remembering to.
|
|
66
|
+
*
|
|
67
|
+
* It is in the fingerprint's own prefix rather than checked separately, so a
|
|
68
|
+
* format change and a content change are one comparison.
|
|
69
|
+
*/
|
|
70
|
+
const INDEX_VERSION = 1;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An entity as the index holds it: everything that matches or distinguishes,
|
|
74
|
+
* and nothing that only renders.
|
|
75
|
+
*
|
|
76
|
+
* No body, and no body summary. A summary would be the largest field per row
|
|
77
|
+
* and nothing consults it — a single match reads the file anyway under
|
|
78
|
+
* confirm-on-read, and several matches render the fields that *differ*, not
|
|
79
|
+
* prose. Dropping it is about a third of the file for no loss.
|
|
80
|
+
*/
|
|
81
|
+
export type CatalogueEntity = Omit<Entity, 'body' | 'bytes'>;
|
|
82
|
+
|
|
83
|
+
/** An edge as the entity it points *at* sees it. Derived, never stored. */
|
|
84
|
+
export interface Backlink {
|
|
85
|
+
readonly from: string;
|
|
86
|
+
readonly predicate: string;
|
|
87
|
+
readonly note?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface Catalogue {
|
|
91
|
+
readonly entities: readonly CatalogueEntity[];
|
|
92
|
+
readonly byId: ReadonlyMap<string, CatalogueEntity>;
|
|
93
|
+
/** Reverse edges, derived in one pass. Keyed by the entity pointed at. */
|
|
94
|
+
readonly backlinks: ReadonlyMap<string, readonly Backlink[]>;
|
|
95
|
+
readonly fingerprint: string;
|
|
96
|
+
/**
|
|
97
|
+
* Whether this came from a valid index rather than from the files.
|
|
98
|
+
*
|
|
99
|
+
* `find` reads it to decide whether a single match needs confirming against
|
|
100
|
+
* its file: an answer rebuilt from the files is already confirmed.
|
|
101
|
+
*/
|
|
102
|
+
readonly fromIndex: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* The listing this was opened against.
|
|
105
|
+
*
|
|
106
|
+
* Carried so a write can compute `fingerprintAfter` without listing twice.
|
|
107
|
+
* It is the one piece of storage detail on this type and it is here rather
|
|
108
|
+
* than fetched again because the two calls could otherwise see different
|
|
109
|
+
* states.
|
|
110
|
+
*/
|
|
111
|
+
readonly listing: readonly BlobMetadata[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A stamp of exactly which entity files existed and how large each was.
|
|
116
|
+
*
|
|
117
|
+
* Filtered by `idFromKey`, which is what keeps `_index.json` out of its own
|
|
118
|
+
* fingerprint. Without that filter, writing the index would change the listing,
|
|
119
|
+
* which would invalidate the index the instant it was written.
|
|
120
|
+
*/
|
|
121
|
+
export function fingerprintOf(blobs: readonly BlobMetadata[]): string {
|
|
122
|
+
return hashLines(
|
|
123
|
+
blobs.flatMap((blob) => (idFromKey(blob.key) === null ? [] : [`${blob.key}:${blob.size}`])),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** What `fingerprintOf` will return after this put or delete lands. */
|
|
128
|
+
export function fingerprintAfter(
|
|
129
|
+
blobs: readonly BlobMetadata[],
|
|
130
|
+
change: { key: string; size: number } | { key: string; deleted: true },
|
|
131
|
+
): string {
|
|
132
|
+
const lines = blobs.flatMap((blob) =>
|
|
133
|
+
idFromKey(blob.key) === null || blob.key === change.key ? [] : [`${blob.key}:${blob.size}`],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
if (!('deleted' in change) && idFromKey(change.key) !== null) {
|
|
137
|
+
lines.push(`${change.key}:${change.size}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return hashLines(lines);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function hashLines(lines: readonly string[]): string {
|
|
144
|
+
return createHash('sha256')
|
|
145
|
+
.update(`entities/${INDEX_VERSION}\n${[...lines].sort().join('\n')}`)
|
|
146
|
+
.digest('hex');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The catalogue, from the index when it is valid and from the files when it is
|
|
151
|
+
* not.
|
|
152
|
+
*
|
|
153
|
+
* Never throws for a storage-shaped reason. The only thing that propagates is a
|
|
154
|
+
* failure to `list()` at all, which is the store being unreachable rather than
|
|
155
|
+
* this cache being wrong.
|
|
156
|
+
*/
|
|
157
|
+
export async function openCatalogue(storage: BlobStore): Promise<Catalogue> {
|
|
158
|
+
const listing = await storage.list();
|
|
159
|
+
const fingerprint = fingerprintOf(listing);
|
|
160
|
+
|
|
161
|
+
const indexed = await readIndex(storage, fingerprint);
|
|
162
|
+
if (indexed !== null) return assemble(indexed, fingerprint, true, listing);
|
|
163
|
+
|
|
164
|
+
const entities = (await allEntities(storage)).map(strip);
|
|
165
|
+
return assemble(entities, fingerprint, false, listing);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Rebuild from the files regardless of what the index says. */
|
|
169
|
+
export async function rebuildCatalogue(storage: BlobStore): Promise<Catalogue> {
|
|
170
|
+
const listing = await storage.list();
|
|
171
|
+
const entities = (await allEntities(storage)).map(strip);
|
|
172
|
+
|
|
173
|
+
return assemble(entities, fingerprintOf(listing), false, listing);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function writeCatalogue(
|
|
177
|
+
storage: BlobStore,
|
|
178
|
+
entities: readonly CatalogueEntity[],
|
|
179
|
+
fingerprint: string,
|
|
180
|
+
now: string,
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
await storage.put(INDEX_KEY, new TextEncoder().encode(serialiseIndex(entities, fingerprint, now)), {
|
|
183
|
+
contentType: 'application/json',
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Why a read had to rebuild, for `entities reindex` and a debug log line. */
|
|
188
|
+
export async function indexState(
|
|
189
|
+
storage: BlobStore,
|
|
190
|
+
): Promise<{ current: boolean; reason: string }> {
|
|
191
|
+
const fingerprint = fingerprintOf(await storage.list());
|
|
192
|
+
const bytes = await storage.get(INDEX_KEY);
|
|
193
|
+
|
|
194
|
+
if (bytes === null) return { current: false, reason: 'no index file' };
|
|
195
|
+
|
|
196
|
+
const parsed = parseIndex(bytes);
|
|
197
|
+
if (parsed === null) return { current: false, reason: 'the index file could not be read' };
|
|
198
|
+
if (parsed.fingerprint !== fingerprint) {
|
|
199
|
+
return { current: false, reason: 'the entity files changed since the index was built' };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return { current: true, reason: 'the index matches the entity files' };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function strip(entity: Entity): CatalogueEntity {
|
|
206
|
+
const { body: _body, bytes: _bytes, ...rest } = entity;
|
|
207
|
+
return rest;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* A catalogue over entities already in hand, deriving the backlinks.
|
|
212
|
+
*
|
|
213
|
+
* The same assembly both open paths use, exposed so `find` can be exercised
|
|
214
|
+
* against literal arrays with no store at all — which is the point of keeping
|
|
215
|
+
* matching pure.
|
|
216
|
+
*/
|
|
217
|
+
export function catalogueFrom(entities: readonly CatalogueEntity[]): Catalogue {
|
|
218
|
+
return assemble(entities, '', false, []);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function assemble(
|
|
222
|
+
entities: readonly CatalogueEntity[],
|
|
223
|
+
fingerprint: string,
|
|
224
|
+
fromIndex: boolean,
|
|
225
|
+
listing: readonly BlobMetadata[],
|
|
226
|
+
): Catalogue {
|
|
227
|
+
const byId = new Map(entities.map((one) => [one.id, one]));
|
|
228
|
+
const backlinks = new Map<string, Backlink[]>();
|
|
229
|
+
|
|
230
|
+
// One pass over the forward edges already loaded. Reverse edges are never
|
|
231
|
+
// stored: a second copy inside the same file could disagree with the first
|
|
232
|
+
// half of it, and this derivation is shared by both open paths so there is
|
|
233
|
+
// one implementation of "who points at this".
|
|
234
|
+
for (const entity of entities) {
|
|
235
|
+
for (const relation of entity.relations) {
|
|
236
|
+
const into = backlinks.get(relation.entity) ?? [];
|
|
237
|
+
into.push({
|
|
238
|
+
from: entity.id,
|
|
239
|
+
predicate: relation.predicate,
|
|
240
|
+
...(relation.note === undefined ? {} : { note: relation.note }),
|
|
241
|
+
});
|
|
242
|
+
backlinks.set(relation.entity, into);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { entities, byId, backlinks, fingerprint, fromIndex, listing };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The stored rows, or null for any reason at all. Every reason rebuilds. */
|
|
250
|
+
async function readIndex(
|
|
251
|
+
storage: BlobStore,
|
|
252
|
+
fingerprint: string,
|
|
253
|
+
): Promise<CatalogueEntity[] | null> {
|
|
254
|
+
const bytes = await storage.get(INDEX_KEY);
|
|
255
|
+
if (bytes === null) return null;
|
|
256
|
+
|
|
257
|
+
const parsed = parseIndex(bytes);
|
|
258
|
+
if (parsed === null || parsed.fingerprint !== fingerprint) return null;
|
|
259
|
+
|
|
260
|
+
return parsed.entities;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function parseIndex(
|
|
264
|
+
bytes: Uint8Array,
|
|
265
|
+
): { fingerprint: string; entities: CatalogueEntity[] } | null {
|
|
266
|
+
let raw: unknown;
|
|
267
|
+
try {
|
|
268
|
+
raw = JSON.parse(new TextDecoder().decode(bytes));
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
274
|
+
const document = raw as Record<string, unknown>;
|
|
275
|
+
|
|
276
|
+
if (document['v'] !== INDEX_VERSION) return null;
|
|
277
|
+
if (typeof document['fingerprint'] !== 'string') return null;
|
|
278
|
+
if (!Array.isArray(document['entities'])) return null;
|
|
279
|
+
|
|
280
|
+
const entities: CatalogueEntity[] = [];
|
|
281
|
+
for (const row of document['entities']) {
|
|
282
|
+
const entity = parseRow(row);
|
|
283
|
+
// One unreadable row is the whole file: unlike an entity document, this is
|
|
284
|
+
// machine-written and a partial one means a partial answer with nothing
|
|
285
|
+
// saying so. Rebuilding is cheap and correct.
|
|
286
|
+
if (entity === null) return null;
|
|
287
|
+
entities.push(entity);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { fingerprint: document['fingerprint'], entities };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function parseRow(raw: unknown): CatalogueEntity | null {
|
|
294
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
295
|
+
const row = raw as Record<string, unknown>;
|
|
296
|
+
|
|
297
|
+
const id = row['id'];
|
|
298
|
+
const updatedAt = row['updated_at'];
|
|
299
|
+
if (typeof id !== 'string' || typeof updatedAt !== 'string') return null;
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
id,
|
|
303
|
+
type: typeof row['type'] === 'string' ? row['type'] : '',
|
|
304
|
+
name: typeof row['name'] === 'string' ? row['name'] : id,
|
|
305
|
+
aliases: strings(row['aliases']),
|
|
306
|
+
tags: strings(row['tags']),
|
|
307
|
+
attributes: rows<Attribute>(row['attributes'], ['kind', 'value']),
|
|
308
|
+
relations: rows<Relation>(row['relations'], ['predicate', 'entity']),
|
|
309
|
+
updatedAt,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function strings(raw: unknown): string[] {
|
|
314
|
+
return Array.isArray(raw) ? raw.filter((one): one is string => typeof one === 'string') : [];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function rows<T>(raw: unknown, required: readonly [string, string]): T[] {
|
|
318
|
+
if (!Array.isArray(raw)) return [];
|
|
319
|
+
|
|
320
|
+
return raw.flatMap((item) => {
|
|
321
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) return [];
|
|
322
|
+
const one = item as Record<string, unknown>;
|
|
323
|
+
if (required.some((key) => typeof one[key] !== 'string')) return [];
|
|
324
|
+
|
|
325
|
+
const note = one['note'];
|
|
326
|
+
return [
|
|
327
|
+
{
|
|
328
|
+
[required[0]]: one[required[0]],
|
|
329
|
+
[required[1]]: one[required[1]],
|
|
330
|
+
...(typeof note === 'string' ? { note } : {}),
|
|
331
|
+
} as T,
|
|
332
|
+
];
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Sorted by id, fixed key order, two-space indent.
|
|
338
|
+
*
|
|
339
|
+
* Determinism is not cosmetic: this lands in a git diff on the owner's own
|
|
340
|
+
* notes repository, and a file that reorders itself makes a one-attribute
|
|
341
|
+
* change unreviewable.
|
|
342
|
+
*/
|
|
343
|
+
function serialiseIndex(
|
|
344
|
+
entities: readonly CatalogueEntity[],
|
|
345
|
+
fingerprint: string,
|
|
346
|
+
now: string,
|
|
347
|
+
): string {
|
|
348
|
+
const rows = [...entities]
|
|
349
|
+
.sort((a, b) => a.id.localeCompare(b.id))
|
|
350
|
+
.map((entity) => ({
|
|
351
|
+
id: entity.id,
|
|
352
|
+
...(entity.type ? { type: entity.type } : {}),
|
|
353
|
+
name: entity.name,
|
|
354
|
+
...(entity.aliases.length > 0 ? { aliases: [...entity.aliases] } : {}),
|
|
355
|
+
...(entity.tags.length > 0 ? { tags: [...entity.tags] } : {}),
|
|
356
|
+
...(entity.attributes.length > 0
|
|
357
|
+
? { attributes: entity.attributes.map((one) => ({ ...one })) }
|
|
358
|
+
: {}),
|
|
359
|
+
...(entity.relations.length > 0
|
|
360
|
+
? { relations: entity.relations.map((one) => ({ ...one })) }
|
|
361
|
+
: {}),
|
|
362
|
+
updated_at: entity.updatedAt,
|
|
363
|
+
}));
|
|
364
|
+
|
|
365
|
+
return `${JSON.stringify({ v: INDEX_VERSION, fingerprint, built_at: now, entities: rows }, null, 2)}\n`;
|
|
366
|
+
}
|