ambient context: give every project's model a clock
Some checks failed
CI/CD / lint (pull_request) Successful in 1m16s
CI/CD / test (pull_request) Successful in 1m26s
CI/CD / typecheck (pull_request) Successful in 3m25s
CI/CD / smoke (pull_request) Failing after 1m57s
CI/CD / build (pull_request) Successful in 5m9s
CI/CD / publish (pull_request) Has been skipped

Models consuming projects have no idea what day it is. Three always-on
layers from mcplocal plus one from mcpd:

- initialize instructions and the begin_session briefing carry
  '[context] now: <date/time> — project: <name>' (frozen at session
  start by design);
- a get_current_time virtual tool (ungated catalogs only, same pattern
  as read_prompts);
- every tools/call result gets a trailing '[context] now: …' content
  item at the endpoint send site — the only chokepoint that also covers
  virtual tools, gate intercepts and failure results, and the layer
  that keeps a days-old chat session current;
- mcpd agent/project chat system blocks append the same line each turn.

Time renders as UTC plus an optional local timezone from
MCPLOCAL_AMBIENT_TZ (IANA name; both services read it; invalid or unset
means UTC only). ~15 tokens per stamp, minute precision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-10-02 00:33:20 +01:00
parent f9ef4ebcb9
commit 979e9720c0
12 changed files with 538 additions and 4 deletions

View File

@@ -0,0 +1,93 @@
/**
* Ambient time rendering — gives models a clock.
*
* Models consuming projects have no idea what day it is, so mcplocal stamps a
* short context line into initialize instructions, the begin_session
* briefing, and every tools/call result, and mcpd appends it to agent system
* blocks. One env var, `MCPLOCAL_AMBIENT_TZ` (an IANA name like
* "Europe/London", read by BOTH services despite the mcplocal-flavoured
* name), adds a local rendering next to UTC; unset or invalid means UTC only.
*
* Minute precision on purpose: the line stays ~15 tokens and does not churn
* within a minute.
*/
export const AMBIENT_TZ_ENV = 'MCPLOCAL_AMBIENT_TZ';
let warnedInvalidTz: string | undefined;
/**
* The configured IANA timezone, or undefined for UTC-only rendering.
* Read at call time (not module load) so tests and config changes apply
* without a restart. An invalid name warns once and behaves as unset.
*/
export function ambientTz(env: NodeJS.ProcessEnv = process.env): string | undefined {
const tz = env[AMBIENT_TZ_ENV]?.trim();
if (!tz) return undefined;
try {
new Intl.DateTimeFormat('en-GB', { timeZone: tz });
return tz;
} catch {
if (warnedInvalidTz !== tz) {
warnedInvalidTz = tz;
console.warn(`[ambient-time] invalid ${AMBIENT_TZ_ENV} '${tz}', falling back to UTC`);
}
return undefined;
}
}
const pad = (n: number): string => String(n).padStart(2, '0');
function utcParts(now: Date): { date: string; time: string; weekday: string } {
const weekday = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][now.getUTCDay()] as string;
return {
weekday,
date: `${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}`,
time: `${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}`,
};
}
/**
* One line of "now", ~15 tokens:
* tz set: `Thu 2026-10-02 10:30 +01:00 Europe/London (09:30 UTC)`
* tz unset: `Thu 2026-10-02 09:30 UTC`
*/
export function formatAmbientNow(now: Date = new Date(), tz: string | undefined = ambientTz()): string {
const utc = utcParts(now);
if (!tz) return `${utc.weekday} ${utc.date} ${utc.time} UTC`;
const parts = new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
weekday: 'short',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZoneName: 'longOffset',
}).formatToParts(now);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
// "GMT+01:00" → "+01:00"; plain "GMT" (winter London) → "+00:00".
const rawOffset = get('timeZoneName');
const offset = rawOffset === 'GMT' ? '+00:00' : rawOffset.replace(/^(GMT|UTC)/, '');
// Intl can render "24:00" for midnight with hour12:false; normalize.
const hour = get('hour') === '24' ? '00' : get('hour');
return `${get('weekday')} ${get('year')}-${get('month')}-${get('day')} ${hour}:${get('minute')} ${offset} ${tz} (${utc.time} UTC)`;
}
/** The ambient context line stamped into instructions and tool results. */
export function ambientContextLine(now: Date = new Date()): string {
return `[context] now: ${formatAmbientNow(now)}`;
}
/** Multi-line body for the get_current_time virtual tool. */
export function formatCurrentTimeResult(now: Date = new Date()): string {
const tz = ambientTz();
const lines = [
`UTC: ${now.toISOString()}`,
...(tz ? [`${tz}: ${formatAmbientNow(now, tz)}`] : []),
`Unix epoch seconds: ${Math.floor(now.getTime() / 1000)}`,
];
return lines.join('\n');
}

View File

@@ -1,2 +1,2 @@
// Shared utility functions
// Will be expanded as tasks are implemented
export * from './ambient-time.js';