@grainulation/silo 1.0.0 → 1.0.1

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/lib/graph.js ADDED
@@ -0,0 +1,414 @@
1
+ /**
2
+ * graph.js — Cross-sprint knowledge graph index
3
+ *
4
+ * Builds an in-memory graph linking claims across sprints and packs
5
+ * by topic, tags, conflicts, and content similarity. Enables discovery
6
+ * of related knowledge across the entire silo.
7
+ *
8
+ * Zero npm dependencies — uses only built-in Node.js modules.
9
+ */
10
+
11
+ const fs = require("node:fs");
12
+ const path = require("node:path");
13
+ const { Store } = require("./store.js");
14
+ const { Packs } = require("./packs.js");
15
+
16
+ class Graph {
17
+ constructor(store) {
18
+ this.store = store || new Store();
19
+ this.packs = new Packs(this.store);
20
+ this._nodes = new Map(); // claimKey -> node
21
+ this._edges = []; // { source, target, relation, weight }
22
+ this._topicIndex = new Map(); // topic -> [claimKey]
23
+ this._tagIndex = new Map(); // tag -> [claimKey]
24
+ this._built = false;
25
+ }
26
+
27
+ /**
28
+ * Build the knowledge graph from all stored collections and packs.
29
+ * Call this before querying.
30
+ */
31
+ build() {
32
+ this._nodes.clear();
33
+ this._edges = [];
34
+ this._topicIndex.clear();
35
+ this._tagIndex.clear();
36
+
37
+ // Index stored collections
38
+ const collections = this.store.list();
39
+ for (const col of collections) {
40
+ const data = this.store.getClaims(col.id);
41
+ if (!data || !data.claims) continue;
42
+ this._indexClaims(data.claims, col.id, "collection");
43
+ }
44
+
45
+ // Index built-in and local packs
46
+ const allPacks = this.packs.list();
47
+ for (const p of allPacks) {
48
+ const pack = this.packs.get(p.id);
49
+ if (!pack || !pack.claims) continue;
50
+ this._indexClaims(pack.claims, p.id, "pack");
51
+ }
52
+
53
+ // Build edges from shared topics
54
+ for (const [, keys] of this._topicIndex) {
55
+ if (keys.length < 2) continue;
56
+ for (let i = 0; i < keys.length; i++) {
57
+ for (let j = i + 1; j < keys.length; j++) {
58
+ this._addEdge(keys[i], keys[j], "same-topic", 1.0);
59
+ }
60
+ }
61
+ }
62
+
63
+ // Build edges from shared tags
64
+ for (const [, keys] of this._tagIndex) {
65
+ if (keys.length < 2) continue;
66
+ for (let i = 0; i < keys.length; i++) {
67
+ for (let j = i + 1; j < Math.min(keys.length, i + 50); j++) {
68
+ this._addEdge(keys[i], keys[j], "shared-tag", 0.5);
69
+ }
70
+ }
71
+ }
72
+
73
+ // Build edges from explicit conflicts
74
+ for (const [key, node] of this._nodes) {
75
+ const conflicts = node.claim.conflicts_with || [];
76
+ for (const conflictId of conflicts) {
77
+ // Find the conflicting claim in same source
78
+ const conflictKey = `${node.source}:${conflictId}`;
79
+ if (this._nodes.has(conflictKey)) {
80
+ this._addEdge(key, conflictKey, "conflict", 1.5);
81
+ }
82
+ }
83
+ }
84
+
85
+ this._built = true;
86
+ return this.stats();
87
+ }
88
+
89
+ /** Get graph statistics. */
90
+ stats() {
91
+ const sources = new Set();
92
+ for (const node of this._nodes.values()) {
93
+ sources.add(node.source);
94
+ }
95
+ return {
96
+ nodes: this._nodes.size,
97
+ edges: this._edges.length,
98
+ sources: sources.size,
99
+ topics: this._topicIndex.size,
100
+ tags: this._tagIndex.size,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Find claims related to a given claim by graph traversal.
106
+ *
107
+ * @param {string} claimId - Claim ID to find neighbors for
108
+ * @param {object} opts
109
+ * @param {number} opts.depth - Traversal depth (default: 1)
110
+ * @param {number} opts.limit - Max results (default: 20)
111
+ * @param {string} opts.relation - Filter by relation type
112
+ * @returns {object[]} Related claims with relation info
113
+ */
114
+ related(claimId, opts = {}) {
115
+ this._ensureBuilt();
116
+ const { depth = 1, limit = 20, relation } = opts;
117
+
118
+ // Find all node keys matching this claim ID
119
+ const startKeys = [];
120
+ for (const [key, node] of this._nodes) {
121
+ if (node.claim.id === claimId || key === claimId) {
122
+ startKeys.push(key);
123
+ }
124
+ }
125
+ if (startKeys.length === 0) return [];
126
+
127
+ // BFS traversal
128
+ const visited = new Set(startKeys);
129
+ let frontier = new Set(startKeys);
130
+ const results = [];
131
+
132
+ for (let d = 0; d < depth; d++) {
133
+ const nextFrontier = new Set();
134
+ for (const edge of this._edges) {
135
+ if (relation && edge.relation !== relation) continue;
136
+
137
+ let neighbor = null;
138
+ if (frontier.has(edge.source) && !visited.has(edge.target)) {
139
+ neighbor = edge.target;
140
+ } else if (frontier.has(edge.target) && !visited.has(edge.source)) {
141
+ neighbor = edge.source;
142
+ }
143
+
144
+ if (neighbor) {
145
+ visited.add(neighbor);
146
+ nextFrontier.add(neighbor);
147
+ const node = this._nodes.get(neighbor);
148
+ results.push({
149
+ claim: node.claim,
150
+ source: node.source,
151
+ sourceType: node.sourceType,
152
+ relation: edge.relation,
153
+ weight: edge.weight,
154
+ depth: d + 1,
155
+ });
156
+ }
157
+ }
158
+ frontier = nextFrontier;
159
+ }
160
+
161
+ results.sort((a, b) => b.weight - a.weight);
162
+ return results.slice(0, limit);
163
+ }
164
+
165
+ /**
166
+ * Find all claims for a topic across all sources.
167
+ *
168
+ * @param {string} topic - Topic string (case-insensitive)
169
+ * @param {number} limit - Max results
170
+ * @returns {object[]}
171
+ */
172
+ byTopic(topic, limit = 50) {
173
+ this._ensureBuilt();
174
+ const normalized = topic.toLowerCase().trim();
175
+ const results = [];
176
+
177
+ for (const [topicKey, keys] of this._topicIndex) {
178
+ if (topicKey.includes(normalized) || normalized.includes(topicKey)) {
179
+ for (const key of keys) {
180
+ const node = this._nodes.get(key);
181
+ results.push({
182
+ claim: node.claim,
183
+ source: node.source,
184
+ sourceType: node.sourceType,
185
+ topic: topicKey,
186
+ });
187
+ }
188
+ }
189
+ }
190
+
191
+ return results.slice(0, limit);
192
+ }
193
+
194
+ /**
195
+ * Find all claims with a given tag across all sources.
196
+ *
197
+ * @param {string} tag - Tag string
198
+ * @param {number} limit - Max results
199
+ * @returns {object[]}
200
+ */
201
+ byTag(tag, limit = 50) {
202
+ this._ensureBuilt();
203
+ const keys = this._tagIndex.get(tag.toLowerCase()) || [];
204
+ return keys.slice(0, limit).map((key) => {
205
+ const node = this._nodes.get(key);
206
+ return {
207
+ claim: node.claim,
208
+ source: node.source,
209
+ sourceType: node.sourceType,
210
+ };
211
+ });
212
+ }
213
+
214
+ /**
215
+ * Find clusters of densely connected claims.
216
+ * Returns groups of claims that share multiple connections.
217
+ *
218
+ * @param {number} minSize - Minimum cluster size (default: 3)
219
+ * @returns {object[]} Array of { topic, claims, edgeCount }
220
+ */
221
+ clusters(minSize = 3) {
222
+ this._ensureBuilt();
223
+ const clusters = [];
224
+
225
+ for (const [topic, keys] of this._topicIndex) {
226
+ if (keys.length < minSize) continue;
227
+
228
+ // Count internal edges
229
+ const keySet = new Set(keys);
230
+ let edgeCount = 0;
231
+ for (const edge of this._edges) {
232
+ if (keySet.has(edge.source) && keySet.has(edge.target)) {
233
+ edgeCount++;
234
+ }
235
+ }
236
+
237
+ const claims = keys.map((k) => {
238
+ const n = this._nodes.get(k);
239
+ return { claim: n.claim, source: n.source, sourceType: n.sourceType };
240
+ });
241
+
242
+ clusters.push({ topic, claims, claimCount: keys.length, edgeCount });
243
+ }
244
+
245
+ clusters.sort((a, b) => b.edgeCount - a.edgeCount);
246
+ return clusters;
247
+ }
248
+
249
+ /**
250
+ * Search for related prior art across all sprints and packs.
251
+ * Combines text matching with graph traversal to find claims
252
+ * relevant to a query, ranked by combined text + graph relevance.
253
+ *
254
+ * @param {string} query - Free-text search query
255
+ * @param {object} opts
256
+ * @param {number} opts.limit - Max results (default: 20)
257
+ * @param {string} opts.type - Filter by claim type
258
+ * @param {string} opts.sourceType - Filter by source type ('collection' or 'pack')
259
+ * @returns {object[]} Ranked results with claim, source, score, and relations
260
+ */
261
+ search(query, opts = {}) {
262
+ this._ensureBuilt();
263
+ const { limit = 20, type, sourceType } = opts;
264
+ const tokens = query
265
+ .toLowerCase()
266
+ .split(/\s+/)
267
+ .filter((t) => t.length > 1);
268
+ if (tokens.length === 0) return [];
269
+
270
+ const scored = new Map(); // claimKey -> { node, textScore, graphScore }
271
+
272
+ // Phase 1: Text matching across all nodes
273
+ for (const [key, node] of this._nodes) {
274
+ if (type && node.claim.type !== type) continue;
275
+ if (sourceType && node.sourceType !== sourceType) continue;
276
+
277
+ const searchable = [
278
+ node.claim.topic || "",
279
+ node.claim.content || "",
280
+ (node.claim.tags || []).join(" "),
281
+ node.source,
282
+ ]
283
+ .join(" ")
284
+ .toLowerCase();
285
+
286
+ let textScore = 0;
287
+ for (const token of tokens) {
288
+ if (searchable.includes(token)) {
289
+ textScore += 1;
290
+ // Bonus for topic match
291
+ if ((node.claim.topic || "").toLowerCase().includes(token)) {
292
+ textScore += 0.5;
293
+ }
294
+ // Bonus for tag match
295
+ if (
296
+ (node.claim.tags || []).some((t) => t.toLowerCase().includes(token))
297
+ ) {
298
+ textScore += 0.3;
299
+ }
300
+ }
301
+ }
302
+
303
+ if (textScore > 0) {
304
+ scored.set(key, { node, textScore, graphScore: 0 });
305
+ }
306
+ }
307
+
308
+ // Phase 2: Graph boost — text matches with many graph connections score higher
309
+ for (const [key, entry] of scored) {
310
+ let graphScore = 0;
311
+ for (const edge of this._edges) {
312
+ if (edge.source === key || edge.target === key) {
313
+ const neighborKey = edge.source === key ? edge.target : edge.source;
314
+ // Boost if neighbor also matched text search
315
+ if (scored.has(neighborKey)) {
316
+ graphScore += edge.weight * 0.5;
317
+ } else {
318
+ graphScore += edge.weight * 0.1;
319
+ }
320
+ }
321
+ }
322
+ entry.graphScore = graphScore;
323
+ }
324
+
325
+ // Combine and rank
326
+ const results = [];
327
+ for (const [key, entry] of scored) {
328
+ const combinedScore = entry.textScore + entry.graphScore;
329
+ results.push({
330
+ claim: entry.node.claim,
331
+ source: entry.node.source,
332
+ sourceType: entry.node.sourceType,
333
+ score: Math.round(combinedScore * 100) / 100,
334
+ textScore: Math.round(entry.textScore * 100) / 100,
335
+ graphScore: Math.round(entry.graphScore * 100) / 100,
336
+ });
337
+ }
338
+
339
+ results.sort((a, b) => b.score - a.score);
340
+ return results.slice(0, limit);
341
+ }
342
+
343
+ /**
344
+ * Export the graph as a portable JSON structure.
345
+ */
346
+ toJSON() {
347
+ this._ensureBuilt();
348
+ const nodes = [];
349
+ for (const [key, node] of this._nodes) {
350
+ nodes.push({
351
+ key,
352
+ id: node.claim.id,
353
+ type: node.claim.type,
354
+ topic: node.claim.topic,
355
+ source: node.source,
356
+ sourceType: node.sourceType,
357
+ tags: node.claim.tags || [],
358
+ });
359
+ }
360
+ return {
361
+ nodes,
362
+ edges: this._edges,
363
+ stats: this.stats(),
364
+ builtAt: new Date().toISOString(),
365
+ };
366
+ }
367
+
368
+ // ── Internals ──────────────────────────────────────────────────────────────
369
+
370
+ _indexClaims(claims, source, sourceType) {
371
+ for (const claim of claims) {
372
+ const key = `${source}:${claim.id || ""}`;
373
+ if (this._nodes.has(key)) continue;
374
+
375
+ this._nodes.set(key, { claim, source, sourceType });
376
+
377
+ // Index by topic
378
+ if (claim.topic) {
379
+ const topic = claim.topic.toLowerCase().trim();
380
+ if (!this._topicIndex.has(topic)) this._topicIndex.set(topic, []);
381
+ this._topicIndex.get(topic).push(key);
382
+ }
383
+
384
+ // Index by tags
385
+ for (const tag of claim.tags || []) {
386
+ const t = tag.toLowerCase();
387
+ if (!this._tagIndex.has(t)) this._tagIndex.set(t, []);
388
+ this._tagIndex.get(t).push(key);
389
+ }
390
+ }
391
+ }
392
+
393
+ _addEdge(source, target, relation, weight) {
394
+ // Avoid duplicate edges
395
+ const exists = this._edges.some(
396
+ (e) =>
397
+ (e.source === source &&
398
+ e.target === target &&
399
+ e.relation === relation) ||
400
+ (e.source === target && e.target === source && e.relation === relation),
401
+ );
402
+ if (!exists) {
403
+ this._edges.push({ source, target, relation, weight });
404
+ }
405
+ }
406
+
407
+ _ensureBuilt() {
408
+ if (!this._built) {
409
+ this.build();
410
+ }
411
+ }
412
+ }
413
+
414
+ module.exports = { Graph };
@@ -5,9 +5,9 @@
5
5
  * (or built-in pack) and merge them into a sprint's claims.json.
6
6
  */
7
7
 
8
- const fs = require('node:fs');
9
- const path = require('node:path');
10
- const { Store } = require('./store.js');
8
+ const fs = require("node:fs");
9
+ const path = require("node:path");
10
+ const { Store } = require("./store.js");
11
11
 
12
12
  /**
13
13
  * Normalize a claim to wheat's canonical schema.
@@ -18,33 +18,36 @@ function normalizeClaim(claim) {
18
18
  const normalized = { ...claim };
19
19
 
20
20
  // Map legacy field names to wheat-canonical names
21
- if ('tier' in normalized && !('evidence' in normalized)) {
21
+ if ("tier" in normalized && !("evidence" in normalized)) {
22
22
  normalized.evidence = normalized.tier;
23
23
  }
24
24
  delete normalized.tier;
25
25
 
26
- if ('text' in normalized && !('content' in normalized)) {
26
+ if ("text" in normalized && !("content" in normalized)) {
27
27
  normalized.content = normalized.text;
28
28
  }
29
29
  delete normalized.text;
30
30
 
31
31
  // Ensure all required wheat fields exist
32
32
  normalized.id = normalized.id || null;
33
- normalized.type = normalized.type || 'factual';
34
- normalized.topic = normalized.topic || '';
35
- normalized.content = normalized.content || '';
36
- normalized.evidence = normalized.evidence || 'stated';
37
- normalized.status = normalized.status || 'active';
38
- normalized.phase_added = normalized.phase_added || 'import';
33
+ normalized.type = normalized.type || "factual";
34
+ normalized.topic = normalized.topic || "";
35
+ normalized.content = normalized.content || "";
36
+ normalized.evidence = normalized.evidence || "stated";
37
+ normalized.status = normalized.status || "active";
38
+ normalized.phase_added = normalized.phase_added || "import";
39
39
  normalized.timestamp = normalized.timestamp || new Date().toISOString();
40
40
  normalized.conflicts_with = normalized.conflicts_with || [];
41
41
  normalized.resolved_by = normalized.resolved_by || null;
42
42
  normalized.tags = normalized.tags || [];
43
43
 
44
44
  // Normalize source to object form
45
- if (!normalized.source || typeof normalized.source === 'string') {
45
+ if (!normalized.source || typeof normalized.source === "string") {
46
46
  normalized.source = {
47
- origin: typeof normalized.source === 'string' ? normalized.source : 'silo-import',
47
+ origin:
48
+ typeof normalized.source === "string"
49
+ ? normalized.source
50
+ : "silo-import",
48
51
  artifact: null,
49
52
  connector: null,
50
53
  };
@@ -71,7 +74,7 @@ class ImportExport {
71
74
  * @param {boolean} opts.dryRun - If true, return what would be imported without writing
72
75
  */
73
76
  pull(source, targetPath, opts = {}) {
74
- const { prefix = 'imp', types, ids, dryRun = false } = opts;
77
+ const { prefix = "imp", types, ids, dryRun = false } = opts;
75
78
 
76
79
  // Resolve source: try silo store first, then built-in packs
77
80
  let sourceClaims = this._resolveSource(source);
@@ -96,7 +99,7 @@ class ImportExport {
96
99
  // Re-prefix claim IDs
97
100
  const imported = sourceClaims.map((claim, i) => ({
98
101
  ...claim,
99
- id: `${prefix}${String(i + 1).padStart(3, '0')}`,
102
+ id: `${prefix}${String(i + 1).padStart(3, "0")}`,
100
103
  importedFrom: source,
101
104
  importedAt: new Date().toISOString(),
102
105
  }));
@@ -108,25 +111,27 @@ class ImportExport {
108
111
  // Read existing target or create empty
109
112
  let existing = [];
110
113
  if (fs.existsSync(targetPath)) {
111
- const raw = JSON.parse(fs.readFileSync(targetPath, 'utf-8'));
114
+ const raw = JSON.parse(fs.readFileSync(targetPath, "utf-8"));
112
115
  existing = Array.isArray(raw) ? raw : raw.claims || [];
113
116
  }
114
117
 
115
118
  // Deduplicate by content (wheat-canonical field)
116
119
  const existingTexts = new Set(
117
- existing.map((c) => (c.content || c.text || '').toLowerCase()),
120
+ existing.map((c) => (c.content || c.text || "").toLowerCase()),
118
121
  );
119
122
  const deduped = imported.filter(
120
- (c) => !existingTexts.has((c.content || '').toLowerCase()),
123
+ (c) => !existingTexts.has((c.content || "").toLowerCase()),
121
124
  );
122
125
 
123
126
  const merged = [...existing, ...deduped];
124
- const output = Array.isArray(JSON.parse(fs.readFileSync(targetPath, 'utf-8') || '[]'))
127
+ const output = Array.isArray(
128
+ JSON.parse(fs.readFileSync(targetPath, "utf-8") || "[]"),
129
+ )
125
130
  ? merged
126
131
  : { claims: merged };
127
132
 
128
- const tmp = targetPath + '.tmp.' + process.pid;
129
- fs.writeFileSync(tmp, JSON.stringify(output, null, 2) + '\n', 'utf-8');
133
+ const tmp = targetPath + ".tmp." + process.pid;
134
+ fs.writeFileSync(tmp, JSON.stringify(output, null, 2) + "\n", "utf-8");
130
135
  fs.renameSync(tmp, targetPath);
131
136
 
132
137
  return {
@@ -148,7 +153,7 @@ class ImportExport {
148
153
  throw new Error(`Claims file not found: ${sourcePath}`);
149
154
  }
150
155
 
151
- const raw = JSON.parse(fs.readFileSync(sourcePath, 'utf-8'));
156
+ const raw = JSON.parse(fs.readFileSync(sourcePath, "utf-8"));
152
157
  const claims = Array.isArray(raw) ? raw : raw.claims || [];
153
158
 
154
159
  return this.store.storeClaims(name, claims, meta);
@@ -161,9 +166,9 @@ class ImportExport {
161
166
  if (stored) return stored.claims;
162
167
 
163
168
  // Try built-in packs
164
- const packPath = path.join(__dirname, '..', 'packs', `${source}.json`);
169
+ const packPath = path.join(__dirname, "..", "packs", `${source}.json`);
165
170
  if (fs.existsSync(packPath)) {
166
- const pack = JSON.parse(fs.readFileSync(packPath, 'utf-8'));
171
+ const pack = JSON.parse(fs.readFileSync(packPath, "utf-8"));
167
172
  return pack.claims || [];
168
173
  }
169
174
 
package/lib/index.js CHANGED
@@ -4,18 +4,22 @@
4
4
  * Re-exports all public modules so consumers can import from a single path.
5
5
  */
6
6
 
7
- const { readFileSync } = require('node:fs');
8
- const { join } = require('node:path');
9
- const { Store, DEFAULT_SILO_DIR } = require('./store.js');
10
- const { Search } = require('./search.js');
11
- const { Packs } = require('./packs.js');
12
- const { ImportExport } = require('./import-export.js');
13
- const { Templates } = require('./templates.js');
7
+ const { readFileSync } = require("node:fs");
8
+ const { join } = require("node:path");
9
+ const { Store, DEFAULT_SILO_DIR } = require("./store.js");
10
+ const { Search } = require("./search.js");
11
+ const { Packs } = require("./packs.js");
12
+ const { ImportExport } = require("./import-export.js");
13
+ const { Templates } = require("./templates.js");
14
+ const { Graph } = require("./graph.js");
15
+ const { Confluence } = require("./confluence.js");
14
16
 
15
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
17
+ const pkg = JSON.parse(
18
+ readFileSync(join(__dirname, "..", "package.json"), "utf-8"),
19
+ );
16
20
 
17
21
  module.exports = {
18
- name: 'silo',
22
+ name: "silo",
19
23
  version: pkg.version,
20
24
  description: pkg.description,
21
25
 
@@ -25,4 +29,6 @@ module.exports = {
25
29
  Packs,
26
30
  ImportExport,
27
31
  Templates,
32
+ Graph,
33
+ Confluence,
28
34
  };