@agentskit/doc-bridge 0.1.0-alpha.2 → 0.1.0-alpha.3

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.
@@ -0,0 +1,142 @@
1
+ # Dogfood round 2 — published `@agentskit/doc-bridge@0.1.0-alpha.2`
2
+
3
+ **Date:** 2026-07-09
4
+ **Install:** npm registry tag `alpha` → `0.1.0-alpha.2`
5
+ **Consumers:** agentskit · agentskit-os · agents-playbook · agentskit-registry
6
+
7
+ ## Scoreboard
8
+
9
+ | Repo | Install | Version | Knowledge | Handoffs | Gate | Sample handoff quality |
10
+ |------|---------|---------|-----------|----------|------|------------------------|
11
+ | **agentskit** | `pnpm add -Dw @0.1.0-alpha.2` ✅ | 0.1.0-alpha.2 | 25 | 24 | ✅ freshness + 23 humanDoc | Excellent: pnpm filter checks + humanDoc `/docs/reference/packages/core` |
12
+ | **agentskit-os** | pnpm store conflict; used **`npx`** ✅ | 0.1.0-alpha.2 | 170 | 92 | ✅ freshness + 9 humanDoc | Good act path; weak human bridge (9/92) |
13
+ | **agents-playbook** | `pnpm add -D` ✅ | 0.1.0-alpha.2 | 127 | 81 | ✅ freshness + okf-type | Pattern ownership + `check:okf-type` ✅ |
14
+ | **agentskit-registry** | needs `--legacy-peer-deps` ⚠️ | 0.1.0-alpha.2 | 36 | 36 | ✅ freshness | Full agent ownership + humanDoc ✅ |
15
+
16
+ ### npm dist-tags (observed)
17
+
18
+ | Tag | Version |
19
+ |-----|---------|
20
+ | `latest` | `0.1.0-alpha.1` |
21
+ | `alpha` | `0.1.0-alpha.2` |
22
+
23
+ **Recommendation:** keep prereleases only on `alpha` until stable; document `npm i -D @agentskit/doc-bridge@alpha`.
24
+
25
+ ---
26
+
27
+ ## What works well (validated)
28
+
29
+ 1. **Published binary works** — `ak-docs 0.1.0-alpha.2` from registry / npx.
30
+ 2. **Monorepo handoffs** — `editRoots` + `pnpm --filter @agentskit/<pkg> test|lint` on agentskit and AKOS.
31
+ 3. **Playbook preset** — gates green without crushing large OKF corpus.
32
+ 4. **Registry ownership** — all 36 agents resolve; checks use `npm run validate`.
33
+ 5. **Fumadocs humanDoc** on agentskit points at **reference** docs, not nested for-agents.
34
+ 6. **Pattern checks** — playbook patterns emit `pnpm run check:okf-type`.
35
+
36
+ ---
37
+
38
+ ## Issues found (prioritized)
39
+
40
+ ### P0 — correctness / ranking (agents will take wrong action)
41
+
42
+ | ID | Issue | Evidence | Suggested fix |
43
+ |----|--------|----------|----------------|
44
+ | **R2-1** | **Search/ask ranking ignores exact package id** | `search core --agent` bestMatch = **angular** (score 8) tied with **core** (score 8) because summary contains `@agentskit/core` | Boost exact `id` / path segment matches; prefer ownership id === term before body mentions |
45
+ | **R2-2** | **`ask` suggests wrong handoff** | “where do I change the **core** package?” → best match **angular**, next `query ownership angular` | Same ranking fix; optional: prefer ownership over knowledge for “package/module” language |
46
+ | **R2-3** | **Purpose/notes truncated mid-sentence** | core notes: `"Stable TypeScript contracts (Adapter, Tool, Memory, Retriever, Skill,"` | Raise firstParagraph budget; strip trailing incomplete commas; prefer frontmatter `purpose` full line |
47
+
48
+ ### P1 — install / peer DX
49
+
50
+ | ID | Issue | Evidence | Suggested fix |
51
+ |----|--------|----------|----------------|
52
+ | **R2-4** | **Optional peers still break npm install** | registry: `ERESOLVE` with optional peers vs `@agentskit/adapters@0.12.x` | Peer ranges more permissive (`>=0.12` / `>=1.0`); document `--legacy-peer-deps`; ensure optional peers never hard-fail Layer 0 |
53
+ | **R2-5** | **Peer `@agentskit/core@^1.10.0` vs monorepo 1.9.0** | pnpm warning on agentskit install | Align peer to `^1.9.0 \|\| ^1.10.0` or `>=1.9.0 <2` for dogfood until monorepo bumps |
54
+ | **R2-6** | **`latest` still points at alpha.1** | `npm view` latest=alpha.1, alpha=alpha.2 | Publish hygiene: don’t put prereleases on `latest`, or retag after publish |
55
+ | **R2-7** | **AKOS cannot `pnpm add` (store v10 vs v11)** | `ERR_PNPM_UNEXPECTED_STORE` | Docs: use npx / align store; not a doc-bridge bug but dogfood friction |
56
+
57
+ ### P2 — search / retrieval quality
58
+
59
+ | ID | Issue | Evidence | Suggested fix |
60
+ |----|--------|----------|----------------|
61
+ | **R2-8** | **Search miss on common English** | agentskit `search documentation` → **0 matches** | Index more body text / titles; lower threshold; BM25 over full file not only description |
62
+ | **R2-9** | **Playbook `search handoff` → 0** | term may only appear deep in body | Same as R2-8; chunk full markdown into search corpus |
63
+ | **R2-10** | **Federation hard-fail noise** | `retrieve` → `Failed to fetch https://registry.agentskit.io/llms.txt: 404` | Soft-fail federation sources; warn once; don’t fail command |
64
+ | **R2-11** | **Text search UX** | tab-separated dense lines | Columnar/TTY formatting (`type id path summary`) |
65
+
66
+ ### P3 — bridge coverage & product polish
67
+
68
+ | ID | Issue | Evidence | Suggested fix |
69
+ |----|--------|----------|----------------|
70
+ | **R2-12** | **AKOS humanDoc still sparse** | 9/92 handoffs with humanDoc | Map for-agents frontmatter `humanDoc`; product Fumadocs catalog; optional plain-markdown `docs/errors` etc. |
71
+ | **R2-13** | **Duplicate knowledge+ownership rows in search** | same path appears as ownership + knowledge | Deduplicate by path; show ownership preferred |
72
+ | **R2-14** | **Default checks for patterns without okf script** | N/A playbook ok | Fine; document convention |
73
+ | **R2-15** | **MCP not re-smoked this round** | — | Keep in CI smoke; optional round 3 |
74
+
75
+ ---
76
+
77
+ ## Sample outputs (good)
78
+
79
+ ### agentskit — `query package core --agent`
80
+
81
+ ```json
82
+ {
83
+ "startHere": "apps/docs-next/content/docs/for-agents/core.mdx",
84
+ "editRoots": ["packages/core"],
85
+ "checks": [
86
+ "pnpm --filter @agentskit/core test",
87
+ "pnpm --filter @agentskit/core lint"
88
+ ],
89
+ "humanDoc": "/docs/reference/packages/core"
90
+ }
91
+ ```
92
+
93
+ ### playbook — pattern handoff
94
+
95
+ ```json
96
+ {
97
+ "startHere": "content/docs/pillars/ai-collaboration/open-knowledge-format-pattern.md",
98
+ "checks": ["pnpm run check:okf-type"],
99
+ "humanDoc": "/docs/pillars/ai-collaboration/open-knowledge-format-pattern"
100
+ }
101
+ ```
102
+
103
+ ### registry — `docs-chat`
104
+
105
+ ```json
106
+ {
107
+ "startHere": "registry/docs-chat/README.md",
108
+ "editRoots": ["registry/docs-chat"],
109
+ "checks": ["npm run validate", "npm test"],
110
+ "humanDoc": "/agents/docs-chat"
111
+ }
112
+ ```
113
+
114
+ ---
115
+
116
+ ## Recommended next alpha (0.1.0-alpha.3) scope
117
+
118
+ 1. **Ranking:** exact id / basename boost; package-intent heuristics for `ask`
119
+ 2. **Search corpus:** full-text over body (not only description); dedupe paths
120
+ 3. **Descriptions:** full purpose from frontmatter or first complete paragraph
121
+ 4. **Peers:** widen optional peer ranges; Layer 0 install never requires AgentsKit packages
122
+ 5. **Federation:** soft-fail missing `llms.txt` sources
123
+ 6. **Publish:** keep `latest` free of broken prereleases; doc install as `@alpha`
124
+
125
+ ---
126
+
127
+ ## Consumer follow-ups (not doc-bridge code)
128
+
129
+ | Repo | Action |
130
+ |------|--------|
131
+ | agentskit | Bump `@agentskit/core` to 1.10+ or live with peer warning |
132
+ | agentskit-os | Align pnpm store / commit lock with published dep; add CI `docs:bridge:gate` |
133
+ | agents-playbook | Optional CI workflow for gate |
134
+ | agentskit-registry | Publish `public/llms.txt` at site root (404 today); use `--legacy-peer-deps` until peer ranges loosen |
135
+ | all | After alpha.3: switch dep to `@agentskit/doc-bridge@alpha` only |
136
+
137
+ ---
138
+
139
+ ## Conclusion
140
+
141
+ **Round 2 proves Layer 0 is useful on real monorepos and OKF/registry corpora with the published package.**
142
+ The biggest remaining gap for “agents do the right thing” is **discovery ranking (R2-1/R2-2)** and **search body coverage (R2-8/R2-9)** — not indexing or handoff structure.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentskit/doc-bridge",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.3",
4
4
  "description": "Human↔agent documentation bridge — deterministic handoffs, doc-site links, memory→docs, optional AgentsKit RAG/chat.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,9 +46,9 @@
46
46
  "changeset": "changeset",
47
47
  "version-packages": "changeset version",
48
48
  "release": "pnpm build && pnpm test && changeset publish",
49
- "prepublishOnly": "npm run build && npm test",
49
+ "prepublishOnly": "pnpm build && pnpm test",
50
50
  "prepare": "node scripts/prepare.mjs",
51
- "prepack": "npm run build"
51
+ "prepack": "pnpm build"
52
52
  },
53
53
  "keywords": [
54
54
  "agentskit",
@@ -75,11 +75,11 @@
75
75
  "zod": "^3.24.2"
76
76
  },
77
77
  "peerDependencies": {
78
- "@agentskit/adapters": "^0.13.0",
79
- "@agentskit/core": "^1.10.0",
80
- "@agentskit/ink": "^0.9.0",
81
- "@agentskit/memory": "^0.10.0",
82
- "@agentskit/rag": "^0.4.0",
78
+ "@agentskit/adapters": ">=0.12.0 <1",
79
+ "@agentskit/core": ">=1.0.0 <2",
80
+ "@agentskit/ink": ">=0.8.0 <1",
81
+ "@agentskit/memory": ">=0.9.0 <1",
82
+ "@agentskit/rag": ">=0.3.0 <1",
83
83
  "react": ">=18.0.0"
84
84
  },
85
85
  "peerDependenciesMeta": {
@@ -160,9 +160,11 @@ const writeTextQuery = (payload: unknown): void => {
160
160
  writeLines([
161
161
  `Search: ${String(data.term ?? '')}`,
162
162
  `Matches: ${String(data.count ?? matches.length)}`,
163
- ...matches.map((match) =>
164
- [match.type, match.id, match.path, match.summary].filter(Boolean).join('\t'),
165
- ),
163
+ ...(matches.length
164
+ ? matches.map((match) =>
165
+ formatSearchMatch(match as { type: string; id: string; path: string; summary?: string }),
166
+ )
167
+ : [' (none)']),
166
168
  ])
167
169
  return
168
170
  }
@@ -170,6 +172,18 @@ const writeTextQuery = (payload: unknown): void => {
170
172
  writeLines([textValue(result.data)])
171
173
  }
172
174
 
175
+ const formatSearchMatch = (match: {
176
+ readonly type: string
177
+ readonly id: string
178
+ readonly path: string
179
+ readonly summary?: string
180
+ readonly score?: number
181
+ }): string => {
182
+ const summary = match.summary ? match.summary.replace(/\s+/g, ' ').slice(0, 100) : ''
183
+ const score = typeof match.score === 'number' ? ` score=${match.score}` : ''
184
+ return ` [${match.type}] ${match.id}${score}\n ${match.path}${summary ? `\n ${summary}` : ''}`
185
+ }
186
+
173
187
  const writeTextSearch = (
174
188
  term: string,
175
189
  matches: readonly {
@@ -177,14 +191,13 @@ const writeTextSearch = (
177
191
  readonly id: string
178
192
  readonly path: string
179
193
  readonly summary?: string
194
+ readonly score?: number
180
195
  }[],
181
196
  ): void => {
182
197
  writeLines([
183
198
  `Search: ${term}`,
184
199
  `Matches: ${matches.length}`,
185
- ...matches.map((match) =>
186
- [match.type, match.id, match.path, match.summary].filter(Boolean).join('\t'),
187
- ),
200
+ ...(matches.length ? matches.map(formatSearchMatch) : [' (none)']),
188
201
  ])
189
202
  }
190
203
 
@@ -193,11 +206,14 @@ const writeAsk = (
193
206
  matches: ReturnType<typeof searchIndex>,
194
207
  index: DocBridgeIndexV1,
195
208
  ): void => {
196
- const best = matches[0]
197
- const owner = matches.find((match) => match.type === 'ownership' || index.lookup?.ownership?.[match.id])
209
+ // Prefer ownership match for routing questions
210
+ const owner =
211
+ matches.find((match) => match.type === 'ownership') ??
212
+ matches.find((match) => Boolean(index.lookup?.ownership?.[match.id]))
213
+ const best = owner ?? matches[0]
198
214
  const bestQuery =
199
- owner
200
- ? `ak-docs query ownership ${owner.id} --agent`
215
+ best && (best.type === 'ownership' || index.lookup?.ownership?.[best.id])
216
+ ? `ak-docs query ownership ${best.id} --agent`
201
217
  : 'ak-docs list knowledge --text'
202
218
  writeLines([
203
219
  `Question: ${question}`,
@@ -206,10 +222,8 @@ const writeAsk = (
206
222
  'Matches:',
207
223
  ...(
208
224
  matches.length
209
- ? matches.slice(0, 5).map((match) =>
210
- [match.type, match.id, match.path, match.summary].filter(Boolean).join('\t'),
211
- )
212
- : ['No local matches. Try: ak-docs search <term>']
225
+ ? matches.slice(0, 5).map(formatSearchMatch)
226
+ : [' No local matches. Try: ak-docs search <term>']
213
227
  ),
214
228
  '',
215
229
  'Next commands:',
@@ -35,11 +35,15 @@ const sourceText = async (
35
35
  root: string,
36
36
  source: string,
37
37
  fetchText: FetchText,
38
- ): Promise<string> => {
39
- if (/^https?:\/\//.test(source)) return fetchText(source)
40
- const path = resolve(root, source)
41
- if (!existsSync(path)) throw new Error(`Federation source not found: ${source}`)
42
- return readFileSync(path, 'utf8')
38
+ ): Promise<string | null> => {
39
+ try {
40
+ if (/^https?:\/\//.test(source)) return await fetchText(source)
41
+ const path = resolve(root, source)
42
+ if (!existsSync(path)) return null
43
+ return readFileSync(path, 'utf8')
44
+ } catch {
45
+ return null
46
+ }
43
47
  }
44
48
 
45
49
  const sameOrigin = (base: string, target: string): boolean => {
@@ -97,9 +101,14 @@ export const loadFederatedChunks = async (
97
101
  ): Promise<DocBridgeRetrievedChunk[]> => {
98
102
  const fetchText = options.fetchText ?? defaultFetchText
99
103
  const chunks: DocBridgeRetrievedChunk[] = []
104
+ const warnings: string[] = []
100
105
  for (const source of config.federation?.sources ?? []) {
101
106
  if (source.includeInRetriever === false || !source.llmsTxt) continue
102
107
  const llms = await sourceText(root, source.llmsTxt, fetchText)
108
+ if (!llms) {
109
+ warnings.push(`federation source skipped (unavailable): ${source.id} → ${source.llmsTxt}`)
110
+ continue
111
+ }
103
112
  chunks.push(...chunksFromMarkdown(source.id, llms, source.llmsTxt))
104
113
  const links = parseLlmsTxtLinks(llms)
105
114
  for (const link of links) {
@@ -108,14 +117,14 @@ export const loadFederatedChunks = async (
108
117
  : link.url
109
118
  if (!/\.(md|txt)(?:$|\?)/.test(url)) continue
110
119
  if (!sameOrigin(source.llmsTxt, url)) continue
111
- try {
112
- const raw = await sourceText(root, url, fetchText)
113
- chunks.push(...chunksFromMarkdown(source.id, raw, url))
114
- } catch {
115
- // ponytail: federation is best-effort; surface-level health belongs in a future diagnostic command.
116
- }
120
+ const raw = await sourceText(root, url, fetchText)
121
+ if (raw) chunks.push(...chunksFromMarkdown(source.id, raw, url))
117
122
  }
118
123
  }
124
+ // Soft-fail: never throw for missing remote sources; one-line warn for agents/humans.
125
+ if (warnings.length && process.stderr.isTTY) {
126
+ for (const w of warnings) process.stderr.write(`${w}\n`)
127
+ }
119
128
  return chunks
120
129
  }
121
130
 
@@ -103,16 +103,31 @@ const resolveHumanDoc = (
103
103
  if (override) return override
104
104
  if (fmHuman) return fmHuman
105
105
  if (humanDocs[packageId]) return humanDocs[packageId]
106
- // common aliases: scoped package name tail, path segments
106
+
107
107
  const aliases = [
108
108
  packageId,
109
109
  packageId.replace(/^@[^/]+\//, ''),
110
110
  packageId.replace(/^os-/, ''),
111
111
  packageId.replace(/-pattern$/, ''),
112
+ `packages/${packageId}`,
113
+ `reference/packages/${packageId}`,
114
+ `packages/${packageId}/index`,
112
115
  ]
113
116
  for (const alias of aliases) {
114
117
  if (humanDocs[alias]) return humanDocs[alias]
115
118
  }
119
+
120
+ // Fuzzy: id is a path segment or suffix of a human doc id
121
+ for (const [key, url] of Object.entries(humanDocs)) {
122
+ if (
123
+ key === packageId ||
124
+ key.endsWith(`/${packageId}`) ||
125
+ key.endsWith(`/${packageId}/index`) ||
126
+ key.endsWith(`-${packageId}`)
127
+ ) {
128
+ return url
129
+ }
130
+ }
116
131
  return undefined
117
132
  }
118
133
 
@@ -3,6 +3,7 @@ import { join } from 'node:path'
3
3
 
4
4
  import type { DocBridgeConfigV1 } from '../config/schema.js'
5
5
  import {
6
+ extractSearchBody,
6
7
  firstHeading,
7
8
  firstParagraph,
8
9
  frontmatterString,
@@ -47,7 +48,9 @@ export const scanAgentCorpus = (root: string, config: DocBridgeConfigV1): Corpus
47
48
  frontmatterString(frontmatter, 'package') ??
48
49
  slugFromPath(relToCorpus)
49
50
  const title = firstHeading(raw) ?? id
50
- const description = firstParagraph(raw)
51
+ const purpose = frontmatterString(frontmatter, 'purpose')
52
+ const description = purpose ?? firstParagraph(raw, 400)
53
+ const body = extractSearchBody(raw)
51
54
  return {
52
55
  id,
53
56
  type: 'agent-doc',
@@ -57,6 +60,7 @@ export const scanAgentCorpus = (root: string, config: DocBridgeConfigV1): Corpus
57
60
  relPath,
58
61
  frontmatter,
59
62
  ...(description ? { description } : {}),
63
+ ...(body ? { body } : {}),
60
64
  }
61
65
  })
62
66
  }
@@ -64,7 +64,8 @@ export const firstHeading = (markdown: string): string | undefined => {
64
64
  return undefined
65
65
  }
66
66
 
67
- export const firstParagraph = (markdown: string): string | undefined => {
67
+ /** Complete first prose block prefer full sentences, cap length without mid-word cuts. */
68
+ export const firstParagraph = (markdown: string, maxLen = 400): string | undefined => {
68
69
  const { body } = parseFrontmatter(markdown)
69
70
  const lines = body.split('\n')
70
71
  const buf: string[] = []
@@ -76,11 +77,37 @@ export const firstParagraph = (markdown: string): string | undefined => {
76
77
  }
77
78
  if (t.startsWith('#')) continue
78
79
  if (t.startsWith('---')) continue
80
+ if (t.startsWith('```')) break
81
+ if (t.startsWith('|') || t.startsWith('- [') || t.startsWith('* [')) {
82
+ if (buf.length) break
83
+ continue
84
+ }
79
85
  buf.push(t)
80
- if (buf.join(' ').length > 40) break
86
+ if (buf.join(' ').length >= maxLen) break
81
87
  }
82
- const text = buf.join(' ').trim()
83
- return text || undefined
88
+ let text = buf.join(' ').replace(/\s+/g, ' ').trim()
89
+ if (!text) return undefined
90
+ if (text.length <= maxLen) return text
91
+ // Prefer ending on sentence boundary
92
+ const sliced = text.slice(0, maxLen)
93
+ const sentenceEnd = Math.max(sliced.lastIndexOf('. '), sliced.lastIndexOf('! '), sliced.lastIndexOf('? '))
94
+ if (sentenceEnd > maxLen * 0.4) return sliced.slice(0, sentenceEnd + 1).trim()
95
+ const wordEnd = sliced.lastIndexOf(' ')
96
+ return (wordEnd > 0 ? sliced.slice(0, wordEnd) : sliced).trim()
97
+ }
98
+
99
+ /** Flatten markdown body for search (strip fences/links noise lightly). */
100
+ export const extractSearchBody = (markdown: string, maxLen = 6_000): string => {
101
+ const { body } = parseFrontmatter(markdown)
102
+ const text = body
103
+ .replace(/```[\s\S]*?```/g, ' ')
104
+ .replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
105
+ .replace(/\[[^\]]*\]\([^)]+\)/g, ' ')
106
+ .replace(/^#+\s+/gm, '')
107
+ .replace(/[|>*_`#]/g, ' ')
108
+ .replace(/\s+/g, ' ')
109
+ .trim()
110
+ return text.length > maxLen ? text.slice(0, maxLen) : text
84
111
  }
85
112
 
86
113
  export const slugFromPath = (relPath: string): string => {
@@ -11,23 +11,80 @@ export type SearchMatch = {
11
11
  const tokenize = (value: string): string[] =>
12
12
  value
13
13
  .toLowerCase()
14
- .split(/[^a-z0-9]+/)
14
+ .split(/[^a-z0-9@/_-]+/)
15
15
  .filter((t) => t.length >= 2)
16
16
 
17
+ const PACKAGE_INTENT =
18
+ /\b(package|module|pkg|edit|change|where|owns?|ownership|handoff|start)\b/i
19
+
20
+ const scoreHay = (tokens: readonly string[], hay: string, weight = 1): number => {
21
+ let score = 0
22
+ for (const token of tokens) {
23
+ if (!hay.includes(token)) continue
24
+ score += token.length * weight
25
+ // whole-word-ish bonus
26
+ if (new RegExp(`(?:^|[^a-z0-9])${token}(?:[^a-z0-9]|$)`).test(hay)) {
27
+ score += token.length
28
+ }
29
+ }
30
+ return score
31
+ }
32
+
33
+ /** Exact / near-exact identity boost so "core" ranks the core package over mentions of @agentskit/core. */
34
+ const identityBoost = (id: string, path: string, tokens: readonly string[], term: string): number => {
35
+ const idLower = id.toLowerCase()
36
+ const termLower = term.toLowerCase().trim()
37
+ const base = path.split('/').pop()?.replace(/\.mdx?$/i, '').toLowerCase() ?? ''
38
+ let boost = 0
39
+
40
+ if (idLower === termLower || base === termLower) boost += 200
41
+ if (tokens.length === 1 && (idLower === tokens[0] || base === tokens[0])) boost += 200
42
+
43
+ for (const token of tokens) {
44
+ if (idLower === token) boost += 120
45
+ else if (idLower.startsWith(`${token}-`) || idLower.endsWith(`-${token}`)) boost += 40
46
+ else if (idLower.includes(token) && idLower.length <= token.length + 4) boost += 30
47
+ if (base === token) boost += 100
48
+ }
49
+
50
+ // Prefer short ids when term is the id (package name)
51
+ if (tokens.includes(idLower)) boost += Math.max(0, 40 - idLower.length)
52
+
53
+ return boost
54
+ }
55
+
56
+ const preferOwnership = (term: string): boolean =>
57
+ PACKAGE_INTENT.test(term) || /^(where|how).*(edit|change|package|module)/i.test(term)
58
+
17
59
  export const searchIndex = (index: DocBridgeIndexV1, term: string, limit = 20): SearchMatch[] => {
18
60
  const tokens = tokenize(term)
19
61
  if (!tokens.length) return []
20
62
 
21
- const matches: SearchMatch[] = []
63
+ const wantOwnership = preferOwnership(term)
64
+ const byPath = new Map<string, SearchMatch>()
22
65
 
23
- for (const entry of index.knowledge) {
24
- const hay = `${entry.id} ${entry.title} ${entry.description ?? ''} ${entry.path}`.toLowerCase()
25
- let score = 0
26
- for (const token of tokens) {
27
- if (hay.includes(token)) score += token.length
66
+ const consider = (match: SearchMatch) => {
67
+ const key = match.path
68
+ const existing = byPath.get(key)
69
+ if (!existing) {
70
+ byPath.set(key, match)
71
+ return
28
72
  }
73
+ // Prefer ownership over knowledge for same path; else higher score
74
+ const prefer =
75
+ match.score > existing.score ||
76
+ (match.score === existing.score && match.type === 'ownership' && existing.type !== 'ownership') ||
77
+ (wantOwnership && match.type === 'ownership' && existing.type !== 'ownership' && match.score >= existing.score - 20)
78
+ if (prefer) byPath.set(key, match)
79
+ }
80
+
81
+ for (const entry of index.knowledge) {
82
+ const body = (entry as { body?: string }).body ?? ''
83
+ const hay = `${entry.id} ${entry.title} ${entry.description ?? ''} ${entry.path} ${body}`.toLowerCase()
84
+ let score = scoreHay(tokens, hay, 1)
85
+ score += identityBoost(entry.id, entry.path, tokens, term)
29
86
  if (score > 0) {
30
- matches.push({
87
+ consider({
31
88
  type: 'knowledge',
32
89
  id: entry.id,
33
90
  path: entry.path,
@@ -38,21 +95,32 @@ export const searchIndex = (index: DocBridgeIndexV1, term: string, limit = 20):
38
95
  }
39
96
 
40
97
  for (const [id, owner] of Object.entries(index.lookup?.ownership ?? {})) {
41
- const hay = `${id} ${owner.path} ${owner.purpose ?? ''} ${owner.group ?? ''}`.toLowerCase()
42
- let score = 0
43
- for (const token of tokens) {
44
- if (hay.includes(token)) score += token.length * 2
45
- }
98
+ const path = owner.agentDoc ?? owner.path
99
+ const hay = `${id} ${owner.path} ${owner.purpose ?? ''} ${owner.group ?? ''} ${owner.agentDoc ?? ''} ${owner.humanDoc ?? ''}`.toLowerCase()
100
+ let score = scoreHay(tokens, hay, 2)
101
+ score += identityBoost(id, path, tokens, term)
102
+ // Ownership is primary for routing questions
103
+ if (wantOwnership) score += 25
104
+ score += 15 // slight base preference for actionable ownership targets
46
105
  if (score > 0) {
47
- matches.push({
106
+ consider({
48
107
  type: 'ownership',
49
108
  id,
50
- path: owner.agentDoc ?? owner.path,
109
+ path,
51
110
  ...(owner.purpose ? { summary: owner.purpose } : {}),
52
111
  score,
53
112
  })
54
113
  }
55
114
  }
56
115
 
57
- return matches.sort((a, b) => b.score - a.score).slice(0, limit)
58
- }
116
+ return [...byPath.values()].sort((a, b) => {
117
+ if (b.score !== a.score) return b.score - a.score
118
+ // Tie-break: exact id match, then ownership, then shorter id
119
+ const aExact = tokens.includes(a.id.toLowerCase()) ? 1 : 0
120
+ const bExact = tokens.includes(b.id.toLowerCase()) ? 1 : 0
121
+ if (bExact !== aExact) return bExact - aExact
122
+ if (a.type === 'ownership' && b.type !== 'ownership') return -1
123
+ if (b.type === 'ownership' && a.type !== 'ownership') return 1
124
+ return a.id.localeCompare(b.id)
125
+ }).slice(0, limit)
126
+ }
@@ -21,7 +21,9 @@ export const KnowledgeEntrySchema = z
21
21
  type: z.string().min(1).max(128),
22
22
  title: z.string().min(1).max(256),
23
23
  path: z.string().min(1).max(512),
24
- description: z.string().max(1024).optional(),
24
+ description: z.string().max(2_048).optional(),
25
+ /** Flattened body excerpt for full-text search (not for display). */
26
+ body: z.string().max(8_000).optional(),
25
27
  links: z.array(z.string().min(1).max(512)).max(64).optional(),
26
28
  tags: z.array(z.string().min(1).max(64)).max(32).optional(),
27
29
  })
@@ -121,7 +121,8 @@ export const DocBridgeIndexV1JsonSchema = {
121
121
  type: { type: 'string', minLength: 1, maxLength: 128 },
122
122
  title: { type: 'string', minLength: 1, maxLength: 256 },
123
123
  path: { type: 'string', minLength: 1, maxLength: 512 },
124
- description: { type: 'string', maxLength: 1024 },
124
+ description: { type: 'string', maxLength: 2048 },
125
+ body: { type: 'string', maxLength: 8000 },
125
126
  links: stringArray(64),
126
127
  tags: stringArray(32),
127
128
  },
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = '0.1.0-alpha.2'
1
+ export const PACKAGE_VERSION = '0.1.0-alpha.3'