stellavault 0.7.2 → 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,12 +697,10 @@ async function buildGraphData(store, options = {}) {
645
697
  ]);
646
698
  const edges = [];
647
699
  const edgeCounts = /* @__PURE__ */ new Map();
700
+ const docsWithVecs = docs.filter((d) => embeddings.has(d.id));
648
701
  const normalizedVecs = /* @__PURE__ */ new Map();
649
- for (const doc of docs) {
650
- const vec = embeddings.get(doc.id);
651
- if (!vec)
652
- continue;
653
- normalizedVecs.set(doc.id, normalizeVector([...vec]));
702
+ for (const doc of docsWithVecs) {
703
+ normalizedVecs.set(doc.id, normalizeVector([...embeddings.get(doc.id)]));
654
704
  }
655
705
  const docIds = [...normalizedVecs.keys()];
656
706
  const vecArray = docIds.map((id) => normalizedVecs.get(id));
@@ -1255,36 +1305,34 @@ var init_wiki_compiler = __esm({
1255
1305
  });
1256
1306
 
1257
1307
  // packages/core/dist/federation/identity.js
1258
- import { randomBytes, createHash as createHash3, createHmac } from "node:crypto";
1259
- import { readFileSync as readFileSync7, writeFileSync as writeFileSync8, existsSync as existsSync7, mkdirSync as mkdirSync9, chmodSync } from "node:fs";
1260
- 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";
1261
1310
  import { homedir as homedir4 } from "node:os";
1262
- function getOrCreateIdentity(displayName) {
1263
- if (existsSync7(IDENTITY_FILE)) {
1264
- const raw = JSON.parse(readFileSync7(IDENTITY_FILE, "utf-8"));
1265
- return {
1266
- peerId: raw.peerId,
1267
- publicKey: Buffer.from(raw.publicKey, "hex"),
1268
- secretKey: Buffer.from(raw.secretKey, "hex"),
1269
- displayName: raw.displayName,
1270
- createdAt: raw.createdAt
1271
- };
1272
- }
1273
- const secretKey = randomBytes(32);
1274
- const publicKey = createHash3("sha256").update(secretKey).digest();
1275
- const peerId = createHash3("sha256").update(publicKey).digest("hex").slice(0, 16);
1276
- 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 {
1277
1321
  peerId,
1278
1322
  publicKey,
1279
1323
  secretKey,
1280
1324
  displayName: displayName ?? `node-${peerId.slice(0, 6)}`,
1281
1325
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
1282
1326
  };
1327
+ }
1328
+ function persistIdentity(identity) {
1283
1329
  mkdirSync9(IDENTITY_DIR, { recursive: true });
1284
1330
  const content = JSON.stringify({
1331
+ version: IDENTITY_VERSION,
1332
+ algorithm: IDENTITY_ALGORITHM,
1285
1333
  peerId: identity.peerId,
1286
- publicKey: publicKey.toString("hex"),
1287
- secretKey: secretKey.toString("hex"),
1334
+ publicKey: identity.publicKey.toString("hex"),
1335
+ secretKey: identity.secretKey.toString("hex"),
1288
1336
  displayName: identity.displayName,
1289
1337
  createdAt: identity.createdAt
1290
1338
  }, null, 2);
@@ -1293,36 +1341,103 @@ function getOrCreateIdentity(displayName) {
1293
1341
  chmodSync(IDENTITY_FILE, 384);
1294
1342
  } catch {
1295
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);
1296
1364
  return identity;
1297
1365
  }
1298
- 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;
1299
1382
  var init_identity = __esm({
1300
1383
  "packages/core/dist/federation/identity.js"() {
1301
1384
  "use strict";
1302
1385
  IDENTITY_DIR = join8(homedir4(), ".stellavault", "federation");
1303
1386
  IDENTITY_FILE = join8(IDENTITY_DIR, "identity.json");
1387
+ IDENTITY_VERSION = 2;
1388
+ IDENTITY_ALGORITHM = "ed25519";
1304
1389
  }
1305
1390
  });
1306
1391
 
1307
1392
  // packages/core/dist/federation/node.js
1308
- import { createHash as createHash4 } from "node:crypto";
1393
+ import { createHash as createHash4, randomBytes as randomBytes2 } from "node:crypto";
1309
1394
  import { EventEmitter } from "node:events";
1310
- 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;
1311
1396
  var init_node = __esm({
1312
1397
  "packages/core/dist/federation/node.js"() {
1313
1398
  "use strict";
1314
1399
  init_identity();
1315
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;
1316
1405
  FederationNode = class extends EventEmitter {
1317
1406
  swarm = null;
1318
1407
  identity;
1319
1408
  peers = /* @__PURE__ */ new Map();
1409
+ connStates = /* @__PURE__ */ new WeakMap();
1410
+ replayCache = /* @__PURE__ */ new Set();
1411
+ replayQueue = [];
1320
1412
  running = false;
1321
1413
  documentCount = 0;
1322
1414
  topTopics = [];
1323
- 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) {
1324
1429
  super();
1325
- 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
+ }
1326
1441
  }
1327
1442
  get peerId() {
1328
1443
  return this.identity.peerId;
@@ -1340,7 +1455,6 @@ var init_node = __esm({
1340
1455
  this.documentCount = documentCount;
1341
1456
  this.topTopics = topTopics.slice(0, 5);
1342
1457
  }
1343
- // Design Ref: §4 — join()
1344
1458
  async join() {
1345
1459
  if (this.running)
1346
1460
  return;
@@ -1354,7 +1468,6 @@ var init_node = __esm({
1354
1468
  this.running = true;
1355
1469
  this.emit("joined", { peerId: this.peerId, topic: FEDERATION_TOPIC.toString("hex").slice(0, 16) });
1356
1470
  }
1357
- // Design Ref: §4 — joinDirect() 수동 IP 폴백
1358
1471
  async joinDirect(host, port) {
1359
1472
  const net = await import("node:net");
1360
1473
  const conn = net.connect(port, host);
@@ -1374,7 +1487,7 @@ var init_node = __esm({
1374
1487
  return;
1375
1488
  for (const [, peer] of this.peers) {
1376
1489
  try {
1377
- this.sendMessage(peer.conn, { type: "leave", peerId: this.peerId });
1490
+ this.sendSigned(peer.conn, { type: "leave", peerId: this.peerId });
1378
1491
  peer.conn.end();
1379
1492
  } catch {
1380
1493
  }
@@ -1388,30 +1501,109 @@ var init_node = __esm({
1388
1501
  getPeers() {
1389
1502
  return [...this.peers.values()].map((p) => p.info);
1390
1503
  }
1391
- // 피어에게 검색 쿼리 전송 (FederatedSearch에서 사용)
1392
1504
  sendSearchQuery(peerId, queryId, embedding, limit) {
1393
1505
  const peer = this.peers.get(peerId);
1394
1506
  if (!peer)
1395
1507
  return;
1396
- this.sendMessage(peer.conn, { type: "search_query", queryId, embedding, limit });
1508
+ this.sendSigned(peer.conn, { type: "search_query", queryId, embedding, limit });
1397
1509
  }
1398
- // 피어에게 검색 결과 응답 (FederatedSearch에서 사용)
1399
1510
  sendSearchResult(peerId, queryId, results) {
1400
1511
  const peer = this.peers.get(peerId);
1401
1512
  if (!peer)
1402
1513
  return;
1403
- this.sendMessage(peer.conn, { type: "search_result", queryId, results });
1514
+ this.sendSigned(peer.conn, { type: "search_result", queryId, results });
1404
1515
  }
1405
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
+ }
1406
1563
  handleConnection(conn) {
1407
- this.sendMessage(conn, {
1408
- type: "handshake",
1409
- peerId: this.peerId,
1410
- displayName: this.identity.displayName,
1411
- version: "0.1.0",
1412
- documentCount: this.documentCount,
1413
- topTopics: this.topTopics
1414
- });
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;
1415
1607
  let buffer = "";
1416
1608
  const MAX_BUFFER = 1024 * 1024;
1417
1609
  const MAX_MESSAGE = 64 * 1024;
@@ -1430,107 +1622,214 @@ var init_node = __esm({
1430
1622
  continue;
1431
1623
  if (line.length > MAX_MESSAGE)
1432
1624
  continue;
1433
- try {
1434
- const msg = JSON.parse(line);
1435
- this.handleMessage(conn, msg);
1436
- } catch {
1437
- }
1625
+ this.dispatchLine(conn, line);
1438
1626
  }
1439
1627
  });
1440
1628
  conn.on("close", () => {
1441
- for (const [peerId, peer] of this.peers) {
1442
- if (peer.conn === conn) {
1443
- this.peers.delete(peerId);
1444
- this.emit("peer_left", { peerId });
1445
- break;
1446
- }
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 });
1447
1636
  }
1448
1637
  });
1449
1638
  conn.on("error", () => {
1450
1639
  });
1451
1640
  }
1452
- // MED: 메시지 스키마 기본 검증
1453
- validateMessage(msg) {
1454
- if (!msg || typeof msg !== "object")
1455
- return false;
1456
- const m = msg;
1457
- if (typeof m.type !== "string")
1458
- return false;
1459
- if (m.type === "handshake" && (typeof m.peerId !== "string" || typeof m.displayName !== "string"))
1460
- return false;
1461
- if (m.type === "search_query" && (!Array.isArray(m.embedding) || m.embedding.length !== 384))
1462
- return false;
1463
- 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 });
1464
1658
  return false;
1659
+ }
1660
+ state.rlTokens -= 1;
1465
1661
  return true;
1466
1662
  }
1467
- handleMessage(conn, msg) {
1468
- 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)
1469
1717
  return;
1718
+ if (!verifySignature(publicKey, canonical, sigBuf))
1719
+ return;
1720
+ this.handleMessage(conn, state, envelope.payload, publicKey);
1721
+ }
1722
+ handleMessage(conn, state, msg, publicKey) {
1470
1723
  switch (msg.type) {
1471
- case "handshake": {
1472
- const safeName = (msg.displayName ?? "").slice(0, 50);
1473
- 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 = {
1474
1742
  peerId: msg.peerId,
1475
- displayName: safeName,
1743
+ displayName: (msg.displayName ?? "").slice(0, 50),
1476
1744
  documentCount: Math.min(msg.documentCount ?? 0, 1e6),
1477
- // 합리적 상한
1478
1745
  topTopics: (msg.topTopics ?? []).slice(0, 10),
1479
1746
  joinedAt: (/* @__PURE__ */ new Date()).toISOString(),
1480
1747
  lastSeen: (/* @__PURE__ */ new Date()).toISOString()
1481
1748
  };
1482
- this.peers.set(msg.peerId, { info: peerInfo, conn });
1483
- 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);
1484
1775
  break;
1485
1776
  }
1486
1777
  case "search_query": {
1778
+ if (!state.ready)
1779
+ return;
1487
1780
  this.emit("search_request", {
1488
- peerId: msg.queryId,
1489
- // queryId를 추적용으로 사용
1781
+ peerId: state.peerId,
1490
1782
  queryId: msg.queryId,
1491
1783
  embedding: msg.embedding,
1492
1784
  limit: msg.limit,
1493
- // respond 함수: 호출 측에서 사용
1494
- respondTo: (() => {
1495
- for (const [pid, peer] of this.peers) {
1496
- if (peer.conn === conn)
1497
- return pid;
1498
- }
1499
- return null;
1500
- })()
1785
+ respondTo: state.peerId
1501
1786
  });
1502
1787
  break;
1503
1788
  }
1504
1789
  case "search_result": {
1790
+ if (!state.ready)
1791
+ return;
1505
1792
  this.emit("search_response", {
1506
1793
  queryId: msg.queryId,
1507
1794
  results: msg.results,
1508
- peerId: (() => {
1509
- for (const [pid, peer] of this.peers) {
1510
- if (peer.conn === conn)
1511
- return pid;
1512
- }
1513
- return "unknown";
1514
- })()
1795
+ peerId: state.peerId ?? "unknown"
1515
1796
  });
1516
1797
  break;
1517
1798
  }
1518
1799
  case "leave": {
1519
- const legit = this.peers.get(msg.peerId);
1520
- 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) {
1521
1806
  this.peers.delete(msg.peerId);
1522
1807
  this.emit("peer_left", { peerId: msg.peerId });
1523
1808
  }
1524
1809
  break;
1525
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;
1526
1817
  }
1527
1818
  }
1528
- // Design Ref: §7 — JSON + newline delimiter
1529
- sendMessage(conn, msg) {
1530
- try {
1531
- conn.write(JSON.stringify(msg) + "\n");
1532
- } 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;
1533
1830
  }
1831
+ this.peers.set(state.peerId, { info: state.pendingPeerInfo, conn });
1832
+ this.emit("peer_joined", state.pendingPeerInfo);
1534
1833
  }
1535
1834
  };
1536
1835
  }
@@ -1710,10 +2009,10 @@ var init_sharing = __esm({
1710
2009
  /\b\d{6}[-]\d{7}\b/
1711
2010
  ];
1712
2011
  DEFAULT_CONFIG2 = {
1713
- defaultLevel: 2,
1714
- // 기본: 스니펫까지
1715
- myNodeLevel: 2,
1716
- // 내 노드 기본: 스니펫까지
2012
+ defaultLevel: 1,
2013
+ // 기본: 제목+유사도까지만 (스니펫 안 보냄)
2014
+ myNodeLevel: 0,
2015
+ // 내 노드 기본: 수신 전용 (명시적 set-level 필요)
1717
2016
  rules: [
1718
2017
  // 기본 규칙
1719
2018
  { pattern: "public", type: "tag", level: 4 },
@@ -1800,6 +2099,11 @@ var init_search = __esm({
1800
2099
  this.node.on("search_request", async (req) => {
1801
2100
  if (!req.respondTo)
1802
2101
  return;
2102
+ const cfg = loadSharingConfig();
2103
+ if (cfg.myNodeLevel === 0) {
2104
+ this.node.sendSearchResult(req.respondTo, req.queryId, []);
2105
+ return;
2106
+ }
1803
2107
  try {
1804
2108
  const allStores = [this.store, ...this.additionalStores];
1805
2109
  const allScored = await Promise.all(allStores.map((s) => s.searchSemantic(req.embedding, req.limit).catch(() => [])));
@@ -1835,8 +2139,13 @@ var federation_exports = {};
1835
2139
  __export(federation_exports, {
1836
2140
  FederatedSearch: () => FederatedSearch,
1837
2141
  FederationNode: () => FederationNode,
1838
- getOrCreateIdentity: () => getOrCreateIdentity
2142
+ getOrCreateIdentity: () => getOrCreateIdentity,
2143
+ isFederationExperimentalEnabled: () => isFederationExperimentalEnabled
1839
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
+ }
1840
2149
  var init_federation = __esm({
1841
2150
  "packages/core/dist/federation/index.js"() {
1842
2151
  "use strict";
@@ -3099,7 +3408,8 @@ import { Command } from "commander";
3099
3408
  // packages/cli/dist/commands/index-cmd.js
3100
3409
  import ora from "ora";
3101
3410
  import chalk from "chalk";
3102
- import { createHash as createHash6 } from "node:crypto";
3411
+ import { createHash as createHash7 } from "node:crypto";
3412
+ import { writeFileSync as writeFileSync15 } from "node:fs";
3103
3413
  import { join as join20 } from "node:path";
3104
3414
  import { homedir as homedir12 } from "node:os";
3105
3415
  import { mkdirSync as mkdirSync15 } from "node:fs";
@@ -3170,9 +3480,8 @@ function createSqliteVecStore(dbPath, dimensions = 384) {
3170
3480
  const rows = db.prepare(`
3171
3481
  SELECT chunk_id, distance
3172
3482
  FROM chunk_embeddings
3173
- WHERE embedding MATCH ?
3483
+ WHERE embedding MATCH ? AND k = ?
3174
3484
  ORDER BY distance
3175
- LIMIT ?
3176
3485
  `).all(float32Buffer(embedding), limit);
3177
3486
  return rows.map((r) => ({
3178
3487
  chunkId: r.chunk_id,
@@ -3258,6 +3567,22 @@ function createSqliteVecStore(dbPath, dimensions = 384) {
3258
3567
  }
3259
3568
  return result;
3260
3569
  },
3570
+ async findDocumentNeighbors(embedding, limit) {
3571
+ const knnK = Math.max(limit * 3, 30);
3572
+ const rows = db.prepare(`
3573
+ SELECT c.document_id, MIN(ce.distance) as distance
3574
+ FROM chunk_embeddings ce
3575
+ JOIN chunks c ON c.id = ce.chunk_id
3576
+ WHERE ce.embedding MATCH ? AND k = ?
3577
+ GROUP BY c.document_id
3578
+ ORDER BY distance
3579
+ LIMIT ?
3580
+ `).all(float32Buffer(embedding), knnK, limit * 2);
3581
+ return rows.slice(0, limit).map((r) => ({
3582
+ documentId: r.document_id,
3583
+ similarity: 1 / (1 + r.distance)
3584
+ }));
3585
+ },
3261
3586
  async close() {
3262
3587
  db.close();
3263
3588
  },
@@ -4599,7 +4924,7 @@ Please write the ${format} draft based on the context above. Save the result to
4599
4924
 
4600
4925
  // packages/core/dist/mcp/tools/agentic-graph.js
4601
4926
  import { writeFileSync as writeFileSync6, mkdirSync as mkdirSync7 } from "node:fs";
4602
- import { join as join7 } from "node:path";
4927
+ import { join as join7, resolve as resolvePath } from "node:path";
4603
4928
  function createAgenticGraphTools(store, embedder, vaultPath) {
4604
4929
  let nodeCreationCount = 0;
4605
4930
  let nodeCreationWindowStart = Date.now();
@@ -4672,8 +4997,8 @@ ${content}${relatedSection}`;
4672
4997
  const safeTitle = title.replace(/[<>:"/\\|?*]/g, "").replace(/\s+/g, " ").trim().slice(0, 80);
4673
4998
  const dir = join7(vaultPath, folder);
4674
4999
  const filePath = join7(dir, `${safeTitle}.md`);
4675
- const resolvedPath = __require("node:path").resolve(filePath);
4676
- const resolvedVault = __require("node:path").resolve(vaultPath);
5000
+ const resolvedPath = resolvePath(filePath);
5001
+ const resolvedVault = resolvePath(vaultPath);
4677
5002
  if (!resolvedPath.startsWith(resolvedVault)) {
4678
5003
  return { content: [{ type: "text", text: "Error: invalid folder path." }] };
4679
5004
  }
@@ -4739,6 +5064,7 @@ The note will appear in the graph after next index.`
4739
5064
  // packages/core/dist/mcp/server.js
4740
5065
  function createMcpServer(options) {
4741
5066
  const { store, searchEngine, embedder, vaultPath = "", decayEngine } = options;
5067
+ const ready = options.ready ?? Promise.resolve();
4742
5068
  const learningPathTool = createLearningPathTool(store);
4743
5069
  const detectGapsTool = createDetectGapsTool(store);
4744
5070
  const getEvolutionTool = createGetEvolutionTool(store);
@@ -4746,7 +5072,7 @@ function createMcpServer(options) {
4746
5072
  const askTool = createAskTool(searchEngine, vaultPath);
4747
5073
  const generateDraftTool = createGenerateDraftTool(searchEngine, vaultPath);
4748
5074
  const agenticTools = embedder ? createAgenticGraphTools(store, embedder, vaultPath) : [];
4749
- const server = new Server({ name: "stellavault", version: "0.7.2" }, { capabilities: { tools: {} } });
5075
+ const server = new Server({ name: "stellavault", version: "0.7.3" }, { capabilities: { tools: {} } });
4750
5076
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
4751
5077
  tools: [
4752
5078
  searchToolDef,
@@ -4770,6 +5096,7 @@ function createMcpServer(options) {
4770
5096
  ]
4771
5097
  }));
4772
5098
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
5099
+ await ready;
4773
5100
  const { name, arguments: args } = request.params;
4774
5101
  try {
4775
5102
  let result;
@@ -4873,8 +5200,18 @@ function createMcpServer(options) {
4873
5200
  const { createServer } = await import("node:http");
4874
5201
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => `sv-${Date.now()}` });
4875
5202
  await server.connect(transport);
5203
+ const allowedOrigins = options.corsOrigins ?? ["http://localhost", "http://127.0.0.1"];
5204
+ const allowWildcard = allowedOrigins.includes("*");
4876
5205
  const httpServer = createServer(async (req, res) => {
4877
- 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
+ }
4878
5215
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
4879
5216
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
4880
5217
  if (req.method === "OPTIONS") {
@@ -5087,11 +5424,11 @@ Author: ${pack2.author}`,
5087
5424
  init_graph_data();
5088
5425
  import express from "express";
5089
5426
  import cors from "cors";
5090
- import { randomBytes as randomBytes2 } from "node:crypto";
5427
+ import { randomBytes as randomBytes3 } from "node:crypto";
5091
5428
 
5092
5429
  // packages/core/dist/api/routes/federation.js
5093
5430
  import { Router } from "express";
5094
- function createFederationRouter(store) {
5431
+ function createFederationRouter(store, requireAuth) {
5095
5432
  const router = Router();
5096
5433
  let federationNode = null;
5097
5434
  let federationAvailable = null;
@@ -5134,7 +5471,15 @@ function createFederationRouter(store) {
5134
5471
  res.json({ available: true, active: false, peerCount: 0, peers: [], displayName: null, peerId: null });
5135
5472
  }
5136
5473
  });
5137
- 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
+ }
5138
5483
  const available = await probeFederationAvailable();
5139
5484
  if (!available) {
5140
5485
  return res.status(501).json({
@@ -5176,7 +5521,7 @@ function createFederationRouter(store) {
5176
5521
  res.status(500).json({ success: false, error: err instanceof Error ? err.message : "Federation join failed" });
5177
5522
  }
5178
5523
  });
5179
- router.post("/leave", async (_req, res) => {
5524
+ router.post("/leave", requireAuth, async (_req, res) => {
5180
5525
  if (!federationNode || !federationNode.isRunning) {
5181
5526
  return res.json({ success: true, active: false, message: "Not active" });
5182
5527
  }
@@ -5231,7 +5576,7 @@ function createKnowledgeRouter(opts) {
5231
5576
  res.status(404).json({ error: "Document not found" });
5232
5577
  return;
5233
5578
  }
5234
- const { readFileSync: readFileSync18, writeFileSync: writeFileSync21, unlinkSync } = await import("node:fs");
5579
+ const { readFileSync: readFileSync18, writeFileSync: writeFileSync22, unlinkSync } = await import("node:fs");
5235
5580
  const { join: join30, resolve: resolve22 } = await import("node:path");
5236
5581
  const [keeper, removed] = docA.content.length >= docB.content.length ? [docA, docB] : [docB, docA];
5237
5582
  const keeperPath = resolve22(join30(vaultPath, keeper.filePath));
@@ -5249,7 +5594,7 @@ function createKnowledgeRouter(opts) {
5249
5594
  > Merged from: ${removed.title} (${removed.filePath})
5250
5595
 
5251
5596
  ${removed.content}`;
5252
- writeFileSync21(keeperPath, keeperContent + appendix, "utf-8");
5597
+ writeFileSync22(keeperPath, keeperContent + appendix, "utf-8");
5253
5598
  try {
5254
5599
  unlinkSync(removedPath);
5255
5600
  } catch {
@@ -5272,7 +5617,7 @@ ${removed.content}`;
5272
5617
  res.status(400).json({ error: "clusterA, clusterB required" });
5273
5618
  return;
5274
5619
  }
5275
- const { writeFileSync: writeFileSync21, mkdirSync: mkdirSync22 } = await import("node:fs");
5620
+ const { writeFileSync: writeFileSync22, mkdirSync: mkdirSync22 } = await import("node:fs");
5276
5621
  const { join: join30, resolve: resolve22 } = await import("node:path");
5277
5622
  const nameA = clusterA.replace(/\s*\(\d+\)$/, "");
5278
5623
  const nameB = clusterB.replace(/\s*\(\d+\)$/, "");
@@ -5320,7 +5665,7 @@ ${removed.content}`;
5320
5665
  }
5321
5666
  mkdirSync22(dir, { recursive: true });
5322
5667
  const filePath = join30(dir, `${safeTitle}.md`);
5323
- writeFileSync21(filePath, content, "utf-8");
5668
+ writeFileSync22(filePath, content, "utf-8");
5324
5669
  res.json({ success: true, title: safeTitle, path: filePath });
5325
5670
  } catch (err) {
5326
5671
  console.error(err);
@@ -5332,6 +5677,7 @@ ${removed.content}`;
5332
5677
 
5333
5678
  // packages/core/dist/api/routes/ingest.js
5334
5679
  import { Router as Router3 } from "express";
5680
+ import { createHash as createHash5 } from "node:crypto";
5335
5681
  function createIngestRouter(opts) {
5336
5682
  const { store, vaultPath, requireAuth, assertNotPrivateUrl } = opts;
5337
5683
  const router = Router3();
@@ -5402,7 +5748,7 @@ function createIngestRouter(opts) {
5402
5748
  });
5403
5749
  try {
5404
5750
  const doc = {
5405
- 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),
5406
5752
  filePath: result.savedTo,
5407
5753
  title: result.title,
5408
5754
  content,
@@ -5469,12 +5815,12 @@ function createIngestRouter(opts) {
5469
5815
  return;
5470
5816
  }
5471
5817
  try {
5472
- const { writeFileSync: writeFileSync21, unlinkSync } = await import("node:fs");
5818
+ const { writeFileSync: writeFileSync22, unlinkSync } = await import("node:fs");
5473
5819
  const { join: join30 } = await import("node:path");
5474
5820
  const { tmpdir } = await import("node:os");
5475
5821
  const safeName = (file.originalname ?? "upload").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 100);
5476
5822
  const tmpPath = join30(tmpdir(), `sv-upload-${Date.now()}-${safeName}`);
5477
- writeFileSync21(tmpPath, file.buffer);
5823
+ writeFileSync22(tmpPath, file.buffer);
5478
5824
  const { extractFileContent: extractFileContent2 } = await Promise.resolve().then(() => (init_file_extractors(), file_extractors_exports));
5479
5825
  const ext = file.originalname.split(".").pop()?.toLowerCase() ?? "";
5480
5826
  const binaryExts = /* @__PURE__ */ new Set(["pdf", "docx", "pptx", "xlsx", "xls"]);
@@ -5510,7 +5856,7 @@ function createIngestRouter(opts) {
5510
5856
  });
5511
5857
  try {
5512
5858
  const doc = {
5513
- 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),
5514
5860
  filePath: result.savedTo,
5515
5861
  title: result.title,
5516
5862
  content,
@@ -5580,7 +5926,7 @@ ${desc}
5580
5926
  if (content.length > 1e4)
5581
5927
  content = content.slice(0, 1e4) + "\n\n...(truncated)";
5582
5928
  }
5583
- const { writeFileSync: writeFileSync21, mkdirSync: mkdirSync22 } = await import("node:fs");
5929
+ const { writeFileSync: writeFileSync22, mkdirSync: mkdirSync22 } = await import("node:fs");
5584
5930
  const { join: join30 } = await import("node:path");
5585
5931
  const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
5586
5932
  const clipDir = join30(vaultPath || ".", "06_Research", "clips");
@@ -5598,7 +5944,7 @@ tags: [clip${isYT ? ", youtube" : ""}]
5598
5944
  > Source: ${url}
5599
5945
 
5600
5946
  ${content}`;
5601
- writeFileSync21(join30(clipDir, fileName), md, "utf-8");
5947
+ writeFileSync22(join30(clipDir, fileName), md, "utf-8");
5602
5948
  res.json({ success: true, fileName, path: join30(clipDir, fileName) });
5603
5949
  } catch (err) {
5604
5950
  console.error(err);
@@ -5856,7 +6202,7 @@ function createAnalyticsRouter(opts) {
5856
6202
  function createApiServer(options) {
5857
6203
  const { store, searchEngine, port = 3333, vaultName = "", vaultPath = "", decayEngine, graphUiPath } = options;
5858
6204
  const app = express();
5859
- const authToken = randomBytes2(32).toString("hex");
6205
+ const authToken = randomBytes3(32).toString("hex");
5860
6206
  app.use((_req, res, next) => {
5861
6207
  res.setHeader("X-Content-Type-Options", "nosniff");
5862
6208
  res.setHeader("X-Frame-Options", "DENY");
@@ -5897,11 +6243,14 @@ function createApiServer(options) {
5897
6243
  const token = req.headers["x-stellavault-token"];
5898
6244
  if (token === authToken)
5899
6245
  return next();
5900
- if (req.query.token === authToken)
5901
- return next();
5902
- 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." });
5903
6247
  }
5904
- 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
+ }
5905
6254
  res.json({ token: authToken });
5906
6255
  });
5907
6256
  function assertNotPrivateUrl(url) {
@@ -6085,7 +6434,7 @@ function createApiServer(options) {
6085
6434
  return;
6086
6435
  }
6087
6436
  const { resolve: resolve22, join: join30 } = await import("node:path");
6088
- const { writeFileSync: writeFileSync21, readFileSync: readFileSync18 } = await import("node:fs");
6437
+ const { writeFileSync: writeFileSync22, readFileSync: readFileSync18 } = await import("node:fs");
6089
6438
  const fullPath = resolve22(vaultPath, doc.filePath);
6090
6439
  if (!fullPath.startsWith(resolve22(vaultPath))) {
6091
6440
  res.status(403).json({ error: "Access denied" });
@@ -6112,7 +6461,7 @@ function createApiServer(options) {
6112
6461
  updated = content;
6113
6462
  }
6114
6463
  }
6115
- writeFileSync21(fullPath, updated, "utf-8");
6464
+ writeFileSync22(fullPath, updated, "utf-8");
6116
6465
  await store.upsertDocument({
6117
6466
  ...doc,
6118
6467
  title: title ?? doc.title,
@@ -6301,7 +6650,7 @@ function createApiServer(options) {
6301
6650
  app.get("/api/sync/status", (_req, res) => {
6302
6651
  res.json(syncState);
6303
6652
  });
6304
- app.use("/api/federate", createFederationRouter(store));
6653
+ app.use("/api/federate", createFederationRouter(store, requireAuth));
6305
6654
  if (graphUiPath) {
6306
6655
  app.get(/^(?!\/api\/)(?!.*\.[a-z0-9]+$).*$/i, (_req, res) => {
6307
6656
  res.sendFile(`${graphUiPath}/index.html`);
@@ -6749,7 +7098,7 @@ async function captureVoice(audioPath, options) {
6749
7098
  }
6750
7099
 
6751
7100
  // packages/core/dist/cloud/sync.js
6752
- 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";
6753
7102
  import { readFileSync as readFileSync14, writeFileSync as writeFileSync14, existsSync as existsSync14, mkdirSync as mkdirSync14, chmodSync as chmodSync2 } from "node:fs";
6754
7103
  import { join as join15 } from "node:path";
6755
7104
  import { homedir as homedir7 } from "node:os";
@@ -6757,7 +7106,7 @@ var CLOUD_DIR = join15(homedir7(), ".stellavault", "cloud");
6757
7106
  var KEY_FILE = join15(CLOUD_DIR, "encryption.key");
6758
7107
  var SYNC_STATE_FILE = join15(CLOUD_DIR, "sync-state.json");
6759
7108
  function encrypt(data, key) {
6760
- const iv = randomBytes3(16);
7109
+ const iv = randomBytes4(16);
6761
7110
  const cipher = createCipheriv("aes-256-gcm", key, iv);
6762
7111
  const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
6763
7112
  const tag = cipher.getAuthTag();
@@ -6771,14 +7120,14 @@ function decrypt(encrypted, key, iv, tag) {
6771
7120
  function getOrCreateEncryptionKey(userKey) {
6772
7121
  mkdirSync14(CLOUD_DIR, { recursive: true });
6773
7122
  if (userKey) {
6774
- const key2 = createHash5("sha256").update(userKey).digest();
7123
+ const key2 = createHash6("sha256").update(userKey).digest();
6775
7124
  writeFileSync14(KEY_FILE, key2.toString("hex"), "utf-8");
6776
7125
  return key2;
6777
7126
  }
6778
7127
  if (existsSync14(KEY_FILE)) {
6779
7128
  return Buffer.from(readFileSync14(KEY_FILE, "utf-8").trim(), "hex");
6780
7129
  }
6781
- const key = randomBytes3(32);
7130
+ const key = randomBytes4(32);
6782
7131
  writeFileSync14(KEY_FILE, key.toString("hex"), { encoding: "utf-8", mode: 384 });
6783
7132
  try {
6784
7133
  chmodSync2(KEY_FILE, 384);
@@ -6805,7 +7154,7 @@ async function s3Put(config, objectKey, data, contentType = "application/octet-s
6805
7154
  "Content-Type": contentType,
6806
7155
  "Content-Length": String(data.length),
6807
7156
  "x-amz-date": date,
6808
- "x-amz-content-sha256": createHash5("sha256").update(data).digest("hex"),
7157
+ "x-amz-content-sha256": createHash6("sha256").update(data).digest("hex"),
6809
7158
  // R2는 Bearer token 지원
6810
7159
  "Authorization": `Bearer ${secretAccessKey}`
6811
7160
  },
@@ -6902,24 +7251,27 @@ var CREDITS_FILE = join19(homedir11(), ".stellavault", "federation", "credits.js
6902
7251
 
6903
7252
  // packages/core/dist/index.js
6904
7253
  init_retry();
7254
+ init_math();
6905
7255
  init_indexer();
6906
- function createKnowledgeHub(config) {
7256
+ function createKnowledgeHub(config, options = {}) {
6907
7257
  const embedder = createLocalEmbedder(config.embedding.localModel);
6908
7258
  const dims = embedder.dimensions;
6909
7259
  const store = createSqliteVecStore(config.dbPath, dims);
6910
7260
  const searchEngine = createSearchEngine({ store, embedder, rrfK: config.search.rrfK });
6911
- const mcpServer = createMcpServer({ store, searchEngine, vaultPath: config.vaultPath });
7261
+ const mcpServer = createMcpServer({ store, searchEngine, vaultPath: config.vaultPath, ready: options.ready });
6912
7262
  return { store, embedder, searchEngine, mcpServer, config };
6913
7263
  }
6914
7264
 
6915
7265
  // packages/cli/dist/commands/index-cmd.js
6916
7266
  function getVaultDbPath(vaultPath) {
6917
- const hash = createHash6("sha256").update(vaultPath).digest("hex").slice(0, 8);
7267
+ const hash = createHash7("sha256").update(vaultPath).digest("hex").slice(0, 8);
6918
7268
  const dir = join20(homedir12(), ".stellavault", "vaults");
6919
7269
  mkdirSync15(dir, { recursive: true });
6920
7270
  return join20(dir, `${hash}.db`);
6921
7271
  }
6922
- async function indexCommand(vaultPath) {
7272
+ async function indexCommand(vaultPath, opts = {}) {
7273
+ if (opts.profileMemory)
7274
+ process.env.STELLAVAULT_PROFILE_MEMORY = "1";
6923
7275
  const config = loadConfig();
6924
7276
  const vault2 = vaultPath ?? config.vaultPath;
6925
7277
  if (!vault2) {
@@ -6936,28 +7288,85 @@ async function indexCommand(vaultPath) {
6936
7288
  } catch {
6937
7289
  }
6938
7290
  }
6939
- const spinner = ora("Initializing...").start();
7291
+ const spinnerEnabled = !opts.noSpinner && !opts.verbose && process.stderr.isTTY;
7292
+ const spinner = ora({ text: "Initializing...", isEnabled: spinnerEnabled }).start();
6940
7293
  const store = createSqliteVecStore(dbPath);
6941
- await store.initialize();
6942
- spinner.text = "Loading embedding model...";
6943
- const embedder = createLocalEmbedder(config.embedding.localModel);
6944
- await embedder.initialize();
6945
- spinner.text = "Starting indexing...";
6946
- const result = await indexVault(vault2, {
6947
- store,
6948
- embedder,
6949
- chunkOptions: config.chunking,
6950
- onProgress(current, total, doc) {
6951
- spinner.text = `[${current}/${total}] ${doc.title}`;
7294
+ const cleanupSpinner = () => {
7295
+ try {
7296
+ spinner.stop();
7297
+ } catch {
6952
7298
  }
7299
+ };
7300
+ process.once("uncaughtException", cleanupSpinner);
7301
+ process.once("SIGINT", () => {
7302
+ cleanupSpinner();
7303
+ process.exit(130);
6953
7304
  });
6954
- await store.close();
6955
- spinner.stop();
6956
- console.log("");
6957
- console.log(chalk.green("\u2705 Indexing complete"));
6958
- console.log(` \u{1F4C4} Indexed: ${result.indexed} | \u23ED\uFE0F Skipped: ${result.skipped} | \u{1F5D1}\uFE0F Deleted: ${result.deleted}${result.failed ? ` | \u274C Failed: ${result.failed}` : ""}`);
6959
- console.log(` \u{1F9E9} Chunks: ${result.totalChunks} | \u23F1 ${(result.elapsedMs / 1e3).toFixed(1)}s`);
6960
- 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
+ }
6961
7370
  }
6962
7371
 
6963
7372
  // packages/cli/dist/commands/search-cmd.js
@@ -7005,14 +7414,29 @@ async function searchCommand(query, options, cmd) {
7005
7414
  import chalk3 from "chalk";
7006
7415
  async function serveCommand() {
7007
7416
  const config = loadConfig();
7008
- const hub = createKnowledgeHub(config);
7009
- await hub.store.initialize();
7010
- await hub.embedder.initialize();
7011
- const stats = await hub.store.getStats();
7012
- console.error(chalk3.green("\u{1F680} MCP Server running (stdio mode)"));
7013
- 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"));
7014
7423
  console.error(chalk3.dim("\u{1F4A1} Claude Code: claude mcp add stellavault -- stellavault serve"));
7015
- 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;
7016
7440
  }
7017
7441
 
7018
7442
  // packages/cli/dist/commands/status-cmd.js
@@ -7026,14 +7450,34 @@ async function statusCommand(_opts, cmd) {
7026
7450
  const stats = await store.getStats();
7027
7451
  const topics = await store.getTopics();
7028
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
+ }
7029
7463
  if (jsonMode) {
7030
- 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));
7031
7472
  return;
7032
7473
  }
7033
7474
  console.log("");
7034
7475
  console.log(chalk4.bold("\u{1F4CA} Stellavault Status"));
7035
7476
  console.log("\u2500".repeat(40));
7036
- 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
+ }
7037
7481
  console.log(` \u{1F9E9} Chunks: ${stats.chunkCount}`);
7038
7482
  console.log(` \u{1F550} Last indexed: ${stats.lastIndexed ?? "never"}`);
7039
7483
  console.log(` \u{1F4BE} DB: ${config.dbPath}`);
@@ -7153,7 +7597,7 @@ async function openBrowser(url) {
7153
7597
 
7154
7598
  // packages/cli/dist/commands/card-cmd.js
7155
7599
  import chalk6 from "chalk";
7156
- import { writeFileSync as writeFileSync15 } from "node:fs";
7600
+ import { writeFileSync as writeFileSync16 } from "node:fs";
7157
7601
  import { resolve as resolve12 } from "node:path";
7158
7602
  async function cardCommand(options) {
7159
7603
  const output = options.output ?? "knowledge-card.svg";
@@ -7164,7 +7608,7 @@ async function cardCommand(options) {
7164
7608
  if (!res.ok)
7165
7609
  throw new Error(`API error: ${res.status}. Is 'stellavault graph' running?`);
7166
7610
  const svg = await res.text();
7167
- writeFileSync15(outPath, svg, "utf-8");
7611
+ writeFileSync16(outPath, svg, "utf-8");
7168
7612
  console.error(chalk6.green(`\u2705 Profile card saved: ${outPath}`));
7169
7613
  console.error(chalk6.dim(" Embed in GitHub README:"));
7170
7614
  console.error(chalk6.dim(` ![Knowledge Card](${output})`));
@@ -7215,8 +7659,8 @@ async function packExportCommand(name, options) {
7215
7659
  }
7216
7660
  const outPath = resolve13(process.cwd(), options.output ?? `${name}.sv-pack`);
7217
7661
  const content = readFileSync15(srcPath, "utf-8");
7218
- const { writeFileSync: writeFileSync21 } = await import("node:fs");
7219
- writeFileSync21(outPath, content);
7662
+ const { writeFileSync: writeFileSync22 } = await import("node:fs");
7663
+ writeFileSync22(outPath, content);
7220
7664
  console.error(chalk7.green(`\u2705 Exported: ${outPath}`));
7221
7665
  }
7222
7666
  async function packImportCommand(filePath) {
@@ -7373,11 +7817,29 @@ function runScript(scriptPath, cwd) {
7373
7817
  // packages/cli/dist/commands/review-cmd.js
7374
7818
  import chalk10 from "chalk";
7375
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
+ }
7376
7835
  async function reviewCommand(options) {
7377
7836
  const config = loadConfig();
7378
7837
  const hub = createKnowledgeHub(config);
7379
7838
  const count = parseInt(options.count ?? "5", 10);
7380
- 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..."));
7381
7843
  await hub.store.initialize();
7382
7844
  const db = hub.store.getDb();
7383
7845
  if (!db) {
@@ -7385,7 +7847,41 @@ async function reviewCommand(options) {
7385
7847
  process.exit(1);
7386
7848
  }
7387
7849
  const decayEngine = new DecayEngine(db);
7388
- 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
+ }
7389
7885
  if (decaying.length === 0) {
7390
7886
  console.log(chalk10.green("\n\u2728 All knowledge is healthy! No notes to review."));
7391
7887
  return;
@@ -7534,7 +8030,7 @@ async function gapsCommand() {
7534
8030
 
7535
8031
  // packages/cli/dist/commands/clip-cmd.js
7536
8032
  import chalk13 from "chalk";
7537
- import { writeFileSync as writeFileSync16, mkdirSync as mkdirSync17 } from "node:fs";
8033
+ import { writeFileSync as writeFileSync17, mkdirSync as mkdirSync17 } from "node:fs";
7538
8034
  import { join as join22 } from "node:path";
7539
8035
  async function clipCommand(url, options) {
7540
8036
  if (!url) {
@@ -7583,7 +8079,7 @@ async function clipCommand(url, options) {
7583
8079
  "",
7584
8080
  content
7585
8081
  ].join("\n");
7586
- writeFileSync16(filePath, md, "utf-8");
8082
+ writeFileSync17(filePath, md, "utf-8");
7587
8083
  console.log(chalk13.green(`\u2705 Saved: ${fileName}`));
7588
8084
  console.log(chalk13.dim(` \u2192 ${filePath}`));
7589
8085
  console.log(chalk13.dim(" \u{1F4A1} Run stellavault index to make it searchable"));
@@ -7784,7 +8280,7 @@ async function digestCommand(options) {
7784
8280
  \u{1F9E0} Health: R=${report.averageR} | Decaying ${report.decayingCount} | Critical ${report.criticalCount}`);
7785
8281
  console.log(chalk15.dim("\n\u2550".repeat(50)));
7786
8282
  if (options.visual) {
7787
- const { writeFileSync: writeFileSync21, mkdirSync: mkdirSync22, existsSync: existsSync25 } = await import("node:fs");
8283
+ const { writeFileSync: writeFileSync22, mkdirSync: mkdirSync22, existsSync: existsSync25 } = await import("node:fs");
7788
8284
  const { join: join30, resolve: resolve22 } = await import("node:path");
7789
8285
  const outputDir = resolve22(config.vaultPath, "_stellavault/digests");
7790
8286
  if (!existsSync25(outputDir))
@@ -7829,7 +8325,7 @@ async function digestCommand(options) {
7829
8325
  "---",
7830
8326
  `*Generated by \`stellavault digest --visual\` on ${(/* @__PURE__ */ new Date()).toISOString()}*`
7831
8327
  ].filter(Boolean).join("\n");
7832
- writeFileSync21(outputPath, md, "utf-8");
8328
+ writeFileSync22(outputPath, md, "utf-8");
7833
8329
  console.log(chalk15.green(`
7834
8330
  Visual digest saved: ${filename}`));
7835
8331
  console.log(chalk15.dim(`Open in Obsidian to see Mermaid charts.`));
@@ -7838,7 +8334,7 @@ Visual digest saved: ${filename}`));
7838
8334
 
7839
8335
  // packages/cli/dist/commands/init-cmd.js
7840
8336
  import { createInterface as createInterface2 } from "node:readline";
7841
- 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";
7842
8338
  import { join as join23 } from "node:path";
7843
8339
  import { homedir as homedir14 } from "node:os";
7844
8340
  import ora2 from "ora";
@@ -7886,7 +8382,7 @@ async function initCommand() {
7886
8382
  search: { defaultLimit: 10, rrfK: 60 },
7887
8383
  mcp: { mode: "stdio", port: 3333 }
7888
8384
  };
7889
- 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");
7890
8386
  console.log(chalk16.dim(` Config saved: ~/.stellavault.json`));
7891
8387
  console.log("");
7892
8388
  console.log(chalk16.cyan(" Step 2/3") + " \u2014 Indexing your vault");
@@ -7984,7 +8480,7 @@ Andrej Karpathy's approach: every session auto-compiles into structured knowledg
7984
8480
  }
7985
8481
  ];
7986
8482
  for (const s of samples) {
7987
- writeFileSync17(join23(rawDir, s.file), s.content, "utf-8");
8483
+ writeFileSync18(join23(rawDir, s.file), s.content, "utf-8");
7988
8484
  }
7989
8485
  const reSpinner = ora2({ text: " Indexing sample notes...", indent: 2 }).start();
7990
8486
  const reResult = await indexVault(vaultPath, {
@@ -8158,10 +8654,22 @@ async function contradictionsCommand(_opts, cmd) {
8158
8654
  import { createInterface as createInterface3 } from "node:readline";
8159
8655
  import chalk19 from "chalk";
8160
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
+ }
8161
8669
  const config = loadConfig();
8162
8670
  const identity = getOrCreateIdentity(options.name);
8163
8671
  console.log("");
8164
- console.log(chalk19.bold(" \u2726 Stellavault Federation"));
8672
+ console.log(chalk19.bold(" \u2726 Stellavault Federation") + chalk19.yellow(" (experimental)"));
8165
8673
  console.log(chalk19.dim(` Node: ${identity.displayName} (${identity.peerId})`));
8166
8674
  console.log("");
8167
8675
  const store = createSqliteVecStore(config.dbPath);
@@ -8174,6 +8682,13 @@ async function federateJoinCommand(options) {
8174
8682
  node.setLocalStats(stats.documentCount, topics.slice(0, 5).map((t2) => t2.topic));
8175
8683
  const search = new FederatedSearch(node, store, embedder);
8176
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
+ }
8177
8692
  node.on("joined", (info) => {
8178
8693
  console.log(chalk19.green(` \u2726 Joined federation network`));
8179
8694
  console.log(chalk19.dim(` Topic: ${info.topic}`));
@@ -8621,7 +9136,7 @@ import chalk25 from "chalk";
8621
9136
  // packages/core/dist/intelligence/draft-generator.js
8622
9137
  init_wiki_compiler();
8623
9138
  init_config();
8624
- 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";
8625
9140
  import { join as join24, resolve as resolve16, basename as basename5, extname as extname7 } from "node:path";
8626
9141
  function generateDraft(vaultPath, options = {}, folders = DEFAULT_FOLDERS) {
8627
9142
  const { topic, format = "blog", maxSections = 8, blueprint } = options;
@@ -8705,7 +9220,7 @@ function generateDraft(vaultPath, options = {}, folders = DEFAULT_FOLDERS) {
8705
9220
  const filename = `${timestamp}-${slug}.md`;
8706
9221
  const filePath = join24("_drafts", filename);
8707
9222
  const fullPath = resolve16(vaultPath, filePath);
8708
- writeFileSync18(fullPath, body, "utf-8");
9223
+ writeFileSync19(fullPath, body, "utf-8");
8709
9224
  return {
8710
9225
  title: draftTitle,
8711
9226
  filePath,
@@ -8901,7 +9416,7 @@ async function draftCommand(topic, options) {
8901
9416
  }
8902
9417
  }
8903
9418
  async function enhanceWithAI(vaultPath, draftPath, topic, format) {
8904
- const { readFileSync: readFileSync18, writeFileSync: writeFileSync21 } = await import("node:fs");
9419
+ const { readFileSync: readFileSync18, writeFileSync: writeFileSync22 } = await import("node:fs");
8905
9420
  const { resolve: resolve22 } = await import("node:path");
8906
9421
  const fullPath = resolve22(vaultPath, draftPath);
8907
9422
  const scaffold = readFileSync18(fullPath, "utf-8");
@@ -8946,7 +9461,7 @@ ${aiContent.text}
8946
9461
  ---
8947
9462
  *Generated by \`stellavault draft --ai\` using Claude API at ${(/* @__PURE__ */ new Date()).toISOString()}*
8948
9463
  `;
8949
- writeFileSync21(fullPath, enhanced, "utf-8");
9464
+ writeFileSync22(fullPath, enhanced, "utf-8");
8950
9465
  }
8951
9466
  } catch (err) {
8952
9467
  console.error(chalk25.yellow(` AI enhancement failed: ${err instanceof Error ? err.message : "unknown"}. Keeping rule-based draft.`));
@@ -8955,7 +9470,7 @@ ${aiContent.text}
8955
9470
 
8956
9471
  // packages/cli/dist/commands/session-cmd.js
8957
9472
  import chalk26 from "chalk";
8958
- 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";
8959
9474
  import { resolve as resolve17, join as join25 } from "node:path";
8960
9475
  async function sessionSaveCommand(options) {
8961
9476
  const config = loadConfig();
@@ -9021,7 +9536,7 @@ async function sessionSaveCommand(options) {
9021
9536
  `# Daily Log \u2014 ${dateStr}`,
9022
9537
  ""
9023
9538
  ].join("\n");
9024
- writeFileSync19(logFile, header + entry.join("\n"), "utf-8");
9539
+ writeFileSync20(logFile, header + entry.join("\n"), "utf-8");
9025
9540
  } else {
9026
9541
  appendFileSync(logFile, entry.join("\n"), "utf-8");
9027
9542
  }
@@ -9192,7 +9707,7 @@ async function lintCommand() {
9192
9707
 
9193
9708
  // packages/cli/dist/commands/fleeting-cmd.js
9194
9709
  import chalk30 from "chalk";
9195
- 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";
9196
9711
  import { join as join27, resolve as resolve19 } from "node:path";
9197
9712
  async function fleetingCommand(text, options) {
9198
9713
  if (!text || text.trim().length < 2) {
@@ -9226,7 +9741,7 @@ async function fleetingCommand(text, options) {
9226
9741
  "---",
9227
9742
  `*Captured via \`stellavault fleeting\` at ${now.toLocaleString("ko-KR")}*`
9228
9743
  ].join("\n");
9229
- writeFileSync20(filePath, content, "utf-8");
9744
+ writeFileSync21(filePath, content, "utf-8");
9230
9745
  console.log(chalk30.green(`Captured: ${filename}`));
9231
9746
  console.log(chalk30.dim(`Location: raw/${filename}`));
9232
9747
  console.log(chalk30.dim("Run `stellavault compile` to process into wiki."));
@@ -9647,25 +10162,25 @@ if (nodeVersion < 20) {
9647
10162
  process.exit(1);
9648
10163
  }
9649
10164
  var program = new Command();
9650
- var SV_VERSION = true ? "0.7.2" : "0.0.0-dev";
10165
+ var SV_VERSION = true ? "0.7.4" : "0.0.0-dev";
9651
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");
9652
10167
  program.command("init").description("Interactive setup wizard \u2014 get started in 3 minutes").action(initCommand);
9653
10168
  program.command("doctor").description("Diagnose setup issues (config, vault, DB, model, Node version)").action(doctorCommand);
9654
- 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));
9655
10170
  program.command("status").description("Show index status (document count, last indexed, DB size)").action(statusCommand);
9656
10171
  program.command("search <query>").description("Search your knowledge base (hybrid BM25 + vector)").option("-l, --limit <n>", "Max results", "5").action(searchCommand);
9657
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));
9658
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));
9659
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);
9660
10175
  program.command("graph").description("Launch the 3D knowledge graph in your browser").action(graphCommand);
9661
- 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);
9662
10177
  program.command("decay").description("Memory decay report \u2014 find notes you are forgetting").action(decayCommand);
9663
10178
  program.command("brief").description("Daily knowledge briefing (decay + gaps + activity)").action(briefCommand);
9664
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);
9665
10180
  program.command("gaps").description("Detect knowledge gaps (weak connections between clusters)").action(gapsCommand);
9666
10181
  program.command("duplicates").description("Find duplicate or near-identical notes").option("-t, --threshold <n>", "Similarity threshold (0\u20131)", "0.88").action(duplicatesCommand);
9667
10182
  program.command("contradictions").description("Detect contradicting statements across your notes").action(contradictionsCommand);
9668
- 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);
9669
10184
  program.command("learn").description("AI learning path \u2014 personalized recommendations based on decay + gaps").action(learnCommand);
9670
10185
  program.command("lint").description("Knowledge health check \u2014 gaps, duplicates, contradictions, stale notes").action(() => lintCommand());
9671
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));