@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,943 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util'
3
+ import path from 'node:path'
4
+ import fs from 'node:fs'
5
+ import readline from 'node:readline/promises'
6
+ import { execFile, spawn } from 'node:child_process'
7
+ import { promisify } from 'node:util'
8
+ import os from 'node:os'
9
+ import { fileURLToPath } from 'node:url'
10
+ import { createKnowledgeServer, attachAnchorDB } from '../server/index.js'
11
+ import { scaffoldBase, seedSkill, ensureGitignore } from '../server/scaffold.js'
12
+ import { buildGraphFromRoot } from '../server/graph/parser.js'
13
+ import {
14
+ claimId,
15
+ deriveStatus,
16
+ recordClaim,
17
+ readClaimsIndex,
18
+ readConsensusMirror,
19
+ updateConsensusMirror,
20
+ inboundConsensus,
21
+ outboundConsensus,
22
+ CONSENSUS_RELATIONS
23
+ } from '../server/consensus.js'
24
+ import { readProjectConfig, readGlobalConfig, projectSlug, anchordbClientOptions, resolveModel, resolveSettings, DEFAULT_MODEL } from '../server/config.js'
25
+ import { createAnchorWireClient, mirrorStillValid } from '../server/graph/anchordb_client.js'
26
+ import { syncSourcesToAnchorDB, traceSources, purgeArchivedRecords } from '../server/graph/sources.js'
27
+ import { readModelRegistry, registerModel, generateModelName, modelRegistryPath } from '../server/models_registry.js'
28
+ import {
29
+ latestRelease,
30
+ releaseAssetName,
31
+ generateToken,
32
+ downloadFile,
33
+ verifySha256,
34
+ mergeGlobalAnchordb,
35
+ detectContainerRuntime,
36
+ GHCR_IMAGE,
37
+ HTTP_PORT_DEFAULT,
38
+ WIRE_PORT_DEFAULT,
39
+ CONTAINER_NAME
40
+ } from '../server/install.js'
41
+
42
+ const exec = promisify(execFile)
43
+
44
+ const here = path.dirname(fileURLToPath(import.meta.url))
45
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'))
46
+
47
+ // Model roles must be lowercase slugs; used by runModelCommand (called from
48
+ // the top-level flow, so declared before use).
49
+ const MODEL_ROLE_RE = /^[a-z][a-z0-9_-]*$/
50
+
51
+ // Claim ids are content-addressed: "c-" + 16 hex chars of the statement
52
+ // hash. Used by runConsensusCommand (invoked from the top-level flow, so
53
+ // declared before use).
54
+ const CLAIM_ID_RE = /^c-[0-9a-f]{16}$/
55
+
56
+ const HELP = `knl-view ${pkg.version}
57
+ Local viewer for the .knowledges knowledge base.
58
+
59
+ Usage:
60
+ knl-view --port 8086 --path=.knowledges
61
+ knl-view --init --path=.knowledges
62
+ knl-view graph trace --target=<id> [--hops=2] [--direction=in|out|both] [--path=.knowledges]
63
+
64
+ Defaults for --port/--path/--host can be stored in knl-config.json in the
65
+ project folder (machine-local, gitignored). CLI flags always override config.
66
+
67
+ AnchorDB is optional and read from the global config
68
+ ~/.knlview/knl-config.json (URL, token, sync) so every project using it
69
+ shares the same server. Without that block, the viewer runs fully local
70
+ and never touches AnchorDB. Per-project model names are managed through
71
+ the "models" map in knl-config.json: role → name; names may be random
72
+ because the map records what each name is for. The global registry
73
+ ~/.knlview/models.json records the owner of every model name.
74
+
75
+ Commands:
76
+ model new <role> Generate a random model name for a role (e.g. knowledges),
77
+ write it to knl-config.json and the global registry
78
+ model check Check this project's models map and the global registry
79
+ install anchordb Install AnchorDB: binary from GitHub Releases on
80
+ Linux and macOS; docker/podman container elsewhere
81
+ consensus claim "<text>" Register a content-addressed claim (c-<hash>)
82
+ consensus agree <a> <b> Claim a supports claim b (stored in AnchorDB)
83
+ consensus contradict <a> <b> Claim a opposes claim b
84
+ consensus retract <a> <b> Remove any relation between two claims
85
+ consensus status <id> Consensus status of one claim
86
+ consensus list Locally known claims with their status
87
+ sources sync Sync the whole-project dependency graph (file
88
+ nodes + import edges) into the AnchorDB
89
+ "sources" model; server-authoritative, no mirror
90
+ sources collect Purge archived (tombstoned) records of the
91
+ "sources" model via the REST collector
92
+ sources trace --target=<path> File lineage over the sources graph
93
+ (--hops, --direction in|out|both)
94
+
95
+ Options:
96
+ --port, -p <number> HTTP port (default: 8085, or knl-config.json)
97
+ --path <folder> Knowledge base folder (default: .knowledges, or knl-config.json)
98
+ --host <address> Bind address (default: 127.0.0.1, or knl-config.json)
99
+ --anchordb <url> AnchorWire URL for this session, overrides the
100
+ "anchordb" block in knl-config.json
101
+ --method <method> Method for "install anchordb": binary | container
102
+ --token <text> Auth token for "install anchordb" (default: random)
103
+ --direction <dir> Lineage direction for "graph trace": in | out | both
104
+ (default: both; see README "Two-way lineage")
105
+ --target <id> Target node id for the subgraph query
106
+ --hops <number> Graph trace hop radius (default: 2)
107
+ --init Scaffold a new base via the wizard (does not start the server)
108
+ --structure <layout> Layout for non-interactive --init:
109
+ standard | empty | custom (default: standard)
110
+ --folders <a,b,c> Folder names for --structure=custom
111
+ --seed-skill=knl-view Write the OpenCode "knl-view" skill (skipped if it already exists)
112
+ --dir <location> Skill location: project (.agents/skills) | global
113
+ (~/.config/opencode/skills). Default: project
114
+ --yes, -y Use all default answers, no prompting
115
+ --help, -h Show this help
116
+ --version, -v Show version
117
+
118
+ Examples:
119
+ knl-view --path=../anchor-connector/.knowledges
120
+ knl-view --port 8086 --path=.knowledges
121
+ knl-view graph trace --target=README.md --hops=1
122
+ knl-view graph trace --target=DEC-20261030-135001
123
+ knl-view --init
124
+ knl-view --seed-skill=knl-view --dir=global --yes
125
+ `
126
+
127
+ let values, positionals
128
+ try {
129
+ ({ values, positionals } = parseArgs({
130
+ options: {
131
+ port: { type: 'string', short: 'p' },
132
+ path: { type: 'string' },
133
+ host: { type: 'string' },
134
+ anchordb: { type: 'string' },
135
+ method: { type: 'string' },
136
+ token: { type: 'string' },
137
+ target: { type: 'string' },
138
+ direction: { type: 'string' },
139
+ hops: { type: 'string', default: '2' },
140
+ init: { type: 'boolean', default: false },
141
+ structure: { type: 'string', default: 'standard' },
142
+ folders: { type: 'string', default: '' },
143
+ 'seed-skill': { type: 'string' },
144
+ dir: { type: 'string', default: 'project' },
145
+ yes: { type: 'boolean', short: 'y', default: false },
146
+ help: { type: 'boolean', short: 'h', default: false },
147
+ version: { type: 'boolean', short: 'v', default: false }
148
+ },
149
+ allowPositionals: true
150
+ }))
151
+ } catch (err) {
152
+ fail(`${err.message}\n\nRun "knl-view --help" for help.`)
153
+ }
154
+
155
+ if (values['seed-skill'] !== undefined && values['seed-skill'] !== 'knl-view') {
156
+ fail(`--seed-skill only knows "knl-view", got "${values['seed-skill']}".`)
157
+ }
158
+ if (!['project', 'global'].includes(values.dir)) {
159
+ fail(`--dir must be project or global, got "${values.dir}".`)
160
+ }
161
+ if (!['standard', 'empty', 'custom'].includes(values.structure)) {
162
+ fail(`--structure must be standard, empty, or custom, got "${values.structure}".`)
163
+ }
164
+
165
+ if (values.help) {
166
+ process.stdout.write(HELP)
167
+ process.exit(0)
168
+ }
169
+ if (values.version) {
170
+ process.stdout.write(`${pkg.version}\n`)
171
+ process.exit(0)
172
+ }
173
+
174
+ // Two-layer config: project (knl-config.json) and global (~/.knlview/).
175
+ // A missing file is fine; a broken one is ignored with a single warning on
176
+ // stderr so unused values are not a mystery.
177
+ const { config: projectConfig, error: projectError } = await readProjectConfig()
178
+ if (projectError) {
179
+ process.stderr.write(`knl-view: ignoring knl-config.json (${projectError})\n`)
180
+ }
181
+ const { config: globalConfig, error: globalError } = await readGlobalConfig()
182
+ if (globalError) {
183
+ process.stderr.write(`knl-view: ignoring ~/.knlview/knl-config.json (${globalError})\n`)
184
+ }
185
+ const settings = resolveSettings({
186
+ cli: values,
187
+ project: projectConfig,
188
+ global: globalConfig
189
+ })
190
+ if (values.anchordb !== undefined && !settings.anchordb) {
191
+ fail('--anchordb must be a wire:// or tcp:// URL, example: --anchordb=wire://127.0.0.1:7448?token=xxx')
192
+ }
193
+
194
+ // The "model" command: manage the role → AnchorDB model name map. No other
195
+ // code ever guesses names; everything resolves through the role in this map.
196
+ const modelCommand = positionals?.[0] === 'model' ? positionals?.[1] : null
197
+ if (modelCommand) {
198
+ await runModelCommand(modelCommand, positionals?.[2], { projectConfig, settings })
199
+ process.exit(0)
200
+ }
201
+
202
+ // The "install" command: install AnchorDB (binary on Linux, container on
203
+ // other platforms) then write the anchordb block to the global config.
204
+ if (positionals?.[0] === 'install') {
205
+ await runInstallCommand(positionals?.[1], { values })
206
+ process.exit(0)
207
+ }
208
+
209
+ // The "consensus" command: agreement/conflict between claims, stored purely
210
+ // in the AnchorDB consensus model (the "consensus" role in the models map).
211
+ const consensusSub = positionals?.[0] === 'consensus' ? positionals?.[1] : null
212
+ if (consensusSub) {
213
+ try {
214
+ await runConsensusCommand(consensusSub, positionals?.slice(2), { settings })
215
+ } catch (err) {
216
+ fail(`Consensus command failed: ${err.message}`)
217
+ }
218
+ process.exit(0)
219
+ }
220
+
221
+ // The "sources" command: dependency graph of the whole project (not just
222
+ // .knowledges) stored purely in the AnchorDB sources model.
223
+ const sourcesSub = positionals?.[0] === 'sources' ? positionals?.[1] : null
224
+ if (sourcesSub) {
225
+ try {
226
+ await runSourcesCommand(sourcesSub, values, { settings })
227
+ } catch (err) {
228
+ fail(`Sources command failed: ${err.message}`)
229
+ }
230
+ process.exit(0)
231
+ }
232
+
233
+ // Register the project's whole models map to the global registry (updating
234
+ // path/timestamp when this project owns them) and warn about names claimed
235
+ // by other projects. This local registration needs no server connection.
236
+ if (settings.models) {
237
+ for (const [role, name] of Object.entries(settings.models)) {
238
+ const { previous, error } = await registerModel(name, {
239
+ role,
240
+ project: projectSlug(process.cwd()),
241
+ cwd: process.cwd()
242
+ })
243
+ if (error) {
244
+ process.stderr.write(`knl-view: ignoring model registry (${error})\n`)
245
+ } else if (previous) {
246
+ process.stdout.write(` warning: model "${name}" (${role}) is also used by another project: ${previous.cwd}\n`)
247
+ }
248
+ }
249
+ }
250
+
251
+ const isGraphTrace = (positionals?.[0] === 'graph' && positionals?.[1] === 'trace') || values.target !== undefined
252
+ if (isGraphTrace) {
253
+ const target = values.target || positionals?.[2]
254
+ if (!target) {
255
+ fail('Parameter --target is required:\n knl-view graph trace --target=<id> [--hops=2] [--direction=in|out|both]')
256
+ }
257
+ const root = path.resolve(process.cwd(), settings.path)
258
+ if (!fs.existsSync(root)) {
259
+ fail(`Folder not found: ${root}\nCheck --path, or run from a project folder that has a .knowledges base.`)
260
+ }
261
+ if (!fs.statSync(root).isDirectory()) {
262
+ fail(`--path must be a folder, not a file: ${root}`)
263
+ }
264
+ const hops = parseInt(values.hops || '2', 10)
265
+ let direction = 'both'
266
+ if (values.direction !== undefined) {
267
+ if (!['in', 'out', 'both'].includes(values.direction)) {
268
+ fail(`--direction must be in, out, or both, got "${values.direction}".`)
269
+ }
270
+ direction = values.direction
271
+ }
272
+ const graph = await buildGraphFromRoot(root)
273
+ const subGraph = graph.extractSubGraph(target, Number.isInteger(hops) && hops > 0 ? hops : 2, direction)
274
+ process.stdout.write(JSON.stringify(subGraph, null, 2) + '\n')
275
+ process.exit(0)
276
+ }
277
+
278
+ if (values.init || values['seed-skill'] !== undefined) {
279
+ await runSetup()
280
+ process.exit(0)
281
+ }
282
+
283
+ const port = Number(settings.port)
284
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
285
+ fail(`--port must be a number 1-65535, got "${settings.port}".`)
286
+ }
287
+
288
+ const root = path.resolve(process.cwd(), settings.path)
289
+ if (!fs.existsSync(root)) {
290
+ fail(`Folder not found: ${root}\nCheck --path, or run from a project folder that has a .knowledges base.`)
291
+ }
292
+ if (!fs.statSync(root).isDirectory()) {
293
+ fail(`--path must be a folder, not a file: ${root}`)
294
+ }
295
+
296
+ const dist = path.join(here, '..', 'dist')
297
+ if (!fs.existsSync(path.join(dist, 'index.html'))) {
298
+ fail(`UI not built yet (${dist}).\nRun first: npm run build`)
299
+ }
300
+
301
+ const server = createKnowledgeServer({
302
+ root,
303
+ dist,
304
+ host: settings.host,
305
+ port,
306
+ anchordb: settings.anchordb,
307
+ consensusModel: resolveModel(settings.models, 'consensus'),
308
+ sourcesModel: resolveModel(settings.models, 'sources')
309
+ })
310
+
311
+ server.on('error', (err) => {
312
+ if (err.code === 'EADDRINUSE') {
313
+ fail(`Port ${port} is already in use by another process.\nTry another port: knl-view --port ${port + 1}`)
314
+ }
315
+ fail(`Failed to start server: ${err.message}`)
316
+ })
317
+
318
+ server.listen(port, settings.host, () => {
319
+ const addr = `http://${settings.host}:${port}`
320
+ process.stdout.write(
321
+ `knl-view ${pkg.version}\n` +
322
+ ` address ${addr}\n` +
323
+ ` source ${root}\n` +
324
+ `Press Ctrl+C to stop.\n`
325
+ )
326
+
327
+ if (settings.anchordb) {
328
+ if (!settings.anchordb.model) {
329
+ // The map is the only source of model names; without an entry, AnchorDB
330
+ // is deliberately left untouched so no meaningless name appears.
331
+ process.stdout.write(
332
+ ' anchordb idle: role "knowledges" not yet mapped in knl-config.json\n' +
333
+ ' run: knl-view model new knowledges\n'
334
+ )
335
+ } else {
336
+ attachAnchorDB(server).then((result) => {
337
+ if (result.ok) {
338
+ let line = ` anchordb connected (${result.model}`
339
+ if (result.synced) {
340
+ if (result.failed) {
341
+ line += `, sync ${result.linked}+${result.unlinked} edges, ${result.failed} failed (retried on next start)`
342
+ } else if (result.linked || result.unlinked) {
343
+ line += `, sync +${result.linked} -${result.unlinked} (${result.kept} edges kept)`
344
+ } else {
345
+ line += `, graph already in sync (${result.kept} edges)`
346
+ }
347
+ }
348
+ process.stdout.write(`${line})\n`)
349
+ if (server.knlAnchorDB.consensusClient) {
350
+ process.stdout.write(` consensus ready (${server.knlAnchorDB.consensusModel})\n`)
351
+ } else if (server.knlAnchorDB.consensusError) {
352
+ process.stdout.write(` consensus unavailable: ${server.knlAnchorDB.consensusError}\n`)
353
+ }
354
+ if (server.knlAnchorDB.sourcesClient) {
355
+ process.stdout.write(` sources ready (${server.knlAnchorDB.sourcesModel})\n`)
356
+ } else if (server.knlAnchorDB.sourcesError) {
357
+ process.stdout.write(` sources unavailable: ${server.knlAnchorDB.sourcesError}\n`)
358
+ }
359
+ } else {
360
+ process.stdout.write(` anchordb failed: ${result.reason} (viewer runs local only)\n`)
361
+ }
362
+ })
363
+ }
364
+ }
365
+ })
366
+
367
+ // The --init / --seed-skill wizard. Ask mode only when stdin is a TTY and
368
+ // no --yes is given; otherwise default answers are used so it never hangs
369
+ // (pipe, CI, or scripts).
370
+ async function runSetup() {
371
+ const root = path.resolve(process.cwd(), settings.path)
372
+ const interactive = Boolean(process.stdin.isTTY) && !values.yes
373
+ const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null
374
+
375
+ try {
376
+ let structure = values.structure
377
+ let folders = values.folders.split(',').map((s) => s.trim()).filter(Boolean)
378
+ let seedScope = values['seed-skill'] !== undefined ? values.dir : null
379
+ let gitignore = true
380
+
381
+ if (values.init) {
382
+ if (rl) {
383
+ structure = await askChoice(rl, 'Layout for the new base?', [
384
+ ['standard', 'Standard: README.md + roadmap/ features/ docs/'],
385
+ ['empty', 'Empty: only the .knowledges/ folder'],
386
+ ['custom', 'Custom: pick your own folders']
387
+ ], structure)
388
+ if (structure === 'custom' && !folders.length) {
389
+ const answer = await ask(rl, 'Folder names (comma-separated): ', 'roadmap, features, docs')
390
+ folders = answer.split(',').map((s) => s.trim()).filter(Boolean)
391
+ }
392
+ seedScope = await askChoice(rl, 'OpenCode knl-view skill?', [
393
+ ['none', 'Skip'],
394
+ ['project', 'Project: .agents/skills/knl-view/'],
395
+ ['global', 'Global: ~/.config/opencode/skills/knl-view/']
396
+ ], seedScope || 'none')
397
+ if (seedScope === 'none') seedScope = null
398
+ gitignore = await askYesNo(rl, 'Add .knowledges/ to .gitignore?', true)
399
+ }
400
+
401
+ process.stdout.write(`Scaffolding base at ${root} (layout: ${structure})...\n`)
402
+ const actions = await scaffoldBase({ root, structure, folders })
403
+ report(actions)
404
+ }
405
+
406
+ if (seedScope) {
407
+ const result = await seedSkill({ scope: seedScope, cwd: process.cwd() })
408
+ const scope = seedScope === 'global' ? 'global' : 'project'
409
+ process.stdout.write(
410
+ result.status === 'skipped'
411
+ ? ` skip skill already exists: ${result.path}\n`
412
+ : ` create skill (${scope}): ${result.path}\n`
413
+ )
414
+ }
415
+
416
+ if (values.init && gitignore) {
417
+ const result = await ensureGitignore({ cwd: process.cwd() })
418
+ process.stdout.write(
419
+ result.status === 'skipped'
420
+ ? ` skip .gitignore already mentions .knowledges\n`
421
+ : ` create adding .knowledges/ to ${result.path}\n`
422
+ )
423
+ }
424
+
425
+ process.stdout.write('Done. Run "knl-view" to view this base.\n')
426
+ } finally {
427
+ rl?.close()
428
+ }
429
+ }
430
+
431
+ function report(actions) {
432
+ for (const action of actions) {
433
+ const label = action.kind === 'dir' ? 'folder ' : 'file '
434
+ const verb = action.status === 'skipped' ? 'skip' : 'create'
435
+ process.stdout.write(` ${verb} ${label}${action.path}\n`)
436
+ }
437
+ }
438
+
439
+ // All questions go through here so EOF (Ctrl+D) mid-wizard stops cleanly
440
+ // instead of throwing an AbortError with a stack trace.
441
+ async function ask(rl, question, fallback) {
442
+ let answer
443
+ try {
444
+ answer = (await rl.question(question)).trim()
445
+ } catch {
446
+ process.stdout.write('\n')
447
+ fail('Cancelled.')
448
+ }
449
+ return answer || fallback
450
+ }
451
+
452
+ async function askChoice(rl, question, choices, current) {
453
+ process.stdout.write(`${question}\n`)
454
+ choices.forEach(([value, label], i) => {
455
+ const marker = value === current ? '>' : ' '
456
+ process.stdout.write(` ${marker} ${i + 1}. ${label}\n`)
457
+ })
458
+ const answer = await ask(rl, `Choice [1-${choices.length}, default: ${defaultIndex(choices, current)}]: `, '')
459
+ const byNumber = Number(answer)
460
+ if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= choices.length) return choices[byNumber - 1][0]
461
+ const byValue = choices.find(([value]) => value === answer)
462
+ if (byValue) return byValue[0]
463
+ return current
464
+ }
465
+
466
+ function defaultIndex(choices, current) {
467
+ const i = choices.findIndex(([value]) => value === current)
468
+ return i >= 0 ? i + 1 : 1
469
+ }
470
+
471
+ async function askYesNo(rl, question, fallback) {
472
+ const answer = (await ask(rl, `${question} (y/n) [default: ${fallback ? 'y' : 'n'}]: `, '')).toLowerCase()
473
+ if (!answer) return fallback
474
+ return answer === 'y' || answer === 'yes'
475
+ }
476
+
477
+ // The "install anchordb" command. Linux and macOS: binary from GitHub
478
+ // Releases (verified against SHA256SUMS.txt). Windows (and anyone who
479
+ // prefers it): ghcr.io container via docker/podman. After install, the
480
+ // "anchordb" block is written to the global config so every project can
481
+ // connect right away.
482
+ async function runInstallCommand(target, { values }) {
483
+ if (target !== 'anchordb') {
484
+ fail(`Unknown install target: ${target || '(none)'}\nAvailable: knl-view install anchordb`)
485
+ }
486
+ const nativeOk = process.platform === 'linux' || process.platform === 'darwin'
487
+ const interactive = Boolean(process.stdin.isTTY) && !values.yes
488
+ const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null
489
+
490
+ let method = values.method
491
+ if (!method) {
492
+ if (nativeOk) {
493
+ method = interactive
494
+ ? await askChoice(rl, 'AnchorDB install method?', [
495
+ ['binary', 'Binary from GitHub Releases (native)'],
496
+ ['container', 'docker/podman container (ghcr.io)']
497
+ ], 'binary')
498
+ : 'binary'
499
+ } else {
500
+ method = 'container'
501
+ process.stdout.write('No native build for this platform; installing as a Linux container (ghcr.io).\n')
502
+ }
503
+ }
504
+ if (!['binary', 'container'].includes(method)) {
505
+ fail(`--method must be binary or container, got "${method}".`)
506
+ }
507
+
508
+ const token = values.token || generateToken()
509
+ const home = process.env.HOME || process.cwd()
510
+ // fs in this module is the callback API; installer steps need the promise version.
511
+ const fsp = fs.promises
512
+ let started = false
513
+
514
+ try {
515
+ if (method === 'binary') {
516
+ if (!nativeOk) {
517
+ fail('The binary method needs Linux or macOS.\nUse --method=container.')
518
+ }
519
+ const { version } = await latestRelease()
520
+ const asset = releaseAssetName(version)
521
+ const workdir = path.join(os.tmpdir(), `knlview-install-${Date.now()}`)
522
+ await fsp.mkdir(workdir, { recursive: true })
523
+ const binDir = path.join(home, '.knlview', 'bin')
524
+
525
+ process.stdout.write(`Downloading ${asset} (v${version}) ...\n`)
526
+ const archive = path.join(workdir, asset)
527
+ await downloadFile(`https://github.com/rachmanzz/anchordb/releases/download/v${version}/${asset}`, archive)
528
+ const sums = path.join(workdir, 'SHA256SUMS.txt')
529
+ await downloadFile(`https://github.com/rachmanzz/anchordb/releases/download/v${version}/SHA256SUMS.txt`, sums)
530
+ await verifySha256(archive, await fsp.readFile(sums, 'utf8'))
531
+ process.stdout.write(' SHA256 matches SHA256SUMS.txt\n')
532
+
533
+ process.stdout.write('Extracting ...\n')
534
+ await exec('tar', ['-xzf', archive, '-C', workdir])
535
+ await fsp.mkdir(binDir, { recursive: true })
536
+ for (const bin of ['anchordb-server', 'anchordb']) {
537
+ const target2 = path.join(binDir, bin)
538
+ await fsp.copyFile(path.join(workdir, bin), target2)
539
+ await fsp.chmod(target2, 0o755)
540
+ }
541
+ process.stdout.write(` installed to ${binDir}\n`)
542
+
543
+ const runNow = interactive
544
+ ? await askYesNo(rl, 'Start the AnchorDB server now?', true)
545
+ : true
546
+ if (runNow) {
547
+ const dataDir = path.join(home, '.knlview', 'anchordb-data')
548
+ await fsp.mkdir(dataDir, { recursive: true })
549
+ const logFd = await fsp.open(path.join(home, '.knlview', 'anchordb.log'), 'a')
550
+ const child = spawn(path.join(binDir, 'anchordb-server'), [], {
551
+ env: {
552
+ ...process.env,
553
+ ANCHORDB_AUTH_TOKEN: token,
554
+ ANCHORDB_DATA_DIR: dataDir,
555
+ ANCHORDB_PORT: String(HTTP_PORT_DEFAULT),
556
+ ANCHORDB_WIRE_PORT: String(WIRE_PORT_DEFAULT)
557
+ },
558
+ detached: true,
559
+ stdio: ['ignore', logFd.fd, logFd.fd]
560
+ })
561
+ child.unref()
562
+ logFd.close()
563
+ await waitAnchorHealth()
564
+ started = true
565
+ process.stdout.write(` server running (log: ${path.join(home, '.knlview', 'anchordb.log')})\n`)
566
+ }
567
+ } else {
568
+ const runtime = await detectContainerRuntime(async (cmd) => {
569
+ try { await exec(cmd, ['--version']); return true } catch { return false }
570
+ })
571
+ if (!runtime) {
572
+ fail('docker/podman not found.\nInstall one first: Docker Desktop (macOS/Windows) or podman.')
573
+ }
574
+ const { version } = await latestRelease()
575
+ const image = `${GHCR_IMAGE}:${version}`
576
+ process.stdout.write(`Pulling ${image} via ${runtime} ...\n`)
577
+ await exec(runtime, ['pull', image])
578
+ process.stdout.write(' image ready\n')
579
+
580
+ // Fixed container name: if it already exists, the user must decide;
581
+ // remove it manually so old data is not lost by accident.
582
+ if (await containerExists(runtime)) {
583
+ fail(`Container "${CONTAINER_NAME}" already exists.\nRemove it first if you really want to reinstall: ${runtime} rm -f ${CONTAINER_NAME}`)
584
+ }
585
+
586
+ const dataDir = path.join(home, '.knlview', 'anchordb-data')
587
+ await fsp.mkdir(dataDir, { recursive: true })
588
+ await fsp.chmod(dataDir, 0o777)
589
+ const baseArgs = ['run', '-d', '--name', CONTAINER_NAME,
590
+ '-p', `${HTTP_PORT_DEFAULT}:${HTTP_PORT_DEFAULT}`,
591
+ '-p', `${WIRE_PORT_DEFAULT}:${WIRE_PORT_DEFAULT}`,
592
+ '-e', `ANCHORDB_AUTH_TOKEN=${token}`,
593
+ '-e', 'ANCHORDB_DATA_DIR=/data']
594
+ // The image runs as user "anchordb" (not root). Rootless podman maps
595
+ // that uid to a different host uid, so data files owned by the host
596
+ // user become unreadable ("permission denied"). keep-id runs the
597
+ // container process with the host user's uid, so binary data and the
598
+ // container can share the folder. Docker (macOS/Windows) runs in a VM
599
+ // and its bind mounts are unaffected, hence no flag here.
600
+ if (runtime === 'podman') baseArgs.push('--userns=keep-id')
601
+ process.stdout.write(`Starting container ...\n`)
602
+ try {
603
+ await exec(runtime, [...baseArgs, '-v', `${dataDir}:/data:Z`, image])
604
+ } catch {
605
+ // Rootless/sandbox setups sometimes reject volume binds; without a
606
+ // volume the data dies with the container, so we say so plainly.
607
+ process.stdout.write(' volume could not be mounted; data is ephemeral inside the container\n')
608
+ await exec(runtime, [...baseArgs, image])
609
+ }
610
+ await waitAnchorHealth()
611
+ started = true
612
+ }
613
+ } catch (err) {
614
+ fail(`Install failed: ${err.message || err}`)
615
+ }
616
+
617
+ const before = await mergeGlobalAnchordb(home, {
618
+ url: `wire://127.0.0.1:${WIRE_PORT_DEFAULT}`,
619
+ token,
620
+ sync: true
621
+ })
622
+ if (before) {
623
+ process.stdout.write(' (old anchordb block in the global config overwritten; new token applied)\n')
624
+ }
625
+
626
+ process.stdout.write(
627
+ `\nAnchorDB ready (optional layer: projects without an anchordb block\n` +
628
+ ` in the global config still run fully local).\n` +
629
+ ` HTTP http://127.0.0.1:${HTTP_PORT_DEFAULT}/health${started ? ' (already responding)' : ''}\n` +
630
+ ` wire wire://127.0.0.1:${WIRE_PORT_DEFAULT}\n` +
631
+ ` token ${token.slice(0, 8)}... (full value in ~/.knlview/knl-config.json)\n` +
632
+ ` config ${home}/.knlview/knl-config.json\n\n` +
633
+ `Next steps (if this project should use sync):\n` +
634
+ ` 1. In your project: knl-view model new knowledges\n` +
635
+ ` 2. knl-view, the "anchordb connected" line appears once the graph is in sync\n`
636
+ )
637
+ if (!started) {
638
+ process.stdout.write(
639
+ ` 3. Server not started yet; start it manually:\n` +
640
+ ` ~/.knlview/bin/anchordb-server (env: ANCHORDB_AUTH_TOKEN, ANCHORDB_DATA_DIR)\n`
641
+ )
642
+ }
643
+ }
644
+
645
+ async function waitAnchorHealth(attempts = 30) {
646
+ for (let i = 0; i < attempts; i++) {
647
+ try {
648
+ const res = await fetch(`http://127.0.0.1:${HTTP_PORT_DEFAULT}/health`)
649
+ if (res.ok) return
650
+ } catch {
651
+ // not ready yet, retry
652
+ }
653
+ await new Promise((resolve) => setTimeout(resolve, 500))
654
+ }
655
+ throw new Error(`server not responding at http://127.0.0.1:${HTTP_PORT_DEFAULT}/health within ${attempts / 2} seconds, check the anchordb log`)
656
+ }
657
+
658
+ async function containerExists(runtime) {
659
+ try {
660
+ await exec(runtime, ['container', 'inspect', CONTAINER_NAME])
661
+ return true
662
+ } catch {
663
+ return false
664
+ }
665
+ }
666
+
667
+ // Claims and their agrees_with/contradicts relations live in the AnchorDB
668
+ // consensus model; the role must be mapped in the project's models map.
669
+ async function runConsensusCommand(sub, args, { settings }) {
670
+ if (!settings.anchordb) {
671
+ fail('Consensus lives in AnchorDB, which is not configured.\n' +
672
+ 'Run "knl-view install anchordb" first (optional layer).')
673
+ }
674
+ const consensusModel = settings.models?.consensus
675
+ if (!consensusModel) {
676
+ fail('Role "consensus" is not mapped in this project\'s models map.\n' +
677
+ 'Run: knl-view model new consensus')
678
+ }
679
+ const base = anchordbClientOptions(settings.anchordb)
680
+ const client = await createAnchorWireClient({ ...base, model: consensusModel })
681
+
682
+ const usage = 'Usage:\n' +
683
+ ' knl-view consensus claim "<statement>"\n' +
684
+ ' knl-view consensus agree <claimId> <otherClaimId>\n' +
685
+ ' knl-view consensus contradict <claimId> <otherClaimId>\n' +
686
+ ' knl-view consensus retract <claimId> <otherClaimId>\n' +
687
+ ' knl-view consensus status <claimId>\n' +
688
+ ' knl-view consensus list'
689
+
690
+ const validClaimArg = (value) => {
691
+ if (!CLAIM_ID_RE.test(value || '')) {
692
+ fail(`Not a claim id: "${value || '(empty)'}".\nClaim ids look like c-a1b2c3d4e5f6a1b2; create one with:\n knl-view consensus claim "<statement>"`)
693
+ }
694
+ return value
695
+ }
696
+
697
+ const link = async (type, args) => {
698
+ const [from, to] = args
699
+ validClaimArg(from)
700
+ validClaimArg(to)
701
+ if (from === to) fail('A claim cannot agree with or contradict itself.')
702
+ await client.link(from, type, to, 1.0)
703
+ const { error } = await updateConsensusMirror(consensusModel, { op: 'link', from, type, to })
704
+ if (error) process.stderr.write(`knl-view: mirror not updated (${error})\n`)
705
+ process.stdout.write(`${type}: ${from} -> ${to}\n`)
706
+ }
707
+
708
+ if (sub === 'claim') {
709
+ const text = (args || []).join(' ').trim()
710
+ if (!text) fail(`A claim needs a statement.\n${usage}`)
711
+ const { id, error } = await recordClaim(consensusModel, text)
712
+ if (error) process.stderr.write(`knl-view: claim index not updated (${error})\n`)
713
+ process.stdout.write(`claim ${id}\n "${text}"\n (relations are created by agree/contradict; status: knl-view consensus status ${id})\n`)
714
+ return
715
+ }
716
+
717
+ if (sub === 'agree') return link('agrees_with', args)
718
+ if (sub === 'contradict') return link('contradicts', args)
719
+
720
+ if (sub === 'retract') {
721
+ const [from, to] = args
722
+ validClaimArg(from)
723
+ validClaimArg(to)
724
+ for (const type of CONSENSUS_RELATIONS) {
725
+ // Unlinking a missing edge is not an error: retract is idempotent.
726
+ await client.unlink(from, type, to).catch(() => {})
727
+ }
728
+ const { error } = await updateConsensusMirror(consensusModel, { op: 'unlink', from, type: '*', to })
729
+ if (error) process.stderr.write(`knl-view: mirror not updated (${error})\n`)
730
+ process.stdout.write(`retracted: ${from} -/-> ${to}\n`)
731
+ return
732
+ }
733
+
734
+ if (sub === 'status' || sub === 'list') {
735
+ const { claims, error } = await readClaimsIndex(consensusModel)
736
+ if (error) process.stderr.write(`knl-view: claim index ignored (${error})\n`)
737
+
738
+ const statusOne = async (id) => {
739
+ const mirrorEdges = await readConsensusMirror(consensusModel)
740
+ // null = no mirror file at all; an existing-but-empty mirror is still
741
+ // valid knowledge (everything was retracted). Non-empty mirrors are
742
+ // probed against the server first: it may have been wiped or replaced
743
+ // since the mirror was written; distrust it the same way the
744
+ // knowledges sync does.
745
+ let mirrored = Array.isArray(mirrorEdges)
746
+ if (mirrored && mirrorEdges.length > 0) {
747
+ mirrored = await mirrorStillValid(client, mirrorEdges).catch(() => false)
748
+ }
749
+ const usable = mirrored ? mirrorEdges : null
750
+ const inbound = inboundConsensus(usable || [], id)
751
+ const stance = outboundConsensus(usable || [], id)
752
+ const status = deriveStatus(inbound.supporters, inbound.objectors)
753
+ const text = claims[id]?.text ? ` "${claims[id].text}"` : ''
754
+ process.stdout.write(`${id}${text}\n status: ${status}${mirrored ? '' : ' (no valid local mirror; relation data unavailable)'}\n`)
755
+ process.stdout.write(` supporters: ${inbound.supporters.join(', ') || '(none)'}\n`)
756
+ process.stdout.write(` objectors: ${inbound.objectors.join(', ') || '(none)'}\n`)
757
+ process.stdout.write(` stance: agrees with ${stance.agrees.join(', ') || '(none)'}; contradicts ${stance.contradicts.join(', ') || '(none)'}\n`)
758
+ }
759
+
760
+ if (sub === 'status') {
761
+ await statusOne(validClaimArg(args?.[0]))
762
+ return
763
+ }
764
+
765
+ const ids = Object.keys(claims)
766
+ if (!ids.length) {
767
+ process.stdout.write('No claims known locally. Create one:\n knl-view consensus claim "<statement>"\n')
768
+ return
769
+ }
770
+ for (const id of ids) await statusOne(id)
771
+ return
772
+ }
773
+
774
+ fail(`Unknown consensus subcommand: ${sub || '(empty)'}\n${usage}`)
775
+ }
776
+
777
+ // The "sources" subcommands: the whole project as a dependency graph in the
778
+ // AnchorDB sources model. Everything lives on the server: file nodes are
779
+ // records (list-able), import edges are stored in both directions, and sync
780
+ // enumerates the server's own state instead of a local mirror.
781
+ async function runSourcesCommand(sub, values, { settings }) {
782
+ if (!settings.anchordb) {
783
+ fail('The sources graph lives in AnchorDB, which is not configured.\n' +
784
+ 'Run "knl-view install anchordb" first (optional layer).')
785
+ }
786
+ const sourcesModel = settings.models?.sources
787
+ if (!sourcesModel) {
788
+ fail('Role "sources" is not mapped in this project\'s models map.\n' +
789
+ 'Run: knl-view model new sources')
790
+ }
791
+ const base = anchordbClientOptions(settings.anchordb)
792
+ const client = await createAnchorWireClient({ ...base, model: sourcesModel })
793
+ const projectRoot = process.cwd()
794
+
795
+ if (sub === 'sync') {
796
+ const result = await syncSourcesToAnchorDB(client, projectRoot, { labelDir: settings.labelDir })
797
+ const parts = [
798
+ `${result.files} files`,
799
+ `${result.symbols} symbols`,
800
+ `${result.edges} edges`
801
+ ]
802
+ if (result.linked) parts.push(`+${result.linked} linked`)
803
+ if (result.unlinked) parts.push(`-${result.unlinked} unlinked`)
804
+ if (result.saved) parts.push(`${result.saved} record${result.saved === 1 ? '' : 's'} saved`)
805
+ if (result.tombstoned) parts.push(`${result.tombstoned} tombstoned`)
806
+ if (result.orphans) parts.push(`${result.orphans} ORPHAN EDGES`)
807
+ if (result.labels.written) parts.push(`${result.labels.written} labels written`)
808
+ if (result.labels.deleted) parts.push(`${result.labels.deleted} labels deleted`)
809
+ if (result.failed) parts.push(`${result.failed} FAILED`)
810
+ process.stdout.write(`sources synced: ${parts.join(', ')}\n`)
811
+ if (result.failed) {
812
+ process.stderr.write('knl-view: some operations failed; run "knl-view sources sync" again to retry.\n')
813
+ }
814
+ if (result.tombstoned) {
815
+ await runSourcesCollector(base, sourcesModel, { quiet: false })
816
+ }
817
+ return
818
+ }
819
+
820
+ if (sub === 'collect') {
821
+ const purged = await runSourcesCollector(base, sourcesModel, { quiet: false })
822
+ if (!purged) process.stdout.write('nothing to purge: no archived records for this model\n')
823
+ return
824
+ }
825
+
826
+ if (sub === 'trace') {
827
+ const target = values.target
828
+ if (!target) {
829
+ fail('Parameter --target is required:\n knl-view sources trace --target=<repo-relative path> [--hops=2] [--direction=in|out|both]')
830
+ }
831
+ let direction = 'both'
832
+ if (values.direction !== undefined) {
833
+ if (!['in', 'out', 'both'].includes(values.direction)) {
834
+ fail(`--direction must be in, out, or both, got "${values.direction}".`)
835
+ }
836
+ direction = values.direction
837
+ }
838
+ const hops = Number.parseInt(values.hops || '2', 10)
839
+ const maxHops = Number.isInteger(hops) && hops > 0 ? hops : 2
840
+ const { out, in: inc } = await traceSources(client, target, direction, maxHops)
841
+ process.stdout.write(JSON.stringify({
842
+ center: target,
843
+ maxHops,
844
+ direction,
845
+ out: out.map(([id, hop]) => ({ id, hop })),
846
+ in: inc.map(([id, hop]) => ({ id, hop }))
847
+ }, null, 2) + '\n')
848
+ return
849
+ }
850
+
851
+ fail('Unknown sources subcommand.\n' +
852
+ 'Usage:\n' +
853
+ ' knl-view sources sync\n' +
854
+ ' knl-view sources collect\n' +
855
+ ' knl-view sources trace --target=<repo-relative path> [--hops=2] [--direction=in|out|both]')
856
+ }
857
+
858
+ // Wire cannot start collector jobs, so archived records are purged over
859
+ // REST. Best effort: a missing endpoint must not fail a sync.
860
+ async function runSourcesCollector(base, model, { quiet }) {
861
+ try {
862
+ const purged = await purgeArchivedRecords(`wire://${base.host}:${base.port}`, base.token, model)
863
+ if (purged) process.stdout.write(` collector purged ${purged} archived record${purged === 1 ? '' : 's'}\n`)
864
+ else if (!quiet) process.stdout.write(' collector ran: nothing to purge\n')
865
+ return purged
866
+ } catch (err) {
867
+ process.stderr.write(`knl-view: collector unavailable (${err.message}); archived records stay hidden until collected.\n` +
868
+ ' Run "knl-view sources collect" once the REST endpoint is reachable.\n')
869
+ return 0
870
+ }
871
+ }
872
+
873
+ function fail(message) {
874
+ process.stderr.write(`knl-view: ${message}\n`)
875
+ process.exit(1)
876
+ }
877
+
878
+ // The "model new" and "model check" commands manage the role → AnchorDB
879
+ // model name map. Names may be random; what gives them meaning is this map
880
+ // plus the global registry ~/.knlview/models.json (owner of every name).
881
+
882
+ async function runModelCommand(sub, roleArg, { projectConfig, settings }) {
883
+ if (sub === 'new') {
884
+ const role = (roleArg || '').trim().toLowerCase()
885
+ if (!MODEL_ROLE_RE.test(role)) {
886
+ fail(`Role must be a lowercase slug (letters, digits, -, _), got "${roleArg || '(none)'}".\nExample: knl-view model new knowledges`)
887
+ }
888
+ const existing = settings.models?.[role]
889
+ if (existing) {
890
+ fail(`Role "${role}" is already mapped to model "${existing}".\nEdit knl-config.json if you really want to change it.`)
891
+ }
892
+ const name = generateModelName()
893
+ const { previous, error } = await registerModel(name, {
894
+ role,
895
+ project: projectSlug(process.cwd()),
896
+ cwd: process.cwd()
897
+ })
898
+ if (error) fail(`Failed to write registry (${error}). The new name is not used.`)
899
+ if (previous) {
900
+ fail(`Name "${name}" is already used by another project (${previous.cwd}) for role "${previous.role}".\nRun again for a new name.`)
901
+ }
902
+ const next = { ...projectConfig, models: { ...(projectConfig?.models || {}), [role]: name } }
903
+ delete next.model // the map replaces the legacy "model" shape
904
+ fs.writeFileSync(path.join(process.cwd(), 'knl-config.json'), JSON.stringify(next, null, 2) + '\n', 'utf8')
905
+ process.stdout.write(
906
+ `model ${name} → role ${role}\n` +
907
+ ` knl-config.json: models.${role} = "${name}"\n` +
908
+ ` registry: ${modelRegistryPath()}\n`
909
+ )
910
+ return
911
+ }
912
+
913
+ if (sub === 'check') {
914
+ const { models: registry, error } = await readModelRegistry()
915
+ if (error) process.stderr.write(`knl-view: ignoring registry (${error})\n`)
916
+ const cwd = path.resolve(process.cwd())
917
+ const map = settings.models || {}
918
+ const roles = Object.keys(map)
919
+ process.stdout.write(`Models map of this project (${projectSlug(cwd)}):\n`)
920
+ if (!roles.length) {
921
+ process.stdout.write(' (empty, run "knl-view model new knowledges")\n')
922
+ }
923
+ for (const role of roles) {
924
+ const name = map[role]
925
+ const entry = registry[name]
926
+ const owner = entry ? `${entry.project}/${entry.role}` : 'not registered'
927
+ const clash = entry && path.resolve(entry.cwd || '') !== cwd
928
+ process.stdout.write(` ${role.padEnd(12)} ${name} (${owner}${clash ? ' - CLASH: claimed by ' + entry.cwd : ''})\n`)
929
+ }
930
+ const others = Object.entries(registry).filter(([, e]) => path.resolve(e.cwd || '') !== cwd)
931
+ if (others.length) {
932
+ process.stdout.write('Models of other projects in the registry:\n')
933
+ for (const [name, e] of others) {
934
+ process.stdout.write(` ${name} → ${e.project}/${e.role} (${e.cwd})\n`)
935
+ }
936
+ }
937
+ process.stdout.write('Note: model existence on the server cannot be checked over the wire protocol;\n')
938
+ process.stdout.write('what is checked here is map consistency, the registry, and name ownership.\n')
939
+ return
940
+ }
941
+
942
+ fail(`Unknown model command: ${sub || '(none)'}\nUse "model new <role>" or "model check".`)
943
+ }