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