Skip to content

feat(openclaw): add recall trace core#2567

Open
Mijamind719 wants to merge 1 commit into
codex/openclaw-tool-governancefrom
codex/openclaw-recall-trace-core
Open

feat(openclaw): add recall trace core#2567
Mijamind719 wants to merge 1 commit into
codex/openclaw-tool-governancefrom
codex/openclaw-recall-trace-core

Conversation

@Mijamind719

Copy link
Copy Markdown
Collaborator

Summary

  • add recall trace types, search-plan helpers, memory store, JSONL persistence, and recorder
  • add trace configuration defaults and schema entries without wiring runtime behavior yet
  • cover memory/persistent store behavior, redaction, corruption handling, and config parsing

Testing

  • cd examples/openclaw-plugin && npm test
  • cd examples/openclaw-plugin && npm run typecheck
  • cd examples/openclaw-plugin && npm run build

Stacked PR plan: this is PR 2/5, based on #2566.

@github-actions

Copy link
Copy Markdown

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

🎫 Ticket compliance analysis 🔶

2566 - Partially compliant

Compliant requirements:

This PR is the second in a stacked series and focuses on recall trace core functionality, building on the tool allowlist/blocklist changes from the first PR.

Non-compliant requirements:

N/A (this PR is focused on recall trace core, not the original ticket requirements)

Requires further human verification:

N/A

⏱️ Estimated effort to review: 3 🔵🔵🔵⚪⚪
🏅 Score: 85
🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ Recommended focus areas for review

Unbounded warning accumulation in RecallTraceJsonlStore

The warnings array in RecallTraceJsonlStore accumulates all warnings indefinitely and is never cleared. This can lead to unbounded memory growth over time, and repeated flush/query calls will return duplicate warnings.

private readonly warnings: string[] = [];

constructor(options: { dir: string; includeRawUserPreview?: boolean; retentionDays?: number; queryMaxDays?: number }) {
  this.dir = options.dir;
  this.includeRawUserPreview = options.includeRawUserPreview === true;
  this.retentionDays = Math.max(1, Math.floor(options.retentionDays ?? 14));
  this.queryMaxDays = Math.max(1, Math.floor(options.queryMaxDays ?? 14));
}

private entryForPersistence(entry: RecallTraceEntry): RecallTraceEntry {
  if (this.includeRawUserPreview || entry.trigger.rawUserTextPreview === undefined) {
    return entry;
  }
  return {
    ...entry,
    trigger: {
      ...entry.trigger,
      rawUserTextPreview: undefined,
    },
  };
}

append(entry: RecallTraceEntry): Promise<void> {
  const write = (async () => {
    await mkdir(this.dir, { recursive: true });
    await this.pruneExpiredFiles(entry.ts);
    await appendFile(
      join(this.dir, jsonlFileNameForTimestamp(entry.ts)),
      `${JSON.stringify(this.entryForPersistence(entry))}\n`,
      "utf8",
    );
  })().catch((err: unknown) => {
    this.warnings.push(`Failed to append recall trace JSONL: ${err instanceof Error ? err.message : String(err)}`);
  });

  this.pending.push(write);
  return write;
}

private async pruneExpiredFiles(nowTs: number): Promise<void> {
  const cutoff = startOfUtcDay(nowTs - this.retentionDays * 86_400_000);
  let files: string[];
  try {
    files = await readdir(this.dir);
  } catch {
    return;
  }
  await Promise.all(files
    .filter((name) => name.endsWith(".jsonl"))
    .filter((name) => {
      const ts = timestampFromJsonlFileName(name);
      return ts !== undefined && ts < cutoff;
    })
    .map(async (name) => {
      try {
        await unlink(join(this.dir, name));
      } catch (err: unknown) {
        this.warnings.push(`Failed to prune recall trace file ${name}: ${err instanceof Error ? err.message : String(err)}`);
      }
    }));
}

async flush(): Promise<RecallTraceFlushResult> {
  const pending = this.pending.splice(0);
  await Promise.all(pending);
  return { warnings: [...this.warnings] };
}
Pending promises array can grow indefinitely without flush()

The pending array in RecallTraceJsonlStore retains all past append promises unless flush() is called. If flush() is not called regularly, this can lead to unnecessary memory retention.

private readonly pending: Promise<void>[] = [];
private readonly warnings: string[] = [];

constructor(options: { dir: string; includeRawUserPreview?: boolean; retentionDays?: number; queryMaxDays?: number }) {
  this.dir = options.dir;
  this.includeRawUserPreview = options.includeRawUserPreview === true;
  this.retentionDays = Math.max(1, Math.floor(options.retentionDays ?? 14));
  this.queryMaxDays = Math.max(1, Math.floor(options.queryMaxDays ?? 14));
}

private entryForPersistence(entry: RecallTraceEntry): RecallTraceEntry {
  if (this.includeRawUserPreview || entry.trigger.rawUserTextPreview === undefined) {
    return entry;
  }
  return {
    ...entry,
    trigger: {
      ...entry.trigger,
      rawUserTextPreview: undefined,
    },
  };
}

append(entry: RecallTraceEntry): Promise<void> {
  const write = (async () => {
    await mkdir(this.dir, { recursive: true });
    await this.pruneExpiredFiles(entry.ts);
    await appendFile(
      join(this.dir, jsonlFileNameForTimestamp(entry.ts)),
      `${JSON.stringify(this.entryForPersistence(entry))}\n`,
      "utf8",
    );
  })().catch((err: unknown) => {
    this.warnings.push(`Failed to append recall trace JSONL: ${err instanceof Error ? err.message : String(err)}`);
  });

  this.pending.push(write);
  return write;
}

@github-actions

Copy link
Copy Markdown

PR Code Suggestions ✨

No code suggestions found for the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant