@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,263 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import fsSync from 'node:fs'
3
+ import path from 'node:path'
4
+
5
+ // Two-layer knl-view config:
6
+ // - Global: ~/.knlview/knl-config.json holds the "anchordb" block (URL, token,
7
+ // sync). That connection belongs to the machine/user, not the repo, so all
8
+ // projects using AnchorDB share the same server.
9
+ // - Per project: knl-config.json at the project root (gitignored) holds
10
+ // path/port/host and the "models" map (role -> AnchorDB model name; names
11
+ // may be random, the map records what each name is for).
12
+ //
13
+ // A missing or broken file must never bring the viewer down. Final values
14
+ // are chosen by resolveSettings in the order: CLI flag > config > default.
15
+
16
+ const PROJECT_CONFIG_FILE = 'knl-config.json'
17
+ const GLOBAL_CONFIG_DIR = '.knlview'
18
+ const GLOBAL_CONFIG_FILE = 'knl-config.json'
19
+ const MAX_CONFIG_BYTES = 64 * 1024
20
+
21
+ export const DEFAULT_SETTINGS = { path: '.knowledges', port: '8085', host: '127.0.0.1' }
22
+ export const DEFAULT_MODEL = 'knowledges'
23
+
24
+ // Causal relations /api/anchordb/trace follows when the config sets none.
25
+ export const DEFAULT_RELATIONS = ['supersedes', 'affects_knowledge', 'depends_on']
26
+
27
+ export function globalConfigPath(home = process.env.HOME) {
28
+ return path.join(home || '', GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE)
29
+ }
30
+
31
+ /**
32
+ * Location of the mirror of the last synced edges for one model:
33
+ * ~/.knlview/sync-state/<model>.json. Incremental sync compares the local
34
+ * graph against this mirror, not against the server contents (the server
35
+ * offers no edge enumeration over the wire protocol).
36
+ */
37
+ export function syncStatePath(model, home = process.env.HOME) {
38
+ const safe = String(model || 'model').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'model'
39
+ return path.join(home || '', GLOBAL_CONFIG_DIR, 'sync-state', `${safe}.json`)
40
+ }
41
+
42
+ /**
43
+ * Read the global config ~/.knlview/knl-config.json (anchordb block).
44
+ * @returns {Promise<{config: object|null, error: string|null}>}
45
+ */
46
+ export async function readGlobalConfig(home = process.env.HOME) {
47
+ return readConfigFile(globalConfigPath(home), normalizeGlobalConfig)
48
+ }
49
+
50
+ /**
51
+ * Read the per-project config: knl-config.json at the project root.
52
+ * @returns {Promise<{config: object|null, error: string|null}>}
53
+ */
54
+ export async function readProjectConfig(cwd = process.cwd()) {
55
+ return readConfigFile(path.join(cwd, PROJECT_CONFIG_FILE), normalizeProjectConfig)
56
+ }
57
+
58
+ async function readConfigFile(abs, normalize) {
59
+ let stat
60
+ try {
61
+ stat = await fs.stat(abs)
62
+ } catch {
63
+ return { config: null, error: null }
64
+ }
65
+ if (!stat.isFile()) return { config: null, error: null }
66
+ if (stat.size > MAX_CONFIG_BYTES) {
67
+ return { config: null, error: `size exceeds ${MAX_CONFIG_BYTES} bytes` }
68
+ }
69
+ let parsed
70
+ try {
71
+ parsed = JSON.parse(await fs.readFile(abs, 'utf8'))
72
+ } catch (err) {
73
+ return { config: null, error: `invalid JSON: ${err.message}` }
74
+ }
75
+ return { config: normalize(parsed), error: null }
76
+ }
77
+
78
+ /**
79
+ * Filter the project config down to known fields. Invalid values are dropped
80
+ * (not fatal) so resolveSettings falls back to the CLI flag or the default.
81
+ *
82
+ * `models` is the role → AnchorDB model name map (names may be random; this
83
+ * map is what records what each name is for). The legacy `model: "x"` shape
84
+ * is still accepted as shorthand for models.knowledges.
85
+ */
86
+ export function normalizeProjectConfig(raw) {
87
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
88
+ const config = {}
89
+ const configPath = cleanString(raw.path)
90
+ if (configPath) config.path = configPath
91
+ const port = toPort(raw.port)
92
+ if (port != null) config.port = String(port)
93
+ const host = cleanString(raw.host)
94
+ if (host) config.host = host
95
+ const models = normalizeModels(raw.models)
96
+ if (models) config.models = models
97
+ const model = cleanString(raw.model)
98
+ if (model) config.model = model
99
+ const labelDir = cleanString(raw.labelDir)
100
+ if (labelDir) config.labelDir = labelDir
101
+ return config
102
+ }
103
+
104
+ const MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
105
+ const MODEL_ROLE_RE = /^[a-z][a-z0-9_-]*$/
106
+
107
+ /**
108
+ * Filter the models map: keys = roles (lowercase slugs), values = model names.
109
+ * Invalid entries are dropped, not fatal.
110
+ */
111
+ export function normalizeModels(raw) {
112
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
113
+ const models = {}
114
+ for (const [role, name] of Object.entries(raw)) {
115
+ const roleSlug = String(role).trim().toLowerCase()
116
+ const nameStr = cleanString(name)
117
+ if (!MODEL_ROLE_RE.test(roleSlug) || !MODEL_NAME_RE.test(nameStr)) continue
118
+ models[roleSlug] = nameStr
119
+ }
120
+ return Object.keys(models).length ? models : null
121
+ }
122
+
123
+ /**
124
+ * Model name for one role, verbatim from the map. No automatic namespacing:
125
+ * model names deliberately stay stable even when the project/folder is
126
+ * renamed. Returns null when the role is unmapped (the caller supplies the
127
+ * "run knl-view model new <role>" hint).
128
+ */
129
+ export function resolveModel(models, role = DEFAULT_MODEL) {
130
+ const name = models?.[role]
131
+ return typeof name === 'string' && name ? name : null
132
+ }
133
+
134
+ /**
135
+ * Filter the global config: only the anchordb block matters here.
136
+ */
137
+ export function normalizeGlobalConfig(raw) {
138
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
139
+ const anchordb = normalizeAnchordb(raw.anchordb)
140
+ return anchordb ? { anchordb } : null
141
+ }
142
+
143
+ /**
144
+ * Normalize the anchordb block shape: a "wire://host:port?token=..." string
145
+ * or a full object. Returns null when unusable.
146
+ */
147
+ export function normalizeAnchordb(raw) {
148
+ if (typeof raw === 'string') raw = { url: raw }
149
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
150
+ const url = cleanString(raw.url)
151
+ if (!url || !/^(wire|tcp):\/\//i.test(url)) return null
152
+ const anchordb = { url }
153
+ const token = cleanString(raw.token)
154
+ if (token) anchordb.token = token
155
+ const model = cleanString(raw.model)
156
+ if (model) anchordb.model = model
157
+ // Present means active (including graph sync on start); sync: false to opt out.
158
+ anchordb.sync = raw.sync !== false
159
+ if (Array.isArray(raw.relations)) {
160
+ const relations = raw.relations.map((r) => cleanString(r)).filter(Boolean)
161
+ if (relations.length) anchordb.relations = relations
162
+ }
163
+ return anchordb
164
+ }
165
+
166
+ /**
167
+ * Project name for the model registry entry: the package.json "name" field
168
+ * when present, otherwise the project folder name. Cleaned into a safe slug.
169
+ */
170
+ export function projectSlug(cwd = process.cwd()) {
171
+ let name = null
172
+ try {
173
+ const parsed = JSON.parse(fsSync.readFileSync(path.join(cwd, 'package.json'), 'utf8'))
174
+ if (typeof parsed?.name === 'string') name = parsed.name
175
+ } catch {
176
+ // Without a package.json, the project folder name is used.
177
+ }
178
+ if (!name || !name.trim()) name = path.basename(path.resolve(cwd))
179
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
180
+ return slug || 'project'
181
+ }
182
+
183
+ /**
184
+ * Merge CLI flags, project config, global config, and defaults.
185
+ *
186
+ * - path/port/host: CLI > project config > default
187
+ * - anchordb: --anchordb flag > global config; fully off when neither exists
188
+ * (a project without global config never touches AnchorDB)
189
+ * - model: taken from the project config `models` map (knowledges role).
190
+ * The name in the URL (?model=) is only used when the map does not set it.
191
+ */
192
+ export function resolveSettings({ cli = {}, project = null, global: globalConfig = null } = {}) {
193
+ const p = project || {}
194
+ const g = globalConfig || {}
195
+ const anchordb = cli.anchordb !== undefined
196
+ ? normalizeAnchordb(cli.anchordb)
197
+ : normalizeAnchordb(g.anchordb)
198
+
199
+ // Legacy "model": "x" shape still counts as models.knowledges.
200
+ const models = { ...(p.models || {}) }
201
+ if (!models[DEFAULT_MODEL] && typeof p.model === 'string' && p.model.trim()) {
202
+ models[DEFAULT_MODEL] = p.model.trim()
203
+ }
204
+ const hasModels = Object.keys(models).length > 0
205
+
206
+ if (anchordb) {
207
+ anchordb.model = (hasModels && resolveModel(models)) || cleanString(anchordb.model) || null
208
+ }
209
+
210
+ const labelDir = pickString(cli.labelDir, p.labelDir)
211
+
212
+ return {
213
+ path: pickString(cli.path, p.path) ?? DEFAULT_SETTINGS.path,
214
+ port: pickString(cli.port, p.port) ?? DEFAULT_SETTINGS.port,
215
+ host: pickString(cli.host, p.host) ?? DEFAULT_SETTINGS.host,
216
+ models: hasModels ? models : null,
217
+ labelDir: labelDir ?? null,
218
+ anchordb
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Build AnchorWire client options from the already-resolved anchordb block.
224
+ * Config fields win over values in the URL query.
225
+ */
226
+ export function anchordbClientOptions(anchordb) {
227
+ if (!anchordb?.url) return null
228
+ const normalized = anchordb.url.replace(/^(wire|tcp):\/\//i, 'http://')
229
+ let parsed
230
+ try {
231
+ parsed = new URL(normalized.startsWith('http') ? normalized : `http://${normalized}`)
232
+ } catch {
233
+ return null
234
+ }
235
+ const options = {}
236
+ if (parsed.hostname) options.host = parsed.hostname.replace(/^\[|\]$/g, '')
237
+ if (parsed.port) {
238
+ const port = Number.parseInt(parsed.port, 10)
239
+ if (Number.isInteger(port) && port > 0 && port <= 65535) options.port = port
240
+ }
241
+ options.token = anchordb.token || parsed.searchParams.get('token') || ''
242
+ // The model name comes from the project map (already resolved by
243
+ // resolveSettings) or the URL query for a one-off session. Deliberately no
244
+ // default: a model with no clear name must not silently create a generic
245
+ // "knowledges" model.
246
+ options.model = anchordb.model || parsed.searchParams.get('model') || ''
247
+ return options
248
+ }
249
+
250
+ function pickString(cliValue, configValue) {
251
+ if (typeof cliValue === 'string' && cliValue.trim()) return cliValue.trim()
252
+ if (typeof configValue === 'string' && configValue.trim()) return configValue.trim()
253
+ return null
254
+ }
255
+
256
+ function cleanString(value) {
257
+ return typeof value === 'string' ? value.trim() : ''
258
+ }
259
+
260
+ function toPort(value) {
261
+ const n = Number(value)
262
+ return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null
263
+ }
@@ -0,0 +1,175 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+
5
+ // Consensus: agreement and conflict between claims, stored purely in
6
+ // AnchorDB as a second model (the "consensus" role in the project's models
7
+ // map). Claims relate only to other claims, never to decisions or files.
8
+ //
9
+ // A claim is content-addressed: id = "c-" + the first 16 hex chars of the
10
+ // statement's SHA-256, so the same statement is the same id on every
11
+ // machine. AnchorDB schemas only admit float32 attributes, so the statement
12
+ // text cannot live in the server; the local index below is a convenience
13
+ // cache (same nature as the sync mirror), never the source of the
14
+ // consensus structure.
15
+ //
16
+ // Relation types are fixed so status derivation stays meaningful:
17
+ // agrees_with: the source claim supports the target claim
18
+ // contradicts: the source claim opposes the target claim
19
+
20
+ export const CONSENSUS_RELATIONS = ['agrees_with', 'contradicts']
21
+
22
+ /**
23
+ * Content-addressed claim id from the statement text.
24
+ * @param {string} text
25
+ * @returns {string} "c-" plus 16 hex characters
26
+ */
27
+ export function claimId(text) {
28
+ const normalized = String(text || '').trim().toLowerCase().replace(/\s+/g, ' ')
29
+ const hash = crypto.createHash('sha256').update(normalized).digest('hex')
30
+ return `c-${hash.slice(0, 16)}`
31
+ }
32
+
33
+ /**
34
+ * Derive consensus status for one claim from who points at it.
35
+ * Any objection outweighs support: a single contradicts makes the claim
36
+ * disputed even if many agree with it.
37
+ * @returns {'disputed'|'consensus'|'uncontested'}
38
+ */
39
+ export function deriveStatus(supporters, objectors) {
40
+ if (objectors.length > 0) return 'disputed'
41
+ if (supporters.length > 0) return 'consensus'
42
+ return 'uncontested'
43
+ }
44
+
45
+ /** Local cache path for claim statements: ~/.knlview/claims/<model>.json */
46
+ export function claimsIndexPath(model, home = process.env.HOME) {
47
+ return path.join(home || '', '.knlview', 'claims', `${model}.json`)
48
+ }
49
+
50
+ /**
51
+ * Read the claim index. Missing file = empty index, not an error.
52
+ * @returns {Promise<{claims: object, error: string|null}>}
53
+ */
54
+ export async function readClaimsIndex(model, home = process.env.HOME) {
55
+ try {
56
+ const parsed = JSON.parse(await fs.readFile(claimsIndexPath(model, home), 'utf8'))
57
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
58
+ return { claims: {}, error: 'claim index is not an object' }
59
+ }
60
+ const claims = {}
61
+ for (const [id, entry] of Object.entries(parsed)) {
62
+ if (entry && typeof entry === 'object' && typeof entry.text === 'string') {
63
+ claims[id] = entry
64
+ }
65
+ }
66
+ return { claims, error: null }
67
+ } catch (err) {
68
+ if (err?.code === 'ENOENT') return { claims: {}, error: null }
69
+ return { claims: {}, error: err.message }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Register a claim statement in the local index. Content-addressed, so
75
+ * re-registering the same statement is an idempotent update.
76
+ */
77
+ export async function recordClaim(model, text, home = process.env.HOME) {
78
+ const id = claimId(text)
79
+ const { claims, error } = await readClaimsIndex(model, home)
80
+ if (error) return { id, written: false, error }
81
+ if (!claims[id]) claims[id] = { text: String(text).trim(), createdAt: new Date().toISOString() }
82
+ try {
83
+ const file = claimsIndexPath(model, home)
84
+ await fs.mkdir(path.dirname(file), { recursive: true })
85
+ await fs.writeFile(file, JSON.stringify(claims, null, 2) + '\n', 'utf8')
86
+ return { id, written: true, error: null }
87
+ } catch (err) {
88
+ return { id, written: false, error: err.message }
89
+ }
90
+ }
91
+
92
+ // Consensus mirror
93
+ // The wire protocol only walks edges forward (causalChain), but "who agrees
94
+ // with this claim?" is an inbound question. So every successful link/unlink
95
+ // also updates a local mirror of the consensus model's edges, the same
96
+ // pattern the knowledges graph already uses. The mirror is a read model:
97
+ // losing it only degrades inbound queries, never the server's data.
98
+
99
+ export function consensusEdgeKey(from, type, to) {
100
+ return `${from}\u0000${type}\u0000${to}`
101
+ }
102
+
103
+ /**
104
+ * Read the consensus mirror for one model. Returns the edge key list, or
105
+ * null when there is no mirror (never synced, or wiped).
106
+ */
107
+ export async function readConsensusMirror(model, home = process.env.HOME) {
108
+ try {
109
+ const file = path.join(home || '', '.knlview', 'sync-state', `${model}.json`)
110
+ const parsed = JSON.parse(await fs.readFile(file, 'utf8'))
111
+ if (parsed?.model !== model || !Array.isArray(parsed.edges)) return null
112
+ return parsed.edges
113
+ } catch {
114
+ return null
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Apply one change to the consensus mirror. Best effort: a failed mirror
120
+ * write is reported but never blocks the server mutation that already
121
+ * succeeded.
122
+ */
123
+ export async function updateConsensusMirror(model, change, home = process.env.HOME) {
124
+ const edges = new Set(await readConsensusMirror(model, home) || [])
125
+ if (change.op === 'link') edges.add(consensusEdgeKey(change.from, change.type, change.to))
126
+ if (change.op === 'unlink') {
127
+ edges.delete(consensusEdgeKey(change.from, 'agrees_with', change.to))
128
+ edges.delete(consensusEdgeKey(change.from, 'contradicts', change.to))
129
+ }
130
+ try {
131
+ const file = path.join(home || '', '.knlview', 'sync-state', `${model}.json`)
132
+ await fs.mkdir(path.dirname(file), { recursive: true })
133
+ await fs.writeFile(file, JSON.stringify({
134
+ model,
135
+ syncedAt: new Date().toISOString(),
136
+ edges: Array.from(edges)
137
+ }), 'utf8')
138
+ return { error: null }
139
+ } catch (err) {
140
+ return { error: err.message }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Inbound half of consensus status, walked backwards over the mirror:
146
+ * supporters (incoming agrees_with) and objectors (incoming contradicts).
147
+ * @returns {{ supporters: string[], objectors: string[] }}
148
+ */
149
+ export function inboundConsensus(mirrorEdges, targetId) {
150
+ const supporters = []
151
+ const objectors = []
152
+ for (const key of mirrorEdges || []) {
153
+ const [from, type, to] = key.split('\u0000')
154
+ if (to !== targetId) continue
155
+ if (type === 'agrees_with') supporters.push(from)
156
+ else if (type === 'contradicts') objectors.push(from)
157
+ }
158
+ return { supporters, objectors }
159
+ }
160
+
161
+ /**
162
+ * Outbound half: the claim's own stance, walked forward over the mirror.
163
+ * @returns {{ agrees: string[], contradicts: string[] }}
164
+ */
165
+ export function outboundConsensus(mirrorEdges, sourceId) {
166
+ const agrees = []
167
+ const contradicts = []
168
+ for (const key of mirrorEdges || []) {
169
+ const [from, type, to] = key.split('\u0000')
170
+ if (from !== sourceId) continue
171
+ if (type === 'agrees_with') agrees.push(to)
172
+ else if (type === 'contradicts') contradicts.push(to)
173
+ }
174
+ return { agrees, contradicts }
175
+ }
@@ -0,0 +1,253 @@
1
+ import { connectWire } from '@rahmantech/anchorwire'
2
+
3
+ /**
4
+ * Optional bridge module to the AnchorDB backend via the AnchorWire protocol
5
+ * (wire://). Follows the KISS principle: active only when configured, with
6
+ * graceful fallback.
7
+ */
8
+
9
+ /**
10
+ * Open an AnchorWire client connection.
11
+ * @param {string|object} urlOrOptions - Example: 'wire://127.0.0.1:7448?model=knowledges'
12
+ */
13
+ export async function createAnchorWireClient(urlOrOptions) {
14
+ try {
15
+ const client = await connectWire(urlOrOptions)
16
+ return client
17
+ } catch (err) {
18
+ const target = typeof urlOrOptions === 'string'
19
+ ? urlOrOptions
20
+ : `${urlOrOptions?.host || '?'}:${urlOrOptions?.port || '?'}`
21
+ throw new Error(`Failed to connect to AnchorDB (${target}): ${err.message}`)
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Stable key for one edge, for diffing and the mirror: "from\x00type\x00to".
27
+ * NUL is used because it cannot appear in healthy markdown IDs.
28
+ */
29
+ export function edgeKey(from, type, to) {
30
+ return `${from}\u0000${type}\u0000${to}`
31
+ }
32
+
33
+ /**
34
+ * Collect the set of unique edges from a DirectedGraph. Duplicate edges in
35
+ * markdown (one decision writing supersedes twice, say) only need to be sent
36
+ * to the server once.
37
+ * @param {import('./model.js').DirectedGraph} graph
38
+ * @returns {string[]} sorted edge keys
39
+ */
40
+ export function edgesOfGraph(graph) {
41
+ const set = new Set()
42
+ for (const edge of graph?.edges ?? []) {
43
+ if (edge?.from && edge?.type && edge?.to) set.add(edgeKey(edge.from, edge.type, edge.to))
44
+ }
45
+ return Array.from(set).sort()
46
+ }
47
+
48
+ /**
49
+ * Diff two edge sets: what must be sent as link and unlink.
50
+ * @param {string[]} desired - edges that should exist (the current local graph)
51
+ * @param {string[]|null} previous - edges per the last sync mirror;
52
+ * null means no mirror (the server is assumed empty → full link)
53
+ * @returns {{toLink: string[], toUnlink: string[]}}
54
+ */
55
+ export function diffEdges(desired, previous) {
56
+ const now = new Set(desired)
57
+ if (!previous) return { toLink: [...now], toUnlink: [] }
58
+ const before = new Set(previous)
59
+ return {
60
+ toLink: desired.filter((k) => !before.has(k)),
61
+ toUnlink: previous.filter((k) => !now.has(k))
62
+ }
63
+ }
64
+
65
+ function isEdgeNotFound(err) {
66
+ return /edge not found/i.test(err?.message || '')
67
+ }
68
+
69
+ /**
70
+ * Cheap check that the server still recognizes one sample edge from the
71
+ * mirror. If the sample edge does not show up in causalChain, the server is
72
+ * considered different (reinstalled, wiped, model reset) and the mirror must
73
+ * be discarded so the sync becomes a full link. Link is an upsert, so a full
74
+ * link is always safe; what is dangerous is trusting a mirror against an
75
+ * empty server.
76
+ */
77
+ export async function mirrorStillValid(client, previousEdges) {
78
+ if (!Array.isArray(previousEdges) || previousEdges.length === 0) return Boolean(previousEdges)
79
+ const [from, type, to] = previousEdges[0].split('\u0000')
80
+ try {
81
+ const results = await client.causalChain([from], type, 5)
82
+ return results.some((item) => item?.word === to)
83
+ } catch {
84
+ return false
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Sync the graph to AnchorDB INCREMENTALLY: only new edges are linked, edges
90
+ * missing from markdown are unlinked, the rest are left alone. The server
91
+ * offers no edge enumeration, so the comparator is the mirror of the last
92
+ * sync (`previousEdges` from the local sync-state), and the mirror is
93
+ * verified with one causal probe before being trusted.
94
+ *
95
+ * Unlinking an edge that no longer exists on the server is treated as
96
+ * success (case: the container/server was reinstalled but the mirror remains).
97
+ *
98
+ * @param {import('@rahmantech/anchorwire').AnchorWireClient} client
99
+ * @param {import('./model.js').DirectedGraph} graph
100
+ * @param {string[]|null} previousEdges - mirror of the last sync (null = full sync)
101
+ * @returns {Promise<{linked: number, unlinked: number, kept: number, failed: number, edges: string[]}>}
102
+ * `edges` = the full edge set after the sync, to be written to the mirror
103
+ * ONLY when `failed === 0`. On partial failure the mirror is not written so
104
+ * the next start retries the failed part.
105
+ */
106
+ export async function syncGraphToAnchorDB(client, graph, previousEdges = null) {
107
+ if (!client || !graph) return { linked: 0, unlinked: 0, kept: 0, failed: 0, edges: [] }
108
+
109
+ const edges = edgesOfGraph(graph)
110
+ if (previousEdges && !(await mirrorStillValid(client, previousEdges))) {
111
+ previousEdges = null
112
+ }
113
+ const { toLink, toUnlink } = diffEdges(edges, previousEdges)
114
+ let linked = 0
115
+ let unlinked = 0
116
+ let failed = 0
117
+
118
+ for (const key of toLink) {
119
+ const [from, type, to] = key.split('\u0000')
120
+ try {
121
+ await client.link(from, type, to, 1.0)
122
+ linked += 1
123
+ } catch {
124
+ failed += 1
125
+ }
126
+ }
127
+
128
+ for (const key of toUnlink) {
129
+ const [from, type, to] = key.split('\u0000')
130
+ try {
131
+ await client.unlink(from, type, to)
132
+ unlinked += 1
133
+ } catch (err) {
134
+ // The edge no longer exists on the server: the end state equals what
135
+ // we want, so it counts as success.
136
+ if (isEdgeNotFound(err)) unlinked += 1
137
+ else failed += 1
138
+ }
139
+ }
140
+
141
+ return { linked, unlinked, kept: edges.length - linked, failed, edges }
142
+ }
143
+
144
+ /**
145
+ * Distributed multi-hop causal chain trace on the AnchorDB server.
146
+ *
147
+ * The AnchorWire client uses a relations array as an ORDERED CHAIN of hops
148
+ * (the second hop goes through the second relation), not "any one of". For
149
+ * the knowledge graphs we want to trace a UNION of relations (DEC
150
+ * --supersedes--> X or --affects_knowledge--> Y), each relation is traced
151
+ * separately per hop and then merged: dedupe per node, highest score wins,
152
+ * and each next hop's score is multiplied by its parent's.
153
+ *
154
+ * @param {import('@rahmantech/anchorwire').AnchorWireClient} client
155
+ * @param {string} seedId - id of the center node of the query
156
+ * @param {string|string[]} relations - relations to follow (e.g. ['supersedes', 'affects_knowledge', 'depends_on'])
157
+ * @param {number} limitK - result limit per server call
158
+ * @param {number} maxHops - trace hop radius
159
+ */
160
+ export async function traceCausalChain(client, seedId, relations = ['supersedes', 'affects_knowledge', 'depends_on'], limitK = 20, maxHops = 2) {
161
+ if (!client) throw new Error('AnchorWire client unavailable.')
162
+ const rels = (Array.isArray(relations) ? relations : [relations]).filter(Boolean)
163
+ if (!rels.length) return []
164
+
165
+ const best = new Map() // word -> { word, score }
166
+ let frontier = [{ word: seedId, score: 1 }]
167
+
168
+ for (let hop = 1; hop <= Math.max(1, maxHops) && frontier.length; hop++) {
169
+ const nextFrontier = []
170
+ for (const { word, score } of frontier) {
171
+ const perRelation = await Promise.all(
172
+ rels.map((rel) => client.causalChain([word], rel, limitK).catch(() => []))
173
+ )
174
+ for (const results of perRelation) {
175
+ for (const item of results) {
176
+ if (!item?.word || item.word === seedId) continue
177
+ const reached = { word: item.word, score: score * (item.score || 1) }
178
+ const prev = best.get(reached.word)
179
+ if (!prev || reached.score > prev.score) {
180
+ best.set(reached.word, reached)
181
+ nextFrontier.push(reached)
182
+ }
183
+ }
184
+ }
185
+ }
186
+ frontier = nextFrontier
187
+ }
188
+
189
+ return Array.from(best.values()).sort((a, b) => b.score - a.score)
190
+ }
191
+
192
+ /**
193
+ * Reverse traversal over the local sync mirror: the inbound half of
194
+ * two-way lineage. The wire protocol has no "who points at X" query, but
195
+ * the mirror holds every edge this project ever linked, which is exactly
196
+ * the inbound map for this project's model. Entries carry score 1: the
197
+ * server's relevance scoring only exists for forward chains.
198
+ *
199
+ * @param {string[]} mirrorEdges - edge keys "from\x00type\x00to" from the sync mirror
200
+ * @param {string} seedId - id of the node to trace inbound from
201
+ * @param {string[]} relations - relation types to follow
202
+ * @param {number} maxHops - traversal radius
203
+ * @returns {Array<{word: string, score: number}>}
204
+ */
205
+ export function traceInboundFromMirror(mirrorEdges, seedId, relations, maxHops = 2) {
206
+ if (!Array.isArray(mirrorEdges) || !mirrorEdges.length || !seedId) return []
207
+ const wanted = new Set(relations.filter(Boolean))
208
+
209
+ // Reverse adjacency: target node -> list of { source, type }.
210
+ const inbound = new Map()
211
+ for (const key of mirrorEdges) {
212
+ const [from, type, to] = key.split('\u0000')
213
+ if (!from || !to) continue
214
+ if (wanted.size && !wanted.has(type)) continue
215
+ if (!inbound.has(to)) inbound.set(to, [])
216
+ inbound.get(to).push(from)
217
+ }
218
+
219
+ const best = new Map() // word -> depth reached (BFS, first reach wins)
220
+ let frontier = [seedId]
221
+ best.set(seedId, 0)
222
+
223
+ for (let hop = 1; hop <= Math.max(1, maxHops) && frontier.length; hop++) {
224
+ const nextFrontier = []
225
+ for (const word of frontier) {
226
+ for (const source of inbound.get(word) || []) {
227
+ if (source === seedId || best.has(source)) continue
228
+ best.set(source, hop)
229
+ nextFrontier.push(source)
230
+ }
231
+ }
232
+ frontier = nextFrontier
233
+ }
234
+
235
+ best.delete(seedId)
236
+ return Array.from(best.keys()).map((word) => ({ word, score: 1 }))
237
+ }
238
+
239
+ /**
240
+ * Merge forward (server-scored) and inbound (mirror) results into one
241
+ * ranked list. A word reached both ways keeps the higher, meaningful score.
242
+ */
243
+ export function mergeLineage(forward, inbound) {
244
+ const merged = new Map()
245
+ for (const { word, score } of forward) {
246
+ merged.set(word, { word, score })
247
+ }
248
+ for (const { word, score } of inbound) {
249
+ const prev = merged.get(word)
250
+ if (!prev || score > prev.score) merged.set(word, { word, score })
251
+ }
252
+ return Array.from(merged.values()).sort((a, b) => b.score - a.score)
253
+ }