agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
274 lines • 11.9 kB
JavaScript
;
/**
* time/clock — the run clock: a declared, recorded input, never a hidden read.
*
* Pattern: normalize-or-refuse at a boundary (`core/runInput.ts`'s shape): the
* app's `.time({ zone })` and the run's `time: { now, zone, window }`
* are read here into one {@link TimeClock}, or refused by name.
* Role: L0 leaf (the time layer's one owner). Imports `instant.ts`,
* `zone.ts`, `range.ts`, `reader.ts` (the reader's check) and
* `resolve.ts` (the policy's) only. The Agent reads the two inputs
* (`Agent.run`, `Agent.resume`); seed stamps the clock once per turn
* (`rows.ts` · `ClockRow`); the ToolCalls resume door compares a
* resume's `time` with the kept clock ({@link clockChange}).
* Emits: N/A.
*
* ## The laws (time design § 4)
*
* - **The zone is per run; the builder's is a fallback.** The run's
* `time.zone` wins, `.time({ zone })` is used when the run names none, and
* with neither the zone is UNKNOWN (G15) — never the server's zone, never a
* guess: the clock records `zoneSource: 'unknown'` and spells instants in
* {@link UNKNOWN_ZONE_SPELLING} (UTC), every reading of the person's words
* waits on their zone (it is asked first), and the zone they answer holds
* for the turns after it (`zoneSource: 'answered'`).
* - **`now` is the app's, else the turn's start** — admitted as
* `nowSource: 'default'`, like any default nobody chose.
* - **Frozen across a pause.** The clock is written once per turn. A resume
* that passes a different `time` keeps the frozen clock and records what was
* passed ({@link clockChange}); it is not refused, because an app that
* passes the current time on every call is doing nothing wrong.
* - **A window set in a UI is a run input** (`time.window`), recorded with
* `source: 'control'` — the person's own typed value, never filed as an
* answer to an ask.
*
* @example
* ```ts
* const run = readRunTime({ now: '2026-10-09T15:40:00Z', zone: 'America/Los_Angeles' });
* const draft = draftClock(run.value, readTimeOptions({}).value!);
* // { now: '2026-10-09T15:40:00Z', zone: 'America/Los_Angeles', zoneSource: 'run' }
* completeClock(draft as ClockDraft, Date.parse('2026-10-09T15:40:02Z'));
* // { now: '2026-10-09T15:40:00Z', nowSource: 'app', zone: 'America/Los_Angeles', zoneSource: 'run' }
* ```
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.clockChange = exports.completeClock = exports.draftClock = exports.readTimeOptions = exports.readRunTime = exports.UNKNOWN_ZONE_SPELLING = void 0;
const instant_js_1 = require("./instant.js");
const range_js_1 = require("./range.js");
const zone_js_1 = require("./zone.js");
const reader_js_1 = require("./reader.js");
const resolveRecord_js_1 = require("./resolveRecord.js");
const ask_js_1 = require("./ask.js");
/**
* The zone a clock with no known zone spells its instants in (G15) — named
* as unknown wherever it is shown, never read as the person's.
*/
exports.UNKNOWN_ZONE_SPELLING = 'UTC';
// ─── Reading the two inputs ──────────────────────────────────────────────
const RUN_KEYS = ['now', 'zone', 'window'];
const OPTION_KEYS = ['zone', 'reader', 'policy', 'messages'];
function isPlainObject(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value))
return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function unknownKeys(value, allowed) {
return Object.keys(value).filter((k) => !allowed.includes(k));
}
/**
* A zone as the app WROTE it, once `zone.ts` · `isZoneName` accepts it — never
* `Intl`'s canonical form, which can be an older link the person did not name
* (`Asia/Kolkata` resolves to `Asia/Calcutta` on some runtimes). The record
* keeps spellings, as it keeps a passed `now`.
*/
function readZone(value) {
if ((0, zone_js_1.isZoneName)(value))
return { value };
return {
problem: `zone must be an IANA zone name such as 'America/Los_Angeles' or 'UTC' — ` +
`an abbreviation (PST) or a bare offset (+05:30) names no zone`,
};
}
function readNow(value) {
if (value instanceof Date) {
const ms = value.getTime();
// A Date's year can leave 0000–9999, which no instant spelling carries.
if (!Number.isFinite(ms) || (0, instant_js_1.instantOf)(new Date(ms).toISOString(), 'strict') === undefined) {
return { problem: 'now is an invalid Date' };
}
return { value: new Date(ms).toISOString() };
}
if ((0, instant_js_1.instantOf)(value, 'strict') !== undefined)
return { value: value };
return {
problem: `now must be an ISO 8601 instant with a zone (e.g. '2026-10-09T15:40:00Z' or ` +
`'2026-10-09T08:40:00-07:00') or a Date`,
};
}
/**
* The run's `time` input, checked. `undefined` reads as "nothing passed".
* Refused, by name: a non-object, an unknown key, a zone that is not an IANA
* name, a `now` that is not an instant with a zone, a `window` that is not two
* strict instants with `from` before `to`.
*/
function readRunTime(value) {
if (value === undefined)
return { value: undefined };
if (!isPlainObject(value))
return { problem: 'time must be an object — { now?, zone?, window? }' };
const extra = unknownKeys(value, RUN_KEYS);
if (extra.length > 0) {
return {
problem: `time takes { now?, zone?, window? } only — unknown key ${extra
.map((k) => `'${k}'`)
.join(', ')}`,
};
}
let now;
let zone;
if (value.now !== undefined) {
const read = readNow(value.now);
if ('problem' in read)
return read;
now = read.value;
}
if (value.zone !== undefined) {
const read = readZone(value.zone);
if ('problem' in read)
return { problem: `time.${read.problem}` };
zone = read.value;
}
if (value.window !== undefined && !(0, range_js_1.isTimeRange)(value.window)) {
return {
problem: 'time.window must be { from, to } — two ISO 8601 instants with a zone, from before to',
};
}
const window = value.window;
return {
value: {
...(now !== undefined && { now }),
...(zone !== undefined && { zone }),
...(window !== undefined && { window: { from: window.from, to: window.to } }),
},
};
}
exports.readRunTime = readRunTime;
/**
* The builder's `.time(options)`, checked. `undefined` reads as `{}` — the
* layer armed with no fallback zone, so every run names its own.
*/
function readTimeOptions(value) {
if (value === undefined)
return { value: {} };
if (!isPlainObject(value)) {
return { problem: 'options must be an object — { zone?, reader?, policy?, messages? }' };
}
const extra = unknownKeys(value, OPTION_KEYS);
if (extra.length > 0) {
return {
problem: `options take { zone?, reader?, policy?, messages? } — unknown key ${extra
.map((k) => `'${k}'`)
.join(', ')}`,
};
}
let zone;
if (value.zone !== undefined) {
const read = readZone(value.zone);
if ('problem' in read)
return read;
zone = read.value;
}
let messages;
if (value.messages !== undefined) {
const read = (0, ask_js_1.readTimeAskMessages)(value.messages);
if ('problem' in read)
return read;
messages = read.value;
}
if (value.reader === undefined) {
// A policy with no reader has nothing to choose among — it would look
// configured and do nothing.
if (value.policy !== undefined) {
return { problem: "policy chooses among a reader's readings — arm a reader with it" };
}
return {
value: { ...(zone !== undefined && { zone }), ...(messages !== undefined && { messages }) },
};
}
const issue = (0, reader_js_1.readerIssue)(value.reader);
if (issue !== undefined)
return { problem: issue };
const policy = (0, resolveRecord_js_1.readPolicy)(value.policy);
if (typeof policy === 'string')
return { problem: policy };
return {
value: {
...(zone !== undefined && { zone }),
reader: value.reader,
policy,
...(messages !== undefined && { messages }),
},
};
}
exports.readTimeOptions = readTimeOptions;
// ─── The clock ───────────────────────────────────────────────────────────
/**
* The turn's clock as far as the run's inputs decide it. With no zone from
* the run or the builder the zone is UNKNOWN (G15): `zoneSource: 'unknown'`,
* instants spelled in {@link UNKNOWN_ZONE_SPELLING} — never the server's
* zone, never a guess (seed reads a zone the person answered in an earlier
* turn, `stages/timeLayer.ts` · `stampClock`).
*/
function draftClock(run, options) {
const zone = run?.zone ?? options.zone;
return {
...(run?.now !== undefined && { now: run.now }),
zone: zone ?? exports.UNKNOWN_ZONE_SPELLING,
zoneSource: run?.zone !== undefined ? 'run' : zone !== undefined ? 'builder' : 'unknown',
...(run?.window !== undefined && { window: run.window }),
};
}
exports.draftClock = draftClock;
/**
* The clock, once the turn's start is known: `now` is the app's, else that
* start. `answeredZone` — the zone the person answered in an earlier turn —
* is read only when the draft's zone is unknown, and is then the turn's zone
* (`zoneSource: 'answered'`).
*/
function completeClock(draft, turnStartMs, answeredZone) {
// Fixed width (`toISOString`, milliseconds always written) — the spelling
// `rowsBuild.ts` · `callRow` gives `dispatchedAt`, so the two wall-clock reads
// compare as text too.
const now = draft.now ?? new Date(turnStartMs).toISOString();
const answered = draft.zoneSource === 'unknown' ? answeredZone : undefined;
return {
now,
nowSource: draft.now !== undefined ? 'app' : 'default',
zone: answered ?? draft.zone,
zoneSource: answered !== undefined ? 'answered' : draft.zoneSource,
};
}
exports.completeClock = completeClock;
/**
* The difference between a resume's `time` and the turn's frozen clock, or
* `undefined` when every value passed equals the kept one (a key not passed
* is no difference). `now` compares as text: a resume passing the same
* instant in another spelling is recorded, because the record keeps spellings.
*
* @example
* ```ts
* clockChange({ now: '2026-10-09T16:10:00Z' }, { now: '2026-10-09T15:40:00Z', zone: 'UTC' });
* // { passed: { now: '2026-10-09T16:10:00Z' }, kept: { now: '2026-10-09T15:40:00Z', zone: 'UTC' } }
* ```
*/
function clockChange(passed, kept) {
const nowDiffers = passed.now !== undefined && passed.now !== kept.now;
const zoneDiffers = passed.zone !== undefined && passed.zone !== kept.zone;
const windowDiffers = passed.window !== undefined &&
(kept.window === undefined ||
passed.window.from !== kept.window.from ||
passed.window.to !== kept.window.to);
if (!nowDiffers && !zoneDiffers && !windowDiffers)
return undefined;
return {
passed,
kept: {
now: kept.now,
zone: kept.zone,
...(kept.window !== undefined && { window: kept.window }),
},
};
}
exports.clockChange = clockChange;
//# sourceMappingURL=clock.js.map