@joshuaswarren/openclaw-engram 8.3.28 → 8.3.30

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
@@ -10733,6 +10733,81 @@ var GraphIndex = class {
10733
10733
  }
10734
10734
  };
10735
10735
 
10736
+ // src/replay/types.ts
10737
+ var VALID_SOURCES = /* @__PURE__ */ new Set(["openclaw", "claude", "chatgpt"]);
10738
+ var VALID_ROLES = /* @__PURE__ */ new Set(["user", "assistant"]);
10739
+ var ISO_UTC_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
10740
+ var REPLAY_UNKNOWN_SESSION_KEY = "replay:unknown";
10741
+ function normalizeIsoForComparison(value) {
10742
+ return value.includes(".") ? value : value.replace("Z", ".000Z");
10743
+ }
10744
+ function isReplaySource(value) {
10745
+ return typeof value === "string" && VALID_SOURCES.has(value);
10746
+ }
10747
+ function isReplayRole(value) {
10748
+ return typeof value === "string" && VALID_ROLES.has(value);
10749
+ }
10750
+ function normalizeReplaySessionKey(value) {
10751
+ if (typeof value !== "string") return REPLAY_UNKNOWN_SESSION_KEY;
10752
+ const trimmed = value.trim();
10753
+ return trimmed.length > 0 ? trimmed : REPLAY_UNKNOWN_SESSION_KEY;
10754
+ }
10755
+ function parseIsoTimestamp(value) {
10756
+ if (typeof value !== "string" || !ISO_UTC_TIMESTAMP_RE.test(value)) return null;
10757
+ const ts = Date.parse(value);
10758
+ if (!Number.isFinite(ts)) return null;
10759
+ const roundTrip = new Date(ts).toISOString();
10760
+ if (roundTrip !== normalizeIsoForComparison(value)) return null;
10761
+ return ts;
10762
+ }
10763
+ function validateReplayTurn(turn, index) {
10764
+ const issues = [];
10765
+ if (!turn || typeof turn !== "object") {
10766
+ issues.push({
10767
+ code: "turn.invalid",
10768
+ message: "Replay turn must be an object.",
10769
+ index
10770
+ });
10771
+ return issues;
10772
+ }
10773
+ if (!isReplayRole(turn.role)) {
10774
+ issues.push({
10775
+ code: "turn.role.invalid",
10776
+ message: `Replay role must be 'user' or 'assistant', received '${String(turn.role)}'.`,
10777
+ index
10778
+ });
10779
+ }
10780
+ if (!isReplaySource(turn.source)) {
10781
+ issues.push({
10782
+ code: "turn.source.invalid",
10783
+ message: `Replay source must be 'openclaw', 'claude', or 'chatgpt', received '${String(turn.source)}'.`,
10784
+ index
10785
+ });
10786
+ }
10787
+ if (!turn.sessionKey || typeof turn.sessionKey !== "string" || turn.sessionKey.trim().length === 0) {
10788
+ issues.push({
10789
+ code: "turn.sessionKey.invalid",
10790
+ message: "Replay sessionKey is required.",
10791
+ index
10792
+ });
10793
+ }
10794
+ if (!turn.content || typeof turn.content !== "string" || turn.content.trim().length === 0) {
10795
+ issues.push({
10796
+ code: "turn.content.invalid",
10797
+ message: "Replay content must be a non-empty string.",
10798
+ index
10799
+ });
10800
+ }
10801
+ if (!turn.timestamp || typeof turn.timestamp !== "string" || parseIsoTimestamp(turn.timestamp) === null) {
10802
+ issues.push({
10803
+ code: "turn.timestamp.invalid",
10804
+ message: `Replay timestamp must be a valid ISO timestamp, received '${String(turn.timestamp)}'.`,
10805
+ index
10806
+ });
10807
+ }
10808
+ return issues;
10809
+ }
10810
+
10736
10811
  // src/conversation-index/chunker.ts
10737
10812
  function chunkTranscriptEntries(sessionKey, entries, opts) {
10738
10813
  const maxChars = Math.max(500, opts.maxChars);
@@ -12056,10 +12131,22 @@ var Orchestrator = class _Orchestrator {
12056
12131
  while (this.queueProcessing || this.extractionQueue.length > 0) {
12057
12132
  if (Date.now() - started > timeoutMs) {
12058
12133
  log.warn(`waitForExtractionIdle timed out after ${timeoutMs}ms`);
12059
- return;
12134
+ return false;
12135
+ }
12136
+ await new Promise((resolve) => setTimeout(resolve, 50));
12137
+ }
12138
+ return true;
12139
+ }
12140
+ async waitForConsolidationIdle(timeoutMs = 6e4) {
12141
+ const started = Date.now();
12142
+ while (this.consolidationInFlight) {
12143
+ if (Date.now() - started > timeoutMs) {
12144
+ log.warn(`waitForConsolidationIdle timed out after ${timeoutMs}ms`);
12145
+ return false;
12060
12146
  }
12061
12147
  await new Promise((resolve) => setTimeout(resolve, 50));
12062
12148
  }
12149
+ return true;
12063
12150
  }
12064
12151
  async getStorage(namespace) {
12065
12152
  const ns = namespace && namespace.length > 0 ? namespace : this.config.defaultNamespace;
@@ -13120,6 +13207,46 @@ _Context: ${topQuestion.context}_`);
13120
13207
  if (decision === "keep_buffering") return;
13121
13208
  await this.queueBufferedExtraction(this.buffer.getTurns(), "trigger_mode");
13122
13209
  }
13210
+ async ingestReplayBatch(turns, options = {}) {
13211
+ if (!Array.isArray(turns) || turns.length === 0) return;
13212
+ const bySession = /* @__PURE__ */ new Map();
13213
+ for (const turn of turns) {
13214
+ if (turn.role !== "user" && turn.role !== "assistant") continue;
13215
+ const key = normalizeReplaySessionKey(turn.sessionKey);
13216
+ const list = bySession.get(key) ?? [];
13217
+ list.push({
13218
+ role: turn.role,
13219
+ content: turn.content,
13220
+ timestamp: turn.timestamp,
13221
+ sessionKey: key
13222
+ });
13223
+ bySession.set(key, list);
13224
+ }
13225
+ const replayTasks = [];
13226
+ for (const sessionTurns of bySession.values()) {
13227
+ if (sessionTurns.length === 0) continue;
13228
+ replayTasks.push(
13229
+ new Promise((resolve, reject) => {
13230
+ void this.queueBufferedExtraction(sessionTurns, "trigger_mode", {
13231
+ skipDedupeCheck: true,
13232
+ clearBufferAfterExtraction: false,
13233
+ skipCharThreshold: true,
13234
+ extractionDeadlineMs: options.deadlineMs,
13235
+ onTaskSettled: (err) => err ? reject(err) : resolve()
13236
+ }).catch(reject);
13237
+ })
13238
+ );
13239
+ }
13240
+ if (replayTasks.length > 0) {
13241
+ const settled = await Promise.allSettled(replayTasks);
13242
+ const firstRejected = settled.find(
13243
+ (result) => result.status === "rejected"
13244
+ );
13245
+ if (firstRejected) {
13246
+ throw firstRejected.reason;
13247
+ }
13248
+ }
13249
+ }
13123
13250
  async observeSessionHeartbeat(sessionKey) {
13124
13251
  if (this.config.sessionObserverEnabled !== true) return;
13125
13252
  if (!sessionKey || sessionKey.length === 0) return;
@@ -13160,10 +13287,21 @@ _Context: ${topQuestion.context}_`);
13160
13287
  async queueBufferedExtraction(turnsToExtract, reason, options = {}) {
13161
13288
  if (!options.skipDedupeCheck && !this.shouldQueueExtraction(turnsToExtract)) {
13162
13289
  log.debug(`extraction dedupe skip: preserving buffer (${reason})`);
13290
+ options.onTaskSettled?.();
13163
13291
  return;
13164
13292
  }
13165
13293
  this.extractionQueue.push(async () => {
13166
- await this.runExtraction(turnsToExtract);
13294
+ try {
13295
+ await this.runExtraction(turnsToExtract, {
13296
+ clearBufferAfterExtraction: options.clearBufferAfterExtraction ?? true,
13297
+ skipCharThreshold: options.skipCharThreshold ?? false,
13298
+ deadlineMs: options.extractionDeadlineMs
13299
+ });
13300
+ options.onTaskSettled?.();
13301
+ } catch (err) {
13302
+ options.onTaskSettled?.(err);
13303
+ throw err;
13304
+ }
13167
13305
  });
13168
13306
  if (!this.queueProcessing) {
13169
13307
  this.queueProcessing = true;
@@ -13217,25 +13355,41 @@ _Context: ${topQuestion.context}_`);
13217
13355
  }
13218
13356
  this.queueProcessing = false;
13219
13357
  }
13220
- async runExtraction(turns) {
13358
+ async runExtraction(turns, options = {}) {
13221
13359
  log.debug(`running extraction on ${turns.length} turns`);
13360
+ const clearBufferAfterExtraction = options.clearBufferAfterExtraction ?? true;
13361
+ const skipCharThreshold = options.skipCharThreshold ?? false;
13362
+ const deadlineMs = typeof options.deadlineMs === "number" && Number.isFinite(options.deadlineMs) ? options.deadlineMs : void 0;
13363
+ const throwIfDeadlineExceeded = (stage) => {
13364
+ if (typeof deadlineMs === "number" && Date.now() > deadlineMs) {
13365
+ throw new Error(`replay extraction deadline exceeded (${stage})`);
13366
+ }
13367
+ };
13368
+ const clearBuffer = async () => {
13369
+ if (clearBufferAfterExtraction) {
13370
+ await this.buffer.clearAfterExtraction();
13371
+ }
13372
+ };
13222
13373
  const sessionKey = turns[0]?.sessionKey ?? "";
13223
13374
  if (sessionKey.includes(":cron:")) {
13224
13375
  log.debug(`skipping extraction for cron session: ${sessionKey}`);
13225
- await this.buffer.clearAfterExtraction();
13376
+ await clearBuffer();
13226
13377
  return;
13227
13378
  }
13228
13379
  const normalizedTurns = turns.filter((t) => (t.role === "user" || t.role === "assistant") && typeof t.content === "string").map((t) => ({
13229
13380
  ...t,
13230
13381
  content: t.content.trim().slice(0, this.config.extractionMaxTurnChars)
13231
13382
  })).filter((t) => t.content.length > 0);
13383
+ throwIfDeadlineExceeded("before_extract");
13232
13384
  const userTurns = normalizedTurns.filter((t) => t.role === "user");
13233
13385
  const totalChars = normalizedTurns.reduce((sum, t) => sum + t.content.length, 0);
13234
- if (totalChars < this.config.extractionMinChars || userTurns.length < this.config.extractionMinUserTurns) {
13386
+ const belowCharThreshold = totalChars < this.config.extractionMinChars;
13387
+ const belowUserTurnThreshold = userTurns.length < this.config.extractionMinUserTurns;
13388
+ if (!skipCharThreshold && belowCharThreshold || belowUserTurnThreshold) {
13235
13389
  log.debug(
13236
13390
  `skipping extraction: below threshold (totalChars=${totalChars}, userTurns=${userTurns.length})`
13237
13391
  );
13238
- await this.buffer.clearAfterExtraction();
13392
+ await clearBuffer();
13239
13393
  return;
13240
13394
  }
13241
13395
  const principal = resolvePrincipal(sessionKey, this.config);
@@ -13243,19 +13397,20 @@ _Context: ${topQuestion.context}_`);
13243
13397
  const storage = await this.storageRouter.storageFor(selfNamespace);
13244
13398
  const existingEntities = await storage.listEntityNames();
13245
13399
  const result = await this.extraction.extract(normalizedTurns, existingEntities);
13400
+ throwIfDeadlineExceeded("before_persist");
13246
13401
  if (!result) {
13247
13402
  log.warn("runExtraction: extraction returned null/undefined");
13248
- await this.buffer.clearAfterExtraction();
13403
+ await clearBuffer();
13249
13404
  return;
13250
13405
  }
13251
13406
  if (!Array.isArray(result.facts)) {
13252
13407
  log.warn("runExtraction: extraction returned invalid facts (not an array)", { factsType: typeof result.facts, resultKeys: Object.keys(result) });
13253
- await this.buffer.clearAfterExtraction();
13408
+ await clearBuffer();
13254
13409
  return;
13255
13410
  }
13256
13411
  if (result.facts.length === 0 && result.entities.length === 0 && result.questions.length === 0 && result.profileUpdates.length === 0) {
13257
13412
  log.debug("runExtraction: extraction produced no durable outputs; skipping persistence");
13258
- await this.buffer.clearAfterExtraction();
13413
+ await clearBuffer();
13259
13414
  return;
13260
13415
  }
13261
13416
  let threadIdForExtraction = null;
@@ -13268,7 +13423,7 @@ _Context: ${topQuestion.context}_`);
13268
13423
  }
13269
13424
  }
13270
13425
  const persistedIds = await this.persistExtraction(result, storage, threadIdForExtraction);
13271
- await this.buffer.clearAfterExtraction();
13426
+ await clearBuffer();
13272
13427
  if (this.config.memoryBoxesEnabled && persistedIds.length > 0) {
13273
13428
  const extractionTopics = deriveTopicsFromExtraction(result);
13274
13429
  await this.boxBuilderFor(storage).onExtraction({
@@ -16666,6 +16821,471 @@ async function detectImportFormat(fromPath) {
16666
16821
  return null;
16667
16822
  }
16668
16823
 
16824
+ // src/replay/runner.ts
16825
+ function clampNonNegativeInt(value, defaultValue) {
16826
+ if (!Number.isFinite(value)) return defaultValue;
16827
+ return Math.max(0, Math.floor(value));
16828
+ }
16829
+ function clampBatchSize(value) {
16830
+ const parsed = clampNonNegativeInt(value, 100);
16831
+ if (parsed < 1) return 1;
16832
+ return Math.min(parsed, 1e3);
16833
+ }
16834
+ function toWarning(issue) {
16835
+ return {
16836
+ code: issue.code,
16837
+ message: issue.message,
16838
+ index: issue.index
16839
+ };
16840
+ }
16841
+ function inDateRange(turn, fromTs, toTs) {
16842
+ const turnTs = parseIsoTimestamp(turn.timestamp);
16843
+ if (turnTs === null) return false;
16844
+ if (fromTs !== null && turnTs < fromTs) return false;
16845
+ if (toTs !== null && turnTs > toTs) return false;
16846
+ return true;
16847
+ }
16848
+ function buildReplayNormalizerRegistry(normalizers) {
16849
+ const registry = {};
16850
+ for (const normalizer of normalizers) {
16851
+ if (!normalizer?.source) {
16852
+ throw new Error("replay normalizer source is required");
16853
+ }
16854
+ if (registry[normalizer.source]) {
16855
+ throw new Error(`duplicate replay normalizer for source '${normalizer.source}'`);
16856
+ }
16857
+ registry[normalizer.source] = normalizer;
16858
+ }
16859
+ return registry;
16860
+ }
16861
+ async function runReplay(source, input, registry, handlers = {}, options = {}) {
16862
+ const normalizer = registry[source];
16863
+ if (!normalizer) {
16864
+ throw new Error(`missing replay normalizer for source '${source}'`);
16865
+ }
16866
+ return runReplayWithNormalizer(normalizer, input, handlers, options);
16867
+ }
16868
+ async function runReplayWithNormalizer(normalizer, input, handlers = {}, options = {}) {
16869
+ const parseResult = await normalizer.parse(input, options);
16870
+ if (!parseResult || typeof parseResult !== "object") {
16871
+ throw new Error(`replay normalizer '${normalizer.source}' returned invalid parse result object`);
16872
+ }
16873
+ if (!Array.isArray(parseResult.turns)) {
16874
+ throw new Error(`replay normalizer '${normalizer.source}' returned invalid parse result: turns must be an array`);
16875
+ }
16876
+ if (parseResult.warnings != null && !Array.isArray(parseResult.warnings)) {
16877
+ throw new Error(`replay normalizer '${normalizer.source}' returned invalid parse result: warnings must be an array`);
16878
+ }
16879
+ const warnings = [...parseResult.warnings ?? []];
16880
+ const parsedTurns = parseResult.turns;
16881
+ const validTurns = [];
16882
+ let invalidTurns = 0;
16883
+ for (let i = 0; i < parsedTurns.length; i += 1) {
16884
+ const turn = parsedTurns[i];
16885
+ const issues = validateReplayTurn(turn, i);
16886
+ if (issues.length === 0 && turn.source !== normalizer.source) {
16887
+ issues.push({
16888
+ code: "turn.source.mismatch",
16889
+ message: `Replay turn source '${turn.source}' does not match normalizer source '${normalizer.source}'.`,
16890
+ index: i
16891
+ });
16892
+ }
16893
+ if (issues.length > 0) {
16894
+ invalidTurns += 1;
16895
+ for (const issue of issues) warnings.push(toWarning(issue));
16896
+ continue;
16897
+ }
16898
+ validTurns.push(turn);
16899
+ }
16900
+ const sorted = [...validTurns].sort((a, b) => {
16901
+ const left = parseIsoTimestamp(a.timestamp) ?? 0;
16902
+ const right = parseIsoTimestamp(b.timestamp) ?? 0;
16903
+ return left - right;
16904
+ });
16905
+ const fromTs = options.from ? parseIsoTimestamp(options.from) : null;
16906
+ const toTs = options.to ? parseIsoTimestamp(options.to) : null;
16907
+ if (options.from && fromTs === null) {
16908
+ throw new Error(`invalid replay --from timestamp '${options.from}'`);
16909
+ }
16910
+ if (options.to && toTs === null) {
16911
+ throw new Error(`invalid replay --to timestamp '${options.to}'`);
16912
+ }
16913
+ if (fromTs !== null && toTs !== null && fromTs > toTs) {
16914
+ throw new Error("invalid replay date range: --from is after --to");
16915
+ }
16916
+ const ranged = sorted.filter((turn) => inDateRange(turn, fromTs, toTs));
16917
+ const filteredByDate = sorted.length - ranged.length;
16918
+ const startOffset = clampNonNegativeInt(options.startOffset, 0);
16919
+ const skippedByOffset = Math.min(startOffset, ranged.length);
16920
+ const offsetApplied = ranged.slice(startOffset);
16921
+ const maxTurns = clampNonNegativeInt(options.maxTurns, offsetApplied.length);
16922
+ const selected = offsetApplied.slice(0, maxTurns);
16923
+ const dryRun = options.dryRun === true;
16924
+ const batchSize = clampBatchSize(options.batchSize);
16925
+ let batchCount = 0;
16926
+ if (!dryRun) {
16927
+ for (let i = 0; i < selected.length; i += batchSize) {
16928
+ const batch = selected.slice(i, i + batchSize);
16929
+ batchCount += 1;
16930
+ if (handlers.onBatch) await handlers.onBatch(batch);
16931
+ if (handlers.onTurn) {
16932
+ for (const turn of batch) {
16933
+ await handlers.onTurn(turn);
16934
+ }
16935
+ }
16936
+ }
16937
+ } else if (selected.length > 0) {
16938
+ batchCount = Math.ceil(selected.length / batchSize);
16939
+ }
16940
+ return {
16941
+ source: normalizer.source,
16942
+ parsedTurns: parsedTurns.length,
16943
+ validTurns: sorted.length,
16944
+ invalidTurns,
16945
+ filteredByDate,
16946
+ skippedByOffset,
16947
+ processedTurns: selected.length,
16948
+ batchCount,
16949
+ dryRun,
16950
+ nextOffset: startOffset + selected.length,
16951
+ firstTimestamp: selected[0]?.timestamp,
16952
+ lastTimestamp: selected[selected.length - 1]?.timestamp,
16953
+ warnings
16954
+ };
16955
+ }
16956
+
16957
+ // src/replay/normalizers/shared.ts
16958
+ function normalizeReplayRole(value, options = {}) {
16959
+ if (typeof value !== "string") return null;
16960
+ const role = value.trim().toLowerCase();
16961
+ if (isReplayRole(role)) return role;
16962
+ if (options.userAliases?.includes(role) || role === "human") return "user";
16963
+ if (options.assistantAliases?.includes(role) || role === "ai" || role === "model") return "assistant";
16964
+ return null;
16965
+ }
16966
+ function normalizeReplayContent(value) {
16967
+ if (typeof value === "string") {
16968
+ const content = value.trim();
16969
+ return content.length > 0 ? content : null;
16970
+ }
16971
+ if (Array.isArray(value)) {
16972
+ const text = value.map((part) => typeof part === "string" ? part : "").join("\n").trim();
16973
+ return text.length > 0 ? text : null;
16974
+ }
16975
+ if (value && typeof value === "object") {
16976
+ const obj = value;
16977
+ if (Array.isArray(obj.parts)) return normalizeReplayContent(obj.parts);
16978
+ if (typeof obj.text === "string") return normalizeReplayContent(obj.text);
16979
+ }
16980
+ return null;
16981
+ }
16982
+ function normalizeReplayTimestamp(value, options = {}) {
16983
+ const toIso = (millis) => {
16984
+ if (!Number.isFinite(millis)) return null;
16985
+ const date = new Date(millis);
16986
+ if (!Number.isFinite(date.getTime())) return null;
16987
+ try {
16988
+ return date.toISOString();
16989
+ } catch {
16990
+ return null;
16991
+ }
16992
+ };
16993
+ if (typeof value === "number" && Number.isFinite(value)) {
16994
+ const millis = value > 1e12 ? value : value * 1e3;
16995
+ return toIso(millis);
16996
+ }
16997
+ if (options.acceptDateObject && value instanceof Date && Number.isFinite(value.getTime())) {
16998
+ return toIso(value.getTime());
16999
+ }
17000
+ if (typeof value !== "string") return null;
17001
+ const raw = options.trimString === false ? value : value.trim();
17002
+ if (raw.length === 0) return null;
17003
+ return toIso(Date.parse(raw));
17004
+ }
17005
+
17006
+ // src/replay/normalizers/chatgpt.ts
17007
+ function gatherConversations(input) {
17008
+ if (Array.isArray(input)) {
17009
+ return input.filter((item) => !!item && typeof item === "object");
17010
+ }
17011
+ if (!input || typeof input !== "object") return [];
17012
+ const obj = input;
17013
+ if (Array.isArray(obj.conversations)) {
17014
+ return obj.conversations.filter((item) => !!item && typeof item === "object");
17015
+ }
17016
+ return [obj];
17017
+ }
17018
+ function extractFromMapping(conversation) {
17019
+ const mapping = conversation.mapping;
17020
+ if (!mapping || typeof mapping !== "object") return [];
17021
+ const nodes = mapping;
17022
+ const currentNodeId = typeof conversation.current_node === "string" ? conversation.current_node : null;
17023
+ if (!currentNodeId || !nodes[currentNodeId] || typeof nodes[currentNodeId] !== "object") {
17024
+ const loose = [];
17025
+ for (const node of Object.values(nodes)) {
17026
+ if (!node || typeof node !== "object") continue;
17027
+ const nodeObj = node;
17028
+ const message = nodeObj.message;
17029
+ if (!message || typeof message !== "object") continue;
17030
+ loose.push({
17031
+ ...message,
17032
+ _nodeId: typeof nodeObj.id === "string" ? nodeObj.id : void 0,
17033
+ _nodeCreateTime: nodeObj.create_time
17034
+ });
17035
+ }
17036
+ return loose;
17037
+ }
17038
+ const chain = [];
17039
+ const seen = /* @__PURE__ */ new Set();
17040
+ let cursor = currentNodeId;
17041
+ while (cursor && !seen.has(cursor)) {
17042
+ seen.add(cursor);
17043
+ const node = nodes[cursor];
17044
+ if (!node || typeof node !== "object") break;
17045
+ const nodeObj = node;
17046
+ const message = nodeObj.message;
17047
+ if (message && typeof message === "object") {
17048
+ chain.push({
17049
+ ...message,
17050
+ _nodeId: typeof nodeObj.id === "string" ? nodeObj.id : cursor,
17051
+ _nodeCreateTime: nodeObj.create_time
17052
+ });
17053
+ }
17054
+ cursor = typeof nodeObj.parent === "string" ? nodeObj.parent : null;
17055
+ }
17056
+ return chain.reverse();
17057
+ }
17058
+ var chatgptReplayNormalizer = {
17059
+ source: "chatgpt",
17060
+ parse(input, options = {}) {
17061
+ let parsedInput = input;
17062
+ if (typeof input === "string") {
17063
+ try {
17064
+ parsedInput = JSON.parse(input);
17065
+ } catch {
17066
+ parsedInput = [];
17067
+ }
17068
+ }
17069
+ const warnings = [];
17070
+ const turns = [];
17071
+ const conversations = gatherConversations(parsedInput);
17072
+ for (let i = 0; i < conversations.length; i += 1) {
17073
+ const conversation = conversations[i];
17074
+ const convIdRaw = conversation.id ?? conversation.conversation_id ?? conversation.uuid;
17075
+ const hasSourceConversationId = typeof convIdRaw === "string" && convIdRaw.trim().length > 0;
17076
+ const convId = hasSourceConversationId ? convIdRaw.trim() : `conv-${i + 1}`;
17077
+ const sessionKey = `replay:chatgpt:${convId}`;
17078
+ const fallbackSessionKey = options.defaultSessionKey?.trim() || sessionKey;
17079
+ const messageRows = Array.isArray(conversation.messages) ? conversation.messages.filter((item) => !!item && typeof item === "object") : extractFromMapping(conversation);
17080
+ for (let j = 0; j < messageRows.length; j += 1) {
17081
+ const row = messageRows[j];
17082
+ const role = normalizeReplayRole(
17083
+ row.author?.role ?? row.role
17084
+ );
17085
+ const content = normalizeReplayContent(
17086
+ row.content?.parts ?? row.content ?? row.text
17087
+ );
17088
+ const timestamp = normalizeReplayTimestamp(
17089
+ row.create_time ?? row.timestamp ?? row._nodeCreateTime ?? row.created_at
17090
+ );
17091
+ if (!role || !content || !timestamp) {
17092
+ const message = `Skipping invalid ChatGPT replay message at conversation ${i + 1}, index ${j}.`;
17093
+ if (options.strict) throw new Error(message);
17094
+ warnings.push({ code: "replay.chatgpt.message.invalid", message, index: j });
17095
+ continue;
17096
+ }
17097
+ const externalIdRaw = row.id ?? row._nodeId;
17098
+ turns.push({
17099
+ source: "chatgpt",
17100
+ sessionKey: hasSourceConversationId ? sessionKey : fallbackSessionKey,
17101
+ role,
17102
+ content,
17103
+ timestamp,
17104
+ externalId: typeof externalIdRaw === "string" ? externalIdRaw : void 0,
17105
+ metadata: {
17106
+ conversationId: convId,
17107
+ conversationTitle: typeof conversation.title === "string" ? conversation.title : void 0
17108
+ }
17109
+ });
17110
+ }
17111
+ }
17112
+ return { turns, warnings };
17113
+ }
17114
+ };
17115
+
17116
+ // src/replay/normalizers/claude.ts
17117
+ function gatherConversations2(input) {
17118
+ if (Array.isArray(input)) return input.filter((item) => !!item && typeof item === "object");
17119
+ if (!input || typeof input !== "object") return [];
17120
+ const obj = input;
17121
+ if (Array.isArray(obj.conversations)) {
17122
+ return obj.conversations.filter((item) => !!item && typeof item === "object");
17123
+ }
17124
+ if (Array.isArray(obj.chat_messages) || Array.isArray(obj.messages)) {
17125
+ return [obj];
17126
+ }
17127
+ return [];
17128
+ }
17129
+ var claudeReplayNormalizer = {
17130
+ source: "claude",
17131
+ parse(input, options = {}) {
17132
+ let parsedInput = input;
17133
+ if (typeof input === "string") {
17134
+ try {
17135
+ parsedInput = JSON.parse(input);
17136
+ } catch {
17137
+ parsedInput = [];
17138
+ }
17139
+ }
17140
+ const warnings = [];
17141
+ const turns = [];
17142
+ const conversations = gatherConversations2(parsedInput);
17143
+ for (let i = 0; i < conversations.length; i += 1) {
17144
+ const conversation = conversations[i];
17145
+ const convoIdRaw = conversation.uuid ?? conversation.id ?? conversation.conversation_id;
17146
+ const hasSourceConversationId = typeof convoIdRaw === "string" && convoIdRaw.trim().length > 0;
17147
+ const convoId = hasSourceConversationId ? convoIdRaw.trim() : `conv-${i + 1}`;
17148
+ const sessionKey = `replay:claude:${convoId}`;
17149
+ const fallbackSessionKey = options.defaultSessionKey?.trim() || sessionKey;
17150
+ const messagesRaw = Array.isArray(conversation.chat_messages) ? conversation.chat_messages : Array.isArray(conversation.messages) ? conversation.messages : [];
17151
+ for (let j = 0; j < messagesRaw.length; j += 1) {
17152
+ const msg = messagesRaw[j];
17153
+ if (!msg || typeof msg !== "object") {
17154
+ warnings.push({
17155
+ code: "replay.claude.message.invalid",
17156
+ message: `Skipping malformed Claude message at conversation ${i + 1}, index ${j}.`,
17157
+ index: j
17158
+ });
17159
+ continue;
17160
+ }
17161
+ const row = msg;
17162
+ const role = normalizeReplayRole(
17163
+ row.sender ?? row.role ?? row.author?.role
17164
+ );
17165
+ const content = normalizeReplayContent(row.text ?? row.content ?? row.message);
17166
+ const timestamp = normalizeReplayTimestamp(
17167
+ row.created_at ?? row.createdAt ?? row.updated_at ?? row.updatedAt ?? row.timestamp
17168
+ );
17169
+ if (!role || !content || !timestamp) {
17170
+ const message = `Skipping invalid Claude replay message at conversation ${i + 1}, index ${j}.`;
17171
+ if (options.strict) throw new Error(message);
17172
+ warnings.push({ code: "replay.claude.message.invalid", message, index: j });
17173
+ continue;
17174
+ }
17175
+ const externalIdRaw = row.uuid ?? row.id;
17176
+ turns.push({
17177
+ source: "claude",
17178
+ sessionKey: hasSourceConversationId ? sessionKey : fallbackSessionKey,
17179
+ role,
17180
+ content,
17181
+ timestamp,
17182
+ externalId: typeof externalIdRaw === "string" ? externalIdRaw : void 0,
17183
+ metadata: {
17184
+ conversationId: convoId,
17185
+ conversationName: typeof conversation.name === "string" ? conversation.name : void 0
17186
+ }
17187
+ });
17188
+ }
17189
+ }
17190
+ return { turns, warnings };
17191
+ }
17192
+ };
17193
+
17194
+ // src/replay/normalizers/openclaw.ts
17195
+ function parseJsonl(raw, warnings) {
17196
+ const out = [];
17197
+ for (const [index, line] of raw.split("\n").entries()) {
17198
+ const trimmed = line.trim();
17199
+ if (!trimmed) continue;
17200
+ try {
17201
+ out.push(JSON.parse(trimmed));
17202
+ } catch {
17203
+ warnings.push({
17204
+ code: "replay.openclaw.jsonl.invalid_line",
17205
+ message: `Skipping invalid JSONL line ${index + 1}.`,
17206
+ index
17207
+ });
17208
+ }
17209
+ }
17210
+ return out;
17211
+ }
17212
+ function gatherCandidates(input, warnings) {
17213
+ if (Array.isArray(input)) return input;
17214
+ if (typeof input === "string") {
17215
+ try {
17216
+ return gatherCandidates(JSON.parse(input), warnings);
17217
+ } catch {
17218
+ return parseJsonl(input, warnings);
17219
+ }
17220
+ }
17221
+ if (!input || typeof input !== "object") return [];
17222
+ const obj = input;
17223
+ if (Array.isArray(obj.turns)) return obj.turns;
17224
+ if (Array.isArray(obj.entries)) return obj.entries;
17225
+ if (Array.isArray(obj.messages)) return obj.messages;
17226
+ if (Array.isArray(obj.records)) {
17227
+ const rows = [];
17228
+ for (const rec of obj.records) {
17229
+ if (!rec || typeof rec !== "object") continue;
17230
+ const record = rec;
17231
+ const content = typeof record.content === "string" ? record.content : null;
17232
+ if (!content) continue;
17233
+ const path36 = typeof record.path === "string" ? record.path : "";
17234
+ if (!path36.startsWith("transcripts/") && !path36.includes("/transcripts/")) continue;
17235
+ rows.push(...parseJsonl(content, warnings));
17236
+ }
17237
+ return rows;
17238
+ }
17239
+ return [];
17240
+ }
17241
+ var openclawReplayNormalizer = {
17242
+ source: "openclaw",
17243
+ parse(input, options = {}) {
17244
+ const warnings = [];
17245
+ const rawTurns = gatherCandidates(input, warnings);
17246
+ const turns = [];
17247
+ const defaultSessionKey = options.defaultSessionKey?.trim() || "replay:openclaw:import";
17248
+ for (let i = 0; i < rawTurns.length; i += 1) {
17249
+ const raw = rawTurns[i];
17250
+ if (!raw || typeof raw !== "object") {
17251
+ warnings.push({
17252
+ code: "replay.openclaw.turn.invalid",
17253
+ message: "Skipping non-object replay turn.",
17254
+ index: i
17255
+ });
17256
+ continue;
17257
+ }
17258
+ const row = raw;
17259
+ const role = normalizeReplayRole(row.role ?? row.sender ?? row.author?.role, {
17260
+ assistantAliases: ["bot"]
17261
+ });
17262
+ const content = normalizeReplayContent(row.content ?? row.text ?? row.message);
17263
+ const timestamp = normalizeReplayTimestamp(
17264
+ row.timestamp ?? row.createdAt ?? row.created_at ?? row.time ?? row.date,
17265
+ { acceptDateObject: true }
17266
+ );
17267
+ if (!role || !content || !timestamp) {
17268
+ const message = `Skipping invalid openclaw turn at index ${i}.`;
17269
+ if (options.strict) throw new Error(message);
17270
+ warnings.push({ code: "replay.openclaw.turn.invalid", message, index: i });
17271
+ continue;
17272
+ }
17273
+ const sessionKeyRaw = row.sessionKey ?? row.session_key;
17274
+ const sessionKey = typeof sessionKeyRaw === "string" && sessionKeyRaw.trim().length > 0 ? sessionKeyRaw.trim() : defaultSessionKey;
17275
+ const externalIdRaw = row.turnId ?? row.turn_id ?? row.id;
17276
+ turns.push({
17277
+ source: "openclaw",
17278
+ sessionKey,
17279
+ role,
17280
+ content,
17281
+ timestamp,
17282
+ externalId: typeof externalIdRaw === "string" ? externalIdRaw : void 0
17283
+ });
17284
+ }
17285
+ return { turns, warnings };
17286
+ }
17287
+ };
17288
+
16669
17289
  // src/cli.ts
16670
17290
  function rankCandidateForKeep(a, b) {
16671
17291
  const aConfidence = typeof a.frontmatter.confidence === "number" ? a.frontmatter.confidence : 0;
@@ -16715,6 +17335,83 @@ function planExactDuplicateDeletions(memories) {
16715
17335
  function planAggressiveDuplicateDeletions(memories) {
16716
17336
  return buildDedupePlan(memories, (memory) => normalizeAggressiveBody(memory.content));
16717
17337
  }
17338
+ async function withTimeout(promise, timeoutMs, timeoutMessage) {
17339
+ let timer;
17340
+ try {
17341
+ return await Promise.race([
17342
+ promise,
17343
+ new Promise((_, reject) => {
17344
+ timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
17345
+ })
17346
+ ]);
17347
+ } finally {
17348
+ if (timer) clearTimeout(timer);
17349
+ }
17350
+ }
17351
+ async function runReplayCliCommand(orchestrator, options) {
17352
+ const extractionIdleTimeoutMs = Number.isFinite(options.extractionIdleTimeoutMs) ? Math.max(1e3, Math.floor(options.extractionIdleTimeoutMs)) : 15 * 6e4;
17353
+ const inputRaw = await readFile23(options.inputPath, "utf-8");
17354
+ const registry = buildReplayNormalizerRegistry([
17355
+ openclawReplayNormalizer,
17356
+ claudeReplayNormalizer,
17357
+ chatgptReplayNormalizer
17358
+ ]);
17359
+ const ingestBatchSize = clampBatchSize(options.batchSize);
17360
+ const turnsBySession = /* @__PURE__ */ new Map();
17361
+ const ingestSessionChunk = async (sessionTurns) => {
17362
+ const deadlineMs = Date.now() + extractionIdleTimeoutMs;
17363
+ await withTimeout(
17364
+ orchestrator.ingestReplayBatch(sessionTurns, { deadlineMs }),
17365
+ extractionIdleTimeoutMs,
17366
+ `replay extraction batch did not complete before timeout (${extractionIdleTimeoutMs}ms)`
17367
+ );
17368
+ };
17369
+ const summary = await runReplay(
17370
+ options.source,
17371
+ inputRaw,
17372
+ registry,
17373
+ {
17374
+ onBatch: async (batch) => {
17375
+ for (const turn of batch) {
17376
+ const key = normalizeReplaySessionKey(turn.sessionKey);
17377
+ const turns = turnsBySession.get(key) ?? [];
17378
+ turns.push(turn);
17379
+ turnsBySession.set(key, turns);
17380
+ while (turns.length >= ingestBatchSize) {
17381
+ const chunk = turns.splice(0, ingestBatchSize);
17382
+ await ingestSessionChunk(chunk);
17383
+ }
17384
+ }
17385
+ }
17386
+ },
17387
+ {
17388
+ from: options.from,
17389
+ to: options.to,
17390
+ dryRun: options.dryRun === true,
17391
+ startOffset: options.startOffset,
17392
+ maxTurns: options.maxTurns,
17393
+ batchSize: options.batchSize,
17394
+ defaultSessionKey: options.defaultSessionKey,
17395
+ strict: options.strict
17396
+ }
17397
+ );
17398
+ if (!summary.dryRun) {
17399
+ for (const turns of turnsBySession.values()) {
17400
+ if (turns.length === 0) continue;
17401
+ await ingestSessionChunk(turns);
17402
+ }
17403
+ if (options.runConsolidation === true) {
17404
+ const consolidationIdle = await orchestrator.waitForConsolidationIdle(extractionIdleTimeoutMs);
17405
+ if (!consolidationIdle) {
17406
+ throw new Error(
17407
+ `replay consolidation did not become idle before timeout (${extractionIdleTimeoutMs}ms)`
17408
+ );
17409
+ }
17410
+ await orchestrator.runConsolidationNow();
17411
+ }
17412
+ }
17413
+ return summary;
17414
+ }
16718
17415
  async function getPluginVersion() {
16719
17416
  try {
16720
17417
  const pkgPath = new URL("../package.json", import.meta.url);
@@ -16945,6 +17642,60 @@ function registerCli(api, orchestrator) {
16945
17642
  });
16946
17643
  console.log("OK");
16947
17644
  });
17645
+ cmd.command("replay").description("Import replay transcripts from external exports").option("--source <source>", "Replay source: openclaw|claude|chatgpt").option("--input <path>", "Path to replay export file").option("--from <iso>", "Inclusive lower bound timestamp (ISO UTC)").option("--to <iso>", "Inclusive upper bound timestamp (ISO UTC)").option("--dry-run", "Parse and validate only; do not enqueue extraction").option("--start-offset <n>", "Start replay at offset", "0").option("--max-turns <n>", "Maximum turns to process", "0").option("--batch-size <n>", "Replay ingestion batch size", "100").option("--default-session-key <key>", "Fallback session key when source session identifiers are missing").option("--strict", "Fail on invalid source rows").option("--run-consolidation", "Run consolidation after replay ingestion completes").option("--idle-timeout-ms <n>", "Extraction idle timeout per replay batch/final drain in milliseconds", "900000").action(async (...args) => {
17646
+ const options = args[0] ?? {};
17647
+ const sourceRaw = typeof options.source === "string" ? options.source.trim().toLowerCase() : "";
17648
+ const inputPath = typeof options.input === "string" ? options.input.trim() : "";
17649
+ if (!isReplaySource(sourceRaw)) {
17650
+ console.log("Missing or invalid --source. Use one of: openclaw, claude, chatgpt.");
17651
+ return;
17652
+ }
17653
+ if (inputPath.length === 0) {
17654
+ console.log("Missing --input. Example: openclaw engram replay --source openclaw --input /tmp/replay.jsonl");
17655
+ return;
17656
+ }
17657
+ const startOffset = parseInt(String(options.startOffset ?? "0"), 10);
17658
+ const maxTurnsRaw = parseInt(String(options.maxTurns ?? "0"), 10);
17659
+ const batchSize = parseInt(String(options.batchSize ?? "100"), 10);
17660
+ const idleTimeoutMs = parseInt(String(options.idleTimeoutMs ?? "900000"), 10);
17661
+ const summary = await runReplayCliCommand(orchestrator, {
17662
+ source: sourceRaw,
17663
+ inputPath,
17664
+ from: typeof options.from === "string" ? options.from : void 0,
17665
+ to: typeof options.to === "string" ? options.to : void 0,
17666
+ dryRun: options.dryRun === true,
17667
+ startOffset: Number.isFinite(startOffset) ? Math.max(0, startOffset) : 0,
17668
+ maxTurns: Number.isFinite(maxTurnsRaw) && maxTurnsRaw > 0 ? maxTurnsRaw : void 0,
17669
+ batchSize: Number.isFinite(batchSize) && batchSize > 0 ? batchSize : 100,
17670
+ defaultSessionKey: typeof options.defaultSessionKey === "string" && options.defaultSessionKey.trim().length > 0 ? options.defaultSessionKey.trim() : void 0,
17671
+ strict: options.strict === true,
17672
+ runConsolidation: options.runConsolidation === true,
17673
+ extractionIdleTimeoutMs: Number.isFinite(idleTimeoutMs) && idleTimeoutMs > 0 ? idleTimeoutMs : 9e5
17674
+ });
17675
+ console.log(`Replay source: ${summary.source}`);
17676
+ console.log(`Parsed turns: ${summary.parsedTurns}`);
17677
+ console.log(`Valid turns: ${summary.validTurns}`);
17678
+ console.log(`Invalid turns: ${summary.invalidTurns}`);
17679
+ console.log(`Filtered by date: ${summary.filteredByDate}`);
17680
+ console.log(`Skipped by offset: ${summary.skippedByOffset}`);
17681
+ console.log(`Processed turns: ${summary.processedTurns}`);
17682
+ console.log(`Batches: ${summary.batchCount}`);
17683
+ console.log(`Dry run: ${summary.dryRun ? "yes" : "no"}`);
17684
+ console.log(`Next offset: ${summary.nextOffset}`);
17685
+ if (summary.firstTimestamp) console.log(`First timestamp: ${summary.firstTimestamp}`);
17686
+ if (summary.lastTimestamp) console.log(`Last timestamp: ${summary.lastTimestamp}`);
17687
+ if (summary.warnings.length > 0) {
17688
+ console.log(`Warnings (${summary.warnings.length}):`);
17689
+ for (const warning of summary.warnings.slice(0, 20)) {
17690
+ const idx = typeof warning.index === "number" ? ` @${warning.index}` : "";
17691
+ console.log(` - ${warning.code}${idx}: ${warning.message}`);
17692
+ }
17693
+ if (summary.warnings.length > 20) {
17694
+ console.log(` ... and ${summary.warnings.length - 20} more`);
17695
+ }
17696
+ }
17697
+ console.log("OK");
17698
+ });
16948
17699
  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) => {
16949
17700
  const options = args[0] ?? {};
16950
17701
  const dryRun = options.dryRun === true;