stellavault 0.7.3 → 0.7.4

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.
@@ -4,12 +4,6 @@ const require = createRequire(import.meta.url);
4
4
 
5
5
  var __defProp = Object.defineProperty;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
8
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
9
- }) : x)(function(x) {
10
- if (typeof require !== "undefined") return require.apply(this, arguments);
11
- throw Error('Dynamic require of "' + x + '" is not supported');
12
- });
13
7
  var __esm = (fn, res) => function __init() {
14
8
  return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
15
9
  };
@@ -88,17 +82,33 @@ import { createHash } from "node:crypto";
88
82
  import matter from "gray-matter";
89
83
  function scanVault(vaultPath) {
90
84
  const documents = [];
91
- let skippedFiles = 0;
85
+ const skipped = [];
92
86
  const mdFiles = findMdFiles(vaultPath);
93
87
  for (const filePath of mdFiles) {
88
+ const rel = relative(vaultPath, filePath).replace(/\\/g, "/");
94
89
  try {
90
+ const stat = statSync(filePath);
91
+ if (stat.size === 0) {
92
+ skipped.push({ path: rel, reason: "empty" });
93
+ continue;
94
+ }
95
+ if (stat.size > MAX_FILE_BYTES) {
96
+ skipped.push({ path: rel, reason: "too-large", detail: `${stat.size}B` });
97
+ continue;
98
+ }
95
99
  const doc = parseDocument(vaultPath, filePath);
100
+ if (!doc.content || doc.content.trim().length === 0) {
101
+ skipped.push({ path: rel, reason: "empty", detail: "no content after frontmatter" });
102
+ continue;
103
+ }
96
104
  documents.push(doc);
97
- } catch {
98
- skippedFiles++;
105
+ } catch (err) {
106
+ const msg = err?.message ?? String(err);
107
+ const reason = /ENOENT|EACCES|EPERM/.test(msg) ? "unreadable" : "parse-error";
108
+ skipped.push({ path: rel, reason, detail: msg.slice(0, 200) });
99
109
  }
100
110
  }
101
- return { documents, scannedFiles: mdFiles.length, skippedFiles };
111
+ return { documents, scannedFiles: mdFiles.length, skippedFiles: skipped.length, skipped };
102
112
  }
103
113
  function findMdFiles(dir, files = []) {
104
114
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -209,9 +219,11 @@ function extractTags(frontmatter, content) {
209
219
  }
210
220
  return [...tags];
211
221
  }
222
+ var MAX_FILE_BYTES;
212
223
  var init_scanner = __esm({
213
224
  "packages/core/dist/indexer/scanner.js"() {
214
225
  "use strict";
226
+ MAX_FILE_BYTES = 5 * 1024 * 1024;
215
227
  }
216
228
  });
217
229
 
@@ -419,28 +431,60 @@ var init_retry = __esm({
419
431
  });
420
432
 
421
433
  // packages/core/dist/indexer/local-embedder.js
434
+ function getPipeline(modelName) {
435
+ let p = pipelineCache.get(modelName);
436
+ if (p)
437
+ return p;
438
+ p = (async () => {
439
+ const { pipeline: createPipeline } = await import("@xenova/transformers");
440
+ return createPipeline("feature-extraction", `Xenova/${modelName}`, { quantized: true });
441
+ })();
442
+ pipelineCache.set(modelName, p);
443
+ return p;
444
+ }
422
445
  function createLocalEmbedder(modelName = "nomic-embed-text-v1.5") {
423
446
  let pipeline;
424
447
  let dims = modelName.includes("MiniLM") ? 384 : 768;
448
+ const profile = process.env.STELLAVAULT_PROFILE_MEMORY === "1";
449
+ let callCount = 0;
425
450
  return {
426
451
  async initialize() {
427
- const { pipeline: createPipeline } = await import("@xenova/transformers");
428
- pipeline = await createPipeline("feature-extraction", `Xenova/${modelName}`, {
429
- quantized: true
430
- });
452
+ pipeline = await getPipeline(modelName);
431
453
  },
432
454
  async embed(text) {
433
- const output = await pipeline(text, { pooling: "mean", normalize: true });
434
- return Array.from(output.data).slice(0, dims);
455
+ let output = await pipeline(text, { pooling: "mean", normalize: true });
456
+ const result = Array.from(output.data).slice(0, dims);
457
+ try {
458
+ output.dispose?.();
459
+ } catch {
460
+ }
461
+ output = null;
462
+ return result;
435
463
  },
436
- async embedBatch(texts, batchSize = 32) {
464
+ async embedBatch(texts, batchSize = 16) {
437
465
  const results = [];
466
+ let processed = 0;
438
467
  for (let i = 0; i < texts.length; i += batchSize) {
439
468
  const batch = texts.slice(i, i + batchSize);
440
- const output = await pipeline(batch, { pooling: "mean", normalize: true });
469
+ let output = await pipeline(batch, { pooling: "mean", normalize: true });
441
470
  const flat = output.data;
442
471
  for (let j = 0; j < batch.length; j++) {
443
- results.push(Array.from(flat.slice(j * dims, (j + 1) * dims)));
472
+ results.push(Array.from(flat.subarray(j * dims, (j + 1) * dims)));
473
+ }
474
+ try {
475
+ output.dispose?.();
476
+ } catch {
477
+ }
478
+ output = null;
479
+ processed += batch.length;
480
+ callCount += batch.length;
481
+ if (processed % 256 === 0 && typeof globalThis.gc === "function") {
482
+ globalThis.gc();
483
+ }
484
+ if (profile && callCount % 100 < batchSize) {
485
+ const rss = Math.round(process.memoryUsage().rss / 1024 / 1024);
486
+ const heap = Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
487
+ console.error(`[profile-memory] embedded=${callCount} rss=${rss}MB heap=${heap}MB`);
444
488
  }
445
489
  }
446
490
  return results;
@@ -453,9 +497,11 @@ function createLocalEmbedder(modelName = "nomic-embed-text-v1.5") {
453
497
  }
454
498
  };
455
499
  }
500
+ var pipelineCache;
456
501
  var init_local_embedder = __esm({
457
502
  "packages/core/dist/indexer/local-embedder.js"() {
458
503
  "use strict";
504
+ pipelineCache = /* @__PURE__ */ new Map();
459
505
  }
460
506
  });
461
507
 
@@ -525,13 +571,15 @@ __export(indexer_exports, {
525
571
  async function indexVault(vaultPath, options) {
526
572
  const start = Date.now();
527
573
  const { store, embedder, chunkOptions, onProgress } = options;
528
- const { documents } = scanVault(vaultPath);
574
+ const scan = scanVault(vaultPath);
575
+ const { documents } = scan;
529
576
  const existingDocs = await store.getAllDocuments();
530
577
  const existingMap = new Map(existingDocs.map((d) => [d.id, d.contentHash]));
531
578
  let indexed = 0;
532
579
  let skipped = 0;
533
580
  let failed = 0;
534
581
  let totalChunks = 0;
582
+ const failedFiles = [];
535
583
  const scannedIds = /* @__PURE__ */ new Set();
536
584
  for (let i = 0; i < documents.length; i++) {
537
585
  const doc = documents[i];
@@ -555,6 +603,7 @@ async function indexVault(vaultPath, options) {
555
603
  totalChunks += chunks.length;
556
604
  } catch (err) {
557
605
  failed++;
606
+ failedFiles.push({ path: doc.filePath, error: err?.message ?? String(err) });
558
607
  console.error(errors.indexingFailed(doc.filePath, err).format());
559
608
  }
560
609
  }
@@ -571,7 +620,10 @@ async function indexVault(vaultPath, options) {
571
620
  deleted,
572
621
  failed,
573
622
  totalChunks,
574
- elapsedMs: Date.now() - start
623
+ elapsedMs: Date.now() - start,
624
+ totalFiles: scan.scannedFiles,
625
+ skippedFiles: scan.skipped,
626
+ failedFiles
575
627
  };
576
628
  }
577
629
  var init_indexer = __esm({
@@ -645,50 +697,31 @@ async function buildGraphData(store, options = {}) {
645
697
  ]);
646
698
  const edges = [];
647
699
  const edgeCounts = /* @__PURE__ */ new Map();
648
- const USE_HNSW_THRESHOLD = 200;
649
700
  const docsWithVecs = docs.filter((d) => embeddings.has(d.id));
650
- if (docsWithVecs.length > USE_HNSW_THRESHOLD) {
651
- for (const doc of docsWithVecs) {
652
- const vec = embeddings.get(doc.id);
653
- const neighbors = await store.findDocumentNeighbors(vec, maxEdgesPerNode + 1);
654
- for (const { documentId: targetId, similarity } of neighbors) {
655
- if (targetId === doc.id)
656
- continue;
657
- if (similarity < edgeThreshold)
658
- continue;
659
- const edgeKey = [doc.id, targetId].sort().join(":");
660
- if (!edgeCounts.has(edgeKey)) {
661
- edges.push({ source: doc.id, target: targetId, weight: similarity });
662
- edgeCounts.set(edgeKey, 1);
663
- }
664
- }
665
- }
666
- } else {
667
- const normalizedVecs = /* @__PURE__ */ new Map();
668
- for (const doc of docsWithVecs) {
669
- normalizedVecs.set(doc.id, normalizeVector([...embeddings.get(doc.id)]));
670
- }
671
- const docIds = [...normalizedVecs.keys()];
672
- const vecArray = docIds.map((id) => normalizedVecs.get(id));
673
- const n = docIds.length;
674
- const neighbors = Array.from({ length: n }, () => []);
675
- for (let i = 0; i < n; i++) {
676
- for (let j = i + 1; j < n; j++) {
677
- const sim = dotProduct(vecArray[i], vecArray[j]);
678
- if (sim >= edgeThreshold) {
679
- neighbors[i].push({ peer: j, sim });
680
- neighbors[j].push({ peer: i, sim });
681
- }
701
+ const normalizedVecs = /* @__PURE__ */ new Map();
702
+ for (const doc of docsWithVecs) {
703
+ normalizedVecs.set(doc.id, normalizeVector([...embeddings.get(doc.id)]));
704
+ }
705
+ const docIds = [...normalizedVecs.keys()];
706
+ const vecArray = docIds.map((id) => normalizedVecs.get(id));
707
+ const n = docIds.length;
708
+ const neighbors = Array.from({ length: n }, () => []);
709
+ for (let i = 0; i < n; i++) {
710
+ for (let j = i + 1; j < n; j++) {
711
+ const sim = dotProduct(vecArray[i], vecArray[j]);
712
+ if (sim >= edgeThreshold) {
713
+ neighbors[i].push({ peer: j, sim });
714
+ neighbors[j].push({ peer: i, sim });
682
715
  }
683
716
  }
684
- for (let i = 0; i < n; i++) {
685
- neighbors[i].sort((a, b) => b.sim - a.sim);
686
- for (const { peer: j, sim } of neighbors[i].slice(0, maxEdgesPerNode)) {
687
- const edgeKey = i < j ? `${i}:${j}` : `${j}:${i}`;
688
- if (!edgeCounts.has(edgeKey)) {
689
- edges.push({ source: docIds[i], target: docIds[j], weight: sim });
690
- edgeCounts.set(edgeKey, 1);
691
- }
717
+ }
718
+ for (let i = 0; i < n; i++) {
719
+ neighbors[i].sort((a, b) => b.sim - a.sim);
720
+ for (const { peer: j, sim } of neighbors[i].slice(0, maxEdgesPerNode)) {
721
+ const edgeKey = i < j ? `${i}:${j}` : `${j}:${i}`;
722
+ if (!edgeCounts.has(edgeKey)) {
723
+ edges.push({ source: docIds[i], target: docIds[j], weight: sim });
724
+ edgeCounts.set(edgeKey, 1);
692
725
  }
693
726
  }
694
727
  }
@@ -722,16 +755,16 @@ async function buildGraphData(store, options = {}) {
722
755
  nodeCount: folderCounts.get(i) ?? 0
723
756
  }));
724
757
  } else {
725
- const docIds = docs.filter((d) => embeddings.has(d.id)).map((d) => d.id);
726
- const vectors = docIds.map((id) => embeddings.get(id));
727
- const k = Math.min(Math.max(5, Math.round(Math.sqrt(docIds.length / 5))), 10);
758
+ const docIds2 = docs.filter((d) => embeddings.has(d.id)).map((d) => d.id);
759
+ const vectors = docIds2.map((id) => embeddings.get(id));
760
+ const k = Math.min(Math.max(5, Math.round(Math.sqrt(docIds2.length / 5))), 10);
728
761
  const assignments = kMeans(vectors, k);
729
762
  const clusterDocInfos = /* @__PURE__ */ new Map();
730
- for (let i = 0; i < docIds.length; i++) {
763
+ for (let i = 0; i < docIds2.length; i++) {
731
764
  const cId = assignments[i];
732
765
  if (!clusterDocInfos.has(cId))
733
766
  clusterDocInfos.set(cId, []);
734
- const doc = docs.find((d) => d.id === docIds[i]);
767
+ const doc = docs.find((d) => d.id === docIds2[i]);
735
768
  if (doc)
736
769
  clusterDocInfos.get(cId).push({ id: doc.id, title: doc.title });
737
770
  }
@@ -752,8 +785,8 @@ async function buildGraphData(store, options = {}) {
752
785
  });
753
786
  }
754
787
  assignmentMap = /* @__PURE__ */ new Map();
755
- for (let i = 0; i < docIds.length; i++) {
756
- assignmentMap.set(docIds[i], assignments[i]);
788
+ for (let i = 0; i < docIds2.length; i++) {
789
+ assignmentMap.set(docIds2[i], assignments[i]);
757
790
  }
758
791
  }
759
792
  const connectionCounts = /* @__PURE__ */ new Map();
@@ -1272,36 +1305,34 @@ var init_wiki_compiler = __esm({
1272
1305
  });
1273
1306
 
1274
1307
  // packages/core/dist/federation/identity.js
1275
- import { randomBytes, createHash as createHash3, createHmac } from "node:crypto";
1276
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync8, existsSync as existsSync7, mkdirSync as mkdirSync9, chmodSync } from "node:fs";
1277
- import { join as join8 } from "node:path";
1308
+ import { createHash as createHash3, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign, verify } from "node:crypto";
1309
+ import { chmodSync, existsSync as existsSync7, mkdirSync as mkdirSync9, readFileSync as readFileSync7, writeFileSync as writeFileSync8 } from "node:fs";
1278
1310
  import { homedir as homedir4 } from "node:os";
1279
- function getOrCreateIdentity(displayName) {
1280
- if (existsSync7(IDENTITY_FILE)) {
1281
- const raw = JSON.parse(readFileSync7(IDENTITY_FILE, "utf-8"));
1282
- return {
1283
- peerId: raw.peerId,
1284
- publicKey: Buffer.from(raw.publicKey, "hex"),
1285
- secretKey: Buffer.from(raw.secretKey, "hex"),
1286
- displayName: raw.displayName,
1287
- createdAt: raw.createdAt
1288
- };
1289
- }
1290
- const secretKey = randomBytes(32);
1291
- const publicKey = createHash3("sha256").update(secretKey).digest();
1292
- const peerId = createHash3("sha256").update(publicKey).digest("hex").slice(0, 16);
1293
- const identity = {
1311
+ import { join as join8 } from "node:path";
1312
+ function derivePeerId(publicKey) {
1313
+ return createHash3("sha256").update(publicKey).digest("hex").slice(0, 16);
1314
+ }
1315
+ function generateIdentity(displayName) {
1316
+ const { publicKey: pkObj, privateKey: skObj } = generateKeyPairSync("ed25519");
1317
+ const publicKey = pkObj.export({ type: "spki", format: "der" });
1318
+ const secretKey = skObj.export({ type: "pkcs8", format: "der" });
1319
+ const peerId = derivePeerId(publicKey);
1320
+ return {
1294
1321
  peerId,
1295
1322
  publicKey,
1296
1323
  secretKey,
1297
1324
  displayName: displayName ?? `node-${peerId.slice(0, 6)}`,
1298
1325
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1299
1326
  };
1327
+ }
1328
+ function persistIdentity(identity) {
1300
1329
  mkdirSync9(IDENTITY_DIR, { recursive: true });
1301
1330
  const content = JSON.stringify({
1331
+ version: IDENTITY_VERSION,
1332
+ algorithm: IDENTITY_ALGORITHM,
1302
1333
  peerId: identity.peerId,
1303
- publicKey: publicKey.toString("hex"),
1304
- secretKey: secretKey.toString("hex"),
1334
+ publicKey: identity.publicKey.toString("hex"),
1335
+ secretKey: identity.secretKey.toString("hex"),
1305
1336
  displayName: identity.displayName,
1306
1337
  createdAt: identity.createdAt
1307
1338
  }, null, 2);
@@ -1310,36 +1341,103 @@ function getOrCreateIdentity(displayName) {
1310
1341
  chmodSync(IDENTITY_FILE, 384);
1311
1342
  } catch {
1312
1343
  }
1344
+ }
1345
+ function getOrCreateIdentity(displayName) {
1346
+ if (existsSync7(IDENTITY_FILE)) {
1347
+ const raw = JSON.parse(readFileSync7(IDENTITY_FILE, "utf-8"));
1348
+ const isV2 = raw.version === IDENTITY_VERSION && raw.algorithm === IDENTITY_ALGORITHM;
1349
+ if (isV2) {
1350
+ return {
1351
+ peerId: String(raw.peerId),
1352
+ publicKey: Buffer.from(String(raw.publicKey), "hex"),
1353
+ secretKey: Buffer.from(String(raw.secretKey), "hex"),
1354
+ displayName: String(raw.displayName),
1355
+ createdAt: String(raw.createdAt)
1356
+ };
1357
+ }
1358
+ const bakPath = IDENTITY_FILE.replace(/\.json$/, ".v1.bak.json");
1359
+ writeFileSync8(bakPath, JSON.stringify(raw, null, 2), { encoding: "utf-8", mode: 384 });
1360
+ console.warn(`[federation] Identity migrated to v2 (Ed25519). v1 backup: ${bakPath}`);
1361
+ }
1362
+ const identity = generateIdentity(displayName);
1363
+ persistIdentity(identity);
1313
1364
  return identity;
1314
1365
  }
1315
- var IDENTITY_DIR, IDENTITY_FILE;
1366
+ function signMessage(secretKeyDer, message) {
1367
+ const keyObj = createPrivateKey({ key: secretKeyDer, format: "der", type: "pkcs8" });
1368
+ return sign(null, message, keyObj);
1369
+ }
1370
+ function verifySignature(publicKeyDer, message, signature) {
1371
+ try {
1372
+ const keyObj = createPublicKey({ key: publicKeyDer, format: "der", type: "spki" });
1373
+ return verify(null, message, keyObj, signature);
1374
+ } catch {
1375
+ return false;
1376
+ }
1377
+ }
1378
+ function peerIdFromPublicKey(publicKey) {
1379
+ return derivePeerId(publicKey);
1380
+ }
1381
+ var IDENTITY_DIR, IDENTITY_FILE, IDENTITY_VERSION, IDENTITY_ALGORITHM;
1316
1382
  var init_identity = __esm({
1317
1383
  "packages/core/dist/federation/identity.js"() {
1318
1384
  "use strict";
1319
1385
  IDENTITY_DIR = join8(homedir4(), ".stellavault", "federation");
1320
1386
  IDENTITY_FILE = join8(IDENTITY_DIR, "identity.json");
1387
+ IDENTITY_VERSION = 2;
1388
+ IDENTITY_ALGORITHM = "ed25519";
1321
1389
  }
1322
1390
  });
1323
1391
 
1324
1392
  // packages/core/dist/federation/node.js
1325
- import { createHash as createHash4 } from "node:crypto";
1393
+ import { createHash as createHash4, randomBytes as randomBytes2 } from "node:crypto";
1326
1394
  import { EventEmitter } from "node:events";
1327
- var FEDERATION_TOPIC, FederationNode;
1395
+ var FEDERATION_TOPIC, REPLAY_CACHE_LIMIT, DEFAULT_HANDSHAKE_TIMEOUT_MS, DEFAULT_RATE_LIMIT_PER_SEC, DEFAULT_RATE_LIMIT_BURST, FederationNode;
1328
1396
  var init_node = __esm({
1329
1397
  "packages/core/dist/federation/node.js"() {
1330
1398
  "use strict";
1331
1399
  init_identity();
1332
1400
  FEDERATION_TOPIC = createHash4("sha256").update("stellavault-federation-v1").digest();
1401
+ REPLAY_CACHE_LIMIT = 1e4;
1402
+ DEFAULT_HANDSHAKE_TIMEOUT_MS = 3e4;
1403
+ DEFAULT_RATE_LIMIT_PER_SEC = 50;
1404
+ DEFAULT_RATE_LIMIT_BURST = 100;
1333
1405
  FederationNode = class extends EventEmitter {
1334
1406
  swarm = null;
1335
1407
  identity;
1336
1408
  peers = /* @__PURE__ */ new Map();
1409
+ connStates = /* @__PURE__ */ new WeakMap();
1410
+ replayCache = /* @__PURE__ */ new Set();
1411
+ replayQueue = [];
1337
1412
  running = false;
1338
1413
  documentCount = 0;
1339
1414
  topTopics = [];
1340
- constructor(displayName) {
1415
+ // Configurable per-instance — see constructor options.
1416
+ handshakeTimeoutMs;
1417
+ rateLimitPerSec;
1418
+ rateLimitBurst;
1419
+ /**
1420
+ * @param init Either a displayName string (legacy positional form) or an
1421
+ * options object. Pass `{ identity }` to skip the on-disk identity
1422
+ * load — useful for tests that need many ephemeral peers.
1423
+ * `handshakeTimeoutMs` drops connections stuck in handshake
1424
+ * (set to 0 to disable; default 30s).
1425
+ * `rateLimitPerSec` / `rateLimitBurst` cap per-peer envelope
1426
+ * processing rate (set rateLimitPerSec=0 to disable).
1427
+ */
1428
+ constructor(init) {
1341
1429
  super();
1342
- this.identity = getOrCreateIdentity(displayName);
1430
+ if (init && typeof init === "object") {
1431
+ this.identity = init.identity ?? getOrCreateIdentity(init.displayName);
1432
+ this.handshakeTimeoutMs = init.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS;
1433
+ this.rateLimitPerSec = init.rateLimitPerSec ?? DEFAULT_RATE_LIMIT_PER_SEC;
1434
+ this.rateLimitBurst = init.rateLimitBurst ?? DEFAULT_RATE_LIMIT_BURST;
1435
+ } else {
1436
+ this.identity = getOrCreateIdentity(init);
1437
+ this.handshakeTimeoutMs = DEFAULT_HANDSHAKE_TIMEOUT_MS;
1438
+ this.rateLimitPerSec = DEFAULT_RATE_LIMIT_PER_SEC;
1439
+ this.rateLimitBurst = DEFAULT_RATE_LIMIT_BURST;
1440
+ }
1343
1441
  }
1344
1442
  get peerId() {
1345
1443
  return this.identity.peerId;
@@ -1357,7 +1455,6 @@ var init_node = __esm({
1357
1455
  this.documentCount = documentCount;
1358
1456
  this.topTopics = topTopics.slice(0, 5);
1359
1457
  }
1360
- // Design Ref: §4 — join()
1361
1458
  async join() {
1362
1459
  if (this.running)
1363
1460
  return;
@@ -1371,7 +1468,6 @@ var init_node = __esm({
1371
1468
  this.running = true;
1372
1469
  this.emit("joined", { peerId: this.peerId, topic: FEDERATION_TOPIC.toString("hex").slice(0, 16) });
1373
1470
  }
1374
- // Design Ref: §4 — joinDirect() 수동 IP 폴백
1375
1471
  async joinDirect(host, port) {
1376
1472
  const net = await import("node:net");
1377
1473
  const conn = net.connect(port, host);
@@ -1391,7 +1487,7 @@ var init_node = __esm({
1391
1487
  return;
1392
1488
  for (const [, peer] of this.peers) {
1393
1489
  try {
1394
- this.sendMessage(peer.conn, { type: "leave", peerId: this.peerId });
1490
+ this.sendSigned(peer.conn, { type: "leave", peerId: this.peerId });
1395
1491
  peer.conn.end();
1396
1492
  } catch {
1397
1493
  }
@@ -1405,30 +1501,109 @@ var init_node = __esm({
1405
1501
  getPeers() {
1406
1502
  return [...this.peers.values()].map((p) => p.info);
1407
1503
  }
1408
- // 피어에게 검색 쿼리 전송 (FederatedSearch에서 사용)
1409
1504
  sendSearchQuery(peerId, queryId, embedding, limit) {
1410
1505
  const peer = this.peers.get(peerId);
1411
1506
  if (!peer)
1412
1507
  return;
1413
- this.sendMessage(peer.conn, { type: "search_query", queryId, embedding, limit });
1508
+ this.sendSigned(peer.conn, { type: "search_query", queryId, embedding, limit });
1414
1509
  }
1415
- // 피어에게 검색 결과 응답 (FederatedSearch에서 사용)
1416
1510
  sendSearchResult(peerId, queryId, results) {
1417
1511
  const peer = this.peers.get(peerId);
1418
1512
  if (!peer)
1419
1513
  return;
1420
- this.sendMessage(peer.conn, { type: "search_result", queryId, results });
1514
+ this.sendSigned(peer.conn, { type: "search_result", queryId, results });
1421
1515
  }
1422
1516
  // --- Private ---
1517
+ /**
1518
+ * Stable JSON for signing. Sorts keys recursively so nested objects
1519
+ * produced by different JS runtimes (or by future code paths that build
1520
+ * objects in a different insertion order) still hash identically on
1521
+ * both sides. RFC 8785-style canonicalization, scoped to what
1522
+ * FederationMessage / SignedEnvelope actually carry. Keys whose value
1523
+ * is `undefined` are omitted (matches JSON.stringify semantics), so
1524
+ * sender and receiver agree on optional fields like `publicKeyHex`.
1525
+ */
1526
+ canonicalize(value) {
1527
+ if (value === null || typeof value !== "object")
1528
+ return JSON.stringify(value);
1529
+ if (Array.isArray(value)) {
1530
+ return "[" + value.map((v) => this.canonicalize(v)).join(",") + "]";
1531
+ }
1532
+ const obj = value;
1533
+ const keys = Object.keys(obj).filter((k) => obj[k] !== void 0).sort();
1534
+ return "{" + keys.map((k) => JSON.stringify(k) + ":" + this.canonicalize(obj[k])).join(",") + "}";
1535
+ }
1536
+ sendSigned(conn, payload, includePublicKey = false) {
1537
+ try {
1538
+ const envBase = {
1539
+ payload,
1540
+ peerId: this.peerId,
1541
+ nonce: randomBytes2(16).toString("hex"),
1542
+ ...includePublicKey ? { publicKeyHex: this.identity.publicKey.toString("hex") } : {}
1543
+ };
1544
+ const canonical = Buffer.from(this.canonicalize(envBase), "utf-8");
1545
+ const sig = signMessage(this.identity.secretKey, canonical);
1546
+ const envelope = { ...envBase, signature: sig.toString("hex") };
1547
+ conn.write(JSON.stringify(envelope) + "\n");
1548
+ } catch {
1549
+ }
1550
+ }
1551
+ rememberNonce(nonceHex) {
1552
+ if (this.replayCache.has(nonceHex))
1553
+ return false;
1554
+ this.replayCache.add(nonceHex);
1555
+ this.replayQueue.push(nonceHex);
1556
+ if (this.replayQueue.length > REPLAY_CACHE_LIMIT) {
1557
+ const oldest = this.replayQueue.shift();
1558
+ if (oldest)
1559
+ this.replayCache.delete(oldest);
1560
+ }
1561
+ return true;
1562
+ }
1423
1563
  handleConnection(conn) {
1424
- this.sendMessage(conn, {
1425
- type: "handshake",
1426
- peerId: this.peerId,
1427
- displayName: this.identity.displayName,
1428
- version: "0.1.0",
1429
- documentCount: this.documentCount,
1430
- topTopics: this.topTopics
1431
- });
1564
+ const state = {
1565
+ conn,
1566
+ ourNonce: randomBytes2(32),
1567
+ helloSent: false,
1568
+ weResponded: false,
1569
+ peerVerified: false,
1570
+ peerPublicKey: null,
1571
+ peerId: null,
1572
+ pendingPeerInfo: null,
1573
+ ready: false,
1574
+ handshakeTimer: null,
1575
+ rlTokens: this.rateLimitBurst,
1576
+ rlLastRefill: Date.now()
1577
+ };
1578
+ this.connStates.set(conn, state);
1579
+ if (this.handshakeTimeoutMs > 0) {
1580
+ state.handshakeTimer = setTimeout(() => {
1581
+ if (state.ready)
1582
+ return;
1583
+ try {
1584
+ conn.end();
1585
+ } catch {
1586
+ }
1587
+ this.emit("handshake_timeout", { peerId: state.peerId });
1588
+ }, this.handshakeTimeoutMs);
1589
+ const timer = state.handshakeTimer;
1590
+ timer.unref?.();
1591
+ }
1592
+ this.sendSigned(
1593
+ conn,
1594
+ {
1595
+ type: "hello",
1596
+ peerId: this.peerId,
1597
+ displayName: this.identity.displayName,
1598
+ version: "0.2.0",
1599
+ documentCount: this.documentCount,
1600
+ topTopics: this.topTopics,
1601
+ nonce: state.ourNonce.toString("hex")
1602
+ },
1603
+ /* includePublicKey */
1604
+ true
1605
+ );
1606
+ state.helloSent = true;
1432
1607
  let buffer = "";
1433
1608
  const MAX_BUFFER = 1024 * 1024;
1434
1609
  const MAX_MESSAGE = 64 * 1024;
@@ -1447,107 +1622,214 @@ var init_node = __esm({
1447
1622
  continue;
1448
1623
  if (line.length > MAX_MESSAGE)
1449
1624
  continue;
1450
- try {
1451
- const msg = JSON.parse(line);
1452
- this.handleMessage(conn, msg);
1453
- } catch {
1454
- }
1625
+ this.dispatchLine(conn, line);
1455
1626
  }
1456
1627
  });
1457
1628
  conn.on("close", () => {
1458
- for (const [peerId, peer] of this.peers) {
1459
- if (peer.conn === conn) {
1460
- this.peers.delete(peerId);
1461
- this.emit("peer_left", { peerId });
1462
- break;
1463
- }
1629
+ const st = this.connStates.get(conn);
1630
+ if (st?.handshakeTimer)
1631
+ clearTimeout(st.handshakeTimer);
1632
+ this.connStates.delete(conn);
1633
+ if (st?.peerId && this.peers.get(st.peerId)?.conn === conn) {
1634
+ this.peers.delete(st.peerId);
1635
+ this.emit("peer_left", { peerId: st.peerId });
1464
1636
  }
1465
1637
  });
1466
1638
  conn.on("error", () => {
1467
1639
  });
1468
1640
  }
1469
- // MED: 메시지 스키마 기본 검증
1470
- validateMessage(msg) {
1471
- if (!msg || typeof msg !== "object")
1472
- return false;
1473
- const m = msg;
1474
- if (typeof m.type !== "string")
1475
- return false;
1476
- if (m.type === "handshake" && (typeof m.peerId !== "string" || typeof m.displayName !== "string"))
1477
- return false;
1478
- if (m.type === "search_query" && (!Array.isArray(m.embedding) || m.embedding.length !== 384))
1479
- return false;
1480
- if (m.type === "search_result" && !Array.isArray(m.results))
1641
+ /**
1642
+ * Token-bucket admission control. Returns false when the inbound rate
1643
+ * exceeds the configured limit; the caller should drop the envelope
1644
+ * silently (we don't disconnect — the peer might be momentarily bursty,
1645
+ * not malicious, and a real attacker would just reconnect).
1646
+ */
1647
+ admitEnvelope(state) {
1648
+ if (this.rateLimitPerSec <= 0)
1649
+ return true;
1650
+ const now = Date.now();
1651
+ const elapsed = (now - state.rlLastRefill) / 1e3;
1652
+ if (elapsed > 0) {
1653
+ state.rlTokens = Math.min(this.rateLimitBurst, state.rlTokens + elapsed * this.rateLimitPerSec);
1654
+ state.rlLastRefill = now;
1655
+ }
1656
+ if (state.rlTokens < 1) {
1657
+ this.emit("rate_limited", { peerId: state.peerId });
1481
1658
  return false;
1659
+ }
1660
+ state.rlTokens -= 1;
1482
1661
  return true;
1483
1662
  }
1484
- handleMessage(conn, msg) {
1485
- if (!this.validateMessage(msg))
1663
+ dispatchLine(conn, line) {
1664
+ const state = this.connStates.get(conn);
1665
+ if (!state)
1666
+ return;
1667
+ if (!this.admitEnvelope(state))
1668
+ return;
1669
+ let envelope;
1670
+ try {
1671
+ envelope = JSON.parse(line);
1672
+ } catch {
1673
+ return;
1674
+ }
1675
+ if (!envelope || typeof envelope !== "object")
1676
+ return;
1677
+ if (typeof envelope.peerId !== "string")
1678
+ return;
1679
+ if (typeof envelope.signature !== "string")
1680
+ return;
1681
+ if (typeof envelope.nonce !== "string")
1682
+ return;
1683
+ if (envelope.nonce.length < 16 || envelope.nonce.length > 128)
1684
+ return;
1685
+ if (!envelope.payload || typeof envelope.payload !== "object")
1686
+ return;
1687
+ if (!this.rememberNonce(envelope.nonce))
1688
+ return;
1689
+ let publicKey = null;
1690
+ if (!state.peerPublicKey) {
1691
+ if (envelope.payload && envelope.payload.type !== "hello")
1692
+ return;
1693
+ if (!envelope.publicKeyHex || typeof envelope.publicKeyHex !== "string")
1694
+ return;
1695
+ try {
1696
+ publicKey = Buffer.from(envelope.publicKeyHex, "hex");
1697
+ } catch {
1698
+ return;
1699
+ }
1700
+ const derived = peerIdFromPublicKey(publicKey);
1701
+ if (derived !== envelope.peerId)
1702
+ return;
1703
+ } else {
1704
+ if (envelope.peerId !== state.peerId)
1705
+ return;
1706
+ publicKey = state.peerPublicKey;
1707
+ }
1708
+ const { signature, ...envBase } = envelope;
1709
+ const canonical = Buffer.from(this.canonicalize(envBase), "utf-8");
1710
+ let sigBuf;
1711
+ try {
1712
+ sigBuf = Buffer.from(signature, "hex");
1713
+ } catch {
1714
+ return;
1715
+ }
1716
+ if (sigBuf.length !== 64)
1717
+ return;
1718
+ if (!verifySignature(publicKey, canonical, sigBuf))
1486
1719
  return;
1720
+ this.handleMessage(conn, state, envelope.payload, publicKey);
1721
+ }
1722
+ handleMessage(conn, state, msg, publicKey) {
1487
1723
  switch (msg.type) {
1488
- case "handshake": {
1489
- const safeName = (msg.displayName ?? "").slice(0, 50);
1490
- const peerInfo = {
1724
+ case "hello": {
1725
+ if (state.peerPublicKey)
1726
+ return;
1727
+ if (typeof msg.peerId !== "string" || typeof msg.nonce !== "string")
1728
+ return;
1729
+ let nonceBuf;
1730
+ try {
1731
+ nonceBuf = Buffer.from(msg.nonce, "hex");
1732
+ } catch {
1733
+ return;
1734
+ }
1735
+ if (nonceBuf.length !== 32)
1736
+ return;
1737
+ if (!this.rememberNonce(msg.nonce))
1738
+ return;
1739
+ state.peerPublicKey = publicKey;
1740
+ state.peerId = msg.peerId;
1741
+ state.pendingPeerInfo = {
1491
1742
  peerId: msg.peerId,
1492
- displayName: safeName,
1743
+ displayName: (msg.displayName ?? "").slice(0, 50),
1493
1744
  documentCount: Math.min(msg.documentCount ?? 0, 1e6),
1494
- // 합리적 상한
1495
1745
  topTopics: (msg.topTopics ?? []).slice(0, 10),
1496
1746
  joinedAt: (/* @__PURE__ */ new Date()).toISOString(),
1497
1747
  lastSeen: (/* @__PURE__ */ new Date()).toISOString()
1498
1748
  };
1499
- this.peers.set(msg.peerId, { info: peerInfo, conn });
1500
- this.emit("peer_joined", peerInfo);
1749
+ const response = signMessage(this.identity.secretKey, nonceBuf);
1750
+ this.sendSigned(conn, {
1751
+ type: "challenge_response",
1752
+ signedNonce: response.toString("hex")
1753
+ });
1754
+ state.weResponded = true;
1755
+ this.maybeMarkReady(conn, state);
1756
+ break;
1757
+ }
1758
+ case "challenge_response": {
1759
+ if (state.peerVerified)
1760
+ return;
1761
+ if (typeof msg.signedNonce !== "string")
1762
+ return;
1763
+ let sigBuf;
1764
+ try {
1765
+ sigBuf = Buffer.from(msg.signedNonce, "hex");
1766
+ } catch {
1767
+ return;
1768
+ }
1769
+ if (sigBuf.length !== 64)
1770
+ return;
1771
+ if (!verifySignature(publicKey, state.ourNonce, sigBuf))
1772
+ return;
1773
+ state.peerVerified = true;
1774
+ this.maybeMarkReady(conn, state);
1501
1775
  break;
1502
1776
  }
1503
1777
  case "search_query": {
1778
+ if (!state.ready)
1779
+ return;
1504
1780
  this.emit("search_request", {
1505
- peerId: msg.queryId,
1506
- // queryId를 추적용으로 사용
1781
+ peerId: state.peerId,
1507
1782
  queryId: msg.queryId,
1508
1783
  embedding: msg.embedding,
1509
1784
  limit: msg.limit,
1510
- // respond 함수: 호출 측에서 사용
1511
- respondTo: (() => {
1512
- for (const [pid, peer] of this.peers) {
1513
- if (peer.conn === conn)
1514
- return pid;
1515
- }
1516
- return null;
1517
- })()
1785
+ respondTo: state.peerId
1518
1786
  });
1519
1787
  break;
1520
1788
  }
1521
1789
  case "search_result": {
1790
+ if (!state.ready)
1791
+ return;
1522
1792
  this.emit("search_response", {
1523
1793
  queryId: msg.queryId,
1524
1794
  results: msg.results,
1525
- peerId: (() => {
1526
- for (const [pid, peer] of this.peers) {
1527
- if (peer.conn === conn)
1528
- return pid;
1529
- }
1530
- return "unknown";
1531
- })()
1795
+ peerId: state.peerId ?? "unknown"
1532
1796
  });
1533
1797
  break;
1534
1798
  }
1535
1799
  case "leave": {
1536
- const legit = this.peers.get(msg.peerId);
1537
- if (legit && legit.conn === conn) {
1800
+ if (!state.ready)
1801
+ return;
1802
+ if (msg.peerId !== state.peerId)
1803
+ return;
1804
+ const registered = this.peers.get(msg.peerId);
1805
+ if (registered && registered.conn === conn) {
1538
1806
  this.peers.delete(msg.peerId);
1539
1807
  this.emit("peer_left", { peerId: msg.peerId });
1540
1808
  }
1541
1809
  break;
1542
1810
  }
1811
+ case "challenge":
1812
+ case "ready":
1813
+ break;
1814
+ case "handshake":
1815
+ console.warn(`[federation] Rejected v1 handshake from peerId=${state.peerId ?? "unknown"}. Peer must upgrade to v2 (Ed25519).`);
1816
+ break;
1543
1817
  }
1544
1818
  }
1545
- // Design Ref: §7 — JSON + newline delimiter
1546
- sendMessage(conn, msg) {
1547
- try {
1548
- conn.write(JSON.stringify(msg) + "\n");
1549
- } catch {
1819
+ maybeMarkReady(conn, state) {
1820
+ if (state.ready)
1821
+ return;
1822
+ if (!state.weResponded || !state.peerVerified)
1823
+ return;
1824
+ if (!state.peerId || !state.pendingPeerInfo)
1825
+ return;
1826
+ state.ready = true;
1827
+ if (state.handshakeTimer) {
1828
+ clearTimeout(state.handshakeTimer);
1829
+ state.handshakeTimer = null;
1550
1830
  }
1831
+ this.peers.set(state.peerId, { info: state.pendingPeerInfo, conn });
1832
+ this.emit("peer_joined", state.pendingPeerInfo);
1551
1833
  }
1552
1834
  };
1553
1835
  }
@@ -1727,10 +2009,10 @@ var init_sharing = __esm({
1727
2009
  /\b\d{6}[-]\d{7}\b/
1728
2010
  ];
1729
2011
  DEFAULT_CONFIG2 = {
1730
- defaultLevel: 2,
1731
- // 기본: 스니펫까지
1732
- myNodeLevel: 2,
1733
- // 내 노드 기본: 스니펫까지
2012
+ defaultLevel: 1,
2013
+ // 기본: 제목+유사도까지만 (스니펫 안 보냄)
2014
+ myNodeLevel: 0,
2015
+ // 내 노드 기본: 수신 전용 (명시적 set-level 필요)
1734
2016
  rules: [
1735
2017
  // 기본 규칙
1736
2018
  { pattern: "public", type: "tag", level: 4 },
@@ -1817,6 +2099,11 @@ var init_search = __esm({
1817
2099
  this.node.on("search_request", async (req) => {
1818
2100
  if (!req.respondTo)
1819
2101
  return;
2102
+ const cfg = loadSharingConfig();
2103
+ if (cfg.myNodeLevel === 0) {
2104
+ this.node.sendSearchResult(req.respondTo, req.queryId, []);
2105
+ return;
2106
+ }
1820
2107
  try {
1821
2108
  const allStores = [this.store, ...this.additionalStores];
1822
2109
  const allScored = await Promise.all(allStores.map((s) => s.searchSemantic(req.embedding, req.limit).catch(() => [])));
@@ -1852,8 +2139,13 @@ var federation_exports = {};
1852
2139
  __export(federation_exports, {
1853
2140
  FederatedSearch: () => FederatedSearch,
1854
2141
  FederationNode: () => FederationNode,
1855
- getOrCreateIdentity: () => getOrCreateIdentity
2142
+ getOrCreateIdentity: () => getOrCreateIdentity,
2143
+ isFederationExperimentalEnabled: () => isFederationExperimentalEnabled
1856
2144
  });
2145
+ function isFederationExperimentalEnabled() {
2146
+ const v = (process.env.STELLAVAULT_FEDERATION_EXPERIMENTAL ?? "").trim().toLowerCase();
2147
+ return v === "1" || v === "true" || v === "yes" || v === "on";
2148
+ }
1857
2149
  var init_federation = __esm({
1858
2150
  "packages/core/dist/federation/index.js"() {
1859
2151
  "use strict";
@@ -3116,7 +3408,8 @@ import { Command } from "commander";
3116
3408
  // packages/cli/dist/commands/index-cmd.js
3117
3409
  import ora from "ora";
3118
3410
  import chalk from "chalk";
3119
- import { createHash as createHash6 } from "node:crypto";
3411
+ import { createHash as createHash7 } from "node:crypto";
3412
+ import { writeFileSync as writeFileSync15 } from "node:fs";
3120
3413
  import { join as join20 } from "node:path";
3121
3414
  import { homedir as homedir12 } from "node:os";
3122
3415
  import { mkdirSync as mkdirSync15 } from "node:fs";
@@ -3187,9 +3480,8 @@ function createSqliteVecStore(dbPath, dimensions = 384) {
3187
3480
  const rows = db.prepare(`
3188
3481
  SELECT chunk_id, distance
3189
3482
  FROM chunk_embeddings
3190
- WHERE embedding MATCH ?
3483
+ WHERE embedding MATCH ? AND k = ?
3191
3484
  ORDER BY distance
3192
- LIMIT ?
3193
3485
  `).all(float32Buffer(embedding), limit);
3194
3486
  return rows.map((r) => ({
3195
3487
  chunkId: r.chunk_id,
@@ -3276,15 +3568,16 @@ function createSqliteVecStore(dbPath, dimensions = 384) {
3276
3568
  return result;
3277
3569
  },
3278
3570
  async findDocumentNeighbors(embedding, limit) {
3571
+ const knnK = Math.max(limit * 3, 30);
3279
3572
  const rows = db.prepare(`
3280
3573
  SELECT c.document_id, MIN(ce.distance) as distance
3281
3574
  FROM chunk_embeddings ce
3282
3575
  JOIN chunks c ON c.id = ce.chunk_id
3283
- WHERE ce.embedding MATCH ?
3576
+ WHERE ce.embedding MATCH ? AND k = ?
3284
3577
  GROUP BY c.document_id
3285
3578
  ORDER BY distance
3286
3579
  LIMIT ?
3287
- `).all(float32Buffer(embedding), limit * 2);
3580
+ `).all(float32Buffer(embedding), knnK, limit * 2);
3288
3581
  return rows.slice(0, limit).map((r) => ({
3289
3582
  documentId: r.document_id,
3290
3583
  similarity: 1 / (1 + r.distance)
@@ -4631,7 +4924,7 @@ Please write the ${format} draft based on the context above. Save the result to
4631
4924
 
4632
4925
  // packages/core/dist/mcp/tools/agentic-graph.js
4633
4926
  import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync7 } from "node:fs";
4634
- import { join as join7 } from "node:path";
4927
+ import { join as join7, resolve as resolvePath } from "node:path";
4635
4928
  function createAgenticGraphTools(store, embedder, vaultPath) {
4636
4929
  let nodeCreationCount = 0;
4637
4930
  let nodeCreationWindowStart = Date.now();
@@ -4704,8 +4997,8 @@ ${content}${relatedSection}`;
4704
4997
  const safeTitle = title.replace(/[<>:"/\\|?*]/g, "").replace(/\s+/g, " ").trim().slice(0, 80);
4705
4998
  const dir = join7(vaultPath, folder);
4706
4999
  const filePath = join7(dir, `${safeTitle}.md`);
4707
- const resolvedPath = __require("node:path").resolve(filePath);
4708
- const resolvedVault = __require("node:path").resolve(vaultPath);
5000
+ const resolvedPath = resolvePath(filePath);
5001
+ const resolvedVault = resolvePath(vaultPath);
4709
5002
  if (!resolvedPath.startsWith(resolvedVault)) {
4710
5003
  return { content: [{ type: "text", text: "Error: invalid folder path." }] };
4711
5004
  }
@@ -4771,6 +5064,7 @@ The note will appear in the graph after next index.`
4771
5064
  // packages/core/dist/mcp/server.js
4772
5065
  function createMcpServer(options) {
4773
5066
  const { store, searchEngine, embedder, vaultPath = "", decayEngine } = options;
5067
+ const ready = options.ready ?? Promise.resolve();
4774
5068
  const learningPathTool = createLearningPathTool(store);
4775
5069
  const detectGapsTool = createDetectGapsTool(store);
4776
5070
  const getEvolutionTool = createGetEvolutionTool(store);
@@ -4802,6 +5096,7 @@ function createMcpServer(options) {
4802
5096
  ]
4803
5097
  }));
4804
5098
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
5099
+ await ready;
4805
5100
  const { name, arguments: args } = request.params;
4806
5101
  try {
4807
5102
  let result;
@@ -4905,8 +5200,18 @@ function createMcpServer(options) {
4905
5200
  const { createServer } = await import("node:http");
4906
5201
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => `sv-${Date.now()}` });
4907
5202
  await server.connect(transport);
5203
+ const allowedOrigins = options.corsOrigins ?? ["http://localhost", "http://127.0.0.1"];
5204
+ const allowWildcard = allowedOrigins.includes("*");
4908
5205
  const httpServer = createServer(async (req, res) => {
4909
- res.setHeader("Access-Control-Allow-Origin", "*");
5206
+ const origin = req.headers.origin;
5207
+ if (origin) {
5208
+ if (allowWildcard) {
5209
+ res.setHeader("Access-Control-Allow-Origin", "*");
5210
+ } else if (allowedOrigins.some((allowed) => origin === allowed || origin.startsWith(allowed + ":"))) {
5211
+ res.setHeader("Access-Control-Allow-Origin", origin);
5212
+ res.setHeader("Vary", "Origin");
5213
+ }
5214
+ }
4910
5215
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
4911
5216
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
4912
5217
  if (req.method === "OPTIONS") {
@@ -5119,11 +5424,11 @@ Author: ${pack2.author}`,
5119
5424
  init_graph_data();
5120
5425
  import express from "express";
5121
5426
  import cors from "cors";
5122
- import { randomBytes as randomBytes2 } from "node:crypto";
5427
+ import { randomBytes as randomBytes3 } from "node:crypto";
5123
5428
 
5124
5429
  // packages/core/dist/api/routes/federation.js
5125
5430
  import { Router } from "express";
5126
- function createFederationRouter(store) {
5431
+ function createFederationRouter(store, requireAuth) {
5127
5432
  const router = Router();
5128
5433
  let federationNode = null;
5129
5434
  let federationAvailable = null;
@@ -5166,7 +5471,15 @@ function createFederationRouter(store) {
5166
5471
  res.json({ available: true, active: false, peerCount: 0, peers: [], displayName: null, peerId: null });
5167
5472
  }
5168
5473
  });
5169
- router.post("/join", async (req, res) => {
5474
+ router.post("/join", requireAuth, async (req, res) => {
5475
+ const { isFederationExperimentalEnabled: isFederationExperimentalEnabled2 } = await Promise.resolve().then(() => (init_federation(), federation_exports));
5476
+ if (!isFederationExperimentalEnabled2()) {
5477
+ return res.status(503).json({
5478
+ success: false,
5479
+ error: "federation-experimental-disabled",
5480
+ message: "Federation is experimental and disabled by default. Set STELLAVAULT_FEDERATION_EXPERIMENTAL=1 in the environment to enable."
5481
+ });
5482
+ }
5170
5483
  const available = await probeFederationAvailable();
5171
5484
  if (!available) {
5172
5485
  return res.status(501).json({
@@ -5208,7 +5521,7 @@ function createFederationRouter(store) {
5208
5521
  res.status(500).json({ success: false, error: err instanceof Error ? err.message : "Federation join failed" });
5209
5522
  }
5210
5523
  });
5211
- router.post("/leave", async (_req, res) => {
5524
+ router.post("/leave", requireAuth, async (_req, res) => {
5212
5525
  if (!federationNode || !federationNode.isRunning) {
5213
5526
  return res.json({ success: true, active: false, message: "Not active" });
5214
5527
  }
@@ -5263,7 +5576,7 @@ function createKnowledgeRouter(opts) {
5263
5576
  res.status(404).json({ error: "Document not found" });
5264
5577
  return;
5265
5578
  }
5266
- const { readFileSync: readFileSync18, writeFileSync: writeFileSync21, unlinkSync } = await import("node:fs");
5579
+ const { readFileSync: readFileSync18, writeFileSync: writeFileSync22, unlinkSync } = await import("node:fs");
5267
5580
  const { join: join30, resolve: resolve22 } = await import("node:path");
5268
5581
  const [keeper, removed] = docA.content.length >= docB.content.length ? [docA, docB] : [docB, docA];
5269
5582
  const keeperPath = resolve22(join30(vaultPath, keeper.filePath));
@@ -5281,7 +5594,7 @@ function createKnowledgeRouter(opts) {
5281
5594
  > Merged from: ${removed.title} (${removed.filePath})
5282
5595
 
5283
5596
  ${removed.content}`;
5284
- writeFileSync21(keeperPath, keeperContent + appendix, "utf-8");
5597
+ writeFileSync22(keeperPath, keeperContent + appendix, "utf-8");
5285
5598
  try {
5286
5599
  unlinkSync(removedPath);
5287
5600
  } catch {
@@ -5304,7 +5617,7 @@ ${removed.content}`;
5304
5617
  res.status(400).json({ error: "clusterA, clusterB required" });
5305
5618
  return;
5306
5619
  }
5307
- const { writeFileSync: writeFileSync21, mkdirSync: mkdirSync22 } = await import("node:fs");
5620
+ const { writeFileSync: writeFileSync22, mkdirSync: mkdirSync22 } = await import("node:fs");
5308
5621
  const { join: join30, resolve: resolve22 } = await import("node:path");
5309
5622
  const nameA = clusterA.replace(/\s*\(\d+\)$/, "");
5310
5623
  const nameB = clusterB.replace(/\s*\(\d+\)$/, "");
@@ -5352,7 +5665,7 @@ ${removed.content}`;
5352
5665
  }
5353
5666
  mkdirSync22(dir, { recursive: true });
5354
5667
  const filePath = join30(dir, `${safeTitle}.md`);
5355
- writeFileSync21(filePath, content, "utf-8");
5668
+ writeFileSync22(filePath, content, "utf-8");
5356
5669
  res.json({ success: true, title: safeTitle, path: filePath });
5357
5670
  } catch (err) {
5358
5671
  console.error(err);
@@ -5364,6 +5677,7 @@ ${removed.content}`;
5364
5677
 
5365
5678
  // packages/core/dist/api/routes/ingest.js
5366
5679
  import { Router as Router3 } from "express";
5680
+ import { createHash as createHash5 } from "node:crypto";
5367
5681
  function createIngestRouter(opts) {
5368
5682
  const { store, vaultPath, requireAuth, assertNotPrivateUrl } = opts;
5369
5683
  const router = Router3();
@@ -5434,7 +5748,7 @@ function createIngestRouter(opts) {
5434
5748
  });
5435
5749
  try {
5436
5750
  const doc = {
5437
- id: __require("node:crypto").createHash("sha256").update(result.savedTo).digest("hex").slice(0, 16),
5751
+ id: createHash5("sha256").update(result.savedTo).digest("hex").slice(0, 16),
5438
5752
  filePath: result.savedTo,
5439
5753
  title: result.title,
5440
5754
  content,
@@ -5501,12 +5815,12 @@ function createIngestRouter(opts) {
5501
5815
  return;
5502
5816
  }
5503
5817
  try {
5504
- const { writeFileSync: writeFileSync21, unlinkSync } = await import("node:fs");
5818
+ const { writeFileSync: writeFileSync22, unlinkSync } = await import("node:fs");
5505
5819
  const { join: join30 } = await import("node:path");
5506
5820
  const { tmpdir } = await import("node:os");
5507
5821
  const safeName = (file.originalname ?? "upload").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 100);
5508
5822
  const tmpPath = join30(tmpdir(), `sv-upload-${Date.now()}-${safeName}`);
5509
- writeFileSync21(tmpPath, file.buffer);
5823
+ writeFileSync22(tmpPath, file.buffer);
5510
5824
  const { extractFileContent: extractFileContent2 } = await Promise.resolve().then(() => (init_file_extractors(), file_extractors_exports));
5511
5825
  const ext = file.originalname.split(".").pop()?.toLowerCase() ?? "";
5512
5826
  const binaryExts = /* @__PURE__ */ new Set(["pdf", "docx", "pptx", "xlsx", "xls"]);
@@ -5542,7 +5856,7 @@ function createIngestRouter(opts) {
5542
5856
  });
5543
5857
  try {
5544
5858
  const doc = {
5545
- id: __require("node:crypto").createHash("sha256").update(result.savedTo).digest("hex").slice(0, 16),
5859
+ id: createHash5("sha256").update(result.savedTo).digest("hex").slice(0, 16),
5546
5860
  filePath: result.savedTo,
5547
5861
  title: result.title,
5548
5862
  content,
@@ -5612,7 +5926,7 @@ ${desc}
5612
5926
  if (content.length > 1e4)
5613
5927
  content = content.slice(0, 1e4) + "\n\n...(truncated)";
5614
5928
  }
5615
- const { writeFileSync: writeFileSync21, mkdirSync: mkdirSync22 } = await import("node:fs");
5929
+ const { writeFileSync: writeFileSync22, mkdirSync: mkdirSync22 } = await import("node:fs");
5616
5930
  const { join: join30 } = await import("node:path");
5617
5931
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
5618
5932
  const clipDir = join30(vaultPath || ".", "06_Research", "clips");
@@ -5630,7 +5944,7 @@ tags: [clip${isYT ? ", youtube" : ""}]
5630
5944
  > Source: ${url}
5631
5945
 
5632
5946
  ${content}`;
5633
- writeFileSync21(join30(clipDir, fileName), md, "utf-8");
5947
+ writeFileSync22(join30(clipDir, fileName), md, "utf-8");
5634
5948
  res.json({ success: true, fileName, path: join30(clipDir, fileName) });
5635
5949
  } catch (err) {
5636
5950
  console.error(err);
@@ -5888,7 +6202,7 @@ function createAnalyticsRouter(opts) {
5888
6202
  function createApiServer(options) {
5889
6203
  const { store, searchEngine, port = 3333, vaultName = "", vaultPath = "", decayEngine, graphUiPath } = options;
5890
6204
  const app = express();
5891
- const authToken = randomBytes2(32).toString("hex");
6205
+ const authToken = randomBytes3(32).toString("hex");
5892
6206
  app.use((_req, res, next) => {
5893
6207
  res.setHeader("X-Content-Type-Options", "nosniff");
5894
6208
  res.setHeader("X-Frame-Options", "DENY");
@@ -5929,11 +6243,14 @@ function createApiServer(options) {
5929
6243
  const token = req.headers["x-stellavault-token"];
5930
6244
  if (token === authToken)
5931
6245
  return next();
5932
- if (req.query.token === authToken)
5933
- return next();
5934
- res.status(403).json({ error: "Invalid or missing auth token. GET /api/token first." });
6246
+ res.status(403).json({ error: "Invalid or missing auth token. Send X-Stellavault-Token header." });
5935
6247
  }
5936
- app.get("/api/token", (_req, res) => {
6248
+ app.get("/api/token", (req, res) => {
6249
+ const origin = req.headers.origin;
6250
+ if (!origin || !allowedOrigins.includes(origin)) {
6251
+ res.status(403).json({ error: "Token endpoint requires a same-origin browser request." });
6252
+ return;
6253
+ }
5937
6254
  res.json({ token: authToken });
5938
6255
  });
5939
6256
  function assertNotPrivateUrl(url) {
@@ -6117,7 +6434,7 @@ function createApiServer(options) {
6117
6434
  return;
6118
6435
  }
6119
6436
  const { resolve: resolve22, join: join30 } = await import("node:path");
6120
- const { writeFileSync: writeFileSync21, readFileSync: readFileSync18 } = await import("node:fs");
6437
+ const { writeFileSync: writeFileSync22, readFileSync: readFileSync18 } = await import("node:fs");
6121
6438
  const fullPath = resolve22(vaultPath, doc.filePath);
6122
6439
  if (!fullPath.startsWith(resolve22(vaultPath))) {
6123
6440
  res.status(403).json({ error: "Access denied" });
@@ -6144,7 +6461,7 @@ function createApiServer(options) {
6144
6461
  updated = content;
6145
6462
  }
6146
6463
  }
6147
- writeFileSync21(fullPath, updated, "utf-8");
6464
+ writeFileSync22(fullPath, updated, "utf-8");
6148
6465
  await store.upsertDocument({
6149
6466
  ...doc,
6150
6467
  title: title ?? doc.title,
@@ -6333,7 +6650,7 @@ function createApiServer(options) {
6333
6650
  app.get("/api/sync/status", (_req, res) => {
6334
6651
  res.json(syncState);
6335
6652
  });
6336
- app.use("/api/federate", createFederationRouter(store));
6653
+ app.use("/api/federate", createFederationRouter(store, requireAuth));
6337
6654
  if (graphUiPath) {
6338
6655
  app.get(/^(?!\/api\/)(?!.*\.[a-z0-9]+$).*$/i, (_req, res) => {
6339
6656
  res.sendFile(`${graphUiPath}/index.html`);
@@ -6781,7 +7098,7 @@ async function captureVoice(audioPath, options) {
6781
7098
  }
6782
7099
 
6783
7100
  // packages/core/dist/cloud/sync.js
6784
- import { createCipheriv, createDecipheriv, randomBytes as randomBytes3, createHash as createHash5 } from "node:crypto";
7101
+ import { createCipheriv, createDecipheriv, randomBytes as randomBytes4, createHash as createHash6 } from "node:crypto";
6785
7102
  import { readFileSync as readFileSync14, writeFileSync as writeFileSync14, existsSync as existsSync14, mkdirSync as mkdirSync14, chmodSync as chmodSync2 } from "node:fs";
6786
7103
  import { join as join15 } from "node:path";
6787
7104
  import { homedir as homedir7 } from "node:os";
@@ -6789,7 +7106,7 @@ var CLOUD_DIR = join15(homedir7(), ".stellavault", "cloud");
6789
7106
  var KEY_FILE = join15(CLOUD_DIR, "encryption.key");
6790
7107
  var SYNC_STATE_FILE = join15(CLOUD_DIR, "sync-state.json");
6791
7108
  function encrypt(data, key) {
6792
- const iv = randomBytes3(16);
7109
+ const iv = randomBytes4(16);
6793
7110
  const cipher = createCipheriv("aes-256-gcm", key, iv);
6794
7111
  const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
6795
7112
  const tag = cipher.getAuthTag();
@@ -6803,14 +7120,14 @@ function decrypt(encrypted, key, iv, tag) {
6803
7120
  function getOrCreateEncryptionKey(userKey) {
6804
7121
  mkdirSync14(CLOUD_DIR, { recursive: true });
6805
7122
  if (userKey) {
6806
- const key2 = createHash5("sha256").update(userKey).digest();
7123
+ const key2 = createHash6("sha256").update(userKey).digest();
6807
7124
  writeFileSync14(KEY_FILE, key2.toString("hex"), "utf-8");
6808
7125
  return key2;
6809
7126
  }
6810
7127
  if (existsSync14(KEY_FILE)) {
6811
7128
  return Buffer.from(readFileSync14(KEY_FILE, "utf-8").trim(), "hex");
6812
7129
  }
6813
- const key = randomBytes3(32);
7130
+ const key = randomBytes4(32);
6814
7131
  writeFileSync14(KEY_FILE, key.toString("hex"), { encoding: "utf-8", mode: 384 });
6815
7132
  try {
6816
7133
  chmodSync2(KEY_FILE, 384);
@@ -6837,7 +7154,7 @@ async function s3Put(config, objectKey, data, contentType = "application/octet-s
6837
7154
  "Content-Type": contentType,
6838
7155
  "Content-Length": String(data.length),
6839
7156
  "x-amz-date": date,
6840
- "x-amz-content-sha256": createHash5("sha256").update(data).digest("hex"),
7157
+ "x-amz-content-sha256": createHash6("sha256").update(data).digest("hex"),
6841
7158
  // R2는 Bearer token 지원
6842
7159
  "Authorization": `Bearer ${secretAccessKey}`
6843
7160
  },
@@ -6936,23 +7253,25 @@ var CREDITS_FILE = join19(homedir11(), ".stellavault", "federation", "credits.js
6936
7253
  init_retry();
6937
7254
  init_math();
6938
7255
  init_indexer();
6939
- function createKnowledgeHub(config) {
7256
+ function createKnowledgeHub(config, options = {}) {
6940
7257
  const embedder = createLocalEmbedder(config.embedding.localModel);
6941
7258
  const dims = embedder.dimensions;
6942
7259
  const store = createSqliteVecStore(config.dbPath, dims);
6943
7260
  const searchEngine = createSearchEngine({ store, embedder, rrfK: config.search.rrfK });
6944
- const mcpServer = createMcpServer({ store, searchEngine, vaultPath: config.vaultPath });
7261
+ const mcpServer = createMcpServer({ store, searchEngine, vaultPath: config.vaultPath, ready: options.ready });
6945
7262
  return { store, embedder, searchEngine, mcpServer, config };
6946
7263
  }
6947
7264
 
6948
7265
  // packages/cli/dist/commands/index-cmd.js
6949
7266
  function getVaultDbPath(vaultPath) {
6950
- const hash = createHash6("sha256").update(vaultPath).digest("hex").slice(0, 8);
7267
+ const hash = createHash7("sha256").update(vaultPath).digest("hex").slice(0, 8);
6951
7268
  const dir = join20(homedir12(), ".stellavault", "vaults");
6952
7269
  mkdirSync15(dir, { recursive: true });
6953
7270
  return join20(dir, `${hash}.db`);
6954
7271
  }
6955
- async function indexCommand(vaultPath) {
7272
+ async function indexCommand(vaultPath, opts = {}) {
7273
+ if (opts.profileMemory)
7274
+ process.env.STELLAVAULT_PROFILE_MEMORY = "1";
6956
7275
  const config = loadConfig();
6957
7276
  const vault2 = vaultPath ?? config.vaultPath;
6958
7277
  if (!vault2) {
@@ -6969,28 +7288,85 @@ async function indexCommand(vaultPath) {
6969
7288
  } catch {
6970
7289
  }
6971
7290
  }
6972
- const spinner = ora("Initializing...").start();
7291
+ const spinnerEnabled = !opts.noSpinner && !opts.verbose && process.stderr.isTTY;
7292
+ const spinner = ora({ text: "Initializing...", isEnabled: spinnerEnabled }).start();
6973
7293
  const store = createSqliteVecStore(dbPath);
6974
- await store.initialize();
6975
- spinner.text = "Loading embedding model...";
6976
- const embedder = createLocalEmbedder(config.embedding.localModel);
6977
- await embedder.initialize();
6978
- spinner.text = "Starting indexing...";
6979
- const result = await indexVault(vault2, {
6980
- store,
6981
- embedder,
6982
- chunkOptions: config.chunking,
6983
- onProgress(current, total, doc) {
6984
- spinner.text = `[${current}/${total}] ${doc.title}`;
7294
+ const cleanupSpinner = () => {
7295
+ try {
7296
+ spinner.stop();
7297
+ } catch {
6985
7298
  }
7299
+ };
7300
+ process.once("uncaughtException", cleanupSpinner);
7301
+ process.once("SIGINT", () => {
7302
+ cleanupSpinner();
7303
+ process.exit(130);
6986
7304
  });
6987
- await store.close();
6988
- spinner.stop();
6989
- console.log("");
6990
- console.log(chalk.green("\u2705 Indexing complete"));
6991
- console.log(` \u{1F4C4} Indexed: ${result.indexed} | \u23ED\uFE0F Skipped: ${result.skipped} | \u{1F5D1}\uFE0F Deleted: ${result.deleted}${result.failed ? ` | \u274C Failed: ${result.failed}` : ""}`);
6992
- console.log(` \u{1F9E9} Chunks: ${result.totalChunks} | \u23F1 ${(result.elapsedMs / 1e3).toFixed(1)}s`);
6993
- console.log(` \u{1F4BE} DB: ${dbPath}`);
7305
+ process.once("SIGTERM", () => {
7306
+ cleanupSpinner();
7307
+ process.exit(143);
7308
+ });
7309
+ try {
7310
+ await store.initialize();
7311
+ spinner.text = "Loading embedding model...";
7312
+ const embedder = createLocalEmbedder(config.embedding.localModel);
7313
+ await embedder.initialize();
7314
+ spinner.text = "Starting indexing...";
7315
+ const result = await indexVault(vault2, {
7316
+ store,
7317
+ embedder,
7318
+ chunkOptions: config.chunking,
7319
+ onProgress(current, total, doc) {
7320
+ const mb = Math.round(process.memoryUsage().rss / 1024 / 1024);
7321
+ if (spinnerEnabled) {
7322
+ spinner.text = `[${current}/${total}] ${doc.title} (${mb}MB)`;
7323
+ } else if (opts.verbose || current % 50 === 0 || current === total) {
7324
+ console.error(`[${current}/${total}] ${doc.title} (${mb}MB)`);
7325
+ }
7326
+ }
7327
+ });
7328
+ await store.close();
7329
+ spinner.stop();
7330
+ const reasonCount = {
7331
+ "empty": 0,
7332
+ "parse-error": 0,
7333
+ "binary": 0,
7334
+ "too-large": 0,
7335
+ "unreadable": 0
7336
+ };
7337
+ for (const s of result.skippedFiles)
7338
+ reasonCount[s.reason]++;
7339
+ console.log("");
7340
+ console.log(chalk.green("\u2705 Indexing complete"));
7341
+ console.log(` \u{1F4C1} Files: ${result.totalFiles} total`);
7342
+ console.log(` \u{1F4C4} Indexed: ${result.indexed} | \u23ED\uFE0F Unchanged: ${result.skipped} | \u{1F5D1}\uFE0F Deleted: ${result.deleted}${result.failed ? ` | \u274C Failed: ${result.failed}` : ""}`);
7343
+ if (result.skippedFiles.length > 0) {
7344
+ const parts = Object.entries(reasonCount).filter(([, c]) => c > 0).map(([r, c]) => `${r}=${c}`).join(", ");
7345
+ console.log(` \u26A0\uFE0F Skipped: ${result.skippedFiles.length} (${parts})`);
7346
+ }
7347
+ console.log(` \u{1F9E9} Chunks: ${result.totalChunks} | \u23F1 ${(result.elapsedMs / 1e3).toFixed(1)}s`);
7348
+ console.log(` \u{1F4BE} DB: ${dbPath}`);
7349
+ if (opts.logSkipped) {
7350
+ writeFileSync15(opts.logSkipped, JSON.stringify({ skipped: result.skippedFiles, failed: result.failedFiles }, null, 2));
7351
+ console.log(chalk.dim(` \u{1F4CB} Skip log: ${opts.logSkipped}`));
7352
+ }
7353
+ } catch (err) {
7354
+ spinner.fail(chalk.red("Indexing failed"));
7355
+ const e = err;
7356
+ console.error(chalk.red(`
7357
+ ${e.message ?? err}`));
7358
+ if (e.stack)
7359
+ console.error(chalk.dim(e.stack.split("\n").slice(1, 5).join("\n")));
7360
+ if ((e.message ?? "").match(/heap|out of memory|allocation failed/i)) {
7361
+ console.error(chalk.yellow("\n \u{1F4A1} Hint: large vault detected. Retry with a larger Node heap:"));
7362
+ console.error(chalk.yellow(' NODE_OPTIONS="--max-old-space-size=8192 --expose-gc" stellavault index <path>'));
7363
+ }
7364
+ try {
7365
+ await store.close();
7366
+ } catch {
7367
+ }
7368
+ process.exit(1);
7369
+ }
6994
7370
  }
6995
7371
 
6996
7372
  // packages/cli/dist/commands/search-cmd.js
@@ -7038,14 +7414,29 @@ async function searchCommand(query, options, cmd) {
7038
7414
  import chalk3 from "chalk";
7039
7415
  async function serveCommand() {
7040
7416
  const config = loadConfig();
7041
- const hub = createKnowledgeHub(config);
7042
- await hub.store.initialize();
7043
- await hub.embedder.initialize();
7044
- const stats = await hub.store.getStats();
7045
- console.error(chalk3.green("\u{1F680} MCP Server running (stdio mode)"));
7046
- console.error(`\u{1F4DA} ${stats.documentCount} documents | ${stats.chunkCount} chunks`);
7417
+ let resolveReady;
7418
+ const ready = new Promise((r) => {
7419
+ resolveReady = r;
7420
+ });
7421
+ const hub = createKnowledgeHub(config, { ready });
7422
+ console.error(chalk3.green("\u{1F680} MCP Server running (stdio mode) \u2014 index loading in background"));
7047
7423
  console.error(chalk3.dim("\u{1F4A1} Claude Code: claude mcp add stellavault -- stellavault serve"));
7048
- await hub.mcpServer.startStdio();
7424
+ const serverPromise = hub.mcpServer.startStdio();
7425
+ (async () => {
7426
+ try {
7427
+ const t0 = Date.now();
7428
+ await hub.store.initialize();
7429
+ await hub.embedder.initialize();
7430
+ const stats = await hub.store.getStats();
7431
+ const elapsed = Date.now() - t0;
7432
+ console.error(`\u{1F4DA} ${stats.documentCount} documents | ${stats.chunkCount} chunks (ready in ${elapsed}ms)`);
7433
+ resolveReady();
7434
+ } catch (err) {
7435
+ console.error(chalk3.red("\u274C Index load failed: " + err.message));
7436
+ resolveReady();
7437
+ }
7438
+ })();
7439
+ await serverPromise;
7049
7440
  }
7050
7441
 
7051
7442
  // packages/cli/dist/commands/status-cmd.js
@@ -7059,14 +7450,34 @@ async function statusCommand(_opts, cmd) {
7059
7450
  const stats = await store.getStats();
7060
7451
  const topics = await store.getTopics();
7061
7452
  await store.close();
7453
+ let totalFiles = null;
7454
+ let skippedFiles = null;
7455
+ if (config.vaultPath) {
7456
+ try {
7457
+ const scan = scanVault(config.vaultPath);
7458
+ totalFiles = scan.scannedFiles;
7459
+ skippedFiles = scan.skippedFiles;
7460
+ } catch {
7461
+ }
7462
+ }
7062
7463
  if (jsonMode) {
7063
- console.log(JSON.stringify({ ...stats, vaultPath: config.vaultPath, dbPath: config.dbPath, topics: topics.slice(0, 20) }, null, 2));
7464
+ console.log(JSON.stringify({
7465
+ ...stats,
7466
+ totalFiles,
7467
+ skippedFiles,
7468
+ vaultPath: config.vaultPath,
7469
+ dbPath: config.dbPath,
7470
+ topics: topics.slice(0, 20)
7471
+ }, null, 2));
7064
7472
  return;
7065
7473
  }
7066
7474
  console.log("");
7067
7475
  console.log(chalk4.bold("\u{1F4CA} Stellavault Status"));
7068
7476
  console.log("\u2500".repeat(40));
7069
- console.log(` \u{1F4C4} Documents: ${stats.documentCount}`);
7477
+ console.log(` \u{1F4C4} Documents: ${stats.documentCount}${totalFiles != null ? ` / ${totalFiles} files (${Math.round(stats.documentCount / totalFiles * 100)}%)` : ""}`);
7478
+ if (skippedFiles != null && skippedFiles > 0) {
7479
+ console.log(` \u26A0\uFE0F Skipped: ${skippedFiles} (run: stellavault index --log-skipped skipped.json)`);
7480
+ }
7070
7481
  console.log(` \u{1F9E9} Chunks: ${stats.chunkCount}`);
7071
7482
  console.log(` \u{1F550} Last indexed: ${stats.lastIndexed ?? "never"}`);
7072
7483
  console.log(` \u{1F4BE} DB: ${config.dbPath}`);
@@ -7186,7 +7597,7 @@ async function openBrowser(url) {
7186
7597
 
7187
7598
  // packages/cli/dist/commands/card-cmd.js
7188
7599
  import chalk6 from "chalk";
7189
- import { writeFileSync as writeFileSync15 } from "node:fs";
7600
+ import { writeFileSync as writeFileSync16 } from "node:fs";
7190
7601
  import { resolve as resolve12 } from "node:path";
7191
7602
  async function cardCommand(options) {
7192
7603
  const output = options.output ?? "knowledge-card.svg";
@@ -7197,7 +7608,7 @@ async function cardCommand(options) {
7197
7608
  if (!res.ok)
7198
7609
  throw new Error(`API error: ${res.status}. Is 'stellavault graph' running?`);
7199
7610
  const svg = await res.text();
7200
- writeFileSync15(outPath, svg, "utf-8");
7611
+ writeFileSync16(outPath, svg, "utf-8");
7201
7612
  console.error(chalk6.green(`\u2705 Profile card saved: ${outPath}`));
7202
7613
  console.error(chalk6.dim(" Embed in GitHub README:"));
7203
7614
  console.error(chalk6.dim(` ![Knowledge Card](${output})`));
@@ -7248,8 +7659,8 @@ async function packExportCommand(name, options) {
7248
7659
  }
7249
7660
  const outPath = resolve13(process.cwd(), options.output ?? `${name}.sv-pack`);
7250
7661
  const content = readFileSync15(srcPath, "utf-8");
7251
- const { writeFileSync: writeFileSync21 } = await import("node:fs");
7252
- writeFileSync21(outPath, content);
7662
+ const { writeFileSync: writeFileSync22 } = await import("node:fs");
7663
+ writeFileSync22(outPath, content);
7253
7664
  console.error(chalk7.green(`\u2705 Exported: ${outPath}`));
7254
7665
  }
7255
7666
  async function packImportCommand(filePath) {
@@ -7406,11 +7817,29 @@ function runScript(scriptPath, cwd) {
7406
7817
  // packages/cli/dist/commands/review-cmd.js
7407
7818
  import chalk10 from "chalk";
7408
7819
  import { createInterface } from "node:readline";
7820
+ function globToRegex(glob) {
7821
+ const esc = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, ".+").replace(/\*/g, "[^/]*").replace(/\?/g, ".");
7822
+ return new RegExp("^" + esc + "$");
7823
+ }
7824
+ function seededRotate(arr, seed) {
7825
+ if (!seed)
7826
+ return arr;
7827
+ let h = 2166136261;
7828
+ for (let i = 0; i < seed.length; i++) {
7829
+ h ^= seed.charCodeAt(i);
7830
+ h = Math.imul(h, 16777619);
7831
+ }
7832
+ const offset = Math.abs(h) % Math.max(1, arr.length);
7833
+ return arr.slice(offset).concat(arr.slice(0, offset));
7834
+ }
7409
7835
  async function reviewCommand(options) {
7410
7836
  const config = loadConfig();
7411
7837
  const hub = createKnowledgeHub(config);
7412
7838
  const count = parseInt(options.count ?? "5", 10);
7413
- console.error(chalk10.dim("\u23F3 Initializing..."));
7839
+ const minAgeDays = parseInt(options.minAge ?? "0", 10);
7840
+ const excludeRe = options.exclude ? globToRegex(options.exclude) : null;
7841
+ if (!options.json)
7842
+ console.error(chalk10.dim("\u23F3 Initializing..."));
7414
7843
  await hub.store.initialize();
7415
7844
  const db = hub.store.getDb();
7416
7845
  if (!db) {
@@ -7418,7 +7847,41 @@ async function reviewCommand(options) {
7418
7847
  process.exit(1);
7419
7848
  }
7420
7849
  const decayEngine = new DecayEngine(db);
7421
- const decaying = await decayEngine.getDecaying(0.6, count);
7850
+ let pool = await decayEngine.getDecaying(0.6, Math.max(count * 5, 50));
7851
+ if (excludeRe || minAgeDays > 0) {
7852
+ pool = pool.filter((d) => {
7853
+ const doc = db.prepare("SELECT file_path FROM documents WHERE id = ?").get(d.documentId);
7854
+ const fp = doc?.file_path ?? "";
7855
+ if (excludeRe && excludeRe.test(fp))
7856
+ return false;
7857
+ if (minAgeDays > 0) {
7858
+ const ageDays = (Date.now() - new Date(d.lastAccess).getTime()) / 864e5;
7859
+ if (ageDays < minAgeDays)
7860
+ return false;
7861
+ }
7862
+ return true;
7863
+ });
7864
+ }
7865
+ if (options.seed)
7866
+ pool = seededRotate(pool, options.seed);
7867
+ const decaying = pool.slice(0, count);
7868
+ if (options.json) {
7869
+ const out = decaying.map((d) => {
7870
+ const doc = db.prepare("SELECT file_path FROM documents WHERE id = ?").get(d.documentId);
7871
+ const ageDays = Math.round((Date.now() - new Date(d.lastAccess).getTime()) / 864e5);
7872
+ return {
7873
+ documentId: d.documentId,
7874
+ title: d.title,
7875
+ filePath: doc?.file_path ?? null,
7876
+ retrievability: d.retrievability,
7877
+ lastAccess: d.lastAccess,
7878
+ ageDays,
7879
+ reviewScore: d.retrievability * 0.7 + Math.min(1, ageDays / 30) * 0.3
7880
+ };
7881
+ });
7882
+ console.log(JSON.stringify(out, null, 2));
7883
+ return;
7884
+ }
7422
7885
  if (decaying.length === 0) {
7423
7886
  console.log(chalk10.green("\n\u2728 All knowledge is healthy! No notes to review."));
7424
7887
  return;
@@ -7567,7 +8030,7 @@ async function gapsCommand() {
7567
8030
 
7568
8031
  // packages/cli/dist/commands/clip-cmd.js
7569
8032
  import chalk13 from "chalk";
7570
- import { writeFileSync as writeFileSync16, mkdirSync as mkdirSync17 } from "node:fs";
8033
+ import { writeFileSync as writeFileSync17, mkdirSync as mkdirSync17 } from "node:fs";
7571
8034
  import { join as join22 } from "node:path";
7572
8035
  async function clipCommand(url, options) {
7573
8036
  if (!url) {
@@ -7616,7 +8079,7 @@ async function clipCommand(url, options) {
7616
8079
  "",
7617
8080
  content
7618
8081
  ].join("\n");
7619
- writeFileSync16(filePath, md, "utf-8");
8082
+ writeFileSync17(filePath, md, "utf-8");
7620
8083
  console.log(chalk13.green(`\u2705 Saved: ${fileName}`));
7621
8084
  console.log(chalk13.dim(` \u2192 ${filePath}`));
7622
8085
  console.log(chalk13.dim(" \u{1F4A1} Run stellavault index to make it searchable"));
@@ -7817,7 +8280,7 @@ async function digestCommand(options) {
7817
8280
  \u{1F9E0} Health: R=${report.averageR} | Decaying ${report.decayingCount} | Critical ${report.criticalCount}`);
7818
8281
  console.log(chalk15.dim("\n\u2550".repeat(50)));
7819
8282
  if (options.visual) {
7820
- const { writeFileSync: writeFileSync21, mkdirSync: mkdirSync22, existsSync: existsSync25 } = await import("node:fs");
8283
+ const { writeFileSync: writeFileSync22, mkdirSync: mkdirSync22, existsSync: existsSync25 } = await import("node:fs");
7821
8284
  const { join: join30, resolve: resolve22 } = await import("node:path");
7822
8285
  const outputDir = resolve22(config.vaultPath, "_stellavault/digests");
7823
8286
  if (!existsSync25(outputDir))
@@ -7862,7 +8325,7 @@ async function digestCommand(options) {
7862
8325
  "---",
7863
8326
  `*Generated by \`stellavault digest --visual\` on ${(/* @__PURE__ */ new Date()).toISOString()}*`
7864
8327
  ].filter(Boolean).join("\n");
7865
- writeFileSync21(outputPath, md, "utf-8");
8328
+ writeFileSync22(outputPath, md, "utf-8");
7866
8329
  console.log(chalk15.green(`
7867
8330
  Visual digest saved: ${filename}`));
7868
8331
  console.log(chalk15.dim(`Open in Obsidian to see Mermaid charts.`));
@@ -7871,7 +8334,7 @@ Visual digest saved: ${filename}`));
7871
8334
 
7872
8335
  // packages/cli/dist/commands/init-cmd.js
7873
8336
  import { createInterface as createInterface2 } from "node:readline";
7874
- import { existsSync as existsSync18, mkdirSync as mkdirSync18, writeFileSync as writeFileSync17 } from "node:fs";
8337
+ import { existsSync as existsSync18, mkdirSync as mkdirSync18, writeFileSync as writeFileSync18 } from "node:fs";
7875
8338
  import { join as join23 } from "node:path";
7876
8339
  import { homedir as homedir14 } from "node:os";
7877
8340
  import ora2 from "ora";
@@ -7919,7 +8382,7 @@ async function initCommand() {
7919
8382
  search: { defaultLimit: 10, rrfK: 60 },
7920
8383
  mcp: { mode: "stdio", port: 3333 }
7921
8384
  };
7922
- writeFileSync17(join23(homedir14(), ".stellavault.json"), JSON.stringify(configData, null, 2), "utf-8");
8385
+ writeFileSync18(join23(homedir14(), ".stellavault.json"), JSON.stringify(configData, null, 2), "utf-8");
7923
8386
  console.log(chalk16.dim(` Config saved: ~/.stellavault.json`));
7924
8387
  console.log("");
7925
8388
  console.log(chalk16.cyan(" Step 2/3") + " \u2014 Indexing your vault");
@@ -8017,7 +8480,7 @@ Andrej Karpathy's approach: every session auto-compiles into structured knowledg
8017
8480
  }
8018
8481
  ];
8019
8482
  for (const s of samples) {
8020
- writeFileSync17(join23(rawDir, s.file), s.content, "utf-8");
8483
+ writeFileSync18(join23(rawDir, s.file), s.content, "utf-8");
8021
8484
  }
8022
8485
  const reSpinner = ora2({ text: " Indexing sample notes...", indent: 2 }).start();
8023
8486
  const reResult = await indexVault(vaultPath, {
@@ -8191,10 +8654,22 @@ async function contradictionsCommand(_opts, cmd) {
8191
8654
  import { createInterface as createInterface3 } from "node:readline";
8192
8655
  import chalk19 from "chalk";
8193
8656
  async function federateJoinCommand(options) {
8657
+ if (!isFederationExperimentalEnabled()) {
8658
+ console.log("");
8659
+ console.log(chalk19.red(" \u2726 Federation is experimental and disabled by default."));
8660
+ console.log("");
8661
+ console.log(chalk19.dim(" Enable it by setting an environment variable:"));
8662
+ console.log(chalk19.dim(' PowerShell: $env:STELLAVAULT_FEDERATION_EXPERIMENTAL = "1"'));
8663
+ console.log(chalk19.dim(" bash/zsh: export STELLAVAULT_FEDERATION_EXPERIMENTAL=1"));
8664
+ console.log("");
8665
+ console.log(chalk19.dim(" Then re-run `stellavault federate join`."));
8666
+ console.log("");
8667
+ process.exit(2);
8668
+ }
8194
8669
  const config = loadConfig();
8195
8670
  const identity = getOrCreateIdentity(options.name);
8196
8671
  console.log("");
8197
- console.log(chalk19.bold(" \u2726 Stellavault Federation"));
8672
+ console.log(chalk19.bold(" \u2726 Stellavault Federation") + chalk19.yellow(" (experimental)"));
8198
8673
  console.log(chalk19.dim(` Node: ${identity.displayName} (${identity.peerId})`));
8199
8674
  console.log("");
8200
8675
  const store = createSqliteVecStore(config.dbPath);
@@ -8207,6 +8682,13 @@ async function federateJoinCommand(options) {
8207
8682
  node.setLocalStats(stats.documentCount, topics.slice(0, 5).map((t2) => t2.topic));
8208
8683
  const search = new FederatedSearch(node, store, embedder);
8209
8684
  search.startResponder();
8685
+ const sharingCfg = loadSharingConfig();
8686
+ if (sharingCfg.myNodeLevel === 0) {
8687
+ console.log(chalk19.yellow(" \u26A0 Receive-only mode (my node level = 0)."));
8688
+ console.log(chalk19.dim(" Run `set-level 1` or higher in the federation prompt to share."));
8689
+ } else {
8690
+ console.log(chalk19.dim(` Sharing level: ${sharingCfg.myNodeLevel} (set-level <0-4> to change)`));
8691
+ }
8210
8692
  node.on("joined", (info) => {
8211
8693
  console.log(chalk19.green(` \u2726 Joined federation network`));
8212
8694
  console.log(chalk19.dim(` Topic: ${info.topic}`));
@@ -8654,7 +9136,7 @@ import chalk25 from "chalk";
8654
9136
  // packages/core/dist/intelligence/draft-generator.js
8655
9137
  init_wiki_compiler();
8656
9138
  init_config();
8657
- import { writeFileSync as writeFileSync18, mkdirSync as mkdirSync19, existsSync as existsSync19 } from "node:fs";
9139
+ import { writeFileSync as writeFileSync19, mkdirSync as mkdirSync19, existsSync as existsSync19 } from "node:fs";
8658
9140
  import { join as join24, resolve as resolve16, basename as basename5, extname as extname7 } from "node:path";
8659
9141
  function generateDraft(vaultPath, options = {}, folders = DEFAULT_FOLDERS) {
8660
9142
  const { topic, format = "blog", maxSections = 8, blueprint } = options;
@@ -8738,7 +9220,7 @@ function generateDraft(vaultPath, options = {}, folders = DEFAULT_FOLDERS) {
8738
9220
  const filename = `${timestamp}-${slug}.md`;
8739
9221
  const filePath = join24("_drafts", filename);
8740
9222
  const fullPath = resolve16(vaultPath, filePath);
8741
- writeFileSync18(fullPath, body, "utf-8");
9223
+ writeFileSync19(fullPath, body, "utf-8");
8742
9224
  return {
8743
9225
  title: draftTitle,
8744
9226
  filePath,
@@ -8934,7 +9416,7 @@ async function draftCommand(topic, options) {
8934
9416
  }
8935
9417
  }
8936
9418
  async function enhanceWithAI(vaultPath, draftPath, topic, format) {
8937
- const { readFileSync: readFileSync18, writeFileSync: writeFileSync21 } = await import("node:fs");
9419
+ const { readFileSync: readFileSync18, writeFileSync: writeFileSync22 } = await import("node:fs");
8938
9420
  const { resolve: resolve22 } = await import("node:path");
8939
9421
  const fullPath = resolve22(vaultPath, draftPath);
8940
9422
  const scaffold = readFileSync18(fullPath, "utf-8");
@@ -8979,7 +9461,7 @@ ${aiContent.text}
8979
9461
  ---
8980
9462
  *Generated by \`stellavault draft --ai\` using Claude API at ${(/* @__PURE__ */ new Date()).toISOString()}*
8981
9463
  `;
8982
- writeFileSync21(fullPath, enhanced, "utf-8");
9464
+ writeFileSync22(fullPath, enhanced, "utf-8");
8983
9465
  }
8984
9466
  } catch (err) {
8985
9467
  console.error(chalk25.yellow(` AI enhancement failed: ${err instanceof Error ? err.message : "unknown"}. Keeping rule-based draft.`));
@@ -8988,7 +9470,7 @@ ${aiContent.text}
8988
9470
 
8989
9471
  // packages/cli/dist/commands/session-cmd.js
8990
9472
  import chalk26 from "chalk";
8991
- import { writeFileSync as writeFileSync19, mkdirSync as mkdirSync20, existsSync as existsSync20, appendFileSync } from "node:fs";
9473
+ import { writeFileSync as writeFileSync20, mkdirSync as mkdirSync20, existsSync as existsSync20, appendFileSync } from "node:fs";
8992
9474
  import { resolve as resolve17, join as join25 } from "node:path";
8993
9475
  async function sessionSaveCommand(options) {
8994
9476
  const config = loadConfig();
@@ -9054,7 +9536,7 @@ async function sessionSaveCommand(options) {
9054
9536
  `# Daily Log \u2014 ${dateStr}`,
9055
9537
  ""
9056
9538
  ].join("\n");
9057
- writeFileSync19(logFile, header + entry.join("\n"), "utf-8");
9539
+ writeFileSync20(logFile, header + entry.join("\n"), "utf-8");
9058
9540
  } else {
9059
9541
  appendFileSync(logFile, entry.join("\n"), "utf-8");
9060
9542
  }
@@ -9225,7 +9707,7 @@ async function lintCommand() {
9225
9707
 
9226
9708
  // packages/cli/dist/commands/fleeting-cmd.js
9227
9709
  import chalk30 from "chalk";
9228
- import { writeFileSync as writeFileSync20, mkdirSync as mkdirSync21, existsSync as existsSync22 } from "node:fs";
9710
+ import { writeFileSync as writeFileSync21, mkdirSync as mkdirSync21, existsSync as existsSync22 } from "node:fs";
9229
9711
  import { join as join27, resolve as resolve19 } from "node:path";
9230
9712
  async function fleetingCommand(text, options) {
9231
9713
  if (!text || text.trim().length < 2) {
@@ -9259,7 +9741,7 @@ async function fleetingCommand(text, options) {
9259
9741
  "---",
9260
9742
  `*Captured via \`stellavault fleeting\` at ${now.toLocaleString("ko-KR")}*`
9261
9743
  ].join("\n");
9262
- writeFileSync20(filePath, content, "utf-8");
9744
+ writeFileSync21(filePath, content, "utf-8");
9263
9745
  console.log(chalk30.green(`Captured: ${filename}`));
9264
9746
  console.log(chalk30.dim(`Location: raw/${filename}`));
9265
9747
  console.log(chalk30.dim("Run `stellavault compile` to process into wiki."));
@@ -9680,25 +10162,25 @@ if (nodeVersion < 20) {
9680
10162
  process.exit(1);
9681
10163
  }
9682
10164
  var program = new Command();
9683
- var SV_VERSION = true ? "0.7.3" : "0.0.0-dev";
10165
+ var SV_VERSION = true ? "0.7.4" : "0.0.0-dev";
9684
10166
  program.name("stellavault").description("Stellavault \u2014 Self-compiling knowledge base for your Obsidian vault").version(SV_VERSION).option("--json", "Output in JSON format (for scripting)").option("--quiet", "Suppress non-essential output");
9685
10167
  program.command("init").description("Interactive setup wizard \u2014 get started in 3 minutes").action(initCommand);
9686
10168
  program.command("doctor").description("Diagnose setup issues (config, vault, DB, model, Node version)").action(doctorCommand);
9687
- program.command("index [vault-path]").description("Index your vault (vectorize all documents for search)").action(indexCommand);
10169
+ program.command("index [vault-path]").description("Index your vault (vectorize all documents for search)").option("--no-spinner", "Disable spinner (for CI / log redirection)").option("-v, --verbose", "Verbose progress logs instead of spinner").option("--log-skipped <file>", "Write skipped/failed file list as JSON").option("--profile-memory", "Log RSS/heap every ~100 embeddings (diagnoses memory leaks)").action((vaultPath, opts) => indexCommand(vaultPath, opts));
9688
10170
  program.command("status").description("Show index status (document count, last indexed, DB size)").action(statusCommand);
9689
10171
  program.command("search <query>").description("Search your knowledge base (hybrid BM25 + vector)").option("-l, --limit <n>", "Max results", "5").action(searchCommand);
9690
10172
  program.command("ask <question>").description("Ask a question \u2014 search, compose answer, optionally save").option("-s, --save", "Save answer as a new note in your vault").option("-q, --quotes", "Show direct quotes from sources").action((question, opts) => askCommand(question, opts));
9691
10173
  program.command("ingest <input>").description("Ingest any input (URL, file, text, PDF, YouTube) into your vault").option("-t, --tags <tags>", "Comma-separated tags").option("-s, --stage <stage>", "Note stage: fleeting, literature, permanent", "fleeting").option("--title <title>", "Override title").action((input, opts) => ingestCommand(input, opts));
9692
10174
  program.command("clip <url>").description("Clip a web page or YouTube video into your vault").option("-f, --folder <path>", "Vault subfolder for clips", "06_Research/clips").action(clipCommand);
9693
10175
  program.command("graph").description("Launch the 3D knowledge graph in your browser").action(graphCommand);
9694
- program.command("serve").description("Start MCP server (for Claude Code / Claude Desktop)").action(serveCommand);
10176
+ program.command("serve").alias("mcp").description("Start MCP server (for Claude Code / Claude Desktop). Alias: mcp").action(serveCommand);
9695
10177
  program.command("decay").description("Memory decay report \u2014 find notes you are forgetting").action(decayCommand);
9696
10178
  program.command("brief").description("Daily knowledge briefing (decay + gaps + activity)").action(briefCommand);
9697
10179
  program.command("digest").description("Weekly knowledge activity report").option("-d, --days <n>", "Period in days", "7").option("-v, --visual", "Save as .md with Mermaid charts for Obsidian").action(digestCommand);
9698
10180
  program.command("gaps").description("Detect knowledge gaps (weak connections between clusters)").action(gapsCommand);
9699
10181
  program.command("duplicates").description("Find duplicate or near-identical notes").option("-t, --threshold <n>", "Similarity threshold (0\u20131)", "0.88").action(duplicatesCommand);
9700
10182
  program.command("contradictions").description("Detect contradicting statements across your notes").action(contradictionsCommand);
9701
- program.command("review").description("Daily review \u2014 resurface fading notes for spaced repetition").option("-n, --count <n>", "Number of notes to review", "5").action(reviewCommand);
10183
+ program.command("review").description("Daily review \u2014 resurface fading notes for spaced repetition").option("-n, --count <n>", "Number of notes to review", "5").option("--json", "Output as JSON (non-interactive, for automation)").option("--seed <value>", "Deterministic seed for rotation (e.g. day-of-year)").option("--exclude <glob>", 'Exclude paths matching glob (e.g. "Templates/**")').option("--min-age <days>", "Only notes older than N days", "0").action(reviewCommand);
9702
10184
  program.command("learn").description("AI learning path \u2014 personalized recommendations based on decay + gaps").action(learnCommand);
9703
10185
  program.command("lint").description("Knowledge health check \u2014 gaps, duplicates, contradictions, stale notes").action(() => lintCommand());
9704
10186
  program.command("draft [topic]").description("Generate a draft from your knowledge (blog/report/outline/instagram/thread/script)").option("--format <type>", "Output format: blog, report, outline, instagram, thread, script").option("--ai", "Use Claude API for AI-enhanced draft (requires ANTHROPIC_API_KEY)").option("--blueprint <spec>", 'Chapter structure: "Ch1:tag1,tag2; Ch2:tag3"').action((topic, opts) => draftCommand(topic, opts));