@grainulation/silo 1.0.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/lib/server.js ADDED
@@ -0,0 +1,425 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * silo serve -- local HTTP server for the silo knowledge browser UI
4
+ *
5
+ * Two-column pack browser with search, import wizard, and SSE live updates.
6
+ * Zero npm dependencies (node:http only).
7
+ *
8
+ * Usage:
9
+ * silo serve [--port 9095] [--root /path/to/repo]
10
+ */
11
+
12
+ import { createServer } from 'node:http';
13
+ import { readFileSync, existsSync, readdirSync, writeFileSync, statSync } from 'node:fs';
14
+ import { join, resolve, extname, dirname } from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const require = createRequire(import.meta.url);
20
+
21
+ // ── Crash handlers ──
22
+ process.on('uncaughtException', (err) => {
23
+ process.stderr.write(`[${new Date().toISOString()}] FATAL: ${err.stack || err}\n`);
24
+ process.exit(1);
25
+ });
26
+ process.on('unhandledRejection', (reason) => {
27
+ process.stderr.write(`[${new Date().toISOString()}] WARN unhandledRejection: ${reason}\n`);
28
+ });
29
+
30
+ const PUBLIC_DIR = join(__dirname, '..', 'public');
31
+ const PACKS_DIR = join(__dirname, '..', 'packs');
32
+
33
+ // ── CLI args ──────────────────────────────────────────────────────────────────
34
+
35
+ const args = process.argv.slice(2);
36
+ function arg(name, fallback) {
37
+ const i = args.indexOf(`--${name}`);
38
+ return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
39
+ }
40
+
41
+ const PORT = parseInt(arg('port', '9095'), 10);
42
+ const ROOT = resolve(arg('root', process.cwd()));
43
+ const CORS_ORIGIN = arg('cors', null);
44
+
45
+ // ── Verbose logging ──────────────────────────────────────────────────────────
46
+
47
+ const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
48
+ function vlog(...a) {
49
+ if (!verbose) return;
50
+ const ts = new Date().toISOString();
51
+ process.stderr.write(`[${ts}] silo: ${a.join(' ')}\n`);
52
+ }
53
+
54
+ // ── Routes manifest ──────────────────────────────────────────────────────────
55
+
56
+ const ROUTES = [
57
+ { method: 'GET', path: '/health', description: 'Health check endpoint' },
58
+ { method: 'GET', path: '/events', description: 'SSE event stream for live updates' },
59
+ { method: 'GET', path: '/api/packs', description: 'List all available knowledge packs' },
60
+ { method: 'GET', path: '/api/packs/:name', description: 'Get pack details and claims by name' },
61
+ { method: 'POST', path: '/api/import', description: 'Import a pack into a target claims file' },
62
+ { method: 'GET', path: '/api/search', description: 'Search claims by ?q, ?type, ?evidence, ?limit' },
63
+ { method: 'POST', path: '/api/refresh', description: 'Reload packs from disk' },
64
+ { method: 'GET', path: '/api/docs', description: 'This API documentation page' },
65
+ ];
66
+
67
+ // ── Load existing CJS modules via createRequire ──────────────────────────────
68
+
69
+ const { Store } = require('./store.js');
70
+ const { Search } = require('./search.js');
71
+ const { Packs } = require('./packs.js');
72
+ const { ImportExport } = require('./import-export.js');
73
+
74
+ const store = new Store();
75
+ const search = new Search(store);
76
+ const packs = new Packs(store);
77
+ const io = new ImportExport(store);
78
+
79
+ // ── State ─────────────────────────────────────────────────────────────────────
80
+
81
+ let state = {
82
+ packs: [],
83
+ searchResults: [],
84
+ };
85
+
86
+ const sseClients = new Set();
87
+
88
+ function broadcast(event) {
89
+ const data = `data: ${JSON.stringify(event)}\n\n`;
90
+ for (const res of sseClients) {
91
+ try { res.write(data); } catch { sseClients.delete(res); }
92
+ }
93
+ }
94
+
95
+ // ── Data loading ──────────────────────────────────────────────────────────────
96
+
97
+ function loadPacks() {
98
+ return packs.list();
99
+ }
100
+
101
+ function refreshState() {
102
+ state.packs = loadPacks();
103
+ broadcast({ type: 'state', data: state });
104
+ }
105
+
106
+ // ── MIME types ────────────────────────────────────────────────────────────────
107
+
108
+ const MIME = {
109
+ '.html': 'text/html; charset=utf-8',
110
+ '.css': 'text/css; charset=utf-8',
111
+ '.js': 'application/javascript; charset=utf-8',
112
+ '.json': 'application/json; charset=utf-8',
113
+ '.svg': 'image/svg+xml',
114
+ '.png': 'image/png',
115
+ };
116
+
117
+ // ── Helpers ───────────────────────────────────────────────────────────────────
118
+
119
+ function jsonResponse(res, code, data) {
120
+ res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
121
+ res.end(JSON.stringify(data));
122
+ }
123
+
124
+ function readBody(req) {
125
+ return new Promise((resolve, reject) => {
126
+ const chunks = [];
127
+ let size = 0;
128
+ req.on('data', c => { size += c.length; if (size > 1048576) { resolve(null); req.destroy(); return; } chunks.push(c); });
129
+ req.on('end', () => {
130
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
131
+ catch { resolve(null); }
132
+ });
133
+ req.on('error', () => resolve(null));
134
+ });
135
+ }
136
+
137
+ // ── HTTP server ───────────────────────────────────────────────────────────────
138
+
139
+ const server = createServer(async (req, res) => {
140
+ const url = new URL(req.url, `http://localhost:${PORT}`);
141
+
142
+ // CORS (only when --cors is passed)
143
+ if (CORS_ORIGIN) {
144
+ res.setHeader('Access-Control-Allow-Origin', CORS_ORIGIN);
145
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
146
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
147
+ }
148
+
149
+ if (req.method === 'OPTIONS' && CORS_ORIGIN) {
150
+ res.writeHead(204);
151
+ res.end();
152
+ return;
153
+ }
154
+
155
+ vlog('request', req.method, url.pathname);
156
+
157
+ // ── API: docs ──
158
+ if (req.method === 'GET' && url.pathname === '/api/docs') {
159
+ const html = `<!DOCTYPE html><html><head><title>silo API</title>
160
+ <style>body{font-family:system-ui;background:#0a0e1a;color:#e8ecf1;max-width:800px;margin:40px auto;padding:0 20px}
161
+ table{width:100%;border-collapse:collapse}th,td{padding:8px 12px;border-bottom:1px solid #1e293b;text-align:left}
162
+ th{color:#9ca3af}code{background:#1e293b;padding:2px 6px;border-radius:4px;font-size:13px}</style></head>
163
+ <body><h1>silo API</h1><p>${ROUTES.length} endpoints</p>
164
+ <table><tr><th>Method</th><th>Path</th><th>Description</th></tr>
165
+ ${ROUTES.map(r => '<tr><td><code>'+r.method+'</code></td><td><code>'+r.path+'</code></td><td>'+r.description+'</td></tr>').join('')}
166
+ </table></body></html>`;
167
+ res.writeHead(200, { 'Content-Type': 'text/html' });
168
+ res.end(html);
169
+ return;
170
+ }
171
+
172
+ // ── Health check ──
173
+ if (req.method === 'GET' && url.pathname === '/health') {
174
+ jsonResponse(res, 200, { status: 'ok', uptime: process.uptime(), packs: state.packs.length });
175
+ return;
176
+ }
177
+
178
+ // ── SSE endpoint ──
179
+ if (req.method === 'GET' && url.pathname === '/events') {
180
+ res.writeHead(200, {
181
+ 'Content-Type': 'text/event-stream',
182
+ 'Cache-Control': 'no-cache',
183
+ 'Connection': 'keep-alive',
184
+ });
185
+ res.write(`data: ${JSON.stringify({ type: 'state', data: state })}\n\n`);
186
+ const heartbeat = setInterval(() => {
187
+ try { res.write(': heartbeat\n\n'); } catch { clearInterval(heartbeat); }
188
+ }, 15000);
189
+ sseClients.add(res);
190
+ vlog('sse', `client connected (${sseClients.size} total)`);
191
+ req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); vlog('sse', `client disconnected (${sseClients.size} total)`); });
192
+ return;
193
+ }
194
+
195
+ // ── API: list packs ──
196
+ if (req.method === 'GET' && url.pathname === '/api/packs') {
197
+ const packList = loadPacks();
198
+ jsonResponse(res, 200, { packs: packList });
199
+ return;
200
+ }
201
+
202
+ // ── API: get pack details ──
203
+ if (req.method === 'GET' && url.pathname.startsWith('/api/packs/')) {
204
+ const name = decodeURIComponent(url.pathname.slice('/api/packs/'.length));
205
+ if (!name) { jsonResponse(res, 400, { error: 'missing pack name' }); return; }
206
+
207
+ const pack = packs.get(name);
208
+ if (!pack) { jsonResponse(res, 404, { error: `pack "${name}" not found` }); return; }
209
+
210
+ jsonResponse(res, 200, {
211
+ id: name,
212
+ name: pack.name,
213
+ description: pack.description,
214
+ version: pack.version,
215
+ claimCount: (pack.claims || []).length,
216
+ claims: pack.claims || [],
217
+ });
218
+ return;
219
+ }
220
+
221
+ // ── API: import pack ──
222
+ if (req.method === 'POST' && url.pathname === '/api/import') {
223
+ try {
224
+ const body = await readBody(req);
225
+ const { pack: packName, targetDir } = body;
226
+ if (!packName || !targetDir) {
227
+ jsonResponse(res, 400, { error: 'missing pack or targetDir' });
228
+ return;
229
+ }
230
+
231
+ const absTarget = resolve(targetDir);
232
+ if (!absTarget.startsWith(ROOT)) {
233
+ jsonResponse(res, 400, { error: 'target directory must be within the project root' });
234
+ return;
235
+ }
236
+
237
+ const targetPath = resolve(absTarget, 'claims.json');
238
+ // Ensure the target file exists
239
+ if (!existsSync(targetPath)) {
240
+ writeFileSync(targetPath, '[]', 'utf-8');
241
+ }
242
+
243
+ const result = io.pull(packName, targetPath);
244
+ refreshState();
245
+ jsonResponse(res, 200, {
246
+ success: true,
247
+ imported: result.imported,
248
+ skippedDuplicates: result.skippedDuplicates,
249
+ totalClaims: result.totalClaims,
250
+ });
251
+ } catch (err) {
252
+ jsonResponse(res, 500, { error: err.message });
253
+ }
254
+ return;
255
+ }
256
+
257
+ // ── API: search ──
258
+ if (req.method === 'GET' && url.pathname === '/api/search') {
259
+ const q = url.searchParams.get('q') || '';
260
+ const type = url.searchParams.get('type') || undefined;
261
+ const evidence = url.searchParams.get('evidence') || undefined;
262
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
263
+
264
+ if (!q) {
265
+ jsonResponse(res, 200, { query: q, results: [] });
266
+ return;
267
+ }
268
+
269
+ // Search built-in packs too
270
+ const results = [];
271
+
272
+ // Search silo store
273
+ const storeResults = search.query(q, { type, evidence, limit });
274
+ results.push(...storeResults);
275
+
276
+ // Also search directly in packs/ directory
277
+ if (existsSync(PACKS_DIR)) {
278
+ for (const file of readdirSync(PACKS_DIR)) {
279
+ if (!file.endsWith('.json')) continue;
280
+ try {
281
+ const data = JSON.parse(readFileSync(join(PACKS_DIR, file), 'utf-8'));
282
+ const packName = data.name || file.replace('.json', '');
283
+ for (const claim of data.claims || []) {
284
+ if (type && claim.type !== type) continue;
285
+ if (evidence && claim.evidence !== evidence) continue;
286
+ const searchable = [
287
+ claim.content || claim.text || '',
288
+ claim.type || '',
289
+ claim.topic || '',
290
+ (claim.tags || []).join(' '),
291
+ ].join(' ').toLowerCase();
292
+
293
+ const tokens = q.toLowerCase().split(/\s+/).filter(t => t.length > 1);
294
+ let score = 0;
295
+ for (const token of tokens) {
296
+ if (searchable.includes(token)) {
297
+ score += 1;
298
+ if (searchable.includes(` ${token} `) || searchable.startsWith(`${token} `)) {
299
+ score += 0.5;
300
+ }
301
+ }
302
+ }
303
+ if (score > 0) {
304
+ results.push({ claim, collection: `pack:${packName}`, score });
305
+ }
306
+ }
307
+ } catch { /* skip malformed */ }
308
+ }
309
+ }
310
+
311
+ // Deduplicate by claim ID, keep highest score
312
+ const seen = new Map();
313
+ for (const r of results) {
314
+ const key = r.claim.id || JSON.stringify(r.claim);
315
+ if (!seen.has(key) || seen.get(key).score < r.score) {
316
+ seen.set(key, r);
317
+ }
318
+ }
319
+
320
+ const deduped = [...seen.values()]
321
+ .sort((a, b) => b.score - a.score)
322
+ .slice(0, limit);
323
+
324
+ jsonResponse(res, 200, { query: q, results: deduped });
325
+ return;
326
+ }
327
+
328
+ // ── API: refresh ──
329
+ if (req.method === 'POST' && url.pathname === '/api/refresh') {
330
+ refreshState();
331
+ jsonResponse(res, 200, state);
332
+ return;
333
+ }
334
+
335
+ // ── Static files ──
336
+ let filePath = url.pathname === '/' ? '/index.html' : url.pathname;
337
+ const resolved = resolve(PUBLIC_DIR, '.' + filePath);
338
+ if (!resolved.startsWith(PUBLIC_DIR)) {
339
+ res.writeHead(403);
340
+ res.end('forbidden');
341
+ return;
342
+ }
343
+
344
+ if (existsSync(resolved) && statSync(resolved).isFile()) {
345
+ const ext = extname(resolved);
346
+ const mime = MIME[ext] || 'application/octet-stream';
347
+ try {
348
+ const content = readFileSync(resolved);
349
+ res.writeHead(200, { 'Content-Type': mime });
350
+ res.end(content);
351
+ } catch {
352
+ res.writeHead(500);
353
+ res.end('read error');
354
+ }
355
+ return;
356
+ }
357
+
358
+ // ── 404 ──
359
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
360
+ res.end('not found');
361
+ });
362
+
363
+ // ── File watching (fingerprint-based polling) ─────────────────────────────────
364
+
365
+ let lastFingerprint = '';
366
+ function computeFingerprint() {
367
+ const parts = [];
368
+ try {
369
+ if (existsSync(PACKS_DIR)) {
370
+ for (const file of readdirSync(PACKS_DIR)) {
371
+ if (!file.endsWith('.json')) continue;
372
+ try { const s = statSync(join(PACKS_DIR, file)); parts.push(file + ':' + s.mtimeMs); } catch { /* skip */ }
373
+ }
374
+ }
375
+ } catch { /* skip */ }
376
+ return parts.join('|');
377
+ }
378
+
379
+ function startWatcher() {
380
+ lastFingerprint = computeFingerprint();
381
+ setInterval(() => {
382
+ const fp = computeFingerprint();
383
+ if (fp !== lastFingerprint) {
384
+ lastFingerprint = fp;
385
+ refreshState();
386
+ vlog('watcher', 'packs changed, state refreshed');
387
+ }
388
+ }, 5000);
389
+ }
390
+
391
+ // ── Graceful shutdown ─────────────────────────────────────────────────────────
392
+ const shutdown = (signal) => {
393
+ console.log(`\nsilo: ${signal} received, shutting down...`);
394
+ for (const res of sseClients) { try { res.end(); } catch {} }
395
+ sseClients.clear();
396
+ server.close(() => process.exit(0));
397
+ setTimeout(() => process.exit(1), 5000);
398
+ };
399
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
400
+ process.on('SIGINT', () => shutdown('SIGINT'));
401
+
402
+ // ── Start ─────────────────────────────────────────────────────────────────────
403
+
404
+ refreshState();
405
+ startWatcher();
406
+
407
+ server.on('error', (err) => {
408
+ if (err.code === 'EADDRINUSE') {
409
+ console.error(`silo: port ${PORT} is already in use. Try --port <other>.`);
410
+ } else if (err.code === 'EACCES') {
411
+ console.error(`silo: port ${PORT} requires elevated privileges.`);
412
+ } else {
413
+ console.error(`silo: server error: ${err.message}`);
414
+ }
415
+ process.exit(1);
416
+ });
417
+
418
+ server.listen(PORT, '127.0.0.1', () => {
419
+ vlog('listen', `port=${PORT}`, `root=${ROOT}`);
420
+ console.log(`silo: serving on http://localhost:${PORT}`);
421
+ console.log(` packs: ${state.packs.length} available`);
422
+ console.log(` root: ${ROOT}`);
423
+ });
424
+
425
+ export { server, PORT };
package/lib/store.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * store.js — Local claim/template storage (filesystem-based)
3
+ *
4
+ * Silo stores everything as JSON files in a local directory (~/.silo by default).
5
+ * No database, no dependencies — just the filesystem.
6
+ */
7
+
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const crypto = require('node:crypto');
11
+
12
+ const DEFAULT_SILO_DIR = path.join(require('node:os').homedir(), '.silo');
13
+
14
+ class Store {
15
+ constructor(siloDir = DEFAULT_SILO_DIR) {
16
+ this.root = siloDir;
17
+ this.claimsDir = path.join(siloDir, 'claims');
18
+ this.templatesDir = path.join(siloDir, 'templates');
19
+ this.packsDir = path.join(siloDir, 'packs');
20
+ this.indexPath = path.join(siloDir, 'index.json');
21
+ }
22
+
23
+ /** Ensure the silo directory structure exists. */
24
+ init() {
25
+ for (const dir of [this.root, this.claimsDir, this.templatesDir, this.packsDir]) {
26
+ if (!fs.existsSync(dir)) {
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ }
29
+ }
30
+ if (!fs.existsSync(this.indexPath)) {
31
+ this._writeJSON(this.indexPath, {
32
+ version: 1,
33
+ created: new Date().toISOString(),
34
+ collections: [],
35
+ });
36
+ }
37
+ return this;
38
+ }
39
+
40
+ /** Store a collection of claims under a name. */
41
+ storeClaims(name, claims, meta = {}) {
42
+ this.init();
43
+ const id = this._slugify(name);
44
+ const entry = {
45
+ id,
46
+ name,
47
+ type: 'claims',
48
+ claimCount: claims.length,
49
+ hash: this._hash(JSON.stringify(claims)),
50
+ storedAt: new Date().toISOString(),
51
+ ...meta,
52
+ };
53
+ const filePath = path.join(this.claimsDir, `${id}.json`);
54
+ this._writeJSON(filePath, { meta: entry, claims });
55
+ this._addToIndex(entry);
56
+ return entry;
57
+ }
58
+
59
+ /** Retrieve claims by collection name/id. Verifies integrity if hash exists. */
60
+ getClaims(nameOrId) {
61
+ const id = this._slugify(nameOrId);
62
+ const filePath = path.join(this.claimsDir, `${id}.json`);
63
+ if (!fs.existsSync(filePath)) return null;
64
+ const data = this._readJSON(filePath);
65
+ if (data.meta && data.meta.hash && data.claims) {
66
+ const actual = this._hash(JSON.stringify(data.claims));
67
+ // Support both old 12-char and new 64-char hashes
68
+ if (data.meta.hash !== actual && !actual.startsWith(data.meta.hash)) {
69
+ data._integrityWarning = `Hash mismatch: expected ${data.meta.hash.slice(0, 12)}..., got ${actual.slice(0, 12)}...`;
70
+ }
71
+ }
72
+ return data;
73
+ }
74
+
75
+ /** Verify integrity of all stored collections. Returns array of {id, ok, warning?}. */
76
+ verifyAll() {
77
+ this.init();
78
+ const results = [];
79
+ const index = this._readJSON(this.indexPath);
80
+ for (const entry of (index.collections || [])) {
81
+ const data = this.getClaims(entry.id);
82
+ if (!data) {
83
+ results.push({ id: entry.id, ok: false, warning: 'Collection file missing' });
84
+ } else {
85
+ results.push({ id: entry.id, ok: !data._integrityWarning, warning: data._integrityWarning || null });
86
+ }
87
+ }
88
+ return results;
89
+ }
90
+
91
+ /** List all stored collections. */
92
+ list() {
93
+ this.init();
94
+ const index = this._readJSON(this.indexPath);
95
+ return index.collections || [];
96
+ }
97
+
98
+ /** Remove a collection by name/id. */
99
+ remove(nameOrId) {
100
+ const id = this._slugify(nameOrId);
101
+ for (const dir of [this.claimsDir, this.templatesDir, this.packsDir]) {
102
+ const filePath = path.join(dir, `${id}.json`);
103
+ if (fs.existsSync(filePath)) {
104
+ fs.unlinkSync(filePath);
105
+ }
106
+ }
107
+ this._removeFromIndex(id);
108
+ return true;
109
+ }
110
+
111
+ // --- Internal helpers ---
112
+
113
+ _readJSON(filePath) {
114
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
115
+ }
116
+
117
+ _writeJSON(filePath, data) {
118
+ const tmp = filePath + '.tmp.' + process.pid;
119
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8');
120
+ fs.renameSync(tmp, filePath);
121
+ }
122
+
123
+ _hash(str) {
124
+ return crypto.createHash('sha256').update(str).digest('hex');
125
+ }
126
+
127
+ _slugify(str) {
128
+ return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
129
+ }
130
+
131
+ _addToIndex(entry) {
132
+ const index = this._readJSON(this.indexPath);
133
+ index.collections = index.collections.filter((c) => c.id !== entry.id);
134
+ index.collections.push(entry);
135
+ this._writeJSON(this.indexPath, index);
136
+ }
137
+
138
+ _removeFromIndex(id) {
139
+ const index = this._readJSON(this.indexPath);
140
+ index.collections = index.collections.filter((c) => c.id !== id);
141
+ this._writeJSON(this.indexPath, index);
142
+ }
143
+ }
144
+
145
+ module.exports = { Store, DEFAULT_SILO_DIR };