@joshuaswarren/openclaw-engram 8.3.14 → 8.3.15

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
@@ -139,6 +139,7 @@ function parseConfig(raw) {
139
139
  // Retrieval options
140
140
  recencyWeight: typeof cfg.recencyWeight === "number" ? cfg.recencyWeight : 0.2,
141
141
  boostAccessCount: cfg.boostAccessCount !== false,
142
+ recordEmptyRecallImpressions: cfg.recordEmptyRecallImpressions === true,
142
143
  // v2.2 Advanced Retrieval (safe defaults: off unless enabled)
143
144
  queryExpansionEnabled: cfg.queryExpansionEnabled === true,
144
145
  queryExpansionMaxQueries: typeof cfg.queryExpansionMaxQueries === "number" ? cfg.queryExpansionMaxQueries : 4,
@@ -366,8 +367,12 @@ function parseConfig(raw) {
366
367
  // v8.2: Multi-graph memory (PR 18)
367
368
  multiGraphMemoryEnabled: cfg.multiGraphMemoryEnabled === true,
368
369
  graphRecallEnabled: cfg.graphRecallEnabled === true,
370
+ graphExpandedIntentEnabled: cfg.graphExpandedIntentEnabled !== false,
371
+ graphAssistInFullModeEnabled: cfg.graphAssistInFullModeEnabled !== false,
372
+ graphAssistMinSeedResults: typeof cfg.graphAssistMinSeedResults === "number" ? Math.max(1, Math.floor(cfg.graphAssistMinSeedResults)) : 3,
369
373
  entityGraphEnabled: cfg.entityGraphEnabled !== false,
370
374
  timeGraphEnabled: cfg.timeGraphEnabled !== false,
375
+ graphWriteSessionAdjacencyEnabled: cfg.graphWriteSessionAdjacencyEnabled !== false,
371
376
  causalGraphEnabled: cfg.causalGraphEnabled !== false,
372
377
  maxGraphTraversalSteps: typeof cfg.maxGraphTraversalSteps === "number" ? Math.max(0, cfg.maxGraphTraversalSteps) : 3,
373
378
  graphActivationDecay: typeof cfg.graphActivationDecay === "number" ? Math.min(1, Math.max(0, cfg.graphActivationDecay)) : 0.7,
@@ -3503,11 +3508,27 @@ var QMD_DAEMON_TIMEOUT_MS = 6e4;
3503
3508
  var QMD_PROBE_TIMEOUT_MS = 8e3;
3504
3509
  var QMD_UPDATE_BACKOFF_MS = 15 * 60 * 1e3;
3505
3510
  var QMD_EMBED_BACKOFF_MS = 60 * 60 * 1e3;
3511
+ var QMD_CLI_WARN_THROTTLE_MS = 15 * 60 * 1e3;
3506
3512
  var QMD_FALLBACK_PATHS = [
3507
3513
  path2.join(os2.homedir(), ".bun", "bin", "qmd"),
3508
3514
  "/usr/local/bin/qmd",
3509
3515
  "/opt/homebrew/bin/qmd"
3510
3516
  ];
3517
+ var QMD_GLOBAL_STATE_KEY = "__openclawEngramQmdGlobalState";
3518
+ function getGlobalQmdState() {
3519
+ const g = globalThis;
3520
+ if (!g[QMD_GLOBAL_STATE_KEY]) {
3521
+ g[QMD_GLOBAL_STATE_KEY] = {
3522
+ warnedGlobalUpdateBehavior: false,
3523
+ lastGlobalUpdateRunAtMs: null,
3524
+ lastGlobalUpdateFailAtMs: null,
3525
+ lastGlobalEmbedRunAtMs: null,
3526
+ lastGlobalEmbedFailAtMs: null,
3527
+ lastCliWarnAtMs: null
3528
+ };
3529
+ }
3530
+ return g[QMD_GLOBAL_STATE_KEY];
3531
+ }
3511
3532
  function sleep(ms) {
3512
3533
  return new Promise((r) => setTimeout(r, ms));
3513
3534
  }
@@ -3724,7 +3745,6 @@ var QmdClient = class {
3724
3745
  lastUpdateFailAtMs = null;
3725
3746
  lastEmbedFailAtMs = null;
3726
3747
  lastUpdateRunAtMs = null;
3727
- warnedGlobalUpdateBehavior = false;
3728
3748
  updateTimeoutMs;
3729
3749
  updateMinIntervalMs;
3730
3750
  slowLog;
@@ -3794,9 +3814,9 @@ ${stderr}`.trim();
3794
3814
  return true;
3795
3815
  } catch (err) {
3796
3816
  markProbeFailure(err);
3797
- log.warn(`QMD: configured qmdPath failed (${this.configuredQmdPath}): ${this.lastCliProbeError}`);
3798
- this.available = false;
3799
- return false;
3817
+ this.logCliProbeWarning(
3818
+ `QMD: configured qmdPath failed (${this.configuredQmdPath}): ${this.lastCliProbeError}`
3819
+ );
3800
3820
  }
3801
3821
  }
3802
3822
  try {
@@ -3827,6 +3847,21 @@ ${stderr}`.trim();
3827
3847
  return false;
3828
3848
  }
3829
3849
  }
3850
+ logCliProbeWarning(message) {
3851
+ const state = getGlobalQmdState();
3852
+ const now = Date.now();
3853
+ const canWarn = state.lastCliWarnAtMs === null || now - state.lastCliWarnAtMs >= QMD_CLI_WARN_THROTTLE_MS;
3854
+ if (!canWarn) {
3855
+ log.debug(message);
3856
+ return;
3857
+ }
3858
+ state.lastCliWarnAtMs = now;
3859
+ if (this.daemonAvailable) {
3860
+ log.debug(message);
3861
+ return;
3862
+ }
3863
+ log.warn(message);
3864
+ }
3830
3865
  /** Re-probe daemon if it was down and recheck interval has elapsed. */
3831
3866
  async maybeProbeDaemon() {
3832
3867
  if (!this.daemonSession) return;
@@ -3951,9 +3986,11 @@ ${stderr}`.trim();
3951
3986
  */
3952
3987
  async hybridSearch(query, collection, maxResults) {
3953
3988
  const n = maxResults ?? this.maxResults;
3989
+ const trimmed = query.trim();
3990
+ if (!trimmed) return [];
3954
3991
  const [bm25Results, vectorResults] = await Promise.all([
3955
- this.bm25Search(query, collection, n),
3956
- this.vectorSearch(query, collection, n)
3992
+ this.bm25Search(trimmed, collection, n),
3993
+ this.vectorSearch(trimmed, collection, n)
3957
3994
  ]);
3958
3995
  const merged = /* @__PURE__ */ new Map();
3959
3996
  for (const r of [...bm25Results, ...vectorResults]) {
@@ -4097,6 +4134,7 @@ ${stderr}`.trim();
4097
4134
  }
4098
4135
  async update() {
4099
4136
  if (this.available === false) return;
4137
+ const globalState = getGlobalQmdState();
4100
4138
  if (this.lastUpdateRunAtMs && Date.now() - this.lastUpdateRunAtMs < this.updateMinIntervalMs) {
4101
4139
  log.debug("QMD update: suppressed due to min-interval gate");
4102
4140
  return;
@@ -4105,9 +4143,17 @@ ${stderr}`.trim();
4105
4143
  log.debug("QMD update: suppressed due to recent failures (backoff)");
4106
4144
  return;
4107
4145
  }
4146
+ if (globalState.lastGlobalUpdateRunAtMs && Date.now() - globalState.lastGlobalUpdateRunAtMs < this.updateMinIntervalMs) {
4147
+ log.debug("QMD update: suppressed by global min-interval gate");
4148
+ return;
4149
+ }
4150
+ if (globalState.lastGlobalUpdateFailAtMs && Date.now() - globalState.lastGlobalUpdateFailAtMs < QMD_UPDATE_BACKOFF_MS) {
4151
+ log.debug("QMD update: suppressed by global failure backoff");
4152
+ return;
4153
+ }
4108
4154
  try {
4109
- if (!this.warnedGlobalUpdateBehavior) {
4110
- this.warnedGlobalUpdateBehavior = true;
4155
+ if (!globalState.warnedGlobalUpdateBehavior) {
4156
+ globalState.warnedGlobalUpdateBehavior = true;
4111
4157
  log.warn(
4112
4158
  "QMD update runs globally across collections in current CLI versions; Engram now rate-limits update calls to reduce gateway load."
4113
4159
  );
@@ -4118,20 +4164,33 @@ ${stderr}`.trim();
4118
4164
  if (this.slowLog?.enabled && durationMs >= this.slowLog.thresholdMs) {
4119
4165
  log.warn(`SLOW QMD update: durationMs=${durationMs}`);
4120
4166
  }
4121
- this.lastUpdateRunAtMs = Date.now();
4167
+ const now = Date.now();
4168
+ this.lastUpdateRunAtMs = now;
4169
+ globalState.lastGlobalUpdateRunAtMs = now;
4122
4170
  log.debug("QMD update completed");
4123
4171
  } catch (err) {
4124
- this.lastUpdateFailAtMs = Date.now();
4172
+ const now = Date.now();
4173
+ this.lastUpdateFailAtMs = now;
4174
+ globalState.lastGlobalUpdateFailAtMs = now;
4125
4175
  const msg = err instanceof Error ? err.message : String(err);
4126
4176
  log.warn(`QMD update failed: ${msg}`);
4127
4177
  }
4128
4178
  }
4129
4179
  async embed() {
4130
4180
  if (this.available === false) return;
4181
+ const globalState = getGlobalQmdState();
4131
4182
  if (this.lastEmbedFailAtMs && Date.now() - this.lastEmbedFailAtMs < QMD_EMBED_BACKOFF_MS) {
4132
4183
  log.debug("QMD embed: suppressed due to recent failures (backoff)");
4133
4184
  return;
4134
4185
  }
4186
+ if (globalState.lastGlobalEmbedRunAtMs && Date.now() - globalState.lastGlobalEmbedRunAtMs < this.updateMinIntervalMs) {
4187
+ log.debug("QMD embed: suppressed by global min-interval gate");
4188
+ return;
4189
+ }
4190
+ if (globalState.lastGlobalEmbedFailAtMs && Date.now() - globalState.lastGlobalEmbedFailAtMs < QMD_EMBED_BACKOFF_MS) {
4191
+ log.debug("QMD embed: suppressed by global failure backoff");
4192
+ return;
4193
+ }
4135
4194
  try {
4136
4195
  const startedAtMs = Date.now();
4137
4196
  await runQmd(["embed", "-c", this.collection], 3e5, this.qmdPath);
@@ -4139,9 +4198,12 @@ ${stderr}`.trim();
4139
4198
  if (this.slowLog?.enabled && durationMs >= this.slowLog.thresholdMs) {
4140
4199
  log.warn(`SLOW QMD embed: durationMs=${durationMs}`);
4141
4200
  }
4201
+ globalState.lastGlobalEmbedRunAtMs = Date.now();
4142
4202
  log.debug("QMD embed completed");
4143
4203
  } catch (err) {
4144
- this.lastEmbedFailAtMs = Date.now();
4204
+ const now = Date.now();
4205
+ this.lastEmbedFailAtMs = now;
4206
+ globalState.lastGlobalEmbedFailAtMs = now;
4145
4207
  const msg = err instanceof Error ? err.message : String(err);
4146
4208
  log.warn(`QMD embed failed: ${msg}`);
4147
4209
  }
@@ -8341,6 +8403,13 @@ function planRecallMode(prompt) {
8341
8403
  }
8342
8404
  return "full";
8343
8405
  }
8406
+ function hasBroadGraphIntent(prompt) {
8407
+ const p = normalizeTextInput(prompt).trim().toLowerCase();
8408
+ if (!p) return false;
8409
+ return /\b(what changed|how did we get here|why did this happen|what led to|cause chain|dependency chain|regression chain|failure chain)\b/i.test(
8410
+ p
8411
+ );
8412
+ }
8344
8413
 
8345
8414
  // src/recall-query-policy.ts
8346
8415
  var DEFAULT_STOPWORDS = /* @__PURE__ */ new Set([
@@ -8883,6 +8952,32 @@ import * as fs from "fs";
8883
8952
  import * as path14 from "path";
8884
8953
  import { mkdir as mkdir11, readFile as readFile12, writeFile as writeFile11, readdir as readdir7 } from "fs/promises";
8885
8954
  var TMT_DIR = "tmt";
8955
+ var TMT_LEVEL_INPUT_LIMITS = {
8956
+ hour: { totalChars: 48e3, itemChars: 2e3, maxItems: 64 },
8957
+ day: { totalChars: 64e3, itemChars: 1800, maxItems: 96 },
8958
+ week: { totalChars: 72e3, itemChars: 1600, maxItems: 120 },
8959
+ persona: { totalChars: 8e4, itemChars: 1600, maxItems: 120 }
8960
+ };
8961
+ function capTmtSummaryInputs(inputs, level) {
8962
+ const { totalChars, itemChars, maxItems } = TMT_LEVEL_INPUT_LIMITS[level];
8963
+ if (inputs.length === 0) return [];
8964
+ const capped = [];
8965
+ let usedChars = 0;
8966
+ for (const raw of inputs) {
8967
+ if (capped.length >= maxItems || usedChars >= totalChars) break;
8968
+ const trimmed = raw.trim();
8969
+ if (!trimmed) continue;
8970
+ const next = trimmed.length > itemChars ? `${trimmed.slice(0, itemChars - 1)}\u2026` : trimmed;
8971
+ const separator = capped.length === 0 ? 0 : 2;
8972
+ if (usedChars + separator + next.length > totalChars) break;
8973
+ capped.push(next);
8974
+ usedChars += separator + next.length;
8975
+ }
8976
+ if (capped.length > 0) return capped;
8977
+ const fallback = inputs[0]?.trim() ?? "";
8978
+ if (!fallback) return [];
8979
+ return [fallback.length > itemChars ? `${fallback.slice(0, itemChars - 1)}\u2026` : fallback];
8980
+ }
8886
8981
  function tmtDir(baseDir) {
8887
8982
  return path14.join(baseDir, TMT_DIR);
8888
8983
  }
@@ -8979,7 +9074,7 @@ var TmtBuilder = class {
8979
9074
  if (!shouldBuild) continue;
8980
9075
  let summary;
8981
9076
  try {
8982
- summary = await summarize(entries.map((e) => e.content), "hour");
9077
+ summary = await summarize(capTmtSummaryInputs(entries.map((e) => e.content), "hour"), "hour");
8983
9078
  } catch (err) {
8984
9079
  console.warn(`[engram] tmt: hour node summarize failed for ${key} (ignored): ${err}`);
8985
9080
  continue;
@@ -9044,7 +9139,7 @@ var TmtBuilder = class {
9044
9139
  if (inputs.length === 0) inputs.push(...entries.map((e) => e.content));
9045
9140
  let summary;
9046
9141
  try {
9047
- summary = await summarize(inputs, "day");
9142
+ summary = await summarize(capTmtSummaryInputs(inputs, "day"), "day");
9048
9143
  } catch (err) {
9049
9144
  console.warn(`[engram] tmt: day node summarize failed for ${date} (ignored): ${err}`);
9050
9145
  continue;
@@ -9114,7 +9209,7 @@ var TmtBuilder = class {
9114
9209
  }
9115
9210
  }
9116
9211
  const inputs = daySummaries.length > 0 ? daySummaries : entries.map((e) => e.content);
9117
- const summary = await summarize(inputs, "week");
9212
+ const summary = await summarize(capTmtSummaryInputs(inputs, "week"), "week");
9118
9213
  const sortedCreated = entries.map((e) => e.created).sort();
9119
9214
  const fm = {
9120
9215
  level: "week",
@@ -9184,7 +9279,7 @@ var TmtBuilder = class {
9184
9279
  }
9185
9280
  }
9186
9281
  if (!shouldBuild) return;
9187
- const summary = await summarize(weekSummaries, "persona");
9282
+ const summary = await summarize(capTmtSummaryInputs(weekSummaries, "persona"), "persona");
9188
9283
  const now = (/* @__PURE__ */ new Date()).toISOString();
9189
9284
  const fm = {
9190
9285
  level: "persona",
@@ -10484,7 +10579,10 @@ function computeArtifactRecallLimit(recallMode, recallResultLimit, verbatimArtif
10484
10579
  return base;
10485
10580
  }
10486
10581
  function resolveEffectiveRecallMode(options) {
10487
- const plannedMode = options.plannerEnabled ? planRecallMode(options.prompt) : "full";
10582
+ let plannedMode = options.plannerEnabled ? planRecallMode(options.prompt) : "full";
10583
+ if (plannedMode !== "graph_mode" && options.plannerEnabled && options.graphExpandedIntentEnabled === true && hasBroadGraphIntent(options.prompt)) {
10584
+ plannedMode = "graph_mode";
10585
+ }
10488
10586
  if (plannedMode === "graph_mode" && (!options.graphRecallEnabled || !options.multiGraphMemoryEnabled)) {
10489
10587
  return "full";
10490
10588
  }
@@ -11190,34 +11288,39 @@ var Orchestrator = class _Orchestrator {
11190
11288
  }
11191
11289
  async fetchQmdMemoryResultsWithArtifactTopUp(prompt, qmdFetchLimit, qmdHybridFetchLimit, options) {
11192
11290
  let fetchLimit = Math.max(qmdFetchLimit, qmdHybridFetchLimit);
11193
- const maxFetchLimit = Math.min(800, Math.max(fetchLimit, qmdFetchLimit * 8));
11194
- const MAX_ATTEMPTS = 4;
11291
+ const maxFetchLimit = Math.min(320, Math.max(fetchLimit, qmdFetchLimit * 5));
11292
+ const MAX_ATTEMPTS = 2;
11293
+ const QMD_RECALL_BUDGET_MS = 25e3;
11294
+ const startedAtMs = Date.now();
11195
11295
  let bestFiltered = [];
11196
- let queryFallbackAttempted = false;
11197
- const runQueryFallback = async () => {
11198
- if (queryFallbackAttempted) return [];
11199
- queryFallbackAttempted = true;
11200
- const queryResults = await this.qmd.search(prompt, options.collection, fetchLimit);
11201
- const filteredQueryResults = filterRecallCandidates(queryResults, {
11202
- namespacesEnabled: options.namespacesEnabled,
11203
- recallNamespaces: options.recallNamespaces,
11204
- resolveNamespace: options.resolveNamespace,
11205
- limit: fetchLimit
11206
- });
11207
- if (filteredQueryResults.length > 0) {
11208
- log.debug(
11209
- `QMD query fallback returned ${filteredQueryResults.length} candidates after hybrid underfilled`
11210
- );
11211
- }
11212
- return filteredQueryResults;
11213
- };
11214
11296
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
11215
- const memoryResults = await this.qmd.hybridSearch(
11297
+ if (Date.now() - startedAtMs >= QMD_RECALL_BUDGET_MS) {
11298
+ break;
11299
+ }
11300
+ const primaryResults = await this.qmd.search(
11216
11301
  prompt,
11217
11302
  options.collection,
11218
11303
  fetchLimit
11219
11304
  );
11220
- const filteredResults = filterRecallCandidates(memoryResults, {
11305
+ let mergedResults = primaryResults;
11306
+ if (primaryResults.length < qmdFetchLimit && Date.now() - startedAtMs < QMD_RECALL_BUDGET_MS) {
11307
+ const hybridResults = await this.qmd.hybridSearch(prompt, options.collection, fetchLimit);
11308
+ if (hybridResults.length > 0) {
11309
+ const mergedByPath = /* @__PURE__ */ new Map();
11310
+ for (const result of [...primaryResults, ...hybridResults]) {
11311
+ const key = result.path || result.docid;
11312
+ const existing = mergedByPath.get(key);
11313
+ if (!existing || result.score > existing.score) {
11314
+ mergedByPath.set(key, {
11315
+ ...result,
11316
+ snippet: result.snippet || existing?.snippet || ""
11317
+ });
11318
+ }
11319
+ }
11320
+ mergedResults = [...mergedByPath.values()].sort((a, b) => b.score - a.score).slice(0, fetchLimit);
11321
+ }
11322
+ }
11323
+ const filteredResults = filterRecallCandidates(mergedResults, {
11221
11324
  namespacesEnabled: options.namespacesEnabled,
11222
11325
  recallNamespaces: options.recallNamespaces,
11223
11326
  resolveNamespace: options.resolveNamespace,
@@ -11229,14 +11332,10 @@ var Orchestrator = class _Orchestrator {
11229
11332
  if (filteredResults.length > bestFiltered.length) {
11230
11333
  bestFiltered = filteredResults;
11231
11334
  }
11232
- if (memoryResults.length === 0) {
11233
- const queryFallback = await runQueryFallback();
11234
- if (queryFallback.length > 0) {
11235
- return queryFallback.slice(0, qmdFetchLimit);
11236
- }
11335
+ if (mergedResults.length === 0) {
11237
11336
  return filteredResults;
11238
11337
  }
11239
- if (memoryResults.length < fetchLimit && filteredResults.length > 0) {
11338
+ if (mergedResults.length < fetchLimit && filteredResults.length > 0) {
11240
11339
  return filteredResults;
11241
11340
  }
11242
11341
  if (fetchLimit >= maxFetchLimit) {
@@ -11245,13 +11344,7 @@ var Orchestrator = class _Orchestrator {
11245
11344
  const growth = Math.max(20, Math.floor(fetchLimit / 2));
11246
11345
  fetchLimit = Math.min(maxFetchLimit, fetchLimit + growth);
11247
11346
  }
11248
- if (bestFiltered.length === 0) {
11249
- const queryFallback = await runQueryFallback();
11250
- if (queryFallback.length > 0) {
11251
- return queryFallback.slice(0, qmdFetchLimit);
11252
- }
11253
- }
11254
- return bestFiltered;
11347
+ return bestFiltered.slice(0, qmdFetchLimit);
11255
11348
  }
11256
11349
  async expandResultsViaGraph(options) {
11257
11350
  const byNamespace = /* @__PURE__ */ new Map();
@@ -11333,6 +11426,7 @@ var Orchestrator = class _Orchestrator {
11333
11426
  async recallInternal(prompt, sessionKey) {
11334
11427
  const recallStart = Date.now();
11335
11428
  const timings = {};
11429
+ const promptHash = createHash4("sha256").update(prompt).digest("hex");
11336
11430
  const sections = [];
11337
11431
  const queryPolicy = buildRecallQueryPolicy(prompt, sessionKey, {
11338
11432
  cronRecallPolicyEnabled: this.config.cronRecallPolicyEnabled,
@@ -11341,11 +11435,16 @@ var Orchestrator = class _Orchestrator {
11341
11435
  cronConversationRecallMode: this.config.cronConversationRecallMode
11342
11436
  });
11343
11437
  const retrievalQuery = queryPolicy.retrievalQuery || prompt;
11438
+ const retrievalQueryHash = createHash4("sha256").update(retrievalQuery).digest("hex");
11439
+ let impressionRecorded = false;
11440
+ let recallSource = "none";
11441
+ let recalledMemoryCount = 0;
11344
11442
  timings.queryPolicy = `${queryPolicy.promptShape}/${queryPolicy.retrievalBudgetMode}${queryPolicy.skipConversationRecall ? "/skip-conv" : ""}`;
11345
11443
  const recallMode = resolveEffectiveRecallMode({
11346
11444
  plannerEnabled: this.config.recallPlannerEnabled,
11347
11445
  graphRecallEnabled: this.config.graphRecallEnabled,
11348
11446
  multiGraphMemoryEnabled: this.config.multiGraphMemoryEnabled,
11447
+ graphExpandedIntentEnabled: this.config.graphExpandedIntentEnabled === true,
11349
11448
  prompt
11350
11449
  });
11351
11450
  timings.recallPlan = recallMode;
@@ -11366,6 +11465,27 @@ var Orchestrator = class _Orchestrator {
11366
11465
  const embeddingFetchLimit = computedFetchLimit;
11367
11466
  if (recallMode === "no_recall") {
11368
11467
  timings.total = `${Date.now() - recallStart}ms`;
11468
+ this.emitTrace({
11469
+ kind: "recall_summary",
11470
+ traceId: createHash4("sha256").update(`${sessionKey ?? "default"}:${Date.now()}:${promptHash}`).digest("hex").slice(0, 16),
11471
+ operation: "recall",
11472
+ sessionKey,
11473
+ promptHash,
11474
+ promptLength: prompt.length,
11475
+ retrievalQueryHash,
11476
+ retrievalQueryLength: retrievalQuery.length,
11477
+ recallMode,
11478
+ recallResultLimit,
11479
+ qmdEnabled: this.config.qmdEnabled,
11480
+ qmdAvailable: this.qmd.isAvailable(),
11481
+ recallNamespaces: [],
11482
+ source: recallSource,
11483
+ recalledMemoryCount,
11484
+ injected: false,
11485
+ contextChars: 0,
11486
+ durationMs: Date.now() - recallStart,
11487
+ timings: { ...timings }
11488
+ });
11369
11489
  return "";
11370
11490
  }
11371
11491
  const principal = resolvePrincipal(sessionKey, this.config);
@@ -11512,7 +11632,8 @@ ${tmtNode.summary}`);
11512
11632
  );
11513
11633
  }
11514
11634
  memoryResults = memoryResults.filter((r) => !isArtifactMemoryPath(r.path));
11515
- if (recallMode === "graph_mode") {
11635
+ const shouldRunGraphExpansion = recallMode === "graph_mode" || this.config.multiGraphMemoryEnabled && this.config.graphAssistInFullModeEnabled !== false && recallMode === "full" && memoryResults.length >= Math.max(1, this.config.graphAssistMinSeedResults ?? 3);
11636
+ if (shouldRunGraphExpansion) {
11516
11637
  const {
11517
11638
  merged,
11518
11639
  seedPaths,
@@ -11567,6 +11688,8 @@ ${tmtNode.summary}`);
11567
11688
  }
11568
11689
  memoryResults = memoryResults.slice(0, recallResultLimit);
11569
11690
  if (memoryResults.length > 0) {
11691
+ recallSource = "hot_qmd";
11692
+ recalledMemoryCount = memoryResults.length;
11570
11693
  this.publishRecallResults({
11571
11694
  title: "Relevant Memories",
11572
11695
  results: memoryResults,
@@ -11574,6 +11697,7 @@ ${tmtNode.summary}`);
11574
11697
  retrievalQuery,
11575
11698
  sessionKey
11576
11699
  });
11700
+ impressionRecorded = true;
11577
11701
  } else {
11578
11702
  const embeddingResults = await this.searchEmbeddingFallback(retrievalQuery, embeddingFetchLimit);
11579
11703
  const scopedCandidates = filterRecallCandidates(embeddingResults, {
@@ -11587,6 +11711,8 @@ ${tmtNode.summary}`);
11587
11711
  recallResultLimit
11588
11712
  );
11589
11713
  if (scoped.length > 0) {
11714
+ recallSource = "hot_embedding";
11715
+ recalledMemoryCount = scoped.length;
11590
11716
  this.publishRecallResults({
11591
11717
  title: "Relevant Memories",
11592
11718
  results: scoped,
@@ -11594,6 +11720,7 @@ ${tmtNode.summary}`);
11594
11720
  retrievalQuery,
11595
11721
  sessionKey
11596
11722
  });
11723
+ impressionRecorded = true;
11597
11724
  } else {
11598
11725
  const longTerm = await this.applyColdFallbackPipeline({
11599
11726
  prompt: retrievalQuery,
@@ -11601,6 +11728,8 @@ ${tmtNode.summary}`);
11601
11728
  recallResultLimit
11602
11729
  });
11603
11730
  if (longTerm.length > 0) {
11731
+ recallSource = "cold_fallback";
11732
+ recalledMemoryCount = longTerm.length;
11604
11733
  this.publishRecallResults({
11605
11734
  title: "Long-Term Memories (Fallback)",
11606
11735
  results: longTerm,
@@ -11608,6 +11737,7 @@ ${tmtNode.summary}`);
11608
11737
  retrievalQuery,
11609
11738
  sessionKey
11610
11739
  });
11740
+ impressionRecorded = true;
11611
11741
  }
11612
11742
  }
11613
11743
  }
@@ -11644,6 +11774,8 @@ ${tmtNode.summary}`);
11644
11774
  retrievalQuery
11645
11775
  )).slice(0, recallResultLimit);
11646
11776
  if (scoped.length > 0) {
11777
+ recallSource = "hot_embedding";
11778
+ recalledMemoryCount = scoped.length;
11647
11779
  this.publishRecallResults({
11648
11780
  title: "Relevant Memories",
11649
11781
  results: scoped,
@@ -11651,6 +11783,7 @@ ${tmtNode.summary}`);
11651
11783
  retrievalQuery,
11652
11784
  sessionKey
11653
11785
  });
11786
+ impressionRecorded = true;
11654
11787
  } else {
11655
11788
  const memories = await this.readAllMemoriesForNamespaces(recallNamespaces);
11656
11789
  if (memories.length > 0) {
@@ -11676,6 +11809,8 @@ ${tmtNode.summary}`);
11676
11809
  preloadedMap
11677
11810
  )).sort((a, b) => b.score - a.score).slice(0, recallResultLimit);
11678
11811
  if (recent.length > 0) {
11812
+ recallSource = "recent_scan";
11813
+ recalledMemoryCount = recent.length;
11679
11814
  this.publishRecallResults({
11680
11815
  title: "Recent Memories",
11681
11816
  results: recent,
@@ -11683,6 +11818,7 @@ ${tmtNode.summary}`);
11683
11818
  retrievalQuery,
11684
11819
  sessionKey
11685
11820
  });
11821
+ impressionRecorded = true;
11686
11822
  } else {
11687
11823
  const longTerm = await this.applyColdFallbackPipeline({
11688
11824
  prompt: retrievalQuery,
@@ -11690,6 +11826,8 @@ ${tmtNode.summary}`);
11690
11826
  recallResultLimit
11691
11827
  });
11692
11828
  if (longTerm.length > 0) {
11829
+ recallSource = "cold_fallback";
11830
+ recalledMemoryCount = longTerm.length;
11693
11831
  this.publishRecallResults({
11694
11832
  title: "Long-Term Memories (Fallback)",
11695
11833
  results: longTerm,
@@ -11697,6 +11835,7 @@ ${tmtNode.summary}`);
11697
11835
  retrievalQuery,
11698
11836
  sessionKey
11699
11837
  });
11838
+ impressionRecorded = true;
11700
11839
  }
11701
11840
  }
11702
11841
  } else {
@@ -11706,6 +11845,8 @@ ${tmtNode.summary}`);
11706
11845
  recallResultLimit
11707
11846
  });
11708
11847
  if (longTerm.length > 0) {
11848
+ recallSource = "cold_fallback";
11849
+ recalledMemoryCount = longTerm.length;
11709
11850
  this.publishRecallResults({
11710
11851
  title: "Long-Term Memories (Fallback)",
11711
11852
  results: longTerm,
@@ -11713,6 +11854,7 @@ ${tmtNode.summary}`);
11713
11854
  retrievalQuery,
11714
11855
  sessionKey
11715
11856
  });
11857
+ impressionRecorded = true;
11716
11858
  }
11717
11859
  }
11718
11860
  }
@@ -11848,8 +11990,32 @@ _Context: ${topQuestion.context}_`);
11848
11990
  timings.total = `${Date.now() - recallStart}ms`;
11849
11991
  const timingParts = Object.entries(timings).map(([k, v]) => `${k}=${v}`).join(", ");
11850
11992
  log.debug(`recall: ${timingParts}`);
11851
- if (sections.length === 0) return "";
11852
- return sections.join("\n\n---\n\n");
11993
+ if (!impressionRecorded && sessionKey && this.config.recordEmptyRecallImpressions) {
11994
+ this.lastRecall.record({ sessionKey, query: retrievalQuery, memoryIds: [] }).catch((err) => log.debug(`last recall record failed: ${err}`));
11995
+ }
11996
+ const context = sections.length === 0 ? "" : sections.join("\n\n---\n\n");
11997
+ this.emitTrace({
11998
+ kind: "recall_summary",
11999
+ traceId: createHash4("sha256").update(`${sessionKey ?? "default"}:${Date.now()}:${promptHash}`).digest("hex").slice(0, 16),
12000
+ operation: "recall",
12001
+ sessionKey,
12002
+ promptHash,
12003
+ promptLength: prompt.length,
12004
+ retrievalQueryHash,
12005
+ retrievalQueryLength: retrievalQuery.length,
12006
+ recallMode,
12007
+ recallResultLimit,
12008
+ qmdEnabled: this.config.qmdEnabled,
12009
+ qmdAvailable: this.qmd.isAvailable(),
12010
+ recallNamespaces,
12011
+ source: recallSource,
12012
+ recalledMemoryCount,
12013
+ injected: context.length > 0,
12014
+ contextChars: context.length,
12015
+ durationMs: Date.now() - recallStart,
12016
+ timings: { ...timings }
12017
+ });
12018
+ return context;
11853
12019
  }
11854
12020
  async processTurn(role, content, sessionKey) {
11855
12021
  if (role !== "user" && role !== "assistant") {
@@ -12426,6 +12592,9 @@ _Context: ${topQuestion.context}_`);
12426
12592
  } catch {
12427
12593
  }
12428
12594
  }
12595
+ if (recentInThread.length === 0 && this.config.graphWriteSessionAdjacencyEnabled !== false && fallbackCausalPredecessor && fallbackCausalPredecessor !== memoryRelPath) {
12596
+ recentInThread.push(fallbackCausalPredecessor);
12597
+ }
12429
12598
  const causalPredecessor = recentInThread[recentInThread.length - 1] ?? fallbackCausalPredecessor;
12430
12599
  await this.graphIndexFor(storage).onMemoryWritten({
12431
12600
  memoryPath: memoryRelPath,
@@ -12938,6 +13107,14 @@ ${snippet}`;
12938
13107
 
12939
13108
  ${lines.join("\n\n")}`;
12940
13109
  }
13110
+ emitTrace(event) {
13111
+ try {
13112
+ const cb = globalThis.__openclawEngramTrace;
13113
+ if (typeof cb === "function") cb(event);
13114
+ } catch (err) {
13115
+ log.debug(`trace callback failed: ${err}`);
13116
+ }
13117
+ }
12941
13118
  publishRecallResults(options) {
12942
13119
  const memoryIds = this.extractMemoryIdsFromResults(options.results);
12943
13120
  this.trackMemoryAccess(memoryIds);
@@ -14298,7 +14475,7 @@ mistakes: ${res.mistakesCount} patterns`
14298
14475
 
14299
14476
  // src/cli.ts
14300
14477
  import path33 from "path";
14301
- import { access as access2, readFile as readFile22 } from "fs/promises";
14478
+ import { access as access2, readFile as readFile22, readdir as readdir12, unlink as unlink3 } from "fs/promises";
14302
14479
 
14303
14480
  // src/transfer/export-json.ts
14304
14481
  import path25 from "path";
@@ -14769,6 +14946,54 @@ async function detectImportFormat(fromPath) {
14769
14946
  }
14770
14947
 
14771
14948
  // src/cli.ts
14949
+ function rankCandidateForKeep(a, b) {
14950
+ const aConfidence = typeof a.frontmatter.confidence === "number" ? a.frontmatter.confidence : 0;
14951
+ const bConfidence = typeof b.frontmatter.confidence === "number" ? b.frontmatter.confidence : 0;
14952
+ if (aConfidence !== bConfidence) return bConfidence - aConfidence;
14953
+ const aTs = Date.parse(a.frontmatter.updated ?? a.frontmatter.created ?? "");
14954
+ const bTs = Date.parse(b.frontmatter.updated ?? b.frontmatter.created ?? "");
14955
+ const aTime = Number.isNaN(aTs) ? 0 : aTs;
14956
+ const bTime = Number.isNaN(bTs) ? 0 : bTs;
14957
+ if (aTime !== bTime) return bTime - aTime;
14958
+ return a.path.localeCompare(b.path);
14959
+ }
14960
+ function buildDedupePlan(memories, keyBuilder) {
14961
+ const byKey = /* @__PURE__ */ new Map();
14962
+ for (const memory of memories) {
14963
+ const key = keyBuilder(memory);
14964
+ if (key.length === 0) continue;
14965
+ const existing = byKey.get(key);
14966
+ if (existing) {
14967
+ existing.push(memory);
14968
+ } else {
14969
+ byKey.set(key, [memory]);
14970
+ }
14971
+ }
14972
+ const keepPaths = [];
14973
+ const deletePaths = [];
14974
+ let groups = 0;
14975
+ let duplicates = 0;
14976
+ for (const entries of byKey.values()) {
14977
+ if (entries.length <= 1) continue;
14978
+ groups += 1;
14979
+ duplicates += entries.length - 1;
14980
+ const ranked = [...entries].sort(rankCandidateForKeep);
14981
+ keepPaths.push(ranked[0].path);
14982
+ for (let i = 1; i < ranked.length; i += 1) {
14983
+ deletePaths.push(ranked[i].path);
14984
+ }
14985
+ }
14986
+ return { groups, duplicates, keepPaths, deletePaths };
14987
+ }
14988
+ function normalizeAggressiveBody(content) {
14989
+ return content.normalize("NFKC").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/[`*_~>#-]+/g, " ").replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim().toLowerCase();
14990
+ }
14991
+ function planExactDuplicateDeletions(memories) {
14992
+ return buildDedupePlan(memories, (memory) => memory.content.trim());
14993
+ }
14994
+ function planAggressiveDuplicateDeletions(memories) {
14995
+ return buildDedupePlan(memories, (memory) => normalizeAggressiveBody(memory.content));
14996
+ }
14772
14997
  async function getPluginVersion() {
14773
14998
  try {
14774
14999
  const pkgPath = new URL("../package.json", import.meta.url);
@@ -14797,6 +15022,55 @@ async function resolveMemoryDirForNamespace(orchestrator, namespace) {
14797
15022
  }
14798
15023
  return candidate;
14799
15024
  }
15025
+ async function readAllMemoryFiles(memoryDir) {
15026
+ const roots = [path33.join(memoryDir, "facts"), path33.join(memoryDir, "corrections")];
15027
+ const out = [];
15028
+ const walk = async (dir) => {
15029
+ let entries;
15030
+ try {
15031
+ entries = await readdir12(dir, { withFileTypes: true });
15032
+ } catch {
15033
+ return;
15034
+ }
15035
+ for (const entry of entries) {
15036
+ const entryName = typeof entry.name === "string" ? entry.name : entry.name.toString("utf-8");
15037
+ const fullPath = path33.join(dir, entryName);
15038
+ if (entry.isDirectory()) {
15039
+ await walk(fullPath);
15040
+ continue;
15041
+ }
15042
+ if (!entry.isFile() || !entryName.endsWith(".md")) continue;
15043
+ try {
15044
+ const raw = await readFile22(fullPath, "utf-8");
15045
+ const parsed = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
15046
+ if (!parsed) continue;
15047
+ const fmRaw = parsed[1];
15048
+ const body = parsed[2] ?? "";
15049
+ const get = (key) => {
15050
+ const match = fmRaw.match(new RegExp(`^${key}:\\s*(.+)$`, "m"));
15051
+ return match ? match[1].trim() : "";
15052
+ };
15053
+ const confidenceRaw = get("confidence");
15054
+ const confidence = confidenceRaw.length > 0 ? Number(confidenceRaw) : void 0;
15055
+ out.push({
15056
+ path: fullPath,
15057
+ content: body,
15058
+ frontmatter: {
15059
+ id: get("id") || void 0,
15060
+ confidence: Number.isFinite(confidence) ? confidence : void 0,
15061
+ updated: get("updated") || void 0,
15062
+ created: get("created") || void 0
15063
+ }
15064
+ });
15065
+ } catch {
15066
+ }
15067
+ }
15068
+ };
15069
+ for (const root of roots) {
15070
+ await walk(root);
15071
+ }
15072
+ return out;
15073
+ }
14800
15074
  function registerCli(api, orchestrator) {
14801
15075
  api.registerCli(
14802
15076
  ({ program }) => {
@@ -14935,6 +15209,100 @@ function registerCli(api, orchestrator) {
14935
15209
  });
14936
15210
  console.log("OK");
14937
15211
  });
15212
+ cmd.command("dedupe-exact").description("Delete exact duplicate memory entries (same body text), keeping highest-confidence/newest copy").option("--dry-run", "Show what would be deleted without deleting files").option("--namespace <ns>", "Namespace to dedupe (v3.0+, default: config defaultNamespace)", "").option("--qmd-sync", "Run QMD update/embed after deletions (default: off)").action(async (...args) => {
15213
+ const options = args[0] ?? {};
15214
+ const dryRun = options.dryRun === true;
15215
+ const namespace = options.namespace ? String(options.namespace) : "";
15216
+ const qmdSync = options.qmdSync === true;
15217
+ const memoryDir = await resolveMemoryDirForNamespace(orchestrator, namespace);
15218
+ const memories = await readAllMemoryFiles(memoryDir);
15219
+ const plan = planExactDuplicateDeletions(memories);
15220
+ console.log(`Scanned ${memories.length} memory files in ${memoryDir}`);
15221
+ console.log(`Duplicate groups: ${plan.groups}`);
15222
+ console.log(`Duplicate files to delete: ${plan.deletePaths.length}`);
15223
+ if (plan.deletePaths.length === 0) {
15224
+ console.log("No exact duplicates found.");
15225
+ return;
15226
+ }
15227
+ if (dryRun) {
15228
+ console.log("Dry run enabled. No files deleted.");
15229
+ for (const filePath of plan.deletePaths.slice(0, 50)) {
15230
+ console.log(` - ${filePath}`);
15231
+ }
15232
+ if (plan.deletePaths.length > 50) {
15233
+ console.log(` ... and ${plan.deletePaths.length - 50} more`);
15234
+ }
15235
+ return;
15236
+ }
15237
+ let deleted = 0;
15238
+ for (const filePath of plan.deletePaths) {
15239
+ try {
15240
+ await unlink3(filePath);
15241
+ deleted += 1;
15242
+ } catch (err) {
15243
+ console.log(` failed to delete ${filePath}: ${String(err)}`);
15244
+ }
15245
+ }
15246
+ console.log(`Deleted ${deleted}/${plan.deletePaths.length} duplicate files.`);
15247
+ if (qmdSync) {
15248
+ await orchestrator.qmd.probe();
15249
+ if (orchestrator.qmd.isAvailable()) {
15250
+ await orchestrator.qmd.update();
15251
+ await orchestrator.qmd.embed();
15252
+ console.log("QMD sync complete.");
15253
+ } else {
15254
+ console.log(`QMD unavailable in this process; skipped sync. Status: ${orchestrator.qmd.debugStatus()}`);
15255
+ }
15256
+ }
15257
+ });
15258
+ cmd.command("dedupe-aggressive").description(
15259
+ "Delete aggressively-normalized duplicate memory entries (formatting/case/punctuation-insensitive)"
15260
+ ).option("--dry-run", "Show what would be deleted without deleting files").option("--namespace <ns>", "Namespace to dedupe (v3.0+, default: config defaultNamespace)", "").option("--qmd-sync", "Run QMD update/embed after deletions (default: off)").action(async (...args) => {
15261
+ const options = args[0] ?? {};
15262
+ const dryRun = options.dryRun === true;
15263
+ const namespace = options.namespace ? String(options.namespace) : "";
15264
+ const qmdSync = options.qmdSync === true;
15265
+ const memoryDir = await resolveMemoryDirForNamespace(orchestrator, namespace);
15266
+ const memories = await readAllMemoryFiles(memoryDir);
15267
+ const plan = planAggressiveDuplicateDeletions(memories);
15268
+ console.log(`Scanned ${memories.length} memory files in ${memoryDir}`);
15269
+ console.log(`Duplicate groups: ${plan.groups}`);
15270
+ console.log(`Duplicate files to delete: ${plan.deletePaths.length}`);
15271
+ if (plan.deletePaths.length === 0) {
15272
+ console.log("No aggressive duplicates found.");
15273
+ return;
15274
+ }
15275
+ if (dryRun) {
15276
+ console.log("Dry run enabled. No files deleted.");
15277
+ for (const filePath of plan.deletePaths.slice(0, 50)) {
15278
+ console.log(` - ${filePath}`);
15279
+ }
15280
+ if (plan.deletePaths.length > 50) {
15281
+ console.log(` ... and ${plan.deletePaths.length - 50} more`);
15282
+ }
15283
+ return;
15284
+ }
15285
+ let deleted = 0;
15286
+ for (const filePath of plan.deletePaths) {
15287
+ try {
15288
+ await unlink3(filePath);
15289
+ deleted += 1;
15290
+ } catch (err) {
15291
+ console.log(` failed to delete ${filePath}: ${String(err)}`);
15292
+ }
15293
+ }
15294
+ console.log(`Deleted ${deleted}/${plan.deletePaths.length} duplicate files.`);
15295
+ if (qmdSync) {
15296
+ await orchestrator.qmd.probe();
15297
+ if (orchestrator.qmd.isAvailable()) {
15298
+ await orchestrator.qmd.update();
15299
+ await orchestrator.qmd.embed();
15300
+ console.log("QMD sync complete.");
15301
+ } else {
15302
+ console.log(`QMD unavailable in this process; skipped sync. Status: ${orchestrator.qmd.debugStatus()}`);
15303
+ }
15304
+ }
15305
+ });
14938
15306
  cmd.command("search").argument("<query>", "Search query").option("-n, --max-results <number>", "Max results", "8").description("Search memories via QMD").action(async (...args) => {
14939
15307
  const query = typeof args[0] === "string" ? args[0] : String(args[0] ?? "");
14940
15308
  const options = args[1] ?? {};
@@ -15452,7 +15820,7 @@ var index_default = {
15452
15820
  `initialized (debug=${cfg.debug}, qmdEnabled=${cfg.qmdEnabled}, transcriptEnabled=${cfg.transcriptEnabled}, hourlySummariesEnabled=${cfg.hourlySummariesEnabled}, localLlmEnabled=${cfg.localLlmEnabled})`
15453
15821
  );
15454
15822
  if (globalThis[ENGRAM_REGISTERED_GUARD] === true) {
15455
- log.warn("register called more than once; skipping duplicate hook/tool registration");
15823
+ log.debug("register called more than once; skipping duplicate hook/tool registration");
15456
15824
  return;
15457
15825
  }
15458
15826
  globalThis[ENGRAM_REGISTERED_GUARD] = true;
@@ -15620,6 +15988,7 @@ Use this context naturally when relevant. Never quote or expose this memory cont
15620
15988
  const newJob = {
15621
15989
  id: jobId,
15622
15990
  agentId: "generalist",
15991
+ model,
15623
15992
  name: "Engram Hourly Summary",
15624
15993
  enabled: true,
15625
15994
  createdAtMs: Date.now(),