knosky 0.4.1 → 0.5.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/core/route.mjs ADDED
@@ -0,0 +1,299 @@
1
+ // KnoSky route engine (KSV2-R1/R2/R3) — structural destination -> ranked advisory route.
2
+ // Pure Node stdlib + internal imports only. ESM. No new deps.
3
+ import { getRelated } from './retrieve.mjs';
4
+ import { makeRouteDoc, validateRouteDoc } from './schema.mjs';
5
+ import { parseDestination } from './destination.mjs';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Internal helpers
9
+ // ---------------------------------------------------------------------------
10
+
11
+ /**
12
+ * Guard: a path from provenance.ref should never be absolute or contain "..".
13
+ * Returns true when the path is safe to include in output.
14
+ * @param {string|undefined} ref
15
+ * @returns {boolean}
16
+ */
17
+ function isSafeRef(ref) {
18
+ if (!ref || typeof ref !== 'string') return false;
19
+ if (ref.startsWith('/')) return false;
20
+ if (/^[A-Za-z]:[\\\/]/.test(ref)) return false;
21
+ if (ref.split(/[/\\]/).some(seg => seg === '..')) return false;
22
+ return true;
23
+ }
24
+
25
+ /**
26
+ * Return the node's provenance.ref, or null if missing/unsafe.
27
+ * @param {object} node
28
+ * @returns {string|null}
29
+ */
30
+ function safeRef(node) {
31
+ const ref = node.provenance && node.provenance.ref;
32
+ return isSafeRef(ref) ? ref : null;
33
+ }
34
+
35
+ /**
36
+ * Return true when node looks like a test file.
37
+ * @param {object} node
38
+ */
39
+ function isTest(node) {
40
+ const ref = safeRef(node) || '';
41
+ const cat = String(node.category || '').toLowerCase();
42
+ return (
43
+ cat === 'test' ||
44
+ /\/test\//.test(ref) ||
45
+ /\.test\./.test(ref) ||
46
+ /\.spec\./.test(ref) ||
47
+ /\/test\//.test(node.id || '')
48
+ );
49
+ }
50
+
51
+ /**
52
+ * Return true when node looks like a docs file.
53
+ * @param {object} node
54
+ */
55
+ function isDoc(node) {
56
+ const ref = safeRef(node) || '';
57
+ const cat = String(node.category || '').toLowerCase();
58
+ return (
59
+ cat === 'docs' ||
60
+ cat === 'documentation' ||
61
+ ref.endsWith('.md') ||
62
+ ref.endsWith('.rst') ||
63
+ ref.endsWith('.txt')
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Tokenise a string into lowercase alphanumeric tokens, stripping any "word:" prefix.
69
+ * @param {string} str
70
+ * @returns {Set<string>}
71
+ */
72
+ function tokenise(str) {
73
+ const stripped = String(str || '').replace(/^\w+:/, '');
74
+ return new Set((stripped.toLowerCase().match(/[a-z0-9]+/g) || []));
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // kcRoute — main export
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /**
82
+ * Build a ranked, advisory, metadata-only route doc.
83
+ *
84
+ * @param {object} ctx — retrieve context from load()
85
+ * @param {string} destination — navigation target string
86
+ * @param {object} [opts]
87
+ * @param {number} [opts.limit=8] — max route entries
88
+ * @param {object} [opts.overlays] — optional overlay map from readOverlays():
89
+ * { '<relpath>': { coverage?: number(0..100), test?: 'pass'|'fail' } }
90
+ * Absence means coverage is unknown — the coverage-unknown caveat fires.
91
+ * @returns {object} route doc (passes validateRouteDoc)
92
+ */
93
+ export function kcRoute(ctx, destination, { limit = 8, overlays } = {}) {
94
+ const clampedLimit = Math.max(1, Math.min(20, limit));
95
+ const { matched, matchStrength } = parseDestination(ctx, destination, clampedLimit);
96
+
97
+ // Collect direct-match ids + categories for scoring
98
+ const directIds = new Set(matched.map(n => n.id));
99
+ const directCategories = new Set(
100
+ matched.map(n => String(n.category || '').toLowerCase()).filter(Boolean),
101
+ );
102
+
103
+ // Destination keyword tokens (strip any "word:" prefix)
104
+ const destTokens = tokenise(destination);
105
+
106
+ // Expand to 1-hop neighbours via getRelated (imports + importedBy); de-dupe by id
107
+ const candidateMap = new Map();
108
+ for (const n of matched) {
109
+ candidateMap.set(n.id, n);
110
+ }
111
+
112
+ for (const n of matched) {
113
+ const rel = getRelated(ctx, n.id);
114
+ if (!rel) continue;
115
+ for (const imp of rel.imports.concat(rel.importedBy)) {
116
+ if (!candidateMap.has(imp.id)) {
117
+ const full = ctx.byId.get(imp.id);
118
+ if (full) candidateMap.set(imp.id, full);
119
+ }
120
+ }
121
+ }
122
+
123
+ // Score candidates
124
+ const caveats = [];
125
+ const lowCoverageCaveats = [];
126
+ const scored = [];
127
+
128
+ for (const [id, node] of candidateMap) {
129
+ let score = 0;
130
+ const reasons = [];
131
+
132
+ // Signal 1: destination match (+5)
133
+ if (directIds.has(id)) {
134
+ score += 5;
135
+ reasons.push('destination');
136
+ }
137
+
138
+ // Signal 2: import proximity (+2)
139
+ const rel = getRelated(ctx, id);
140
+ if (rel) {
141
+ const importsDirectMatch = rel.imports.some(m => directIds.has(m.id));
142
+ const importedByDirectMatch = rel.importedBy.some(m => directIds.has(m.id));
143
+ if (importsDirectMatch || importedByDirectMatch) {
144
+ score += 2;
145
+ reasons.push(importsDirectMatch ? 'imports destination' : 'imported by destination');
146
+ }
147
+ }
148
+
149
+ // Signal 3: same district (+1.5) — shares category with a direct match but is not itself a direct match
150
+ if (!directIds.has(id) && directCategories.size > 0) {
151
+ const cat = String(node.category || '').toLowerCase();
152
+ if (cat && directCategories.has(cat)) {
153
+ score += 1.5;
154
+ reasons.push('same district');
155
+ }
156
+ }
157
+
158
+ // Signal 4: keyword overlap (+1, capped once)
159
+ if (destTokens.size > 0) {
160
+ const candidateText = [node.title || '', ...(node.headings || [])].join(' ');
161
+ const candidateTokens = tokenise(candidateText);
162
+ const hasOverlap = [...destTokens].some(t => candidateTokens.has(t));
163
+ if (hasOverlap) {
164
+ score += 1;
165
+ reasons.push('name/heading match');
166
+ }
167
+ }
168
+
169
+ // Signal 5: recently changed (+1)
170
+ if (node.churn && typeof node.churn === 'object' && node.churn.c > 0) {
171
+ score += 1;
172
+ reasons.push('recently changed');
173
+ } else if (typeof node.churn === 'number' && node.churn > 0) {
174
+ score += 1;
175
+ reasons.push('recently changed');
176
+ }
177
+
178
+ // Signal 6: low coverage (+0.5 if overlays provided and coverage < 50)
179
+ if (overlays !== undefined && overlays !== null) {
180
+ const ref = safeRef(node);
181
+ if (ref) {
182
+ const overlay = overlays[ref];
183
+ if (overlay && typeof overlay.coverage === 'number') {
184
+ if (overlay.coverage < 50) {
185
+ score += 0.5;
186
+ lowCoverageCaveats.push(
187
+ 'low coverage: ' + ref + ' (' + Math.round(overlay.coverage) + '%) — extra review advised',
188
+ );
189
+ }
190
+ // coverage >= 50: no change, no penalty
191
+ }
192
+ }
193
+ }
194
+
195
+ scored.push({ id, node, score, reason: reasons.join('; ') || 'neighbourhood match' });
196
+ }
197
+
198
+ // Sort: score desc, then id asc
199
+ scored.sort((a, b) => b.score - a.score || String(a.id).localeCompare(String(b.id)));
200
+
201
+ // Build caveats
202
+ // — churn caveat for any high-churn file in the top results
203
+ const topCandidates = scored.slice(0, clampedLimit + 5); // check a bit beyond route
204
+ for (const s of topCandidates) {
205
+ const churn = s.node.churn;
206
+ const hasChurn = (churn && typeof churn === 'object' && churn.c > 0) ||
207
+ (typeof churn === 'number' && churn > 0);
208
+ if (hasChurn) {
209
+ caveats.push('recently changed: ' + (s.node.provenance && s.node.provenance.ref || s.id) + ' has high churn — verify before acting');
210
+ break; // one caveat entry is sufficient
211
+ }
212
+ }
213
+
214
+ // — low-coverage caveats (one per underperforming file, from signal 6)
215
+ for (const c of lowCoverageCaveats) {
216
+ caveats.push(c);
217
+ }
218
+
219
+ // — coverage unknown caveat: only when overlays were NOT provided
220
+ if (overlays === undefined || overlays === null) {
221
+ const hasCoverageGap = scored.some(s => s.node.coverage === undefined || s.node.coverage === null);
222
+ if (hasCoverageGap) {
223
+ caveats.push('coverage unknown: one or more nodes lack coverage overlay data');
224
+ }
225
+ }
226
+
227
+ // — mandatory advisory caveat (always present)
228
+ caveats.push('advisory route — verify before acting; KnoSky does not read code meaning');
229
+
230
+ // — staleness caveat when source_rev is present
231
+ const source_rev = ctx.city.source_rev || null;
232
+ if (source_rev) {
233
+ caveats.push('route is based on rev ' + source_rev + ' and may be stale');
234
+ }
235
+
236
+ // Slice into route + alternates
237
+ const routeEntries = scored.slice(0, clampedLimit);
238
+ const alternateEntries = scored.slice(clampedLimit, clampedLimit + 5);
239
+
240
+ function toEntry(s) {
241
+ const path = safeRef(s.node) || s.id;
242
+ return { path, id: s.id, reason: s.reason, score: s.score };
243
+ }
244
+
245
+ // Defense in depth: only isSafeRef() gates output (the previous `|| !path.startsWith('/')`
246
+ // clause was a bug — any path lacking a leading '/' but containing '..' would pass through
247
+ // it unfiltered; validateRouteDoc() catches that downstream today, but this filter should
248
+ // not have been able to let it through in the first place).
249
+ const route = routeEntries.map(toEntry).filter(e => isSafeRef(e.path));
250
+ const alternates = alternateEntries.map(toEntry).filter(e => isSafeRef(e.path));
251
+
252
+ // Confidence: direct -> ~0.95 ceiling; folder/district/edges/chain -> ~0.75; keyword -> ~0.6
253
+ let confidence = 0;
254
+ if (routeEntries.length > 0) {
255
+ const topScore = routeEntries[0].score;
256
+ if (matchStrength === 'direct') {
257
+ // direct hit scores at least 5; normalise to ~0.95 max
258
+ confidence = Math.min(0.95, topScore / 10);
259
+ } else if (matchStrength === 'folder' || matchStrength === 'district' ||
260
+ matchStrength === 'edges' || matchStrength === 'chain') {
261
+ confidence = Math.min(0.75, topScore / 10);
262
+ } else {
263
+ // keyword — lower ceiling
264
+ confidence = Math.min(0.6, topScore / 20);
265
+ }
266
+ confidence = Math.max(0, Math.min(1, confidence));
267
+ }
268
+
269
+ // Classify tests / docs from all candidates
270
+ const tests = [];
271
+ const docs = [];
272
+ for (const [, node] of candidateMap) {
273
+ const ref = safeRef(node);
274
+ if (!ref) continue;
275
+ if (isTest(node)) tests.push({ path: ref, id: node.id });
276
+ else if (isDoc(node)) docs.push({ path: ref, id: node.id });
277
+ }
278
+
279
+ const doc = makeRouteDoc({
280
+ destination,
281
+ route,
282
+ alternates,
283
+ caveats,
284
+ confidence,
285
+ source_rev,
286
+ });
287
+
288
+ // Attach tests + docs (extra fields beyond the base envelope — schema.validateRouteDoc does not reject them)
289
+ doc.tests = tests;
290
+ doc.docs = docs;
291
+
292
+ // Invariant: doc MUST pass validateRouteDoc
293
+ const validation = validateRouteDoc(doc);
294
+ if (!validation.ok) {
295
+ throw new Error('kcRoute produced an invalid route doc: ' + validation.errors.join('; '));
296
+ }
297
+
298
+ return doc;
299
+ }
@@ -0,0 +1,250 @@
1
+ // KnoSky protocol artifact schemas and validators (SAT-427 / KSV2-P2).
2
+ // Covers: route.json and intent-manifest.json envelope shapes.
3
+ // Pure Node stdlib, ESM — no third-party dependencies.
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Protocol version constant
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /** @type {string} */
10
+ export const PROTOCOL_VERSION = '1.0';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Internal helpers
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * Return true if `str` is an absolute path (Unix or Windows).
18
+ * @param {string} str
19
+ * @returns {boolean}
20
+ */
21
+ function isAbsolutePath(str) {
22
+ if (str.startsWith('/')) return true;
23
+ // Windows: C:\ or C:/
24
+ if (/^[A-Za-z]:[\\\/]/.test(str)) return true;
25
+ return false;
26
+ }
27
+
28
+ /**
29
+ * Return true if `str` contains a `..` path segment.
30
+ * Splits on both `/` and `\`.
31
+ * @param {string} str
32
+ * @returns {boolean}
33
+ */
34
+ function hasDotDotSegment(str) {
35
+ return str.split(/[/\\]/).some(seg => seg === '..');
36
+ }
37
+
38
+ /**
39
+ * Extract the path string from a route/manifest entry.
40
+ * An entry is either a plain string or an object with a `path` field.
41
+ * Returns null if the entry carries no recognisable path.
42
+ *
43
+ * @param {unknown} entry
44
+ * @returns {string|null}
45
+ */
46
+ function extractPathString(entry) {
47
+ if (typeof entry === 'string') return entry;
48
+ if (entry !== null && typeof entry === 'object' && typeof entry.path === 'string') {
49
+ return entry.path;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * Validate the absolute-path invariant over an array of entries.
56
+ * Pushes error messages into `errors` for each violation found.
57
+ *
58
+ * @param {unknown[]} entries Array of string or { path } objects.
59
+ * @param {string} fieldName Label for error messages.
60
+ * @param {string[]} errors Accumulator array.
61
+ */
62
+ function checkAbsolutePaths(entries, fieldName, errors) {
63
+ for (let i = 0; i < entries.length; i++) {
64
+ const p = extractPathString(entries[i]);
65
+ if (p === null) continue; // non-path entry — skip
66
+ if (isAbsolutePath(p)) {
67
+ errors.push(`${fieldName}[${i}] must not be an absolute path: ${JSON.stringify(p)}`);
68
+ }
69
+ if (hasDotDotSegment(p)) {
70
+ errors.push(`${fieldName}[${i}] must not contain a ".." path segment: ${JSON.stringify(p)}`);
71
+ }
72
+ }
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // makeRouteDoc
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Construct a KnoSky `route` artifact envelope.
81
+ *
82
+ * @param {object} opts
83
+ * @param {string} opts.destination Navigation target.
84
+ * @param {unknown[]} [opts.route] Ordered waypoints.
85
+ * @param {unknown[]} [opts.alternates] Alternate routes.
86
+ * @param {string[]} [opts.caveats] Advisory notes.
87
+ * @param {number} [opts.confidence] Confidence score [0..1].
88
+ * @param {string|null} [opts.source_rev] VCS revision that produced this doc.
89
+ * @returns {object}
90
+ */
91
+ export function makeRouteDoc({
92
+ destination,
93
+ route = [],
94
+ alternates = [],
95
+ caveats = [],
96
+ confidence = 0,
97
+ source_rev = null,
98
+ } = {}) {
99
+ return {
100
+ knosky_protocol: PROTOCOL_VERSION,
101
+ artifact_type: 'route',
102
+ advisory: true,
103
+ generated_at: new Date().toISOString(),
104
+ source_rev,
105
+ destination,
106
+ route,
107
+ alternates,
108
+ caveats,
109
+ confidence,
110
+ };
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // validateRouteDoc
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Validate a KnoSky `route` document.
119
+ * Collects every violation; `ok` is true only when `errors` is empty.
120
+ *
121
+ * @param {object} doc
122
+ * @returns {{ ok: boolean, errors: string[] }}
123
+ */
124
+ export function validateRouteDoc(doc) {
125
+ const errors = [];
126
+
127
+ if (doc.knosky_protocol !== '1.0') {
128
+ errors.push(`knosky_protocol must be "1.0", got: ${JSON.stringify(doc.knosky_protocol)}`);
129
+ }
130
+
131
+ if (doc.artifact_type !== 'route') {
132
+ errors.push(`artifact_type must be "route", got: ${JSON.stringify(doc.artifact_type)}`);
133
+ }
134
+
135
+ if (doc.advisory !== true) {
136
+ errors.push(`advisory must be true, got: ${JSON.stringify(doc.advisory)}`);
137
+ }
138
+
139
+ for (const field of ['route', 'alternates', 'caveats']) {
140
+ if (!Array.isArray(doc[field])) {
141
+ errors.push(`${field} must be an array, got: ${JSON.stringify(doc[field])}`);
142
+ }
143
+ }
144
+
145
+ if (typeof doc.confidence !== 'number' || doc.confidence < 0 || doc.confidence > 1) {
146
+ errors.push(`confidence must be a number in [0, 1], got: ${JSON.stringify(doc.confidence)}`);
147
+ }
148
+
149
+ // Absolute-path invariant applies to route[] and alternates[]
150
+ if (Array.isArray(doc.route)) {
151
+ checkAbsolutePaths(doc.route, 'route', errors);
152
+ }
153
+ if (Array.isArray(doc.alternates)) {
154
+ checkAbsolutePaths(doc.alternates, 'alternates', errors);
155
+ }
156
+
157
+ return { ok: errors.length === 0, errors };
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // makeIntentManifest
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * Construct a KnoSky `intent-manifest` artifact envelope.
166
+ *
167
+ * @param {object} opts
168
+ * @param {Array<{ path: string, sha256: string }>} [opts.paths] Files covered.
169
+ * @param {unknown[]} [opts.edges] Dependency edges.
170
+ * @param {string|null} [opts.expiry] ISO-8601 expiry timestamp or null.
171
+ * @param {object} opts.secret_scan REQUIRED secret-scan result object.
172
+ * @returns {object}
173
+ */
174
+ export function makeIntentManifest({
175
+ paths = [],
176
+ edges = [],
177
+ expiry = null,
178
+ secret_scan,
179
+ } = {}) {
180
+ return {
181
+ knosky_protocol: PROTOCOL_VERSION,
182
+ artifact_type: 'intent-manifest',
183
+ advisory: true,
184
+ generated_at: new Date().toISOString(),
185
+ paths,
186
+ edges,
187
+ expiry,
188
+ secret_scan,
189
+ };
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // validateIntentManifest
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Validate a KnoSky `intent-manifest` document.
198
+ * Collects every violation; `ok` is true only when `errors` is empty.
199
+ *
200
+ * @param {object} doc
201
+ * @returns {{ ok: boolean, errors: string[] }}
202
+ */
203
+ export function validateIntentManifest(doc) {
204
+ const errors = [];
205
+
206
+ if (doc.knosky_protocol !== '1.0') {
207
+ errors.push(`knosky_protocol must be "1.0", got: ${JSON.stringify(doc.knosky_protocol)}`);
208
+ }
209
+
210
+ if (doc.artifact_type !== 'intent-manifest') {
211
+ errors.push(`artifact_type must be "intent-manifest", got: ${JSON.stringify(doc.artifact_type)}`);
212
+ }
213
+
214
+ if (doc.advisory !== true) {
215
+ errors.push(`advisory must be true, got: ${JSON.stringify(doc.advisory)}`);
216
+ }
217
+
218
+ // paths must be an array of { path: string (non-empty), sha256: string }
219
+ if (!Array.isArray(doc.paths)) {
220
+ errors.push(`paths must be an array, got: ${JSON.stringify(doc.paths)}`);
221
+ } else {
222
+ for (let i = 0; i < doc.paths.length; i++) {
223
+ const entry = doc.paths[i];
224
+ if (!entry || typeof entry !== 'object') {
225
+ errors.push(`paths[${i}] must be an object`);
226
+ continue;
227
+ }
228
+ if (typeof entry.path !== 'string' || entry.path.length === 0) {
229
+ errors.push(`paths[${i}].path must be a non-empty string, got: ${JSON.stringify(entry.path)}`);
230
+ }
231
+ if (typeof entry.sha256 !== 'string') {
232
+ errors.push(`paths[${i}].sha256 must be a string, got: ${JSON.stringify(entry.sha256)}`);
233
+ }
234
+ }
235
+
236
+ // Absolute-path invariant on paths[].path
237
+ checkAbsolutePaths(doc.paths, 'paths', errors);
238
+ }
239
+
240
+ // secret_scan must be present and have a valid status
241
+ if (!doc.secret_scan || typeof doc.secret_scan !== 'object') {
242
+ errors.push('secret_scan is required and must be an object');
243
+ } else if (doc.secret_scan.status !== 'clean' && doc.secret_scan.status !== 'blocked') {
244
+ errors.push(
245
+ `secret_scan.status must be "clean" or "blocked", got: ${JSON.stringify(doc.secret_scan.status)}`,
246
+ );
247
+ }
248
+
249
+ return { ok: errors.length === 0, errors };
250
+ }
package/mcp/server.mjs CHANGED
@@ -4,6 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
5
  import { z } from "zod";
6
6
  import { load, search, getNode, listCategories, getProvenance, getRelated } from "../core/retrieve.mjs";
7
+ import { kcRoute } from "../core/route.mjs";
7
8
 
8
9
  const CITY = process.env.KC_CITY || process.argv[2];
9
10
  if (!CITY) { console.error("Knowledge City MCP: set KC_CITY (or pass a path) to a city-data.v2.json"); process.exit(1); }
@@ -70,5 +71,38 @@ server.registerTool("kc_related", {
70
71
  return { content: [{ type: "text", text: lines.join(NL) }], structuredContent: r };
71
72
  });
72
73
 
74
+ server.registerTool("kc_route", {
75
+ title: "Route to a destination (agent GPS)",
76
+ description: "ADVISORY, metadata-only route through the repo towards a destination. Returns where-to-look-first (ranked waypoints), alternates, related tests, related docs, caveats, and a confidence score. Does NOT read or analyse code — structural navigation only, local, no network. Verify findings before acting.",
77
+ inputSchema: {
78
+ destination: z.string().max(400).describe("navigation target — e.g. file:src/auth.js, folder:src/auth, or keywords"),
79
+ limit: z.number().int().min(1).max(20).optional().describe("max route entries (default 8, max 20)"),
80
+ },
81
+ annotations: { readOnlyHint: true, openWorldHint: false },
82
+ }, async ({ destination, limit }) => {
83
+ const doc = kcRoute(ctx, String(destination).slice(0, 400), { limit: limit ? Math.min(Math.max(1, limit), 20) : 8 });
84
+ const lines = [
85
+ `Route to: ${doc.destination} (confidence ${(doc.confidence * 100).toFixed(0)}%)`,
86
+ '',
87
+ 'Route:',
88
+ ...(doc.route.map((e, i) => ` ${i + 1}. ${e.path} [${e.reason}]`)),
89
+ ];
90
+ if (doc.alternates && doc.alternates.length > 0) {
91
+ lines.push('', 'Alternates:');
92
+ for (const e of doc.alternates) lines.push(` - ${e.path} [${e.reason}]`);
93
+ }
94
+ if (doc.tests && doc.tests.length > 0) {
95
+ lines.push('', 'Tests: ' + doc.tests.map(e => e.path).join(', '));
96
+ }
97
+ if (doc.docs && doc.docs.length > 0) {
98
+ lines.push('', 'Docs: ' + doc.docs.map(e => e.path).join(', '));
99
+ }
100
+ if (doc.caveats && doc.caveats.length > 0) {
101
+ lines.push('', 'Caveats:');
102
+ for (const c of doc.caveats) lines.push(` ⚠ ${c}`);
103
+ }
104
+ return { content: [{ type: "text", text: lines.join('\n') }], structuredContent: doc };
105
+ });
106
+
73
107
  await server.connect(new StdioServerTransport());
74
108
  console.error(`knowledge-city MCP ready — ${ctx.city.node_count} nodes, ${(ctx.city.categories||[]).length} categories`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "knosky",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Turn any repo or docs folder into an explorable city, plus a local MCP that grounds your AI in your own source with citations. Local-first, free, $0 tokens.",
5
5
  "type": "module",
6
6
  "bin": {