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
2 changes: 2 additions & 0 deletions apps/memos-local-plugin/agent-contract/log-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export interface SerializedLogError {
export interface LogRecord {
/** Unix epoch milliseconds (UTC). */
ts: number;
/** IANA timezone used for display formatting. Canonical event time remains `ts`. */
tz?: string;
level: LogLevel;
kind: LogKind;
channel: string;
Expand Down
1 change: 1 addition & 0 deletions apps/memos-local-plugin/core/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
logging: {
level: "info",
detailedView: false,
timezone: "UTC",
console: { enabled: true, pretty: true, channels: ["*"] },
file: {
enabled: true,
Expand Down
6 changes: 6 additions & 0 deletions apps/memos-local-plugin/core/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ export function resolveConfig(raw: unknown, warnings?: string[]): ResolvedConfig
});
}

try {
new Intl.DateTimeFormat("en-US", { timeZone: completed.logging.timezone }).format(0);
} catch {
throw new MemosError("config_invalid", `invalid logging.timezone: ${completed.logging.timezone}`);
}

return Object.freeze(completed) as ResolvedConfig;
}

Expand Down
2 changes: 2 additions & 0 deletions apps/memos-local-plugin/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,8 @@ const LoggingSchema = Type.Object({
], { default: "info" }),
/** Viewer-only switch: expose detailed logs, lifecycle tags and chain view. */
detailedView: Bool(false),
/** IANA timezone for log timestamp display. */
timezone: StringWithDefault("UTC"),
console: Type.Object({
enabled: Bool(true),
pretty: Bool(true),
Expand Down
2 changes: 1 addition & 1 deletion apps/memos-local-plugin/core/logger/format/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { LogRecord } from "../types.js";

export function formatCompact(record: LogRecord): string {
const parts: string[] = [];
parts.push(isoFromEpochMs(record.ts));
parts.push(isoFromEpochMs(record.ts, record.tz, { offset: true }));
parts.push(record.level);
parts.push(record.kind);
parts.push(`channel=${record.channel}`);
Expand Down
2 changes: 1 addition & 1 deletion apps/memos-local-plugin/core/logger/format/pretty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const KIND_TAG: Record<string, string> = {
};

export function formatPretty(record: LogRecord, opts: { color: boolean }): string {
const ts = isoFromEpochMs(record.ts).slice(11, 23); // HH:mm:ss.SSS
const ts = isoFromEpochMs(record.ts, record.tz).slice(11, 23); // HH:mm:ss.SSS
const lvl = record.level.toUpperCase().padEnd(5);
const kind = KIND_TAG[record.kind] ?? "";
const channel = record.channel ?? "?";
Expand Down
6 changes: 6 additions & 0 deletions apps/memos-local-plugin/core/logger/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ interface LoggerCore {
pid: number;
host: string;
seq: number;
/** IANA timezone used by display formatters. */
tz: string;
/** Whether file sinks are wired up (false in pre-init / null mode). */
filesActive: boolean;
}
Expand Down Expand Up @@ -90,6 +92,7 @@ function bootstrapConsoleOnly(): LoggerCore {
pid: process.pid,
host: hostname(),
seq: 0,
tz: "UTC",
filesActive: false,
};
}
Expand Down Expand Up @@ -199,6 +202,7 @@ export function initLogger(
pid: process.pid,
host: hostname(),
seq: 0,
tz: logging.timezone,
filesActive: filesEnabled,
};

Expand Down Expand Up @@ -229,6 +233,7 @@ export function initTestLogger(): void {
pid: process.pid,
host: hostname(),
seq: 0,
tz: "UTC",
filesActive: false,
};
}
Expand Down Expand Up @@ -360,6 +365,7 @@ function emitToCore(record: LogRecord, skipLevelGate = false): void {
host: core.host,
src: "ts",
seq: ++core.seq,
tz: core.tz,
...record,
};
const safe = core.redactor.redact(enriched);
Expand Down
45 changes: 42 additions & 3 deletions apps/memos-local-plugin/core/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,46 @@ export function formatDurationMs(ms: number): string {
return seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`;
}

/** Convenience: ISO 8601 string for a given epoch ms (UTC). */
export function isoFromEpochMs(ms: EpochMs): string {
return new Date(ms).toISOString();
export interface IsoFromEpochOptions {
/** Include numeric UTC offset for localized timestamps. UTC fast path stays unchanged. */
offset?: boolean;
}

/** Convenience: ISO 8601 string for a given epoch ms. Defaults to UTC. */
export function isoFromEpochMs(ms: EpochMs, tz?: string, opts: IsoFromEpochOptions = {}): string {
if (!tz || tz === "UTC") return new Date(ms).toISOString();
const d = new Date(ms);
const parts = dateTimeParts(d, tz);
const base = `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${parts.fractionalSecond ?? "000"}`;
return opts.offset ? base + offsetForTimeZone(d, tz) : base;
}

function dateTimeParts(d: Date, tz: string): Record<string, string> {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
fractionalSecondDigits: 3,
hour12: false,
});
const out: Record<string, string> = {};
for (const part of fmt.formatToParts(d)) {
if (part.type !== "literal") out[part.type] = part.value;
}
return out;
}

function offsetForTimeZone(d: Date, tz: string): string {
const fmt = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
timeZoneName: "longOffset",
hour: "2-digit",
});
const zone = fmt.formatToParts(d).find((part) => part.type === "timeZoneName")?.value;
if (!zone || zone === "GMT" || zone === "UTC") return "+00:00";
return zone.replace(/^GMT/, "");
}
21 changes: 21 additions & 0 deletions apps/memos-local-plugin/tests/unit/config/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { promises as fs } from "node:fs";
import { join } from "node:path";

import { MemosError } from "../../../agent-contract/errors.js";
import { DEFAULT_CONFIG, loadConfig, resolveConfig, resolveHome } from "../../../core/config/index.js";
import { makeTmpHome } from "../../helpers/tmp-home.js";

Expand All @@ -21,6 +22,26 @@ describe("config/loadConfig", () => {
expect(result.config.embedding.provider).toBe(DEFAULT_CONFIG.embedding.provider);
});


it("defaults logging.timezone to UTC and accepts custom IANA zones", () => {
expect(resolveConfig({}).logging.timezone).toBe("UTC");
const cfg = resolveConfig({ logging: { timezone: "America/Los_Angeles" } });
expect(cfg.logging.timezone).toBe("America/Los_Angeles");
});

it("rejects invalid logging.timezone with config_invalid", () => {
expect(() => resolveConfig({ logging: { timezone: "Not/AZone" } })).toThrow(MemosError);
try {
resolveConfig({ logging: { timezone: "Not/AZone" } });
throw new Error("expected resolveConfig to reject invalid timezone");
} catch (err) {
expect(MemosError.is(err)).toBe(true);
expect((err as MemosError).code).toBe("config_invalid");
expect((err as MemosError).message).toContain("invalid logging.timezone");
}
});


it("merges YAML over defaults and preserves unspecified branches", async () => {
const yaml = `
viewer:
Expand Down
18 changes: 18 additions & 0 deletions apps/memos-local-plugin/tests/unit/id-time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,22 @@ describe("core/time", () => {
const t = 1_700_000_000_000;
expect(isoFromEpochMs(t)).toBe(new Date(t).toISOString());
});

it("isoFromEpochMs stays byte-identical to Date.toISOString by default and for UTC", () => {
const t = Date.UTC(2026, 5, 21, 21, 30, 45, 123);
expect(isoFromEpochMs(t)).toBe(new Date(t).toISOString());
expect(isoFromEpochMs(t, "UTC")).toBe(new Date(t).toISOString());
});

it("isoFromEpochMs formats local wall time for a configured timezone", () => {
const t = Date.UTC(2026, 5, 21, 21, 30, 45, 123);
expect(isoFromEpochMs(t, "America/Los_Angeles")).toBe("2026-06-21T14:30:45.123");
});

it("isoFromEpochMs can include numeric UTC offset for compact logs", () => {
const summer = Date.UTC(2026, 5, 21, 21, 30, 45, 123);
const winter = Date.UTC(2026, 11, 21, 21, 30, 45, 123);
expect(isoFromEpochMs(summer, "America/Los_Angeles", { offset: true })).toBe("2026-06-21T14:30:45.123-07:00");
expect(isoFromEpochMs(winter, "America/Los_Angeles", { offset: true })).toBe("2026-12-21T13:30:45.123-08:00");
});
});
48 changes: 48 additions & 0 deletions apps/memos-local-plugin/tests/unit/logger/dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,4 +155,52 @@ logging:
await rootLogger.flush();
expect(memoryBuffer().tail({ limit: 1 }).at(0)?.msg).toBe("py.heartbeat");
});

it("attaches configured timezone to locally-created records", async () => {
const yaml = `
logging:
timezone: America/Los_Angeles
`;
const ctx = await makeTmpHome({ agent: "openclaw", configYaml: yaml });
cleanup = ctx.cleanup;
initLogger(ctx.config, ctx.home);

rootLogger.child({ channel: "core.session" }).info("timezone.local");
await rootLogger.flush();

expect(memoryBuffer().tail({ limit: 1 }).at(0)?.tz).toBe("America/Los_Angeles");
});

it("forward preserves explicit timezone and defaults missing timezone", async () => {
const yaml = `
logging:
timezone: America/Los_Angeles
`;
const ctx = await makeTmpHome({ agent: "openclaw", configYaml: yaml });
cleanup = ctx.cleanup;
initLogger(ctx.config, ctx.home);

const log = rootLogger.child({ channel: "adapter.hermes" });
log.forward({
ts: Date.UTC(2026, 5, 21, 21, 30, 45, 123),
level: "info",
kind: "app",
channel: "adapter.hermes",
msg: "py.explicit",
src: "py",
tz: "Europe/London",
});
log.forward({
ts: Date.UTC(2026, 5, 21, 21, 30, 45, 123),
level: "info",
kind: "app",
channel: "adapter.hermes",
msg: "py.defaulted",
src: "py",
});

const tail = memoryBuffer().tail({ limit: 2 });
expect(tail.find((r) => r.msg === "py.explicit")?.tz).toBe("Europe/London");
expect(tail.find((r) => r.msg === "py.defaulted")?.tz).toBe("America/Los_Angeles");
});
});
30 changes: 30 additions & 0 deletions apps/memos-local-plugin/tests/unit/logger/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";

import type { LogRecord } from "../../../agent-contract/log-record.js";
import { formatCompact } from "../../../core/logger/format/compact.js";
import { formatPretty } from "../../../core/logger/format/pretty.js";

const base: LogRecord = {
ts: Date.UTC(2026, 5, 21, 21, 30, 45, 123),
level: "info",
kind: "app",
channel: "core.session",
msg: "session.opened",
};

describe("logger/format", () => {
it("pretty formatter displays configured local time", () => {
const out = formatPretty({ ...base, tz: "America/Los_Angeles" }, { color: false });
expect(out.startsWith("14:30:45.123 INFO [core.session] session.opened")).toBe(true);
});

it("compact formatter preserves UTC default output", () => {
const out = formatCompact(base);
expect(out.startsWith("2026-06-21T21:30:45.123Z info app ")).toBe(true);
});

it("compact formatter emits offset-bearing local timestamp", () => {
const out = formatCompact({ ...base, tz: "America/Los_Angeles" });
expect(out.startsWith("2026-06-21T14:30:45.123-07:00 info app ")).toBe(true);
});
});