@rahmantech/knl-view 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +483 -2
  3. package/bin/knl-view.js +1320 -0
  4. package/dist/assets/abnfDiagram-WEYOIA3C-hoSbsMOz.js +1 -0
  5. package/dist/assets/arc-g87Np6E5.js +1 -0
  6. package/dist/assets/architecture-WOLXFQ4H-DOIq-0IA.js +1 -0
  7. package/dist/assets/architectureDiagram-ZHVYKT53-Dxwe8n7m.js +36 -0
  8. package/dist/assets/array-BifhSqXX.js +1 -0
  9. package/dist/assets/blockDiagram-RJAE7QA3-BnGng59Z.js +140 -0
  10. package/dist/assets/c4Diagram-XDPPPE54-C5LjqIIo.js +38 -0
  11. package/dist/assets/channel-BVWkRFBw.js +1 -0
  12. package/dist/assets/chunk-2E7WHVMD-BJ1dyGLq.js +88 -0
  13. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  14. package/dist/assets/chunk-4EMNKR2T-DXF02Sgw.js +1 -0
  15. package/dist/assets/chunk-55XCU76K-CM1akAbu.js +1 -0
  16. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  17. package/dist/assets/chunk-7S6XSTFO-BOUjqldL.js +2 -0
  18. package/dist/assets/chunk-AQDTSKZB-C4isdNLA.js +72 -0
  19. package/dist/assets/chunk-DSNLSHJZ-CbOTkSVM.js +129 -0
  20. package/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  21. package/dist/assets/chunk-HAGFOTED-CRfnketR.js +62 -0
  22. package/dist/assets/chunk-HNE4Q36P-rxlpGUU8.js +1 -0
  23. package/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  24. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  25. package/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  26. package/dist/assets/chunk-NQOKRVIF-BOYCgTmV.js +213 -0
  27. package/dist/assets/chunk-UHW7PFBP-Cnogp1Fb.js +1 -0
  28. package/dist/assets/chunk-VQ5W62QI-Cu1WY5Uo.js +1 -0
  29. package/dist/assets/chunk-WQQL4CDK-BJc3FPE2.js +10 -0
  30. package/dist/assets/chunk-XXDRQBXY-BlPj4H7s.js +1 -0
  31. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  32. package/dist/assets/chunk-YZO67HFG-V85H31x4.js +1 -0
  33. package/dist/assets/chunk-ZAUX2LTX-C3XKXW09.js +1 -0
  34. package/dist/assets/chunk-ZCHMQSOR-B0mmpEgW.js +2 -0
  35. package/dist/assets/classDiagram-v2-4MYWR3UA-oMd7YaHP.js +217 -0
  36. package/dist/assets/cose-bilkent-JH36ORCC-J0kSVsDf.js +1 -0
  37. package/dist/assets/cynefin-EF2NZ3EQ-DK2TNhel.js +1 -0
  38. package/dist/assets/cynefinDiagram-WF24UDRP-DDvKgieA.js +62 -0
  39. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  40. package/dist/assets/dagre-NK4GIZVX-Ca2Fe-i-.js +4 -0
  41. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  42. package/dist/assets/diagram-CBK3MWTC-L6dmeQ2V.js +24 -0
  43. package/dist/assets/diagram-I5XIIV2D-DhG8R-v5.js +24 -0
  44. package/dist/assets/diagram-ODIL2UCW-BpYYbYBT.js +30 -0
  45. package/dist/assets/diagram-PFBEJUXE-DKQpHSsC.js +3 -0
  46. package/dist/assets/diagram-RJA3I4VV-DP7Dsnya.js +41 -0
  47. package/dist/assets/diagram-TYUNFSNR-CfSFewUS.js +200 -0
  48. package/dist/assets/dist-CQNjnrld.js +1 -0
  49. package/dist/assets/ebnfDiagram-V4P3TTPT-DE3MZoJO.js +1 -0
  50. package/dist/assets/elk-QONTBCTQ-p_aAQPHU.js +27 -0
  51. package/dist/assets/erDiagram-KYQIGQAA-DZO3vQrk.js +99 -0
  52. package/dist/assets/eventmodeling-K75KTNOO-BII9EPl5.js +1 -0
  53. package/dist/assets/flowDiagram-IUGLLWBU-D9uPno39.js +1 -0
  54. package/dist/assets/ganttDiagram-R7GPNTJY-BKiH3v2r.js +292 -0
  55. package/dist/assets/gitGraph-VSP46ZUC-oHL6cr73.js +1 -0
  56. package/dist/assets/gitGraphDiagram-PDYL7XHY-CqPVIEeo.js +106 -0
  57. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  58. package/dist/assets/index-CSpirV16.js +76 -0
  59. package/dist/assets/index-DLgMsUs2.css +1 -0
  60. package/dist/assets/info-OHQRW6UA-Drrbnhpd.js +1 -0
  61. package/dist/assets/infoDiagram-MVDRKAX5-DnQQFMAd.js +2 -0
  62. package/dist/assets/init-C-OQMol4.js +1 -0
  63. package/dist/assets/ishikawaDiagram-HPEHV2GE-DIpQlxRh.js +70 -0
  64. package/dist/assets/journeyDiagram-BOSHW7O7-EBR9I5h1.js +139 -0
  65. package/dist/assets/kanban-definition-FNSA4HC5-CdvNsjik.js +89 -0
  66. package/dist/assets/katex-B_JcIwqj.js +257 -0
  67. package/dist/assets/line-CDLzgxDc.js +1 -0
  68. package/dist/assets/linear-DfXKql9u.js +1 -0
  69. package/dist/assets/mermaid-parser.core-DnXGphT2.js +7 -0
  70. package/dist/assets/mermaid.core-h6fiCaSZ.js +44 -0
  71. package/dist/assets/mindmap-definition-6OY57UG3-BiNfio-j.js +98 -0
  72. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  73. package/dist/assets/packet-JDAUHWVQ-D76byv1S.js +1 -0
  74. package/dist/assets/path-fybaL0A-.js +1 -0
  75. package/dist/assets/pegDiagram-ECIUJNBX-Drs8Pxdt.js +1 -0
  76. package/dist/assets/pie-XZMESJXO-D_83z1lM.js +1 -0
  77. package/dist/assets/pieDiagram-GNMPY74T-wvzIphSf.js +39 -0
  78. package/dist/assets/quadrantDiagram-D57K4MC3-B7EfMRWb.js +7 -0
  79. package/dist/assets/radar-ABXABTNO-Bs6dVdAR.js +1 -0
  80. package/dist/assets/railroad-I3PHUGI6-lKaN4miw.js +1 -0
  81. package/dist/assets/railroad-abnf-LNEFI6M7-Bhh16b7Z.js +1 -0
  82. package/dist/assets/railroad-ebnf-DWYD2UWJ-m6ybW-Y_.js +1 -0
  83. package/dist/assets/railroad-peg-JR7BVNR7-DNn72e7r.js +1 -0
  84. package/dist/assets/railroadDiagram-Y72NRBL7-DNeop1iZ.js +1 -0
  85. package/dist/assets/requirementDiagram-EQYSW5RA-BVYZ2XYJ.js +84 -0
  86. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  87. package/dist/assets/sankeyDiagram-SY24JHO5-B2MP51on.js +40 -0
  88. package/dist/assets/sequenceDiagram-BLEHNHQU-BUvxstXG.js +169 -0
  89. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  90. package/dist/assets/src-BrR0lqlG.js +1 -0
  91. package/dist/assets/stateDiagram-v2-A6IESKWZ-Ba84OGKE.js +291 -0
  92. package/dist/assets/swimlanes-B3ENRKFQ-BMSl2PNA.js +1 -0
  93. package/dist/assets/swimlanesDiagram-AYQRV4AK-M1dmvq7m.js +8 -0
  94. package/dist/assets/timeline-definition-JXYKGLGB-DOdW-dxq.js +120 -0
  95. package/dist/assets/treeView-D4JQ5SDB-DZ9KQyn5.js +1 -0
  96. package/dist/assets/treemap-SAJKECNS-WEC5wtDV.js +1 -0
  97. package/dist/assets/usecaseDiagram-WPOLBNNX-cldTztFL.js +328 -0
  98. package/dist/assets/vennDiagram-Q3Y3IDCW-c41KySoY.js +34 -0
  99. package/dist/assets/wardley-7MLQ67FV-Bf9_aSSZ.js +1 -0
  100. package/dist/assets/wardleyDiagram-IVAIDKWA-BeKEvEBG.js +78 -0
  101. package/dist/assets/xychartDiagram-4URNHDU4-tNkAxTxG.js +7 -0
  102. package/dist/index.html +22 -0
  103. package/package.json +50 -4
  104. package/server/config.js +389 -0
  105. package/server/consensus.js +180 -0
  106. package/server/graph/anchordb_client.js +253 -0
  107. package/server/graph/model.js +157 -0
  108. package/server/graph/parser.js +327 -0
  109. package/server/graph/sources.js +801 -0
  110. package/server/graph/syntax.js +437 -0
  111. package/server/index.js +742 -0
  112. package/server/install.js +437 -0
  113. package/server/map.js +126 -0
  114. package/server/models_registry.js +109 -0
  115. package/server/scaffold.js +466 -0
@@ -0,0 +1,389 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import fsSync from 'node:fs'
3
+ import path from 'node:path'
4
+ import os from 'node:os'
5
+
6
+ // Two-layer knl-view config:
7
+ // - Global: %APPDATA%\knlview on Windows (fallback to ~/.knlview), or
8
+ // ~/.knlview/knl-config.json on Linux/macOS. Holds the "anchordb" block
9
+ // (URL, token, sync). That connection belongs to the machine/user, not
10
+ // the repo, so all projects using AnchorDB share the same server.
11
+ // - Per project: knl-config.json at the project root (gitignored) holds
12
+ // path/port/host and the "models" map (role -> AnchorDB model name; names
13
+ // may be random, the map records what each name is for).
14
+ //
15
+ // A missing or broken file must never bring the viewer down. Final values
16
+ // are chosen by resolveSettings in the order: CLI flag > config > default.
17
+
18
+ const PROJECT_CONFIG_FILE = 'knl-config.json'
19
+ const GLOBAL_CONFIG_DIR = '.knlview'
20
+ const GLOBAL_CONFIG_FILE = 'knl-config.json'
21
+ const MAX_CONFIG_BYTES = 64 * 1024
22
+
23
+ export const DEFAULT_SETTINGS = { path: '.knowledges', port: '8085', host: '127.0.0.1' }
24
+ export const DEFAULT_MODEL = 'knowledges'
25
+
26
+ // Causal relations /api/anchordb/trace follows when the config sets none.
27
+ export const DEFAULT_RELATIONS = ['supersedes', 'affects_knowledge', 'depends_on']
28
+
29
+ /**
30
+ * Resolve the global knl-view base directory based on OS:
31
+ * - On Windows (win32): uses %APPDATA%\knlview (falling back to %USERPROFILE%\AppData\Roaming\knlview).
32
+ * - On macOS/Linux: uses ~/.knlview (falling back to os.homedir()/.knlview).
33
+ * - If an explicit `home` path is provided (e.g. in unit tests), it maps to path.join(home, '.knlview').
34
+ */
35
+ export function knlGlobalDir(home = null, { env = process.env, platform = process.platform } = {}) {
36
+ const p = platform === 'win32' ? path.win32 : path
37
+ if (home && typeof home === 'string' && home.trim()) {
38
+ return p.join(home.trim(), GLOBAL_CONFIG_DIR)
39
+ }
40
+
41
+ // OS detection: Windows (win32) uses %APPDATA%\knlview
42
+ if (platform === 'win32') {
43
+ if (env.APPDATA && env.APPDATA.trim()) {
44
+ return p.join(env.APPDATA.trim(), 'knlview')
45
+ }
46
+ const winHome = env.USERPROFILE || (typeof os.homedir === 'function' ? os.homedir() : '') || '.'
47
+ return p.join(winHome, 'AppData', 'Roaming', 'knlview')
48
+ }
49
+
50
+ // macOS and Linux use ~/.knlview
51
+ const unixHome = env.HOME || (typeof os.homedir === 'function' ? os.homedir() : '') || '.'
52
+ return p.join(unixHome, GLOBAL_CONFIG_DIR)
53
+ }
54
+
55
+ export function globalConfigPath(home = null, options = {}) {
56
+ const platform = options.platform || process.platform
57
+ const p = platform === 'win32' ? path.win32 : path
58
+ return p.join(knlGlobalDir(home, options), GLOBAL_CONFIG_FILE)
59
+ }
60
+
61
+ /**
62
+ * Location of the mirror of the last synced edges for one model:
63
+ * ~/.knlview/sync-state/<model>.json (or %APPDATA%\knlview\sync-state\ on Windows).
64
+ * Incremental sync compares the local graph against this mirror, not against
65
+ * the server contents (the server offers no edge enumeration over the wire protocol).
66
+ */
67
+ export function syncStatePath(model, home = null, options = {}) {
68
+ const platform = options.platform || process.platform
69
+ const p = platform === 'win32' ? path.win32 : path
70
+ const safe = String(model || 'model').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'model'
71
+ return p.join(knlGlobalDir(home, options), 'sync-state', `${safe}.json`)
72
+ }
73
+
74
+ /**
75
+ * Read the global config knl-config.json (anchordb block).
76
+ * On Windows, falls back to legacy ~/.knlview/knl-config.json if %APPDATA% has no config yet.
77
+ * @returns {Promise<{config: object|null, error: string|null}>}
78
+ */
79
+ export async function readGlobalConfig(home = null, options = {}) {
80
+ const primary = globalConfigPath(home, options)
81
+ const res = await readConfigFile(primary, normalizeGlobalConfig)
82
+ if (res.config) return res
83
+
84
+ // On Windows, if config is missing in %APPDATA%, check legacy ~/.knlview fallback
85
+ const platform = options.platform || process.platform
86
+ const p = platform === 'win32' ? path.win32 : path
87
+ const env = options.env || process.env
88
+ if (platform === 'win32' && !home) {
89
+ const winHome = env.USERPROFILE || env.HOME || (typeof os.homedir === 'function' ? os.homedir() : null)
90
+ if (winHome) {
91
+ const fallbackFile = p.join(winHome, GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE)
92
+ if (fallbackFile !== primary) {
93
+ const fallbackRes = await readConfigFile(fallbackFile, normalizeGlobalConfig)
94
+ if (fallbackRes.config) return fallbackRes
95
+ }
96
+ }
97
+ }
98
+
99
+ return res
100
+ }
101
+
102
+ /**
103
+ * Read the per-project config: knl-config.json at the project root.
104
+ * @returns {Promise<{config: object|null, error: string|null}>}
105
+ */
106
+ export async function readProjectConfig(cwd = process.cwd()) {
107
+ return readConfigFile(path.join(cwd, PROJECT_CONFIG_FILE), normalizeProjectConfig)
108
+ }
109
+
110
+ async function readConfigFile(abs, normalize) {
111
+ let stat
112
+ try {
113
+ stat = await fs.stat(abs)
114
+ } catch {
115
+ return { config: null, error: null }
116
+ }
117
+ if (!stat.isFile()) return { config: null, error: null }
118
+ if (stat.size > MAX_CONFIG_BYTES) {
119
+ return { config: null, error: `size exceeds ${MAX_CONFIG_BYTES} bytes` }
120
+ }
121
+ let parsed
122
+ try {
123
+ parsed = JSON.parse(await fs.readFile(abs, 'utf8'))
124
+ } catch (err) {
125
+ return { config: null, error: `invalid JSON: ${err.message}` }
126
+ }
127
+ return { config: normalize(parsed), error: null }
128
+ }
129
+
130
+ /**
131
+ * Filter the project config down to known fields. Invalid values are dropped
132
+ * (not fatal) so resolveSettings falls back to the CLI flag or the default.
133
+ *
134
+ * `models` is the role → AnchorDB model name map (names may be random; this
135
+ * map is what records what each name is for). The legacy `model: "x"` shape
136
+ * is still accepted as shorthand for models.knowledges.
137
+ */
138
+ export function normalizeProjectConfig(raw) {
139
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
140
+ const config = {}
141
+ const configPath = cleanString(raw.path)
142
+ if (configPath) config.path = configPath
143
+ const port = toPort(raw.port)
144
+ if (port != null) config.port = String(port)
145
+ const host = cleanString(raw.host)
146
+ if (host) config.host = host
147
+ const models = normalizeModels(raw.models)
148
+ if (models) config.models = models
149
+ const model = cleanString(raw.model)
150
+ if (model) config.model = model
151
+ const labelDir = cleanString(raw.labelDir)
152
+ if (labelDir) config.labelDir = labelDir
153
+ const include = cleanStringList(raw.include)
154
+ if (include) config.include = include
155
+ const exclude = cleanStringList(raw.exclude)
156
+ if (exclude) config.exclude = exclude
157
+ return config
158
+ }
159
+
160
+ const MODEL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
161
+ const MODEL_ROLE_RE = /^[a-z][a-z0-9_-]*$/
162
+
163
+ /**
164
+ * Filter the models map: keys = roles (lowercase slugs), values = model names.
165
+ * Invalid entries are dropped, not fatal.
166
+ */
167
+ export function normalizeModels(raw) {
168
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
169
+ const models = {}
170
+ for (const [role, name] of Object.entries(raw)) {
171
+ const roleSlug = String(role).trim().toLowerCase()
172
+ const nameStr = cleanString(name)
173
+ if (!MODEL_ROLE_RE.test(roleSlug) || !MODEL_NAME_RE.test(nameStr)) continue
174
+ models[roleSlug] = nameStr
175
+ }
176
+ return Object.keys(models).length ? models : null
177
+ }
178
+
179
+ /**
180
+ * Model name for one role, verbatim from the map. No automatic namespacing:
181
+ * model names deliberately stay stable even when the project/folder is
182
+ * renamed. Returns null when the role is unmapped (the caller supplies the
183
+ * "run knl-view model new <role>" hint).
184
+ */
185
+ export function resolveModel(models, role = DEFAULT_MODEL) {
186
+ const name = models?.[role]
187
+ return typeof name === 'string' && name ? name : null
188
+ }
189
+
190
+ /**
191
+ * Filter the global config: only the anchordb block matters here.
192
+ */
193
+ export function normalizeGlobalConfig(raw) {
194
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
195
+ const anchordb = normalizeAnchordb(raw.anchordb)
196
+ return anchordb ? { anchordb } : null
197
+ }
198
+
199
+ /**
200
+ * Normalize the anchordb block shape: a "wire://host:port?token=..." string
201
+ * or a full object. Returns null when unusable.
202
+ */
203
+ export function normalizeAnchordb(raw) {
204
+ if (typeof raw === 'string') raw = { url: raw }
205
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
206
+ const url = cleanString(raw.url)
207
+ if (!url || !/^(wire|tcp):\/\//i.test(url)) return null
208
+ const anchordb = { url }
209
+ const token = cleanString(raw.token)
210
+ if (token) anchordb.token = token
211
+ const model = cleanString(raw.model)
212
+ if (model) anchordb.model = model
213
+ // Present means active (including graph sync on start); sync: false to opt out.
214
+ anchordb.sync = raw.sync !== false
215
+ if (Array.isArray(raw.relations)) {
216
+ const relations = raw.relations.map((r) => cleanString(r)).filter(Boolean)
217
+ if (relations.length) anchordb.relations = relations
218
+ }
219
+ const daemon = cleanString(raw.daemon || raw.deamon)
220
+ if (daemon) {
221
+ anchordb.daemon = daemon
222
+ anchordb.deamon = daemon
223
+ }
224
+ const containerName = cleanString(raw.container_name)
225
+ if (containerName) anchordb.container_name = containerName
226
+ const containerId = cleanString(raw.container_id)
227
+ if (containerId) anchordb.container_id = containerId
228
+ const containerCreatedOn = cleanString(raw.container_created_on)
229
+ if (containerCreatedOn) anchordb.container_created_on = containerCreatedOn
230
+ return anchordb
231
+ }
232
+
233
+ /**
234
+ * Project name for the model registry entry: the package.json "name" field
235
+ * when present, otherwise the project folder name. Cleaned into a safe slug.
236
+ */
237
+ export function projectSlug(cwd = process.cwd()) {
238
+ let name = null
239
+ try {
240
+ const parsed = JSON.parse(fsSync.readFileSync(path.join(cwd, 'package.json'), 'utf8'))
241
+ if (typeof parsed?.name === 'string') name = parsed.name
242
+ } catch {
243
+ // Without a package.json, the project folder name is used.
244
+ }
245
+ if (!name || !name.trim()) name = path.basename(path.resolve(cwd))
246
+ const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
247
+ return slug || 'project'
248
+ }
249
+
250
+ /**
251
+ * Merge CLI flags, project config, global config, and defaults.
252
+ *
253
+ * - path/port/host: CLI > project config > default
254
+ * - anchordb: --anchordb flag > global config; fully off when neither exists
255
+ * (a project without global config never touches AnchorDB)
256
+ * - model: taken from the project config `models` map (knowledges role).
257
+ * The name in the URL (?model=) is only used when the map does not set it.
258
+ */
259
+ export function resolveSettings({ cli = {}, project = null, global: globalConfig = null } = {}) {
260
+ const p = project || {}
261
+ const g = globalConfig || {}
262
+ const anchordb = cli.anchordb !== undefined
263
+ ? normalizeAnchordb(cli.anchordb)
264
+ : normalizeAnchordb(g.anchordb)
265
+
266
+ // Legacy "model": "x" shape still counts as models.knowledges.
267
+ const models = { ...(p.models || {}) }
268
+ if (!models[DEFAULT_MODEL] && typeof p.model === 'string' && p.model.trim()) {
269
+ models[DEFAULT_MODEL] = p.model.trim()
270
+ }
271
+ const hasModels = Object.keys(models).length > 0
272
+
273
+ if (anchordb) {
274
+ anchordb.model = (hasModels && resolveModel(models)) || cleanString(anchordb.model) || null
275
+ }
276
+
277
+ const labelDir = pickString(cli.labelDir, p.labelDir)
278
+ const include = Array.isArray(p.include) && p.include.length ? p.include : null
279
+ const exclude = Array.isArray(p.exclude) && p.exclude.length ? p.exclude : null
280
+
281
+ return {
282
+ path: pickString(cli.path, p.path) ?? DEFAULT_SETTINGS.path,
283
+ port: pickString(cli.port, p.port) ?? DEFAULT_SETTINGS.port,
284
+ host: pickString(cli.host, p.host) ?? DEFAULT_SETTINGS.host,
285
+ models: hasModels ? models : null,
286
+ labelDir: labelDir ?? null,
287
+ include,
288
+ exclude,
289
+ anchordb
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Build AnchorWire client options from the already-resolved anchordb block.
295
+ * Config fields win over values in the URL query.
296
+ */
297
+ export function anchordbClientOptions(anchordb) {
298
+ if (!anchordb?.url || typeof anchordb.url !== 'string') return null
299
+ const normalized = anchordb.url.replace(/^(wire|tcp):\/\//i, 'http://')
300
+ let parsed
301
+ try {
302
+ parsed = new URL(normalized.startsWith('http') ? normalized : `http://${normalized}`)
303
+ } catch {
304
+ return null
305
+ }
306
+ const options = {}
307
+ if (parsed.hostname) options.host = parsed.hostname.replace(/^\[|\]$/g, '')
308
+ if (parsed.port) {
309
+ const port = Number.parseInt(parsed.port, 10)
310
+ if (Number.isInteger(port) && port > 0 && port <= 65535) options.port = port
311
+ }
312
+ options.token = anchordb.token || parsed.searchParams.get('token') || ''
313
+ // The model name comes from the project map (already resolved by
314
+ // resolveSettings) or the URL query for a one-off session. Deliberately no
315
+ // default: a model with no clear name must not silently create a generic
316
+ // "knowledges" model.
317
+ options.model = anchordb.model || parsed.searchParams.get('model') || ''
318
+ return options
319
+ }
320
+
321
+ function pickString(cliValue, configValue) {
322
+ if (typeof cliValue === 'string' && cliValue.trim()) return cliValue.trim()
323
+ if (typeof configValue === 'string' && configValue.trim()) return configValue.trim()
324
+ return null
325
+ }
326
+
327
+ function cleanString(value) {
328
+ return typeof value === 'string' ? value.trim() : ''
329
+ }
330
+
331
+ function toPort(value) {
332
+ const n = Number(value)
333
+ return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : null
334
+ }
335
+
336
+ function cleanStringList(raw) {
337
+ if (!raw) return null
338
+ const arr = Array.isArray(raw) ? raw : [raw]
339
+ const cleaned = arr
340
+ .map((item) => (typeof item === 'string' ? item.trim() : ''))
341
+ .filter(Boolean)
342
+ return cleaned.length ? cleaned : null
343
+ }
344
+
345
+ /**
346
+ * Compile a list of string patterns (globs, directory names, or exact paths)
347
+ * into a reusable matcher object with `matches(relPath, baseName)` and
348
+ * `isPrefixOfAny(relPath)`.
349
+ */
350
+ export function createPathMatcher(patterns) {
351
+ if (!patterns) return null
352
+ const list = cleanStringList(patterns)
353
+ if (!list) return null
354
+
355
+ const matchers = list.map((pattern) => {
356
+ const clean = pattern.replace(/^\/+|\/+$/g, '')
357
+ const hasWildcard = clean.includes('*') || clean.includes('?')
358
+ if (!hasWildcard) {
359
+ return {
360
+ pattern: clean,
361
+ test: (rel, name) => rel === clean || rel.startsWith(clean + '/') || name === clean
362
+ }
363
+ }
364
+ const escaped = clean
365
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
366
+ .replace(/\*\*/g, '§§GLOBSTAR§§')
367
+ .replace(/\*/g, '[^/]*')
368
+ .replace(/§§GLOBSTAR§§/g, '.*')
369
+ .replace(/\?/g, '[^/]')
370
+ try {
371
+ const re = new RegExp(`^(?:${escaped}|.*/${escaped})(?:/.*)?$`)
372
+ return {
373
+ pattern: clean,
374
+ test: (rel, name) => re.test(rel) || (name && re.test(name))
375
+ }
376
+ } catch {
377
+ return {
378
+ pattern: clean,
379
+ test: () => false
380
+ }
381
+ }
382
+ })
383
+
384
+ return {
385
+ patterns: list,
386
+ matches: (rel, name = '') => matchers.some((m) => m.test(rel, name)),
387
+ isPrefixOfAny: (rel) => matchers.some((m) => m.pattern.startsWith(rel + '/') || m.test(rel, rel))
388
+ }
389
+ }
@@ -0,0 +1,180 @@
1
+ import { promises as fs } from 'node:fs'
2
+ import path from 'node:path'
3
+ import crypto from 'node:crypto'
4
+ import { knlGlobalDir, syncStatePath } from './config.js'
5
+
6
+ // Consensus: agreement and conflict between claims, stored purely in
7
+ // AnchorDB as a second model (the "consensus" role in the project's models
8
+ // map). Claims relate only to other claims, never to decisions or files.
9
+ //
10
+ // A claim is content-addressed: id = "c-" + the first 16 hex chars of the
11
+ // statement's SHA-256, so the same statement is the same id on every
12
+ // machine. AnchorDB schemas only admit float32 attributes, so the statement
13
+ // text cannot live in the server; the local index below is a convenience
14
+ // cache (same nature as the sync mirror), never the source of the
15
+ // consensus structure.
16
+ //
17
+ // Relation types are fixed so status derivation stays meaningful:
18
+ // agrees_with: the source claim supports the target claim
19
+ // contradicts: the source claim opposes the target claim
20
+
21
+ export const CONSENSUS_RELATIONS = ['agrees_with', 'contradicts']
22
+
23
+ /**
24
+ * Content-addressed claim id from the statement text.
25
+ * @param {string} text
26
+ * @returns {string} "c-" plus 16 hex characters
27
+ */
28
+ export function claimId(text) {
29
+ const normalized = String(text || '').trim().toLowerCase().replace(/\s+/g, ' ')
30
+ const hash = crypto.createHash('sha256').update(normalized).digest('hex')
31
+ return `c-${hash.slice(0, 16)}`
32
+ }
33
+
34
+ /**
35
+ * Derive consensus status for one claim from who points at it.
36
+ * Any objection outweighs support: a single contradicts makes the claim
37
+ * disputed even if many agree with it.
38
+ * @returns {'disputed'|'consensus'|'uncontested'}
39
+ */
40
+ export function deriveStatus(supporters, objectors) {
41
+ if (objectors.length > 0) return 'disputed'
42
+ if (supporters.length > 0) return 'consensus'
43
+ return 'uncontested'
44
+ }
45
+
46
+ /** Local cache path for claim statements: ~/.knlview/claims/<model>.json (or %APPDATA%\knlview\claims\ on Windows) */
47
+ export function claimsIndexPath(model, home = null, options = {}) {
48
+ return path.join(knlGlobalDir(home, options), 'claims', `${model}.json`)
49
+ }
50
+
51
+ export function consensusMirrorPath(model, home = null, options = {}) {
52
+ return path.join(knlGlobalDir(home, options), 'consensus', `${model}.json`)
53
+ }
54
+
55
+ /**
56
+ * Read the claim index. Missing file = empty index, not an error.
57
+ * @returns {Promise<{claims: object, error: string|null}>}
58
+ */
59
+ export async function readClaimsIndex(model, home = null, options = {}) {
60
+ try {
61
+ const parsed = JSON.parse(await fs.readFile(claimsIndexPath(model, home, options), 'utf8'))
62
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
63
+ return { claims: {}, error: 'claim index is not an object' }
64
+ }
65
+ const claims = {}
66
+ for (const [id, entry] of Object.entries(parsed)) {
67
+ if (entry && typeof entry === 'object' && typeof entry.text === 'string') {
68
+ claims[id] = entry
69
+ }
70
+ }
71
+ return { claims, error: null }
72
+ } catch (err) {
73
+ if (err?.code === 'ENOENT') return { claims: {}, error: null }
74
+ return { claims: {}, error: err.message }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Register a claim statement in the local index. Content-addressed, so
80
+ * re-registering the same statement is an idempotent update.
81
+ */
82
+ export async function recordClaim(model, text, home = null, options = {}) {
83
+ const id = claimId(text)
84
+ const { claims, error } = await readClaimsIndex(model, home, options)
85
+ if (error) return { id, written: false, error }
86
+ if (!claims[id]) claims[id] = { text: String(text).trim(), createdAt: new Date().toISOString() }
87
+ try {
88
+ const file = claimsIndexPath(model, home, options)
89
+ await fs.mkdir(path.dirname(file), { recursive: true })
90
+ await fs.writeFile(file, JSON.stringify(claims, null, 2) + '\n', 'utf8')
91
+ return { id, written: true, error: null }
92
+ } catch (err) {
93
+ return { id, written: false, error: err.message }
94
+ }
95
+ }
96
+
97
+ // Consensus mirror
98
+ // The wire protocol only walks edges forward (causalChain), but "who agrees
99
+ // with this claim?" is an inbound question. So every successful link/unlink
100
+ // also updates a local mirror of the consensus model's edges, the same
101
+ // pattern the knowledges graph already uses. The mirror is a read model:
102
+ // losing it only degrades inbound queries, never the server's data.
103
+
104
+ export function consensusEdgeKey(from, type, to) {
105
+ return `${from}\u0000${type}\u0000${to}`
106
+ }
107
+
108
+ /**
109
+ * Read the consensus mirror for one model. Returns the edge key list, or
110
+ * null when there is no mirror (never synced, or wiped).
111
+ */
112
+ export async function readConsensusMirror(model, home = null, options = {}) {
113
+ try {
114
+ const file = syncStatePath(model, home, options)
115
+ const parsed = JSON.parse(await fs.readFile(file, 'utf8'))
116
+ if (parsed?.model !== model || !Array.isArray(parsed.edges)) return null
117
+ return parsed.edges
118
+ } catch {
119
+ return null
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Apply one change to the consensus mirror. Best effort: a failed mirror
125
+ * write is reported but never blocks the server mutation that already
126
+ * succeeded.
127
+ */
128
+ export async function updateConsensusMirror(model, change, home = null, options = {}) {
129
+ const edges = new Set(await readConsensusMirror(model, home, options) || [])
130
+ if (change.op === 'link') edges.add(consensusEdgeKey(change.from, change.type, change.to))
131
+ if (change.op === 'unlink') {
132
+ edges.delete(consensusEdgeKey(change.from, 'agrees_with', change.to))
133
+ edges.delete(consensusEdgeKey(change.from, 'contradicts', change.to))
134
+ }
135
+ try {
136
+ const file = syncStatePath(model, home, options)
137
+ await fs.mkdir(path.dirname(file), { recursive: true })
138
+ await fs.writeFile(file, JSON.stringify({
139
+ model,
140
+ syncedAt: new Date().toISOString(),
141
+ edges: Array.from(edges)
142
+ }), 'utf8')
143
+ return { error: null }
144
+ } catch (err) {
145
+ return { error: err.message }
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Inbound half of consensus status, walked backwards over the mirror:
151
+ * supporters (incoming agrees_with) and objectors (incoming contradicts).
152
+ * @returns {{ supporters: string[], objectors: string[] }}
153
+ */
154
+ export function inboundConsensus(mirrorEdges, targetId) {
155
+ const supporters = []
156
+ const objectors = []
157
+ for (const key of mirrorEdges || []) {
158
+ const [from, type, to] = key.split('\u0000')
159
+ if (to !== targetId) continue
160
+ if (type === 'agrees_with') supporters.push(from)
161
+ else if (type === 'contradicts') objectors.push(from)
162
+ }
163
+ return { supporters, objectors }
164
+ }
165
+
166
+ /**
167
+ * Outbound half: the claim's own stance, walked forward over the mirror.
168
+ * @returns {{ agrees: string[], contradicts: string[] }}
169
+ */
170
+ export function outboundConsensus(mirrorEdges, sourceId) {
171
+ const agrees = []
172
+ const contradicts = []
173
+ for (const key of mirrorEdges || []) {
174
+ const [from, type, to] = key.split('\u0000')
175
+ if (from !== sourceId) continue
176
+ if (type === 'agrees_with') agrees.push(to)
177
+ else if (type === 'contradicts') contradicts.push(to)
178
+ }
179
+ return { agrees, contradicts }
180
+ }