@rahmantech/knl-view 0.0.0-stage → 0.2.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.
Files changed (115) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +483 -2
  3. package/bin/knl-view.js +1320 -0
  4. package/dist/assets/abnfDiagram-WEYOIA3C-hoSbsMOz.js +1 -0
  5. package/dist/assets/arc-g87Np6E5.js +1 -0
  6. package/dist/assets/architecture-WOLXFQ4H-DOIq-0IA.js +1 -0
  7. package/dist/assets/architectureDiagram-ZHVYKT53-Dxwe8n7m.js +36 -0
  8. package/dist/assets/array-BifhSqXX.js +1 -0
  9. package/dist/assets/blockDiagram-RJAE7QA3-BnGng59Z.js +140 -0
  10. package/dist/assets/c4Diagram-XDPPPE54-C5LjqIIo.js +38 -0
  11. package/dist/assets/channel-BVWkRFBw.js +1 -0
  12. package/dist/assets/chunk-2E7WHVMD-BJ1dyGLq.js +88 -0
  13. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  14. package/dist/assets/chunk-4EMNKR2T-DXF02Sgw.js +1 -0
  15. package/dist/assets/chunk-55XCU76K-CM1akAbu.js +1 -0
  16. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  17. package/dist/assets/chunk-7S6XSTFO-BOUjqldL.js +2 -0
  18. package/dist/assets/chunk-AQDTSKZB-C4isdNLA.js +72 -0
  19. package/dist/assets/chunk-DSNLSHJZ-CbOTkSVM.js +129 -0
  20. package/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  21. package/dist/assets/chunk-HAGFOTED-CRfnketR.js +62 -0
  22. package/dist/assets/chunk-HNE4Q36P-rxlpGUU8.js +1 -0
  23. package/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  24. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  25. package/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  26. package/dist/assets/chunk-NQOKRVIF-BOYCgTmV.js +213 -0
  27. package/dist/assets/chunk-UHW7PFBP-Cnogp1Fb.js +1 -0
  28. package/dist/assets/chunk-VQ5W62QI-Cu1WY5Uo.js +1 -0
  29. package/dist/assets/chunk-WQQL4CDK-BJc3FPE2.js +10 -0
  30. package/dist/assets/chunk-XXDRQBXY-BlPj4H7s.js +1 -0
  31. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  32. package/dist/assets/chunk-YZO67HFG-V85H31x4.js +1 -0
  33. package/dist/assets/chunk-ZAUX2LTX-C3XKXW09.js +1 -0
  34. package/dist/assets/chunk-ZCHMQSOR-B0mmpEgW.js +2 -0
  35. package/dist/assets/classDiagram-v2-4MYWR3UA-oMd7YaHP.js +217 -0
  36. package/dist/assets/cose-bilkent-JH36ORCC-J0kSVsDf.js +1 -0
  37. package/dist/assets/cynefin-EF2NZ3EQ-DK2TNhel.js +1 -0
  38. package/dist/assets/cynefinDiagram-WF24UDRP-DDvKgieA.js +62 -0
  39. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  40. package/dist/assets/dagre-NK4GIZVX-Ca2Fe-i-.js +4 -0
  41. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  42. package/dist/assets/diagram-CBK3MWTC-L6dmeQ2V.js +24 -0
  43. package/dist/assets/diagram-I5XIIV2D-DhG8R-v5.js +24 -0
  44. package/dist/assets/diagram-ODIL2UCW-BpYYbYBT.js +30 -0
  45. package/dist/assets/diagram-PFBEJUXE-DKQpHSsC.js +3 -0
  46. package/dist/assets/diagram-RJA3I4VV-DP7Dsnya.js +41 -0
  47. package/dist/assets/diagram-TYUNFSNR-CfSFewUS.js +200 -0
  48. package/dist/assets/dist-CQNjnrld.js +1 -0
  49. package/dist/assets/ebnfDiagram-V4P3TTPT-DE3MZoJO.js +1 -0
  50. package/dist/assets/elk-QONTBCTQ-p_aAQPHU.js +27 -0
  51. package/dist/assets/erDiagram-KYQIGQAA-DZO3vQrk.js +99 -0
  52. package/dist/assets/eventmodeling-K75KTNOO-BII9EPl5.js +1 -0
  53. package/dist/assets/flowDiagram-IUGLLWBU-D9uPno39.js +1 -0
  54. package/dist/assets/ganttDiagram-R7GPNTJY-BKiH3v2r.js +292 -0
  55. package/dist/assets/gitGraph-VSP46ZUC-oHL6cr73.js +1 -0
  56. package/dist/assets/gitGraphDiagram-PDYL7XHY-CqPVIEeo.js +106 -0
  57. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  58. package/dist/assets/index-CSpirV16.js +76 -0
  59. package/dist/assets/index-DLgMsUs2.css +1 -0
  60. package/dist/assets/info-OHQRW6UA-Drrbnhpd.js +1 -0
  61. package/dist/assets/infoDiagram-MVDRKAX5-DnQQFMAd.js +2 -0
  62. package/dist/assets/init-C-OQMol4.js +1 -0
  63. package/dist/assets/ishikawaDiagram-HPEHV2GE-DIpQlxRh.js +70 -0
  64. package/dist/assets/journeyDiagram-BOSHW7O7-EBR9I5h1.js +139 -0
  65. package/dist/assets/kanban-definition-FNSA4HC5-CdvNsjik.js +89 -0
  66. package/dist/assets/katex-B_JcIwqj.js +257 -0
  67. package/dist/assets/line-CDLzgxDc.js +1 -0
  68. package/dist/assets/linear-DfXKql9u.js +1 -0
  69. package/dist/assets/mermaid-parser.core-DnXGphT2.js +7 -0
  70. package/dist/assets/mermaid.core-h6fiCaSZ.js +44 -0
  71. package/dist/assets/mindmap-definition-6OY57UG3-BiNfio-j.js +98 -0
  72. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  73. package/dist/assets/packet-JDAUHWVQ-D76byv1S.js +1 -0
  74. package/dist/assets/path-fybaL0A-.js +1 -0
  75. package/dist/assets/pegDiagram-ECIUJNBX-Drs8Pxdt.js +1 -0
  76. package/dist/assets/pie-XZMESJXO-D_83z1lM.js +1 -0
  77. package/dist/assets/pieDiagram-GNMPY74T-wvzIphSf.js +39 -0
  78. package/dist/assets/quadrantDiagram-D57K4MC3-B7EfMRWb.js +7 -0
  79. package/dist/assets/radar-ABXABTNO-Bs6dVdAR.js +1 -0
  80. package/dist/assets/railroad-I3PHUGI6-lKaN4miw.js +1 -0
  81. package/dist/assets/railroad-abnf-LNEFI6M7-Bhh16b7Z.js +1 -0
  82. package/dist/assets/railroad-ebnf-DWYD2UWJ-m6ybW-Y_.js +1 -0
  83. package/dist/assets/railroad-peg-JR7BVNR7-DNn72e7r.js +1 -0
  84. package/dist/assets/railroadDiagram-Y72NRBL7-DNeop1iZ.js +1 -0
  85. package/dist/assets/requirementDiagram-EQYSW5RA-BVYZ2XYJ.js +84 -0
  86. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  87. package/dist/assets/sankeyDiagram-SY24JHO5-B2MP51on.js +40 -0
  88. package/dist/assets/sequenceDiagram-BLEHNHQU-BUvxstXG.js +169 -0
  89. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  90. package/dist/assets/src-BrR0lqlG.js +1 -0
  91. package/dist/assets/stateDiagram-v2-A6IESKWZ-Ba84OGKE.js +291 -0
  92. package/dist/assets/swimlanes-B3ENRKFQ-BMSl2PNA.js +1 -0
  93. package/dist/assets/swimlanesDiagram-AYQRV4AK-M1dmvq7m.js +8 -0
  94. package/dist/assets/timeline-definition-JXYKGLGB-DOdW-dxq.js +120 -0
  95. package/dist/assets/treeView-D4JQ5SDB-DZ9KQyn5.js +1 -0
  96. package/dist/assets/treemap-SAJKECNS-WEC5wtDV.js +1 -0
  97. package/dist/assets/usecaseDiagram-WPOLBNNX-cldTztFL.js +328 -0
  98. package/dist/assets/vennDiagram-Q3Y3IDCW-c41KySoY.js +34 -0
  99. package/dist/assets/wardley-7MLQ67FV-Bf9_aSSZ.js +1 -0
  100. package/dist/assets/wardleyDiagram-IVAIDKWA-BeKEvEBG.js +78 -0
  101. package/dist/assets/xychartDiagram-4URNHDU4-tNkAxTxG.js +7 -0
  102. package/dist/index.html +22 -0
  103. package/package.json +50 -4
  104. package/server/config.js +389 -0
  105. package/server/consensus.js +180 -0
  106. package/server/graph/anchordb_client.js +253 -0
  107. package/server/graph/model.js +157 -0
  108. package/server/graph/parser.js +327 -0
  109. package/server/graph/sources.js +801 -0
  110. package/server/graph/syntax.js +437 -0
  111. package/server/index.js +742 -0
  112. package/server/install.js +437 -0
  113. package/server/map.js +126 -0
  114. package/server/models_registry.js +109 -0
  115. package/server/scaffold.js +466 -0
@@ -0,0 +1,742 @@
1
+ import http from 'node:http'
2
+ import { promises as fs, createReadStream } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { readMap, normalizeMap, decorateNode } from './map.js'
5
+ import { buildGraphFromRoot } from './graph/parser.js'
6
+ import { createAnchorWireClient, syncGraphToAnchorDB, traceCausalChain, traceInboundFromMirror, mergeLineage, mirrorStillValid } from './graph/anchordb_client.js'
7
+ import { traceSources } from './graph/sources.js'
8
+ import {
9
+ deriveStatus,
10
+ readClaimsIndex,
11
+ readConsensusMirror,
12
+ inboundConsensus,
13
+ outboundConsensus
14
+ } from './consensus.js'
15
+ import { anchordbClientOptions, syncStatePath, DEFAULT_RELATIONS, createPathMatcher } from './config.js'
16
+
17
+ const MIME = {
18
+ '.html': 'text/html; charset=utf-8',
19
+ '.js': 'text/javascript; charset=utf-8',
20
+ '.mjs': 'text/javascript; charset=utf-8',
21
+ '.css': 'text/css; charset=utf-8',
22
+ '.json': 'application/json; charset=utf-8',
23
+ '.map': 'application/json; charset=utf-8',
24
+ '.svg': 'image/svg+xml',
25
+ '.png': 'image/png',
26
+ '.jpg': 'image/jpeg',
27
+ '.jpeg': 'image/jpeg',
28
+ '.gif': 'image/gif',
29
+ '.webp': 'image/webp',
30
+ '.avif': 'image/avif',
31
+ '.ico': 'image/x-icon',
32
+ '.woff': 'font/woff',
33
+ '.woff2': 'font/woff2',
34
+ '.ttf': 'font/ttf',
35
+ '.txt': 'text/plain; charset=utf-8',
36
+ '.md': 'text/markdown; charset=utf-8',
37
+ '.prd': 'text/markdown; charset=utf-8',
38
+ '.pdf': 'application/pdf'
39
+ }
40
+
41
+ const MD_EXTS = new Set(['md', 'markdown', 'prd'])
42
+ const MAX_DOC_BYTES = 8 * 1024 * 1024
43
+ const TITLE_READ_BYTES = 8192
44
+
45
+ export function createKnowledgeServer({ root, dist = null, host = '127.0.0.1', port = 8085, anchordb = null, consensusModel = null, sourcesModel = null, include = null, exclude = null }) {
46
+ const rootAbs = path.resolve(root)
47
+ const distAbs = dist ? path.resolve(dist) : null
48
+ // Live AnchorDB connection status lives here, not a snapshot: attachAnchorDB
49
+ // fills it after the server listens, and routes read the latest status.
50
+ const anchordbState = {
51
+ config: anchordb,
52
+ client: null,
53
+ status: anchordb ? 'connecting' : 'off',
54
+ error: null,
55
+ syncedAt: null,
56
+ consensusModel,
57
+ consensusClient: null,
58
+ consensusError: null,
59
+ sourcesModel,
60
+ sourcesClient: null,
61
+ sourcesError: null
62
+ }
63
+
64
+ const server = http.createServer((req, res) => {
65
+ route(req, res, rootAbs, distAbs, { host, port, anchordb: anchordbState, include, exclude }).catch((err) => {
66
+ if (!res.headersSent) sendJson(res, 500, { error: `Failed to handle request: ${err.message}` })
67
+ else res.end()
68
+ })
69
+ })
70
+
71
+ server.knlRoot = rootAbs
72
+ server.knlHost = host
73
+ server.knlPort = port
74
+ server.knlAnchorDB = anchordbState
75
+ return server
76
+ }
77
+
78
+ // Connect knl-view to AnchorDB after the server listens. Connect or sync
79
+ // failures never bring the server down: the viewer keeps running fully
80
+ // local, and the status can be read from the state.
81
+ export async function attachAnchorDB(server) {
82
+ const state = server.knlAnchorDB
83
+ if (!state?.config) {
84
+ state.status = 'off'
85
+ return { ok: false, reason: 'off' }
86
+ }
87
+ const options = anchordbClientOptions(state.config)
88
+ if (!options) {
89
+ state.status = 'error'
90
+ state.error = 'anchordb URL is unreadable'
91
+ return { ok: false, reason: state.error }
92
+ }
93
+ if (!options.model) {
94
+ state.status = 'error'
95
+ state.error = 'model name is not mapped yet'
96
+ return { ok: false, reason: 'role "knowledges" is missing from the knl-config.json "models" map, run: knl-view model new knowledges' }
97
+ }
98
+ try {
99
+ state.client = await createAnchorWireClient(options)
100
+ } catch (err) {
101
+ state.client = null
102
+ state.status = 'error'
103
+ state.error = err.message
104
+ return { ok: false, reason: err.message }
105
+ }
106
+ state.status = 'connected'
107
+ state.error = null
108
+ state.model = options.model
109
+
110
+ // A second client for the consensus model, when the project maps that
111
+ // role. Best effort: a failed consensus connection only disables
112
+ // consensus endpoints, never the main graph sync.
113
+ if (state.consensusModel) {
114
+ try {
115
+ state.consensusClient = await createAnchorWireClient({ ...options, model: state.consensusModel })
116
+ } catch (err) {
117
+ state.consensusClient = null
118
+ state.consensusError = err.message
119
+ }
120
+ }
121
+
122
+ // Third client for the sources model (whole-project dependency graph),
123
+ // same best-effort policy as consensus.
124
+ if (state.sourcesModel) {
125
+ try {
126
+ state.sourcesClient = await createAnchorWireClient({ ...options, model: state.sourcesModel })
127
+ } catch (err) {
128
+ state.sourcesClient = null
129
+ state.sourcesError = err.message
130
+ }
131
+ }
132
+ let synced = false
133
+ if (state.config.sync) {
134
+ try {
135
+ const graph = await buildGraphFromRoot(server.knlRoot)
136
+ const previous = await readSyncState(options.model)
137
+ const result = await syncGraphToAnchorDB(state.client, graph, previous)
138
+ if (result.failed === 0) {
139
+ // The mirror is only written on a clean sync, so failed parts are
140
+ // retried automatically on the next start.
141
+ await writeSyncState(options.model, result.edges)
142
+ }
143
+ state.syncedAt = Date.now()
144
+ state.lastSync = result
145
+ synced = true
146
+ } catch (err) {
147
+ // A connection is enough for trace; a sync failure is only recorded.
148
+ state.error = err.message
149
+ }
150
+ }
151
+ return {
152
+ ok: true,
153
+ model: options.model,
154
+ synced,
155
+ linked: state.lastSync?.linked ?? 0,
156
+ unlinked: state.lastSync?.unlinked ?? 0,
157
+ kept: state.lastSync?.kept ?? 0,
158
+ failed: state.lastSync?.failed ?? 0
159
+ }
160
+ }
161
+
162
+ // Mirror of the edges from the last sync: ~/.knlview/sync-state/<model>.json.
163
+ // A broken/missing file is treated as "never synced" (full sync), not an
164
+ // error; this mirror is purely an optimization, not a source of truth.
165
+ async function readSyncState(model) {
166
+ try {
167
+ const parsed = JSON.parse(await fs.readFile(syncStatePath(model), 'utf8'))
168
+ if (parsed?.model !== model || !Array.isArray(parsed.edges)) return null
169
+ return parsed.edges
170
+ } catch {
171
+ return null
172
+ }
173
+ }
174
+
175
+ async function writeSyncState(model, edges) {
176
+ try {
177
+ const file = syncStatePath(model)
178
+ await fs.mkdir(path.dirname(file), { recursive: true })
179
+ await fs.writeFile(file, JSON.stringify({ model, syncedAt: new Date().toISOString(), edges }), 'utf8')
180
+ } catch {
181
+ // Mirror write failed: the sync still counts, only incrementality is lost.
182
+ }
183
+ }
184
+
185
+ async function route(req, res, rootAbs, distAbs, listen) {
186
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
187
+ res.setHeader('Allow', 'GET, HEAD')
188
+ return sendJson(res, 405, { error: 'Only GET is supported.' })
189
+ }
190
+
191
+ const url = new URL(req.url, 'http://localhost')
192
+ const pathname = url.pathname
193
+
194
+ if (pathname === '/api/meta') return sendJson(res, 200, await buildMeta(rootAbs, listen))
195
+ if (pathname === '/api/tree') return sendJson(res, 200, await buildTree(rootAbs, '', await loadMapNorm(rootAbs), listen))
196
+ if (pathname === '/api/doc') return sendDoc(res, rootAbs, url)
197
+ if (pathname === '/api/raw') return sendRaw(res, rootAbs, url)
198
+ if (pathname === '/api/graph') {
199
+ const graph = await buildGraphFromRoot(rootAbs)
200
+ return sendJson(res, 200, graph.toJSON())
201
+ }
202
+ if (pathname === '/api/graph/trace') {
203
+ const target = url.searchParams.get('target') || url.searchParams.get('id')
204
+ if (!target) {
205
+ return sendJson(res, 400, { error: 'Parameter "target" or "id" is required.' })
206
+ }
207
+ const rawHops = parseInt(url.searchParams.get('hops') || '2', 10)
208
+ const hops = Math.min(Math.max(1, Number.isInteger(rawHops) ? rawHops : 2), 10)
209
+ const rawDirection = url.searchParams.get('direction')
210
+ if (rawDirection !== null && !['in', 'out', 'both'].includes(rawDirection)) {
211
+ return sendJson(res, 400, { error: 'Parameter "direction" must be in, out, or both.' })
212
+ }
213
+ const direction = rawDirection || 'both'
214
+ const graph = await buildGraphFromRoot(rootAbs)
215
+ const subGraph = graph.extractSubGraph(target, hops, direction)
216
+ return sendJson(res, 200, subGraph)
217
+ }
218
+ if (pathname === '/api/anchordb/trace') {
219
+ return handleAnchorTrace(res, listen.anchordb, url)
220
+ }
221
+ if (pathname === '/api/consensus') {
222
+ return handleConsensus(res, listen.anchordb, url)
223
+ }
224
+ if (pathname === '/api/sources/trace') {
225
+ return handleSourcesTrace(res, listen.anchordb, url)
226
+ }
227
+ if (pathname.startsWith('/api/')) {
228
+ return sendJson(res, 404, { error: `Unknown endpoint: ${pathname}` })
229
+ }
230
+
231
+ return sendStatic(req, res, distAbs, pathname)
232
+ }
233
+
234
+ // Multi-hop causal chain trace on the AnchorDB side. Only active when the
235
+ // project's knl-config.json carries an anchordb block and the connection
236
+ // is alive.
237
+ async function handleAnchorTrace(res, state, url) {
238
+ if (!state?.config) {
239
+ return sendJson(res, 503, { error: 'AnchorDB is not active. Add an "anchordb" block to knl-config.json.' })
240
+ }
241
+ if (state.status !== 'connected' || !state.client) {
242
+ return sendJson(res, 503, {
243
+ error: `AnchorDB not connected yet (status: ${state.status}).`,
244
+ detail: state.error || null
245
+ })
246
+ }
247
+ const target = url.searchParams.get('target') || url.searchParams.get('id')
248
+ if (!target) {
249
+ return sendJson(res, 400, { error: 'Parameter "target" or "id" is required.' })
250
+ }
251
+ const relations = url.searchParams.get('relations')
252
+ ? url.searchParams.get('relations').split(',').map((s) => s.trim()).filter(Boolean)
253
+ : (state.config.relations?.length ? state.config.relations : DEFAULT_RELATIONS)
254
+ const rawLimit = parseInt(url.searchParams.get('limit') || '20', 10)
255
+ const limit = Math.min(Math.max(1, Number.isInteger(rawLimit) ? rawLimit : 20), 100)
256
+ const rawHops = parseInt(url.searchParams.get('hops') || '2', 10)
257
+ const hops = Math.min(Math.max(1, Number.isInteger(rawHops) ? rawHops : 2), 10)
258
+ const rawDirection = url.searchParams.get('direction')
259
+ if (rawDirection !== null && !['in', 'out', 'both'].includes(rawDirection)) {
260
+ return sendJson(res, 400, { error: 'Parameter "direction" must be in, out, or both.' })
261
+ }
262
+ const direction = rawDirection || 'out'
263
+
264
+ // Mechanically directional, never cause-and-effect:
265
+ // out: follow the relations forward from the target (server-scored)
266
+ // in: who references the target: walked from the local sync mirror,
267
+ // because the wire protocol has no reverse query
268
+ // both: union of the two, best score wins per word
269
+ const maxHops = Number.isInteger(hops) && hops > 0 ? hops : 2
270
+ const limitK = Number.isInteger(limit) && limit > 0 ? limit : 20
271
+ try {
272
+ const forward = direction === 'in'
273
+ ? []
274
+ : await traceCausalChain(state.client, target, relations, limitK, maxHops)
275
+ let inbound = []
276
+ let mirrored = false
277
+ if (direction !== 'out') {
278
+ const mirrorEdges = await readSyncState(state.model || state.config.model)
279
+ mirrored = Array.isArray(mirrorEdges) && mirrorEdges.length > 0
280
+ inbound = mirrored ? traceInboundFromMirror(mirrorEdges, target, relations, maxHops) : []
281
+ }
282
+ const chain = direction === 'out' ? forward : mergeLineage(forward, inbound)
283
+ return sendJson(res, 200, {
284
+ target,
285
+ relations,
286
+ limit,
287
+ hops,
288
+ direction,
289
+ mirrored,
290
+ chain
291
+ })
292
+ } catch (err) {
293
+ return sendJson(res, 502, { error: `AnchorDB trace failed: ${err.message}` })
294
+ }
295
+ }
296
+
297
+ // Read-only consensus status over HTTP. Mutations stay in the CLI on
298
+ // purpose: one write path, one mirror to keep honest.
299
+ async function handleConsensus(res, state, url) {
300
+ if (!state?.config) {
301
+ return sendJson(res, 503, { error: 'AnchorDB is off. Add the "anchordb" block to the global knl-config.json.' })
302
+ }
303
+ if (state.status !== 'connected' || !state.client) {
304
+ return sendJson(res, 503, {
305
+ error: `AnchorDB is not connected yet (status: ${state.status}).`,
306
+ detail: state.error || null
307
+ })
308
+ }
309
+ if (!state.consensusModel) {
310
+ return sendJson(res, 503, {
311
+ error: 'Role "consensus" is not mapped in this project\'s models map.',
312
+ hint: 'knl-view model new consensus'
313
+ })
314
+ }
315
+ const target = url.searchParams.get('target') || url.searchParams.get('id')
316
+ if (!target) {
317
+ return sendJson(res, 400, { error: 'Parameter "target" or "id" is required.' })
318
+ }
319
+ try {
320
+ let probeClient = state.consensusClient
321
+ if (!probeClient) {
322
+ return sendJson(res, 503, {
323
+ error: 'Consensus model is not reachable.',
324
+ detail: state.consensusError || 'no consensus client'
325
+ })
326
+ }
327
+ const mirrorEdges = await readConsensusMirror(state.consensusModel)
328
+ let mirrored = Array.isArray(mirrorEdges)
329
+ if (mirrored && mirrorEdges.length > 0) {
330
+ mirrored = await mirrorStillValid(probeClient, mirrorEdges).catch(() => false)
331
+ }
332
+ const usable = mirrored ? mirrorEdges : []
333
+ const inbound = inboundConsensus(usable, target)
334
+ const stance = outboundConsensus(usable, target)
335
+ const { claims } = await readClaimsIndex(state.consensusModel)
336
+ return sendJson(res, 200, {
337
+ target,
338
+ status: deriveStatus(inbound.supporters, inbound.objectors),
339
+ supporters: inbound.supporters,
340
+ objectors: inbound.objectors,
341
+ stance,
342
+ mirrored,
343
+ statements: Object.fromEntries(
344
+ [target, ...inbound.supporters, ...inbound.objectors]
345
+ .filter((id) => claims[id]?.text)
346
+ .map((id) => [id, claims[id].text])
347
+ )
348
+ })
349
+ } catch (err) {
350
+ return sendJson(res, 502, { error: `Consensus status failed: ${err.message}` })
351
+ }
352
+ }
353
+
354
+ // Read-only lineage over the sources model (whole-project dependency
355
+ // graph). Sync stays in the CLI; the server only answers traces.
356
+ async function handleSourcesTrace(res, state, url) {
357
+ if (!state?.config) {
358
+ return sendJson(res, 503, { error: 'AnchorDB is off. Add the "anchordb" block to the global knl-config.json.' })
359
+ }
360
+ if (state.status !== 'connected' || !state.client) {
361
+ return sendJson(res, 503, {
362
+ error: `AnchorDB is not connected yet (status: ${state.status}).`,
363
+ detail: state.error || null
364
+ })
365
+ }
366
+ if (!state.sourcesModel) {
367
+ return sendJson(res, 503, {
368
+ error: 'Role "sources" is not mapped in this project\'s models map.',
369
+ hint: 'knl-view model new sources'
370
+ })
371
+ }
372
+ if (!state.sourcesClient) {
373
+ return sendJson(res, 503, {
374
+ error: 'Sources model is not reachable.',
375
+ detail: state.sourcesError || 'no sources client'
376
+ })
377
+ }
378
+ const target = url.searchParams.get('target')
379
+ if (!target) {
380
+ return sendJson(res, 400, { error: 'Parameter "target" is required.' })
381
+ }
382
+ let direction = 'both'
383
+ const rawDirection = url.searchParams.get('direction')
384
+ if (rawDirection) {
385
+ if (!['in', 'out', 'both'].includes(rawDirection)) {
386
+ return sendJson(res, 400, { error: `direction must be in, out, or both, got "${rawDirection}".` })
387
+ }
388
+ direction = rawDirection
389
+ }
390
+ const hops = Number.parseInt(url.searchParams.get('hops') || '2', 10)
391
+ const maxHops = Math.min(Math.max(1, Number.isInteger(hops) ? hops : 2), 10)
392
+ try {
393
+ const { out, in: inc } = await traceSources(state.sourcesClient, target, direction, maxHops)
394
+ return sendJson(res, 200, {
395
+ target,
396
+ maxHops,
397
+ direction,
398
+ out: out.map(([id, hop]) => ({ id, hop, via: 'out' })),
399
+ in: inc.map(([id, hop]) => ({ id, hop, via: 'in' }))
400
+ })
401
+ } catch (err) {
402
+ return sendJson(res, 502, { error: `Sources trace failed: ${err.message}` })
403
+ }
404
+ }
405
+
406
+ async function safeJoin(base, relPath) {
407
+ if (!relPath || typeof relPath !== 'string') return null
408
+ if (relPath.includes('\0')) return null
409
+ const abs = path.resolve(base, relPath)
410
+ if (abs !== base && !abs.startsWith(base + path.sep)) return null
411
+
412
+ try {
413
+ const realBase = await fs.realpath(base)
414
+ const realAbs = await fs.realpath(abs)
415
+ if (realAbs !== realBase && !realAbs.startsWith(realBase + path.sep)) return null
416
+ return realAbs
417
+ } catch (err) {
418
+ if (err.code === 'ENOENT') {
419
+ try {
420
+ const realParent = await fs.realpath(path.dirname(abs))
421
+ const realBase = await fs.realpath(base)
422
+ if (realParent !== realBase && !realParent.startsWith(realBase + path.sep)) return null
423
+ return abs
424
+ } catch {
425
+ return null
426
+ }
427
+ }
428
+ return null
429
+ }
430
+ }
431
+
432
+ async function buildMeta(rootAbs, listen = {}) {
433
+ const norm = await loadMapNorm(rootAbs)
434
+ const tree = await buildTree(rootAbs, '', norm, listen)
435
+ const counts = { files: 0, dirs: 0, newest: 0 }
436
+ walkStats(tree, counts)
437
+ const host = listen.host || '127.0.0.1'
438
+ const port = listen.port || 0
439
+ return {
440
+ name: path.basename(rootAbs),
441
+ root: rootAbs,
442
+ files: counts.files,
443
+ dirs: counts.dirs,
444
+ newest: counts.newest,
445
+ map: norm.raw ? { title: norm.title } : null,
446
+ address: `http://${host}:${port}`
447
+ }
448
+ }
449
+
450
+ // Read + normalize map.json once per request. No map at all is fine too:
451
+ // every overlay is optional and applies as annotation only.
452
+ async function loadMapNorm(rootAbs) {
453
+ const raw = await readMap(rootAbs)
454
+ return { raw, ...normalizeMap(raw) }
455
+ }
456
+
457
+ function walkStats(nodes, acc) {
458
+ for (const node of nodes) {
459
+ if (node.type === 'dir') {
460
+ acc.dirs += 1
461
+ if (node.mtime > acc.newest) acc.newest = node.mtime
462
+ walkStats(node.children, acc)
463
+ } else {
464
+ acc.files += 1
465
+ if (node.mtime > acc.newest) acc.newest = node.mtime
466
+ }
467
+ }
468
+ }
469
+
470
+ async function buildTree(dirAbs, rel, norm, options = {}) {
471
+ const excludeFilter = options.exclude ? createPathMatcher(options.exclude) : null
472
+ const includeFilter = options.include ? createPathMatcher(options.include) : null
473
+ const entries = await fs.readdir(dirAbs, { withFileTypes: true })
474
+ const nodes = []
475
+
476
+ for (const entry of entries) {
477
+ if (entry.name === '.git') continue
478
+ if (rel === '' && entry.name === 'map.json') continue
479
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name
480
+ // The map may hide entries (`ignore`). A hidden folder hides its whole
481
+ // contents with it: never walk into it at all.
482
+ if (norm?.entries?.get(childRel)?.ignore) continue
483
+
484
+ // 1. Hard exclude: knl-config exclude is truly excluded, always wins
485
+ if (excludeFilter && excludeFilter.matches(childRel, entry.name)) continue
486
+
487
+ // 2. Explicit include: if includeFilter is set, only included items or parent dirs are allowed
488
+ if (includeFilter) {
489
+ const isExplicitlyIncluded = includeFilter.matches(childRel, entry.name)
490
+ const isDirPrefixOfInclude = entry.isDirectory() && includeFilter.isPrefixOfAny(childRel)
491
+ if (!isExplicitlyIncluded && !isDirPrefixOfInclude) continue
492
+ }
493
+
494
+ const childAbs = path.join(dirAbs, entry.name)
495
+
496
+ try {
497
+ if (entry.isDirectory()) {
498
+ const stat = await fs.stat(childAbs)
499
+ const node = {
500
+ name: entry.name,
501
+ path: childRel,
502
+ type: 'dir',
503
+ mtime: stat.mtimeMs,
504
+ children: await buildTree(childAbs, childRel, norm, options)
505
+ }
506
+ nodes.push(decorateNode(node, norm))
507
+ continue
508
+ }
509
+ if (!entry.isFile()) continue
510
+
511
+ const stat = await fs.stat(childAbs)
512
+ const ext = path.extname(entry.name).slice(1).toLowerCase()
513
+ const node = {
514
+ name: entry.name,
515
+ path: childRel,
516
+ type: 'file',
517
+ ext,
518
+ size: stat.size,
519
+ mtime: stat.mtimeMs
520
+ }
521
+ if (MD_EXTS.has(ext)) {
522
+ const title = await readFirstHeading(childAbs, stat.size)
523
+ if (title) node.title = title
524
+ }
525
+ nodes.push(decorateNode(node, norm))
526
+ } catch {
527
+ // Skip unreadable or concurrently deleted entries
528
+ }
529
+ }
530
+
531
+ // One sort rule, same as orderForIndex in the views: documents first, then
532
+ // folders. When the map sets `order`, ordered entries come first (ascending)
533
+ // regardless of type; the rest fall back to the name rule.
534
+ nodes.sort((a, b) => {
535
+ const ao = a.order ?? Number.POSITIVE_INFINITY
536
+ const bo = b.order ?? Number.POSITIVE_INFINITY
537
+ if (ao !== bo) return ao - bo
538
+ if (a.type !== b.type) return a.type === 'file' ? -1 : 1
539
+ return a.name.localeCompare(b.name, 'en', { numeric: true, sensitivity: 'base' })
540
+ })
541
+ return nodes
542
+ }
543
+
544
+ async function readFirstHeading(abs, size) {
545
+ if (size === 0 || size > 512 * 1024) return null
546
+ let handle
547
+ try {
548
+ handle = await fs.open(abs, 'r')
549
+ const buffer = Buffer.alloc(TITLE_READ_BYTES)
550
+ const { bytesRead } = await handle.read(buffer, 0, TITLE_READ_BYTES, 0)
551
+ const text = buffer.subarray(0, bytesRead).toString('utf8')
552
+ const match = text.match(/^#\s+(.+)$/m)
553
+ if (!match) return null
554
+ const title = cleanTitle(match[1])
555
+ return title || null
556
+ } catch {
557
+ return null
558
+ } finally {
559
+ if (handle) await handle.close()
560
+ }
561
+ }
562
+
563
+ function cleanTitle(raw) {
564
+ const title = raw
565
+ .replace(/\s+#+\s*$/, '')
566
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
567
+ .replace(/`([^`]+)`/g, '$1')
568
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
569
+ .replace(/\*([^*]+)\*/g, '$1')
570
+ .replace(/\s+/g, ' ')
571
+ .trim()
572
+ return title.length > 160 ? `${title.slice(0, 157)}...` : title
573
+ }
574
+
575
+ async function sendDoc(res, rootAbs, url) {
576
+ const rel = url.searchParams.get('path')
577
+ if (!rel) return sendJson(res, 400, { error: 'Parameter path is required.' })
578
+
579
+ const abs = await safeJoin(rootAbs, rel)
580
+ if (!abs) return sendJson(res, 400, { error: `Path outside the knowledge base: ${rel}` })
581
+
582
+ let stat
583
+ try {
584
+ stat = await fs.stat(abs)
585
+ } catch {
586
+ return sendJson(res, 404, { error: `File not found: ${rel}` })
587
+ }
588
+ if (stat.isDirectory()) return sendJson(res, 400, { error: `That is a folder, not a file: ${rel}` })
589
+ if (stat.size > MAX_DOC_BYTES) {
590
+ return sendJson(res, 413, { error: `File too large to display (${formatBytes(stat.size)}).` })
591
+ }
592
+
593
+ const content = await fs.readFile(abs, 'utf8')
594
+ return sendJson(res, 200, {
595
+ path: rel,
596
+ name: path.basename(rel),
597
+ ext: path.extname(rel).slice(1).toLowerCase(),
598
+ content,
599
+ size: stat.size,
600
+ mtime: stat.mtimeMs
601
+ })
602
+ }
603
+
604
+ async function sendRaw(res, rootAbs, url) {
605
+ const rel = url.searchParams.get('path')
606
+ if (!rel) return sendJson(res, 400, { error: 'Parameter path is required.' })
607
+
608
+ const abs = await safeJoin(rootAbs, rel)
609
+ if (!abs) return sendJson(res, 400, { error: `Path outside the knowledge base: ${rel}` })
610
+
611
+ let stat
612
+ try {
613
+ stat = await fs.stat(abs)
614
+ } catch {
615
+ return sendJson(res, 404, { error: `File not found: ${rel}` })
616
+ }
617
+ if (!stat.isFile()) return sendJson(res, 400, { error: `Not a file: ${rel}` })
618
+
619
+ const type = MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream'
620
+ res.writeHead(200, {
621
+ 'Content-Type': type,
622
+ 'Content-Length': stat.size,
623
+ 'Cache-Control': 'no-store',
624
+ 'X-Content-Type-Options': 'nosniff',
625
+ 'X-Frame-Options': 'SAMEORIGIN'
626
+ })
627
+ if (res.req.method === 'HEAD') return res.end()
628
+ return new Promise((resolve, reject) => {
629
+ const stream = createReadStream(abs)
630
+ stream.on('error', (err) => {
631
+ res.destroy(err)
632
+ reject(err)
633
+ })
634
+ res.on('close', () => {
635
+ stream.destroy()
636
+ })
637
+ stream.on('end', resolve)
638
+ stream.pipe(res)
639
+ })
640
+ }
641
+
642
+ async function sendStatic(req, res, distAbs, pathname) {
643
+ if (!distAbs) {
644
+ res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' })
645
+ res.end('UI not built yet. Run: npm run build')
646
+ return
647
+ }
648
+
649
+ let rel
650
+ try {
651
+ rel = decodeURIComponent(pathname)
652
+ } catch {
653
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
654
+ res.end('Bad Request: Malformed URI path.')
655
+ return
656
+ }
657
+ if (rel.endsWith('/')) rel += 'index.html'
658
+
659
+ let abs = await safeJoin(distAbs, rel.replace(/^\/+/, ''))
660
+ if (!abs) {
661
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
662
+ res.end('Access denied.')
663
+ return
664
+ }
665
+
666
+ let stat = await statOrNull(abs)
667
+ if (stat?.isDirectory()) {
668
+ abs = path.join(abs, 'index.html')
669
+ stat = await statOrNull(abs)
670
+ }
671
+
672
+ if (!stat || !stat.isFile()) {
673
+ // No extension means an SPA route request, not a missing file.
674
+ if (!path.extname(rel)) {
675
+ abs = path.join(distAbs, 'index.html')
676
+ stat = await statOrNull(abs)
677
+ }
678
+ if (!stat || !stat.isFile()) {
679
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
680
+ res.end('File not found.')
681
+ return
682
+ }
683
+ }
684
+
685
+ const ext = path.extname(abs).toLowerCase()
686
+ const immutable = abs.startsWith(path.join(distAbs, 'assets') + path.sep)
687
+ const cache = immutable ? 'public, max-age=31536000, immutable' : 'no-store'
688
+
689
+ res.writeHead(200, {
690
+ 'Content-Type': MIME[ext] || 'application/octet-stream',
691
+ 'Content-Length': stat.size,
692
+ 'Cache-Control': cache,
693
+ 'X-Content-Type-Options': 'nosniff',
694
+ 'X-Frame-Options': 'SAMEORIGIN'
695
+ })
696
+ if (req.method === 'HEAD') return res.end()
697
+ return new Promise((resolve, reject) => {
698
+ const stream = createReadStream(abs)
699
+ stream.on('error', (err) => {
700
+ res.destroy(err)
701
+ reject(err)
702
+ })
703
+ res.on('close', () => {
704
+ stream.destroy()
705
+ })
706
+ stream.on('end', resolve)
707
+ stream.pipe(res)
708
+ })
709
+ }
710
+
711
+ async function statOrNull(abs) {
712
+ try {
713
+ return await fs.stat(abs)
714
+ } catch {
715
+ return null
716
+ }
717
+ }
718
+
719
+ function sendJson(res, status, body) {
720
+ const payload = JSON.stringify(body)
721
+ res.writeHead(status, {
722
+ 'Content-Type': 'application/json; charset=utf-8',
723
+ 'Content-Length': Buffer.byteLength(payload),
724
+ 'Cache-Control': 'no-store',
725
+ 'X-Content-Type-Options': 'nosniff',
726
+ 'X-Frame-Options': 'SAMEORIGIN'
727
+ })
728
+ res.end(payload)
729
+ }
730
+
731
+ export function formatBytes(bytes) {
732
+ if (bytes < 1024) return `${bytes} B`
733
+ const units = ['KB', 'MB', 'GB']
734
+ let value = bytes / 1024
735
+ let unit = 0
736
+ while (value >= 1024 && unit < units.length - 1) {
737
+ value /= 1024
738
+ unit += 1
739
+ }
740
+ const digits = value < 10 ? 1 : 0
741
+ return `${value.toFixed(digits).replace('.', ',')} ${units[unit]}`
742
+ }