From c2e5b14bf80587b9fcffcec5c2d739960578f1ff Mon Sep 17 00:00:00 2001 From: gavrielc Date: Fri, 10 Jul 2026 21:03:27 +0300 Subject: [PATCH] fix: ISO storage + local-time display for all timestamps Storage: every JS-side datetime('now') write becomes new Date().toISOString() (messages_out all writers, processing_ack all stamps, delivered, retry backoff, roles/members/destinations, skill SQL via strftime ISO-Z). Dormant deliver_after raw string compares wrapped in datetime(). Display: ncl human output renders ISO instants as local "YYYY-MM-DD HH:mm" (host + container renderers; --json stays ISO; the stamp shape round-trips through parseZonedToUtc). Task run logs, conversation archive filenames, clidash, the dashboard pusher, and the uninstall backup stamp all render local; instruction texts updated in lockstep. CLAUDE.md documents the convention. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01A2YZQDTw9TQrH3m8NBtVAW --- .../add-clidash/add/tools/clidash/activity.js | 12 ++++-- .../add/tools/clidash/public/app.js | 12 +++++- .../clidash/test/activity-server.test.js | 11 +++-- .../add/tools/clidash/test/activity.test.js | 22 +++++++--- .../resources/dashboard-pusher.ts | 12 +++++- .claude/skills/add-gcal-tool/REMOVE.md | 2 +- .claude/skills/add-gcal-tool/SKILL.md | 4 +- .claude/skills/add-github/SKILL.md | 6 +-- .claude/skills/add-gmail-tool/REMOVE.md | 2 +- .claude/skills/add-gmail-tool/SKILL.md | 4 +- .claude/skills/add-linear/SKILL.md | 4 +- .claude/skills/add-wechat/scripts/wire-dm.ts | 4 +- CLAUDE.md | 7 +++ container/agent-runner/src/cli/ncl.ts | 43 +++++++++++++++++-- container/agent-runner/src/db/messages-in.ts | 16 +++---- container/agent-runner/src/db/messages-out.ts | 5 ++- .../src/mcp-tools/cli.instructions.md | 2 +- .../agent-runner/src/providers/claude.ts | 5 ++- container/agent-runner/src/timezone.ts | 16 +++++++ scripts/test-v2-agent.ts | 3 +- setup/uninstall/remove.ts | 6 ++- src/cli/dispatch.ts | 4 +- src/cli/format.ts | 30 ++++++++++++- src/cli/resources/destinations.ts | 4 +- src/cli/resources/members.ts | 4 +- src/cli/resources/roles.ts | 4 +- src/cli/resources/tasks.test.ts | 3 +- src/cli/resources/tasks.ts | 10 ++--- src/db/session-db.ts | 15 +++---- src/modules/scheduling/recurrence.ts | 3 +- src/session-manager.ts | 4 +- src/timezone.ts | 16 +++++++ 32 files changed, 222 insertions(+), 73 deletions(-) diff --git a/.claude/skills/add-clidash/add/tools/clidash/activity.js b/.claude/skills/add-clidash/add/tools/clidash/activity.js index 1b4c11009..f807316da 100644 --- a/.claude/skills/add-clidash/add/tools/clidash/activity.js +++ b/.claude/skills/add-clidash/add/tools/clidash/activity.js @@ -18,6 +18,12 @@ function normTs(ts) { return `${ts.replace(' ', 'T')}Z`; } +// Local calendar day "YYYY-MM-DD" — chart labels are read by a human, so +// bucket by the server's local day, not the UTC date prefix. +function localDay(date) { + return date.toLocaleDateString('sv-SE'); +} + function readTable(dbPath, table) { let db; try { @@ -28,7 +34,7 @@ function readTable(dbPath, table) { for (const r of rows) { const ts = normTs(r.timestamp); if (!ts) continue; - const day = ts.slice(0, 10); // ISO date prefix + const day = localDay(new Date(ts)); byDay.set(day, (byDay.get(day) ?? 0) + 1); if (last === null || ts > last) last = ts; } @@ -52,12 +58,12 @@ function listDirs(path) { * Aggregate message activity across all session DBs under `sessionsRoot`. * @returns {{ sessions: Array, series: Array<{date,in,out}> }} * sessions — per session: { agent_group_id, session_id, in, out, lastActivity } - * series — one bucket per day for the last `days` days (UTC, newest last) + * series — one bucket per day for the last `days` days (local time, newest last) */ export function collectActivity(sessionsRoot, days, now) { const dates = []; for (let i = days - 1; i >= 0; i--) { - dates.push(new Date(now.getTime() - i * 86_400_000).toISOString().slice(0, 10)); + dates.push(localDay(new Date(now.getTime() - i * 86_400_000))); } const series = new Map(dates.map((d) => [d, { date: d, in: 0, out: 0 }])); const sessions = []; diff --git a/.claude/skills/add-clidash/add/tools/clidash/public/app.js b/.claude/skills/add-clidash/add/tools/clidash/public/app.js index 7a3813de3..1e97e5cdd 100644 --- a/.claude/skills/add-clidash/add/tools/clidash/public/app.js +++ b/.claude/skills/add-clidash/add/tools/clidash/public/app.js @@ -71,6 +71,14 @@ function icon(name) { const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/; +// Local wall time "YYYY-MM-DD HH:mm" — the raw ISO string is UTC; slicing it +// would display UTC wall time with no marker, masquerading as local. +function absTime(iso) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }); +} + function relTime(iso) { const ms = Date.now() - new Date(iso).getTime(); if (Number.isNaN(ms)) return iso; @@ -127,7 +135,7 @@ function cellFor(value) { if (f.cls === 'null') return el('td', { class: 'null' }, 'null'); if (f.iso) { return el('td', {}, el('span', { class: 'reltime', title: f.iso }, [ - relTime(f.iso), el('span', { class: 'abs' }, f.iso.slice(0, 16).replace('T', ' ')), + relTime(f.iso), el('span', { class: 'abs' }, absTime(f.iso)), ])); } if (f.text.length > 42) { @@ -142,7 +150,7 @@ function kvRows(obj) { return Object.entries(obj ?? {}).map(([k, v]) => { let valEl; if (v && typeof v === 'object') valEl = el('pre', { class: 'kv-json' }, JSON.stringify(v, null, 2)); - else if (typeof v === 'string' && ISO_RE.test(v)) valEl = el('span', { class: 'reltime', title: v }, `${relTime(v)} (${v.slice(0, 16).replace('T', ' ')})`); + else if (typeof v === 'string' && ISO_RE.test(v)) valEl = el('span', { class: 'reltime', title: v }, `${relTime(v)} (${absTime(v)})`); else if (v === null || v === undefined) valEl = el('span', { class: 'null' }, 'null'); else valEl = el('span', {}, String(v)); return el('div', { class: 'kv-row' }, [el('span', { class: 'kv-key' }, k), valEl]); diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/activity-server.test.js b/.claude/skills/add-clidash/add/tools/clidash/test/activity-server.test.js index 7ac9d7802..ab1be8cab 100644 --- a/.claude/skills/add-clidash/add/tools/clidash/test/activity-server.test.js +++ b/.claude/skills/add-clidash/add/tools/clidash/test/activity-server.test.js @@ -11,9 +11,14 @@ before(() => { root = mkdtempSync(join(tmpdir(), 'clidash-actsrv-')); mkdirSync(join(root, 'ag-1', 'sess-1'), { recursive: true }); const mk = (p, t, ts) => { const db = new DatabaseSync(p); db.exec(`CREATE TABLE ${t}(id TEXT, timestamp TEXT)`); const i = db.prepare(`INSERT INTO ${t} VALUES (?,?)`); ts.forEach((x, n) => i.run(String(n), x)); db.close(); }; - const today = new Date().toISOString().slice(0, 10); - mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [`${today} 09:00:00`, `${today} 10:00:00`]); - mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [`${today} 09:05:00`]); + // Seed with instants moments ago: they land in the LOCAL-today bucket + // (series.at(-1)) regardless of the machine's timezone. + const now = Date.now(); + mk(join(root, 'ag-1', 'sess-1', 'inbound.db'), 'messages_in', [ + new Date(now - 120_000).toISOString(), + new Date(now - 60_000).toISOString(), + ]); + mk(join(root, 'ag-1', 'sess-1', 'outbound.db'), 'messages_out', [new Date(now - 90_000).toISOString()]); }); after(() => rmSync(root, { recursive: true, force: true })); diff --git a/.claude/skills/add-clidash/add/tools/clidash/test/activity.test.js b/.claude/skills/add-clidash/add/tools/clidash/test/activity.test.js index ba169e980..729f666c0 100644 --- a/.claude/skills/add-clidash/add/tools/clidash/test/activity.test.js +++ b/.claude/skills/add-clidash/add/tools/clidash/test/activity.test.js @@ -45,20 +45,30 @@ test('collectActivity: per-session in/out totals + last activity', () => { assert.equal(s2.out, 0); }); +// Buckets are LOCAL calendar days — derive expectations with the same +// mapping so the assertions hold in any machine timezone. +const day = (t) => new Date(t).toLocaleDateString('sv-SE'); + test('collectActivity: series has one bucket per day for `days`, newest last', () => { const { series } = collectActivity(root, 14, NOW); assert.equal(series.length, 14); - assert.equal(series[0].date, '2026-06-01'); - assert.equal(series[13].date, '2026-06-14'); + assert.equal(series[0].date, day(new Date(NOW.getTime() - 13 * 86_400_000))); + assert.equal(series[13].date, day(NOW)); }); test('collectActivity: counts land in the right day buckets', () => { const { series } = collectActivity(root, 14, NOW); const byDate = Object.fromEntries(series.map((d) => [d.date, d])); - assert.equal(byDate['2026-06-14'].in, 2); - assert.equal(byDate['2026-06-14'].out, 2); - assert.equal(byDate['2026-06-13'].in, 1); - assert.equal(byDate['2026-06-13'].out, 0); + const expIn = {}; + const expOut = {}; + for (const t of ['2026-06-14T09:01:23Z', '2026-06-14T10:00:00Z', '2026-06-13T08:00:00Z']) { + expIn[day(t)] = (expIn[day(t)] ?? 0) + 1; + } + for (const t of ['2026-06-14T09:05:00Z', '2026-06-14T10:05:00Z']) { + expOut[day(t)] = (expOut[day(t)] ?? 0) + 1; + } + for (const [d, n] of Object.entries(expIn)) assert.equal(byDate[d].in, n); + for (const [d, n] of Object.entries(expOut)) assert.equal(byDate[d].out, n); }); test('collectActivity: messages outside the window are counted in totals but not the series', () => { diff --git a/.claude/skills/add-dashboard/resources/dashboard-pusher.ts b/.claude/skills/add-dashboard/resources/dashboard-pusher.ts index 849e75507..2d5dbd7bf 100644 --- a/.claude/skills/add-dashboard/resources/dashboard-pusher.ts +++ b/.claude/skills/add-dashboard/resources/dashboard-pusher.ts @@ -427,12 +427,20 @@ function collectContextWindows() { return results; } +// "YYYY-MM-DDTHH" in the host's local time — the chart's hour labels are read +// by a human, so bucket by local hour, not UTC. sv-SE renders "YYYY-MM-DD HH". +function localHourKey(d: Date): string { + return d + .toLocaleString('sv-SE', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }) + .replace(' ', 'T'); +} + function collectActivity() { const now = Date.now(); const buckets: Record = {}; for (let i = 0; i < 24; i++) { - const key = new Date(now - i * 3600000).toISOString().slice(0, 13); + const key = localHourKey(new Date(now - i * 3600000)); buckets[key] = { inbound: 0, outbound: 0 }; } @@ -453,7 +461,7 @@ function collectActivity() { const table = direction === 'outbound' ? 'messages_out' : 'messages_in'; const rows = db.prepare(`SELECT timestamp FROM ${table} WHERE timestamp > ?`).all(cutoff) as { timestamp: string }[]; for (const row of rows) { - const key = row.timestamp.slice(0, 13); + const key = localHourKey(new Date(row.timestamp)); if (buckets[key]) buckets[key][direction]++; } db.close(); diff --git a/.claude/skills/add-gcal-tool/REMOVE.md b/.claude/skills/add-gcal-tool/REMOVE.md index 517119d13..72aab20f1 100644 --- a/.claude/skills/add-gcal-tool/REMOVE.md +++ b/.claude/skills/add-gcal-tool/REMOVE.md @@ -18,7 +18,7 @@ There is no `ncl groups config remove-mount` verb yet (tracked in [#2395](https: pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \ SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \ WHERE json_extract(value, '\$.containerPath') != '.calendar-mcp'), \ - updated_at = datetime('now') \ + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \ WHERE agent_group_id = '';" ``` diff --git a/.claude/skills/add-gcal-tool/SKILL.md b/.claude/skills/add-gcal-tool/SKILL.md index cdbb43430..3d7237db3 100644 --- a/.claude/skills/add-gcal-tool/SKILL.md +++ b/.claude/skills/add-gcal-tool/SKILL.md @@ -168,11 +168,11 @@ HOST_PATH="$HOME/.calendar-mcp" MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".calendar-mcp", readonly:false}') pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \ SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \ - updated_at = datetime('now') \ + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \ WHERE agent_group_id = '$GROUP_ID';" ``` -Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings. +Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is an ISO-with-Z string everywhere else in the schema (`new Date().toISOString()`), so stamp the same shape with `strftime` — plain `datetime('now')` would mix naive UTC strings into the column, and `strftime('%s','now')` epoch ints. **Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time. diff --git a/.claude/skills/add-github/SKILL.md b/.claude/skills/add-github/SKILL.md index b52560f88..a6e1c2656 100644 --- a/.claude/skills/add-github/SKILL.md +++ b/.claude/skills/add-github/SKILL.md @@ -112,11 +112,11 @@ Run `/manage-channels` to wire the GitHub channel to an agent group, or insert m ```sql -- Create messaging group (one per repo) INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at) -VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '', datetime('now')); +VALUES ('mg-github-myrepo', 'github', 'github:owner/repo', 'github', 'owner/repo', 1, '', strftime('%Y-%m-%dT%H:%M:%fZ','now')); -- Wire to agent group INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) -VALUES ('mga-github-myrepo', 'mg-github-myrepo', '', '', 'all', 'per-thread', 10, datetime('now')); +VALUES ('mga-github-myrepo', 'mg-github-myrepo', '', '', 'all', 'per-thread', 10, strftime('%Y-%m-%dT%H:%M:%fZ','now')); ``` Replace `` with `public` or `strict` based on the user's choice above. @@ -128,7 +128,7 @@ When using `strict`, add each GitHub user who should be able to trigger the agen ```sql -- Add user (kind = 'github', id = 'github:') INSERT OR IGNORE INTO users (id, kind, display_name, created_at) -VALUES ('github:', 'github', '', datetime('now')); +VALUES ('github:', 'github', '', strftime('%Y-%m-%dT%H:%M:%fZ','now')); -- Grant membership to the agent group INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id) diff --git a/.claude/skills/add-gmail-tool/REMOVE.md b/.claude/skills/add-gmail-tool/REMOVE.md index bcbd03570..a8529cf04 100644 --- a/.claude/skills/add-gmail-tool/REMOVE.md +++ b/.claude/skills/add-gmail-tool/REMOVE.md @@ -26,7 +26,7 @@ GROUP_ID='' pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \ SET additional_mounts = (SELECT json_group_array(value) FROM json_each(additional_mounts) \ WHERE json_extract(value, '\$.containerPath') != '.gmail-mcp'), \ - updated_at = datetime('now') \ + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \ WHERE agent_group_id = '$GROUP_ID';" ``` diff --git a/.claude/skills/add-gmail-tool/SKILL.md b/.claude/skills/add-gmail-tool/SKILL.md index 343df669d..7b479402b 100644 --- a/.claude/skills/add-gmail-tool/SKILL.md +++ b/.claude/skills/add-gmail-tool/SKILL.md @@ -189,11 +189,11 @@ HOST_PATH="$HOME/.gmail-mcp" MOUNT=$(jq -cn --arg h "$HOST_PATH" '{hostPath:$h, containerPath:".gmail-mcp", readonly:false}') pnpm exec tsx scripts/q.ts data/v2.db "UPDATE container_configs \ SET additional_mounts = json_insert(additional_mounts, '\$[#]', json('$MOUNT')), \ - updated_at = datetime('now') \ + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') \ WHERE agent_group_id = '$GROUP_ID';" ``` -Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is ISO-string everywhere else in the schema, so use `datetime('now')` — not `strftime('%s','now')`, which would silently mix epoch ints into a column of YYYY-MM-DD HH:MM:SS strings. +Run from your NanoClaw project root (where `data/v2.db` lives). The `$[#]` placeholder is SQLite JSON1's append-to-end notation; it's `\$`-escaped so bash doesn't arithmetic-expand it before sqlite sees it. `updated_at` is an ISO-with-Z string everywhere else in the schema (`new Date().toISOString()`), so stamp the same shape with `strftime` — plain `datetime('now')` would mix naive UTC strings into the column, and `strftime('%s','now')` epoch ints. **Switch to `ncl groups config add-mount` once #2395 lands.** Update this skill at that time. diff --git a/.claude/skills/add-linear/SKILL.md b/.claude/skills/add-linear/SKILL.md index 28f4be500..1af266eb5 100644 --- a/.claude/skills/add-linear/SKILL.md +++ b/.claude/skills/add-linear/SKILL.md @@ -120,11 +120,11 @@ Run `/manage-channels` to wire the Linear channel to an agent group, or insert m ```sql -- Create messaging group (one per team) INSERT INTO messaging_groups (id, channel_type, platform_id, instance, name, is_group, unknown_sender_policy, created_at) -VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', datetime('now')); +VALUES ('mg-linear-eng', 'linear', 'linear:ENG', 'linear', 'Engineering', 1, 'public', strftime('%Y-%m-%dT%H:%M:%fZ','now')); -- Wire to agent group INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) -VALUES ('mga-linear-eng', 'mg-linear-eng', '', '', 'all', 'per-thread', 10, datetime('now')); +VALUES ('mga-linear-eng', 'mg-linear-eng', '', '', 'all', 'per-thread', 10, strftime('%Y-%m-%dT%H:%M:%fZ','now')); ``` The `platform_id` must be `linear:` matching the `LINEAR_TEAM_KEY` env var. Use `per-thread` session mode so each issue comment thread gets its own agent session. diff --git a/.claude/skills/add-wechat/scripts/wire-dm.ts b/.claude/skills/add-wechat/scripts/wire-dm.ts index f94c88d98..68c8cb4ca 100644 --- a/.claude/skills/add-wechat/scripts/wire-dm.ts +++ b/.claude/skills/add-wechat/scripts/wire-dm.ts @@ -156,8 +156,8 @@ async function main(): Promise { db.prepare(` INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at) - VALUES (?, ?, ?, '', 'all', ?, 10, datetime('now')) - `).run(generateId('mga'), mg.id, ag.id, args.sessionMode); + VALUES (?, ?, ?, '', 'all', ?, 10, ?) + `).run(generateId('mga'), mg.id, ag.id, args.sessionMode, new Date().toISOString()); }); tx(); diff --git a/CLAUDE.md b/CLAUDE.md index 6fb08fdaa..ad6542906 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,6 +252,13 @@ Check these first when something goes wrong: Note: container logs are lost after the container exits (`--rm` flag). If the agent silently failed inside the container, there's no persistent log to inspect. +## Timestamps + +Two rules, no exceptions: + +- **Storage**: every timestamp written from JS is `new Date().toISOString()` (ISO-8601 UTC with `Z`). Never `datetime('now')` — its naive `YYYY-MM-DD HH:MM:SS` shape is misparsed as local time by `new Date()` and breaks string comparisons against ISO values. In pure-SQL contexts (skill snippets) use `strftime('%Y-%m-%dT%H:%M:%fZ','now')`. SQL-side *comparisons* wrap both sides in `datetime()`. +- **Display**: anything shown to an agent or a user renders in the install timezone — `formatLocalTime` (prose) or `formatLocalStamp` (log lines) from `src/timezone.ts` / `container/agent-runner/src/timezone.ts`. `--json` output, DB values, and operator logs stay ISO. + ## Supply Chain Security (pnpm) This project uses pnpm with `minimumReleaseAge: 4320` (3 days) in `pnpm-workspace.yaml`. New package versions must exist on the npm registry for 3 days before pnpm will resolve them. diff --git a/container/agent-runner/src/cli/ncl.ts b/container/agent-runner/src/cli/ncl.ts index f8e3290a0..2025e3b96 100644 --- a/container/agent-runner/src/cli/ncl.ts +++ b/container/agent-runner/src/cli/ncl.ts @@ -65,10 +65,11 @@ function writeRequest(req: RequestFrame): void { db.prepare( `INSERT INTO messages_out (id, seq, timestamp, kind, content) - VALUES ($id, $seq, datetime('now'), 'system', $content)`, + VALUES ($id, $seq, $timestamp, 'system', $content)`, ).run({ $id: req.id, $seq: nextSeq, + $timestamp: new Date().toISOString(), $content: JSON.stringify({ action: 'cli_request', requestId: req.id, @@ -110,9 +111,9 @@ function pollResponse(requestId: string, timeoutMs: number): ResponseFrame | nul outDb.exec('PRAGMA busy_timeout = 5000'); outDb .prepare( - "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))", + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', ?)", ) - .run(row.id); + .run(row.id, new Date().toISOString()); outDb.close(); const parsed = JSON.parse(row.content); @@ -184,12 +185,46 @@ function printUsage(): void { // Formatting (mirrors src/cli/format.ts on the host) // --------------------------------------------------------------------------- +// Mirrors localizeIsoTimestamps in src/cli/format.ts (self-contained — no +// imports from agent-runner). Human display shows local time (container TZ +// env = install timezone); --json keeps the ISO machine contract. Only whole +// ISO-instant strings convert — embedded occurrences may be machine payloads. +const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/; + +// "YYYY-MM-DD HH:mm" — round-trips through parseZonedToUtc (naive = local +// wall-clock), so a value copied from human output into --process-after +// means what it shows. +function localTime(iso: string): string { + return new Date(iso).toLocaleString('sv-SE', { + timeZone: process.env.TZ || 'UTC', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); +} + +function localizeIsoTimestamps(value: unknown): unknown { + if (typeof value === 'string') { + return ISO_UTC_RE.test(value) ? localTime(value) : value; + } + if (Array.isArray(value)) return value.map(localizeIsoTimestamps); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [k, localizeIsoTimestamps(v)]), + ); + } + return value; +} + function formatHuman(resp: ResponseFrame): string { if (!resp.ok) { return `error (${resp.error.code}): ${resp.error.message}\n`; } - const data = resp.data; + const data = localizeIsoTimestamps(resp.data); if (!Array.isArray(data) || data.length === 0) { return JSON.stringify(data, null, 2) + '\n'; } diff --git a/container/agent-runner/src/db/messages-in.ts b/container/agent-runner/src/db/messages-in.ts index 6eb01b2d6..f482af453 100644 --- a/container/agent-runner/src/db/messages-in.ts +++ b/container/agent-runner/src/db/messages-in.ts @@ -101,10 +101,10 @@ export function markProcessing(ids: string[]): void { if (ids.length === 0) return; const db = getOutboundDb(); const stmt = db.prepare( - "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', datetime('now'))", + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'processing', ?)", ); db.transaction(() => { - for (const id of ids) stmt.run(id); + for (const id of ids) stmt.run(id, new Date().toISOString()); })(); } @@ -113,10 +113,10 @@ export function markCompleted(ids: string[]): void { if (ids.length === 0) return; const db = getOutboundDb(); const stmt = db.prepare( - "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', datetime('now'))", + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'completed', ?)", ); db.transaction(() => { - for (const id of ids) stmt.run(id); + for (const id of ids) stmt.run(id, new Date().toISOString()); })(); } @@ -131,10 +131,10 @@ export function markScriptSkipped(skips: Array<{ id: string; reason: string }>): if (skips.length === 0) return; const db = getOutboundDb(); const stmt = db.prepare( - "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, datetime('now'))", + 'INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, ?, ?)', ); db.transaction(() => { - for (const s of skips) stmt.run(s.id, s.reason === 'error' ? 'script-skip:error' : 'completed'); + for (const s of skips) stmt.run(s.id, s.reason === 'error' ? 'script-skip:error' : 'completed', new Date().toISOString()); })(); } @@ -142,9 +142,9 @@ export function markScriptSkipped(skips: Array<{ id: string; reason: string }>): export function markFailed(id: string): void { getOutboundDb() .prepare( - "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'failed', datetime('now'))", + "INSERT OR REPLACE INTO processing_ack (message_id, status, status_changed) VALUES (?, 'failed', ?)", ) - .run(id); + .run(id, new Date().toISOString()); } /** Get a message by ID (read from inbound.db). */ diff --git a/container/agent-runner/src/db/messages-out.ts b/container/agent-runner/src/db/messages-out.ts index 1b2d9f865..6f834fbee 100644 --- a/container/agent-runner/src/db/messages-out.ts +++ b/container/agent-runner/src/db/messages-out.ts @@ -58,11 +58,12 @@ export function writeMessageOut(msg: WriteMessageOut): number { outbound .prepare( `INSERT INTO messages_out (id, seq, in_reply_to, timestamp, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content) - VALUES ($id, $seq, $in_reply_to, datetime('now'), $deliver_after, $recurrence, $kind, $platform_id, $channel_type, $thread_id, $content)`, + VALUES ($id, $seq, $in_reply_to, $timestamp, $deliver_after, $recurrence, $kind, $platform_id, $channel_type, $thread_id, $content)`, ) .run({ $id: msg.id, $seq: nextSeq, + $timestamp: new Date().toISOString(), $in_reply_to: msg.in_reply_to ?? null, $deliver_after: msg.deliver_after ?? null, $recurrence: msg.recurrence ?? null, @@ -136,7 +137,7 @@ export function getUndeliveredMessages(): MessageOutRow[] { return getOutboundDb() .prepare( `SELECT * FROM messages_out - WHERE (deliver_after IS NULL OR deliver_after <= datetime('now')) + WHERE (deliver_after IS NULL OR datetime(deliver_after) <= datetime('now')) ORDER BY timestamp ASC`, ) .all() as MessageOutRow[]; diff --git a/container/agent-runner/src/mcp-tools/cli.instructions.md b/container/agent-runner/src/mcp-tools/cli.instructions.md index f6673bd90..452ce2d8d 100644 --- a/container/agent-runner/src/mcp-tools/cli.instructions.md +++ b/container/agent-runner/src/mcp-tools/cli.instructions.md @@ -67,7 +67,7 @@ ncl tasks list # Always pass a short descriptive --name so the task id is readable (e.g. daily-briefing-a25c, not a long uuid). # For a recurring task, --recurrence alone sets the schedule (first run derived from it); add --process-after only for one-shots. ncl tasks create --name "daily briefing" --prompt "Send the daily briefing" --recurrence "0 9 * * *" -# At the END of every task run, record one line of history. The host stamps the UTC time — you supply only the summary. +# At the END of every task run, record one line of history. The host stamps the local time — you supply only the summary. # This is a LOG ENTRY, not a message: it sends nothing to anyone. Inside a task fire --id is auto-derived from your session. ncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped" diff --git a/container/agent-runner/src/providers/claude.ts b/container/agent-runner/src/providers/claude.ts index 17946b5cb..22accb3af 100644 --- a/container/agent-runner/src/providers/claude.ts +++ b/container/agent-runner/src/providers/claude.ts @@ -5,6 +5,7 @@ import path from 'path'; import { query as sdkQuery, type HookCallback, type PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; import { clearContainerToolInFlight, setContainerToolInFlight } from '../db/connection.js'; +import { TIMEZONE, formatLocalStamp } from '../timezone.js'; import { registerProvider } from './provider-registry.js'; import type { AgentProvider, AgentQuery, McpServerConfig, ProviderEvent, ProviderOptions, QueryInput } from './types.js'; @@ -223,7 +224,9 @@ function archiveTranscriptFile(transcriptPath: string | undefined, sessionId: st const conversationsDir = process.env.NANOCLAW_CONVERSATIONS_DIR || '/workspace/agent/conversations'; fs.mkdirSync(conversationsDir, { recursive: true }); - const filename = `${new Date().toISOString().split('T')[0]}-${name}.md`; + // Local calendar date — the fallback `name` above already uses local + // hours, and the agent navigates conversations/ by these date prefixes. + const filename = `${formatLocalStamp(new Date(), TIMEZONE).slice(0, 10)}-${name}.md`; fs.writeFileSync(path.join(conversationsDir, filename), formatTranscriptMarkdown(messages, summary, assistantName)); log(`Archived conversation to ${filename}`); return true; diff --git a/container/agent-runner/src/timezone.ts b/container/agent-runner/src/timezone.ts index d9a2e1bcc..ab4370100 100644 --- a/container/agent-runner/src/timezone.ts +++ b/container/agent-runner/src/timezone.ts @@ -48,6 +48,22 @@ export function formatLocalTime(utcIso: string, timezone: string): string { }); } +/** + * Compact sortable local stamp for log lines: "YYYY-MM-DD HH:mm" in `timezone`. + * (sv-SE is the one locale whose default rendering is this exact shape.) + */ +export function formatLocalStamp(date: Date, timezone: string): string { + return date.toLocaleString('sv-SE', { + timeZone: resolveTimezone(timezone), + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); +} + function resolveContainerTimezone(): string { const candidates = [process.env.TZ, Intl.DateTimeFormat().resolvedOptions().timeZone]; for (const tz of candidates) { diff --git a/scripts/test-v2-agent.ts b/scripts/test-v2-agent.ts index 433f2a009..049766d14 100644 --- a/scripts/test-v2-agent.ts +++ b/scripts/test-v2-agent.ts @@ -34,9 +34,10 @@ db.exec(` // Insert test message db.prepare( - `INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', datetime('now'), 'pending', ?)`, + `INSERT INTO messages_in (id, kind, timestamp, status, content) VALUES (?, 'chat', ?, 'pending', ?)`, ).run( 'test-1', + new Date().toISOString(), JSON.stringify({ sender: 'Gavriel', text: 'Say "Hello from v2!" and nothing else. Do not use any tools.' }), ); console.log('✓ Session DB created with test message'); diff --git a/setup/uninstall/remove.ts b/setup/uninstall/remove.ts index 153ef08ff..e249c03b3 100644 --- a/setup/uninstall/remove.ts +++ b/setup/uninstall/remove.ts @@ -46,10 +46,12 @@ export function backupEnv(envPath: string): string { const dir = path.dirname(envPath); let backup = path.join(dir, '.env.bak'); if (fs.existsSync(backup)) { + // Local time (system TZ) — the stamp is read by the human running the + // uninstall. sv-SE renders "YYYY-MM-DD HH:mm:ss". const stamp = new Date() - .toISOString() + .toLocaleString('sv-SE', { hour12: false }) .replace(/[-:]/g, '') - .replace('T', '-') + .replace(' ', '-') .slice(0, 15); backup = path.join(dir, `.env.bak.${stamp}`); } diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 5ea9f65c3..3885e931e 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -11,6 +11,7 @@ import { getAgentGroup } from '../db/agent-groups.js'; import { getSession } from '../db/sessions.js'; import { registerApprovalHandler, requestApproval } from '../modules/approvals/index.js'; import type { CallerContext, ErrorCode, RequestFrame, ResponseFrame } from './frame.js'; +import { localizeIsoTimestamps } from './format.js'; import { getResource } from './crud.js'; import { listVerbs, renderVerbHelp } from './help-render.js'; import { GROUP_SCOPE_RESOURCES, listCommands, lookup } from './registry.js'; @@ -221,7 +222,8 @@ registerApprovalHandler('cli_command', async ({ payload, notify }) => { const response = await dispatch(frame, callerContext, { approved: true }); if (response.ok) { - const data = typeof response.data === 'string' ? response.data : JSON.stringify(response.data, null, 2); + const localized = localizeIsoTimestamps(response.data); + const data = typeof localized === 'string' ? localized : JSON.stringify(localized, null, 2); notify(`Your \`ncl ${frame.command}\` request was approved and executed.\n\n${data}`); } else { notify(`Your \`ncl ${frame.command}\` request was approved but failed: ${response.error.message}`); diff --git a/src/cli/format.ts b/src/cli/format.ts index 9b54599b7..abcd5bea6 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -8,10 +8,37 @@ * itself. The DB transport (when it lands) skips this layer entirely — * the agent sees frames directly. */ +import { TIMEZONE } from '../config.js'; +import { formatLocalStamp } from '../timezone.js'; import type { ResponseFrame } from './frame.js'; export type FormatMode = 'human' | 'json'; +// A string is treated as a display timestamp only when the WHOLE value is a +// UTC ISO instant; embedded occurrences inside longer strings may be machine +// payloads and stay raw. Mirrored in container/agent-runner/src/cli/ncl.ts +// (the two runtimes share no modules). +const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?Z$/; + +/** + * Human display shows local time; --json keeps the ISO machine contract. + * The "YYYY-MM-DD HH:mm" stamp shape round-trips: parseZonedToUtc reads a + * naive string as local wall-clock time, so a value copied from `ncl tasks + * get` output into `--process-after` means what it shows. + */ +export function localizeIsoTimestamps(value: unknown): unknown { + if (typeof value === 'string') { + return ISO_UTC_RE.test(value) ? formatLocalStamp(new Date(value), TIMEZONE) : value; + } + if (Array.isArray(value)) return value.map(localizeIsoTimestamps); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([k, v]) => [k, localizeIsoTimestamps(v)]), + ); + } + return value; +} + export function formatResponse(res: ResponseFrame, mode: FormatMode): string { if (mode === 'json') return JSON.stringify(res, null, 2) + '\n'; @@ -21,7 +48,8 @@ export function formatResponse(res: ResponseFrame, mode: FormatMode): string { return formatHuman(res.data) + '\n'; } -function formatHuman(data: unknown): string { +function formatHuman(rawData: unknown): string { + const data = localizeIsoTimestamps(rawData); if (data === null || data === undefined) return ''; if (typeof data === 'string') return data; if (Array.isArray(data) && data.every(isFlatRecord)) { diff --git a/src/cli/resources/destinations.ts b/src/cli/resources/destinations.ts index 1d934f354..f44072914 100644 --- a/src/cli/resources/destinations.ts +++ b/src/cli/resources/destinations.ts @@ -110,9 +110,9 @@ registerResource({ getDb() .prepare( `INSERT INTO agent_destinations (agent_group_id, local_name, target_type, target_id, created_at) - VALUES (?, ?, ?, ?, datetime('now'))`, + VALUES (?, ?, ?, ?, ?)`, ) - .run(agentGroupId, localName, targetType, targetId); + .run(agentGroupId, localName, targetType, targetId, new Date().toISOString()); await projectDestinationsToSessions(agentGroupId); return { agent_group_id: agentGroupId, local_name: localName, target_type: targetType, target_id: targetId }; }, diff --git a/src/cli/resources/members.ts b/src/cli/resources/members.ts index 2a9343097..845bc668b 100644 --- a/src/cli/resources/members.ts +++ b/src/cli/resources/members.ts @@ -41,9 +41,9 @@ registerResource({ getDb() .prepare( `INSERT OR IGNORE INTO agent_group_members (user_id, agent_group_id, added_by, added_at) - VALUES (?, ?, ?, datetime('now'))`, + VALUES (?, ?, ?, ?)`, ) - .run(userId, groupId, addedBy); + .run(userId, groupId, addedBy, new Date().toISOString()); return { user_id: userId, agent_group_id: groupId }; }, }, diff --git a/src/cli/resources/roles.ts b/src/cli/resources/roles.ts index 9d51815e4..88398cf4d 100644 --- a/src/cli/resources/roles.ts +++ b/src/cli/resources/roles.ts @@ -41,9 +41,9 @@ registerResource({ getDb() .prepare( `INSERT OR IGNORE INTO user_roles (user_id, role, agent_group_id, granted_by, granted_at) - VALUES (?, ?, ?, ?, datetime('now'))`, + VALUES (?, ?, ?, ?, ?)`, ) - .run(userId, role, groupId, grantedBy); + .run(userId, role, groupId, grantedBy, new Date().toISOString()); return { user_id: userId, role, agent_group_id: groupId }; }, }, diff --git a/src/cli/resources/tasks.test.ts b/src/cli/resources/tasks.test.ts index bd0a5cd6e..14091a585 100644 --- a/src/cli/resources/tasks.test.ts +++ b/src/cli/resources/tasks.test.ts @@ -455,7 +455,8 @@ describe('tasks CLI resource', () => { expect(resp.ok).toBe(true); if (!resp.ok) return; const content = fs.readFileSync(logFile('ag-1', 'my-task-1'), 'utf8').trim(); - expect(content).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z — did the thing; it worked$/); + // Local-time stamp (formatLocalStamp): "YYYY-MM-DD HH:mm". + expect(content).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2} — did the thing; it worked$/); }); it('derives the series from the caller task session when --id is omitted', async () => { diff --git a/src/cli/resources/tasks.ts b/src/cli/resources/tasks.ts index 2d5f56579..35b171355 100644 --- a/src/cli/resources/tasks.ts +++ b/src/cli/resources/tasks.ts @@ -24,7 +24,7 @@ import { type TaskUpdate, } from '../../modules/scheduling/db.js'; import { inboundDbPath, resolveTaskSession, withInboundDb } from '../../session-manager.js'; -import { parseZonedToUtc } from '../../timezone.js'; +import { formatLocalStamp, parseZonedToUtc } from '../../timezone.js'; import { registerResource } from '../crud.js'; import { formatTasksTable } from '../format-tasks.js'; import type { CallerContext } from '../frame.js'; @@ -288,7 +288,7 @@ function createTask(args: Record, ctx: CallerContext) { `• MESSAGE (only if asked): if the task says to report/notify the user, send your result with an EXPLICIT destination — or send_message({ to: "name", … }). This run has no chat attached: an unaddressed reply is DISCARDED, so the explicit send is the ONLY thing the user receives.\n` + `• RUN LOG (ALWAYS — even if you sent no message and did nothing else this run): after any sends, end the run with:\n` + ` ncl tasks append-log --msg ""\n` + - ` Write it like a work-log entry a human keeps — concrete: what you did and WHY (a no-op run still gets a line saying why nothing was needed). If you wrote or modified files this run, name them in --msg. Not a greeting, not a copy of the message you sent. The host stamps the UTC time (do NOT add one), do NOT edit tasks/${id}.md by hand, and this NEVER goes to the user.\n` + + ` Write it like a work-log entry a human keeps — concrete: what you did and WHY (a no-op run still gets a line saying why nothing was needed). If you wrote or modified files this run, name them in --msg. Not a greeting, not a copy of the message you sent. The host stamps the local time (do NOT add one), do NOT edit tasks/${id}.md by hand, and this NEVER goes to the user.\n` + `Need context from past runs? Read tasks/${id}.md first.]`; const created = withInbound(session, (db) => { @@ -339,7 +339,7 @@ function appendTaskLog( const ag = getAgentGroup(group); if (!ag) throw new Error(`agent group not found: ${group}`); - const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); + const timestamp = formatLocalStamp(new Date(), TIMEZONE); const dir = `${GROUPS_DIR}/${ag.folder}/tasks`; const file = `${dir}/${series}.md`; fs.mkdirSync(dir, { recursive: true }); @@ -659,7 +659,7 @@ registerResource({ 'append-log': { access: 'open', description: - 'Append a one-line run summary to a task run log (tasks/.md).\n\nThe host stamps the UTC timestamp; you supply --msg. This is a LOG ENTRY, not a message — it sends nothing to anyone. Inside a task fire --id is auto-derived from your session. If you wrote or modified files during the run, name them in --msg.', + 'Append a one-line run summary to a task run log (tasks/.md).\n\nThe host stamps the local timestamp; you supply --msg. This is a LOG ENTRY, not a message — it sends nothing to anyone. Inside a task fire --id is auto-derived from your session. If you wrote or modified files during the run, name them in --msg.', examples: [ `# Inside a task fire (--id auto-derived) — the run's work-log line:\nncl tasks append-log --msg "posted the daily digest to slack; one feed returned 403, skipped"`, ], @@ -668,7 +668,7 @@ registerResource({ name: 'msg', type: 'string', description: - 'Your work-log entry: what you did and why it mattered (like a human work log). The host prepends the UTC timestamp; this is logged, never sent to the user.', + 'Your work-log entry: what you did and why it mattered (like a human work log). The host prepends the local timestamp; this is logged, never sent to the user.', required: true, }, { diff --git a/src/db/session-db.ts b/src/db/session-db.ts index 3424fce65..b5fbaf4bf 100644 --- a/src/db/session-db.ts +++ b/src/db/session-db.ts @@ -151,9 +151,8 @@ export function markMessageFailed(db: Database.Database, messageId: string): voi } export function retryWithBackoff(db: Database.Database, messageId: string, backoffSec: number): void { - db.prepare( - `UPDATE messages_in SET tries = tries + 1, process_after = datetime('now', '+${backoffSec} seconds') WHERE id = ?`, - ).run(messageId); + const processAfter = new Date(Date.now() + backoffSec * 1000).toISOString(); + db.prepare('UPDATE messages_in SET tries = tries + 1, process_after = ? WHERE id = ?').run(processAfter, messageId); } export function getMessageForRetry( @@ -261,7 +260,7 @@ export function getDueOutboundMessages(db: Database.Database): OutboundMessage[] return db .prepare( `SELECT * FROM messages_out - WHERE (deliver_after IS NULL OR deliver_after <= datetime('now')) + WHERE (deliver_after IS NULL OR datetime(deliver_after) <= datetime('now')) ORDER BY timestamp ASC`, ) .all() as OutboundMessage[]; @@ -281,14 +280,14 @@ export function getDeliveredIds(db: Database.Database): Set { export function markDelivered(db: Database.Database, messageOutId: string, platformMessageId: string | null): void { db.prepare( - "INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, ?, 'delivered', datetime('now'))", - ).run(messageOutId, platformMessageId ?? null); + "INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, ?, 'delivered', ?)", + ).run(messageOutId, platformMessageId ?? null, new Date().toISOString()); } export function markDeliveryFailed(db: Database.Database, messageOutId: string): void { db.prepare( - "INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, NULL, 'failed', datetime('now'))", - ).run(messageOutId); + "INSERT OR IGNORE INTO delivered (message_out_id, platform_message_id, status, delivered_at) VALUES (?, NULL, 'failed', ?)", + ).run(messageOutId, new Date().toISOString()); } /** Ensure the delivered table has columns added after initial schema. */ diff --git a/src/modules/scheduling/recurrence.ts b/src/modules/scheduling/recurrence.ts index 0ebecd5fe..833d4fdb1 100644 --- a/src/modules/scheduling/recurrence.ts +++ b/src/modules/scheduling/recurrence.ts @@ -18,6 +18,7 @@ import type Database from 'better-sqlite3'; import { CronExpressionParser } from 'cron-parser'; import { GROUPS_DIR, TIMEZONE } from '../../config.js'; +import { formatLocalStamp } from '../../timezone.js'; import { getAgentGroup } from '../../db/agent-groups.js'; import { log } from '../../log.js'; import type { Session } from '../../types.js'; @@ -44,7 +45,7 @@ function appendHostTaskNote(agentGroupId: string, seriesId: string, note: string const ag = getAgentGroup(agentGroupId); if (!ag) return; const dir = path.join(GROUPS_DIR, ag.folder, 'tasks'); - const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); + const timestamp = formatLocalStamp(new Date(), TIMEZONE); fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync(path.join(dir, `${seriesId}.md`), `${timestamp} — ${note}\n`); } diff --git a/src/session-manager.ts b/src/session-manager.ts index c8ca725a7..a4fb86479 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -426,8 +426,8 @@ export function writeOutboundDirect( try { db.prepare( `INSERT OR IGNORE INTO messages_out (id, seq, timestamp, kind, platform_id, channel_type, thread_id, content) - VALUES (?, (SELECT COALESCE(MAX(seq), 0) + 2 FROM messages_out), datetime('now'), ?, ?, ?, ?, ?)`, - ).run(message.id, message.kind, message.platformId, message.channelType, message.threadId, message.content); + VALUES (?, (SELECT COALESCE(MAX(seq), 0) + 2 FROM messages_out), ?, ?, ?, ?, ?, ?)`, + ).run(message.id, new Date().toISOString(), message.kind, message.platformId, message.channelType, message.threadId, message.content); } finally { db.close(); } diff --git a/src/timezone.ts b/src/timezone.ts index 603b7daae..a2809c083 100644 --- a/src/timezone.ts +++ b/src/timezone.ts @@ -36,6 +36,22 @@ export function formatLocalTime(utcIso: string, timezone: string): string { }); } +/** + * Compact sortable local stamp for log lines: "YYYY-MM-DD HH:mm" in `timezone`. + * (sv-SE is the one locale whose default rendering is this exact shape.) + */ +export function formatLocalStamp(date: Date, timezone: string): string { + return date.toLocaleString('sv-SE', { + timeZone: resolveTimezone(timezone), + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); +} + /** * Interpret a naive ISO-like timestamp (no trailing `Z`, no offset) as wall-clock * time in `tz` and return the corresponding UTC Date. Strings that already carry