Skip to content

Commit 99aa41e

Browse files
committed
feat(temporal): add experimental temporal_awareness flag
Give the agent wall-clock awareness across long-running sessions without busting prompt cache. When experimental.temporal_awareness is true (default false): * User messages are prefixed with HTML-comment gap markers showing time elapsed since the previous message's effective end: <!-- +12m --> under an hour <!-- +2h 15m --> under a day <!-- +3d 4h --> under a week <!-- +2w 3d --> week or more Zero-component forms elide to <!-- +2h --> etc. Gaps <= 5 min emit nothing. Previous anchor is any role, timed from time.completed if present else time.created, so an 8-minute assistant turn answered instantly produces no marker. * Compartments in <session-history> carry start-date and end-date attributes (YYYY-MM-DD) resolved from OpenCode message.time_created for the compartment's start_message_id and end_message_id. Separate from existing ordinal start=/end= attributes so no collision. * The injected Magic Context agent prompt gains a short paragraph explaining both markers (only when the flag is enabled). Cache-safe properties: * Gap markers derive from immutable message.time values. Idempotent across passes — pre-existing <!-- +Xm --> prefixes are detected via TEMPORAL_MARKER_PATTERN before insertion. * Marker sits after any §N§ tag prefix so tag round-trip stays stable. * Compartment date attributes are recomputed on each injection build from stored message IDs + DB times. The existing memory_block_cache behavior applies. * Historian input (read-session-chunk.ts) is unchanged — historian stays temporally neutral; time context only reaches live agent view. Retroactive: when the flag flips on, all live-tail user messages and compartments get markers/dates on the first transform pass. The cache bust on the flag-flip itself is expected. Verified: 712/712 tests pass, typecheck clean, 20 unit tests for the pure formatting and injection functions.
1 parent 38f6fdf commit 99aa41e

13 files changed

Lines changed: 624 additions & 4 deletions

packages/plugin/src/agents/magic-context-prompt.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,20 +225,24 @@ export function detectAgentFromSystemPrompt(systemPrompt: string): AgentType | n
225225
return null;
226226
}
227227

228+
const TEMPORAL_AWARENESS_GUIDANCE = `\n**Temporal awareness**: User messages may be preceded by HTML comments like \`<!-- +12m -->\`, \`<!-- +2h 15m -->\`, or \`<!-- +3d 4h -->\` indicating time elapsed since the previous message's completion. Compartments in \`<session-history>\` carry \`start-date\` and \`end-date\` attributes (YYYY-MM-DD) showing real-time boundaries. Use these when reasoning about workflow pacing, log durations, build times, or how long ago something happened.`;
229+
228230
export function buildMagicContextSection(
229231
agent: AgentType | null,
230232
protectedTags: number,
231233
ctxReduceEnabled = true,
232234
dreamerEnabled = false,
233235
dropToolStructure = true,
236+
temporalAwarenessEnabled = false,
234237
): string {
235238
const smartNoteGuidance = dreamerEnabled
236239
? `\nWhen \`surface_condition\` is provided with \`write\`, the note becomes a project-scoped smart note.\nThe dreamer evaluates smart note conditions during nightly runs and surfaces them when conditions are met.\nExample: \`ctx_note(action="write", content="Implement X because Y", surface_condition="When PR #42 is merged in this repo")\``
237240
: "";
241+
const temporalGuidance = temporalAwarenessEnabled ? TEMPORAL_AWARENESS_GUIDANCE : "";
238242

239243
if (!ctxReduceEnabled) {
240-
return `## Magic Context\n\n${BASE_INTRO_NO_REDUCE(dropToolStructure)}${smartNoteGuidance}`;
244+
return `## Magic Context\n\n${BASE_INTRO_NO_REDUCE(dropToolStructure)}${smartNoteGuidance}${temporalGuidance}`;
241245
}
242246
const section = agent ? AGENT_SECTIONS[agent] : GENERIC_SECTION;
243-
return `## Magic Context\n\n${BASE_INTRO(protectedTags, dropToolStructure)}${smartNoteGuidance}\n${section}\n\nPrefer many small targeted operations over one large blanket operation. Compress early and often — don't wait for warnings.`;
247+
return `## Magic Context\n\n${BASE_INTRO(protectedTags, dropToolStructure)}${smartNoteGuidance}${temporalGuidance}\n${section}\n\nPrefer many small targeted operations over one large blanket operation. Compress early and often — don't wait for warnings.`;
244248
}

packages/plugin/src/config/schema/magic-context.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ describe("MagicContextConfigSchema", () => {
8080
token_budget: 10000,
8181
min_reads: 4,
8282
},
83+
temporal_awareness: false,
8384
},
8485
embedding: {
8586
provider: "openai-compatible",

packages/plugin/src/config/schema/magic-context.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ export interface MagicContextConfig {
181181
/** Minimum full-read count before a file is considered for pinning (default: 4) */
182182
min_reads: number;
183183
};
184+
/** Inject elapsed-time markers between user messages and date ranges on
185+
* compartments so the agent has a wall-clock sense of the session. */
186+
temporal_awareness: boolean;
184187
};
185188
embedding: EmbeddingConfig;
186189
memory: {
@@ -346,10 +349,16 @@ export const MagicContextConfigSchema = z
346349
min_reads: z.number().min(2).max(20).default(4),
347350
})
348351
.default({ enabled: false, token_budget: 10000, min_reads: 4 }),
352+
/** Inject wall-clock gap markers (<!-- +Xm -->) between user messages
353+
* where > 5 min elapsed since the previous message, and add start/end
354+
* date attributes on compartments. Gives the agent a sense of session
355+
* pacing and "how long ago" across multi-day sessions. Default: false. */
356+
temporal_awareness: z.boolean().default(false),
349357
})
350358
.default({
351359
user_memories: { enabled: false, promotion_threshold: 3 },
352360
pin_key_files: { enabled: false, token_budget: 10000, min_reads: 4 },
361+
temporal_awareness: false,
353362
}),
354363
/** Cross-session memory configuration */
355364
memory: z

packages/plugin/src/features/magic-context/compartment-storage.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,10 +269,16 @@ export function replaceAllCompartmentState(
269269
})();
270270
}
271271

272+
export interface CompartmentDateRanges {
273+
/** Map compartment id → `{ start: "YYYY-MM-DD", end: "YYYY-MM-DD" }` */
274+
byId: Map<number, { start: string; end: string }>;
275+
}
276+
272277
export function buildCompartmentBlock(
273278
compartments: Compartment[],
274279
facts: SessionFact[],
275280
memoryBlock?: string,
281+
dateRanges?: CompartmentDateRanges,
276282
): string {
277283
const lines: string[] = [];
278284

@@ -282,8 +288,10 @@ export function buildCompartmentBlock(
282288
}
283289

284290
for (const c of compartments) {
291+
const dates = dateRanges?.byId.get(c.id);
292+
const dateAttr = dates ? ` start-date="${dates.start}" end-date="${dates.end}"` : "";
285293
lines.push(
286-
`<compartment start="${c.startMessage}" end="${c.endMessage}" title="${escapeXmlAttr(c.title)}">`,
294+
`<compartment start="${c.startMessage}" end="${c.endMessage}"${dateAttr} title="${escapeXmlAttr(c.title)}">`,
287295
);
288296
lines.push(escapeXmlContent(c.content));
289297
lines.push("</compartment>");

packages/plugin/src/hooks/magic-context/compartment-runner-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface CompartmentRunnerDeps {
2121
experimentalCompactionMarkers?: boolean;
2222
/** When true, extract user behavior observations from historian output */
2323
experimentalUserMemories?: boolean;
24+
/** When true, inject wall-clock dates on compartments in <session-history>. */
25+
experimentalTemporalAwareness?: boolean;
2426
/** When true, run an editor pass after successful historian output to clean
2527
* low-signal U: lines and cross-compartment duplicates. */
2628
historianTwoPass?: boolean;

packages/plugin/src/hooks/magic-context/hook.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ export interface MagicContextDeps {
9191
experimental?: {
9292
user_memories?: { enabled: boolean; promotion_threshold: number };
9393
pin_key_files?: { enabled: boolean; token_budget: number; min_reads: number };
94+
temporal_awareness?: boolean;
9495
};
9596
};
9697
}
@@ -259,6 +260,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
259260
projectPath,
260261
experimentalCompactionMarkers: deps.config.compaction_markers,
261262
experimentalUserMemories: deps.config.experimental?.user_memories?.enabled,
263+
experimentalTemporalAwareness: deps.config.experimental?.temporal_awareness === true,
262264
historianTwoPass: deps.config.historian?.two_pass === true,
263265
compressorMinCompartmentRatio:
264266
deps.config.compressor?.enabled === false
@@ -440,6 +442,7 @@ export function createMagicContextHook(deps: MagicContextDeps) {
440442
experimentalUserMemories: deps.config.experimental?.user_memories?.enabled,
441443
experimentalPinKeyFiles: deps.config.experimental?.pin_key_files?.enabled ?? false,
442444
experimentalPinKeyFilesTokenBudget: deps.config.experimental?.pin_key_files?.token_budget,
445+
experimentalTemporalAwareness: deps.config.experimental?.temporal_awareness === true,
443446
});
444447

445448
const eventHook = createEventHook({

packages/plugin/src/hooks/magic-context/inject-compartments.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Database } from "bun:sqlite";
22
import {
33
buildCompartmentBlock,
4+
type CompartmentDateRanges,
45
escapeXmlContent,
56
getCompartments,
67
getSessionFacts,
@@ -9,8 +10,10 @@ import { CATEGORY_PRIORITY } from "../../features/magic-context/memory/constants
910
import { getMemoriesByProject } from "../../features/magic-context/memory/storage-memory";
1011
import type { Memory, MemoryCategory } from "../../features/magic-context/memory/types";
1112
import { sessionLog } from "../../shared/logger";
13+
import { getMessageTimesFromOpenCodeDb } from "./read-session-db";
1214
import { estimateTokens } from "./read-session-formatting";
1315
import type { MessageLike } from "./tag-messages";
16+
import { formatDate } from "./temporal-awareness";
1417

1518
export interface PreparedCompartmentInjection {
1619
block: string;
@@ -162,6 +165,7 @@ export function prepareCompartmentInjection(
162165
isCacheBusting: boolean,
163166
projectPath?: string,
164167
injectionBudgetTokens?: number,
168+
temporalAwareness?: boolean,
165169
): PreparedCompartmentInjection | null {
166170
// On defer (cache-safe) passes, replay the cached injection result so that
167171
// historian publications between passes do not bust the prompt-cache prefix.
@@ -249,7 +253,27 @@ export function prepareCompartmentInjection(
249253
return null;
250254
}
251255

252-
const block = buildCompartmentBlock(compartments, facts, memoryBlock);
256+
let dateRanges: CompartmentDateRanges | undefined;
257+
if (temporalAwareness && compartments.length > 0) {
258+
// Resolve start/end message times from OpenCode's DB in a single batched query.
259+
const ids = new Set<string>();
260+
for (const c of compartments) {
261+
if (c.startMessageId) ids.add(c.startMessageId);
262+
if (c.endMessageId) ids.add(c.endMessageId);
263+
}
264+
const times = getMessageTimesFromOpenCodeDb(sessionId, Array.from(ids));
265+
const byId = new Map<number, { start: string; end: string }>();
266+
for (const c of compartments) {
267+
const startMs = times.get(c.startMessageId);
268+
const endMs = times.get(c.endMessageId);
269+
if (startMs !== undefined && endMs !== undefined) {
270+
byId.set(c.id, { start: formatDate(startMs), end: formatDate(endMs) });
271+
}
272+
}
273+
if (byId.size > 0) dateRanges = { byId };
274+
}
275+
276+
const block = buildCompartmentBlock(compartments, facts, memoryBlock, dateRanges);
253277

254278
// When there are no compartments yet (new session, or memories seeded before
255279
// historian first run), inject memories/facts without a boundary cutoff.

packages/plugin/src/hooks/magic-context/read-session-db.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,49 @@ interface AssistantModelRow {
7575
*
7676
* Returns null for brand-new sessions with no assistant turn yet.
7777
*/
78+
interface MessageTimeRow {
79+
id?: string;
80+
time_created?: number;
81+
}
82+
83+
/**
84+
* Resolve `time_created` (ms since epoch) for a set of OpenCode message IDs.
85+
* Returns a Map keyed by message ID. Missing IDs are simply omitted.
86+
*
87+
* Used by temporal-awareness to map compartment start/end message IDs to
88+
* wall-clock dates for the `start="YYYY-MM-DD"` / `end="YYYY-MM-DD"` attrs
89+
* on the `<compartment>` elements in `<session-history>`.
90+
*/
91+
export function getMessageTimesFromOpenCodeDb(
92+
sessionId: string,
93+
messageIds: readonly string[],
94+
): Map<string, number> {
95+
const result = new Map<string, number>();
96+
if (messageIds.length === 0) return result;
97+
98+
try {
99+
withReadOnlySessionDb((db) => {
100+
// SQLite limits on IN (?, ?, ...) are high (~999 by default) so a
101+
// single batched query is safe for any realistic compartment count.
102+
const placeholders = messageIds.map(() => "?").join(",");
103+
const rows = db
104+
.prepare(
105+
`SELECT id, time_created FROM message WHERE session_id = ? AND id IN (${placeholders})`,
106+
)
107+
.all(sessionId, ...messageIds) as MessageTimeRow[];
108+
for (const row of rows) {
109+
if (typeof row.id === "string" && typeof row.time_created === "number") {
110+
result.set(row.id, row.time_created);
111+
}
112+
}
113+
});
114+
} catch (error) {
115+
log("[magic-context] failed to resolve message times from OpenCode DB:", error);
116+
}
117+
118+
return result;
119+
}
120+
78121
export function findLastAssistantModelFromOpenCodeDb(
79122
sessionId: string,
80123
): { providerID: string; modelID: string } | null {

packages/plugin/src/hooks/magic-context/system-prompt-hash.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ export function createSystemPromptHashHandler(deps: {
8181
experimentalPinKeyFiles?: boolean;
8282
/** Token budget for key files injection (default 10000) */
8383
experimentalPinKeyFilesTokenBudget?: number;
84+
/** When true, add a temporal-awareness guidance paragraph + surface compartment dates */
85+
experimentalTemporalAwareness?: boolean;
8486
}): (input: { sessionID?: string }, output: { system: string[] }) => Promise<void> {
8587
// Per-session sticky date: we freeze the date string from the system prompt
8688
// and only update it on cache-busting passes. This prevents a midnight date
@@ -108,6 +110,7 @@ export function createSystemPromptHashHandler(deps: {
108110
deps.ctxReduceEnabled,
109111
deps.dreamerEnabled,
110112
deps.dropToolStructure,
113+
deps.experimentalTemporalAwareness,
111114
);
112115
output.system.push(guidance);
113116
sessionLog(

0 commit comments

Comments
 (0)