diff --git a/README.md b/README.md index b72a0a72..429b4acb 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,43 @@ Web/Mobile Clients - **Gateway**: WebSocket server for remote clients - **Hub**: Agent lifecycle and event distribution +## Time + +Super Multica now uses **message-level timestamp injection** for time awareness. +Instead of placing dynamic time text in the system prompt, user turns are stamped at runtime. + +```mermaid +flowchart TD + A[Incoming turn] --> B{Entry point} + B -->|Desktop/Gateway/Cron/Subagent| C[AsyncAgent.write] + B -->|Heartbeat poll| D[AsyncAgent.write injectTimestamp=false] + C --> E{Already stamped or has 'Current time:'?} + E -->|Yes| F[Keep original message] + E -->|No| G[Prefix: [DOW YYYY-MM-DD HH:mm TZ]] + D --> H[Keep original heartbeat prompt] + F --> I[Agent.run] + G --> I + H --> I + I --> J[LLM receives final turn text] +``` + +### Injection Matrix + +| Path | Runtime call | Timestamp injected? | Notes | +| --- | --- | --- | --- | +| Desktop direct chat | `agent.write(content)` | Yes | Default behavior | +| Gateway/remote chat | `agent.write(content)` | Yes | Same entry path as desktop | +| `sessions_spawn` child task | `childAgent.write(task)` | Yes | Child turn gets current time context | +| Cron `agent-turn` payload | `agent.write(cronMessage)` | Yes (guarded) | Skips if message already carries `Current time:` | +| Heartbeat runner | `agent.write(prompt, { injectTimestamp: false })` | No | Prevents heartbeat prompt matching from breaking | +| Internal orchestration | `writeInternal(...)` | No | Uses separate internal run path | + +### Why this design + +- Keeps system prompt cache-stable (no per-turn date churn in system prompt text) +- Gives the model an explicit "now" reference on each user turn +- Uses guardrails to avoid double-stamping and heartbeat regressions + ## Scripts ```bash diff --git a/src/agent/async-agent.test.ts b/src/agent/async-agent.test.ts index 3ebe01fd..f50cefc8 100644 --- a/src/agent/async-agent.test.ts +++ b/src/agent/async-agent.test.ts @@ -4,8 +4,8 @@ import { AsyncAgent } from "./async-agent.js"; const subscribeCallbacks: Array<(event: any) => void> = []; const internalRunState = { value: false }; -const runMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined })); -const runInternalMock = vi.fn(async () => ({ text: "", thinking: undefined, error: undefined })); +const runMock = vi.fn(async (_prompt: string) => ({ text: "", thinking: undefined, error: undefined as string | undefined })); +const runInternalMock = vi.fn(async (_prompt: string) => ({ text: "", thinking: undefined, error: undefined as string | undefined })); const flushSessionMock = vi.fn(async () => {}); const persistAssistantSummaryMock = vi.fn(); const subscribeAllMock = vi.fn((fn: (event: any) => void) => { @@ -80,6 +80,8 @@ async function nextWithTimeout(iter: AsyncIterator, timeoutMs = 40): Promi } describe("AsyncAgent internal flow", () => { + const originalTz = process.env.TZ; + afterEach(() => { subscribeCallbacks.length = 0; internalRunState.value = false; @@ -91,6 +93,35 @@ describe("AsyncAgent internal flow", () => { runMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined }); runInternalMock.mockResolvedValue({ text: "", thinking: undefined, error: undefined }); flushSessionMock.mockResolvedValue(undefined); + vi.useRealTimers(); + process.env.TZ = originalTz; + }); + + it("injects a timestamp prefix into external user writes", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); + process.env.TZ = "America/New_York"; + const agent = new AsyncAgent(); + + agent.write("recent news"); + await agent.waitForIdle(); + + expect(runMock).toHaveBeenCalledTimes(1); + const [message] = runMock.mock.calls[0] ?? []; + expect(message).toMatch(/^\[Wed 2026-01-28 20:30 EST\] recent news$/); + + agent.close(); + }); + + it("allows disabling timestamp injection per write", async () => { + const agent = new AsyncAgent(); + + agent.write("raw heartbeat prompt", { injectTimestamp: false }); + await agent.waitForIdle(); + + expect(runMock).toHaveBeenCalledWith("raw heartbeat prompt"); + + agent.close(); }); it("filters internal events in direct subscribe stream", () => { diff --git a/src/agent/async-agent.ts b/src/agent/async-agent.ts index 9aed5913..824da617 100644 --- a/src/agent/async-agent.ts +++ b/src/agent/async-agent.ts @@ -4,6 +4,7 @@ import { Agent } from "./runner.js"; import { Channel } from "./channel.js"; import type { AgentOptions, Message } from "./types.js"; import type { MulticaEvent } from "./events.js"; +import { injectMessageTimestamp } from "./message-timestamp.js"; const devNull = { write: () => true } as unknown as NodeJS.WritableStream; @@ -17,6 +18,11 @@ export interface WriteInternalOptions { persistResponse?: boolean | undefined; } +export interface WriteOptions { + /** Disable automatic message timestamp injection */ + injectTimestamp?: boolean | undefined; +} + export class AsyncAgent { private readonly agent: Agent; private readonly channel = new Channel(); @@ -48,14 +54,18 @@ export class AsyncAgent { } /** Write message to agent (non-blocking, serialized queue) */ - write(content: string): void { + write(content: string, options?: WriteOptions): void { if (this._closed) throw new Error("Agent is closed"); this.pendingWrites += 1; + const message = + options?.injectTimestamp === false + ? content + : injectMessageTimestamp(content); this.queue = this.queue .then(async () => { if (this._closed) return; - const result = await this.agent.run(content); + const result = await this.agent.run(message); // Flush pending session writes so waitForIdle() callers // can safely read session data from disk. await this.agent.flushSession(); diff --git a/src/agent/message-timestamp.test.ts b/src/agent/message-timestamp.test.ts new file mode 100644 index 00000000..f49592bf --- /dev/null +++ b/src/agent/message-timestamp.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { injectMessageTimestamp, resolveMessageTimezone } from "./message-timestamp.js"; + +describe("injectMessageTimestamp", () => { + const originalTz = process.env.TZ; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-29T01:30:00.000Z")); + process.env.TZ = "America/New_York"; + }); + + afterEach(() => { + vi.useRealTimers(); + process.env.TZ = originalTz; + }); + + it("prepends a compact timestamp prefix", () => { + const result = injectMessageTimestamp("Is it the weekend?"); + expect(result).toMatch(/^\[Wed 2026-01-28 20:30 EST\] Is it the weekend\?$/); + }); + + it("does not double-stamp already enveloped messages", () => { + const existing = "[Wed 2026-01-28 20:30 EST] hello"; + expect(injectMessageTimestamp(existing)).toBe(existing); + }); + + it("does not stamp cron messages that already include current time lines", () => { + const existing = "Cron run\nCurrent time: Wednesday, January 28th, 2026 — 8:30 PM (America/New_York)"; + expect(injectMessageTimestamp(existing)).toBe(existing); + }); + + it("returns empty/whitespace input unchanged", () => { + expect(injectMessageTimestamp("")).toBe(""); + expect(injectMessageTimestamp(" ")).toBe(" "); + }); +}); + +describe("resolveMessageTimezone", () => { + const originalTz = process.env.TZ; + + afterEach(() => { + process.env.TZ = originalTz; + }); + + it("prefers explicit argument when valid", () => { + process.env.TZ = "UTC"; + expect(resolveMessageTimezone("America/Chicago")).toBe("America/Chicago"); + }); + + it("falls back to UTC for invalid values", () => { + process.env.TZ = "Invalid/Timezone"; + const resolved = resolveMessageTimezone("also/invalid"); + expect(resolved).not.toBe("Invalid/Timezone"); + expect(resolved.length).toBeGreaterThan(0); + }); +}); diff --git a/src/agent/message-timestamp.ts b/src/agent/message-timestamp.ts new file mode 100644 index 00000000..6a9decfe --- /dev/null +++ b/src/agent/message-timestamp.ts @@ -0,0 +1,100 @@ +/** + * Message timestamp injection for time awareness. + * + * Keeps system prompt stable while giving the model a reliable "now" + * reference in each incoming user turn. + */ + +const CRON_TIME_PATTERN = /Current time:\s/; +const TIMESTAMP_ENVELOPE_PATTERN = /^\[.*\d{4}-\d{2}-\d{2} \d{2}:\d{2}/; + +export interface MessageTimestampOptions { + timeZone?: string; + now?: Date; +} + +export function resolveMessageTimezone(configured?: string): string { + const fromArg = configured?.trim(); + if (fromArg && isValidTimezone(fromArg)) { + return fromArg; + } + + const fromEnv = process.env.TZ?.trim(); + if (fromEnv && isValidTimezone(fromEnv)) { + return fromEnv; + } + + const hostTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + return hostTimezone?.trim() || "UTC"; +} + +function isValidTimezone(timeZone: string): boolean { + try { + new Intl.DateTimeFormat("en-US", { timeZone }).format(new Date()); + return true; + } catch { + return false; + } +} + +function formatZonedTimestamp(date: Date, timeZone: string): string | undefined { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }).formatToParts(date); + + const pick = (type: string) => parts.find((part) => part.type === type)?.value; + const yyyy = pick("year"); + const mm = pick("month"); + const dd = pick("day"); + const hh = pick("hour"); + const min = pick("minute"); + const tz = [...parts] + .reverse() + .find((part) => part.type === "timeZoneName") + ?.value?.trim(); + + if (!yyyy || !mm || !dd || !hh || !min) { + return undefined; + } + + return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`; +} + +export function injectMessageTimestamp( + message: string, + opts?: MessageTimestampOptions, +): string { + if (!message.trim()) { + return message; + } + + if (TIMESTAMP_ENVELOPE_PATTERN.test(message)) { + return message; + } + + if (CRON_TIME_PATTERN.test(message)) { + return message; + } + + const now = opts?.now ?? new Date(); + const timeZone = resolveMessageTimezone(opts?.timeZone); + const formatted = formatZonedTimestamp(now, timeZone); + + if (!formatted) { + return message; + } + + const dow = new Intl.DateTimeFormat("en-US", { + timeZone, + weekday: "short", + }).format(now); + + return `[${dow} ${formatted}] ${message}`; +} diff --git a/src/agent/system-prompt/builder.ts b/src/agent/system-prompt/builder.ts index a8578616..78829ebf 100644 --- a/src/agent/system-prompt/builder.ts +++ b/src/agent/system-prompt/builder.ts @@ -20,6 +20,7 @@ import { buildSafetySection, buildSkillsSection, buildSubagentSection, + buildTimeAwarenessSection, buildToolCallStyleSection, buildToolingSummary, buildUserSection, @@ -66,6 +67,7 @@ export function buildSystemPromptWithReport(options: SystemPromptOptions): { { name: "conditional-tools", lines: buildConditionalToolSections(tools, mode) }, { name: "skills", lines: buildSkillsSection(skillsPrompt, mode) }, { name: "runtime", lines: buildRuntimeSection(runtime, mode) }, + { name: "time-awareness", lines: buildTimeAwarenessSection(tools, mode) }, { name: "profile-dir", lines: buildProfileDirSection(profileDir, mode) }, { name: "subagent", lines: buildSubagentSection(subagent, mode) }, { name: "extra", lines: buildExtraPromptSection(extraSystemPrompt, mode) }, diff --git a/src/agent/system-prompt/runtime-info.ts b/src/agent/system-prompt/runtime-info.ts index 0d1abf64..5a6bc09a 100644 --- a/src/agent/system-prompt/runtime-info.ts +++ b/src/agent/system-prompt/runtime-info.ts @@ -4,6 +4,7 @@ import os from "node:os"; import type { RuntimeInfo } from "./types.js"; +import { resolveMessageTimezone } from "../message-timestamp.js"; /** * Collect runtime environment information. @@ -16,6 +17,7 @@ export function collectRuntimeInfo(overrides?: Partial): RuntimeInf os: overrides?.os ?? process.platform, arch: overrides?.arch ?? process.arch, nodeVersion: overrides?.nodeVersion ?? process.version, + timezone: overrides?.timezone ?? resolveMessageTimezone(), provider: overrides?.provider, model: overrides?.model, cwd: overrides?.cwd ?? process.cwd(), @@ -38,6 +40,7 @@ export function formatRuntimeLine(info: RuntimeInfo): string { parts.push(`arch=${info.arch}`); } if (info.nodeVersion) parts.push(`node=${info.nodeVersion}`); + if (info.timezone) parts.push(`tz=${info.timezone}`); if (info.model) { const modelStr = info.provider ? `${info.provider}/${info.model}` : info.model; parts.push(`model=${modelStr}`); diff --git a/src/agent/system-prompt/sections.test.ts b/src/agent/system-prompt/sections.test.ts index 7d09b0ce..1830f9fc 100644 --- a/src/agent/system-prompt/sections.test.ts +++ b/src/agent/system-prompt/sections.test.ts @@ -8,6 +8,7 @@ import { buildSafetySection, buildSkillsSection, buildSubagentSection, + buildTimeAwarenessSection, buildToolCallStyleSection, buildToolingSummary, buildUserSection, @@ -209,7 +210,7 @@ describe("buildSkillsSection", () => { describe("buildRuntimeSection", () => { it("formats runtime info in full mode", () => { const result = buildRuntimeSection( - { agentName: "test", os: "darwin", arch: "arm64", nodeVersion: "v22.0.0", model: "claude", provider: "anthropic" }, + { agentName: "test", os: "darwin", arch: "arm64", nodeVersion: "v22.0.0", timezone: "UTC", model: "claude", provider: "anthropic" }, "full", ); const text = result.join("\n"); @@ -228,6 +229,25 @@ describe("buildRuntimeSection", () => { }); }); +describe("buildTimeAwarenessSection", () => { + it("includes time awareness in full mode", () => { + const result = buildTimeAwarenessSection(["exec"], "full"); + const text = result.join("\n"); + expect(text).toContain("## Time Awareness"); + expect(text).toContain("latest prefixed timestamp"); + expect(text).toContain("`exec`"); + }); + + it("includes time awareness in minimal mode", () => { + const result = buildTimeAwarenessSection(undefined, "minimal"); + expect(result.join("\n")).toContain("## Time Awareness"); + }); + + it("omits time awareness in none mode", () => { + expect(buildTimeAwarenessSection(["exec"], "none")).toEqual([]); + }); +}); + describe("buildProfileDirSection", () => { it("returns empty in all modes (merged into workspace section)", () => { // Profile directory info is now part of buildWorkspaceSection diff --git a/src/agent/system-prompt/sections.ts b/src/agent/system-prompt/sections.ts index 51b80f40..f0db6e33 100644 --- a/src/agent/system-prompt/sections.ts +++ b/src/agent/system-prompt/sections.ts @@ -314,6 +314,30 @@ export function buildRuntimeSection( return ["## Runtime", formatRuntimeLine(runtime)]; } +/** + * Time awareness section — helps the agent reason about "now" safely. + * Included in full and minimal modes. + */ +export function buildTimeAwarenessSection( + tools: string[] | undefined, + mode: SystemPromptMode, +): string[] { + if (mode === "none") return []; + + const hasExecTool = (tools ?? []).some((tool) => tool.toLowerCase() === "exec"); + const fallbackLine = hasExecTool + ? "If a turn lacks a timestamp and exact current time matters, use `exec` with `date`." + : "If a turn lacks a timestamp and exact current time matters, ask for clarification."; + + return [ + "## Time Awareness", + "Incoming user messages may include a prefix like `[Wed 2026-02-09 21:15 PST]`.", + "Treat the latest prefixed timestamp as your reference for relative time requests (today, recent, last month).", + fallbackLine, + "", + ]; +} + /** * Profile directory section — now merged into buildWorkspaceSection. * Kept for backwards compatibility but returns empty. diff --git a/src/agent/system-prompt/types.ts b/src/agent/system-prompt/types.ts index 2dc2b18e..9f75788e 100644 --- a/src/agent/system-prompt/types.ts +++ b/src/agent/system-prompt/types.ts @@ -31,6 +31,8 @@ export interface RuntimeInfo { arch?: string | undefined; /** Node.js version (e.g. "v22.0.0") */ nodeVersion?: string | undefined; + /** User-facing timezone for temporal reasoning (e.g. "America/Los_Angeles") */ + timezone?: string | undefined; /** Current working directory */ cwd?: string | undefined; } diff --git a/src/heartbeat/runner.test.ts b/src/heartbeat/runner.test.ts index 4a77c8fd..8a3570a3 100644 --- a/src/heartbeat/runner.test.ts +++ b/src/heartbeat/runner.test.ts @@ -9,7 +9,7 @@ type StubAgent = { sessionId: string; ensureInitialized: () => Promise; getMessages: () => Array; - write: (content: string) => void; + write: (content: string, options?: { injectTimestamp?: boolean }) => void; waitForIdle: () => Promise; getHeartbeatConfig: () => { prompt?: string; ackMaxChars?: number; enabled?: boolean }; getPendingWrites: () => number; @@ -71,4 +71,19 @@ describe("heartbeat runner", () => { expect(result.status).toBe("ran"); }); + + it("disables timestamp injection for heartbeat prompt writes", async () => { + const writes: Array<{ content: string; options?: { injectTimestamp?: boolean } }> = []; + const agent = createStubAgent({ replyText: "HEARTBEAT_OK" }); + const originalWrite = agent.write; + agent.write = (content, options) => { + writes.push(options ? { content, options } : { content }); + originalWrite(content, options); + }; + + await runHeartbeatOnce({ agent: agent as any, reason: "manual" }); + + expect(writes.length).toBeGreaterThan(0); + expect(writes[0]?.options?.injectTimestamp).toBe(false); + }); }); diff --git a/src/heartbeat/runner.ts b/src/heartbeat/runner.ts index 281adf9b..f9ba6bfa 100644 --- a/src/heartbeat/runner.ts +++ b/src/heartbeat/runner.ts @@ -176,7 +176,7 @@ export async function runHeartbeatOnce(opts: { ? `${basePrompt}\n\nSystem events:\n${pendingEvents.map((line) => `- ${line}`).join("\n")}` : basePrompt; - agent.write(prompt); + agent.write(prompt, { injectTimestamp: false }); await agent.waitForIdle(); const afterMessages = agent.getMessages();