@escalay/kit 0.0.0-stage → 1.8.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/LICENSE +21 -0
- package/README.md +142 -2
- package/cli.mjs +438 -0
- package/docs/STANDARD.md +342 -0
- package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
- package/docs/decisions/0002-node-and-workers.md +45 -0
- package/docs/releasing.md +97 -0
- package/package.json +59 -3
- package/registry.json +548 -0
- package/src/a2a/README.md +25 -0
- package/src/a2a/index.ts +262 -0
- package/src/boards/README.md +35 -0
- package/src/boards/handlers.ts +87 -0
- package/src/boards/index.ts +64 -0
- package/src/boards/refs.ts +23 -0
- package/src/boards/repo.ts +50 -0
- package/src/boards/routes.ts +83 -0
- package/src/boards/schema.ts +28 -0
- package/src/boards/tools.ts +76 -0
- package/src/browser/README.md +31 -0
- package/src/browser/boards-client.ts +74 -0
- package/src/browser/boards-react.ts +73 -0
- package/src/browser/search-client.ts +258 -0
- package/src/browser/search-react.ts +116 -0
- package/src/browser/vector-search-block.tsx +94 -0
- package/src/core/README.md +37 -0
- package/src/core/api.ts +239 -0
- package/src/core/auth.ts +147 -0
- package/src/core/cache.ts +53 -0
- package/src/core/discovery.ts +315 -0
- package/src/core/guards.ts +109 -0
- package/src/core/hal.ts +63 -0
- package/src/core/kv.ts +29 -0
- package/src/core/problem.ts +124 -0
- package/src/db/README.md +39 -0
- package/src/db/guards.ts +61 -0
- package/src/db/index.ts +122 -0
- package/src/guide/README.md +38 -0
- package/src/guide/guide.ts +152 -0
- package/src/guide/reference.ts +204 -0
- package/src/index.ts +15 -0
- package/src/mcp/README.md +34 -0
- package/src/mcp/index.ts +251 -0
- package/src/oauth/README.md +29 -0
- package/src/oauth/index.ts +439 -0
- package/src/oauth/schema.ts +7 -0
- package/src/oauth/store.ts +34 -0
- package/src/pagination/README.md +31 -0
- package/src/pagination/index.ts +65 -0
- package/src/testing/README.md +31 -0
- package/src/testing/index.ts +162 -0
- package/src/vector-search/README.md +27 -0
- package/src/vector-search/index.ts +367 -0
- package/src/vectors/README.md +30 -0
- package/src/vectors/index.ts +326 -0
- package/src/web/README.md +21 -0
- package/src/web/index.ts +100 -0
- package/templates/app/AGENTS.md +41 -0
- package/templates/app/CLAUDE.md +3 -0
- package/templates/app/Dockerfile +12 -0
- package/templates/app/container/entrypoint.sh +7 -0
- package/templates/app/gitignore +4 -0
- package/templates/app/guide/llms.txt +11 -0
- package/templates/app/pnpm-workspace.yaml +5 -0
- package/templates/app/scripts/guide.ts +8 -0
- package/templates/app/src/app.ts +39 -0
- package/templates/app/src/features/index.ts +30 -0
- package/templates/app/src/server.ts +14 -0
- package/templates/app/test/api.test.ts +34 -0
- package/templates/app/tsconfig.json +16 -0
- package/templates/feature/feature.test.ts +19 -0
- package/templates/feature/handlers.ts +43 -0
- package/templates/feature/index.ts +34 -0
- package/templates/feature/repo.ts +24 -0
- package/templates/feature/routes.ts +43 -0
- package/templates/feature/schema.ts +14 -0
- package/templates/feature/tools.ts +46 -0
- package/templates/npm-release/release-please-config.json +60 -0
- package/templates/npm-release/release-please-manifest.json +3 -0
- package/templates/runtime-cloudflare/dev.vars.example +3 -0
- package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
- package/templates/runtime-cloudflare/src/worker.ts +19 -0
- package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
- package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
- package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
- package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
- package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
- package/templates/runtime-cloudflare/vitest.config.ts +23 -0
- package/templates/runtime-cloudflare/wrangler.jsonc +16 -0
package/src/db/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# db
|
|
2
|
+
|
|
3
|
+
The app's database through Drizzle: one `openDb` per app, and every feature's `repo.ts` queries through it. The same
|
|
4
|
+
queries run on a local SQLite file today and on Turso or D1 later; only how the database is opened changes.
|
|
5
|
+
|
|
6
|
+
Needs: `drizzle-orm` (0.45). On Node ≥ 22.16, `node:sqlite` (with `setReturnArrays`); on Cloudflare Workers, a D1
|
|
7
|
+
binding. No native module. The module imports on both: `node:sqlite` loads only when a path asks for it.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { migrate, openDb, testDb } from './kit/index.js'
|
|
11
|
+
|
|
12
|
+
const db = await openDb(`file:${join(env.STATE_DIR, 'state.sqlite')}`) // a path, ':memory:', or an open DatabaseSync
|
|
13
|
+
await migrate(db, 'notes', NOTES_MIGRATIONS) // each feature brings its own steps
|
|
14
|
+
const rows = await db.select().from(notes).where(eq(notes.id, id)) // in repo.ts only
|
|
15
|
+
|
|
16
|
+
const db = await openDb(drizzle(createClient({ url, authToken }))) // Turso: any async Drizzle SQLite database
|
|
17
|
+
const db = await openDb(drizzle(env.DB)) // Cloudflare D1 (drizzle-orm/d1), in worker.ts
|
|
18
|
+
const db = await testDb() // tests: empty, in memory
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
| Export | What |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `openDb(source)` | a path or `file:` URL or `:memory:` or `DatabaseSync` → Drizzle over node:sqlite (the `sqlite-proxy` driver, rows as arrays so same-named columns in a join stay apart); a Drizzle database → used as it is; any other URL → an error naming what to do |
|
|
24
|
+
| `migrate(db, feature, steps)` | applies the steps not yet applied, in order, each in a transaction, recorded in `kit_migrations (feature, step)`; on D1 (which refuses `begin`) each step and its record go as one atomic batch |
|
|
25
|
+
| `dbGuardStore(db)` | the guards' state (`core/guards.ts`) in table `kit_guards`, each check one atomic statement: rate windows and Idempotency-Key answers shared by every instance. On Workers pass it to `createAgentApi({ guards: { store } })` and every `rateLimit` |
|
|
26
|
+
| `testDb()` | Node: an empty in-memory database; workerd: the test Worker's `DB` binding (storage is kept apart per test file, not per test) |
|
|
27
|
+
| `Db` | the type every repo takes |
|
|
28
|
+
|
|
29
|
+
## Rules
|
|
30
|
+
|
|
31
|
+
- **Only a feature's `repo.ts` queries the database.** Handlers call the repo, never `db`. That keeps a move off SQLite to
|
|
32
|
+
the repos, and SQLite-only SQL (FTS5, `json_each`) inside a repo function.
|
|
33
|
+
- **A migration step never changes once it has run anywhere.** A change is a new step at the end. Step 1 of a table
|
|
34
|
+
that already exists is its exact `create table if not exists …`, so live data is adopted.
|
|
35
|
+
- One step may hold several statements separated by `;` (not triggers: give a trigger its own step).
|
|
36
|
+
- **Rows have one shape on every database only through the query builder.** Raw `db.all(sql…)` returns arrays on
|
|
37
|
+
node:sqlite and objects on D1: a repo uses `db.select()`, or names its columns and reads both shapes.
|
|
38
|
+
- Read-only catalogs (a built store, JSON in memory) don't go through `db`; they stay behind their own store module.
|
|
39
|
+
- Decision record: `docs/decisions/0001-features-drizzle-scaffold.md`.
|
package/src/db/guards.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guards' state (`core/guards.ts`) in the app's database, so every instance shares it: on Workers each isolate has
|
|
3
|
+
* its own memory, and a rate limit or an idempotency key kept there holds for that isolate alone. Each method is one
|
|
4
|
+
* atomic statement (an upsert with `returning`, or an insert that does nothing on conflict), on node:sqlite and D1
|
|
5
|
+
* alike.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const guards = await dbGuardStore(db)
|
|
9
|
+
* const app = createAgentApi({ …, guards: { store: guards } })
|
|
10
|
+
*/
|
|
11
|
+
import { and, eq, lte, sql } from 'drizzle-orm'
|
|
12
|
+
import { integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
13
|
+
import type { GuardStore, StoredAnswer } from '../core/guards.js'
|
|
14
|
+
import { migrate, type Db } from './index.js'
|
|
15
|
+
|
|
16
|
+
/** One row per rate window (`rate`) or idempotency key (`key`); `value` is empty while a key's request runs. */
|
|
17
|
+
export const kitGuards = sqliteTable('kit_guards', {
|
|
18
|
+
kind: text('kind').notNull(),
|
|
19
|
+
id: text('id').notNull(),
|
|
20
|
+
start: integer('start').notNull(),
|
|
21
|
+
used: integer('used').notNull(),
|
|
22
|
+
value: text('value').notNull(),
|
|
23
|
+
expiresAt: integer('expires_at').notNull(),
|
|
24
|
+
}, (table) => [primaryKey({ columns: [table.kind, table.id] })])
|
|
25
|
+
|
|
26
|
+
export const GUARD_MIGRATIONS = ['create table if not exists kit_guards (kind text not null, id text not null, start integer not null, used integer not null, value text not null, expires_at integer not null, primary key (kind, id))'] as const
|
|
27
|
+
|
|
28
|
+
/** Makes the guards' table if needed and drops expired rows (epoch ms throughout). */
|
|
29
|
+
export const dbGuardStore = async (db: Db): Promise<GuardStore> => {
|
|
30
|
+
await migrate(db, 'guards', GUARD_MIGRATIONS)
|
|
31
|
+
await db.delete(kitGuards).where(lte(kitGuards.expiresAt, Date.now()))
|
|
32
|
+
const key = (id: string) => and(eq(kitGuards.kind, 'key'), eq(kitGuards.id, id))
|
|
33
|
+
return {
|
|
34
|
+
count: async (id, windowMs, now) => {
|
|
35
|
+
// SQLite evaluates every `set` against the row as it was, so `start` and `used` restart together
|
|
36
|
+
const stale = sql`${kitGuards.start} <= ${now - windowMs}`
|
|
37
|
+
const [window] = await db.insert(kitGuards).values({ kind: 'rate', id, start: now, used: 1, value: '', expiresAt: now + windowMs })
|
|
38
|
+
.onConflictDoUpdate({
|
|
39
|
+
target: [kitGuards.kind, kitGuards.id],
|
|
40
|
+
set: {
|
|
41
|
+
used: sql`case when ${stale} then 1 else ${kitGuards.used} + 1 end`,
|
|
42
|
+
start: sql`case when ${stale} then ${now} else ${kitGuards.start} end`,
|
|
43
|
+
expiresAt: sql`case when ${stale} then ${now + windowMs} else ${kitGuards.expiresAt} end`,
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
.returning({ start: kitGuards.start, used: kitGuards.used })
|
|
47
|
+
return window!
|
|
48
|
+
},
|
|
49
|
+
claim: async (id, lockMs, now) => {
|
|
50
|
+
// An expired answer, or a lock whose request died, no longer holds the key
|
|
51
|
+
await db.delete(kitGuards).where(and(key(id), lte(kitGuards.expiresAt, now)))
|
|
52
|
+
const taken = await db.insert(kitGuards).values({ kind: 'key', id, start: now, used: 0, value: '', expiresAt: now + lockMs }).onConflictDoNothing().returning({ id: kitGuards.id })
|
|
53
|
+
if (taken.length) return null
|
|
54
|
+
const [row] = await db.select({ value: kitGuards.value }).from(kitGuards).where(key(id))
|
|
55
|
+
if (!row) return null
|
|
56
|
+
return row.value ? (JSON.parse(row.value) as StoredAnswer) : 'running'
|
|
57
|
+
},
|
|
58
|
+
save: async (id, answer, expiresAt) => void (await db.update(kitGuards).set({ value: JSON.stringify(answer), expiresAt }).where(key(id))),
|
|
59
|
+
release: async (id) => void (await db.delete(kitGuards).where(and(key(id), eq(kitGuards.value, '')))),
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/db/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's database, through Drizzle: one handle every feature's repo queries, opened from where the data lives, so
|
|
3
|
+
* the same queries run on a local SQLite file today and on Turso or D1 later. Only repos (`<feature>/repo.ts`) touch
|
|
4
|
+
* it; handlers never do.
|
|
5
|
+
* 'file:./state.sqlite', a plain path, ':memory:', or an open `DatabaseSync` → node:sqlite (built in, no native
|
|
6
|
+
* module), through Drizzle's proxy driver
|
|
7
|
+
* any async Drizzle SQLite database → used as it is: Turso / libSQL
|
|
8
|
+
* `drizzle(createClient({ url, authToken }))`
|
|
9
|
+
* (`drizzle-orm/libsql`), D1 `drizzle(env.DB)`
|
|
10
|
+
* The module imports on Workers: node:sqlite loads only when a path asks for it. Raw `db.all(sql…)` rows are arrays
|
|
11
|
+
* on node:sqlite and objects on D1, so repos read rows through the query builder or name their columns.
|
|
12
|
+
* Each feature brings its tables and its migrations: `migrate(db, 'boards', BOARDS_MIGRATIONS)` applies the steps it
|
|
13
|
+
* hasn't yet, in order, and records them in `kit_migrations`. A step never changes once shipped; a change is a new step.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* const db = await openDb(`file:${join(env.STATE_DIR, 'state.sqlite')}`)
|
|
17
|
+
* registerBoards(app, { db, … }) // migrates its own tables
|
|
18
|
+
* const rows = await db.select().from(notes).where(eq(notes.owner, id))
|
|
19
|
+
*/
|
|
20
|
+
import type { DatabaseSync, SQLInputValue } from 'node:sqlite'
|
|
21
|
+
import { is, sql } from 'drizzle-orm'
|
|
22
|
+
import { DrizzleD1Database } from 'drizzle-orm/d1'
|
|
23
|
+
import type { BaseSQLiteDatabase } from 'drizzle-orm/sqlite-core'
|
|
24
|
+
import { drizzle } from 'drizzle-orm/sqlite-proxy'
|
|
25
|
+
|
|
26
|
+
/** Any async Drizzle SQLite database: node:sqlite through the proxy, libSQL, D1. */
|
|
27
|
+
export type Db = BaseSQLiteDatabase<'async', unknown>
|
|
28
|
+
|
|
29
|
+
/** Where the data lives: a URL or path, an open node:sqlite database, or a Drizzle database already made. */
|
|
30
|
+
export type DbSource = string | DatabaseSync | Db
|
|
31
|
+
|
|
32
|
+
// node:sqlite is loaded only when a source needs it, so the module imports on Workers (where it is a stub)
|
|
33
|
+
const isDb = (source: DbSource): source is Db => typeof source === 'object' && 'select' in source
|
|
34
|
+
const isDatabaseSync = (source: DbSource): source is DatabaseSync => typeof source === 'object' && 'prepare' in source && 'exec' in source
|
|
35
|
+
|
|
36
|
+
/** What migrate needs of a D1 binding (typed here so the module needs no Workers types). */
|
|
37
|
+
type D1Statement = { bind: (...values: unknown[]) => D1Statement }
|
|
38
|
+
type D1Client = { prepare: (query: string) => D1Statement; batch: (statements: D1Statement[]) => Promise<unknown> }
|
|
39
|
+
|
|
40
|
+
/** Drizzle over node:sqlite: rows come back as arrays, so columns with the same name in a join stay apart. */
|
|
41
|
+
export const nodeSqlite = (sqlite: DatabaseSync): Db => {
|
|
42
|
+
sqlite.exec('pragma foreign_keys = on')
|
|
43
|
+
return drizzle(async (query, params, method) => {
|
|
44
|
+
const statement = sqlite.prepare(query)
|
|
45
|
+
const values = params as SQLInputValue[]
|
|
46
|
+
if (method === 'run') {
|
|
47
|
+
statement.run(...values)
|
|
48
|
+
return { rows: [] }
|
|
49
|
+
}
|
|
50
|
+
statement.setReturnArrays(true)
|
|
51
|
+
return method === 'get' ? { rows: statement.get(...values) as unknown as unknown[] } : { rows: statement.all(...values) as unknown as unknown[] }
|
|
52
|
+
}) as unknown as Db
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Opens the database a source names (see the module comment). */
|
|
56
|
+
export const openDb = async (source: DbSource): Promise<Db> => {
|
|
57
|
+
if (isDb(source)) return source
|
|
58
|
+
if (isDatabaseSync(source)) return nodeSqlite(source)
|
|
59
|
+
if (/^[a-z]+:\/\//.test(source)) throw new Error(`openDb: ${source.split('//')[0]}// is not a file; open it with its Drizzle driver and pass the database in (Turso: drizzle-orm/libsql, D1: drizzle(env.DB) from drizzle-orm/d1)`)
|
|
60
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
61
|
+
return nodeSqlite(new DatabaseSync(source === ':memory:' ? source : source.replace(/^file:/, '')))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A database for tests: empty and in memory on Node; inside workerd (the Workers test project) the `DB` D1 binding,
|
|
66
|
+
* whose storage is kept apart per test file, not per test, so tests there use their own ids.
|
|
67
|
+
*/
|
|
68
|
+
export const testDb = async () => {
|
|
69
|
+
if (globalThis.navigator?.userAgent !== 'Cloudflare-Workers') return openDb(':memory:')
|
|
70
|
+
const runtime = 'cloudflare:workers'
|
|
71
|
+
const { env } = (await import(runtime)) as { env: { DB?: unknown } }
|
|
72
|
+
if (!env.DB) throw new Error('testDb: the Workers test project has no D1 binding named DB; add one to its wrangler config')
|
|
73
|
+
const { drizzle } = await import('drizzle-orm/d1')
|
|
74
|
+
return openDb(drizzle(env.DB as never) as unknown as Db)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A step's statements: split on every `;` outside quotes (a prepared statement runs only the first of several). */
|
|
78
|
+
const statementsOf = (step: string) => {
|
|
79
|
+
const statements: string[] = []
|
|
80
|
+
let current = ''
|
|
81
|
+
let quote: string | null = null
|
|
82
|
+
for (const char of step) {
|
|
83
|
+
if (quote) quote = char === quote ? null : quote
|
|
84
|
+
else if (char === "'" || char === '"' || char === '`') quote = char
|
|
85
|
+
else if (char === ';') {
|
|
86
|
+
statements.push(current)
|
|
87
|
+
current = ''
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
current += char
|
|
91
|
+
}
|
|
92
|
+
return [...statements, current].map((statement) => statement.trim()).filter(Boolean)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Applies a feature's migration steps it hasn't applied yet, in order, each in a transaction with its record in
|
|
97
|
+
* `kit_migrations (feature, step)`. Steps are SQL; one step may hold several statements separated by `;` (not
|
|
98
|
+
* triggers: their bodies hold `;`, so give each its own step and no other statement). On D1, which refuses `begin`,
|
|
99
|
+
* a step and its record go as one batch: D1 runs a batch as a single transaction.
|
|
100
|
+
*/
|
|
101
|
+
export const migrate = async (db: Db, feature: string, steps: readonly string[]) => {
|
|
102
|
+
await db.run(sql`create table if not exists kit_migrations (feature text not null, step integer not null, applied_at text not null, primary key (feature, step))`)
|
|
103
|
+
const done = await db.all<{ step: number }>(sql`select step from kit_migrations where feature = ${feature}`)
|
|
104
|
+
const applied = new Set(done.map((row) => Number(Array.isArray(row) ? row[0] : row.step)))
|
|
105
|
+
for (const [index, step] of steps.entries()) {
|
|
106
|
+
if (applied.has(index + 1)) continue
|
|
107
|
+
const appliedAt = new Date().toISOString()
|
|
108
|
+
if (is(db, DrizzleD1Database)) {
|
|
109
|
+
const d1 = (db as unknown as { $client: D1Client }).$client
|
|
110
|
+
const record = d1.prepare('insert into kit_migrations (feature, step, applied_at) values (?, ?, ?)').bind(feature, index + 1, appliedAt)
|
|
111
|
+
await d1.batch([...statementsOf(step).map((statement) => d1.prepare(statement)), record])
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
await db.transaction(async (tx) => {
|
|
115
|
+
for (const statement of statementsOf(step)) await tx.run(sql.raw(statement))
|
|
116
|
+
await tx.run(sql`insert into kit_migrations (feature, step, applied_at) values (${feature}, ${index + 1}, ${appliedAt})`)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// An app's generated kit index re-exports each module's own index.ts, so the module's other parts go out from here
|
|
122
|
+
export { dbGuardStore, kitGuards, GUARD_MIGRATIONS } from './guards.js'
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# guide
|
|
2
|
+
|
|
3
|
+
The agent guide: `/llms.txt`, `/llms-full.txt`, `/agents/*.md` and `/skills/*` from Markdown templates, filled per
|
|
4
|
+
request. Everything the code knows (endpoints, tools, playbooks, how to get unstuck) is generated from the OpenAPI
|
|
5
|
+
document and the MCP tools, so the guide can't drift from the API.
|
|
6
|
+
|
|
7
|
+
Needs: `core`.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { guideFiles, serveGuide } from './kit/index.js'
|
|
11
|
+
|
|
12
|
+
serveGuide(app, { dir: GUIDE_DIR, vars: () => ({ counts: { notes: store.notes.length } }), full: ['llms.txt', 'agents/*', 'skills/*'] })
|
|
13
|
+
serveGuide(app, { files: GUIDE_FILES, vars, full }) // Workers can't read a folder: `kit guide` writes src/guide.gen.ts
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```md
|
|
17
|
+
# Notes
|
|
18
|
+
> {{counts.notes}} notes. Start with GET /api/search?q=…
|
|
19
|
+
|
|
20
|
+
## Endpoints
|
|
21
|
+
{{api.endpoints}}
|
|
22
|
+
|
|
23
|
+
## MCP tools ({{mcp.count}})
|
|
24
|
+
{{mcp.tools}}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| File | What |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `guide.ts` | `serveGuide` (from `dir`, or `files` on Workers), `guideFiles(dir)`, `guideModuleSource(files)` (the text `kit guide` writes; an app's test compares its `guide.gen.ts` to it), `fillTemplate` |
|
|
30
|
+
| `reference.ts` | the generated parts: `{{api.endpoints}}`, `{{api.operations}}`, `{{api.unblock}}`, `{{mcp.tools}}`, `{{mcp.count}}`, `{{mcp.resources}}`, `{{skills.<name>.playbooks}}` |
|
|
31
|
+
|
|
32
|
+
## Rules
|
|
33
|
+
|
|
34
|
+
- Write only what the code can't know: concepts, trust and measured accuracy, real-id examples, playbook steps and
|
|
35
|
+
judgement. Never hand-write a list of endpoints or tools.
|
|
36
|
+
- Layout: `guide/llms.txt`, `guide/agents/{concepts,endpoints,trust,mcp}.md`, `guide/skills/<name>/SKILL.md` +
|
|
37
|
+
`references/*.md` (served as MCP prompts too).
|
|
38
|
+
- Test it: `guideRoutes` and `guideGaps` (the testing module) return `[]`. Standard: `docs/STANDARD.md` §10.
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent guide, served from a folder of Markdown templates so it reads and diffs like docs:
|
|
3
|
+
* llms.txt the index (llmstxt.org): what this is, real example calls, links to the rest
|
|
4
|
+
* agents/*.md concepts, endpoints, trust and limits, MCP
|
|
5
|
+
* skills/SKILL.md, *.md a skill folder: a router plus one playbook per task (also the MCP prompts)
|
|
6
|
+
* `/llms-full.txt` is assembled from them in the order given, so it never drifts from its parts. `{{origin}}` and any
|
|
7
|
+
* `{{dotted.path}}` from `vars()` are filled per request; a placeholder with no value is left visible and reported by
|
|
8
|
+
* `fillTemplate`, which the tests check. The parts that must match the code are generated, never written by hand:
|
|
9
|
+
* `{{api.endpoints}}`, `{{api.tag.<slug>}}`, `{{api.unblock}}`, `{{mcp.tools}}`, `{{skills.<name>.playbooks}}`
|
|
10
|
+
* (see `reference.ts`). Each skill folder is also published for discovery: `/.well-known/agent-skills/index.json`
|
|
11
|
+
* and a deterministic archive per skill with references, or its SKILL.md alone.
|
|
12
|
+
*
|
|
13
|
+
* On Node the templates are read from `dir`; on Workers, which can't read a folder, from `files`: the module
|
|
14
|
+
* `kit guide` writes from the folder (`GUIDE_FILES`), checked current by a test with `guideModuleSource`.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* serveGuide(app, { dir: join(APP_DIR, 'guide'), vars: () => ({ snapshot: store.snapshot, counts: store.counts }),
|
|
18
|
+
* full: ['llms.txt', 'agents/concepts.md', 'agents/endpoints.md', 'agents/trust.md', 'agents/mcp.md', 'skills/*'] })
|
|
19
|
+
* serveGuide(app, { files: GUIDE_FILES, vars, full }) // Workers: import { GUIDE_FILES } from './guide.gen.js'
|
|
20
|
+
*/
|
|
21
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs'
|
|
22
|
+
import { join, relative } from 'node:path'
|
|
23
|
+
import type { Context, Hono } from 'hono'
|
|
24
|
+
import { CACHE } from '../core/cache.js'
|
|
25
|
+
import { frontmatterOf, recordMounted, mountedOf, sendPublic, skillsIndex, tarGz, WELL_KNOWN, type SkillArtifact } from '../core/discovery.js'
|
|
26
|
+
import { originOf } from '../core/hal.js'
|
|
27
|
+
import { problemResponse } from '../core/problem.js'
|
|
28
|
+
import { generatedVars, type OpenApiDoc } from './reference.js'
|
|
29
|
+
|
|
30
|
+
export const MARKDOWN = 'text/markdown; charset=utf-8'
|
|
31
|
+
|
|
32
|
+
/** `{{counts.recipes}}` → the value at that path; numbers get thousands separators. Unknown names stay as they are. */
|
|
33
|
+
export const fillTemplate = (template: string, vars: Record<string, unknown>) => {
|
|
34
|
+
const missing = new Set<string>()
|
|
35
|
+
const text = template.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (whole, path: string) => {
|
|
36
|
+
const value = path.split('.').reduce<unknown>((at, key) => (at && typeof at === 'object' ? (at as Record<string, unknown>)[key] : undefined), vars)
|
|
37
|
+
if (value === undefined || value === null) {
|
|
38
|
+
missing.add(path)
|
|
39
|
+
return whole
|
|
40
|
+
}
|
|
41
|
+
return typeof value === 'number' ? value.toLocaleString('en-US') : String(value)
|
|
42
|
+
})
|
|
43
|
+
return { text, missing: [...missing] }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Every Markdown/text file under `dir`, by its path relative to it (`skills/plan-a-week.md`). */
|
|
47
|
+
export const guideFiles = (dir: string) => {
|
|
48
|
+
const files = new Map<string, string>()
|
|
49
|
+
const walk = (folder: string) => {
|
|
50
|
+
for (const entry of readdirSync(folder, { withFileTypes: true })) {
|
|
51
|
+
const path = join(folder, entry.name)
|
|
52
|
+
if (entry.isDirectory()) walk(path)
|
|
53
|
+
else if (/\.(md|txt)$/.test(entry.name)) files.set(relative(dir, path), readFileSync(path, 'utf8'))
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (existsSync(dir)) walk(dir)
|
|
57
|
+
return files
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The guide's files as a TypeScript module (`GUIDE_FILES`), for runtimes that can't read a folder. Deterministic:
|
|
62
|
+
* paths sorted, `/` separators. `kit guide` writes the same text, and an app's test compares the two.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* expect(readFileSync('src/guide.gen.ts', 'utf8')).toBe(guideModuleSource(guideFiles(GUIDE_DIR)))
|
|
66
|
+
*/
|
|
67
|
+
export const guideModuleSource = (files: Map<string, string>) =>
|
|
68
|
+
`// Generated by \`kit guide\` from the guide folder: edit the templates there, then run it again.\n` +
|
|
69
|
+
`export const GUIDE_FILES: Record<string, string> = {\n` +
|
|
70
|
+
[...files.entries()].map(([path, text]) => [path.split('\\').join('/'), text] as const).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([path, text]) => ` ${JSON.stringify(path)}: ${JSON.stringify(text)},\n`).join('') +
|
|
71
|
+
`}\n`
|
|
72
|
+
|
|
73
|
+
/** The parts of llms-full.txt in order; `folder/*` expands to that folder's files, `SKILL.md` first. */
|
|
74
|
+
export const fullOrder = (files: Map<string, string>, order: string[]) =>
|
|
75
|
+
order.flatMap((entry) =>
|
|
76
|
+
entry.endsWith('/*')
|
|
77
|
+
? [...files.keys()].filter((name) => name.startsWith(entry.slice(0, -1))).sort((a, b) => Number(b.endsWith('SKILL.md')) - Number(a.endsWith('SKILL.md')) || a.localeCompare(b))
|
|
78
|
+
: files.has(entry) ? [entry] : [],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
/** The guide as served: its template files and a renderer that fills them for one origin. */
|
|
82
|
+
export type Guide = { files: Map<string, string>; render: (origin: string, name: string) => Promise<string> }
|
|
83
|
+
|
|
84
|
+
type Requester = { request: (input: string, init?: RequestInit) => Response | Promise<Response> }
|
|
85
|
+
|
|
86
|
+
export type GuideSource = { dir: string; files?: never } | { files: Map<string, string> | Record<string, string>; dir?: never }
|
|
87
|
+
|
|
88
|
+
export const serveGuide = (app: Hono<any, any, any> & Requester, { vars, full, ...source }: GuideSource & { vars: () => Record<string, unknown>; full: string[] }): Guide => {
|
|
89
|
+
const files = source.files ? (source.files instanceof Map ? source.files : new Map(Object.entries(source.files))) : guideFiles(source.dir!)
|
|
90
|
+
// The OpenAPI document only changes with a deploy: fetch it once per origin, in-process, with the caller's own
|
|
91
|
+
// credentials (a private service answers the spec only to them); only a good answer is kept
|
|
92
|
+
const docs = new Map<string, Promise<OpenApiDoc>>()
|
|
93
|
+
const docOf = (origin: string, credentials: Record<string, string>) => {
|
|
94
|
+
if (!docs.has(origin)) {
|
|
95
|
+
const url = new URL(origin)
|
|
96
|
+
const fetched = Promise.resolve(app.request(WELL_KNOWN.openapi, { headers: { 'x-forwarded-proto': url.protocol.slice(0, -1), 'x-forwarded-host': url.host, ...credentials } })).then((response) => {
|
|
97
|
+
if (!response.ok) throw new Error(`the spec answered ${response.status}`)
|
|
98
|
+
return response.json() as Promise<OpenApiDoc>
|
|
99
|
+
})
|
|
100
|
+
docs.set(origin, fetched)
|
|
101
|
+
fetched.catch(() => docs.delete(origin))
|
|
102
|
+
}
|
|
103
|
+
return docs.get(origin)!
|
|
104
|
+
}
|
|
105
|
+
const credentialsOf = (c: Context) => Object.fromEntries((['authorization', 'cookie', 'cf-access-jwt-assertion'] as const).flatMap((name) => (c.req.header(name) ? [[name, c.req.header(name)!]] : [])))
|
|
106
|
+
const render = async (origin: string, name: string, credentials: Record<string, string> = {}) => {
|
|
107
|
+
const generated = generatedVars({ origin, doc: await docOf(origin, credentials), tools: mountedOf(app).mcp?.tools, resources: mountedOf(app).mcp?.resources, files })
|
|
108
|
+
return fillTemplate(files.get(name)!, { origin, ...generated, ...vars() }).text
|
|
109
|
+
}
|
|
110
|
+
const send = (c: Context, body: string) => c.body(body, 200, { 'content-type': MARKDOWN, 'cache-control': CACHE.docs })
|
|
111
|
+
|
|
112
|
+
app.get('/llms.txt', async (c) => (files.has('llms.txt') ? send(c, await render(originOf(c), 'llms.txt', credentialsOf(c))) : problemResponse(c, 'not_found')))
|
|
113
|
+
app.get('/llms-full.txt', async (c) => {
|
|
114
|
+
const parts = await Promise.all(fullOrder(files, full).map(async (name) => `<!-- ${name} -->\n\n${(await render(originOf(c), name, credentialsOf(c))).trim()}\n`))
|
|
115
|
+
return send(c, parts.join('\n---\n\n'))
|
|
116
|
+
})
|
|
117
|
+
for (const folder of ['agents', 'skills']) {
|
|
118
|
+
app.get(`/${folder}/*`, async (c) => {
|
|
119
|
+
const name = c.req.path.slice(1)
|
|
120
|
+
return files.has(name) ? send(c, await render(originOf(c), name, credentialsOf(c))) : problemResponse(c, 'not_found', { detail: `No ${name}. The index is /llms.txt.` })
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Skills for discovery: a skill with references ships as one archive, a lone SKILL.md as itself
|
|
125
|
+
const skillNames = [...files.keys()].flatMap((path) => path.match(/^skills\/([^/]+)\/SKILL\.md$/)?.[1] ?? [])
|
|
126
|
+
const artifacts = async (origin: string, credentials: Record<string, string> = {}): Promise<SkillArtifact[]> =>
|
|
127
|
+
Promise.all(
|
|
128
|
+
skillNames.map(async (name) => {
|
|
129
|
+
const own = [...files.keys()].filter((path) => path.startsWith(`skills/${name}/`))
|
|
130
|
+
const skillMd = await render(origin, `skills/${name}/SKILL.md`, credentials)
|
|
131
|
+
const { description = '' } = frontmatterOf(skillMd)
|
|
132
|
+
if (own.length === 1) return { name, description, type: 'skill-md' as const, url: `/skills/${name}/SKILL.md`, bytes: skillMd, mediaType: MARKDOWN }
|
|
133
|
+
const entries = await Promise.all(own.map(async (path): Promise<[string, string]> => [path.slice(`skills/${name}/`.length), await render(origin, path, credentials)]))
|
|
134
|
+
return { name, description, type: 'archive' as const, url: `/.well-known/agent-skills/${name}.tar.gz`, bytes: tarGz(entries), mediaType: 'application/gzip' }
|
|
135
|
+
}),
|
|
136
|
+
)
|
|
137
|
+
// The catalogs read artifacts synchronously: keep the last set built per origin
|
|
138
|
+
const built = new Map<string, SkillArtifact[]>()
|
|
139
|
+
const artifactsNow = async (origin: string, credentials: Record<string, string> = {}) => {
|
|
140
|
+
const list = await artifacts(origin, credentials)
|
|
141
|
+
built.set(origin, list)
|
|
142
|
+
return list
|
|
143
|
+
}
|
|
144
|
+
recordMounted(app, { skills: (origin) => built.get(origin) ?? [], refreshSkills: artifactsNow })
|
|
145
|
+
|
|
146
|
+
app.get(WELL_KNOWN.skillsIndex, async (c) => sendPublic(c, JSON.stringify(skillsIndex(originOf(c), await artifactsNow(originOf(c), credentialsOf(c))), null, 2), 'application/json'))
|
|
147
|
+
app.get('/.well-known/agent-skills/:file', async (c) => {
|
|
148
|
+
const skill = (await artifactsNow(originOf(c), credentialsOf(c))).find((one) => one.url === c.req.path)
|
|
149
|
+
return skill ? sendPublic(c, skill.bytes, skill.mediaType) : problemResponse(c, 'not_found', { detail: `No ${c.req.path}. The index is ${WELL_KNOWN.skillsIndex}.` })
|
|
150
|
+
})
|
|
151
|
+
return { files, render }
|
|
152
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The parts of the agent guide that must never drift from the code, written from the code: every operation from the
|
|
3
|
+
* OpenAPI document, every MCP tool with the route it calls, every playbook in the skill folder, and how to get
|
|
4
|
+
* unstuck. Guide templates place them with `{{api.endpoints}}`, `{{api.tag.<slug>}}`, `{{api.unblock}}`,
|
|
5
|
+
* `{{api.operations}}`, `{{mcp.tools}}`, `{{mcp.count}}`, `{{mcp.resources}}` and `{{skills.<name>.playbooks}}`; `serveGuide` fills them.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* endpointsMarkdown(doc) // ### Search … - `GET /api/v1/search/recipes?q=…` — Recipes for a description. …
|
|
9
|
+
* toolRoute(tool, doc) // { method: 'GET', path: '/api/v1/search/recipes', operationId: 'searchRecipes' }
|
|
10
|
+
*/
|
|
11
|
+
import type { ResourceLike, ToolLike } from '../core/discovery.js'
|
|
12
|
+
|
|
13
|
+
type Schema = { type?: string | string[]; enum?: unknown[]; default?: unknown; example?: unknown; description?: string; items?: Schema; minimum?: number; maximum?: number; anyOf?: Schema[]; $ref?: string; properties?: Record<string, Schema>; additionalProperties?: unknown }
|
|
14
|
+
type Parameter = { name: string; in: string; required?: boolean; description?: string; example?: unknown; schema?: Schema }
|
|
15
|
+
type Operation = { operationId?: string; summary?: string; description?: string; tags?: string[]; parameters?: Parameter[]; requestBody?: { content?: Record<string, { schema?: unknown }> }; responses?: Record<string, { content?: Record<string, { schema?: Schema }> }>; security?: unknown[] }
|
|
16
|
+
export type OpenApiDoc = { tags?: { name: string; description?: string }[]; paths?: Record<string, Record<string, Operation>>; components?: { schemas?: Record<string, Schema & { properties?: Record<string, Schema>; required?: string[] }> } }
|
|
17
|
+
|
|
18
|
+
const METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const
|
|
19
|
+
const slugOf = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
20
|
+
|
|
21
|
+
/** The first sentence: what an index line needs, the rest is one call away. */
|
|
22
|
+
export const firstSentence = (text = '') => (text.replace(/\s+/g, ' ').trim().match(/^.*?[.!?](?=\s|$)/)?.[0] ?? text.trim())
|
|
23
|
+
|
|
24
|
+
const typeOf = (schema: Schema = {}): string => {
|
|
25
|
+
if (schema.enum) return schema.enum.map(String).join('|')
|
|
26
|
+
// `one | one[]` is a repeatable parameter: say it once
|
|
27
|
+
const single = schema.anyOf?.find((one) => one.type !== 'array')
|
|
28
|
+
const many = schema.anyOf?.find((one) => one.type === 'array')
|
|
29
|
+
if (single && many && schema.anyOf!.length === 2 && typeOf(single) === typeOf(many.items)) return `${typeOf(single)} (repeatable)`
|
|
30
|
+
if (schema.anyOf) return [...new Set(schema.anyOf.map(typeOf))].join('|')
|
|
31
|
+
if (schema.type === 'array') return `${typeOf(schema.items)}[]`
|
|
32
|
+
return Array.isArray(schema.type) ? schema.type.join('|') : (schema.type ?? 'any')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `q*` (required), `by=meaning|look (meaning)`: a parameter as an agent scans it. */
|
|
36
|
+
const paramLine = (parameter: Parameter) => {
|
|
37
|
+
const schema = parameter.schema ?? {}
|
|
38
|
+
const shape = schema.enum || schema.anyOf?.some((one) => one.enum) ? `=${typeOf(schema)}` : ''
|
|
39
|
+
const fallback = schema.default !== undefined ? ` (default ${JSON.stringify(schema.default)})` : ''
|
|
40
|
+
return `\`${parameter.name}${parameter.required ? '*' : ''}${shape}\`${fallback}`
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A call with the documented examples filled in, so the line itself is a working request. */
|
|
44
|
+
const exampleCall = (method: string, path: string, operation: Operation) => {
|
|
45
|
+
const params = operation.parameters ?? []
|
|
46
|
+
const filled = path.replace(/\{(\w+)\}/g, (whole, name: string) => {
|
|
47
|
+
const example = params.find((parameter) => parameter.in === 'path' && parameter.name === name)?.example ?? params.find((parameter) => parameter.name === name)?.schema?.example
|
|
48
|
+
return example === undefined ? whole : String(example)
|
|
49
|
+
})
|
|
50
|
+
const query = params
|
|
51
|
+
.filter((parameter) => parameter.in === 'query' && (parameter.required || parameter.example !== undefined) && parameter.name !== 'cursor')
|
|
52
|
+
.slice(0, 3)
|
|
53
|
+
.flatMap((parameter) => {
|
|
54
|
+
const example = parameter.example ?? parameter.schema?.example
|
|
55
|
+
return example === undefined ? [] : [`${parameter.name}=${Array.isArray(example) ? example[0] : example}`]
|
|
56
|
+
})
|
|
57
|
+
return `${method.toUpperCase()} ${filled}${query.length ? `?${query.join('&')}` : ''}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const operationsOf = (doc: OpenApiDoc) =>
|
|
61
|
+
Object.entries(doc.paths ?? {}).flatMap(([path, item]) => METHODS.flatMap((method) => (item[method] ? [{ method, path, operation: item[method]! }] : [])))
|
|
62
|
+
|
|
63
|
+
/** `{ "$ref": "#/components/schemas/Page" }` → the named schema and its name. */
|
|
64
|
+
const resolve = (doc: OpenApiDoc, schema: Schema | undefined): { name?: string; schema?: Schema } => {
|
|
65
|
+
const name = schema?.$ref?.split('/').pop()
|
|
66
|
+
return name ? { name, schema: doc.components?.schemas?.[name] } : { schema }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `Page { count, _links, _embedded.apps[] }`: the success body's top-level fields, where lists sit, by name. */
|
|
70
|
+
export const returnsOf = (doc: OpenApiDoc, operation: Operation) => {
|
|
71
|
+
const success = Object.entries(operation.responses ?? {}).find(([status]) => /^20[01]$/.test(status))?.[1]
|
|
72
|
+
const content = success?.content ? Object.values(success.content)[0] : undefined
|
|
73
|
+
const { name, schema } = resolve(doc, content?.schema)
|
|
74
|
+
if (!schema?.properties) return name ? `\`${name}\`` : ''
|
|
75
|
+
const fields = Object.entries(schema.properties).flatMap(([key, value]) => {
|
|
76
|
+
if (key !== '_embedded') return [key]
|
|
77
|
+
const embedded = resolve(doc, value).schema?.properties ?? {}
|
|
78
|
+
return Object.keys(embedded).map((inner) => `_embedded.${inner}[]`)
|
|
79
|
+
})
|
|
80
|
+
const accepted = operation.responses?.['202'] ? ' (or `202` with a run)' : ''
|
|
81
|
+
return `${name ? `\`${name}\` ` : ''}{ ${fields.join(', ')} }${accepted}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One line per operation: the call (with real example values), what it is for, its parameters (`*` = required), what it returns. */
|
|
85
|
+
export const operationLine = ({ method, path, operation }: { method: string; path: string; operation: Operation }, doc: OpenApiDoc = {}) => {
|
|
86
|
+
const query = (operation.parameters ?? []).filter((parameter) => parameter.in === 'query' && parameter.name !== 'cursor')
|
|
87
|
+
const body = operation.requestBody?.content ? Object.keys(operation.requestBody.content).map((type) => type.split(';')[0]).join(' or ') : ''
|
|
88
|
+
const open = Array.isArray(operation.security) && operation.security.length === 0 ? ' No key needed.' : ''
|
|
89
|
+
return [
|
|
90
|
+
`- \`${exampleCall(method, path, operation)}\` — ${firstSentence(operation.description ?? operation.summary)}${open}`,
|
|
91
|
+
query.length ? `Query: ${query.map(paramLine).join(', ')}.` : '',
|
|
92
|
+
body ? `Body: ${body}.` : '',
|
|
93
|
+
returnsOf(doc, operation) ? `Returns ${returnsOf(doc, operation)}.` : '',
|
|
94
|
+
operation.operationId ? `Spec: \`${operation.operationId}\`.` : '',
|
|
95
|
+
]
|
|
96
|
+
.filter(Boolean)
|
|
97
|
+
.join(' ')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Every operation, grouped under its tag in the document's tag order; `tags` narrows to some of them. */
|
|
101
|
+
export const endpointsMarkdown = (doc: OpenApiDoc, { tags }: { tags?: string[] } = {}) => {
|
|
102
|
+
const operations = operationsOf(doc)
|
|
103
|
+
const order = (doc.tags ?? []).map((tag) => tag.name).filter((name) => !tags || tags.includes(name))
|
|
104
|
+
return order
|
|
105
|
+
.map((name) => {
|
|
106
|
+
const lines = operations.filter(({ operation }) => (operation.tags ?? [])[0] === name).map((entry) => operationLine(entry, doc))
|
|
107
|
+
if (!lines.length) return ''
|
|
108
|
+
const description = doc.tags?.find((tag) => tag.name === name)?.description
|
|
109
|
+
return `### ${name}\n\n${description ? `${description}\n\n` : ''}${lines.join('\n')}`
|
|
110
|
+
})
|
|
111
|
+
.filter(Boolean)
|
|
112
|
+
.join('\n\n')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Each tag's section on its own, for a template that interleaves prose: `{{api.tag.search}}`. */
|
|
116
|
+
export const endpointsByTag = (doc: OpenApiDoc) =>
|
|
117
|
+
Object.fromEntries((doc.tags ?? []).map((tag) => [slugOf(tag.name), endpointsMarkdown(doc, { tags: [tag.name] })]))
|
|
118
|
+
|
|
119
|
+
// ---- MCP tools ----
|
|
120
|
+
|
|
121
|
+
/** Runs a tool's `call` with placeholder arguments (`{name}`) to see the route it hits, then finds that operation. */
|
|
122
|
+
export const toolRoute = (tool: ToolLike, doc: OpenApiDoc) => {
|
|
123
|
+
const placeholders = new Proxy({}, { get: (_, key) => (typeof key === 'string' ? `{${key}}` : undefined) })
|
|
124
|
+
let call: { method?: string; path: string }
|
|
125
|
+
try {
|
|
126
|
+
call = tool.call(placeholders as never)
|
|
127
|
+
} catch {
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
const method = (call.method ?? 'GET').toLowerCase()
|
|
131
|
+
const path = call.path.split('?')[0]!
|
|
132
|
+
for (const [template, item] of Object.entries(doc.paths ?? {})) {
|
|
133
|
+
if (!item[method]) continue
|
|
134
|
+
const pattern = new RegExp(`^${template.replace(/[.*+?^$()|[\]\\]/g, '\\$&').replace(/\\?\{[^}]+\\?\}/g, '[^/]+')}$`)
|
|
135
|
+
if (pattern.test(path)) return { method: method.toUpperCase(), path: template, operationId: item[method]!.operationId }
|
|
136
|
+
}
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One line per tool: its name, the route it runs, when to use it (its own description, which also states the cost). */
|
|
141
|
+
export const toolsMarkdown = (tools: ToolLike[], doc: OpenApiDoc) =>
|
|
142
|
+
tools
|
|
143
|
+
.map((tool) => {
|
|
144
|
+
const route = toolRoute(tool, doc)
|
|
145
|
+
return `- **\`${tool.name}\`** → \`${route ? `${route.method} ${route.path}` : 'no route found'}\` — ${tool.description.replace(/\s+/g, ' ').trim()}`
|
|
146
|
+
})
|
|
147
|
+
.join('\n')
|
|
148
|
+
|
|
149
|
+
/** One line per MCP resource: its URI template and what attaching it gives. */
|
|
150
|
+
export const resourcesMarkdown = (resources: ResourceLike[]) => resources.map((resource) => `- \`${resource.uriTemplate}\` — ${resource.title}: ${resource.description.replace(/\s+/g, ' ').trim()}`).join('\n')
|
|
151
|
+
|
|
152
|
+
// ---- Skills ----
|
|
153
|
+
|
|
154
|
+
/** A playbook's title (its `# heading`) and its first paragraph (what it is for). */
|
|
155
|
+
export const playbookSummary = (markdown: string) => ({
|
|
156
|
+
title: markdown.match(/^# (.+)$/m)?.[1]?.trim() ?? '',
|
|
157
|
+
purpose: markdown.replace(/^---[\s\S]*?---/, '').split(/\n\s*\n/).map((part) => part.trim()).find((part) => part && !part.startsWith('#'))?.replace(/\s+/g, ' ') ?? '',
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
/** `- [Plan a week](/skills/cookbook/references/plan-a-week.md): …` for every playbook of one skill. */
|
|
161
|
+
export const playbooksMarkdown = (files: Map<string, string>, skill: string) =>
|
|
162
|
+
[...files.entries()]
|
|
163
|
+
.filter(([path]) => path.startsWith(`skills/${skill}/references/`) && path.endsWith('.md'))
|
|
164
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
165
|
+
.map(([path, text]) => {
|
|
166
|
+
const { title, purpose } = playbookSummary(text)
|
|
167
|
+
return `- [${title || path}](/${path}): ${firstSentence(purpose)}`
|
|
168
|
+
})
|
|
169
|
+
.join('\n')
|
|
170
|
+
|
|
171
|
+
// ---- Getting unstuck ----
|
|
172
|
+
|
|
173
|
+
/** The same five ways out for every kit API: the spec, the errors, the root, runs, the problem catalogue. */
|
|
174
|
+
export const unblockMarkdown = (origin: string) => {
|
|
175
|
+
const search = `curl -s ${origin}/openapi.json | jq -r '.paths | to_entries[] | .key as $p | .value | to_entries[] | "\\(.key|ascii_upcase) \\($p) — \\(.value.summary)"' | grep -i <word>`
|
|
176
|
+
return [
|
|
177
|
+
'1. **Look the call up in the spec.** `GET /openapi.json` (with your key on a private service) has every operation with its parameters, examples and',
|
|
178
|
+
` response schema. Find one by words: \`${search}\`.`,
|
|
179
|
+
' What a field means: the call\'s line in `/agents/endpoints.md` names its schema after "Returns"; read it with',
|
|
180
|
+
` \`curl -s ${origin}/openapi.json | jq '.components.schemas.<Name>'\` (every field is described there).`,
|
|
181
|
+
'2. **Read the error.** Every error is `application/problem+json` with a `code`, a `detail` that says what to change,',
|
|
182
|
+
' and `errors[]` naming the field. A `400` for an unknown query parameter lists the ones the operation takes.',
|
|
183
|
+
' `GET /problems/{code}` explains a code.',
|
|
184
|
+
'3. **Start over from the root.** `GET /api` links every list, search and workflow; resources link their neighbours',
|
|
185
|
+
' in `_links`, so follow links instead of building URLs. Where an answer keeps its list (`_embedded.<name>`, or a',
|
|
186
|
+
' top-level `items` with `total`) is on each operation\'s `Returns` line in the endpoints page.',
|
|
187
|
+
'4. **A `202` is not an error.** It started a run: poll `Location` until `status` is `done`, then repeat the call',
|
|
188
|
+
' (or read `_links.result`).',
|
|
189
|
+
'5. **Still stuck?** Say exactly which call failed and its `detail`; never guess an id, a field or a number.',
|
|
190
|
+
].join('\n')
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Everything generated, keyed the way guide templates name it. */
|
|
194
|
+
export const generatedVars = ({ origin, doc, tools, resources, files }: { origin: string; doc: OpenApiDoc; tools?: ToolLike[]; resources?: ResourceLike[]; files: Map<string, string> }) => {
|
|
195
|
+
const skills = [...new Set([...files.keys()].flatMap((path) => path.match(/^skills\/([^/]+)\/SKILL\.md$/)?.[1] ?? []))]
|
|
196
|
+
return {
|
|
197
|
+
api: { endpoints: endpointsMarkdown(doc), tag: endpointsByTag(doc), unblock: unblockMarkdown(origin), operations: operationsOf(doc).length },
|
|
198
|
+
mcp: { tools: tools ? toolsMarkdown(tools, doc) : '', count: tools?.length ?? 0, resources: resources?.length ? resourcesMarkdown(resources) : 'None.' },
|
|
199
|
+
skills: Object.fromEntries(skills.map((name) => [name, { playbooks: playbooksMarkdown(files, name) }])),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Template names the kit fills itself; `guideGaps` doesn't ask the app for them. */
|
|
204
|
+
export const GENERATED_ROOTS = ['origin', 'api', 'mcp', 'skills'] as const
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export * from './core/api.js'
|
|
2
|
+
export * from './core/auth.js'
|
|
3
|
+
export * from './core/cache.js'
|
|
4
|
+
export * from './db/index.js'
|
|
5
|
+
export * from './core/discovery.js'
|
|
6
|
+
export * from './guide/guide.js'
|
|
7
|
+
export * from './core/hal.js'
|
|
8
|
+
export * from './core/kv.js'
|
|
9
|
+
export * from './oauth/index.js'
|
|
10
|
+
export * from './pagination/index.js'
|
|
11
|
+
export * from './core/problem.js'
|
|
12
|
+
export * from './guide/reference.js'
|
|
13
|
+
export * from './core/guards.js'
|
|
14
|
+
export * from './vectors/index.js'
|
|
15
|
+
export * from './web/index.js'
|