@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.
- package/LICENSE +201 -0
- package/README.md +398 -2
- package/bin/knl-view.js +943 -0
- package/dist/assets/abnfDiagram-WEYOIA3C-B1gaBaSI.js +1 -0
- package/dist/assets/arc-NZ9uRaCl.js +1 -0
- package/dist/assets/architecture-WOLXFQ4H-8MalLwTd.js +1 -0
- package/dist/assets/architectureDiagram-ZHVYKT53-eZ8i5cl4.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-RJAE7QA3-BavOE8BM.js +140 -0
- package/dist/assets/c4Diagram-XDPPPE54-DqMaTAzx.js +38 -0
- package/dist/assets/channel-BZ2_TTTJ.js +1 -0
- package/dist/assets/chunk-2E7WHVMD-B1Hj2BjJ.js +88 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4EMNKR2T-HDscrCiq.js +1 -0
- package/dist/assets/chunk-55XCU76K-QR_ARj8I.js +1 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-7S6XSTFO-B0p3o547.js +2 -0
- package/dist/assets/chunk-AQDTSKZB-CCxwNyyA.js +72 -0
- package/dist/assets/chunk-DSNLSHJZ-BM5PBFmz.js +129 -0
- package/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/assets/chunk-HAGFOTED-UCDVFuN_.js +62 -0
- package/dist/assets/chunk-HNE4Q36P-C_1sakp2.js +1 -0
- package/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/dist/assets/chunk-NQOKRVIF-b2D1Z4oK.js +213 -0
- package/dist/assets/chunk-UHW7PFBP-CJs0VwBY.js +1 -0
- package/dist/assets/chunk-VQ5W62QI-DQxjgE4_.js +1 -0
- package/dist/assets/chunk-WQQL4CDK-DcjPeAfQ.js +10 -0
- package/dist/assets/chunk-XXDRQBXY-glA0kB5s.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/chunk-YZO67HFG-DJqACL5V.js +1 -0
- package/dist/assets/chunk-ZAUX2LTX-CWfaYtwb.js +1 -0
- package/dist/assets/chunk-ZCHMQSOR-BGX73Hho.js +2 -0
- package/dist/assets/classDiagram-v2-4MYWR3UA-BpYXPz-e.js +217 -0
- package/dist/assets/cose-bilkent-JH36ORCC-e1quKm0Q.js +1 -0
- package/dist/assets/cynefin-EF2NZ3EQ-hb3oUmFO.js +1 -0
- package/dist/assets/cynefinDiagram-WF24UDRP-CnMQvLqi.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-NK4GIZVX-BH_Y8y3F.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-CBK3MWTC-Dh79VetZ.js +24 -0
- package/dist/assets/diagram-I5XIIV2D-D3u-o1RV.js +24 -0
- package/dist/assets/diagram-ODIL2UCW-Bfuo-OKS.js +30 -0
- package/dist/assets/diagram-PFBEJUXE-CwHasIbs.js +3 -0
- package/dist/assets/diagram-RJA3I4VV-D8ttmuE8.js +41 -0
- package/dist/assets/diagram-TYUNFSNR-CzC-8RZ0.js +200 -0
- package/dist/assets/dist-CUJYGnnI.js +1 -0
- package/dist/assets/ebnfDiagram-V4P3TTPT-9AAhU7In.js +1 -0
- package/dist/assets/elk-QONTBCTQ-BgvnD1v_.js +27 -0
- package/dist/assets/erDiagram-KYQIGQAA-RLtbiLlP.js +99 -0
- package/dist/assets/eventmodeling-K75KTNOO-DVY22vNZ.js +1 -0
- package/dist/assets/flowDiagram-IUGLLWBU-l7L5DTb5.js +1 -0
- package/dist/assets/ganttDiagram-R7GPNTJY-hHiBGa9W.js +292 -0
- package/dist/assets/gitGraph-VSP46ZUC-Cvxay1wh.js +1 -0
- package/dist/assets/gitGraphDiagram-PDYL7XHY-AOhlpSqg.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-CBLNQvK3.js +76 -0
- package/dist/assets/index-DLgMsUs2.css +1 -0
- package/dist/assets/info-OHQRW6UA-CJ8EUO1m.js +1 -0
- package/dist/assets/infoDiagram-MVDRKAX5-DOQu7oYI.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-HPEHV2GE-DcVMvGmx.js +70 -0
- package/dist/assets/journeyDiagram-BOSHW7O7-luMeqEqz.js +139 -0
- package/dist/assets/kanban-definition-FNSA4HC5-KTZbmrW2.js +89 -0
- package/dist/assets/katex-B_JcIwqj.js +257 -0
- package/dist/assets/line-CmPHiOVm.js +1 -0
- package/dist/assets/linear-DHYlKXBA.js +1 -0
- package/dist/assets/mermaid-parser.core-BRDI-WhC.js +7 -0
- package/dist/assets/mermaid.core-DPTRaYLy.js +44 -0
- package/dist/assets/mindmap-definition-6OY57UG3-70EN5Kw_.js +98 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-JDAUHWVQ-CXWz83oS.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-ECIUJNBX-z5UXKmp4.js +1 -0
- package/dist/assets/pie-XZMESJXO-rWI1HIQ7.js +1 -0
- package/dist/assets/pieDiagram-GNMPY74T-Bh4Kv3NQ.js +39 -0
- package/dist/assets/quadrantDiagram-D57K4MC3-wGM4Ckig.js +7 -0
- package/dist/assets/radar-ABXABTNO-CIJ92p5N.js +1 -0
- package/dist/assets/railroad-I3PHUGI6-8bxua-Hf.js +1 -0
- package/dist/assets/railroad-abnf-LNEFI6M7-B9KVI10m.js +1 -0
- package/dist/assets/railroad-ebnf-DWYD2UWJ-Bt73rnyJ.js +1 -0
- package/dist/assets/railroad-peg-JR7BVNR7-Cie82UPT.js +1 -0
- package/dist/assets/railroadDiagram-Y72NRBL7-C1nmi_tl.js +1 -0
- package/dist/assets/requirementDiagram-EQYSW5RA-BSianW40.js +84 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-SY24JHO5-DIa11-h7.js +40 -0
- package/dist/assets/sequenceDiagram-BLEHNHQU-Df5rZKz2.js +169 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-dClg85lX.js +1 -0
- package/dist/assets/stateDiagram-v2-A6IESKWZ-B15PTSvX.js +291 -0
- package/dist/assets/swimlanes-B3ENRKFQ-C9mgRCMi.js +1 -0
- package/dist/assets/swimlanesDiagram-AYQRV4AK-BGFq4t7O.js +8 -0
- package/dist/assets/timeline-definition-JXYKGLGB-BDVf_mIv.js +120 -0
- package/dist/assets/treeView-D4JQ5SDB-BbBK-25J.js +1 -0
- package/dist/assets/treemap-SAJKECNS-CEDX8apk.js +1 -0
- package/dist/assets/usecaseDiagram-WPOLBNNX-D1QkwFF8.js +328 -0
- package/dist/assets/vennDiagram-Q3Y3IDCW-5PeGZvEO.js +34 -0
- package/dist/assets/wardley-7MLQ67FV-BatN94pO.js +1 -0
- package/dist/assets/wardleyDiagram-IVAIDKWA-DyCcDS3Y.js +78 -0
- package/dist/assets/xychartDiagram-4URNHDU4-CbCXcRlg.js +7 -0
- package/dist/index.html +22 -0
- package/package.json +46 -4
- package/server/config.js +263 -0
- package/server/consensus.js +175 -0
- package/server/graph/anchordb_client.js +253 -0
- package/server/graph/model.js +157 -0
- package/server/graph/parser.js +326 -0
- package/server/graph/sources.js +660 -0
- package/server/graph/syntax.js +257 -0
- package/server/index.js +679 -0
- package/server/install.js +97 -0
- package/server/map.js +126 -0
- package/server/models_registry.js +84 -0
- package/server/scaffold.js +376 -0
package/bin/knl-view.js
ADDED
|
@@ -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
|
+
}
|