@rahmantech/knl-view 0.2.0 → 0.3.1

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,329 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { knlGlobalDir } from '../config.js'
4
+
5
+ export const SUPPORTED_ECOSYSTEMS = new Set(['npm', 'go', 'cargo', 'pypi'])
6
+
7
+ /**
8
+ * Validate that a module name does not contain traversal tokens, null bytes, or invalid paths.
9
+ */
10
+ export function isValidModuleName(name) {
11
+ if (!name || typeof name !== 'string') return false
12
+ if (name.includes('\0')) return false
13
+ const segments = name.replace(/\\/g, '/').split('/')
14
+ for (const seg of segments) {
15
+ if (seg === '.' || seg === '..' || !seg) return false
16
+ }
17
+ return true
18
+ }
19
+
20
+ /**
21
+ * Returns the path to the global modules catalog directory (~/.knlview/modules).
22
+ */
23
+ export function modulesCatalogDir(home = null, options = {}) {
24
+ const base = knlGlobalDir(home, options)
25
+ return path.join(base, 'modules')
26
+ }
27
+
28
+ /**
29
+ * Default global AnchorDB model name for an ecosystem.
30
+ */
31
+ export function defaultEcosystemModel(ecosystem) {
32
+ const eco = String(ecosystem || '').toLowerCase()
33
+ if (!SUPPORTED_ECOSYSTEMS.has(eco)) {
34
+ throw new Error(`Unsupported ecosystem "${ecosystem}". Supported: ${[...SUPPORTED_ECOSYSTEMS].join(', ')}`)
35
+ }
36
+ return `modules-${eco}`
37
+ }
38
+
39
+ export function formatModuleKey(pkgName, version) {
40
+ return `${pkgName}@${version}`
41
+ }
42
+
43
+ export function formatExternalPackageNodeId(ecosystem, pkgName, version) {
44
+ return `ext:${ecosystem}/${pkgName}@${version}`
45
+ }
46
+
47
+ export function formatExternalSymbolNodeId(ecosystem, pkgName, version, symbol) {
48
+ return `ext:${ecosystem}/${pkgName}@${version}#${symbol}`
49
+ }
50
+
51
+ /**
52
+ * Read the catalog for a specific ecosystem ('npm', 'go', 'cargo', 'pypi').
53
+ */
54
+ export async function readModulesCatalog(ecosystem, options = {}) {
55
+ const eco = String(ecosystem || '').toLowerCase()
56
+ if (!SUPPORTED_ECOSYSTEMS.has(eco)) return {}
57
+ const dir = modulesCatalogDir(options.home, options)
58
+ const file = path.join(dir, `${eco}.json`)
59
+ try {
60
+ const raw = await fs.readFile(file, 'utf8')
61
+ const parsed = JSON.parse(raw)
62
+ return parsed && typeof parsed === 'object' ? parsed : {}
63
+ } catch {
64
+ return {}
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Check if a module version is already indexed in the catalog.
70
+ */
71
+ export async function isModuleSynced(ecosystem, pkgName, version, options = {}) {
72
+ const catalog = await readModulesCatalog(ecosystem, options)
73
+ const key = formatModuleKey(pkgName, version)
74
+ return Boolean(catalog[key]?.syncedAt)
75
+ }
76
+
77
+ /**
78
+ * Record discovered modules and symbols to ~/.knlview/modules/<ecosystem>.json.
79
+ * Stores lightweight metadata and symbolCount to avoid bloat in the local JSON catalog.
80
+ */
81
+ export async function updateModulesCatalog(ecosystem, entries, options = {}) {
82
+ const eco = String(ecosystem || '').toLowerCase()
83
+ if (!SUPPORTED_ECOSYSTEMS.has(eco)) return
84
+ if (!entries || !Object.keys(entries).length) return
85
+ const dir = modulesCatalogDir(options.home, options)
86
+ const file = path.join(dir, `${eco}.json`)
87
+ try {
88
+ await fs.mkdir(dir, { recursive: true })
89
+ const existing = await readModulesCatalog(eco, options)
90
+ for (const [key, item] of Object.entries(entries)) {
91
+ const prev = existing[key] || {}
92
+ const symbolCount = item.symbolCount !== undefined
93
+ ? item.symbolCount
94
+ : Array.isArray(item.symbols)
95
+ ? item.symbols.length
96
+ : (prev.symbolCount || 0)
97
+
98
+ existing[key] = {
99
+ name: item.name,
100
+ version: item.version,
101
+ ecosystem: eco,
102
+ model: item.model || prev.model || defaultEcosystemModel(eco),
103
+ nodeId: item.nodeId || prev.nodeId || formatExternalPackageNodeId(eco, item.name, item.version),
104
+ symbolCount,
105
+ syncedAt: item.syncedAt || prev.syncedAt || Date.now()
106
+ }
107
+ }
108
+ await fs.writeFile(file, JSON.stringify(existing, null, 2) + '\n', 'utf8')
109
+ } catch {
110
+ // Fail gracefully: catalog is an informational index, never fatal.
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Detect project dependency versions from on-disk manifests:
116
+ * package.json, go.mod, Cargo.toml, requirements.txt.
117
+ */
118
+ export async function detectProjectManifests(rootAbs) {
119
+ const manifests = {
120
+ npm: {},
121
+ go: {},
122
+ cargo: {},
123
+ pypi: {}
124
+ }
125
+
126
+ // 1. npm (package.json)
127
+ try {
128
+ const pkgRaw = await fs.readFile(path.join(rootAbs, 'package.json'), 'utf8')
129
+ const pkg = JSON.parse(pkgRaw)
130
+ const all = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies }
131
+ for (const [name, ver] of Object.entries(all)) {
132
+ if (typeof ver === 'string') {
133
+ manifests.npm[name] = ver.replace(/^[\^~>=<]/, '').trim() || 'latest'
134
+ }
135
+ }
136
+ } catch {}
137
+
138
+ // 2. Go (go.mod)
139
+ try {
140
+ const text = await fs.readFile(path.join(rootAbs, 'go.mod'), 'utf8')
141
+ const singleMatch = text.matchAll(/^require\s+([^\s()]+)\s+([^\s()]+)/gm)
142
+ for (const m of singleMatch) manifests.go[m[1]] = m[2]
143
+ const blockMatch = text.match(/require\s*\(([\s\S]*?)\)/m)
144
+ if (blockMatch) {
145
+ for (const line of blockMatch[1].split('\n')) {
146
+ const parts = line.trim().split(/\s+/)
147
+ if (parts.length >= 2 && !parts[0].startsWith('//')) {
148
+ manifests.go[parts[0]] = parts[1]
149
+ }
150
+ }
151
+ }
152
+ } catch {}
153
+
154
+ // 3. Rust (Cargo.toml)
155
+ try {
156
+ const text = await fs.readFile(path.join(rootAbs, 'Cargo.toml'), 'utf8')
157
+ let inDeps = false
158
+ for (const line of text.split('\n')) {
159
+ const trimmed = line.trim()
160
+ if (
161
+ trimmed.startsWith('[dependencies]') ||
162
+ trimmed.startsWith('[dev-dependencies]') ||
163
+ trimmed.startsWith('[build-dependencies]')
164
+ ) {
165
+ inDeps = true
166
+ continue
167
+ }
168
+ if (trimmed.startsWith('[')) {
169
+ inDeps = false
170
+ continue
171
+ }
172
+ if (inDeps && trimmed && !trimmed.startsWith('#')) {
173
+ const eqIdx = trimmed.indexOf('=')
174
+ if (eqIdx > 0) {
175
+ const name = trimmed.slice(0, eqIdx).trim()
176
+ const val = trimmed.slice(eqIdx + 1).trim()
177
+ let ver = 'unknown'
178
+ const strMatch = val.match(/^"([^"]+)"/)
179
+ if (strMatch) ver = strMatch[1]
180
+ const objMatch = val.match(/version\s*=\s*"([^"]+)"/)
181
+ if (objMatch) ver = objMatch[1]
182
+ manifests.cargo[name] = ver
183
+ }
184
+ }
185
+ }
186
+ } catch {}
187
+
188
+ // 4. Python (requirements.txt)
189
+ try {
190
+ const reqText = await fs.readFile(path.join(rootAbs, 'requirements.txt'), 'utf8')
191
+ for (const line of reqText.split('\n')) {
192
+ const trimmed = line.trim()
193
+ if (!trimmed || trimmed.startsWith('#')) continue
194
+ const m = trimmed.match(/^([a-zA-Z0-9_.-]+)(?:[=<>~!\s]*([a-zA-Z0-9_.-]+))?/)
195
+ if (m) manifests.pypi[m[1].toLowerCase()] = m[2] || 'unknown'
196
+ }
197
+ } catch {}
198
+
199
+ return manifests
200
+ }
201
+
202
+ /**
203
+ * Resolve the directory of an installed external module on disk.
204
+ * Supports npm (node_modules), Go (GOPATH/pkg/mod or vendor), Cargo (cargo cache or vendor),
205
+ * and Python (venv site-packages).
206
+ */
207
+ export async function resolveModuleLocation(ecosystem, moduleName, options = {}) {
208
+ const eco = String(ecosystem || '').toLowerCase()
209
+ if (!SUPPORTED_ECOSYSTEMS.has(eco) || !isValidModuleName(moduleName)) return null
210
+ const root = path.resolve(options.projectRoot || process.cwd())
211
+ const manifests = options.manifests || await detectProjectManifests(root)
212
+
213
+ if (eco === 'npm') {
214
+ // 1. Check node_modules in projectRoot and walk up if necessary
215
+ let curr = root
216
+ let foundDir = null
217
+ while (true) {
218
+ const baseModules = path.join(curr, 'node_modules')
219
+ const candidate = path.resolve(baseModules, moduleName)
220
+ if (candidate.startsWith(baseModules + path.sep)) {
221
+ try {
222
+ const stat = await fs.stat(candidate)
223
+ if (stat.isDirectory()) {
224
+ foundDir = candidate
225
+ break
226
+ }
227
+ } catch {}
228
+ }
229
+ const parent = path.dirname(curr)
230
+ if (parent === curr) break
231
+ curr = parent
232
+ }
233
+
234
+ let version = manifests.npm[moduleName] || 'unknown'
235
+ if (foundDir) {
236
+ try {
237
+ const pkgText = await fs.readFile(path.join(foundDir, 'package.json'), 'utf8')
238
+ const pkg = JSON.parse(pkgText)
239
+ if (pkg.version) version = pkg.version
240
+ } catch {}
241
+ return { location: foundDir, version, name: moduleName, ecosystem: eco }
242
+ }
243
+ } else if (eco === 'go') {
244
+ // 1. Check local vendor
245
+ const vendorBase = path.join(root, 'vendor')
246
+ const vendorDir = path.resolve(vendorBase, moduleName)
247
+ if (vendorDir.startsWith(vendorBase + path.sep)) {
248
+ try {
249
+ const stat = await fs.stat(vendorDir)
250
+ if (stat.isDirectory()) {
251
+ return { location: vendorDir, version: manifests.go[moduleName] || 'vendor', name: moduleName, ecosystem: eco }
252
+ }
253
+ } catch {}
254
+ }
255
+
256
+ // 2. Check GOPATH
257
+ const gopath = process.env.GOPATH || path.join(options.home || process.env.HOME || '', 'go')
258
+ const version = manifests.go[moduleName]
259
+ if (version) {
260
+ const modBase = path.join(gopath, 'pkg', 'mod')
261
+ const modDir = path.resolve(modBase, `${moduleName}@${version}`)
262
+ if (modDir.startsWith(modBase + path.sep)) {
263
+ try {
264
+ const stat = await fs.stat(modDir)
265
+ if (stat.isDirectory()) {
266
+ return { location: modDir, version, name: moduleName, ecosystem: eco }
267
+ }
268
+ } catch {}
269
+ }
270
+ }
271
+ } else if (eco === 'cargo') {
272
+ // 1. Check local vendor
273
+ const vendorBase = path.join(root, 'vendor')
274
+ const vendorDir = path.resolve(vendorBase, moduleName)
275
+ if (vendorDir.startsWith(vendorBase + path.sep)) {
276
+ try {
277
+ const stat = await fs.stat(vendorDir)
278
+ if (stat.isDirectory()) {
279
+ return { location: vendorDir, version: manifests.cargo[moduleName] || 'vendor', name: moduleName, ecosystem: eco }
280
+ }
281
+ } catch {}
282
+ }
283
+
284
+ // 2. Check cargo registry cache (~/.cargo/registry/src)
285
+ const cargoHome = process.env.CARGO_HOME || path.join(options.home || process.env.HOME || '', '.cargo')
286
+ const regSrc = path.join(cargoHome, 'registry', 'src')
287
+ const version = manifests.cargo[moduleName]
288
+ try {
289
+ const registries = await fs.readdir(regSrc)
290
+ for (const reg of registries) {
291
+ const targetPrefix = version ? `${moduleName}-${version}` : `${moduleName}-`
292
+ const regDir = path.join(regSrc, reg)
293
+ const entries = await fs.readdir(regDir)
294
+ const match = entries.find((e) => version ? e === targetPrefix : e.startsWith(targetPrefix))
295
+ if (match) {
296
+ const location = path.resolve(regDir, match)
297
+ if (location.startsWith(regDir + path.sep)) {
298
+ const matchedVersion = version || match.slice(moduleName.length + 1)
299
+ return { location, version: matchedVersion, name: moduleName, ecosystem: eco }
300
+ }
301
+ }
302
+ }
303
+ } catch {}
304
+ } else if (eco === 'pypi') {
305
+ const version = manifests.pypi[moduleName.toLowerCase()] || 'unknown'
306
+ // 1. Check .venv and venv site-packages
307
+ const venvDirs = ['.venv', 'venv']
308
+ for (const venv of venvDirs) {
309
+ const libDir = path.join(root, venv, 'lib')
310
+ try {
311
+ const pyDirs = await fs.readdir(libDir)
312
+ for (const py of pyDirs) {
313
+ const spBase = path.join(libDir, py, 'site-packages')
314
+ const sp = path.resolve(spBase, moduleName)
315
+ if (sp.startsWith(spBase + path.sep)) {
316
+ try {
317
+ const stat = await fs.stat(sp)
318
+ if (stat.isDirectory()) {
319
+ return { location: sp, version, name: moduleName, ecosystem: eco }
320
+ }
321
+ } catch {}
322
+ }
323
+ }
324
+ } catch {}
325
+ }
326
+ }
327
+
328
+ return null
329
+ }
@@ -27,8 +27,7 @@ import { createPathMatcher } from '../config.js'
27
27
 
28
28
  export const SOURCE_RELATIONS = ['imports', 'imported_by']
29
29
  export const SOURCE_TOMBSTONE = 2
30
- // OpStatus on the wire: 1 = Active, 2 = Archived (TOMBSTONE).
31
- export const OP_ACTIVE = 1
30
+ // OpStatus on the wire: 2 = Archived (TOMBSTONE).
32
31
  export const OP_ARCHIVED = 2
33
32
  // Symbol layer, same AnchorDB model as the files: a file defines symbols,
34
33
  // symbols call symbols. Mirrored both directions like imports, for the same
@@ -78,12 +78,14 @@ function firstDescendant(node, type) {
78
78
  function specsFromTree(grammar, tree) {
79
79
  const specs = []
80
80
  const names = []
81
+ const bindings = []
81
82
  const visit = (node) => {
82
83
  if (grammar === 'javascript' || grammar === 'typescript' || grammar === 'tsx') {
83
84
  if (node.type === 'import_statement' || node.type === 'export_statement') {
84
85
  const source = node.childForFieldName('source')
85
86
  const fragment = source && firstDescendant(source, 'string_fragment')
86
- if (fragment) specs.push(fragment.text)
87
+ const specText = fragment ? fragment.text : null
88
+ if (specText) specs.push(specText)
87
89
  // Local binding names a qualified call may be addressed through:
88
90
  // the default import, the namespace alias, and each named import.
89
91
  for (let i = 0; i < node.childCount; i++) {
@@ -91,17 +93,25 @@ function specsFromTree(grammar, tree) {
91
93
  if (clause.type !== 'import_clause') continue
92
94
  for (let j = 0; j < clause.childCount; j++) {
93
95
  const part = clause.child(j)
94
- if (part.type === 'identifier') names.push(part.text)
95
- else if (part.type === 'namespace_import') {
96
+ if (part.type === 'identifier') {
97
+ names.push(part.text)
98
+ if (specText) bindings.push({ name: part.text, spec: specText })
99
+ } else if (part.type === 'namespace_import') {
96
100
  for (let k = 0; k < part.childCount; k++) {
97
- if (part.child(k).type === 'identifier') names.push(part.child(k).text)
101
+ if (part.child(k).type === 'identifier') {
102
+ names.push(part.child(k).text)
103
+ if (specText) bindings.push({ name: part.child(k).text, spec: specText })
104
+ }
98
105
  }
99
106
  } else if (part.type === 'named_imports') {
100
107
  for (let k = 0; k < part.childCount; k++) {
101
108
  const specifier = part.child(k)
102
109
  if (specifier.type !== 'import_specifier') continue
103
110
  const alias = specifier.childForFieldName('alias') || specifier.childForFieldName('name')
104
- if (alias) names.push(alias.text)
111
+ if (alias) {
112
+ names.push(alias.text)
113
+ if (specText) bindings.push({ name: alias.text, spec: specText })
114
+ }
105
115
  }
106
116
  }
107
117
  }
@@ -111,7 +121,8 @@ function specsFromTree(grammar, tree) {
111
121
  if (fn && (fn.text === 'require' || fn.text === 'import')) {
112
122
  const args = node.childForFieldName('arguments')
113
123
  const fragment = args && firstDescendant(args, 'string_fragment')
114
- if (fragment) specs.push(fragment.text)
124
+ const specText = fragment ? fragment.text : null
125
+ if (specText) specs.push(specText)
115
126
  // `const x = require('y')` / `const x = await import('y')`: the
116
127
  // declarator name is the binding: the visit starts above it, so
117
128
  // walk down from any enclosing declarator via field lookup here.
@@ -119,7 +130,10 @@ function specsFromTree(grammar, tree) {
119
130
  while (parent && parent.type === 'await_expression') parent = parent.parent
120
131
  if (parent && parent.type === 'variable_declarator') {
121
132
  const name = parent.childForFieldName('name')
122
- if (name) names.push(name.text)
133
+ if (name) {
134
+ names.push(name.text)
135
+ if (specText) bindings.push({ name: name.text, spec: specText })
136
+ }
123
137
  }
124
138
  }
125
139
  }
@@ -133,8 +147,9 @@ function specsFromTree(grammar, tree) {
133
147
  // The name a qualified call uses: the declared alias, or the last
134
148
  // path segment (Go's convention when no alias is given).
135
149
  const alias = node.childForFieldName('name')
136
- if (alias) names.push(alias.text)
137
- else names.push(path.slice(path.lastIndexOf('/') + 1))
150
+ const name = alias ? alias.text : path.slice(path.lastIndexOf('/') + 1)
151
+ names.push(name)
152
+ bindings.push({ name, spec: path })
138
153
  }
139
154
  }
140
155
  } else if (grammar === 'python') {
@@ -144,12 +159,18 @@ function specsFromTree(grammar, tree) {
144
159
  if (c.type === 'dotted_name') {
145
160
  specs.push(c.text)
146
161
  const firstId = c.child(0)
147
- if (firstId) names.push(firstId.text)
162
+ if (firstId) {
163
+ names.push(firstId.text)
164
+ bindings.push({ name: firstId.text, spec: c.text })
165
+ }
148
166
  } else if (c.type === 'aliased_import') {
149
167
  const name = c.childForFieldName('name')
150
168
  const alias = c.childForFieldName('alias')
151
169
  if (name) specs.push(name.text)
152
- if (alias) names.push(alias.text)
170
+ if (alias) {
171
+ names.push(alias.text)
172
+ bindings.push({ name: alias.text, spec: name ? name.text : alias.text })
173
+ }
153
174
  }
154
175
  }
155
176
  } else if (node.type === 'import_from_statement') {
@@ -167,10 +188,14 @@ function specsFromTree(grammar, tree) {
167
188
  if (c.type === 'dotted_name') {
168
189
  names.push(c.text)
169
190
  if (modText) specs.push(modText + '.' + c.text)
191
+ bindings.push({ name: c.text, spec: modText || c.text })
170
192
  } else if (c.type === 'aliased_import') {
171
193
  const name = c.childForFieldName('name')
172
194
  const alias = c.childForFieldName('alias')
173
- if (alias) names.push(alias.text)
195
+ if (alias) {
196
+ names.push(alias.text)
197
+ bindings.push({ name: alias.text, spec: modText || (name ? name.text : alias.text) })
198
+ }
174
199
  if (name && modText) specs.push(modText + '.' + name.text)
175
200
  }
176
201
  }
@@ -182,6 +207,7 @@ function specsFromTree(grammar, tree) {
182
207
  if (name) {
183
208
  specs.push('./' + name.text)
184
209
  names.push(name.text)
210
+ bindings.push({ name: name.text, spec: './' + name.text })
185
211
  }
186
212
  }
187
213
  } else if (node.type === 'use_declaration') {
@@ -204,16 +230,27 @@ function specsFromTree(grammar, tree) {
204
230
  const aliasNode = n.childForFieldName('alias')
205
231
  const full = (prefix ? prefix + '::' : '') + (pathNode ? pathNode.text : '')
206
232
  specs.push(full)
207
- if (aliasNode) names.push(aliasNode.text)
233
+ const alias = aliasNode ? aliasNode.text : null
234
+ if (alias) {
235
+ names.push(alias)
236
+ const crateName = full.split('::')[0]
237
+ bindings.push({ name: alias, spec: crateName, full })
238
+ }
208
239
  } else if (n.type === 'scoped_identifier') {
209
240
  const full = (prefix ? prefix + '::' : '') + n.text
210
241
  specs.push(full)
211
242
  const name = n.childForFieldName('name')
212
- if (name) names.push(name.text)
243
+ if (name) {
244
+ names.push(name.text)
245
+ const crateName = full.split('::')[0]
246
+ bindings.push({ name: name.text, spec: crateName, full })
247
+ }
213
248
  } else if (n.type === 'identifier') {
214
249
  const full = (prefix ? prefix + '::' : '') + n.text
215
250
  specs.push(full)
216
251
  names.push(n.text)
252
+ const crateName = full.split('::')[0]
253
+ bindings.push({ name: n.text, spec: crateName, full })
217
254
  }
218
255
  }
219
256
  extractRustUse(node.childForFieldName('argument'))
@@ -222,7 +259,7 @@ function specsFromTree(grammar, tree) {
222
259
  for (let i = 0; i < node.childCount; i++) visit(node.child(i))
223
260
  }
224
261
  visit(tree.rootNode)
225
- return { specs, names }
262
+ return { specs, names, bindings }
226
263
  }
227
264
 
228
265
  /**
@@ -262,9 +299,9 @@ export async function extractCode(ext, content) {
262
299
  const parser = parserFor(grammar, lang)
263
300
  const tree = parser.parse(content)
264
301
  try {
265
- const { specs: imports, names: importNames } = specsFromTree(grammar, tree)
302
+ const { specs: imports, names: importNames, bindings: importBindings } = specsFromTree(grammar, tree)
266
303
  const { symbols, calls } = structureFromTree(grammar, tree)
267
- return { imports, importNames, symbols, calls }
304
+ return { imports, importNames, importBindings, symbols, calls }
268
305
  } finally {
269
306
  tree.delete()
270
307
  }
@@ -90,9 +90,10 @@ export function buildAgentInstructionBlock(hasGraphify = false) {
90
90
 
91
91
  return `<!-- knl-view:start -->
92
92
  # Knowledge graph tools
93
- - KNL-View is the primary graph tool. For \`.knowledges\`, source graphs, and project graph questions, read the project's \`knl-view\` skill and use the relevant KNL-View feature first.
93
+ - KNL-View is the primary graph tool. For \`.knowledges\`, source graphs, external module graphs, and project graph questions, read the project's \`knl-view\` skill and use the relevant KNL-View feature first.
94
94
  ${fallback}## KNL-View graph access
95
- - Use the KNL-View feature that fits the task (\`graph trace\`, \`sources trace/sync/collect\`, \`model check/new\`, consensus commands, or the viewer). Do not approximate a supported KNL-View operation with ad-hoc graph queries.
95
+ - Use the KNL-View feature that fits the task (\`graph trace\`, \`sources trace/sync/collect\`, \`modules build-graph/trace/list\`, \`model check/new\`, consensus commands, or the viewer). Do not approximate a supported KNL-View operation with ad-hoc graph queries.
96
+ - For third-party libraries and dependencies (\`node_modules\`, \`go mod\`, \`site-packages\`, \`crates\`): NEVER read raw library files directly. Build and query the module graph on-demand: run \`knl-view modules build-graph <eco>::<module_name>\` (e.g. \`npm::express\`, \`go::github.com/gin-gonic/gin\`), then inspect API definitions and call hierarchies via \`knl-view modules trace <eco>::<module_name> [symbol]\` to save context tokens.
96
97
  - Route all graph reads and writes through KNL-View's documented CLI, viewer, or application endpoints. Never connect to AnchorDB directly through AnchorWire, its CLI, direct database endpoints, files, SQL, or custom clients/scripts. If KNL-View cannot perform a requested operation, explain the limitation; do not bypass it.
97
98
  - Keep graph work targeted: specify the relevant node/path, direction, and smallest useful hop count; summarize only relevant nodes and edges; avoid dumping whole graphs or large raw JSON.
98
99
  - Follow the KNL-View skill's trace-before-edit and \`sources sync\`-after-change rules. Treat edge direction as lineage; do not claim causality without separate evidence.
@@ -196,7 +197,7 @@ async function statOrNull(abs) {
196
197
  // structure.
197
198
  const SKILL_MD = `---
198
199
  name: knl-view
199
- description: Read and maintain .knowledges knowledge bases. Use whenever a task involves a .knowledges folder, its map.json, or the knl-view viewer.
200
+ description: Read and maintain .knowledges knowledge bases, project source dependency graphs, and on-demand external module boundary graphs (npm, go, cargo, pypi). Use whenever a task involves project architecture, dependencies, third-party libraries, .knowledges, or the knl-view viewer.
200
201
  ---
201
202
 
202
203
  # Knowledges base
@@ -366,6 +367,12 @@ file or symbol is tombstoned (attribute \`tombstone = 2\`) after its
366
367
  edges are unlinked; if it comes back, the tombstone lifts and the edges
367
368
  are relinked.
368
369
 
370
+ External modules graph (third-party dependencies): stored in global AnchorDB
371
+ models per ecosystem (\`modules-npm\`, \`modules-go\`, \`modules-cargo\`, \`modules-pypi\`)
372
+ and cataloged in \`~/.knlview/modules/<eco>.json\`. Built on-demand via
373
+ \`knl-view modules build-graph <eco>::<name>\`, avoiding polluting project models.
374
+ Traceable via \`knl-view modules trace <eco>::<name> [symbol]\`.
375
+
369
376
  Decision history skill: recording architectural decisions is delegated
370
377
  to the separate decision-history skill (its decision block format is its
371
378
  own domain; this skill never writes decision blocks). If it is not
@@ -454,6 +461,10 @@ this.
454
461
  - Before renaming a symbol or moving it to another file: remember its
455
462
  record id contains the old name/path. Sync tombstones the stale record
456
463
  and creates the new one; trace by the new id afterwards.
464
+ - Before reading raw third-party library source files:
465
+ run \`knl-view modules build-graph <eco>::<module>\` (e.g. \`npm::express\`, \`go::github.com/gin-gonic/gin\`)
466
+ to index it on-demand into global AnchorDB model \`modules-<eco>\`, then trace
467
+ its methods with \`knl-view modules trace <eco>::<module> [symbol]\` to save context tokens.
457
468
  - A new architectural decision: trigger the decision-history skill
458
469
  (install it first if missing; see "Decision history skill" above).
459
470
  The decision block format is that skill's domain; this skill never