From eea83a362d15d12cb93f16a7ee1ee694dd0aa09d Mon Sep 17 00:00:00 2001 From: James Yang Date: Thu, 13 Aug 2026 11:51:35 -0400 Subject: [PATCH 1/2] fix(client): stamp captured_at on every addMemory write Extraction was inventing calendar dates because add() metadata had no capture-time anchor. Always set ISO-8601 captured_at (honoring explicit or legacy timestamp) so tool, slash, and capture paths share one stamp. --- client.ts | 13 +++---- hooks/capture.ts | 2 +- lib/add-metadata.test.ts | 81 ++++++++++++++++++++++++++++++++++++++++ lib/add-metadata.ts | 36 ++++++++++++++++++ package.json | 2 + tsconfig.json | 2 +- 6 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 lib/add-metadata.test.ts create mode 100644 lib/add-metadata.ts diff --git a/client.ts b/client.ts index 47d32d1..965e748 100644 --- a/client.ts +++ b/client.ts @@ -1,4 +1,5 @@ import Supermemory from "supermemory" +import { buildAddMemoryMetadata } from "./lib/add-metadata.ts" import { sanitizeContent, validateApiKeyFormat, @@ -72,14 +73,10 @@ export class SupermemoryClient { const cleaned = sanitizeContent(content) const tag = containerTag ?? this.containerTag - // Always stamp `sm_source` so mono's `document.source` column attributes - // these writes to the OpenClaw plugin. Existing callers can still pass - // extra metadata (e.g. `source: "openclaw_tool"`) and it is preserved - // underneath the canonical `sm_source` key. - const mergedMetadata: Record = { - sm_source: "openclaw", - ...(metadata ?? {}), - } + // Always stamp `sm_source` + `captured_at` so mono can attribute the write + // and extraction has a real capture-time anchor (issue #61). Callers may + // still pass extra keys; an explicit `captured_at` / legacy `timestamp` wins. + const mergedMetadata = buildAddMemoryMetadata(metadata) log.debugRequest("add", { contentLength: cleaned.length, diff --git a/hooks/capture.ts b/hooks/capture.ts index d89d949..0fa2f9b 100644 --- a/hooks/capture.ts +++ b/hooks/capture.ts @@ -115,7 +115,7 @@ export function buildCaptureHandler( try { await client.addMemory( content, - { source: "openclaw", timestamp: new Date().toISOString() }, + { source: "openclaw" }, customId, undefined, cfg.entityContext, diff --git a/lib/add-metadata.test.ts b/lib/add-metadata.test.ts new file mode 100644 index 0000000..80fb043 --- /dev/null +++ b/lib/add-metadata.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test" +import { buildAddMemoryMetadata } from "./add-metadata.ts" + +const FIXED = new Date("2026-08-13T15:30:00.000Z") + +describe("buildAddMemoryMetadata", () => { + test("stamps sm_source and captured_at when metadata is omitted", () => { + expect(buildAddMemoryMetadata(undefined, () => FIXED)).toEqual({ + sm_source: "openclaw", + captured_at: "2026-08-13T15:30:00.000Z", + }) + }) + + test("preserves caller fields under sm_source and captured_at", () => { + expect( + buildAddMemoryMetadata( + { type: "preference", source: "openclaw_command" }, + () => FIXED, + ), + ).toEqual({ + sm_source: "openclaw", + type: "preference", + source: "openclaw_command", + captured_at: "2026-08-13T15:30:00.000Z", + }) + }) + + test("keeps an explicit captured_at", () => { + expect( + buildAddMemoryMetadata( + { captured_at: "2026-07-01T00:00:00.000Z" }, + () => FIXED, + ), + ).toEqual({ + sm_source: "openclaw", + captured_at: "2026-07-01T00:00:00.000Z", + }) + }) + + test("promotes legacy timestamp when captured_at is missing", () => { + expect( + buildAddMemoryMetadata( + { timestamp: "2026-08-09T12:00:00.000Z" }, + () => FIXED, + ), + ).toEqual({ + sm_source: "openclaw", + timestamp: "2026-08-09T12:00:00.000Z", + captured_at: "2026-08-09T12:00:00.000Z", + }) + }) + + test("ignores blank captured_at / timestamp and falls back to now", () => { + expect( + buildAddMemoryMetadata( + { captured_at: " ", timestamp: "" }, + () => FIXED, + ), + ).toEqual({ + sm_source: "openclaw", + captured_at: "2026-08-13T15:30:00.000Z", + timestamp: "", + }) + }) + + test("prefers captured_at over timestamp", () => { + expect( + buildAddMemoryMetadata( + { + captured_at: "2026-01-01T00:00:00.000Z", + timestamp: "2025-01-01T00:00:00.000Z", + }, + () => FIXED, + ), + ).toEqual({ + sm_source: "openclaw", + captured_at: "2026-01-01T00:00:00.000Z", + timestamp: "2025-01-01T00:00:00.000Z", + }) + }) +}) diff --git a/lib/add-metadata.ts b/lib/add-metadata.ts new file mode 100644 index 0000000..0ed20e0 --- /dev/null +++ b/lib/add-metadata.ts @@ -0,0 +1,36 @@ +export type MemoryMetadata = Record + +/** + * Merge caller metadata with OpenClaw defaults for `add()`. + * + * Always sets `sm_source` and ensures an ISO-8601 `captured_at` so extraction + * can anchor invented calendar dates to a real capture time (see #61). + * + * Preference order for the stamp: explicit `captured_at` → non-empty `timestamp` + * (legacy callers) → `now()`. + */ +export function buildAddMemoryMetadata( + metadata?: MemoryMetadata, + now: () => Date = () => new Date(), +): MemoryMetadata { + const merged: MemoryMetadata = { + sm_source: "openclaw", + ...(metadata ?? {}), + } + + const fromCapturedAt = readIsoField(merged.captured_at) + if (fromCapturedAt) { + merged.captured_at = fromCapturedAt + return merged + } + + const fromTimestamp = readIsoField(merged.timestamp) + merged.captured_at = fromTimestamp ?? now().toISOString() + return merged +} + +function readIsoField(value: unknown): string | null { + if (typeof value !== "string") return null + const trimmed = value.trim() + return trimmed.length > 0 ? trimmed : null +} diff --git a/package.json b/package.json index 4cd2553..fd9dfd6 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "runtime.ts", "types", "lib/version-check.ts", + "lib/add-metadata.ts", "lib/validate.d.ts", "lib/validate.js", "dist" @@ -32,6 +33,7 @@ "check-types": "tsc --noEmit", "lint": "bunx @biomejs/biome ci .", "lint:fix": "bunx @biomejs/biome check --write .", + "test": "bun test", "build:lib": "node -e \"const fs=require('node:fs'); fs.accessSync('lib/validate.js'); fs.accessSync('lib/validate.d.ts')\"", "prepack": "npm run build" }, diff --git a/tsconfig.json b/tsconfig.json index 852bfd0..cb687ac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "lib/*.ts", "lib/*.d.ts" ], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "**/*.test.ts"] } From e47862411e2c455f25b7599e0cecb16813d4ef19 Mon Sep 17 00:00:00 2001 From: James Yang Date: Thu, 13 Aug 2026 12:51:06 -0400 Subject: [PATCH 2/2] refactor: simplify captured_at stamping Keep the fix in a small root metadata helper (avoids gitignored lib/*.ts), drop unused timestamp promotion, and slim the unit tests. --- client.ts | 5 +-- lib/add-metadata.test.ts | 81 ---------------------------------------- lib/add-metadata.ts | 36 ------------------ metadata.test.ts | 46 +++++++++++++++++++++++ metadata.ts | 21 +++++++++++ package.json | 2 +- 6 files changed, 69 insertions(+), 122 deletions(-) delete mode 100644 lib/add-metadata.test.ts delete mode 100644 lib/add-metadata.ts create mode 100644 metadata.test.ts create mode 100644 metadata.ts diff --git a/client.ts b/client.ts index 965e748..e900175 100644 --- a/client.ts +++ b/client.ts @@ -1,5 +1,4 @@ import Supermemory from "supermemory" -import { buildAddMemoryMetadata } from "./lib/add-metadata.ts" import { sanitizeContent, validateApiKeyFormat, @@ -7,6 +6,7 @@ import { } from "./lib/validate.js" import { log } from "./logger.ts" import { clampEntityContext } from "./memory.ts" +import { buildAddMemoryMetadata } from "./metadata.ts" export type SearchResult = { id: string @@ -73,9 +73,6 @@ export class SupermemoryClient { const cleaned = sanitizeContent(content) const tag = containerTag ?? this.containerTag - // Always stamp `sm_source` + `captured_at` so mono can attribute the write - // and extraction has a real capture-time anchor (issue #61). Callers may - // still pass extra keys; an explicit `captured_at` / legacy `timestamp` wins. const mergedMetadata = buildAddMemoryMetadata(metadata) log.debugRequest("add", { diff --git a/lib/add-metadata.test.ts b/lib/add-metadata.test.ts deleted file mode 100644 index 80fb043..0000000 --- a/lib/add-metadata.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { buildAddMemoryMetadata } from "./add-metadata.ts" - -const FIXED = new Date("2026-08-13T15:30:00.000Z") - -describe("buildAddMemoryMetadata", () => { - test("stamps sm_source and captured_at when metadata is omitted", () => { - expect(buildAddMemoryMetadata(undefined, () => FIXED)).toEqual({ - sm_source: "openclaw", - captured_at: "2026-08-13T15:30:00.000Z", - }) - }) - - test("preserves caller fields under sm_source and captured_at", () => { - expect( - buildAddMemoryMetadata( - { type: "preference", source: "openclaw_command" }, - () => FIXED, - ), - ).toEqual({ - sm_source: "openclaw", - type: "preference", - source: "openclaw_command", - captured_at: "2026-08-13T15:30:00.000Z", - }) - }) - - test("keeps an explicit captured_at", () => { - expect( - buildAddMemoryMetadata( - { captured_at: "2026-07-01T00:00:00.000Z" }, - () => FIXED, - ), - ).toEqual({ - sm_source: "openclaw", - captured_at: "2026-07-01T00:00:00.000Z", - }) - }) - - test("promotes legacy timestamp when captured_at is missing", () => { - expect( - buildAddMemoryMetadata( - { timestamp: "2026-08-09T12:00:00.000Z" }, - () => FIXED, - ), - ).toEqual({ - sm_source: "openclaw", - timestamp: "2026-08-09T12:00:00.000Z", - captured_at: "2026-08-09T12:00:00.000Z", - }) - }) - - test("ignores blank captured_at / timestamp and falls back to now", () => { - expect( - buildAddMemoryMetadata( - { captured_at: " ", timestamp: "" }, - () => FIXED, - ), - ).toEqual({ - sm_source: "openclaw", - captured_at: "2026-08-13T15:30:00.000Z", - timestamp: "", - }) - }) - - test("prefers captured_at over timestamp", () => { - expect( - buildAddMemoryMetadata( - { - captured_at: "2026-01-01T00:00:00.000Z", - timestamp: "2025-01-01T00:00:00.000Z", - }, - () => FIXED, - ), - ).toEqual({ - sm_source: "openclaw", - captured_at: "2026-01-01T00:00:00.000Z", - timestamp: "2025-01-01T00:00:00.000Z", - }) - }) -}) diff --git a/lib/add-metadata.ts b/lib/add-metadata.ts deleted file mode 100644 index 0ed20e0..0000000 --- a/lib/add-metadata.ts +++ /dev/null @@ -1,36 +0,0 @@ -export type MemoryMetadata = Record - -/** - * Merge caller metadata with OpenClaw defaults for `add()`. - * - * Always sets `sm_source` and ensures an ISO-8601 `captured_at` so extraction - * can anchor invented calendar dates to a real capture time (see #61). - * - * Preference order for the stamp: explicit `captured_at` → non-empty `timestamp` - * (legacy callers) → `now()`. - */ -export function buildAddMemoryMetadata( - metadata?: MemoryMetadata, - now: () => Date = () => new Date(), -): MemoryMetadata { - const merged: MemoryMetadata = { - sm_source: "openclaw", - ...(metadata ?? {}), - } - - const fromCapturedAt = readIsoField(merged.captured_at) - if (fromCapturedAt) { - merged.captured_at = fromCapturedAt - return merged - } - - const fromTimestamp = readIsoField(merged.timestamp) - merged.captured_at = fromTimestamp ?? now().toISOString() - return merged -} - -function readIsoField(value: unknown): string | null { - if (typeof value !== "string") return null - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : null -} diff --git a/metadata.test.ts b/metadata.test.ts new file mode 100644 index 0000000..4889678 --- /dev/null +++ b/metadata.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { buildAddMemoryMetadata } from "./metadata.ts" + +const FIXED = new Date("2026-08-13T15:30:00.000Z") + +describe("buildAddMemoryMetadata", () => { + test("stamps sm_source and captured_at when metadata is omitted", () => { + expect(buildAddMemoryMetadata(undefined, () => FIXED)).toEqual({ + sm_source: "openclaw", + captured_at: "2026-08-13T15:30:00.000Z", + }) + }) + + test("preserves caller fields and fills missing captured_at", () => { + expect( + buildAddMemoryMetadata( + { type: "preference", source: "openclaw_command" }, + () => FIXED, + ), + ).toEqual({ + sm_source: "openclaw", + type: "preference", + source: "openclaw_command", + captured_at: "2026-08-13T15:30:00.000Z", + }) + }) + + test("keeps an explicit captured_at", () => { + expect( + buildAddMemoryMetadata( + { captured_at: "2026-07-01T00:00:00.000Z" }, + () => FIXED, + ), + ).toEqual({ + sm_source: "openclaw", + captured_at: "2026-07-01T00:00:00.000Z", + }) + }) + + test("replaces blank captured_at", () => { + expect(buildAddMemoryMetadata({ captured_at: " " }, () => FIXED)).toEqual({ + sm_source: "openclaw", + captured_at: "2026-08-13T15:30:00.000Z", + }) + }) +}) diff --git a/metadata.ts b/metadata.ts new file mode 100644 index 0000000..aeae7de --- /dev/null +++ b/metadata.ts @@ -0,0 +1,21 @@ +export type MemoryMetadata = Record + +/** Merge caller metadata with OpenClaw defaults for `add()`. */ +export function buildAddMemoryMetadata( + metadata?: MemoryMetadata, + now: () => Date = () => new Date(), +): MemoryMetadata { + const merged: MemoryMetadata = { + sm_source: "openclaw", + ...(metadata ?? {}), + } + + // Give extraction a real capture-time anchor (issue #61). Callers can still + // pass an explicit `captured_at` (e.g. when replaying older events). + const existing = merged.captured_at + if (typeof existing !== "string" || !existing.trim()) { + merged.captured_at = now().toISOString() + } + + return merged +} diff --git a/package.json b/package.json index fd9dfd6..97c2275 100644 --- a/package.json +++ b/package.json @@ -19,10 +19,10 @@ "config.ts", "logger.ts", "memory.ts", + "metadata.ts", "runtime.ts", "types", "lib/version-check.ts", - "lib/add-metadata.ts", "lib/validate.d.ts", "lib/validate.js", "dist"