@aitne/shared 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/advisor-models.d.ts +34 -0
- package/dist/advisor-models.d.ts.map +1 -0
- package/dist/advisor-models.js +39 -0
- package/dist/advisor-models.js.map +1 -0
- package/dist/agent-identity.d.ts +11 -0
- package/dist/agent-identity.d.ts.map +1 -0
- package/dist/agent-identity.js +29 -0
- package/dist/agent-identity.js.map +1 -0
- package/dist/alerts.d.ts +44 -0
- package/dist/alerts.d.ts.map +1 -0
- package/dist/alerts.js +12 -0
- package/dist/alerts.js.map +1 -0
- package/dist/backend-api-key-config.d.ts +337 -0
- package/dist/backend-api-key-config.d.ts.map +1 -0
- package/dist/backend-api-key-config.js +682 -0
- package/dist/backend-api-key-config.js.map +1 -0
- package/dist/backend.d.ts +93 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +22 -0
- package/dist/backend.js.map +1 -0
- package/dist/branding.d.ts +96 -0
- package/dist/branding.d.ts.map +1 -0
- package/dist/branding.js +102 -0
- package/dist/branding.js.map +1 -0
- package/dist/chat-session-scope.d.ts +14 -0
- package/dist/chat-session-scope.d.ts.map +1 -0
- package/dist/chat-session-scope.js +18 -0
- package/dist/chat-session-scope.js.map +1 -0
- package/dist/date-utils.d.ts +80 -0
- package/dist/date-utils.d.ts.map +1 -0
- package/dist/date-utils.js +187 -0
- package/dist/date-utils.js.map +1 -0
- package/dist/docs-frontmatter.d.ts +51 -0
- package/dist/docs-frontmatter.d.ts.map +1 -0
- package/dist/docs-frontmatter.js +184 -0
- package/dist/docs-frontmatter.js.map +1 -0
- package/dist/docs-schema.d.ts +79 -0
- package/dist/docs-schema.d.ts.map +1 -0
- package/dist/docs-schema.js +135 -0
- package/dist/docs-schema.js.map +1 -0
- package/dist/editable-config-keys.d.ts +14 -0
- package/dist/editable-config-keys.d.ts.map +1 -0
- package/dist/editable-config-keys.js +157 -0
- package/dist/editable-config-keys.js.map +1 -0
- package/dist/exec-with-stdin.d.ts +14 -0
- package/dist/exec-with-stdin.d.ts.map +1 -0
- package/dist/exec-with-stdin.js +35 -0
- package/dist/exec-with-stdin.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +49 -0
- package/dist/index.js.map +1 -0
- package/dist/integrations-snapshot.d.ts +183 -0
- package/dist/integrations-snapshot.d.ts.map +1 -0
- package/dist/integrations-snapshot.js +757 -0
- package/dist/integrations-snapshot.js.map +1 -0
- package/dist/integrations.d.ts +675 -0
- package/dist/integrations.d.ts.map +1 -0
- package/dist/integrations.js +1656 -0
- package/dist/integrations.js.map +1 -0
- package/dist/keychain-helper-client.d.ts +31 -0
- package/dist/keychain-helper-client.d.ts.map +1 -0
- package/dist/keychain-helper-client.js +105 -0
- package/dist/keychain-helper-client.js.map +1 -0
- package/dist/log-entry.d.ts +14 -0
- package/dist/log-entry.d.ts.map +1 -0
- package/dist/log-entry.js +2 -0
- package/dist/log-entry.js.map +1 -0
- package/dist/management-domains.d.ts +369 -0
- package/dist/management-domains.d.ts.map +1 -0
- package/dist/management-domains.js +499 -0
- package/dist/management-domains.js.map +1 -0
- package/dist/process-key.d.ts +67 -0
- package/dist/process-key.d.ts.map +1 -0
- package/dist/process-key.js +366 -0
- package/dist/process-key.js.map +1 -0
- package/dist/schemas.d.ts +267 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +271 -0
- package/dist/schemas.js.map +1 -0
- package/dist/secret-client-factory.d.ts +16 -0
- package/dist/secret-client-factory.d.ts.map +1 -0
- package/dist/secret-client-factory.js +111 -0
- package/dist/secret-client-factory.js.map +1 -0
- package/dist/secret-client-file.d.ts +51 -0
- package/dist/secret-client-file.d.ts.map +1 -0
- package/dist/secret-client-file.js +160 -0
- package/dist/secret-client-file.js.map +1 -0
- package/dist/secret-client-linux.d.ts +26 -0
- package/dist/secret-client-linux.d.ts.map +1 -0
- package/dist/secret-client-linux.js +63 -0
- package/dist/secret-client-linux.js.map +1 -0
- package/dist/secret-client-windows.d.ts +37 -0
- package/dist/secret-client-windows.d.ts.map +1 -0
- package/dist/secret-client-windows.js +82 -0
- package/dist/secret-client-windows.js.map +1 -0
- package/dist/secret-redaction.d.ts +3 -0
- package/dist/secret-redaction.d.ts.map +1 -0
- package/dist/secret-redaction.js +31 -0
- package/dist/secret-redaction.js.map +1 -0
- package/dist/skill-curation/decision-language.d.ts +6 -0
- package/dist/skill-curation/decision-language.d.ts.map +1 -0
- package/dist/skill-curation/decision-language.js +38 -0
- package/dist/skill-curation/decision-language.js.map +1 -0
- package/dist/skill-curation/schemas.d.ts +461 -0
- package/dist/skill-curation/schemas.d.ts.map +1 -0
- package/dist/skill-curation/schemas.js +211 -0
- package/dist/skill-curation/schemas.js.map +1 -0
- package/dist/types.d.ts +204 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +54 -0
- package/dist/types.js.map +1 -0
- package/package.json +50 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { INTEGRATION_KEYS } from "./integrations.js";
|
|
3
|
+
/**
|
|
4
|
+
* Default TTL for `integration_writes` entries, per integration.
|
|
5
|
+
*
|
|
6
|
+
* INTEGRATION-DRIFT-DETECTION-PLAN.md §17.11 requires `TTL ≥ slowest_cadence
|
|
7
|
+
* × 1.5` so an agent-originated write at T0 cannot have its attribution
|
|
8
|
+
* mark expire before the next reconcile sees it as a user write. Phase 7
|
|
9
|
+
* (c) tightens these constants to cover the §8.3 default cadences with
|
|
10
|
+
* margin:
|
|
11
|
+
*
|
|
12
|
+
* - google_calendar: slowest cadence is the 24h-window default (60 min)
|
|
13
|
+
* → 60 × 1.5 = 90 min. The 10-min imminent cadence is irrelevant for
|
|
14
|
+
* TTL because an agent-originated event always lands inside the 24h
|
|
15
|
+
* window first; the slowest cadence governs the contract.
|
|
16
|
+
* - gmail: 30 min default → 45 min. The 15-min soft floor gives the
|
|
17
|
+
* same TTL margin if the operator presses cadence to its minimum.
|
|
18
|
+
* - notion: 60 min default → 90 min. Pre-Phase-7 this was 30 min, which
|
|
19
|
+
* left the agent's own page edits at risk of mis-attribution on the
|
|
20
|
+
* very next worker tick.
|
|
21
|
+
*
|
|
22
|
+
* Operators who push a cadence past the corresponding TTL via
|
|
23
|
+
* `runtime_state.delegatedSync.intervals` opt into the pre-fix regime.
|
|
24
|
+
* The DelegatedSyncWorker logs warn at start and surfaces the violation
|
|
25
|
+
* via `getStatus().ttlContractViolations` so the breach is observable
|
|
26
|
+
* rather than silent.
|
|
27
|
+
*/
|
|
28
|
+
export const INTEGRATION_WRITE_TTL_MS = {
|
|
29
|
+
google_calendar: 90 * 60 * 1000,
|
|
30
|
+
gmail: 45 * 60 * 1000,
|
|
31
|
+
notion: 90 * 60 * 1000,
|
|
32
|
+
// Git/GitHub do not currently participate in delegated drift snapshots,
|
|
33
|
+
// but IntegrationKey is intentionally exhaustive. Keep a conservative
|
|
34
|
+
// value so generic write-tracker callers never fall through to undefined.
|
|
35
|
+
git: 90 * 60 * 1000,
|
|
36
|
+
github: 90 * 60 * 1000,
|
|
37
|
+
// SETUP-FLOW-REDESIGN-PLAN §6.1 — Outlook mail goes through the unified
|
|
38
|
+
// mail poller, same cadence floor as Gmail. Outlook calendar has no
|
|
39
|
+
// poller in v1 (`observersTouched: []`); the value is set defensively so
|
|
40
|
+
// callers never see an undefined TTL if a snapshot path is ever wired
|
|
41
|
+
// up.
|
|
42
|
+
outlook_mail: 45 * 60 * 1000,
|
|
43
|
+
outlook_calendar: 90 * 60 * 1000,
|
|
44
|
+
};
|
|
45
|
+
function asString(v) {
|
|
46
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
47
|
+
}
|
|
48
|
+
function extractTime(field) {
|
|
49
|
+
if (typeof field === "string")
|
|
50
|
+
return field.length > 0 ? field : null;
|
|
51
|
+
if (field == null)
|
|
52
|
+
return null;
|
|
53
|
+
if (typeof field !== "object")
|
|
54
|
+
return null;
|
|
55
|
+
const dateTime = asString(field.dateTime);
|
|
56
|
+
if (dateTime !== null)
|
|
57
|
+
return dateTime;
|
|
58
|
+
return asString(field.date);
|
|
59
|
+
}
|
|
60
|
+
function normalizeTimeForRange(value) {
|
|
61
|
+
if (value === null)
|
|
62
|
+
return null;
|
|
63
|
+
const ms = Date.parse(value);
|
|
64
|
+
if (!Number.isFinite(ms))
|
|
65
|
+
return null;
|
|
66
|
+
return new Date(ms).toISOString();
|
|
67
|
+
}
|
|
68
|
+
function normalizeAttendees(raw) {
|
|
69
|
+
if (!Array.isArray(raw))
|
|
70
|
+
return [];
|
|
71
|
+
const out = [];
|
|
72
|
+
for (const a of raw) {
|
|
73
|
+
if (a == null || typeof a !== "object")
|
|
74
|
+
continue;
|
|
75
|
+
const email = asString(a.email);
|
|
76
|
+
if (email === null)
|
|
77
|
+
continue;
|
|
78
|
+
const responseStatus = asString(a.responseStatus)
|
|
79
|
+
?? "needsAction";
|
|
80
|
+
out.push({ email, responseStatus });
|
|
81
|
+
}
|
|
82
|
+
// Sort by email so two responses with the same set produce the same
|
|
83
|
+
// hash regardless of API ordering. Stable enough — duplicate-email
|
|
84
|
+
// entries (Google occasionally yields these) preserve their relative
|
|
85
|
+
// order via Array.prototype.sort's algorithmic stability.
|
|
86
|
+
out.sort((a, b) => (a.email < b.email ? -1 : a.email > b.email ? 1 : 0));
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Recurring-event instance keys. Per §5.2 + §13: instance-level snapshots
|
|
91
|
+
* keyed as `${seriesId}@${start}` keep instance edits visible even when
|
|
92
|
+
* Google occasionally rewrites `recurringEventId`. A non-recurring event
|
|
93
|
+
* has no `recurringEventId`, so we fall back to the raw id.
|
|
94
|
+
*/
|
|
95
|
+
function calendarItemId(raw) {
|
|
96
|
+
const id = asString(raw.id);
|
|
97
|
+
if (id === null) {
|
|
98
|
+
// Defensive — Google's API never omits id on a real event. Throw
|
|
99
|
+
// with a recognisable error so the route handler returns a precise
|
|
100
|
+
// 400 instead of silently storing an empty key.
|
|
101
|
+
throw new Error("calendar event missing id");
|
|
102
|
+
}
|
|
103
|
+
const series = asString(raw.recurringEventId);
|
|
104
|
+
if (series === null)
|
|
105
|
+
return id;
|
|
106
|
+
const start = extractTime(raw.start);
|
|
107
|
+
return start === null ? id : `${series}@${start}`;
|
|
108
|
+
}
|
|
109
|
+
function calendarPayload(raw) {
|
|
110
|
+
return {
|
|
111
|
+
summary: asString(raw.summary),
|
|
112
|
+
start: extractTime(raw.start),
|
|
113
|
+
end: extractTime(raw.end),
|
|
114
|
+
location: asString(raw.location),
|
|
115
|
+
description: normalizeWhitespace(asString(raw.description)),
|
|
116
|
+
status: asString(raw.status),
|
|
117
|
+
attendees: normalizeAttendees(raw.attendees),
|
|
118
|
+
htmlLink: asString(raw.htmlLink),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* INTEGRATION-DRIFT-PHASE-7-PLAN.md §3.4 — collapse runs of whitespace and
|
|
123
|
+
* convert non-breaking-space (U+00A0) to regular space before hashing the
|
|
124
|
+
* calendar description. Google occasionally re-wraps long descriptions or
|
|
125
|
+
* substitutes NBSP into the body; without normalization the hash flaps on
|
|
126
|
+
* an unmodified event and surfaces as a phantom `modified` diff every
|
|
127
|
+
* poll. Returns null for empty input so the canonical payload still
|
|
128
|
+
* distinguishes "no description" from "all-whitespace description".
|
|
129
|
+
*/
|
|
130
|
+
function normalizeWhitespace(value) {
|
|
131
|
+
if (value === null)
|
|
132
|
+
return null;
|
|
133
|
+
const collapsed = value.replace(/ /g, " ").replace(/\s+/g, " ").trim();
|
|
134
|
+
return collapsed.length > 0 ? collapsed : null;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Hash discipline: serialize a stable, sorted JSON encoding of the fields
|
|
138
|
+
* that semantically constitute the event. `htmlLink` is excluded — it is
|
|
139
|
+
* derivable from the id, and Google occasionally rewrites the path
|
|
140
|
+
* fragment. `updated`, `etag`, `iCalUID`, and snippet-like server-generated
|
|
141
|
+
* fields are likewise excluded so the hash does not flap on every poll.
|
|
142
|
+
*/
|
|
143
|
+
function calendarHashableShape(p) {
|
|
144
|
+
return {
|
|
145
|
+
attendees: p.attendees,
|
|
146
|
+
description: p.description,
|
|
147
|
+
end: p.end,
|
|
148
|
+
location: p.location,
|
|
149
|
+
start: p.start,
|
|
150
|
+
status: p.status,
|
|
151
|
+
summary: p.summary,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Lexicographic-key JSON encoder. Drop-in replacement for JSON.stringify
|
|
156
|
+
* that walks objects with a sorted key list at every level so two
|
|
157
|
+
* structurally-equal payloads always serialize identically. Arrays
|
|
158
|
+
* preserve order — the calendar normalizer pre-sorts attendees so
|
|
159
|
+
* `[{email:"a"}, {email:"b"}]` and `[{email:"b"}, {email:"a"}]` are
|
|
160
|
+
* distinct payloads pre-normalize but identical post-normalize.
|
|
161
|
+
*/
|
|
162
|
+
export function stableStringify(value) {
|
|
163
|
+
if (value === null || typeof value !== "object") {
|
|
164
|
+
return JSON.stringify(value);
|
|
165
|
+
}
|
|
166
|
+
if (Array.isArray(value)) {
|
|
167
|
+
return `[${value.map((v) => stableStringify(v)).join(",")}]`;
|
|
168
|
+
}
|
|
169
|
+
const keys = Object.keys(value).sort();
|
|
170
|
+
const parts = [];
|
|
171
|
+
for (const k of keys) {
|
|
172
|
+
const v = value[k];
|
|
173
|
+
if (v === undefined)
|
|
174
|
+
continue;
|
|
175
|
+
parts.push(`${JSON.stringify(k)}:${stableStringify(v)}`);
|
|
176
|
+
}
|
|
177
|
+
return `{${parts.join(",")}}`;
|
|
178
|
+
}
|
|
179
|
+
function sha256Hex(s) {
|
|
180
|
+
return createHash("sha256").update(s).digest("hex");
|
|
181
|
+
}
|
|
182
|
+
const calendarNormalizer = {
|
|
183
|
+
itemId: calendarItemId,
|
|
184
|
+
payload: calendarPayload,
|
|
185
|
+
hash: (payload) => sha256Hex(stableStringify(calendarHashableShape(payload))),
|
|
186
|
+
itemStart: (raw) => normalizeTimeForRange(extractTime(raw.start)),
|
|
187
|
+
inWindow: (payload, windowMin, windowMax) => {
|
|
188
|
+
const startMs = payload.start === null ? NaN : Date.parse(payload.start);
|
|
189
|
+
const minMs = Date.parse(windowMin);
|
|
190
|
+
const maxMs = Date.parse(windowMax);
|
|
191
|
+
if (!Number.isFinite(startMs)
|
|
192
|
+
|| !Number.isFinite(minMs)
|
|
193
|
+
|| !Number.isFinite(maxMs)) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
return startMs >= minMs && startMs < maxMs;
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* Extract the email address from a connector "from" representation.
|
|
201
|
+
* Accepts `"Name <a@b.com>"`, `"a@b.com"`, `{ email: "a@b.com" }`, and
|
|
202
|
+
* `{ name, email }` shapes; returns `null` for anything else. Lowercased
|
|
203
|
+
* so capitalisation jitter does not flap the hash.
|
|
204
|
+
*/
|
|
205
|
+
function gmailCanonicalFrom(raw) {
|
|
206
|
+
if (raw == null)
|
|
207
|
+
return null;
|
|
208
|
+
if (typeof raw === "string") {
|
|
209
|
+
const trimmed = raw.trim();
|
|
210
|
+
if (trimmed.length === 0)
|
|
211
|
+
return null;
|
|
212
|
+
const angled = /<([^>]+)>/.exec(trimmed);
|
|
213
|
+
const candidate = angled?.[1] ?? trimmed;
|
|
214
|
+
return candidate.toLowerCase();
|
|
215
|
+
}
|
|
216
|
+
if (typeof raw === "object" && !Array.isArray(raw)) {
|
|
217
|
+
const email = asString(raw.email);
|
|
218
|
+
if (email !== null)
|
|
219
|
+
return email.toLowerCase();
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Walk a Gmail headers list. `headers` is sometimes hosted at
|
|
225
|
+
* `payload.headers` (Google API native) and sometimes flattened at the
|
|
226
|
+
* top level (Codex / Gemini search-result wrappers). Returns the first
|
|
227
|
+
* matching header value (case-insensitive name match).
|
|
228
|
+
*/
|
|
229
|
+
function gmailHeader(headers, name) {
|
|
230
|
+
if (!Array.isArray(headers))
|
|
231
|
+
return null;
|
|
232
|
+
for (const entry of headers) {
|
|
233
|
+
if (entry == null || typeof entry !== "object")
|
|
234
|
+
continue;
|
|
235
|
+
const headerName = asString(entry.name);
|
|
236
|
+
if (headerName === null)
|
|
237
|
+
continue;
|
|
238
|
+
if (headerName.toLowerCase() !== name.toLowerCase())
|
|
239
|
+
continue;
|
|
240
|
+
return asString(entry.value);
|
|
241
|
+
}
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
function gmailMessageHeaders(message) {
|
|
245
|
+
if (message.payload && typeof message.payload === "object") {
|
|
246
|
+
const inner = message.payload.headers;
|
|
247
|
+
if (inner !== undefined)
|
|
248
|
+
return inner;
|
|
249
|
+
}
|
|
250
|
+
return message.headers;
|
|
251
|
+
}
|
|
252
|
+
function gmailMessageId(message) {
|
|
253
|
+
return asString(message.id);
|
|
254
|
+
}
|
|
255
|
+
function gmailMessageInternalDateMs(message) {
|
|
256
|
+
const raw = message.internalDate;
|
|
257
|
+
if (typeof raw === "number" && Number.isFinite(raw))
|
|
258
|
+
return raw;
|
|
259
|
+
if (typeof raw === "string" && raw.length > 0) {
|
|
260
|
+
const asNumber = Number(raw);
|
|
261
|
+
if (Number.isFinite(asNumber))
|
|
262
|
+
return asNumber;
|
|
263
|
+
const parsed = Date.parse(raw);
|
|
264
|
+
if (Number.isFinite(parsed))
|
|
265
|
+
return parsed;
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
function gmailMessageLabelIds(message) {
|
|
270
|
+
if (!Array.isArray(message.labelIds))
|
|
271
|
+
return [];
|
|
272
|
+
const out = [];
|
|
273
|
+
for (const v of message.labelIds) {
|
|
274
|
+
const s = asString(v);
|
|
275
|
+
if (s !== null)
|
|
276
|
+
out.push(s);
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
function gmailExtractMessages(raw) {
|
|
281
|
+
if (Array.isArray(raw.messages)) {
|
|
282
|
+
return raw.messages.filter((m) => m !== null && typeof m === "object" && !Array.isArray(m));
|
|
283
|
+
}
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
function gmailItemId(raw) {
|
|
287
|
+
const threadId = asString(raw.threadId);
|
|
288
|
+
if (threadId !== null)
|
|
289
|
+
return threadId;
|
|
290
|
+
// Some Gmail connectors only return `id` on a single-message search hit;
|
|
291
|
+
// Gemini's `gmail.search` is the canonical example. The id IS the thread
|
|
292
|
+
// id when no separate threadId surfaces.
|
|
293
|
+
const id = asString(raw.id);
|
|
294
|
+
if (id !== null)
|
|
295
|
+
return id;
|
|
296
|
+
// Fall back to the first message's threadId if a `messages: [...]` shape
|
|
297
|
+
// was passed without a top-level threadId.
|
|
298
|
+
const messages = gmailExtractMessages(raw);
|
|
299
|
+
for (const m of messages) {
|
|
300
|
+
const tid = asString(m.threadId);
|
|
301
|
+
if (tid !== null)
|
|
302
|
+
return tid;
|
|
303
|
+
const mid = asString(m.id);
|
|
304
|
+
if (mid !== null)
|
|
305
|
+
return mid;
|
|
306
|
+
}
|
|
307
|
+
throw new Error("gmail thread missing threadId and id");
|
|
308
|
+
}
|
|
309
|
+
function gmailPayload(raw) {
|
|
310
|
+
const threadId = gmailItemId(raw);
|
|
311
|
+
const messages = gmailExtractMessages(raw);
|
|
312
|
+
// Gather subject / from / labelIds / messageIds / lastDate. Prefer the
|
|
313
|
+
// latest message (highest internalDate); fall back to the top-level
|
|
314
|
+
// fields when the connector returns a flattened search hit.
|
|
315
|
+
let lastDateMs = null;
|
|
316
|
+
let lastSubject = null;
|
|
317
|
+
let lastFrom = null;
|
|
318
|
+
const labelSet = new Set();
|
|
319
|
+
const messageIds = [];
|
|
320
|
+
for (const m of messages) {
|
|
321
|
+
const mid = gmailMessageId(m);
|
|
322
|
+
if (mid !== null)
|
|
323
|
+
messageIds.push(mid);
|
|
324
|
+
for (const label of gmailMessageLabelIds(m))
|
|
325
|
+
labelSet.add(label);
|
|
326
|
+
const dateMs = gmailMessageInternalDateMs(m);
|
|
327
|
+
if (dateMs !== null && (lastDateMs === null || dateMs > lastDateMs)) {
|
|
328
|
+
lastDateMs = dateMs;
|
|
329
|
+
const headers = gmailMessageHeaders(m);
|
|
330
|
+
lastSubject
|
|
331
|
+
= asString(m.subject)
|
|
332
|
+
?? gmailHeader(headers, "Subject");
|
|
333
|
+
lastFrom = gmailCanonicalFrom(m.from)
|
|
334
|
+
?? gmailCanonicalFrom(gmailHeader(headers, "From"));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// Top-level fallback for flattened search hits (Codex / Gemini singletons).
|
|
338
|
+
if (lastDateMs === null) {
|
|
339
|
+
lastDateMs = gmailMessageInternalDateMs(raw);
|
|
340
|
+
}
|
|
341
|
+
if (lastSubject === null) {
|
|
342
|
+
const topHeaders = gmailMessageHeaders(raw);
|
|
343
|
+
lastSubject = asString(raw.subject) ?? gmailHeader(topHeaders, "Subject");
|
|
344
|
+
}
|
|
345
|
+
if (lastFrom === null) {
|
|
346
|
+
const topHeaders = gmailMessageHeaders(raw);
|
|
347
|
+
lastFrom = gmailCanonicalFrom(raw.from)
|
|
348
|
+
?? gmailCanonicalFrom(gmailHeader(topHeaders, "From"));
|
|
349
|
+
}
|
|
350
|
+
if (Array.isArray(raw.labelIds)) {
|
|
351
|
+
for (const v of raw.labelIds) {
|
|
352
|
+
const s = asString(v);
|
|
353
|
+
if (s !== null)
|
|
354
|
+
labelSet.add(s);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (messageIds.length === 0) {
|
|
358
|
+
// Singleton search-hit shape. The thread's only known message id is the
|
|
359
|
+
// top-level id; if absent (Gemini search returns only `{id, threadId}`)
|
|
360
|
+
// we record an empty messageIds list — the next reconcile that fetches
|
|
361
|
+
// the thread's full body will populate it and surface a `modified`
|
|
362
|
+
// diff at that point.
|
|
363
|
+
const topId = asString(raw.id);
|
|
364
|
+
if (topId !== null && topId !== threadId)
|
|
365
|
+
messageIds.push(topId);
|
|
366
|
+
}
|
|
367
|
+
// Singletons sometimes carry a top-level `messageIds` array (Codex search
|
|
368
|
+
// hits flatten multiple message ids into the search row).
|
|
369
|
+
if (Array.isArray(raw.messageIds)) {
|
|
370
|
+
for (const v of raw.messageIds) {
|
|
371
|
+
const s = asString(v);
|
|
372
|
+
if (s !== null && !messageIds.includes(s))
|
|
373
|
+
messageIds.push(s);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Sort labelIds + messageIds so connector ordering jitter doesn't flap
|
|
377
|
+
// the hash.
|
|
378
|
+
const labelIds = [...labelSet].sort();
|
|
379
|
+
messageIds.sort();
|
|
380
|
+
return {
|
|
381
|
+
threadId,
|
|
382
|
+
subject: lastSubject,
|
|
383
|
+
from: lastFrom,
|
|
384
|
+
labelIds,
|
|
385
|
+
messageIds,
|
|
386
|
+
lastMessageInternalDate: lastDateMs === null ? null : new Date(lastDateMs).toISOString(),
|
|
387
|
+
snippet: asString(raw.snippet),
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
function gmailHashableShape(p) {
|
|
391
|
+
// §17.10 — snippet is excluded; subject/from/labelIds/messageIds/lastDate
|
|
392
|
+
// is the semantic identity of the thread for diff purposes.
|
|
393
|
+
return {
|
|
394
|
+
from: p.from,
|
|
395
|
+
labelIds: p.labelIds,
|
|
396
|
+
lastMessageInternalDate: p.lastMessageInternalDate,
|
|
397
|
+
messageIds: p.messageIds,
|
|
398
|
+
subject: p.subject,
|
|
399
|
+
threadId: p.threadId,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
const gmailNormalizer = {
|
|
403
|
+
itemId: gmailItemId,
|
|
404
|
+
payload: gmailPayload,
|
|
405
|
+
hash: (payload) => sha256Hex(stableStringify(gmailHashableShape(payload))),
|
|
406
|
+
// Gmail threads have no scheduled start time — `null` keeps them out of
|
|
407
|
+
// the imminent-event index that the calendar scheduler reads.
|
|
408
|
+
itemStart: () => null,
|
|
409
|
+
inWindow: (payload, windowMin, windowMax) => {
|
|
410
|
+
const dateMs = payload.lastMessageInternalDate === null
|
|
411
|
+
? NaN
|
|
412
|
+
: Date.parse(payload.lastMessageInternalDate);
|
|
413
|
+
const minMs = Date.parse(windowMin);
|
|
414
|
+
const maxMs = Date.parse(windowMax);
|
|
415
|
+
// §5.1: a payload with no parseable date cannot be classified as
|
|
416
|
+
// "still in window" — treat it as out-of-window so the prior row gets
|
|
417
|
+
// pruned silently rather than falsely emitted as `deleted`. This also
|
|
418
|
+
// matches how the calendar normalizer handles a null start.
|
|
419
|
+
if (!Number.isFinite(dateMs)
|
|
420
|
+
|| !Number.isFinite(minMs)
|
|
421
|
+
|| !Number.isFinite(maxMs)) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
return dateMs >= minMs && dateMs < maxMs;
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
function notionItemId(raw) {
|
|
428
|
+
const id = asString(raw.id);
|
|
429
|
+
if (id === null) {
|
|
430
|
+
throw new Error("notion page missing id");
|
|
431
|
+
}
|
|
432
|
+
return id;
|
|
433
|
+
}
|
|
434
|
+
function notionParentDatabaseId(raw) {
|
|
435
|
+
const parent = raw.parent;
|
|
436
|
+
if (parent == null || typeof parent !== "object")
|
|
437
|
+
return null;
|
|
438
|
+
const p = parent;
|
|
439
|
+
// A page can be parented under a `database_id`, `data_source_id` (the
|
|
440
|
+
// newer Notion data-source surface), `page_id`, or `workspace`. The
|
|
441
|
+
// observation source pattern in NotionPoller keys by `databaseId`, so
|
|
442
|
+
// we mirror that and return `database_id` first; data sources fall
|
|
443
|
+
// back next; page-rooted and workspace-rooted return null and the
|
|
444
|
+
// drift-effects layer falls through to `notion:lifecycle`.
|
|
445
|
+
return (asString(p.database_id)
|
|
446
|
+
?? asString(p.data_source_id)
|
|
447
|
+
?? null);
|
|
448
|
+
}
|
|
449
|
+
function notionExtractTitle(raw) {
|
|
450
|
+
const props = raw.properties;
|
|
451
|
+
if (props == null || typeof props !== "object")
|
|
452
|
+
return null;
|
|
453
|
+
for (const value of Object.values(props)) {
|
|
454
|
+
if (value == null || typeof value !== "object")
|
|
455
|
+
continue;
|
|
456
|
+
const prop = value;
|
|
457
|
+
if (prop.type !== "title")
|
|
458
|
+
continue;
|
|
459
|
+
const titleArr = prop.title;
|
|
460
|
+
if (!Array.isArray(titleArr))
|
|
461
|
+
continue;
|
|
462
|
+
const parts = [];
|
|
463
|
+
for (const seg of titleArr) {
|
|
464
|
+
if (seg == null || typeof seg !== "object")
|
|
465
|
+
continue;
|
|
466
|
+
const text = asString(seg.plain_text);
|
|
467
|
+
if (text !== null)
|
|
468
|
+
parts.push(text);
|
|
469
|
+
}
|
|
470
|
+
if (parts.length > 0)
|
|
471
|
+
return parts.join("");
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Per-property-type canonicalisation. Each entry returns a value that
|
|
477
|
+
* `stableStringify` can hash deterministically. Unhandled types map to
|
|
478
|
+
* `{ type }` so the hash captures presence/absence of the property without
|
|
479
|
+
* a per-shape handler — adding richer extractors later is additive.
|
|
480
|
+
*
|
|
481
|
+
* `relation` is intentionally NOT handled here: relation ids feed
|
|
482
|
+
* `relationsHash` via {@link notionRelationsShape} instead, so the page
|
|
483
|
+
* hash stays focused on scalar property changes.
|
|
484
|
+
*/
|
|
485
|
+
function notionPropertyValueShape(prop) {
|
|
486
|
+
const type = asString(prop.type);
|
|
487
|
+
if (type === null)
|
|
488
|
+
return null;
|
|
489
|
+
switch (type) {
|
|
490
|
+
case "title":
|
|
491
|
+
case "rich_text": {
|
|
492
|
+
const arr = prop[type];
|
|
493
|
+
if (!Array.isArray(arr))
|
|
494
|
+
return { type };
|
|
495
|
+
const parts = [];
|
|
496
|
+
for (const seg of arr) {
|
|
497
|
+
if (seg == null || typeof seg !== "object")
|
|
498
|
+
continue;
|
|
499
|
+
const text = asString(seg.plain_text);
|
|
500
|
+
if (text !== null)
|
|
501
|
+
parts.push(text);
|
|
502
|
+
}
|
|
503
|
+
return { type, value: parts.join("") };
|
|
504
|
+
}
|
|
505
|
+
case "status":
|
|
506
|
+
case "select": {
|
|
507
|
+
const inner = prop[type];
|
|
508
|
+
if (inner == null || typeof inner !== "object")
|
|
509
|
+
return { type, value: null };
|
|
510
|
+
return { type, value: asString(inner.name) };
|
|
511
|
+
}
|
|
512
|
+
case "multi_select": {
|
|
513
|
+
const arr = prop.multi_select;
|
|
514
|
+
if (!Array.isArray(arr))
|
|
515
|
+
return { type, value: [] };
|
|
516
|
+
const names = [];
|
|
517
|
+
for (const seg of arr) {
|
|
518
|
+
if (seg == null || typeof seg !== "object")
|
|
519
|
+
continue;
|
|
520
|
+
const name = asString(seg.name);
|
|
521
|
+
if (name !== null)
|
|
522
|
+
names.push(name);
|
|
523
|
+
}
|
|
524
|
+
names.sort();
|
|
525
|
+
return { type, value: names };
|
|
526
|
+
}
|
|
527
|
+
case "date": {
|
|
528
|
+
const inner = prop.date;
|
|
529
|
+
if (inner == null || typeof inner !== "object")
|
|
530
|
+
return { type, value: null };
|
|
531
|
+
const d = inner;
|
|
532
|
+
return {
|
|
533
|
+
type,
|
|
534
|
+
value: {
|
|
535
|
+
start: asString(d.start),
|
|
536
|
+
end: asString(d.end),
|
|
537
|
+
time_zone: asString(d.time_zone),
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
case "checkbox": {
|
|
542
|
+
const v = prop.checkbox;
|
|
543
|
+
return { type, value: typeof v === "boolean" ? v : null };
|
|
544
|
+
}
|
|
545
|
+
case "number": {
|
|
546
|
+
const v = prop.number;
|
|
547
|
+
return { type, value: typeof v === "number" && Number.isFinite(v) ? v : null };
|
|
548
|
+
}
|
|
549
|
+
case "url":
|
|
550
|
+
case "email":
|
|
551
|
+
case "phone_number": {
|
|
552
|
+
return { type, value: asString(prop[type]) };
|
|
553
|
+
}
|
|
554
|
+
case "people": {
|
|
555
|
+
const arr = prop.people;
|
|
556
|
+
if (!Array.isArray(arr))
|
|
557
|
+
return { type, value: [] };
|
|
558
|
+
const ids = [];
|
|
559
|
+
for (const seg of arr) {
|
|
560
|
+
if (seg == null || typeof seg !== "object")
|
|
561
|
+
continue;
|
|
562
|
+
const id = asString(seg.id);
|
|
563
|
+
if (id !== null)
|
|
564
|
+
ids.push(id);
|
|
565
|
+
}
|
|
566
|
+
ids.sort();
|
|
567
|
+
return { type, value: ids };
|
|
568
|
+
}
|
|
569
|
+
case "files": {
|
|
570
|
+
const arr = prop.files;
|
|
571
|
+
if (!Array.isArray(arr))
|
|
572
|
+
return { type, value: [] };
|
|
573
|
+
const refs = [];
|
|
574
|
+
for (const seg of arr) {
|
|
575
|
+
if (seg == null || typeof seg !== "object")
|
|
576
|
+
continue;
|
|
577
|
+
const name = asString(seg.name);
|
|
578
|
+
const file = seg.file;
|
|
579
|
+
const external = seg.external;
|
|
580
|
+
const url = (file && typeof file === "object" ? asString(file.url) : null)
|
|
581
|
+
?? (external && typeof external === "object" ? asString(external.url) : null)
|
|
582
|
+
?? name;
|
|
583
|
+
if (url !== null)
|
|
584
|
+
refs.push(url);
|
|
585
|
+
}
|
|
586
|
+
refs.sort();
|
|
587
|
+
return { type, value: refs };
|
|
588
|
+
}
|
|
589
|
+
case "created_time":
|
|
590
|
+
case "last_edited_time": {
|
|
591
|
+
// Notion regenerates these on every edit — exclude from the hash so
|
|
592
|
+
// the page does not "modify" itself for free on every fetch (the
|
|
593
|
+
// lastEditedTime field on the page itself is the canonical signal).
|
|
594
|
+
return { type };
|
|
595
|
+
}
|
|
596
|
+
default:
|
|
597
|
+
// Unknown type — record the type tag only so a structurally identical
|
|
598
|
+
// unknown property does not flap, and so adding a stable extractor
|
|
599
|
+
// for it later is additive (the new shape will diverge from the
|
|
600
|
+
// type-tag-only previous hash, surfaced as one `modified` per page,
|
|
601
|
+
// then steady).
|
|
602
|
+
return { type };
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
function notionPropertiesShape(raw) {
|
|
606
|
+
const props = raw.properties;
|
|
607
|
+
if (props == null || typeof props !== "object")
|
|
608
|
+
return {};
|
|
609
|
+
const out = {};
|
|
610
|
+
for (const [name, value] of Object.entries(props)) {
|
|
611
|
+
if (value == null || typeof value !== "object")
|
|
612
|
+
continue;
|
|
613
|
+
const prop = value;
|
|
614
|
+
if (prop.type === "relation")
|
|
615
|
+
continue;
|
|
616
|
+
out[name] = notionPropertyValueShape(prop);
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
function notionRelationsShape(raw) {
|
|
621
|
+
const props = raw.properties;
|
|
622
|
+
if (props == null || typeof props !== "object")
|
|
623
|
+
return {};
|
|
624
|
+
const out = {};
|
|
625
|
+
for (const [name, value] of Object.entries(props)) {
|
|
626
|
+
if (value == null || typeof value !== "object")
|
|
627
|
+
continue;
|
|
628
|
+
const prop = value;
|
|
629
|
+
if (prop.type !== "relation")
|
|
630
|
+
continue;
|
|
631
|
+
const arr = prop.relation;
|
|
632
|
+
if (!Array.isArray(arr)) {
|
|
633
|
+
out[name] = [];
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
const ids = [];
|
|
637
|
+
for (const seg of arr) {
|
|
638
|
+
if (seg == null || typeof seg !== "object")
|
|
639
|
+
continue;
|
|
640
|
+
const id = asString(seg.id);
|
|
641
|
+
if (id !== null)
|
|
642
|
+
ids.push(id);
|
|
643
|
+
}
|
|
644
|
+
ids.sort();
|
|
645
|
+
out[name] = ids;
|
|
646
|
+
}
|
|
647
|
+
return out;
|
|
648
|
+
}
|
|
649
|
+
function notionPropertiesSummaryString(raw) {
|
|
650
|
+
const props = raw.properties;
|
|
651
|
+
if (props == null || typeof props !== "object")
|
|
652
|
+
return null;
|
|
653
|
+
const parts = [];
|
|
654
|
+
for (const [name, value] of Object.entries(props)) {
|
|
655
|
+
if (value == null || typeof value !== "object")
|
|
656
|
+
continue;
|
|
657
|
+
const prop = value;
|
|
658
|
+
const type = asString(prop.type);
|
|
659
|
+
if (type === "status" || type === "select") {
|
|
660
|
+
const inner = prop[type];
|
|
661
|
+
if (inner != null && typeof inner === "object") {
|
|
662
|
+
const n = asString(inner.name);
|
|
663
|
+
if (n !== null)
|
|
664
|
+
parts.push(`${name}: ${n}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
else if (type === "date") {
|
|
668
|
+
const inner = prop.date;
|
|
669
|
+
if (inner != null && typeof inner === "object") {
|
|
670
|
+
const start = asString(inner.start);
|
|
671
|
+
if (start !== null)
|
|
672
|
+
parts.push(`${name}: ${start}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return parts.length === 0 ? null : parts.join(" | ");
|
|
677
|
+
}
|
|
678
|
+
function notionPayload(raw) {
|
|
679
|
+
const propertiesShape = notionPropertiesShape(raw);
|
|
680
|
+
const relationsShape = notionRelationsShape(raw);
|
|
681
|
+
const propertiesSummaryHash = sha256Hex(stableStringify(propertiesShape));
|
|
682
|
+
const relationsHash = sha256Hex(stableStringify(relationsShape));
|
|
683
|
+
const inTrash = Boolean(raw.in_trash) || Boolean(raw.archived);
|
|
684
|
+
return {
|
|
685
|
+
pageId: notionItemId(raw),
|
|
686
|
+
title: notionExtractTitle(raw),
|
|
687
|
+
lastEditedTime: asString(raw.last_edited_time),
|
|
688
|
+
parentDatabase: notionParentDatabaseId(raw),
|
|
689
|
+
url: asString(raw.url),
|
|
690
|
+
inTrash,
|
|
691
|
+
propertiesSummary: notionPropertiesSummaryString(raw),
|
|
692
|
+
propertiesSummaryHash,
|
|
693
|
+
relationsHash,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function notionHashableShape(p) {
|
|
697
|
+
// `propertiesSummary` is excluded — it's a display string derived from
|
|
698
|
+
// the same data the propertiesSummaryHash already covers. `url` and
|
|
699
|
+
// `inTrash` are also excluded: trash transitions show up via
|
|
700
|
+
// `lastEditedTime` (Notion bumps it on archive) and `url` is derivable
|
|
701
|
+
// from id; including either would flap on incidental rewrites.
|
|
702
|
+
return {
|
|
703
|
+
lastEditedTime: p.lastEditedTime,
|
|
704
|
+
pageId: p.pageId,
|
|
705
|
+
parentDatabase: p.parentDatabase,
|
|
706
|
+
propertiesSummaryHash: p.propertiesSummaryHash,
|
|
707
|
+
relationsHash: p.relationsHash,
|
|
708
|
+
title: p.title,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
const notionNormalizer = {
|
|
712
|
+
itemId: notionItemId,
|
|
713
|
+
payload: notionPayload,
|
|
714
|
+
hash: (payload) => sha256Hex(stableStringify(notionHashableShape(payload))),
|
|
715
|
+
itemStart: () => null,
|
|
716
|
+
inWindow: (payload, windowMin, windowMax) => {
|
|
717
|
+
const editedMs = payload.lastEditedTime === null
|
|
718
|
+
? NaN
|
|
719
|
+
: Date.parse(payload.lastEditedTime);
|
|
720
|
+
const minMs = Date.parse(windowMin);
|
|
721
|
+
const maxMs = Date.parse(windowMax);
|
|
722
|
+
if (!Number.isFinite(editedMs)
|
|
723
|
+
|| !Number.isFinite(minMs)
|
|
724
|
+
|| !Number.isFinite(maxMs)) {
|
|
725
|
+
return false;
|
|
726
|
+
}
|
|
727
|
+
return editedMs >= minMs && editedMs < maxMs;
|
|
728
|
+
},
|
|
729
|
+
};
|
|
730
|
+
/**
|
|
731
|
+
* Normalizer registry. Phase 5 ships gmail and notion alongside calendar.
|
|
732
|
+
* Adding a future integration is one Record entry — the route handler
|
|
733
|
+
* reads via `getSnapshotNormalizer(key)` and stays integration-key-driven.
|
|
734
|
+
*/
|
|
735
|
+
export const SNAPSHOT_NORMALIZERS = {
|
|
736
|
+
google_calendar: calendarNormalizer,
|
|
737
|
+
gmail: gmailNormalizer,
|
|
738
|
+
notion: notionNormalizer,
|
|
739
|
+
};
|
|
740
|
+
/** True if the integration has a Phase-1 normalizer registered. Used by
|
|
741
|
+
* the reconcile route to fail fast with a precise error message before
|
|
742
|
+
* parsing items. */
|
|
743
|
+
export function hasSnapshotNormalizer(key) {
|
|
744
|
+
return key in SNAPSHOT_NORMALIZERS;
|
|
745
|
+
}
|
|
746
|
+
/** List the integrations currently registered with a normalizer. Drives
|
|
747
|
+
* the `supportedIntegrations` field in error responses so a caller sees
|
|
748
|
+
* the contract without grepping the source. */
|
|
749
|
+
export function listSnapshotNormalizers() {
|
|
750
|
+
return INTEGRATION_KEYS.filter((k) => k in SNAPSHOT_NORMALIZERS);
|
|
751
|
+
}
|
|
752
|
+
/** Fetch the normalizer for an integration. Returns `undefined` for
|
|
753
|
+
* unsupported integrations so the caller can return a structured 400. */
|
|
754
|
+
export function getSnapshotNormalizer(key) {
|
|
755
|
+
return SNAPSHOT_NORMALIZERS[key];
|
|
756
|
+
}
|
|
757
|
+
//# sourceMappingURL=integrations-snapshot.js.map
|