@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,437 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import net from 'node:net'
5
+ import { globalConfigPath } from './config.js'
6
+
7
+ // AnchorDB installer for knl-view. Two paths:
8
+ // - Linux: binary from GitHub Releases (tar.gz per architecture, verified
9
+ // against the release's official SHA256SUMS.txt).
10
+ // - Non-Linux: AnchorDB needs Linux mmap, so the path is the
11
+ // ghcr.io/rachmanzz/anchordb:<version> container via docker/podman.
12
+ // Everything here is as pure/deterministic as possible; interactive
13
+ // orchestration (prompts, spawn) stays in bin/knl-view.js.
14
+
15
+ export const GHCR_IMAGE = 'ghcr.io/rachmanzz/anchordb'
16
+ export const HTTP_PORT_DEFAULT = 17447
17
+ export const WIRE_PORT_DEFAULT = 17448
18
+ export const CONTAINER_NAME = 'anchordb-knlview'
19
+
20
+ /** Check if a TCP port is currently free on a host. */
21
+ export async function isPortAvailable(port, host = '127.0.0.1') {
22
+ return new Promise((resolve) => {
23
+ const server = net.createServer()
24
+ server.once('error', () => resolve(false))
25
+ server.once('listening', () => {
26
+ server.close(() => resolve(true))
27
+ })
28
+ server.listen(port, host)
29
+ })
30
+ }
31
+
32
+ /** Find the next available TCP port sequentially starting from startPort. */
33
+ export async function findNextAvailablePort(startPort, host = '127.0.0.1') {
34
+ let port = Number(startPort)
35
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
36
+ throw new RangeError(`Invalid start port: ${startPort}`)
37
+ }
38
+ while (!(await isPortAvailable(port, host))) {
39
+ port++
40
+ if (port > 65535) {
41
+ throw new Error(`No available port found up to 65535 starting from ${startPort}`)
42
+ }
43
+ }
44
+ return port
45
+ }
46
+
47
+ /** Check desired AnchorDB ports and suggest alternatives if any is taken. */
48
+ export async function resolveAnchorPorts({
49
+ desiredWirePort = WIRE_PORT_DEFAULT,
50
+ desiredHttpPort = HTTP_PORT_DEFAULT,
51
+ host = '127.0.0.1'
52
+ } = {}) {
53
+ const wireAvailable = await isPortAvailable(desiredWirePort, host)
54
+ const httpAvailable = desiredHttpPort !== desiredWirePort && (await isPortAvailable(desiredHttpPort, host))
55
+
56
+ const suggestedWirePort = wireAvailable ? desiredWirePort : await findNextAvailablePort(desiredWirePort + 1, host)
57
+ let candidateHttp = (httpAvailable && desiredHttpPort !== suggestedWirePort)
58
+ ? desiredHttpPort
59
+ : await findNextAvailablePort(desiredHttpPort + 1, host)
60
+ if (candidateHttp === suggestedWirePort) {
61
+ candidateHttp = await findNextAvailablePort(candidateHttp + 1, host)
62
+ }
63
+
64
+ return {
65
+ wirePort: desiredWirePort,
66
+ httpPort: desiredHttpPort,
67
+ wireAvailable,
68
+ httpAvailable,
69
+ suggestedWirePort,
70
+ suggestedHttpPort: candidateHttp
71
+ }
72
+ }
73
+
74
+ /** Release asset name for a platform/architecture, per the AnchorDB release pattern. */
75
+ export function releaseAssetName(version, platform = process.platform, arch = process.arch) {
76
+ const archName = arch === 'arm64' ? 'arm64' : 'amd64'
77
+ const platformName = platform === 'darwin' ? 'darwin' : 'linux'
78
+ return `anchordb_${version}_${platformName}_${archName}.tar.gz`
79
+ }
80
+
81
+ /** Random token for ANCHORDB_AUTH_TOKEN: 32 bytes of hex. */
82
+ export function generateToken() {
83
+ return crypto.randomBytes(32).toString('hex')
84
+ }
85
+
86
+ /** Fetch the latest release from the GitHub API. */
87
+ export async function latestRelease(repo = 'rachmanzz/anchordb') {
88
+ const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`)
89
+ if (!res.ok) throw new Error(`GitHub API ${res.status}: failed to read the latest release`)
90
+ const body = await res.json()
91
+ if (!body.tag_name) throw new Error('Latest release has no tag_name')
92
+ const version = String(body.tag_name).replace(/^v/, '')
93
+ return { tag: body.tag_name, version }
94
+ }
95
+
96
+ /** Download one file to disk. */
97
+ export async function downloadFile(url, dest) {
98
+ const res = await fetch(url, { redirect: 'follow' })
99
+ if (!res.ok) throw new Error(`Download failed (${res.status}): ${url}`)
100
+ const buf = Buffer.from(await res.arrayBuffer())
101
+ await fs.writeFile(dest, buf)
102
+ return buf.length
103
+ }
104
+
105
+ /**
106
+ * Verify a file against SHA256SUMS.txt contents in the standard format:
107
+ * "<hash> <name>" per line. Throws an Error when the name is missing or
108
+ * the hash does not match.
109
+ */
110
+ export async function verifySha256(filePath, sumsText) {
111
+ const fileName = path.basename(filePath)
112
+ const line = String(sumsText)
113
+ .split('\n')
114
+ .find((l) => l.trim().endsWith(fileName))
115
+ if (!line) throw new Error(`${fileName} is not in SHA256SUMS.txt`)
116
+ const expected = line.trim().split(/\s+/)[0].toLowerCase()
117
+ const actual = crypto.createHash('sha256').update(await fs.readFile(filePath)).digest('hex')
118
+ if (actual !== expected) {
119
+ throw new Error(`SHA256 mismatch for ${fileName}\n wanted ${expected}\n got ${actual}`)
120
+ }
121
+ return expected
122
+ }
123
+
124
+ /**
125
+ * Merge the anchordb block into the global config (knl-config.json)
126
+ * without dropping other fields that may live there. Returns the config
127
+ * contents before the overwrite (as a backup, in case the caller shows it).
128
+ */
129
+ export async function mergeGlobalAnchordb(home, anchordb, options = {}) {
130
+ const file = globalConfigPath(home, options)
131
+ let before = null
132
+ let raw = {}
133
+ try {
134
+ const text = await fs.readFile(file, 'utf8')
135
+ before = text
136
+ const parsed = JSON.parse(text)
137
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) raw = parsed
138
+ } catch {
139
+ // Missing/broken file: start from an empty object.
140
+ }
141
+ raw.anchordb = anchordb
142
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
143
+ await fs.writeFile(file, JSON.stringify(raw, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
144
+ try { await fs.chmod(file, 0o600) } catch {}
145
+ return before
146
+ }
147
+
148
+ /** List all available container runtimes on the system. */
149
+ export async function availableContainerRuntimes(which) {
150
+ const found = []
151
+ for (const runtime of ['docker', 'podman']) {
152
+ if (await which(runtime)) found.push(runtime)
153
+ }
154
+ return found
155
+ }
156
+
157
+ /** Pick an available container runtime: preferred first, or first detected. */
158
+ export async function detectContainerRuntime(which, preferred = null) {
159
+ if (preferred) {
160
+ if (await which(preferred)) return preferred
161
+ return null
162
+ }
163
+ const available = await availableContainerRuntimes(which)
164
+ return available[0] || null
165
+ }
166
+
167
+ /**
168
+ * Inspect detailed container lifecycle status across available runtimes.
169
+ * Returns exists, runtime, running, valid, status, id, created, token, wire_port.
170
+ */
171
+ export async function inspectContainerStatus(exec, name = CONTAINER_NAME) {
172
+ for (const runtime of ['podman', 'docker']) {
173
+ try {
174
+ const { stdout } = await exec(runtime, [
175
+ 'inspect',
176
+ '--format',
177
+ '{{.Id}}|{{.Created}}|{{.State.Status}}|{{.State.Running}}|{{json .Config.Env}}',
178
+ name
179
+ ])
180
+ const parts = stdout.trim().split('|')
181
+ if (parts.length >= 4 && parts[0]) {
182
+ const id = parts[0].trim()
183
+ const created = parts[1].trim()
184
+ const status = (parts[2] || '').trim().toLowerCase()
185
+ const running = (parts[3] || '').trim().toLowerCase() === 'true'
186
+ const valid = !['dead', 'removal', 'corrupt'].includes(status) && id.length >= 12
187
+ const envStr = parts.slice(4).join('|')
188
+ let token = null
189
+ let port = String(WIRE_PORT_DEFAULT)
190
+ try {
191
+ const envs = JSON.parse(envStr)
192
+ for (const e of envs) {
193
+ if (e.startsWith('ANCHORDB_AUTH_TOKEN=')) token = e.slice('ANCHORDB_AUTH_TOKEN='.length)
194
+ if (e.startsWith('ANCHORDB_WIRE_PORT=')) port = e.slice('ANCHORDB_WIRE_PORT='.length)
195
+ }
196
+ } catch {}
197
+
198
+ return {
199
+ exists: true,
200
+ runtime,
201
+ name,
202
+ id,
203
+ created,
204
+ status,
205
+ running,
206
+ valid,
207
+ token,
208
+ wire_port: port
209
+ }
210
+ }
211
+ } catch {
212
+ // not in this runtime
213
+ }
214
+ }
215
+
216
+ return {
217
+ exists: false,
218
+ runtime: null,
219
+ name,
220
+ id: null,
221
+ created: null,
222
+ status: null,
223
+ running: false,
224
+ valid: false,
225
+ token: null,
226
+ wire_port: String(WIRE_PORT_DEFAULT)
227
+ }
228
+ }
229
+
230
+ export async function startContainer(exec, runtime, name = CONTAINER_NAME) {
231
+ await exec(runtime, ['start', name])
232
+ }
233
+
234
+ export async function removeContainer(exec, runtime, name = CONTAINER_NAME) {
235
+ await exec(runtime, ['rm', '-f', name])
236
+ }
237
+
238
+ export async function detectGraphify(exec) {
239
+ try {
240
+ await exec('which', ['graphify'])
241
+ return true
242
+ } catch {
243
+ return false
244
+ }
245
+ }
246
+
247
+ export const DECISIONSKILLS_REPO = 'rachmanzz/decisionskills'
248
+
249
+ /**
250
+ * Check if the decision-history skill (from rachmanzz/decisionskills) is
251
+ * installed in project or global agent directories, and if an update is available.
252
+ */
253
+ export async function checkDecisionSkillsStatus({ cwd = process.cwd(), home = process.env.HOME }) {
254
+ const projectSkill = path.join(cwd, '.agents', 'skills', 'decision-history', 'SKILL.md')
255
+ let hasProject = false
256
+ try {
257
+ await fs.stat(projectSkill)
258
+ hasProject = true
259
+ } catch {}
260
+
261
+ const globalPaths = [
262
+ path.join(home || '', '.codex', 'skills', 'decision-history', 'SKILL.md'),
263
+ path.join(home || '', '.claude', 'skills', 'decision-history', 'SKILL.md'),
264
+ path.join(home || '', '.config', 'opencode', 'skills', 'decision-history', 'SKILL.md'),
265
+ path.join(home || '', '.gemini', 'config', 'plugins', 'decision-history', 'skills', 'decision-history', 'SKILL.md'),
266
+ path.join(home || '', '.agents', 'skills', 'decision-history', 'SKILL.md')
267
+ ]
268
+
269
+ let hasGlobal = false
270
+ for (const p of globalPaths) {
271
+ try {
272
+ await fs.stat(p)
273
+ hasGlobal = true
274
+ break
275
+ } catch {}
276
+ }
277
+
278
+ let currentVersion = null
279
+ let latestVersion = null
280
+ let status = 'not_installed'
281
+
282
+ if (hasProject || hasGlobal) {
283
+ status = 'installed'
284
+ const versionCandidates = [
285
+ path.join(cwd, '.agents', 'skills', 'decision-history', 'VERSION'),
286
+ path.join(home || '', '.gemini', 'config', 'plugins', 'decision-history', 'VERSION')
287
+ ]
288
+ for (const vFile of versionCandidates) {
289
+ try {
290
+ const text = await fs.readFile(vFile, 'utf8')
291
+ if (text.trim()) {
292
+ currentVersion = text.trim()
293
+ break
294
+ }
295
+ } catch {}
296
+ }
297
+
298
+ try {
299
+ const res = await fetch('https://raw.githubusercontent.com/rachmanzz/decisionskills/main/VERSION', {
300
+ signal: AbortSignal.timeout(2000)
301
+ })
302
+ if (res.ok) {
303
+ latestVersion = (await res.text()).trim()
304
+ if (currentVersion && latestVersion && currentVersion !== latestVersion) {
305
+ status = 'needs_update'
306
+ }
307
+ }
308
+ } catch {}
309
+ }
310
+
311
+ return {
312
+ hasProject,
313
+ hasGlobal,
314
+ status, // 'not_installed' | 'installed' | 'needs_update'
315
+ currentVersion,
316
+ latestVersion,
317
+ projectPath: projectSkill
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Install or update the decision-history skill from rachmanzz/decisionskills.
323
+ * Scope: 'project' | 'global' | 'both'.
324
+ */
325
+ export async function installDecisionSkills({
326
+ scope = 'project',
327
+ cwd = process.cwd(),
328
+ home = process.env.HOME,
329
+ exec
330
+ }) {
331
+ const results = []
332
+
333
+ const copyDir = async (src, dest) => {
334
+ await fs.mkdir(dest, { recursive: true })
335
+ const entries = await fs.readdir(src, { withFileTypes: true })
336
+ for (const entry of entries) {
337
+ const srcPath = path.join(src, entry.name)
338
+ const destPath = path.join(dest, entry.name)
339
+ if (entry.isDirectory()) {
340
+ await copyDir(srcPath, destPath)
341
+ } else {
342
+ await fs.copyFile(srcPath, destPath)
343
+ }
344
+ }
345
+ }
346
+
347
+ const doInstall = async (isGlobal) => {
348
+ let success = false
349
+ if (exec) {
350
+ try {
351
+ const args = ['--yes', 'skills', 'add', DECISIONSKILLS_REPO, '--skill', 'decision-history', '-y', '--copy']
352
+ if (isGlobal) args.push('-g')
353
+ await exec('npx', args, { cwd })
354
+ success = true
355
+ } catch {}
356
+ }
357
+
358
+ if (!success) {
359
+ const fallbackSources = [
360
+ path.join(home || '', '.gemini', 'config', 'plugins', 'decision-history', 'skills', 'decision-history'),
361
+ path.join(home || '', '.agents', 'skills', 'decision-history'),
362
+ path.join(home || '', '.codex', 'skills', 'decision-history')
363
+ ]
364
+ let srcDir = null
365
+ for (const candidate of fallbackSources) {
366
+ try {
367
+ await fs.stat(path.join(candidate, 'SKILL.md'))
368
+ srcDir = candidate
369
+ break
370
+ } catch {}
371
+ }
372
+
373
+ if (srcDir) {
374
+ const dest = isGlobal
375
+ ? path.join(home || '', '.agents', 'skills', 'decision-history')
376
+ : path.join(cwd, '.agents', 'skills', 'decision-history')
377
+ await copyDir(srcDir, dest)
378
+ success = true
379
+ }
380
+ }
381
+
382
+ if (isGlobal && exec) {
383
+ const agyDir = path.join(home || '', '.gemini', 'config', 'plugins', 'decision-history')
384
+ try {
385
+ await fs.stat(agyDir)
386
+ await exec('git', ['pull'], { cwd: agyDir })
387
+ } catch {
388
+ try {
389
+ await fs.mkdir(path.dirname(agyDir), { recursive: true })
390
+ await exec('git', ['clone', 'https://github.com/rachmanzz/decisionskills.git', agyDir])
391
+ } catch {}
392
+ }
393
+ }
394
+
395
+ return success
396
+ }
397
+
398
+ if (scope === 'project' || scope === 'both') {
399
+ const ok = await doInstall(false)
400
+ results.push({ scope: 'project', success: ok })
401
+ }
402
+ if (scope === 'global' || scope === 'both') {
403
+ const ok = await doInstall(true)
404
+ results.push({ scope: 'global', success: ok })
405
+ }
406
+
407
+ return results
408
+ }
409
+
410
+ /**
411
+ * Detect a running AnchorDB daemon on the system (docker, podman, or binary).
412
+ * Inspects containers for CONTAINER_NAME or checks running processes.
413
+ */
414
+ export async function detectRunningDaemon(exec) {
415
+ const container = await inspectContainerStatus(exec)
416
+ if (container.exists && container.running && container.valid) {
417
+ return {
418
+ daemon: container.runtime,
419
+ deamon: container.runtime,
420
+ container_name: container.name,
421
+ container_id: container.id,
422
+ container_created_on: container.created,
423
+ token: container.token,
424
+ wire_port: container.wire_port
425
+ }
426
+ }
427
+
428
+ // Check if binary process is running
429
+ try {
430
+ const { stdout } = await exec('pgrep', ['-f', 'anchordb-server'])
431
+ if (stdout.trim()) {
432
+ return { daemon: 'binary', deamon: 'binary' }
433
+ }
434
+ } catch {}
435
+
436
+ return null
437
+ }
package/server/map.js ADDED
@@ -0,0 +1,126 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ // The optional map (map.json): pure annotation on top of the disk structure.
5
+ // Principle: the disk is the truth, the map is an overlay. The map NEVER
6
+ // defines the existence of files/folders. A file missing from disk stays
7
+ // missing from the viewer even when the map still lists it (orphan entries
8
+ // are silently ignored). The map only decorates: title, order, metadata.
9
+
10
+ const MAP_FILE = 'map.json'
11
+ const MAX_MAP_BYTES = 1024 * 1024
12
+
13
+ export async function readMap(rootAbs) {
14
+ const abs = path.join(rootAbs, MAP_FILE)
15
+ const stat = await statOrNull(abs)
16
+ if (!stat || !stat.isFile() || stat.size > MAX_MAP_BYTES) return null
17
+
18
+ let parsed
19
+ try {
20
+ parsed = JSON.parse(await fs.readFile(abs, 'utf8'))
21
+ } catch {
22
+ // A broken map must never bring the viewer down: ignore it quietly.
23
+ return null
24
+ }
25
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
26
+ return parsed
27
+ }
28
+
29
+ // Normalize shapes: the map may write the title in `title` (root/files),
30
+ // `sections`/`dirs` (folders), and `docs`/`files` (files). Only one clean
31
+ // representation matters for the overlay. Each entry may carry
32
+ // `ignore: true` to hide it (the disk still owns the file).
33
+ export function normalizeMap(map) {
34
+ if (!map) return { title: null, entries: new Map() }
35
+ const entries = new Map()
36
+
37
+ const dirs = { ...asObject(map.sections), ...asObject(map.dirs) }
38
+ for (const [key, value] of Object.entries(dirs)) {
39
+ const entry = asObject(value)
40
+ if (!entry) continue
41
+ mergeEntry(entries, cleanKey(key), entry)
42
+ }
43
+
44
+ // `docs`/`files` can be placed per folder:
45
+ // { "features/x": { "docs": { "a.md": {...} } } } or globally.
46
+ // Both are written into the same file entries.
47
+ const globalDocs = { ...asObject(map.docs), ...asObject(map.files) }
48
+ applyDocs(entries, '', globalDocs)
49
+ for (const [key, value] of Object.entries(dirs)) {
50
+ const docs = asObject(asObject(value)?.docs)
51
+ if (docs) applyDocs(entries, cleanKey(key), docs)
52
+ }
53
+
54
+ const ignoreList = Array.isArray(map.ignore) ? map.ignore : []
55
+ for (const path of ignoreList) {
56
+ if (typeof path !== 'string' || !path.trim()) continue
57
+ mergeEntry(entries, cleanKey(path), { ignore: true })
58
+ }
59
+
60
+ return {
61
+ title: typeof map.title === 'string' ? map.title.trim() : null,
62
+ entries
63
+ }
64
+ }
65
+
66
+ function applyDocs(entries, dirKey, docs) {
67
+ for (const [name, value] of Object.entries(docs)) {
68
+ const entry = asObject(value) || {}
69
+ const key = dirKey ? `${dirKey}/${name}` : name
70
+ mergeEntry(entries, cleanKey(key), entry)
71
+ }
72
+ }
73
+
74
+ function mergeEntry(entries, key, entry) {
75
+ const prev = entries.get(key) || {}
76
+ entries.set(key, {
77
+ title: typeof entry.title === 'string' ? entry.title.trim() : prev.title ?? null,
78
+ order: toOrder(entry.order) ?? prev.order ?? null,
79
+ meta: typeof entry.meta === 'string' ? entry.meta.trim() : prev.meta ?? null,
80
+ ignore: entry.ignore === true || entry.hidden === true ? true : prev.ignore === true
81
+ })
82
+ }
83
+
84
+ // Apply the overlay to one node. Returns a new node (without corrupting the
85
+ // original object from disk), or the same node when the map does not touch it.
86
+ export function decorateNode(node, norm) {
87
+ const entry = norm?.entries?.get(node.path)
88
+ let next = node
89
+
90
+ const title = entry?.title
91
+ if (title) {
92
+ // A map title beats the heading title read from the file contents.
93
+ // `titleFromMap` marks that this label came from the map, so the view
94
+ // may use it directly as the label (not just as a subtitle).
95
+ next = { ...next, title, titleFromMap: true }
96
+ }
97
+ const order = entry?.order
98
+ if (order != null) next = { ...next, order }
99
+ const meta = entry?.meta
100
+ if (meta != null) next = { ...next, meta }
101
+
102
+ // Children are already decorated by buildTree while walking the disk,
103
+ // so here it is enough to touch this node only.
104
+ return next
105
+ }
106
+
107
+ function asObject(value) {
108
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null
109
+ }
110
+
111
+ function cleanKey(key) {
112
+ return String(key).replace(/^\.?\//, '').replace(/\/+$/, '')
113
+ }
114
+
115
+ function toOrder(value) {
116
+ const n = Number(value)
117
+ return Number.isFinite(n) ? n : null
118
+ }
119
+
120
+ async function statOrNull(abs) {
121
+ try {
122
+ return await fs.stat(abs)
123
+ } catch {
124
+ return null
125
+ }
126
+ }
@@ -0,0 +1,109 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import os from 'node:os'
5
+ import { knlGlobalDir } from './config.js'
6
+
7
+ // Global registry of AnchorDB model names:
8
+ // - %APPDATA%\knlview\models.json on Windows
9
+ // - ~/.knlview/models.json on Linux and macOS
10
+ //
11
+ // Model names may be random; what makes a random name meaningful is this
12
+ // registry: the mapping of name → project, role, and project location.
13
+ // knl-view code never guesses model names: it only knows roles (knowledges,
14
+ // consensus, etc.) and reads each role from the project map.
15
+
16
+ const REGISTRY_FILE = 'models.json'
17
+
18
+ export function modelRegistryPath(home = null, options = {}) {
19
+ return path.join(knlGlobalDir(home, options), REGISTRY_FILE)
20
+ }
21
+
22
+ /**
23
+ * Random name for a model: 8 Crockford base32 characters (no I, L, O, U
24
+ * so humans never misread/mistype them).
25
+ */
26
+ export function generateModelName() {
27
+ const alphabet = '0123456789abcdefghjkmnpqrstvwxyz'
28
+ const bytes = crypto.randomBytes(8)
29
+ let name = ''
30
+ for (const byte of bytes) name += alphabet[byte % alphabet.length]
31
+ return name
32
+ }
33
+
34
+ /**
35
+ * Read the registry. Missing/broken file = empty registry, not an error.
36
+ * @returns {Promise<{models: object, error: string|null}>}
37
+ */
38
+ export async function readModelRegistry(home = null, options = {}) {
39
+ const primary = modelRegistryPath(home, options)
40
+ let content = null
41
+ try {
42
+ content = await fs.readFile(primary, 'utf8')
43
+ } catch (err) {
44
+ if (err?.code === 'ENOENT') {
45
+ const platform = options.platform || process.platform
46
+ const env = options.env || process.env
47
+ if (platform === 'win32' && !home) {
48
+ const winHome = env.USERPROFILE || env.HOME || (typeof os.homedir === 'function' ? os.homedir() : null)
49
+ if (winHome) {
50
+ try {
51
+ content = await fs.readFile(path.join(winHome, '.knlview', REGISTRY_FILE), 'utf8')
52
+ } catch {}
53
+ }
54
+ }
55
+ if (!content) return { models: {}, error: null }
56
+ } else {
57
+ return { models: {}, error: err.message }
58
+ }
59
+ }
60
+
61
+ try {
62
+ const parsed = JSON.parse(content)
63
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
64
+ return { models: {}, error: 'registry contents are not an object' }
65
+ }
66
+ const models = {}
67
+ for (const [name, entry] of Object.entries(parsed)) {
68
+ if (entry && typeof entry === 'object' && typeof entry.role === 'string') {
69
+ models[name] = entry
70
+ }
71
+ }
72
+ return { models, error: null }
73
+ } catch (err) {
74
+ return { models: {}, error: err.message }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Register (or update) one model in the registry. Returns the previous owner
80
+ * when the name is already claimed by another project; the caller decides
81
+ * whether to warn.
82
+ * @returns {Promise<{previous: object|null, written: boolean, error: string|null}>}
83
+ */
84
+ export async function registerModel(name, { role, project, cwd }, home = null, options = {}) {
85
+ const { models, error } = await readModelRegistry(home, options)
86
+ if (error) return { previous: null, written: false, error }
87
+
88
+ const previous = models[name] || null
89
+ const sameOwner = previous && path.resolve(previous.cwd || '') === path.resolve(cwd)
90
+ if (previous && !sameOwner) {
91
+ return { previous, written: false, error: null }
92
+ }
93
+
94
+ models[name] = {
95
+ role,
96
+ project,
97
+ cwd: path.resolve(cwd),
98
+ registeredAt: new Date().toISOString()
99
+ }
100
+
101
+ try {
102
+ const file = modelRegistryPath(home, options)
103
+ await fs.mkdir(path.dirname(file), { recursive: true })
104
+ await fs.writeFile(file, JSON.stringify(models, null, 2) + '\n', 'utf8')
105
+ return { previous: null, written: true, error: null }
106
+ } catch (err) {
107
+ return { previous: null, written: false, error: err.message }
108
+ }
109
+ }