Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ docker exec -it hermes-memory hermes
| `recall.timeoutMs` | `5000` | Recall timeout; on timeout, skip injection without blocking the conversation |
| `extraction.enableDedup` | `true` | L1 vector dedup / conflict detection |
| `capture.excludeAgents` | `[]` | Glob patterns to exclude specific agents (e.g. `bench-judge-*`) |
| `capture.timezoneOffsetMinutes` | `480` | Timezone offset used for captured timestamps and extraction prompts; `480` = UTC+08:00 |
| `capture.l0l1RetentionDays` | `0` | Local retention days for L0 / L1 files; `0` = never clean up |
| `offload.mildOffloadRatio` | `0.5` | Mild compression trigger ratio (of context window) |
| `offload.aggressiveCompressRatio` | `0.85` | Aggressive compression trigger ratio |
Expand Down
1 change: 1 addition & 0 deletions README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ docker exec -it hermes-memory hermes
| `recall.timeoutMs` | `5000` | 召回超时阈值,超时跳过注入不阻塞对话 |
| `extraction.enableDedup` | `true` | L1 向量去重 / 冲突检测 |
| `capture.excludeAgents` | `[]` | Glob 模式排除特定 Agent(如 `bench-judge-*`) |
| `capture.timezoneOffsetMinutes` | `480` | 捕获时间戳和提取提示词使用的时区偏移分钟数;`480` = UTC+08:00 |
| `capture.l0l1RetentionDays` | `0` | L0/L1 本地文件保留天数,`0` = 永不清理 |
| `offload.mildOffloadRatio` | `0.5` | 温和压缩触发比例(占 context window) |
| `offload.aggressiveCompressRatio` | `0.85` | 激进压缩触发比例 |
Expand Down
1 change: 1 addition & 0 deletions openclaw.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"properties": {
"enabled": { "type": "boolean", "default": true, "description": "是否启用自动对话捕获" },
"excludeAgents": { "type": "array", "items": { "type": "string" }, "default": [], "description": "排除的 Agent glob 模式列表(如 bench-judge-*),匹配的 agent 不会被捕获、召回或参与 pipeline 调度" },
"timezoneOffsetMinutes": { "type": "number", "default": 480, "description": "捕获与提取提示词中时间戳使用的时区偏移分钟数,范围 -840 到 840;默认 480 表示 UTC+08:00" },
"l0l1RetentionDays": { "type": "number", "default": 0, "description": "L0/L1 本地文件保留天数(单位天)。0=不清理;非0时默认必须>=3。若为1或2,需显式开启 allowAggressiveCleanup" },
"allowAggressiveCleanup": { "type": "boolean", "default": false, "description": "是否允许高风险清理配置(l0l1RetentionDays=1或2)" },
"cleanTime": { "type": "string", "default": "03:00", "description": "每日清理执行时间(HH:mm)" }
Expand Down
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,12 @@
"files": [
"dist/",
"bin/",
"index.ts",
"scripts/migrate-sqlite-to-tcvdb/dist/",
"scripts/export-tencent-vdb/dist/",
"scripts/read-local-memory/dist/",
"scripts/memory-tencentdb-ctl.sh",
"scripts/install_hermes_memory_tencentdb.sh",
"scripts/README.memory-tencentdb-ctl.md",
"src/",
"scripts/openclaw-after-tool-call-messages.patch.sh",
"scripts/setup-offload.sh",
"hermes-plugin/",
Expand Down
31 changes: 31 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";

import { parseConfig } from "./config.js";

describe("parseConfig capture timezone", () => {
it("defaults capture timestamps to UTC+08:00", () => {
const cfg = parseConfig({});

expect(cfg.capture.timezoneOffsetMinutes).toBe(480);
});

it("accepts a custom capture timezone offset in minutes", () => {
const cfg = parseConfig({
capture: {
timezoneOffsetMinutes: -300,
},
});

expect(cfg.capture.timezoneOffsetMinutes).toBe(-300);
});

it("falls back to UTC+08:00 when the timezone offset is outside the supported range", () => {
const cfg = parseConfig({
capture: {
timezoneOffsetMinutes: 15 * 60,
},
});

expect(cfg.capture.timezoneOffsetMinutes).toBe(480);
});
});
5 changes: 5 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* Minimal config (zero config): {} — all fields have sensible defaults.
*/

import { normalizeTimezoneOffsetMinutes } from "./utils/timezone.js";

// ============================
// Type definitions
// ============================
Expand All @@ -17,6 +19,8 @@ export interface CaptureConfig {
enabled: boolean;
/** Glob patterns to exclude agents (e.g. "bench-judge-*"); matched agents are fully ignored */
excludeAgents: string[];
/** Timezone offset, in minutes, used when formatting captured timestamps (default: 480, UTC+08:00) */
timezoneOffsetMinutes: number;
/**
* L0/L1 local file retention days used as TTL switch.
* 0 means cleanup disabled.(default: 0)
Expand Down Expand Up @@ -458,6 +462,7 @@ export function parseConfig(raw: Record<string, unknown> | undefined): MemoryTda
capture: {
enabled: bool(captureGroup, "enabled") ?? true,
excludeAgents: strArray(captureGroup, "excludeAgents") ?? [],
timezoneOffsetMinutes: normalizeTimezoneOffsetMinutes(num(captureGroup, "timezoneOffsetMinutes")),
l0l1RetentionDays: retentionDays ?? 0,
allowAggressiveCleanup,
},
Expand Down
42 changes: 42 additions & 0 deletions src/core/conversation/l0-recorder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";

import { recordConversation } from "./l0-recorder.js";

describe("recordConversation timezone", () => {
it("writes recordedAt and shard filename in the configured timezone", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T02:30:00.000Z"));
const baseDir = await mkdtemp(path.join(tmpdir(), "tdai-l0-tz-"));

try {
await recordConversation({
sessionKey: "session-a",
sessionId: "sid-a",
rawMessages: [
{
id: "msg-1",
role: "user",
content: "User wants local timezone timestamps in recorded memory data.",
timestamp: Date.parse("2026-01-01T02:29:00.000Z"),
},
],
baseDir,
timezoneOffsetMinutes: -300,
});

const raw = await readFile(
path.join(baseDir, "conversations", "2025-12-31.jsonl"),
"utf-8",
);
const record = JSON.parse(raw.trim()) as { recordedAt: string };

expect(record.recordedAt).toBe("2025-12-31T21:30:00.000-05:00");
} finally {
vi.useRealTimers();
await rm(baseDir, { recursive: true, force: true });
}
});
});
22 changes: 8 additions & 14 deletions src/core/conversation/l0-recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import crypto from "node:crypto";
import { sanitizeText, stripCodeBlocks, shouldCaptureL0 } from "../../utils/sanitize.js";
import { formatTimezoneDate, formatTimezoneISO } from "../../utils/timezone.js";

// ============================
// Types
Expand Down Expand Up @@ -101,6 +102,8 @@ export async function recordConversation(params: {
originalUserText?: string;
/** Epoch ms cursor: only process messages with timestamp strictly greater than this. */
afterTimestamp?: number;
/** Timezone offset, in minutes, used for recordedAt and daily shard filenames. */
timezoneOffsetMinutes?: number;
/**
* Number of messages in the session at before_prompt_build time.
* Used to locate the exact user message that originalUserText corresponds to:
Expand All @@ -109,7 +112,7 @@ export async function recordConversation(params: {
*/
originalUserMessageCount?: number;
}): Promise<ConversationMessage[]> {
const { sessionKey, sessionId, rawMessages, baseDir, logger, originalUserText, afterTimestamp, originalUserMessageCount } = params;
const { sessionKey, sessionId, rawMessages, baseDir, logger, originalUserText, afterTimestamp, originalUserMessageCount, timezoneOffsetMinutes } = params;

// Step 1: Position slice + extract user/assistant messages.
//
Expand Down Expand Up @@ -268,7 +271,8 @@ export async function recordConversation(params: {
}

// Step 4: Write to JSONL file — one message per line (flat format)
const now = new Date().toISOString();
const nowDate = new Date();
const now = formatTimezoneISO(nowDate, timezoneOffsetMinutes);
const lines: string[] = [];
for (const msg of filtered) {
const record: L0MessageRecord = {
Expand All @@ -283,7 +287,7 @@ export async function recordConversation(params: {
lines.push(JSON.stringify(record));
}

const shardDate = formatLocalDate(new Date());
const shardDate = formatTimezoneDate(nowDate, timezoneOffsetMinutes);
const outDir = path.join(baseDir, "conversations");
const outPath = path.join(outDir, `${shardDate}.jsonl`);

Expand Down Expand Up @@ -371,7 +375,7 @@ export async function readConversationRecords(
records.push({
sessionKey: (parsed.sessionKey as string) || sessionKey,
sessionId: (parsed.sessionId as string) || "",
recordedAt: (parsed.recordedAt as string) || new Date().toISOString(),
recordedAt: (parsed.recordedAt as string) || formatTimezoneISO(new Date()),
messageCount: 1,
messages: [msg],
});
Expand Down Expand Up @@ -570,13 +574,3 @@ function extractUserAssistantMessages(messages: unknown[]): ConversationMessage[

return result;
}

/**
* Format local date as YYYY-MM-DD.
*/
function formatLocalDate(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
4 changes: 3 additions & 1 deletion src/core/hooks/auto-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { recordConversation } from "../conversation/l0-recorder.js";
import type { ConversationMessage } from "../conversation/l0-recorder.js";
import type { IMemoryStore, L0Record } from "../store/types.js";
import type { EmbeddingService } from "../store/embedding.js";
import { formatTimezoneISO } from "../../utils/timezone.js";

const TAG = "[memory-tdai] [capture]";

Expand Down Expand Up @@ -130,6 +131,7 @@ export async function performAutoCapture(params: {
originalUserText,
afterTimestamp,
originalUserMessageCount,
timezoneOffsetMinutes: cfg.capture.timezoneOffsetMinutes,
});

if (filteredMessages.length === 0) {
Expand Down Expand Up @@ -172,7 +174,7 @@ export async function performAutoCapture(params: {
const supportsBgEmbed = vectorStore?.supportsDeferredEmbedding === true;

if (filteredMessages.length > 0 && vectorStore) {
const now = new Date().toISOString();
const now = formatTimezoneISO(new Date(), cfg.capture.timezoneOffsetMinutes);
const bgRecords: Array<{ recordId: string; content: string }> = [];
logger?.debug?.(
`${TAG} [L0-vec-index] START indexing ${filteredMessages.length} message(s) for session ${sessionKey} ` +
Expand Down
31 changes: 31 additions & 0 deletions src/core/prompts/l1-extraction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";

import { formatExtractionPrompt } from "./l1-extraction.js";

describe("formatExtractionPrompt", () => {
it("formats message timestamps with the configured timezone offset", () => {
const prompt = formatExtractionPrompt({
timezoneOffsetMinutes: -300,
backgroundMessages: [
{
id: "bg-1",
role: "assistant",
content: "Background message",
timestamp: Date.parse("2026-01-01T02:00:00.000Z"),
},
],
newMessages: [
{
id: "new-1",
role: "user",
content: "User prefers local timestamps in memory prompts.",
timestamp: Date.parse("2026-01-01T02:30:00.000Z"),
},
],
});

expect(prompt).toContain("[2025-12-31T21:00:00.000-05:00]");
expect(prompt).toContain("[2025-12-31T21:30:00.000-05:00]");
expect(prompt).not.toContain("2026-01-01T02:30:00.000Z");
});
});
10 changes: 7 additions & 3 deletions src/core/prompts/l1-extraction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type { ConversationMessage } from "../conversation/l0-recorder.js";
import { formatTimezoneISO } from "../../utils/timezone.js";

// ============================
// System Prompt
Expand Down Expand Up @@ -112,17 +113,20 @@ export function formatExtractionPrompt(params: {
newMessages: ConversationMessage[];
backgroundMessages?: ConversationMessage[];
previousSceneName?: string;
timezoneOffsetMinutes?: number;
}): string {
const { newMessages, backgroundMessages = [], previousSceneName = "无" } = params;
const { newMessages, backgroundMessages = [], previousSceneName = "无", timezoneOffsetMinutes } = params;
const formatMessageTimestamp = (timestamp: number) =>
formatTimezoneISO(new Date(timestamp), timezoneOffsetMinutes);

const bgText = backgroundMessages.length > 0
? backgroundMessages
.map((m) => `[${m.id}] [${m.role}] [${new Date(m.timestamp).toISOString()}]: ${m.content}`)
.map((m) => `[${m.id}] [${m.role}] [${formatMessageTimestamp(m.timestamp)}]: ${m.content}`)
.join("\n\n")
: "无";

const newText = newMessages
.map((m) => `[${m.id}] [${m.role}] [${new Date(m.timestamp).toISOString()}]: ${m.content}`)
.map((m) => `[${m.id}] [${m.role}] [${formatMessageTimestamp(m.timestamp)}]: ${m.content}`)
.join("\n\n");

return `【上一个情境】:${previousSceneName}
Expand Down
7 changes: 6 additions & 1 deletion src/core/record/l1-extractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export async function extractL1Memories(params: {
conflictRecallTopK?: number;
/** Override embedding timeout for capture-path calls (milliseconds) */
embeddingTimeoutMs?: number;
/** Timezone offset, in minutes, used when formatting prompt timestamps. */
timezoneOffsetMinutes?: number;
/**
* Host-neutral LLM runner. When provided, used instead of creating
* a CleanContextRunner (decouples from OpenClaw runtime).
Expand Down Expand Up @@ -165,6 +167,7 @@ export async function extractL1Memories(params: {
config,
logger,
model: options.model,
timezoneOffsetMinutes: options.timezoneOffsetMinutes,
llmRunner: options.llmRunner,
});
logger?.debug?.(`${TAG} LLM detected ${scenes.length} scene(s)`);
Expand Down Expand Up @@ -307,15 +310,17 @@ async function callLlmExtraction(params: {
config: unknown;
logger?: Logger;
model?: string;
timezoneOffsetMinutes?: number;
/** Host-neutral LLM runner — when provided, used instead of CleanContextRunner. */
llmRunner?: LLMRunner;
}): Promise<SceneSegment[]> {
const { newMessages, backgroundMessages, previousSceneName, config, logger, model, llmRunner } = params;
const { newMessages, backgroundMessages, previousSceneName, config, logger, model, timezoneOffsetMinutes, llmRunner } = params;

const userPrompt = formatExtractionPrompt({
newMessages,
backgroundMessages,
previousSceneName,
timezoneOffsetMinutes,
});

// [l1-debug] ENTRY — what are we about to ask the LLM to extract?
Expand Down
48 changes: 48 additions & 0 deletions src/core/scene/scene-extractor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";

import type { LLMRunParams, LLMRunner } from "../types.js";
import { SceneExtractor } from "./scene-extractor.js";

describe("SceneExtractor timezone", () => {
it("injects the current timestamp with the configured timezone offset", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T02:30:00.000Z"));
const baseDir = await mkdtemp(path.join(tmpdir(), "tdai-scene-tz-"));
let capturedPrompt = "";

const llmRunner: LLMRunner = {
async run(params: LLMRunParams): Promise<string> {
capturedPrompt = params.prompt;
return "";
},
};

try {
const extractor = new SceneExtractor({
dataDir: baseDir,
config: {},
maxScenes: 5,
llmRunner,
timezoneOffsetMinutes: -300,
});

const result = await extractor.extract([
{
id: "memory-1",
content: "User enabled configurable timezone timestamps.",
created_at: "2026-01-01T02:00:00.000Z",
},
]);

expect(result.success).toBe(true);
expect(capturedPrompt).toContain("2025-12-31T21:30:00.000-05:00");
expect(capturedPrompt).not.toContain("2026-01-01T02:30:00.000Z");
} finally {
vi.useRealTimers();
await rm(baseDir, { recursive: true, force: true });
}
});
});
Loading
Loading