knosky 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to KnoSky. Versions are git-tagged on this repo.
4
4
 
5
+ ## [0.4.0] - 2026-06-29 — File Connections + churn (code-intel)
6
+
7
+ ### Added
8
+ - **File Connections:** import/dependency edges drawn as roads between buildings — select a file to see what it imports and what imports it. Per-language import scan over a bounded prefix; specifiers are resolved to repo files and then discarded (file-to-file edges only).
9
+ - **Churn heat:** recently-changed files glow on the map, from `git log` (per-file commit count only).
10
+ - **MCP `kc_related`:** ask your assistant "what connects to this file?" — out-edges, in-edges, and churn, with citations.
11
+ - Flags: `--no-graph`, `--no-churn` to omit either signal.
12
+
13
+ ### Notes
14
+ - Strictly **file-level metadata** (decision D-155): no symbol names, no ASTs, no code bodies, no commit messages/diffs. KnoSky maps how files connect; it does not analyze your code.
15
+
5
16
  ## [0.3.0] - 2026-06-29 — One-command launcher (npx)
6
17
 
7
18
  ### Added
package/README.md CHANGED
@@ -32,14 +32,13 @@ Developers, founders, and architects sitting on a sprawling repo or knowledge ba
32
32
 
33
33
  ## How it works
34
34
 
35
- **Quickstart:**
35
+ **Quickstart — one command:**
36
36
  ```bash
37
- git clone https://github.com/SathiaAI/knosky && cd knosky && npm install
38
- node bin/knosky.mjs /path/to/your/repo
37
+ npx knosky .
39
38
  ```
40
- Indexes the folder, opens the city, prints the MCP config for your AI assistant (Claude Code / Claude Desktop / Cursor / VS Code) plus a few starter prompts, and starts the local connector. Flags: `--no-open`, `--no-serve`.
39
+ Indexes the current folder, opens the city, prints the MCP config for your AI assistant (Claude Code / Claude Desktop / Cursor / VS Code) plus a few starter prompts, and starts the local connector. Point it anywhere with `npx knosky /path/to/your/repo`. Flags: `--no-open`, `--no-serve`.
41
40
 
42
- *Coming to npm:* `npx knosky .` the same thing in one command, no clone.
41
+ Prefer a clone? `git clone https://github.com/SathiaAI/knosky && cd knosky && npm install && node bin/knosky.mjs .`
43
42
 
44
43
  Want the individual pieces instead? Read on.
45
44
 
@@ -68,7 +67,9 @@ Or add to your Claude Desktop / Cursor / VS Code MCP config:
68
67
  "env": { "KC_CITY": "/abs/path/city-data.json" }
69
68
  }
70
69
  ```
71
- Then ask: *"search KnoSky for what we decided about authentication."* Read-only tools exposed: `kc_search`, `kc_get_node`, `kc_list_categories`, `kc_get_provenance`.
70
+ Then ask: *"search KnoSky for what we decided about authentication."* Read-only tools exposed: `kc_search`, `kc_get_node`, `kc_list_categories`, `kc_get_provenance`, `kc_related`.
71
+
72
+ **See how your code connects.** Select a file in the city to see its **connections** (roads to the files it imports / that import it) and **churn** (recently-changed files glow). Or ask your assistant *"what connects to src/auth.js?"* — file-level structure only, not code analysis.
72
73
 
73
74
  ---
74
75
 
package/core/churn.mjs ADDED
@@ -0,0 +1,20 @@
1
+ // Git churn (D-155): per-file commit count (windowed) + last-commit timestamp ONLY.
2
+ // No commit messages, diffs, hunks, authors, or line-level churn retained.
3
+ import { execSync } from 'node:child_process';
4
+
5
+ export function gitChurn(root) {
6
+ const counts = {}, last = {};
7
+ try {
8
+ const out = execSync('git log --since=90.days.ago --name-only --pretty=format:%ct -- .', { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 });
9
+ let ts = 0;
10
+ for (const raw of out.split(/\r?\n/)) {
11
+ const line = raw.trim();
12
+ if (!line) continue;
13
+ if (/^\d+$/.test(line)) { ts = parseInt(line, 10); continue; }
14
+ const rel = line.replace(/\\/g, '/');
15
+ counts[rel] = (counts[rel] || 0) + 1;
16
+ if (!last[rel] || ts > last[rel]) last[rel] = ts;
17
+ }
18
+ } catch { /* no git / no history -> empty */ }
19
+ return { counts, last };
20
+ }
package/core/contract.mjs CHANGED
@@ -7,7 +7,7 @@ export const SCHEMA_VERSION = '2.0';
7
7
  // The ONLY node fields that may ever be serialized into the index. Anything else is dropped/flagged.
8
8
  export const NODE_FIELD_ALLOWLIST = [
9
9
  'id', 'kind', 'title', 'summary', 'category', 'status',
10
- 'fact_date', 'tags', 'headings', 'links', 'provenance', 'visibility', 'sensitive',
10
+ 'fact_date', 'tags', 'headings', 'links', 'churn', 'provenance', 'visibility', 'sensitive',
11
11
  ];
12
12
  export const NODE_REQUIRED = ['id', 'kind', 'title', 'category', 'links', 'provenance'];
13
13
  export const PROVENANCE_REQUIRED = ['store', 'ref'];
package/core/edges.mjs ADDED
@@ -0,0 +1,44 @@
1
+ // File-level import edges (D-155): file-to-file ONLY. Import specifiers are read transiently
2
+ // and discarded after resolution; nothing but the resolved file-to-file edge is ever stored.
3
+ // No symbol names, no raw import lines, no body text. Reviewer rule: only file paths survive.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ const PATTERNS = [
8
+ /^\s*import\s+[^'"]*?from\s*['"]([^'"]+)['"]/, // js/ts: import x from 'y'
9
+ /^\s*import\s*['"]([^'"]+)['"]/, // js/ts: import 'y'
10
+ /\brequire\(\s*['"]([^'"]+)['"]\s*\)/, // js: require('y')
11
+ /^\s*export\s+[^'"]*?from\s*['"]([^'"]+)['"]/, // js/ts: export ... from 'y'
12
+ /^\s*from\s+([.\w/]+)\s+import\b/, // python: from x import
13
+ /^\s*#include\s*["<]([^">]+)[">]/, // c/c++: #include "y"
14
+ ];
15
+
16
+ // Read up to 8KB / 150 lines, scan for import specifiers, then drop the buffer. Returns specifier strings only.
17
+ export function extractImportSpecifiers(filePath) {
18
+ let txt = '';
19
+ try { const fd = fs.openSync(filePath, 'r'); const buf = Buffer.alloc(8192); const n = fs.readSync(fd, buf, 0, 8192, 0); fs.closeSync(fd); txt = buf.slice(0, n).toString('utf8'); }
20
+ catch { return []; }
21
+ const lines = txt.split(/\r?\n/);
22
+ txt = '';
23
+ const specs = new Set();
24
+ for (let i = 0; i < lines.length && i < 150; i++) {
25
+ for (const re of PATTERNS) { const m = lines[i].match(re); if (m && m[1]) { specs.add(m[1]); break; } }
26
+ }
27
+ return [...specs];
28
+ }
29
+
30
+ const EXTS = ['', '.js', '.ts', '.tsx', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.java', '.rb', '.php', '.c', '.h', '.cpp', '.hpp', '.cs', '.kt', '.swift'];
31
+
32
+ // Resolve a relative specifier to an indexed repo file (rel path). Returns the target rel or null.
33
+ // Only relative/internal specifiers resolve; bare package names return null (external = no edge).
34
+ export function resolveSpec(fileRel, spec, relSet) {
35
+ if (!spec || !(spec.startsWith('.') || spec.startsWith('/'))) return null;
36
+ const baseDir = path.posix.dirname(fileRel);
37
+ const target = path.posix.normalize(path.posix.join(spec.startsWith('/') ? '.' : baseDir, spec)).replace(/^\.\//, '');
38
+ for (const e of EXTS) {
39
+ if (relSet.has(target + e)) return target + e;
40
+ const idx = path.posix.join(target, 'index' + (e || '.js'));
41
+ if (relSet.has(idx)) return idx;
42
+ }
43
+ return null;
44
+ }
@@ -5,6 +5,8 @@ import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { execSync } from 'node:child_process';
7
7
  import { SCHEMA_VERSION, IGNORE_DEFAULTS, deriveCategories, serializeNode, validateCity, setRedactTerms, findSecrets } from './contract.mjs';
8
+ import { extractImportSpecifiers, resolveSpec } from './edges.mjs';
9
+ import { gitChurn } from './churn.mjs';
8
10
 
9
11
  const args = Object.fromEntries(process.argv.slice(2).map((a, i, arr) => a.startsWith('--') ? [a.slice(2), arr[i + 1]] : []).filter(Boolean));
10
12
  const ROOT = args.root && path.resolve(args.root);
@@ -13,6 +15,8 @@ const MAX = parseInt(args.max || '6000', 10);
13
15
  const SHARE_SAFE = process.argv.includes('--share-safe');
14
16
  const INCLUDE_ABS = process.argv.includes('--include-absolute-root');
15
17
  const ALLOW_LEAKS = process.argv.includes('--allow-leaks');
18
+ const NO_GRAPH = process.argv.includes('--no-graph');
19
+ const NO_CHURN = process.argv.includes('--no-churn');
16
20
  if (!ROOT || !fs.existsSync(ROOT)) { console.log('usage: node fs-indexer.mjs --root <dir> [--out <file>] [--max N] [--redact a,b]'); process.exit(1); }
17
21
  const REDACT = (args.redact || '').split(',').map(s => s.trim()).filter(Boolean);
18
22
  if (REDACT.length) setRedactTerms(REDACT);
@@ -94,6 +98,7 @@ function walk(dir, depth) {
94
98
  tags: ext ? [ext.slice(1)] : [],
95
99
  headings,
96
100
  links: [],
101
+ importSpecs: (!NO_GRAPH && readable) ? extractImportSpecifiers(fp) : [],
97
102
  provenance: { store: 'fs', ref: rel, source_rev: REV, fetched_at: new Date().toISOString() },
98
103
  visibility: 'internal',
99
104
  });
@@ -115,11 +120,27 @@ try {
115
120
  }
116
121
  } catch (e) { /* git unavailable: keep the conservative-parser result */ }
117
122
 
118
- // light edges: markdown relative links that resolve to an indexed node
119
- const byRel = new Map(raw.map(n => [n.provenance.ref, n]));
120
- for (const n of raw) {
121
- if (n.kind !== 'doc') continue;
122
- // (edges intentionally minimal in v1 fs; reserved for Phase 2 — keep links=[] unless trivially resolvable)
123
+ // File Connections (D-155): resolve import specifiers to indexed files; store ONLY file-to-file edges.
124
+ const relSet = new Set(raw.map(n => n.provenance.ref));
125
+ if (!NO_GRAPH) {
126
+ for (const n of raw) {
127
+ const targets = new Set();
128
+ for (const spec of (n.importSpecs || [])) {
129
+ const t = resolveSpec(n.provenance.ref, spec, relSet);
130
+ if (t) { const id = 'fs:' + t; if (id !== n.id) targets.add(id); }
131
+ }
132
+ n.links = [...targets];
133
+ }
134
+ }
135
+ for (const n of raw) delete n.importSpecs; // discard specifiers; only the resolved file-to-file edge survives
136
+
137
+ // Churn overlay (D-155): per-file commit count + last-commit ts only (no messages/diffs/authors/lines).
138
+ if (!NO_CHURN) {
139
+ const ch = gitChurn(ROOT);
140
+ for (const n of raw) {
141
+ const c = ch.counts[n.provenance.ref] || 0;
142
+ if (c > 0) n.churn = { c, t: ch.last[n.provenance.ref] || 0, b: c >= 6 ? 3 : c >= 3 ? 2 : 1 };
143
+ }
123
144
  }
124
145
 
125
146
  const nodes = raw.map(serializeNode);
package/core/retrieve.mjs CHANGED
@@ -51,3 +51,13 @@ export function getProvenance(ctx, id) {
51
51
  if (!n) return null;
52
52
  return { id, title: n.title, provenance: n.provenance, links: n.links || [] };
53
53
  }
54
+
55
+ // related = file connections (D-155): out-edges (imports) + in-edges (imported-by) + churn signal. File-level only.
56
+ export function getRelated(ctx, id) {
57
+ const n = ctx.byId.get(id);
58
+ if (!n) return null;
59
+ const ref = (m) => ({ id: m.id, title: m.title, source: m.provenance && m.provenance.ref });
60
+ const imports = (n.links || []).map(tid => ctx.byId.get(tid)).filter(Boolean).map(ref);
61
+ const importedBy = ctx.city.nodes.filter(m => (m.links || []).includes(id)).map(ref);
62
+ return { id, title: n.title, churn: n.churn || null, imports, importedBy };
63
+ }
package/mcp/server.mjs CHANGED
@@ -3,13 +3,13 @@
3
3
  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
- import { load, search, getNode, listCategories, getProvenance } from "../core/retrieve.mjs";
6
+ import { load, search, getNode, listCategories, getProvenance, getRelated } from "../core/retrieve.mjs";
7
7
 
8
8
  const CITY = process.env.KC_CITY || process.argv[2];
9
9
  if (!CITY) { console.error("Knowledge City MCP: set KC_CITY (or pass a path) to a city-data.v2.json"); process.exit(1); }
10
10
  const ctx = load(CITY);
11
11
 
12
- const server = new McpServer({ name: "knowledge-city", version: "0.2.0" });
12
+ const server = new McpServer({ name: "knowledge-city", version: "0.4.0" });
13
13
 
14
14
  server.registerTool("kc_search", {
15
15
  title: "Search the Knowledge City",
@@ -54,5 +54,21 @@ server.registerTool("kc_get_provenance", {
54
54
  return { content: [{ type: "text", text: p ? JSON.stringify(p, null, 2) : `No item with id ${id}` }], structuredContent: p || {} };
55
55
  });
56
56
 
57
+ server.registerTool("kc_related", {
58
+ title: "Related files (connections)",
59
+ description: "How a file connects to others in this project: which files it imports (out), which files import it (in), and its recent-change (churn) signal. File-level structure with citations, not code analysis.",
60
+ inputSchema: { id: z.string().max(400).describe("node id, e.g. fs:src/auth.js") },
61
+ annotations: { readOnlyHint: true },
62
+ }, async ({ id }) => {
63
+ const r = getRelated(ctx, id);
64
+ if (!r) return { content: [{ type: "text", text: "No item with id " + id }], structuredContent: {} };
65
+ const NL = String.fromCharCode(10);
66
+ const lines = [r.title + " (" + id + ")"];
67
+ if (r.churn) lines.push("recent changes: " + r.churn.c + " commit(s), heat " + r.churn.b);
68
+ lines.push("imports (" + r.imports.length + "): " + (r.imports.map(x => x.source || x.id).join(", ") || "none"));
69
+ lines.push("imported by (" + r.importedBy.length + "): " + (r.importedBy.map(x => x.source || x.id).join(", ") || "none"));
70
+ return { content: [{ type: "text", text: lines.join(NL) }], structuredContent: r };
71
+ });
72
+
57
73
  await server.connect(new StdioServerTransport());
58
74
  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.3.0",
3
+ "version": "0.4.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": {
@@ -184,7 +184,7 @@
184
184
  function adapt(n){const versions=Array.isArray(n.versions)&&n.versions.length?n.versions:[{v:n.version||"1.0",date:n.fact_date||GD,note:n.status||""}];
185
185
  const dc=n.category||n.district;const d=DCFG[dc]?dc:(DKEYS[0]||"general");
186
186
  return {id:n.id,d,t:n.title||n.id,s:n.summary||"",kind:n.kind,status:n.status||"",
187
- versions,prov:n.provenance||null,links:(n.links||[]),sensitive:!!n.sensitive};}
187
+ versions,prov:n.provenance||null,links:(n.links||[]),churn:n.churn||null,sensitive:!!n.sensitive};}
188
188
  // NODES/byId are (re)built by ingest() from the postMessage payload.
189
189
  let NODES=[];
190
190
  let byId={};
@@ -501,6 +501,15 @@
501
501
  ctx.fillStyle="rgba(255,233,166,.28)";ctx.beginPath();ctx.arc(B[0],B[1],7,0,7);ctx.fill();ctx.setLineDash([2,6]);});
502
502
  ctx.setLineDash([]);ctx.fillStyle="#fff2c4";ctx.beginPath();ctx.arc(A[0],A[1],4.5,0,7);ctx.fill();ctx.restore();}
503
503
 
504
+ // ---------- churn heat: ground glow under recently-changed files (D-155, file-level metadata only) ----------
505
+ function drawChurn(){
506
+ for(const n of NODES){ const ch=n.churn; if(!ch||!ch.b||ch.b<2||filteredHidden.has(n.id)) continue;
507
+ const col = ch.b>=3 ? 'rgba(242,104,63,' : 'rgba(232,178,74,';
508
+ ctx.fillStyle=col+'0.20)'; diamondPath(n.bx,n.by,TW*0.62,TH*0.62); ctx.fill();
509
+ ctx.fillStyle=col+'0.32)'; diamondPath(n.bx,n.by,TW*0.40,TH*0.40); ctx.fill();
510
+ }
511
+ }
512
+
504
513
  // ---------- cars (more realistic iso car) ----------
505
514
  const CARCOL=["#e85d54","#3f7fd0","#e8c44e","#43b07a","#d9dde4","#9a6cd0"];
506
515
  // Sprite columns: 0=up-left(-x) 1=up-right(-y) 2=down-right(+x) 3=down-left(+y). Each car faces its travel dir.
@@ -569,7 +578,7 @@
569
578
  function render(){NOW=performance.now()-T0;moveSky();ctx.setTransform(DPR,0,0,DPR,0,0);drawBackdrop();
570
579
  ctx.save();ctx.translate(cam.x,cam.y);ctx.scale(cam.zoom,cam.zoom);
571
580
  ctx.globalAlpha=Math.min(1,NOW/450);
572
- drawIslandShadow();drawSlab();drawGround();drawCloudShadows();drawShadows();
581
+ drawIslandShadow();drawSlab();drawGround();drawCloudShadows();drawShadows();drawChurn();
573
582
  const list=OBJ.slice();drawCars(list);
574
583
  list.sort((a,b)=>(a.gx+a.gy)-(b.gx+b.gy)||a.gx-b.gx);
575
584
  for(const o of list){