agent-retrieval.mjs
211 lines 7.7 KB
Raw
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