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
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:
93
src/shared/src/utils/ambient-time.ts
Normal file
93
src/shared/src/utils/ambient-time.ts
Normal 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');
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
// Shared utility functions
|
||||
// Will be expanded as tasks are implemented
|
||||
export * from './ambient-time.js';
|
||||
|
||||
Reference in New Issue
Block a user