@xdarkicex/openclaw-memory-libravdb 1.4.67 → 1.4.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -9467,7 +9467,7 @@ var require_service_config = __commonJS({
9467
9467
  exports2.validateRetryThrottling = validateRetryThrottling;
9468
9468
  exports2.validateServiceConfig = validateServiceConfig;
9469
9469
  exports2.extractAndSelectServiceConfig = extractAndSelectServiceConfig;
9470
- var os3 = __require("os");
9470
+ var os4 = __require("os");
9471
9471
  var constants_1 = require_constants();
9472
9472
  var DURATION_REGEX = /^\d+(\.\d{1,9})?s$/;
9473
9473
  var CLIENT_LANGUAGE_STRING = "node";
@@ -9766,7 +9766,7 @@ var require_service_config = __commonJS({
9766
9766
  if (Array.isArray(validatedConfig.clientHostname)) {
9767
9767
  let hostnameMatched = false;
9768
9768
  for (const hostname2 of validatedConfig.clientHostname) {
9769
- if (hostname2 === os3.hostname()) {
9769
+ if (hostname2 === os4.hostname()) {
9770
9770
  hostnameMatched = true;
9771
9771
  }
9772
9772
  }
@@ -24066,7 +24066,7 @@ var require_subchannel_call = __commonJS({
24066
24066
  Object.defineProperty(exports2, "__esModule", { value: true });
24067
24067
  exports2.Http2SubchannelCall = void 0;
24068
24068
  var http2 = __require("http2");
24069
- var os3 = __require("os");
24069
+ var os4 = __require("os");
24070
24070
  var constants_1 = require_constants();
24071
24071
  var metadata_1 = require_metadata();
24072
24072
  var stream_decoder_1 = require_stream_decoder();
@@ -24074,7 +24074,7 @@ var require_subchannel_call = __commonJS({
24074
24074
  var constants_2 = require_constants();
24075
24075
  var TRACER_NAME = "subchannel_call";
24076
24076
  function getSystemErrorName(errno) {
24077
- for (const [name, num] of Object.entries(os3.constants.errno)) {
24077
+ for (const [name, num] of Object.entries(os4.constants.errno)) {
24078
24078
  if (num === errno) {
24079
24079
  return name;
24080
24080
  }
@@ -34523,6 +34523,7 @@ function isRecord(value) {
34523
34523
  // src/markdown-ingest.ts
34524
34524
  import fs2 from "node:fs";
34525
34525
  import fsp2 from "node:fs/promises";
34526
+ import os2 from "node:os";
34526
34527
  import path2 from "node:path";
34527
34528
 
34528
34529
  // node_modules/.pnpm/@bufbuild+protobuf@1.7.2/node_modules/@bufbuild/protobuf/dist/proxy/index.js
@@ -38484,7 +38485,8 @@ function createMarkdownIngestionHandle(cfg, getRpc, logger = console, fsApi = cr
38484
38485
  roots: genericRoots,
38485
38486
  include: cfg.markdownIngestionInclude,
38486
38487
  exclude: cfg.markdownIngestionExclude,
38487
- debounceMs: cfg.markdownIngestionDebounceMs ?? DEFAULT_DEBOUNCE_MS2
38488
+ debounceMs: cfg.markdownIngestionDebounceMs ?? DEFAULT_DEBOUNCE_MS2,
38489
+ snapshotPath: resolveMarkdownSnapshotPath("generic", cfg.markdownIngestionSnapshotPath)
38488
38490
  },
38489
38491
  getRpc,
38490
38492
  logger,
@@ -38493,7 +38495,7 @@ function createMarkdownIngestionHandle(cfg, getRpc, logger = console, fsApi = cr
38493
38495
  );
38494
38496
  }
38495
38497
  const obsidianRoots = normalizeMarkdownRoots(cfg.markdownIngestionObsidianRoots);
38496
- if (cfg.markdownIngestionObsidianEnabled !== false && obsidianRoots.length > 0) {
38498
+ if (cfg.markdownIngestionObsidianEnabled === true && obsidianRoots.length > 0) {
38497
38499
  adapters.push(
38498
38500
  new DirectoryMarkdownSourceAdapter(
38499
38501
  "obsidian",
@@ -38501,7 +38503,8 @@ function createMarkdownIngestionHandle(cfg, getRpc, logger = console, fsApi = cr
38501
38503
  roots: obsidianRoots,
38502
38504
  include: cfg.markdownIngestionObsidianInclude,
38503
38505
  exclude: cfg.markdownIngestionObsidianExclude,
38504
- debounceMs: cfg.markdownIngestionObsidianDebounceMs ?? cfg.markdownIngestionDebounceMs ?? DEFAULT_DEBOUNCE_MS2
38506
+ debounceMs: cfg.markdownIngestionObsidianDebounceMs ?? cfg.markdownIngestionDebounceMs ?? DEFAULT_DEBOUNCE_MS2,
38507
+ snapshotPath: resolveMarkdownSnapshotPath("obsidian", cfg.markdownIngestionObsidianSnapshotPath)
38505
38508
  },
38506
38509
  getRpc,
38507
38510
  logger,
@@ -38557,6 +38560,7 @@ var DirectoryMarkdownSourceAdapter = class {
38557
38560
  fsApi;
38558
38561
  getRpc;
38559
38562
  logger;
38563
+ snapshotPath;
38560
38564
  states = /* @__PURE__ */ new Map();
38561
38565
  fileStates = /* @__PURE__ */ new Map();
38562
38566
  activeScans = /* @__PURE__ */ new Set();
@@ -38565,6 +38569,8 @@ var DirectoryMarkdownSourceAdapter = class {
38565
38569
  started = false;
38566
38570
  ingestQueue = null;
38567
38571
  stopping = false;
38572
+ snapshotLoaded = false;
38573
+ snapshotDirty = false;
38568
38574
  constructor(kind, config, getRpc, logger, fsApi) {
38569
38575
  this.kind = kind;
38570
38576
  this.roots = config.roots;
@@ -38574,6 +38580,7 @@ var DirectoryMarkdownSourceAdapter = class {
38574
38580
  this.fsApi = fsApi;
38575
38581
  this.getRpc = getRpc;
38576
38582
  this.logger = logger;
38583
+ this.snapshotPath = config.snapshotPath ?? resolveMarkdownSnapshotPath(kind);
38577
38584
  this.tokenizerId = DEFAULT_TOKENIZER_ID;
38578
38585
  this.coreDoc = true;
38579
38586
  }
@@ -38581,6 +38588,7 @@ var DirectoryMarkdownSourceAdapter = class {
38581
38588
  if (this.started) {
38582
38589
  return;
38583
38590
  }
38591
+ await this.loadSnapshot();
38584
38592
  this.started = true;
38585
38593
  this.stopping = false;
38586
38594
  await this.refresh();
@@ -38608,8 +38616,10 @@ var DirectoryMarkdownSourceAdapter = class {
38608
38616
  if (this.activeScans.size > 0) {
38609
38617
  await Promise.allSettled([...this.activeScans]);
38610
38618
  }
38619
+ await this.saveSnapshotIfDirty();
38611
38620
  this.states.clear();
38612
38621
  this.fileStates.clear();
38622
+ this.snapshotLoaded = false;
38613
38623
  this.started = false;
38614
38624
  }
38615
38625
  getRootState(root) {
@@ -38625,7 +38635,7 @@ var DirectoryMarkdownSourceAdapter = class {
38625
38635
  dirty: false,
38626
38636
  timer: null
38627
38637
  },
38628
- knownFiles: /* @__PURE__ */ new Set(),
38638
+ knownFiles: this.snapshotFilesForRoot(resolved),
38629
38639
  directoryWatchers: /* @__PURE__ */ new Map()
38630
38640
  };
38631
38641
  this.states.set(resolved, created);
@@ -38642,12 +38652,16 @@ var DirectoryMarkdownSourceAdapter = class {
38642
38652
  }
38643
38653
  rootState.scanState.scanning = true;
38644
38654
  const scan = (async () => {
38655
+ const stats = createScanStats();
38656
+ const startedAt = Date.now();
38645
38657
  try {
38646
38658
  const currentFiles = /* @__PURE__ */ new Set();
38647
- await this.walkDirectory(rootState, rootState.root, currentFiles);
38659
+ await this.walkDirectory(rootState, rootState.root, currentFiles, stats);
38648
38660
  if (!this.stopping) {
38649
- await this.pruneDeletedFiles(rootState, currentFiles);
38661
+ await this.pruneDeletedFiles(rootState, currentFiles, stats);
38650
38662
  rootState.knownFiles = currentFiles;
38663
+ await this.saveSnapshotIfDirty();
38664
+ this.logScanStats(rootState.root, stats, Date.now() - startedAt);
38651
38665
  }
38652
38666
  } finally {
38653
38667
  rootState.scanState.scanning = false;
@@ -38684,7 +38698,12 @@ var DirectoryMarkdownSourceAdapter = class {
38684
38698
  });
38685
38699
  }, this.debounceMs);
38686
38700
  }
38687
- async walkDirectory(rootState, dir, currentFiles) {
38701
+ async walkDirectory(rootState, dir, currentFiles, stats) {
38702
+ if (this.shouldPruneDirectory(rootState.root, dir)) {
38703
+ stats.directoriesPruned++;
38704
+ return;
38705
+ }
38706
+ stats.directoriesScanned++;
38688
38707
  await this.ensureDirectoryWatcher(rootState, dir);
38689
38708
  let entries;
38690
38709
  try {
@@ -38702,25 +38721,42 @@ var DirectoryMarkdownSourceAdapter = class {
38702
38721
  }
38703
38722
  const child = path2.join(dir, entry.name);
38704
38723
  if (entry.isDirectory()) {
38705
- await this.walkDirectory(rootState, child, currentFiles);
38724
+ await this.walkDirectory(rootState, child, currentFiles, stats);
38706
38725
  continue;
38707
38726
  }
38708
38727
  if (!entry.isFile() || !isMarkdownFile(entry.name)) {
38709
38728
  continue;
38710
38729
  }
38730
+ stats.markdownFilesSeen++;
38711
38731
  if (!this.shouldIncludeFile(rootState.root, child)) {
38732
+ stats.filesSkipped++;
38712
38733
  continue;
38713
38734
  }
38735
+ stats.filesIncluded++;
38714
38736
  currentFiles.add(child);
38715
38737
  try {
38716
- await this.syncMarkdownFile(rootState, child);
38738
+ const result = await this.syncMarkdownFile(rootState, child);
38739
+ recordSyncResult(stats, result);
38717
38740
  } catch (error) {
38741
+ stats.syncErrors++;
38718
38742
  if (!this.stopping) {
38719
38743
  this.logger.warn?.(`[markdown-ingest] sync failed for ${child}: ${formatError(error)}`);
38720
38744
  }
38721
38745
  }
38722
38746
  }
38723
38747
  }
38748
+ shouldPruneDirectory(root, dir) {
38749
+ const relative = toPosixPath(path2.relative(root, dir));
38750
+ if (!relative || relative === "." || relative.startsWith("..")) {
38751
+ return false;
38752
+ }
38753
+ for (const pattern of this.excludePatterns) {
38754
+ if (matchesExcludedDirectory(relative, pattern)) {
38755
+ return true;
38756
+ }
38757
+ }
38758
+ return false;
38759
+ }
38724
38760
  async ensureDirectoryWatcher(rootState, dir) {
38725
38761
  if (rootState.directoryWatchers.has(dir)) {
38726
38762
  return;
@@ -38761,7 +38797,7 @@ var DirectoryMarkdownSourceAdapter = class {
38761
38797
  }
38762
38798
  return true;
38763
38799
  }
38764
- async pruneDeletedFiles(rootState, currentFiles) {
38800
+ async pruneDeletedFiles(rootState, currentFiles, stats) {
38765
38801
  const removed = [];
38766
38802
  for (const previous of rootState.knownFiles) {
38767
38803
  if (!currentFiles.has(previous)) {
@@ -38774,6 +38810,8 @@ var DirectoryMarkdownSourceAdapter = class {
38774
38810
  for (const filePath of removed) {
38775
38811
  await this.deleteSourceDocument(filePath);
38776
38812
  this.fileStates.delete(filePath);
38813
+ this.snapshotDirty = true;
38814
+ stats.filesDeleted++;
38777
38815
  }
38778
38816
  }
38779
38817
  async syncMarkdownFile(rootState, filePath) {
@@ -38783,21 +38821,23 @@ var DirectoryMarkdownSourceAdapter = class {
38783
38821
  if (!stat) {
38784
38822
  await this.deleteSourceDocument(sourceDoc);
38785
38823
  this.fileStates.delete(sourceDoc);
38786
- return;
38824
+ this.snapshotDirty = true;
38825
+ return "deleted";
38787
38826
  }
38788
38827
  const cached = this.fileStates.get(sourceDoc);
38789
38828
  if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
38790
- return;
38829
+ return "unchanged";
38791
38830
  }
38792
38831
  const bytes = await this.safeReadFile(filePath);
38793
38832
  if (!bytes) {
38794
38833
  await this.deleteSourceDocument(sourceDoc);
38795
38834
  this.fileStates.delete(sourceDoc);
38796
- return;
38835
+ this.snapshotDirty = true;
38836
+ return "deleted";
38797
38837
  }
38798
38838
  const fileHash = hashBytes(bytes);
38799
38839
  if (cached && cached.fileHash === fileHash) {
38800
- this.fileStates.set(sourceDoc, {
38840
+ this.setFileState(sourceDoc, {
38801
38841
  root: rootState.root,
38802
38842
  sourceDoc,
38803
38843
  relativePath,
@@ -38805,16 +38845,17 @@ var DirectoryMarkdownSourceAdapter = class {
38805
38845
  size: stat.size,
38806
38846
  mtimeMs: stat.mtimeMs
38807
38847
  });
38808
- return;
38848
+ return "unchanged";
38809
38849
  }
38810
38850
  const text = textDecoder2.decode(bytes);
38811
38851
  if (this.kind === "obsidian" && this.includePatterns.length === 0 && !looksLikeObsidianNote(filePath, text)) {
38812
38852
  await this.deleteSourceDocument(sourceDoc);
38813
38853
  this.fileStates.delete(sourceDoc);
38814
- return;
38854
+ this.snapshotDirty = true;
38855
+ return "skipped";
38815
38856
  }
38816
38857
  await this.ingestMarkdownDocument(sourceDoc, text, rootState.root, relativePath, fileHash, stat.size, stat.mtimeMs);
38817
- this.fileStates.set(sourceDoc, {
38858
+ this.setFileState(sourceDoc, {
38818
38859
  root: rootState.root,
38819
38860
  sourceDoc,
38820
38861
  relativePath,
@@ -38822,6 +38863,11 @@ var DirectoryMarkdownSourceAdapter = class {
38822
38863
  size: stat.size,
38823
38864
  mtimeMs: stat.mtimeMs
38824
38865
  });
38866
+ return "ingested";
38867
+ }
38868
+ setFileState(sourceDoc, state) {
38869
+ this.fileStates.set(sourceDoc, state);
38870
+ this.snapshotDirty = true;
38825
38871
  }
38826
38872
  async ingestMarkdownDocument(sourceDoc, text, sourceRoot, sourcePath, fileHash, sourceSize, sourceMtimeMs) {
38827
38873
  const queue = await this.getIngestQueue();
@@ -38869,7 +38915,96 @@ var DirectoryMarkdownSourceAdapter = class {
38869
38915
  return null;
38870
38916
  }
38871
38917
  }
38918
+ snapshotFilesForRoot(root) {
38919
+ const files = /* @__PURE__ */ new Set();
38920
+ for (const state of this.fileStates.values()) {
38921
+ if (state.root === root) {
38922
+ files.add(state.sourceDoc);
38923
+ }
38924
+ }
38925
+ return files;
38926
+ }
38927
+ async loadSnapshot() {
38928
+ if (this.snapshotLoaded) {
38929
+ return;
38930
+ }
38931
+ this.snapshotLoaded = true;
38932
+ let raw;
38933
+ try {
38934
+ raw = await fsp2.readFile(this.snapshotPath, "utf8");
38935
+ } catch (error) {
38936
+ if (!formatError(error).includes("ENOENT")) {
38937
+ this.logger.warn?.(`[markdown-ingest] failed to read snapshot ${this.snapshotPath}: ${formatError(error)}`);
38938
+ }
38939
+ return;
38940
+ }
38941
+ try {
38942
+ const parsed = JSON.parse(raw);
38943
+ if (parsed.ingestVersion !== MARKDOWN_INGEST_VERSION || parsed.hashBackend !== HASH_BACKEND || !parsed.files) {
38944
+ return;
38945
+ }
38946
+ const configuredRoots = new Set(this.roots.map((root) => path2.resolve(root)));
38947
+ for (const [sourceDoc, state] of Object.entries(parsed.files)) {
38948
+ if (isValidSnapshotState(sourceDoc, state) && configuredRoots.has(path2.resolve(state.root))) {
38949
+ this.fileStates.set(sourceDoc, state);
38950
+ }
38951
+ }
38952
+ this.logger.info?.(`[markdown-ingest] loaded ${this.fileStates.size} ${this.kind} file snapshots from ${this.snapshotPath}`);
38953
+ } catch (error) {
38954
+ this.logger.warn?.(`[markdown-ingest] failed to parse snapshot ${this.snapshotPath}: ${formatError(error)}`);
38955
+ }
38956
+ }
38957
+ async saveSnapshotIfDirty() {
38958
+ if (!this.snapshotDirty) {
38959
+ return;
38960
+ }
38961
+ const payload = {
38962
+ version: 1,
38963
+ ingestVersion: MARKDOWN_INGEST_VERSION,
38964
+ hashBackend: HASH_BACKEND,
38965
+ files: Object.fromEntries([...this.fileStates.entries()].sort(([left], [right]) => left.localeCompare(right)))
38966
+ };
38967
+ try {
38968
+ await fsp2.mkdir(path2.dirname(this.snapshotPath), { recursive: true });
38969
+ const tmp = `${this.snapshotPath}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
38970
+ await fsp2.writeFile(tmp, `${JSON.stringify(payload, null, 2)}
38971
+ `);
38972
+ await fsp2.rename(tmp, this.snapshotPath);
38973
+ this.snapshotDirty = false;
38974
+ } catch (error) {
38975
+ this.logger.warn?.(`[markdown-ingest] failed to write snapshot ${this.snapshotPath}: ${formatError(error)}`);
38976
+ }
38977
+ }
38978
+ logScanStats(root, stats, durationMs) {
38979
+ this.logger.info?.(
38980
+ `[markdown-ingest] ${this.kind} scan complete root=${root} dirs=${stats.directoriesScanned} prunedDirs=${stats.directoriesPruned} markdown=${stats.markdownFilesSeen} included=${stats.filesIncluded} skipped=${stats.filesSkipped} unchanged=${stats.filesUnchanged} ingested=${stats.filesIngested} deleted=${stats.filesDeleted} errors=${stats.syncErrors} durationMs=${durationMs}`
38981
+ );
38982
+ }
38872
38983
  };
38984
+ function createScanStats() {
38985
+ return {
38986
+ directoriesScanned: 0,
38987
+ directoriesPruned: 0,
38988
+ markdownFilesSeen: 0,
38989
+ filesIncluded: 0,
38990
+ filesSkipped: 0,
38991
+ filesUnchanged: 0,
38992
+ filesIngested: 0,
38993
+ filesDeleted: 0,
38994
+ syncErrors: 0
38995
+ };
38996
+ }
38997
+ function recordSyncResult(stats, result) {
38998
+ if (result === "ingested") {
38999
+ stats.filesIngested++;
39000
+ } else if (result === "unchanged") {
39001
+ stats.filesUnchanged++;
39002
+ } else if (result === "deleted") {
39003
+ stats.filesDeleted++;
39004
+ } else {
39005
+ stats.filesSkipped++;
39006
+ }
39007
+ }
38873
39008
  function toPosixPath(value) {
38874
39009
  return value.split(path2.sep).join("/");
38875
39010
  }
@@ -38888,11 +39023,16 @@ function normalizeMarkdownRoots(roots) {
38888
39023
  }
38889
39024
  return [...resolved];
38890
39025
  }
38891
- function isMarkdownIngestionEnabled(cfg, roots) {
38892
- if (cfg.markdownIngestionEnabled === false) {
38893
- return false;
39026
+ function resolveMarkdownSnapshotPath(kind, configuredPath) {
39027
+ const trimmed = configuredPath?.trim();
39028
+ if (trimmed) {
39029
+ return path2.resolve(trimmed);
38894
39030
  }
38895
- return roots.length > 0;
39031
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || path2.join(os2.homedir(), ".openclaw");
39032
+ return path2.join(stateDir, `libravdb-markdown-ingest-${kind}.json`);
39033
+ }
39034
+ function isMarkdownIngestionEnabled(cfg, roots) {
39035
+ return cfg.markdownIngestionEnabled === true && roots.length > 0;
38896
39036
  }
38897
39037
  function createRealFsApi2() {
38898
39038
  return {
@@ -38913,6 +39053,17 @@ function matchesGlob(value, pattern) {
38913
39053
  const escaped = pattern.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
38914
39054
  return new RegExp(`^${escaped}$`).test(value);
38915
39055
  }
39056
+ function matchesExcludedDirectory(relativeDir, pattern) {
39057
+ const normalized = relativeDir.replace(/\/+$/, "");
39058
+ return matchesGlob(normalized, pattern) || matchesGlob(`${normalized}/`, pattern) || matchesGlob(`${normalized}/.probe`, pattern);
39059
+ }
39060
+ function isValidSnapshotState(sourceDoc, value) {
39061
+ if (!value || typeof value !== "object") {
39062
+ return false;
39063
+ }
39064
+ const state = value;
39065
+ return state.sourceDoc === sourceDoc && typeof state.root === "string" && typeof state.relativePath === "string" && typeof state.fileHash === "string" && typeof state.size === "number" && Number.isFinite(state.size) && typeof state.mtimeMs === "number" && Number.isFinite(state.mtimeMs);
39066
+ }
38916
39067
  function looksLikeObsidianNote(filePath, text) {
38917
39068
  const frontmatterStart = parseFrontmatterStart(text);
38918
39069
  if (frontmatterStart == null) {
@@ -39439,7 +39590,7 @@ var GrpcKernelClient = class {
39439
39590
  // src/sidecar.ts
39440
39591
  import fs3 from "node:fs";
39441
39592
  import net from "node:net";
39442
- import os2 from "node:os";
39593
+ import os3 from "node:os";
39443
39594
  import path4 from "node:path";
39444
39595
  var STARTUP_CONNECT_MAX_RETRIES = 5;
39445
39596
  var STARTUP_CONNECT_BASE_DELAY_MS = 100;
@@ -39710,7 +39861,7 @@ function resolveConfiguredEndpoint(cfg) {
39710
39861
  function daemonProvisioningHint() {
39711
39862
  return "If you installed the npm package, install and start libravdbd separately; the package does not provision the daemon binary, ONNX Runtime, or model assets.";
39712
39863
  }
39713
- function defaultEndpoint(platform = process.platform, homeDir = os2.homedir(), pathExists = fs3.existsSync) {
39864
+ function defaultEndpoint(platform = process.platform, homeDir = os3.homedir(), pathExists = fs3.existsSync) {
39714
39865
  const envEndpoint = normalizeConfiguredEndpoint(process.env.LIBRAVDB_RPC_ENDPOINT);
39715
39866
  if (envEndpoint) {
39716
39867
  return envEndpoint;
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import fsp from "node:fs/promises";
3
+ import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { hashBytes } from "./markdown-hash.js";
5
6
  import { formatError } from "./format-error.js";
@@ -17,15 +18,17 @@ export function createMarkdownIngestionHandle(cfg, getRpc, logger = console, fsA
17
18
  include: cfg.markdownIngestionInclude,
18
19
  exclude: cfg.markdownIngestionExclude,
19
20
  debounceMs: cfg.markdownIngestionDebounceMs ?? DEFAULT_DEBOUNCE_MS,
21
+ snapshotPath: resolveMarkdownSnapshotPath("generic", cfg.markdownIngestionSnapshotPath),
20
22
  }, getRpc, logger, fsApi));
21
23
  }
22
24
  const obsidianRoots = normalizeMarkdownRoots(cfg.markdownIngestionObsidianRoots);
23
- if (cfg.markdownIngestionObsidianEnabled !== false && obsidianRoots.length > 0) {
25
+ if (cfg.markdownIngestionObsidianEnabled === true && obsidianRoots.length > 0) {
24
26
  adapters.push(new DirectoryMarkdownSourceAdapter("obsidian", {
25
27
  roots: obsidianRoots,
26
28
  include: cfg.markdownIngestionObsidianInclude,
27
29
  exclude: cfg.markdownIngestionObsidianExclude,
28
30
  debounceMs: cfg.markdownIngestionObsidianDebounceMs ?? cfg.markdownIngestionDebounceMs ?? DEFAULT_DEBOUNCE_MS,
31
+ snapshotPath: resolveMarkdownSnapshotPath("obsidian", cfg.markdownIngestionObsidianSnapshotPath),
29
32
  }, getRpc, logger, fsApi));
30
33
  }
31
34
  if (adapters.length === 0) {
@@ -73,6 +76,7 @@ class DirectoryMarkdownSourceAdapter {
73
76
  fsApi;
74
77
  getRpc;
75
78
  logger;
79
+ snapshotPath;
76
80
  states = new Map();
77
81
  fileStates = new Map();
78
82
  activeScans = new Set();
@@ -81,6 +85,8 @@ class DirectoryMarkdownSourceAdapter {
81
85
  started = false;
82
86
  ingestQueue = null;
83
87
  stopping = false;
88
+ snapshotLoaded = false;
89
+ snapshotDirty = false;
84
90
  constructor(kind, config, getRpc, logger, fsApi) {
85
91
  this.kind = kind;
86
92
  this.roots = config.roots;
@@ -90,6 +96,7 @@ class DirectoryMarkdownSourceAdapter {
90
96
  this.fsApi = fsApi;
91
97
  this.getRpc = getRpc;
92
98
  this.logger = logger;
99
+ this.snapshotPath = config.snapshotPath ?? resolveMarkdownSnapshotPath(kind);
93
100
  this.tokenizerId = DEFAULT_TOKENIZER_ID;
94
101
  this.coreDoc = true;
95
102
  }
@@ -97,6 +104,7 @@ class DirectoryMarkdownSourceAdapter {
97
104
  if (this.started) {
98
105
  return;
99
106
  }
107
+ await this.loadSnapshot();
100
108
  this.started = true;
101
109
  this.stopping = false;
102
110
  await this.refresh();
@@ -124,8 +132,10 @@ class DirectoryMarkdownSourceAdapter {
124
132
  if (this.activeScans.size > 0) {
125
133
  await Promise.allSettled([...this.activeScans]);
126
134
  }
135
+ await this.saveSnapshotIfDirty();
127
136
  this.states.clear();
128
137
  this.fileStates.clear();
138
+ this.snapshotLoaded = false;
129
139
  this.started = false;
130
140
  }
131
141
  getRootState(root) {
@@ -141,7 +151,7 @@ class DirectoryMarkdownSourceAdapter {
141
151
  dirty: false,
142
152
  timer: null,
143
153
  },
144
- knownFiles: new Set(),
154
+ knownFiles: this.snapshotFilesForRoot(resolved),
145
155
  directoryWatchers: new Map(),
146
156
  };
147
157
  this.states.set(resolved, created);
@@ -158,12 +168,16 @@ class DirectoryMarkdownSourceAdapter {
158
168
  }
159
169
  rootState.scanState.scanning = true;
160
170
  const scan = (async () => {
171
+ const stats = createScanStats();
172
+ const startedAt = Date.now();
161
173
  try {
162
174
  const currentFiles = new Set();
163
- await this.walkDirectory(rootState, rootState.root, currentFiles);
175
+ await this.walkDirectory(rootState, rootState.root, currentFiles, stats);
164
176
  if (!this.stopping) {
165
- await this.pruneDeletedFiles(rootState, currentFiles);
177
+ await this.pruneDeletedFiles(rootState, currentFiles, stats);
166
178
  rootState.knownFiles = currentFiles;
179
+ await this.saveSnapshotIfDirty();
180
+ this.logScanStats(rootState.root, stats, Date.now() - startedAt);
167
181
  }
168
182
  }
169
183
  finally {
@@ -202,7 +216,12 @@ class DirectoryMarkdownSourceAdapter {
202
216
  });
203
217
  }, this.debounceMs);
204
218
  }
205
- async walkDirectory(rootState, dir, currentFiles) {
219
+ async walkDirectory(rootState, dir, currentFiles, stats) {
220
+ if (this.shouldPruneDirectory(rootState.root, dir)) {
221
+ stats.directoriesPruned++;
222
+ return;
223
+ }
224
+ stats.directoriesScanned++;
206
225
  await this.ensureDirectoryWatcher(rootState, dir);
207
226
  let entries;
208
227
  try {
@@ -221,26 +240,43 @@ class DirectoryMarkdownSourceAdapter {
221
240
  }
222
241
  const child = path.join(dir, entry.name);
223
242
  if (entry.isDirectory()) {
224
- await this.walkDirectory(rootState, child, currentFiles);
243
+ await this.walkDirectory(rootState, child, currentFiles, stats);
225
244
  continue;
226
245
  }
227
246
  if (!entry.isFile() || !isMarkdownFile(entry.name)) {
228
247
  continue;
229
248
  }
249
+ stats.markdownFilesSeen++;
230
250
  if (!this.shouldIncludeFile(rootState.root, child)) {
251
+ stats.filesSkipped++;
231
252
  continue;
232
253
  }
254
+ stats.filesIncluded++;
233
255
  currentFiles.add(child);
234
256
  try {
235
- await this.syncMarkdownFile(rootState, child);
257
+ const result = await this.syncMarkdownFile(rootState, child);
258
+ recordSyncResult(stats, result);
236
259
  }
237
260
  catch (error) {
261
+ stats.syncErrors++;
238
262
  if (!this.stopping) {
239
263
  this.logger.warn?.(`[markdown-ingest] sync failed for ${child}: ${formatError(error)}`);
240
264
  }
241
265
  }
242
266
  }
243
267
  }
268
+ shouldPruneDirectory(root, dir) {
269
+ const relative = toPosixPath(path.relative(root, dir));
270
+ if (!relative || relative === "." || relative.startsWith("..")) {
271
+ return false;
272
+ }
273
+ for (const pattern of this.excludePatterns) {
274
+ if (matchesExcludedDirectory(relative, pattern)) {
275
+ return true;
276
+ }
277
+ }
278
+ return false;
279
+ }
244
280
  async ensureDirectoryWatcher(rootState, dir) {
245
281
  if (rootState.directoryWatchers.has(dir)) {
246
282
  return;
@@ -282,7 +318,7 @@ class DirectoryMarkdownSourceAdapter {
282
318
  }
283
319
  return true;
284
320
  }
285
- async pruneDeletedFiles(rootState, currentFiles) {
321
+ async pruneDeletedFiles(rootState, currentFiles, stats) {
286
322
  const removed = [];
287
323
  for (const previous of rootState.knownFiles) {
288
324
  if (!currentFiles.has(previous)) {
@@ -295,6 +331,8 @@ class DirectoryMarkdownSourceAdapter {
295
331
  for (const filePath of removed) {
296
332
  await this.deleteSourceDocument(filePath);
297
333
  this.fileStates.delete(filePath);
334
+ this.snapshotDirty = true;
335
+ stats.filesDeleted++;
298
336
  }
299
337
  }
300
338
  async syncMarkdownFile(rootState, filePath) {
@@ -304,21 +342,23 @@ class DirectoryMarkdownSourceAdapter {
304
342
  if (!stat) {
305
343
  await this.deleteSourceDocument(sourceDoc);
306
344
  this.fileStates.delete(sourceDoc);
307
- return;
345
+ this.snapshotDirty = true;
346
+ return "deleted";
308
347
  }
309
348
  const cached = this.fileStates.get(sourceDoc);
310
349
  if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) {
311
- return;
350
+ return "unchanged";
312
351
  }
313
352
  const bytes = await this.safeReadFile(filePath);
314
353
  if (!bytes) {
315
354
  await this.deleteSourceDocument(sourceDoc);
316
355
  this.fileStates.delete(sourceDoc);
317
- return;
356
+ this.snapshotDirty = true;
357
+ return "deleted";
318
358
  }
319
359
  const fileHash = hashBytes(bytes);
320
360
  if (cached && cached.fileHash === fileHash) {
321
- this.fileStates.set(sourceDoc, {
361
+ this.setFileState(sourceDoc, {
322
362
  root: rootState.root,
323
363
  sourceDoc,
324
364
  relativePath,
@@ -326,16 +366,17 @@ class DirectoryMarkdownSourceAdapter {
326
366
  size: stat.size,
327
367
  mtimeMs: stat.mtimeMs,
328
368
  });
329
- return;
369
+ return "unchanged";
330
370
  }
331
371
  const text = textDecoder.decode(bytes);
332
372
  if (this.kind === "obsidian" && this.includePatterns.length === 0 && !looksLikeObsidianNote(filePath, text)) {
333
373
  await this.deleteSourceDocument(sourceDoc);
334
374
  this.fileStates.delete(sourceDoc);
335
- return;
375
+ this.snapshotDirty = true;
376
+ return "skipped";
336
377
  }
337
378
  await this.ingestMarkdownDocument(sourceDoc, text, rootState.root, relativePath, fileHash, stat.size, stat.mtimeMs);
338
- this.fileStates.set(sourceDoc, {
379
+ this.setFileState(sourceDoc, {
339
380
  root: rootState.root,
340
381
  sourceDoc,
341
382
  relativePath,
@@ -343,6 +384,11 @@ class DirectoryMarkdownSourceAdapter {
343
384
  size: stat.size,
344
385
  mtimeMs: stat.mtimeMs,
345
386
  });
387
+ return "ingested";
388
+ }
389
+ setFileState(sourceDoc, state) {
390
+ this.fileStates.set(sourceDoc, state);
391
+ this.snapshotDirty = true;
346
392
  }
347
393
  async ingestMarkdownDocument(sourceDoc, text, sourceRoot, sourcePath, fileHash, sourceSize, sourceMtimeMs) {
348
394
  const queue = await this.getIngestQueue();
@@ -388,6 +434,98 @@ class DirectoryMarkdownSourceAdapter {
388
434
  return null;
389
435
  }
390
436
  }
437
+ snapshotFilesForRoot(root) {
438
+ const files = new Set();
439
+ for (const state of this.fileStates.values()) {
440
+ if (state.root === root) {
441
+ files.add(state.sourceDoc);
442
+ }
443
+ }
444
+ return files;
445
+ }
446
+ async loadSnapshot() {
447
+ if (this.snapshotLoaded) {
448
+ return;
449
+ }
450
+ this.snapshotLoaded = true;
451
+ let raw;
452
+ try {
453
+ raw = await fsp.readFile(this.snapshotPath, "utf8");
454
+ }
455
+ catch (error) {
456
+ if (!formatError(error).includes("ENOENT")) {
457
+ this.logger.warn?.(`[markdown-ingest] failed to read snapshot ${this.snapshotPath}: ${formatError(error)}`);
458
+ }
459
+ return;
460
+ }
461
+ try {
462
+ const parsed = JSON.parse(raw);
463
+ if (parsed.ingestVersion !== MARKDOWN_INGEST_VERSION || parsed.hashBackend !== HASH_BACKEND || !parsed.files) {
464
+ return;
465
+ }
466
+ const configuredRoots = new Set(this.roots.map((root) => path.resolve(root)));
467
+ for (const [sourceDoc, state] of Object.entries(parsed.files)) {
468
+ if (isValidSnapshotState(sourceDoc, state) && configuredRoots.has(path.resolve(state.root))) {
469
+ this.fileStates.set(sourceDoc, state);
470
+ }
471
+ }
472
+ this.logger.info?.(`[markdown-ingest] loaded ${this.fileStates.size} ${this.kind} file snapshots from ${this.snapshotPath}`);
473
+ }
474
+ catch (error) {
475
+ this.logger.warn?.(`[markdown-ingest] failed to parse snapshot ${this.snapshotPath}: ${formatError(error)}`);
476
+ }
477
+ }
478
+ async saveSnapshotIfDirty() {
479
+ if (!this.snapshotDirty) {
480
+ return;
481
+ }
482
+ const payload = {
483
+ version: 1,
484
+ ingestVersion: MARKDOWN_INGEST_VERSION,
485
+ hashBackend: HASH_BACKEND,
486
+ files: Object.fromEntries([...this.fileStates.entries()].sort(([left], [right]) => left.localeCompare(right))),
487
+ };
488
+ try {
489
+ await fsp.mkdir(path.dirname(this.snapshotPath), { recursive: true });
490
+ const tmp = `${this.snapshotPath}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
491
+ await fsp.writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`);
492
+ await fsp.rename(tmp, this.snapshotPath);
493
+ this.snapshotDirty = false;
494
+ }
495
+ catch (error) {
496
+ this.logger.warn?.(`[markdown-ingest] failed to write snapshot ${this.snapshotPath}: ${formatError(error)}`);
497
+ }
498
+ }
499
+ logScanStats(root, stats, durationMs) {
500
+ this.logger.info?.(`[markdown-ingest] ${this.kind} scan complete root=${root} dirs=${stats.directoriesScanned} prunedDirs=${stats.directoriesPruned} markdown=${stats.markdownFilesSeen} included=${stats.filesIncluded} skipped=${stats.filesSkipped} unchanged=${stats.filesUnchanged} ingested=${stats.filesIngested} deleted=${stats.filesDeleted} errors=${stats.syncErrors} durationMs=${durationMs}`);
501
+ }
502
+ }
503
+ function createScanStats() {
504
+ return {
505
+ directoriesScanned: 0,
506
+ directoriesPruned: 0,
507
+ markdownFilesSeen: 0,
508
+ filesIncluded: 0,
509
+ filesSkipped: 0,
510
+ filesUnchanged: 0,
511
+ filesIngested: 0,
512
+ filesDeleted: 0,
513
+ syncErrors: 0,
514
+ };
515
+ }
516
+ function recordSyncResult(stats, result) {
517
+ if (result === "ingested") {
518
+ stats.filesIngested++;
519
+ }
520
+ else if (result === "unchanged") {
521
+ stats.filesUnchanged++;
522
+ }
523
+ else if (result === "deleted") {
524
+ stats.filesDeleted++;
525
+ }
526
+ else {
527
+ stats.filesSkipped++;
528
+ }
391
529
  }
392
530
  function toPosixPath(value) {
393
531
  return value.split(path.sep).join("/");
@@ -407,11 +545,16 @@ function normalizeMarkdownRoots(roots) {
407
545
  }
408
546
  return [...resolved];
409
547
  }
410
- function isMarkdownIngestionEnabled(cfg, roots) {
411
- if (cfg.markdownIngestionEnabled === false) {
412
- return false;
548
+ function resolveMarkdownSnapshotPath(kind, configuredPath) {
549
+ const trimmed = configuredPath?.trim();
550
+ if (trimmed) {
551
+ return path.resolve(trimmed);
413
552
  }
414
- return roots.length > 0;
553
+ const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || path.join(os.homedir(), ".openclaw");
554
+ return path.join(stateDir, `libravdb-markdown-ingest-${kind}.json`);
555
+ }
556
+ function isMarkdownIngestionEnabled(cfg, roots) {
557
+ return cfg.markdownIngestionEnabled === true && roots.length > 0;
415
558
  }
416
559
  function createRealFsApi() {
417
560
  return {
@@ -435,6 +578,24 @@ function matchesGlob(value, pattern) {
435
578
  .join(".*");
436
579
  return new RegExp(`^${escaped}$`).test(value);
437
580
  }
581
+ function matchesExcludedDirectory(relativeDir, pattern) {
582
+ const normalized = relativeDir.replace(/\/+$/, "");
583
+ return matchesGlob(normalized, pattern) || matchesGlob(`${normalized}/`, pattern) || matchesGlob(`${normalized}/.probe`, pattern);
584
+ }
585
+ function isValidSnapshotState(sourceDoc, value) {
586
+ if (!value || typeof value !== "object") {
587
+ return false;
588
+ }
589
+ const state = value;
590
+ return (state.sourceDoc === sourceDoc &&
591
+ typeof state.root === "string" &&
592
+ typeof state.relativePath === "string" &&
593
+ typeof state.fileHash === "string" &&
594
+ typeof state.size === "number" &&
595
+ Number.isFinite(state.size) &&
596
+ typeof state.mtimeMs === "number" &&
597
+ Number.isFinite(state.mtimeMs));
598
+ }
438
599
  function looksLikeObsidianNote(filePath, text) {
439
600
  const frontmatterStart = parseFrontmatterStart(text);
440
601
  if (frontmatterStart == null) {
package/dist/types.d.ts CHANGED
@@ -48,6 +48,8 @@ export interface PluginConfig {
48
48
  markdownIngestionInclude?: string[];
49
49
  markdownIngestionExclude?: string[];
50
50
  markdownIngestionDebounceMs?: number;
51
+ markdownIngestionSnapshotPath?: string;
52
+ markdownIngestionObsidianSnapshotPath?: string;
51
53
  dreamPromotionEnabled?: boolean;
52
54
  dreamPromotionDiaryPath?: string;
53
55
  dreamPromotionUserId?: string;
@@ -98,6 +98,10 @@ The plugin exposes `ingestionGateThreshold` for host-side gating decisions:
98
98
  | `markdownIngestionObsidianExclude` | string[] | — | Obsidian glob exclude patterns |
99
99
  | `markdownIngestionObsidianDebounceMs` | number | `150` | Obsidian debounce window |
100
100
 
101
+ Configured markdown roots are ignored unless the matching enable flag is set to
102
+ `true`. Set `markdownIngestionEnabled: true` for generic roots and
103
+ `markdownIngestionObsidianEnabled: true` for Obsidian vault roots.
104
+
101
105
  ## Dream promotion
102
106
 
103
107
  | Key | Type | Default | Notes |
@@ -2,7 +2,7 @@
2
2
  "id": "libravdb-memory",
3
3
  "name": "LibraVDB Memory",
4
4
  "description": "Persistent vector memory with three-tier hybrid scoring",
5
- "version": "1.4.67",
5
+ "version": "1.4.68",
6
6
  "kind": [
7
7
  "memory",
8
8
  "context-engine"
@@ -206,6 +206,12 @@
206
206
  "type": "number",
207
207
  "default": 150
208
208
  },
209
+ "markdownIngestionSnapshotPath": {
210
+ "type": "string"
211
+ },
212
+ "markdownIngestionObsidianSnapshotPath": {
213
+ "type": "string"
214
+ },
209
215
  "dreamPromotionEnabled": {
210
216
  "type": "boolean",
211
217
  "default": false
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xdarkicex/openclaw-memory-libravdb",
3
- "version": "1.4.67",
3
+ "version": "1.4.68",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",