agent-retrieval.mjs
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠ breaking
1 day ago
| 1 | /** |
| 2 | * Agent calendar context retrieval with server-side tier enforcement |
| 3 | * (Calendar Events v0 — Phase 1E). |
| 4 | * |
| 5 | * This is the ONLY path through which agents may read calendar context. It |
| 6 | * enforces, server-side and independently of any client toggle: |
| 7 | * |
| 8 | * 1. `enabled_for_agents` — a calendar with agents disabled contributes |
| 9 | * nothing, regardless of the requested tier. |
| 10 | * 2. `agent_context_tier_max` — the per-calendar cap a user set. |
| 11 | * 3. Org policy cap — `KNOWTATION_CALENDAR_AGENT_TIER_MAX_CAP` / |
| 12 | * `hub_calendar_policy.json` may further lower the ceiling (minors, |
| 13 | * classrooms). |
| 14 | * 4. v0 ceiling — retrieval exposes tiers 0–2 only. Tier 3 (linked notes) |
| 15 | * and tier 4 (location/description) are deferred and never returned here. |
| 16 | * |
| 17 | * Agent visibility is intentionally independent of `enabled_for_display`: |
| 18 | * "Show on timeline" and "Include in agent context" are separate switches |
| 19 | * (security checklist #7). Calendar text is untrusted prompt content; redaction |
| 20 | * happens here, never on the client. |
| 21 | * |
| 22 | * @see docs/CALENDAR-EVENTS-V0-SPEC.md — Agent Context Tiers, Security Gates |
| 23 | */ |
| 24 | |
| 25 | import { getVaultCalendarStore, queryStoredEvents } from './event-store.mjs'; |
| 26 | import { redactEventForAgentTier } from './agent-context-tier.mjs'; |
| 27 | import { readCalendarAgentTierCap } from './calendar-policy.mjs'; |
| 28 | import { parseTimelineRange, parseSourceCalendarIds } from './timeline.mjs'; |
| 29 | |
| 30 | /** @typedef {import('./source-calendar-defaults.mjs').AgentContextTier} AgentContextTier */ |
| 31 | /** @typedef {import('./agent-context-tier.mjs').AgentVisibleCalendarEvent} AgentVisibleCalendarEvent */ |
| 32 | |
| 33 | /** |
| 34 | * Maximum tier the v0 retrieval API will ever expose. Tiers 3–4 require a |
| 35 | * separate consent gate and are not implemented in retrieval yet. |
| 36 | * @type {2} |
| 37 | */ |
| 38 | export const AGENT_RETRIEVAL_MAX_TIER = 2; |
| 39 | |
| 40 | /** |
| 41 | * @typedef {Object} AgentContextCalendarSummary |
| 42 | * @property {string} source_calendar_id |
| 43 | * @property {string} display_name |
| 44 | * @property {'personal'|'work'|'school'|'other'|null} user_group |
| 45 | * @property {boolean} enabled_for_agents |
| 46 | * @property {AgentContextTier} agent_context_tier_max — user-set per-calendar cap |
| 47 | * @property {0|1|2} effective_tier — tier actually applied after all caps |
| 48 | * @property {number} event_count — events contributed at the effective tier |
| 49 | */ |
| 50 | |
| 51 | /** |
| 52 | * @typedef {AgentVisibleCalendarEvent & { event_id: string, source_calendar_id: string, agent_tier: 1|2 }} AgentContextEventItem |
| 53 | */ |
| 54 | |
| 55 | /** |
| 56 | * @typedef {Object} AgentContextResult |
| 57 | * @property {'knowtation.calendar_agent_context/v0'} schema |
| 58 | * @property {string} vault_id |
| 59 | * @property {string} from — YYYY-MM-DD |
| 60 | * @property {string} to — YYYY-MM-DD |
| 61 | * @property {0|1|2} requested_tier |
| 62 | * @property {0|1|2} effective_tier — requested tier after org policy clamp |
| 63 | * @property {AgentContextTier} policy_agent_context_tier_max_cap |
| 64 | * @property {AgentContextCalendarSummary[]} source_calendars — calendars in scope (agent-enabled) |
| 65 | * @property {AgentContextEventItem[]} items — redacted, tier-enforced events |
| 66 | */ |
| 67 | |
| 68 | /** |
| 69 | * Validate and normalize the requested agent context tier for v0 retrieval. |
| 70 | * |
| 71 | * @param {unknown} raw |
| 72 | * @returns {0|1|2} |
| 73 | */ |
| 74 | export function parseAgentContextTier(raw) { |
| 75 | const value = typeof raw === 'string' ? Number.parseInt(raw.trim(), 10) : raw; |
| 76 | if (!Number.isInteger(value)) { |
| 77 | throw new RangeError('agent_context_tier must be an integer 0, 1, or 2'); |
| 78 | } |
| 79 | if (value < 0 || value > AGENT_RETRIEVAL_MAX_TIER) { |
| 80 | throw new RangeError( |
| 81 | `agent_context_tier must be 0, 1, or 2 (v0 retrieval ceiling is ${AGENT_RETRIEVAL_MAX_TIER})`, |
| 82 | ); |
| 83 | } |
| 84 | return /** @type {0|1|2} */ (value); |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Compute the tier actually applied to one source calendar after enforcing the |
| 89 | * agent toggle, the per-calendar cap, the org policy cap, and the requested |
| 90 | * tier. Fails closed to 0 (no fields) whenever the calendar is not agent-enabled. |
| 91 | * |
| 92 | * @param {{ enabled_for_agents: boolean, agent_context_tier_max: AgentContextTier }} calendar |
| 93 | * @param {0|1|2} requestedTier — already clamped by org policy |
| 94 | * @returns {0|1|2} |
| 95 | */ |
| 96 | export function resolveEffectiveTier(calendar, requestedTier) { |
| 97 | if (!calendar.enabled_for_agents) { |
| 98 | return 0; |
| 99 | } |
| 100 | const capped = Math.min(requestedTier, calendar.agent_context_tier_max, AGENT_RETRIEVAL_MAX_TIER); |
| 101 | return /** @type {0|1|2} */ (capped < 0 ? 0 : capped); |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * Retrieve redacted, tier-enforced calendar context for an agent. |
| 106 | * |
| 107 | * @param {string} dataDir |
| 108 | * @param {string} vaultId |
| 109 | * @param {{ |
| 110 | * from: string, |
| 111 | * to: string, |
| 112 | * agentContextTier: unknown, |
| 113 | * sourceCalendarIds?: unknown, |
| 114 | * }} query |
| 115 | * @returns {AgentContextResult} |
| 116 | */ |
| 117 | export function retrieveAgentCalendarContext(dataDir, vaultId, query) { |
| 118 | const range = parseTimelineRange(query.from, query.to); |
| 119 | const requestedTier = parseAgentContextTier(query.agentContextTier); |
| 120 | const requestedIds = parseSourceCalendarIds(query.sourceCalendarIds); |
| 121 | |
| 122 | const policyCap = readCalendarAgentTierCap(dataDir); |
| 123 | // Org policy can only lower the ceiling; clamp requested tier to it. |
| 124 | const effectiveRequested = /** @type {0|1|2} */ ( |
| 125 | Math.min(requestedTier, policyCap, AGENT_RETRIEVAL_MAX_TIER) |
| 126 | ); |
| 127 | |
| 128 | const vaultStore = getVaultCalendarStore(dataDir, vaultId); |
| 129 | const requestedIdSet = requestedIds ? new Set(requestedIds) : null; |
| 130 | |
| 131 | /** @type {Map<string, { calendar: import('./event-store.mjs').StoredSourceCalendar, tier: 0|1|2 }>} */ |
| 132 | const scopedById = new Map(); |
| 133 | /** @type {AgentContextCalendarSummary[]} */ |
| 134 | const summaries = []; |
| 135 | |
| 136 | for (const calendar of vaultStore.source_calendars) { |
| 137 | if (requestedIdSet && !requestedIdSet.has(calendar.source_calendar_id)) { |
| 138 | continue; |
| 139 | } |
| 140 | if (!calendar.enabled_for_agents) { |
| 141 | continue; |
| 142 | } |
| 143 | const tier = resolveEffectiveTier(calendar, effectiveRequested); |
| 144 | scopedById.set(calendar.source_calendar_id, { calendar, tier }); |
| 145 | summaries.push({ |
| 146 | source_calendar_id: calendar.source_calendar_id, |
| 147 | display_name: calendar.display_name, |
| 148 | user_group: calendar.user_group ?? null, |
| 149 | enabled_for_agents: calendar.enabled_for_agents, |
| 150 | agent_context_tier_max: calendar.agent_context_tier_max, |
| 151 | effective_tier: tier, |
| 152 | event_count: 0, |
| 153 | }); |
| 154 | } |
| 155 | |
| 156 | /** @type {AgentContextEventItem[]} */ |
| 157 | const items = []; |
| 158 | |
| 159 | if (scopedById.size > 0 && effectiveRequested > 0) { |
| 160 | // Agent visibility is independent of enabled_for_display. |
| 161 | const events = queryStoredEvents(dataDir, vaultId, { |
| 162 | fromIso: range.fromIso, |
| 163 | toIso: range.toIso, |
| 164 | sourceCalendarIds: [...scopedById.keys()], |
| 165 | displayOnly: false, |
| 166 | }); |
| 167 | |
| 168 | const summaryById = new Map(summaries.map((s) => [s.source_calendar_id, s])); |
| 169 | |
| 170 | for (const event of events) { |
| 171 | const scoped = scopedById.get(event.source_calendar_id); |
| 172 | if (!scoped || scoped.tier === 0) { |
| 173 | continue; |
| 174 | } |
| 175 | const redacted = redactEventForAgentTier(event, scoped.tier, { |
| 176 | calendar_label: scoped.calendar.display_name, |
| 177 | }); |
| 178 | if (!redacted) { |
| 179 | continue; |
| 180 | } |
| 181 | items.push({ |
| 182 | ...redacted, |
| 183 | event_id: event.event_id, |
| 184 | source_calendar_id: event.source_calendar_id, |
| 185 | agent_tier: /** @type {1|2} */ (scoped.tier), |
| 186 | }); |
| 187 | const summary = summaryById.get(event.source_calendar_id); |
| 188 | if (summary) { |
| 189 | summary.event_count += 1; |
| 190 | } |
| 191 | } |
| 192 | } |
| 193 | |
| 194 | items.sort((a, b) => { |
| 195 | const cmp = a.start.localeCompare(b.start); |
| 196 | if (cmp !== 0) return cmp; |
| 197 | return a.event_id.localeCompare(b.event_id); |
| 198 | }); |
| 199 | |
| 200 | return { |
| 201 | schema: 'knowtation.calendar_agent_context/v0', |
| 202 | vault_id: vaultId, |
| 203 | from: range.fromDate, |
| 204 | to: range.toDate, |
| 205 | requested_tier: requestedTier, |
| 206 | effective_tier: effectiveRequested, |
| 207 | policy_agent_context_tier_max_cap: policyCap, |
| 208 | source_calendars: summaries, |
| 209 | items, |
| 210 | }; |
| 211 | } |
File History
1 commit
sha256:65ccb454656ea5acdea0a10e559b78bcde1eb6ff753ecc2911bc99d1c3d7cadd
feat(calendar): enforce agent context tiers in retrieval AP…
Human
minor
⚠
1 day ago