@athenaintel/react 0.21.1 → 0.22.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/README.md +63 -0
- package/dist/chat/StatewireApprovalCard.cjs +70 -10
- package/dist/chat/StatewireApprovalCard.cjs.map +1 -1
- package/dist/chat/StatewireApprovalCard.d.ts +12 -1
- package/dist/chat/StatewireApprovalCard.js +73 -13
- package/dist/chat/StatewireApprovalCard.js.map +1 -1
- package/dist/chat/tiptap-text.cjs +28 -23
- package/dist/chat/tiptap-text.cjs.map +1 -1
- package/dist/chat/tiptap-text.js +28 -23
- package/dist/chat/tiptap-text.js.map +1 -1
- package/dist/chat/use-statewire-approval-request.cjs +22 -1
- package/dist/chat/use-statewire-approval-request.cjs.map +1 -1
- package/dist/chat/use-statewire-approval-request.d.ts +41 -0
- package/dist/chat/use-statewire-approval-request.js +24 -3
- package/dist/chat/use-statewire-approval-request.js.map +1 -1
- package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.cjs +0 -1
- package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.cjs.map +1 -1
- package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.js +0 -1
- package/dist/chat-ui/mentions/suggestions/data/sources/asset-type-icons.js.map +1 -1
- package/dist/collab/client.cjs +4 -9
- package/dist/collab/client.cjs.map +1 -1
- package/dist/collab/client.js +2 -7
- package/dist/collab/client.js.map +1 -1
- package/dist/collab/index.cjs +23 -0
- package/dist/collab/index.cjs.map +1 -1
- package/dist/collab/index.d.ts +17 -2
- package/dist/collab/index.js +24 -1
- package/dist/collab/index.js.map +1 -1
- package/dist/collab/mint.cjs +18 -13
- package/dist/collab/mint.cjs.map +1 -1
- package/dist/collab/mint.d.ts +10 -0
- package/dist/collab/mint.js +18 -13
- package/dist/collab/mint.js.map +1 -1
- package/dist/collab/presence-client.cjs +211 -0
- package/dist/collab/presence-client.cjs.map +1 -0
- package/dist/collab/presence-client.d.ts +60 -0
- package/dist/collab/presence-client.js +194 -0
- package/dist/collab/presence-client.js.map +1 -0
- package/dist/collab/presence-mint.cjs +68 -0
- package/dist/collab/presence-mint.cjs.map +1 -0
- package/dist/collab/presence-mint.d.ts +87 -0
- package/dist/collab/presence-mint.js +68 -0
- package/dist/collab/presence-mint.js.map +1 -0
- package/dist/collab/presence-react.cjs +114 -0
- package/dist/collab/presence-react.cjs.map +1 -0
- package/dist/collab/presence-react.d.ts +47 -0
- package/dist/collab/presence-react.js +114 -0
- package/dist/collab/presence-react.js.map +1 -0
- package/dist/collab/presence-slots.cjs +220 -0
- package/dist/collab/presence-slots.cjs.map +1 -0
- package/dist/collab/presence-slots.d.ts +119 -0
- package/dist/collab/presence-slots.js +220 -0
- package/dist/collab/presence-slots.js.map +1 -0
- package/dist/collab/token-refresh.cjs +13 -0
- package/dist/collab/token-refresh.cjs.map +1 -0
- package/dist/collab/token-refresh.d.ts +20 -0
- package/dist/collab/token-refresh.js +13 -0
- package/dist/collab/token-refresh.js.map +1 -0
- package/dist/packages/agent-runtime/src/interrupts.cjs +40 -1
- package/dist/packages/agent-runtime/src/interrupts.cjs.map +1 -1
- package/dist/packages/agent-runtime/src/interrupts.js +40 -1
- package/dist/packages/agent-runtime/src/interrupts.js.map +1 -1
- package/dist/packages/react/package.json.cjs +1 -1
- package/dist/packages/react/package.json.js +1 -1
- package/package.json +7 -7
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const PRESENCE_CONTEXT_TYPE = {
|
|
4
|
+
ASSET: "asset",
|
|
5
|
+
SESSION: "session",
|
|
6
|
+
/** Server-written only: "somewhere in that project", never which document. */
|
|
7
|
+
PROJECT: "project"
|
|
8
|
+
};
|
|
9
|
+
const MAX_PRESENCE_CONTEXTS_PER_SLOT = 64;
|
|
10
|
+
const MAX_PRESENCE_STRING_CHARS = 512;
|
|
11
|
+
const PRESENCE_ACTIVE_WINDOW_MS = 6e4;
|
|
12
|
+
const PRESENCE_IDLE_WINDOW_MS = 3e5;
|
|
13
|
+
function isRecord(value) {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function cleanString(value) {
|
|
17
|
+
if (typeof value !== "string") return null;
|
|
18
|
+
const trimmed = value.trim();
|
|
19
|
+
if (!trimmed) return null;
|
|
20
|
+
return trimmed.slice(0, MAX_PRESENCE_STRING_CHARS);
|
|
21
|
+
}
|
|
22
|
+
function cleanEpochMs(value) {
|
|
23
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
function cleanContextType(value) {
|
|
27
|
+
if (value === PRESENCE_CONTEXT_TYPE.ASSET || value === PRESENCE_CONTEXT_TYPE.SESSION || value === PRESENCE_CONTEXT_TYPE.PROJECT) {
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function cleanStringList(value) {
|
|
33
|
+
if (!Array.isArray(value)) return [];
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const item of value) {
|
|
36
|
+
const cleaned = cleanString(item);
|
|
37
|
+
if (cleaned === null || out.includes(cleaned)) continue;
|
|
38
|
+
out.push(cleaned);
|
|
39
|
+
if (out.length >= MAX_PRESENCE_CONTEXTS_PER_SLOT) break;
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
function parseContext(raw) {
|
|
44
|
+
if (!isRecord(raw)) return null;
|
|
45
|
+
const type = cleanContextType(raw.type);
|
|
46
|
+
const guid = cleanString(raw.guid);
|
|
47
|
+
if (type === null || guid === null) return null;
|
|
48
|
+
return {
|
|
49
|
+
type,
|
|
50
|
+
guid,
|
|
51
|
+
locationLabel: cleanString(raw.locationLabel),
|
|
52
|
+
projects: cleanStringList(raw.projects),
|
|
53
|
+
coarse: raw.coarse === true,
|
|
54
|
+
lastAnchorChangeAt: cleanEpochMs(raw.lastAnchorChangeAt)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
function parseContexts(raw) {
|
|
58
|
+
if (!Array.isArray(raw)) return [];
|
|
59
|
+
const seen = /* @__PURE__ */ new Set();
|
|
60
|
+
const contexts = [];
|
|
61
|
+
for (const item of raw) {
|
|
62
|
+
const parsed = parseContext(item);
|
|
63
|
+
if (parsed === null) continue;
|
|
64
|
+
const key = `${parsed.type}:${parsed.guid}`;
|
|
65
|
+
if (seen.has(key)) continue;
|
|
66
|
+
seen.add(key);
|
|
67
|
+
contexts.push(parsed);
|
|
68
|
+
if (contexts.length >= MAX_PRESENCE_CONTEXTS_PER_SLOT) break;
|
|
69
|
+
}
|
|
70
|
+
return contexts;
|
|
71
|
+
}
|
|
72
|
+
function parseAgentInitiator(raw) {
|
|
73
|
+
if (!isRecord(raw)) return null;
|
|
74
|
+
const initiator = {
|
|
75
|
+
userId: cleanString(raw.userId),
|
|
76
|
+
email: cleanString(raw.email),
|
|
77
|
+
name: cleanString(raw.name)
|
|
78
|
+
};
|
|
79
|
+
if (initiator.userId === null && initiator.email === null && initiator.name === null) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return initiator;
|
|
83
|
+
}
|
|
84
|
+
function parseSlot(raw) {
|
|
85
|
+
if (!isRecord(raw)) return null;
|
|
86
|
+
const user = raw.user;
|
|
87
|
+
if (!isRecord(user)) return null;
|
|
88
|
+
const email = cleanString(user.email);
|
|
89
|
+
const userId = cleanString(user.userId) ?? email;
|
|
90
|
+
if (userId === null) return null;
|
|
91
|
+
return {
|
|
92
|
+
kind: user.kind === "agent" ? "agent" : "human",
|
|
93
|
+
userId,
|
|
94
|
+
authUserId: cleanString(user.authUserId),
|
|
95
|
+
name: cleanString(user.name) ?? userId,
|
|
96
|
+
email,
|
|
97
|
+
color: cleanString(user.color),
|
|
98
|
+
avatarUrl: cleanString(user.avatarUrl),
|
|
99
|
+
agentInitiator: parseAgentInitiator(user.agentInitiator),
|
|
100
|
+
contexts: parseContexts(raw.activeContexts),
|
|
101
|
+
hidden: raw.hidden === true,
|
|
102
|
+
lastIntentAt: cleanEpochMs(raw.lastIntentAt),
|
|
103
|
+
manualAway: raw.status === "away"
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function mergeContexts(existing, incoming) {
|
|
107
|
+
const index = /* @__PURE__ */ new Map();
|
|
108
|
+
existing.forEach((context, position) => {
|
|
109
|
+
index.set(`${context.type}:${context.guid}`, position);
|
|
110
|
+
});
|
|
111
|
+
for (const context of incoming) {
|
|
112
|
+
const key = `${context.type}:${context.guid}`;
|
|
113
|
+
const position = index.get(key);
|
|
114
|
+
if (position === void 0) {
|
|
115
|
+
existing.push(context);
|
|
116
|
+
index.set(key, existing.length - 1);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const prior = existing[position];
|
|
120
|
+
if (prior && (context.lastAnchorChangeAt ?? 0) > (prior.lastAnchorChangeAt ?? 0)) {
|
|
121
|
+
existing[position] = context;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function maxIntent(a, b) {
|
|
126
|
+
if (a === null) return b;
|
|
127
|
+
if (b === null) return a;
|
|
128
|
+
return Math.max(a, b);
|
|
129
|
+
}
|
|
130
|
+
function toPresenceContext(context) {
|
|
131
|
+
return {
|
|
132
|
+
type: context.type,
|
|
133
|
+
guid: context.guid,
|
|
134
|
+
locationLabel: context.locationLabel,
|
|
135
|
+
projects: context.projects,
|
|
136
|
+
coarse: context.coarse
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function byName(a, b) {
|
|
140
|
+
const names = a.name.localeCompare(b.name);
|
|
141
|
+
return names !== 0 ? names : a.key.localeCompare(b.key);
|
|
142
|
+
}
|
|
143
|
+
function mergePresenceStates(states) {
|
|
144
|
+
const humans = /* @__PURE__ */ new Map();
|
|
145
|
+
const agents = /* @__PURE__ */ new Map();
|
|
146
|
+
for (const raw of states.values()) {
|
|
147
|
+
const slot = parseSlot(raw);
|
|
148
|
+
if (slot === null) continue;
|
|
149
|
+
if (slot.kind === "agent") {
|
|
150
|
+
const existing2 = agents.get(slot.userId);
|
|
151
|
+
if (existing2) {
|
|
152
|
+
existing2.lastIntentAt = maxIntent(existing2.lastIntentAt, slot.lastIntentAt);
|
|
153
|
+
existing2.agentInitiator = existing2.agentInitiator ?? slot.agentInitiator;
|
|
154
|
+
mergeContexts(existing2.contexts, slot.contexts);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
agents.set(slot.userId, {
|
|
158
|
+
key: slot.userId,
|
|
159
|
+
userId: slot.userId,
|
|
160
|
+
name: slot.name,
|
|
161
|
+
agentInitiator: slot.agentInitiator,
|
|
162
|
+
contexts: [...slot.contexts],
|
|
163
|
+
lastIntentAt: slot.lastIntentAt
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const key = slot.authUserId ?? slot.userId;
|
|
168
|
+
const existing = humans.get(key);
|
|
169
|
+
if (existing) {
|
|
170
|
+
existing.connections += 1;
|
|
171
|
+
existing.lastIntentAt = maxIntent(existing.lastIntentAt, slot.lastIntentAt);
|
|
172
|
+
existing.hidden = existing.hidden && slot.hidden;
|
|
173
|
+
existing.manualAway = existing.manualAway || slot.manualAway;
|
|
174
|
+
existing.email = existing.email ?? slot.email;
|
|
175
|
+
existing.avatarUrl = existing.avatarUrl ?? slot.avatarUrl;
|
|
176
|
+
existing.color = existing.color ?? slot.color;
|
|
177
|
+
mergeContexts(existing.contexts, slot.contexts);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
humans.set(key, {
|
|
181
|
+
key,
|
|
182
|
+
authUserId: slot.authUserId,
|
|
183
|
+
userId: slot.userId,
|
|
184
|
+
name: slot.name,
|
|
185
|
+
email: slot.email,
|
|
186
|
+
avatarUrl: slot.avatarUrl,
|
|
187
|
+
color: slot.color,
|
|
188
|
+
contexts: [...slot.contexts],
|
|
189
|
+
lastIntentAt: slot.lastIntentAt,
|
|
190
|
+
hidden: slot.hidden,
|
|
191
|
+
manualAway: slot.manualAway,
|
|
192
|
+
connections: 1
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
humans: [...humans.values()].map((human) => ({ ...human, contexts: human.contexts.map(toPresenceContext) })).sort(byName),
|
|
197
|
+
agents: [...agents.values()].map((agent) => ({ ...agent, contexts: agent.contexts.map(toPresenceContext) })).sort(byName)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function derivePresenceState({
|
|
201
|
+
lastIntentAt,
|
|
202
|
+
hidden,
|
|
203
|
+
manualAway,
|
|
204
|
+
now
|
|
205
|
+
}) {
|
|
206
|
+
if (manualAway) return "away";
|
|
207
|
+
if (lastIntentAt === null) return "idle";
|
|
208
|
+
const elapsed = Math.max(0, now - lastIntentAt);
|
|
209
|
+
if (elapsed <= PRESENCE_ACTIVE_WINDOW_MS) return hidden ? "idle" : "active";
|
|
210
|
+
if (elapsed <= PRESENCE_IDLE_WINDOW_MS) return "idle";
|
|
211
|
+
return "away";
|
|
212
|
+
}
|
|
213
|
+
exports.MAX_PRESENCE_CONTEXTS_PER_SLOT = MAX_PRESENCE_CONTEXTS_PER_SLOT;
|
|
214
|
+
exports.MAX_PRESENCE_STRING_CHARS = MAX_PRESENCE_STRING_CHARS;
|
|
215
|
+
exports.PRESENCE_ACTIVE_WINDOW_MS = PRESENCE_ACTIVE_WINDOW_MS;
|
|
216
|
+
exports.PRESENCE_CONTEXT_TYPE = PRESENCE_CONTEXT_TYPE;
|
|
217
|
+
exports.PRESENCE_IDLE_WINDOW_MS = PRESENCE_IDLE_WINDOW_MS;
|
|
218
|
+
exports.derivePresenceState = derivePresenceState;
|
|
219
|
+
exports.mergePresenceStates = mergePresenceStates;
|
|
220
|
+
//# sourceMappingURL=presence-slots.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"presence-slots.cjs","sources":["../../src/collab/presence-slots.ts"],"sourcesContent":["/**\n * Pure decoding of workspace awareness slots into typed presence entries.\n *\n * Every connected Olympus tab (and every agent run, published by the backend)\n * holds one slot in the workspace awareness room. Keryx narrows what each\n * recipient receives (a document the viewer may not open collapses into a\n * coarse `project` context, agents with nothing visible are dropped), so the\n * slots seen here are already scoped to the token holder. This module treats\n * every field as untrusted anyway: a slot is JSON a browser published, and a\n * malformed one must be dropped rather than crash the feed or merge every\n * anonymous tab into one phantom person.\n *\n * The shape mirrors Olympus's `WorkspaceAwarenessSlot`\n * (`olympus/src/store/workspaceAwarenessStore.ts`) and the bounds mirror\n * Agora's observer (`agora/agora/services/presence/awareness_slots.py`).\n * No React, no network — the merge and the intent ladder are testable directly.\n */\n\nexport const PRESENCE_CONTEXT_TYPE = {\n ASSET: 'asset',\n SESSION: 'session',\n /** Server-written only: \"somewhere in that project\", never which document. */\n PROJECT: 'project',\n} as const;\n\nexport type PresenceContextType =\n (typeof PRESENCE_CONTEXT_TYPE)[keyof typeof PRESENCE_CONTEXT_TYPE];\n\n/** Contexts kept per slot; anything past this is publisher junk. */\nexport const MAX_PRESENCE_CONTEXTS_PER_SLOT = 64;\n/** Characters kept per string field. */\nexport const MAX_PRESENCE_STRING_CHARS = 512;\n\n/**\n * Intent ladder thresholds, mirroring `agora/agora/services/presence/types.py`\n * and `olympus/src/sections/presence-monitor/presence-state.ts`. Readers\n * recompute the state from `lastIntentAt` on every tick so it decays smoothly\n * instead of stepping when a snapshot lands.\n */\nexport const PRESENCE_ACTIVE_WINDOW_MS = 60_000;\nexport const PRESENCE_IDLE_WINDOW_MS = 300_000;\n\nexport type PresenceState = 'active' | 'idle' | 'away';\n\nexport interface PresenceContext {\n type: PresenceContextType;\n /** Asset id, session thread id, or project asset id depending on `type`. */\n guid: string;\n /** Human-readable location inside the asset (\"Slide 4\", \"Sheet1!B14\"), when the tab reported one. */\n locationLabel: string | null;\n /** Server-written: projects containing this document that the viewer may also open. */\n projects: string[];\n /** Server-written on `project` contexts: the viewer may not open the underlying document. */\n coarse: boolean;\n}\n\nexport interface PresenceHuman {\n /** Identity the person's tabs merge by: the server-attested id when present, else the self-reported one. */\n key: string;\n /** Agora user id stamped by Keryx from the publishing socket's token; null on unstamped slots. */\n authUserId: string | null;\n /** Self-reported user identifier (the email, for Olympus tabs). */\n userId: string;\n name: string;\n email: string | null;\n avatarUrl: string | null;\n color: string | null;\n /** Union of open contexts across the person's tabs, deduped by type + guid. */\n contexts: PresenceContext[];\n /** Most recent self-reported input across the person's tabs (epoch ms, publisher clock); null when none reported. */\n lastIntentAt: number | null;\n /** True only when every one of the person's tabs reports itself backgrounded. */\n hidden: boolean;\n /** True when any tab carries the manual \"away\" status set in Settings. */\n manualAway: boolean;\n /** Number of awareness slots (tabs) merged into this entry. */\n connections: number;\n}\n\nexport interface PresenceAgent {\n /** The agent slot's own id (`agent:<run>`); runs never collapse by publisher. */\n key: string;\n userId: string;\n name: string;\n /** The person the run works for; what a reader shows as \"Athena for …\". */\n agentInitiator: { userId: string | null; email: string | null; name: string | null } | null;\n contexts: PresenceContext[];\n lastIntentAt: number | null;\n}\n\nexport interface PresenceRoster {\n humans: PresenceHuman[];\n agents: PresenceAgent[];\n}\n\ninterface ParsedContext extends PresenceContext {\n /** Publisher-reported ms of the last anchor change; breaks ties when tabs share a context. */\n lastAnchorChangeAt: number | null;\n}\n\ninterface ParsedSlot {\n kind: 'human' | 'agent';\n userId: string;\n authUserId: string | null;\n name: string;\n email: string | null;\n color: string | null;\n avatarUrl: string | null;\n agentInitiator: PresenceAgent['agentInitiator'];\n contexts: ParsedContext[];\n hidden: boolean;\n lastIntentAt: number | null;\n manualAway: boolean;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/** Accept a non-empty string, trimmed and truncated. Anything else becomes null. */\nfunction cleanString(value: unknown): string | null {\n if (typeof value !== 'string') return null;\n const trimmed = value.trim();\n if (!trimmed) return null;\n return trimmed.slice(0, MAX_PRESENCE_STRING_CHARS);\n}\n\n/** Accept a plausible epoch-milliseconds number, else null. */\nfunction cleanEpochMs(value: unknown): number | null {\n if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;\n return value;\n}\n\nfunction cleanContextType(value: unknown): PresenceContextType | null {\n if (\n value === PRESENCE_CONTEXT_TYPE.ASSET ||\n value === PRESENCE_CONTEXT_TYPE.SESSION ||\n value === PRESENCE_CONTEXT_TYPE.PROJECT\n ) {\n return value;\n }\n return null;\n}\n\nfunction cleanStringList(value: unknown): string[] {\n if (!Array.isArray(value)) return [];\n const out: string[] = [];\n for (const item of value) {\n const cleaned = cleanString(item);\n if (cleaned === null || out.includes(cleaned)) continue;\n out.push(cleaned);\n if (out.length >= MAX_PRESENCE_CONTEXTS_PER_SLOT) break;\n }\n return out;\n}\n\nfunction parseContext(raw: unknown): ParsedContext | null {\n if (!isRecord(raw)) return null;\n const type = cleanContextType(raw.type);\n const guid = cleanString(raw.guid);\n if (type === null || guid === null) return null;\n return {\n type,\n guid,\n locationLabel: cleanString(raw.locationLabel),\n projects: cleanStringList(raw.projects),\n coarse: raw.coarse === true,\n lastAnchorChangeAt: cleanEpochMs(raw.lastAnchorChangeAt),\n };\n}\n\nfunction parseContexts(raw: unknown): ParsedContext[] {\n if (!Array.isArray(raw)) return [];\n const seen = new Set<string>();\n const contexts: ParsedContext[] = [];\n for (const item of raw) {\n const parsed = parseContext(item);\n if (parsed === null) continue;\n const key = `${parsed.type}:${parsed.guid}`;\n if (seen.has(key)) continue;\n seen.add(key);\n contexts.push(parsed);\n if (contexts.length >= MAX_PRESENCE_CONTEXTS_PER_SLOT) break;\n }\n return contexts;\n}\n\nfunction parseAgentInitiator(raw: unknown): PresenceAgent['agentInitiator'] {\n if (!isRecord(raw)) return null;\n const initiator = {\n userId: cleanString(raw.userId),\n email: cleanString(raw.email),\n name: cleanString(raw.name),\n };\n if (initiator.userId === null && initiator.email === null && initiator.name === null) {\n return null;\n }\n return initiator;\n}\n\n/**\n * Validate one slot. Returns null for anything that cannot be attributed to a\n * stable identity: a slot with neither `userId` nor `email` would otherwise\n * merge every anonymous tab in the workspace into one phantom participant.\n * A missing or unrecognised `kind` reads as human — an older client is far\n * likelier than an agent, and mislabelling a person would hide them.\n */\nfunction parseSlot(raw: unknown): ParsedSlot | null {\n if (!isRecord(raw)) return null;\n const user = raw.user;\n if (!isRecord(user)) return null;\n const email = cleanString(user.email);\n const userId = cleanString(user.userId) ?? email;\n if (userId === null) return null;\n return {\n kind: user.kind === 'agent' ? 'agent' : 'human',\n userId,\n authUserId: cleanString(user.authUserId),\n name: cleanString(user.name) ?? userId,\n email,\n color: cleanString(user.color),\n avatarUrl: cleanString(user.avatarUrl),\n agentInitiator: parseAgentInitiator(user.agentInitiator),\n contexts: parseContexts(raw.activeContexts),\n hidden: raw.hidden === true,\n lastIntentAt: cleanEpochMs(raw.lastIntentAt),\n manualAway: raw.status === 'away',\n };\n}\n\n/**\n * Fold one slot's contexts into an already-seen participant's list, keeping\n * the entry with the most recent anchor activity for a duplicate (type, guid).\n */\nfunction mergeContexts(existing: ParsedContext[], incoming: readonly ParsedContext[]): void {\n const index = new Map<string, number>();\n existing.forEach((context, position) => {\n index.set(`${context.type}:${context.guid}`, position);\n });\n for (const context of incoming) {\n const key = `${context.type}:${context.guid}`;\n const position = index.get(key);\n if (position === undefined) {\n existing.push(context);\n index.set(key, existing.length - 1);\n continue;\n }\n const prior = existing[position];\n if (prior && (context.lastAnchorChangeAt ?? 0) > (prior.lastAnchorChangeAt ?? 0)) {\n existing[position] = context;\n }\n }\n}\n\nfunction maxIntent(a: number | null, b: number | null): number | null {\n if (a === null) return b;\n if (b === null) return a;\n return Math.max(a, b);\n}\n\nfunction toPresenceContext(context: ParsedContext): PresenceContext {\n return {\n type: context.type,\n guid: context.guid,\n locationLabel: context.locationLabel,\n projects: context.projects,\n coarse: context.coarse,\n };\n}\n\ninterface HumanAccumulator extends Omit<PresenceHuman, 'contexts'> {\n contexts: ParsedContext[];\n}\n\ninterface AgentAccumulator extends Omit<PresenceAgent, 'contexts'> {\n contexts: ParsedContext[];\n}\n\nfunction byName<T extends { name: string; key: string }>(a: T, b: T): number {\n const names = a.name.localeCompare(b.name);\n return names !== 0 ? names : a.key.localeCompare(b.key);\n}\n\n/**\n * Merge the awareness room's slots into one entry per person and one per\n * agent run.\n *\n * Humans merge by the server-attested `authUserId` when Keryx stamped one\n * (a tab that merely *claims* a colleague's `userId` gets its own row, with its\n * real identity on it) and by the self-reported `userId` only for unstamped\n * slots — the same rule as Agora's observer and Olympus's store. A person is\n * as active as their most active tab, backgrounded only when every tab is,\n * and away whenever any tab carries the manual flag. Agents keep their own\n * slot id: every run is published by the same service identity, so merging\n * by publisher would collapse concurrent runs into one.\n */\nexport function mergePresenceStates(states: ReadonlyMap<number, unknown>): PresenceRoster {\n const humans = new Map<string, HumanAccumulator>();\n const agents = new Map<string, AgentAccumulator>();\n\n for (const raw of states.values()) {\n const slot = parseSlot(raw);\n if (slot === null) continue;\n\n if (slot.kind === 'agent') {\n const existing = agents.get(slot.userId);\n if (existing) {\n existing.lastIntentAt = maxIntent(existing.lastIntentAt, slot.lastIntentAt);\n existing.agentInitiator = existing.agentInitiator ?? slot.agentInitiator;\n mergeContexts(existing.contexts, slot.contexts);\n continue;\n }\n agents.set(slot.userId, {\n key: slot.userId,\n userId: slot.userId,\n name: slot.name,\n agentInitiator: slot.agentInitiator,\n contexts: [...slot.contexts],\n lastIntentAt: slot.lastIntentAt,\n });\n continue;\n }\n\n const key = slot.authUserId ?? slot.userId;\n const existing = humans.get(key);\n if (existing) {\n existing.connections += 1;\n existing.lastIntentAt = maxIntent(existing.lastIntentAt, slot.lastIntentAt);\n existing.hidden = existing.hidden && slot.hidden;\n existing.manualAway = existing.manualAway || slot.manualAway;\n // A person with tabs in two surfaces publishes identity from both;\n // prefer whichever slot actually carried the optional fields.\n existing.email = existing.email ?? slot.email;\n existing.avatarUrl = existing.avatarUrl ?? slot.avatarUrl;\n existing.color = existing.color ?? slot.color;\n mergeContexts(existing.contexts, slot.contexts);\n continue;\n }\n humans.set(key, {\n key,\n authUserId: slot.authUserId,\n userId: slot.userId,\n name: slot.name,\n email: slot.email,\n avatarUrl: slot.avatarUrl,\n color: slot.color,\n contexts: [...slot.contexts],\n lastIntentAt: slot.lastIntentAt,\n hidden: slot.hidden,\n manualAway: slot.manualAway,\n connections: 1,\n });\n }\n\n return {\n humans: [...humans.values()]\n .map((human) => ({ ...human, contexts: human.contexts.map(toPresenceContext) }))\n .sort(byName),\n agents: [...agents.values()]\n .map((agent) => ({ ...agent, contexts: agent.contexts.map(toPresenceContext) }))\n .sort(byName),\n };\n}\n\n/**\n * Where a person sits on the intent ladder right now.\n *\n * The manual \"away\" status overrides the ladder. A person who never reported\n * an input is `idle` rather than `away` — absence of evidence is not evidence\n * of leaving. Every tab backgrounded (`hidden`) caps the result below\n * `active`: whatever they did a moment ago, they are demonstrably not looking\n * at Athena now; it does not force `away`, because alt-tabbing to check\n * something is not leaving. A `lastIntentAt` ahead of `now` (a publisher whose\n * clock runs fast) counts as \"just acted\" rather than a negative elapsed.\n */\nexport function derivePresenceState({\n lastIntentAt,\n hidden,\n manualAway,\n now,\n}: {\n lastIntentAt: number | null;\n hidden: boolean;\n manualAway: boolean;\n now: number;\n}): PresenceState {\n if (manualAway) return 'away';\n if (lastIntentAt === null) return 'idle';\n const elapsed = Math.max(0, now - lastIntentAt);\n if (elapsed <= PRESENCE_ACTIVE_WINDOW_MS) return hidden ? 'idle' : 'active';\n if (elapsed <= PRESENCE_IDLE_WINDOW_MS) return 'idle';\n return 'away';\n}\n"],"names":["existing"],"mappings":"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"}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decoding of workspace awareness slots into typed presence entries.
|
|
3
|
+
*
|
|
4
|
+
* Every connected Olympus tab (and every agent run, published by the backend)
|
|
5
|
+
* holds one slot in the workspace awareness room. Keryx narrows what each
|
|
6
|
+
* recipient receives (a document the viewer may not open collapses into a
|
|
7
|
+
* coarse `project` context, agents with nothing visible are dropped), so the
|
|
8
|
+
* slots seen here are already scoped to the token holder. This module treats
|
|
9
|
+
* every field as untrusted anyway: a slot is JSON a browser published, and a
|
|
10
|
+
* malformed one must be dropped rather than crash the feed or merge every
|
|
11
|
+
* anonymous tab into one phantom person.
|
|
12
|
+
*
|
|
13
|
+
* The shape mirrors Olympus's `WorkspaceAwarenessSlot`
|
|
14
|
+
* (`olympus/src/store/workspaceAwarenessStore.ts`) and the bounds mirror
|
|
15
|
+
* Agora's observer (`agora/agora/services/presence/awareness_slots.py`).
|
|
16
|
+
* No React, no network — the merge and the intent ladder are testable directly.
|
|
17
|
+
*/
|
|
18
|
+
export declare const PRESENCE_CONTEXT_TYPE: {
|
|
19
|
+
readonly ASSET: 'asset';
|
|
20
|
+
readonly SESSION: 'session';
|
|
21
|
+
/** Server-written only: "somewhere in that project", never which document. */
|
|
22
|
+
readonly PROJECT: 'project';
|
|
23
|
+
};
|
|
24
|
+
export type PresenceContextType = (typeof PRESENCE_CONTEXT_TYPE)[keyof typeof PRESENCE_CONTEXT_TYPE];
|
|
25
|
+
/** Contexts kept per slot; anything past this is publisher junk. */
|
|
26
|
+
export declare const MAX_PRESENCE_CONTEXTS_PER_SLOT = 64;
|
|
27
|
+
/** Characters kept per string field. */
|
|
28
|
+
export declare const MAX_PRESENCE_STRING_CHARS = 512;
|
|
29
|
+
/**
|
|
30
|
+
* Intent ladder thresholds, mirroring `agora/agora/services/presence/types.py`
|
|
31
|
+
* and `olympus/src/sections/presence-monitor/presence-state.ts`. Readers
|
|
32
|
+
* recompute the state from `lastIntentAt` on every tick so it decays smoothly
|
|
33
|
+
* instead of stepping when a snapshot lands.
|
|
34
|
+
*/
|
|
35
|
+
export declare const PRESENCE_ACTIVE_WINDOW_MS = 60000;
|
|
36
|
+
export declare const PRESENCE_IDLE_WINDOW_MS = 300000;
|
|
37
|
+
export type PresenceState = 'active' | 'idle' | 'away';
|
|
38
|
+
export interface PresenceContext {
|
|
39
|
+
type: PresenceContextType;
|
|
40
|
+
/** Asset id, session thread id, or project asset id depending on `type`. */
|
|
41
|
+
guid: string;
|
|
42
|
+
/** Human-readable location inside the asset ("Slide 4", "Sheet1!B14"), when the tab reported one. */
|
|
43
|
+
locationLabel: string | null;
|
|
44
|
+
/** Server-written: projects containing this document that the viewer may also open. */
|
|
45
|
+
projects: string[];
|
|
46
|
+
/** Server-written on `project` contexts: the viewer may not open the underlying document. */
|
|
47
|
+
coarse: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface PresenceHuman {
|
|
50
|
+
/** Identity the person's tabs merge by: the server-attested id when present, else the self-reported one. */
|
|
51
|
+
key: string;
|
|
52
|
+
/** Agora user id stamped by Keryx from the publishing socket's token; null on unstamped slots. */
|
|
53
|
+
authUserId: string | null;
|
|
54
|
+
/** Self-reported user identifier (the email, for Olympus tabs). */
|
|
55
|
+
userId: string;
|
|
56
|
+
name: string;
|
|
57
|
+
email: string | null;
|
|
58
|
+
avatarUrl: string | null;
|
|
59
|
+
color: string | null;
|
|
60
|
+
/** Union of open contexts across the person's tabs, deduped by type + guid. */
|
|
61
|
+
contexts: PresenceContext[];
|
|
62
|
+
/** Most recent self-reported input across the person's tabs (epoch ms, publisher clock); null when none reported. */
|
|
63
|
+
lastIntentAt: number | null;
|
|
64
|
+
/** True only when every one of the person's tabs reports itself backgrounded. */
|
|
65
|
+
hidden: boolean;
|
|
66
|
+
/** True when any tab carries the manual "away" status set in Settings. */
|
|
67
|
+
manualAway: boolean;
|
|
68
|
+
/** Number of awareness slots (tabs) merged into this entry. */
|
|
69
|
+
connections: number;
|
|
70
|
+
}
|
|
71
|
+
export interface PresenceAgent {
|
|
72
|
+
/** The agent slot's own id (`agent:<run>`); runs never collapse by publisher. */
|
|
73
|
+
key: string;
|
|
74
|
+
userId: string;
|
|
75
|
+
name: string;
|
|
76
|
+
/** The person the run works for; what a reader shows as "Athena for …". */
|
|
77
|
+
agentInitiator: {
|
|
78
|
+
userId: string | null;
|
|
79
|
+
email: string | null;
|
|
80
|
+
name: string | null;
|
|
81
|
+
} | null;
|
|
82
|
+
contexts: PresenceContext[];
|
|
83
|
+
lastIntentAt: number | null;
|
|
84
|
+
}
|
|
85
|
+
export interface PresenceRoster {
|
|
86
|
+
humans: PresenceHuman[];
|
|
87
|
+
agents: PresenceAgent[];
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Merge the awareness room's slots into one entry per person and one per
|
|
91
|
+
* agent run.
|
|
92
|
+
*
|
|
93
|
+
* Humans merge by the server-attested `authUserId` when Keryx stamped one
|
|
94
|
+
* (a tab that merely *claims* a colleague's `userId` gets its own row, with its
|
|
95
|
+
* real identity on it) and by the self-reported `userId` only for unstamped
|
|
96
|
+
* slots — the same rule as Agora's observer and Olympus's store. A person is
|
|
97
|
+
* as active as their most active tab, backgrounded only when every tab is,
|
|
98
|
+
* and away whenever any tab carries the manual flag. Agents keep their own
|
|
99
|
+
* slot id: every run is published by the same service identity, so merging
|
|
100
|
+
* by publisher would collapse concurrent runs into one.
|
|
101
|
+
*/
|
|
102
|
+
export declare function mergePresenceStates(states: ReadonlyMap<number, unknown>): PresenceRoster;
|
|
103
|
+
/**
|
|
104
|
+
* Where a person sits on the intent ladder right now.
|
|
105
|
+
*
|
|
106
|
+
* The manual "away" status overrides the ladder. A person who never reported
|
|
107
|
+
* an input is `idle` rather than `away` — absence of evidence is not evidence
|
|
108
|
+
* of leaving. Every tab backgrounded (`hidden`) caps the result below
|
|
109
|
+
* `active`: whatever they did a moment ago, they are demonstrably not looking
|
|
110
|
+
* at Athena now; it does not force `away`, because alt-tabbing to check
|
|
111
|
+
* something is not leaving. A `lastIntentAt` ahead of `now` (a publisher whose
|
|
112
|
+
* clock runs fast) counts as "just acted" rather than a negative elapsed.
|
|
113
|
+
*/
|
|
114
|
+
export declare function derivePresenceState({ lastIntentAt, hidden, manualAway, now, }: {
|
|
115
|
+
lastIntentAt: number | null;
|
|
116
|
+
hidden: boolean;
|
|
117
|
+
manualAway: boolean;
|
|
118
|
+
now: number;
|
|
119
|
+
}): PresenceState;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
const PRESENCE_CONTEXT_TYPE = {
|
|
2
|
+
ASSET: "asset",
|
|
3
|
+
SESSION: "session",
|
|
4
|
+
/** Server-written only: "somewhere in that project", never which document. */
|
|
5
|
+
PROJECT: "project"
|
|
6
|
+
};
|
|
7
|
+
const MAX_PRESENCE_CONTEXTS_PER_SLOT = 64;
|
|
8
|
+
const MAX_PRESENCE_STRING_CHARS = 512;
|
|
9
|
+
const PRESENCE_ACTIVE_WINDOW_MS = 6e4;
|
|
10
|
+
const PRESENCE_IDLE_WINDOW_MS = 3e5;
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function cleanString(value) {
|
|
15
|
+
if (typeof value !== "string") return null;
|
|
16
|
+
const trimmed = value.trim();
|
|
17
|
+
if (!trimmed) return null;
|
|
18
|
+
return trimmed.slice(0, MAX_PRESENCE_STRING_CHARS);
|
|
19
|
+
}
|
|
20
|
+
function cleanEpochMs(value) {
|
|
21
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null;
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
function cleanContextType(value) {
|
|
25
|
+
if (value === PRESENCE_CONTEXT_TYPE.ASSET || value === PRESENCE_CONTEXT_TYPE.SESSION || value === PRESENCE_CONTEXT_TYPE.PROJECT) {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
function cleanStringList(value) {
|
|
31
|
+
if (!Array.isArray(value)) return [];
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const item of value) {
|
|
34
|
+
const cleaned = cleanString(item);
|
|
35
|
+
if (cleaned === null || out.includes(cleaned)) continue;
|
|
36
|
+
out.push(cleaned);
|
|
37
|
+
if (out.length >= MAX_PRESENCE_CONTEXTS_PER_SLOT) break;
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
function parseContext(raw) {
|
|
42
|
+
if (!isRecord(raw)) return null;
|
|
43
|
+
const type = cleanContextType(raw.type);
|
|
44
|
+
const guid = cleanString(raw.guid);
|
|
45
|
+
if (type === null || guid === null) return null;
|
|
46
|
+
return {
|
|
47
|
+
type,
|
|
48
|
+
guid,
|
|
49
|
+
locationLabel: cleanString(raw.locationLabel),
|
|
50
|
+
projects: cleanStringList(raw.projects),
|
|
51
|
+
coarse: raw.coarse === true,
|
|
52
|
+
lastAnchorChangeAt: cleanEpochMs(raw.lastAnchorChangeAt)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function parseContexts(raw) {
|
|
56
|
+
if (!Array.isArray(raw)) return [];
|
|
57
|
+
const seen = /* @__PURE__ */ new Set();
|
|
58
|
+
const contexts = [];
|
|
59
|
+
for (const item of raw) {
|
|
60
|
+
const parsed = parseContext(item);
|
|
61
|
+
if (parsed === null) continue;
|
|
62
|
+
const key = `${parsed.type}:${parsed.guid}`;
|
|
63
|
+
if (seen.has(key)) continue;
|
|
64
|
+
seen.add(key);
|
|
65
|
+
contexts.push(parsed);
|
|
66
|
+
if (contexts.length >= MAX_PRESENCE_CONTEXTS_PER_SLOT) break;
|
|
67
|
+
}
|
|
68
|
+
return contexts;
|
|
69
|
+
}
|
|
70
|
+
function parseAgentInitiator(raw) {
|
|
71
|
+
if (!isRecord(raw)) return null;
|
|
72
|
+
const initiator = {
|
|
73
|
+
userId: cleanString(raw.userId),
|
|
74
|
+
email: cleanString(raw.email),
|
|
75
|
+
name: cleanString(raw.name)
|
|
76
|
+
};
|
|
77
|
+
if (initiator.userId === null && initiator.email === null && initiator.name === null) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return initiator;
|
|
81
|
+
}
|
|
82
|
+
function parseSlot(raw) {
|
|
83
|
+
if (!isRecord(raw)) return null;
|
|
84
|
+
const user = raw.user;
|
|
85
|
+
if (!isRecord(user)) return null;
|
|
86
|
+
const email = cleanString(user.email);
|
|
87
|
+
const userId = cleanString(user.userId) ?? email;
|
|
88
|
+
if (userId === null) return null;
|
|
89
|
+
return {
|
|
90
|
+
kind: user.kind === "agent" ? "agent" : "human",
|
|
91
|
+
userId,
|
|
92
|
+
authUserId: cleanString(user.authUserId),
|
|
93
|
+
name: cleanString(user.name) ?? userId,
|
|
94
|
+
email,
|
|
95
|
+
color: cleanString(user.color),
|
|
96
|
+
avatarUrl: cleanString(user.avatarUrl),
|
|
97
|
+
agentInitiator: parseAgentInitiator(user.agentInitiator),
|
|
98
|
+
contexts: parseContexts(raw.activeContexts),
|
|
99
|
+
hidden: raw.hidden === true,
|
|
100
|
+
lastIntentAt: cleanEpochMs(raw.lastIntentAt),
|
|
101
|
+
manualAway: raw.status === "away"
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function mergeContexts(existing, incoming) {
|
|
105
|
+
const index = /* @__PURE__ */ new Map();
|
|
106
|
+
existing.forEach((context, position) => {
|
|
107
|
+
index.set(`${context.type}:${context.guid}`, position);
|
|
108
|
+
});
|
|
109
|
+
for (const context of incoming) {
|
|
110
|
+
const key = `${context.type}:${context.guid}`;
|
|
111
|
+
const position = index.get(key);
|
|
112
|
+
if (position === void 0) {
|
|
113
|
+
existing.push(context);
|
|
114
|
+
index.set(key, existing.length - 1);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const prior = existing[position];
|
|
118
|
+
if (prior && (context.lastAnchorChangeAt ?? 0) > (prior.lastAnchorChangeAt ?? 0)) {
|
|
119
|
+
existing[position] = context;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function maxIntent(a, b) {
|
|
124
|
+
if (a === null) return b;
|
|
125
|
+
if (b === null) return a;
|
|
126
|
+
return Math.max(a, b);
|
|
127
|
+
}
|
|
128
|
+
function toPresenceContext(context) {
|
|
129
|
+
return {
|
|
130
|
+
type: context.type,
|
|
131
|
+
guid: context.guid,
|
|
132
|
+
locationLabel: context.locationLabel,
|
|
133
|
+
projects: context.projects,
|
|
134
|
+
coarse: context.coarse
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function byName(a, b) {
|
|
138
|
+
const names = a.name.localeCompare(b.name);
|
|
139
|
+
return names !== 0 ? names : a.key.localeCompare(b.key);
|
|
140
|
+
}
|
|
141
|
+
function mergePresenceStates(states) {
|
|
142
|
+
const humans = /* @__PURE__ */ new Map();
|
|
143
|
+
const agents = /* @__PURE__ */ new Map();
|
|
144
|
+
for (const raw of states.values()) {
|
|
145
|
+
const slot = parseSlot(raw);
|
|
146
|
+
if (slot === null) continue;
|
|
147
|
+
if (slot.kind === "agent") {
|
|
148
|
+
const existing2 = agents.get(slot.userId);
|
|
149
|
+
if (existing2) {
|
|
150
|
+
existing2.lastIntentAt = maxIntent(existing2.lastIntentAt, slot.lastIntentAt);
|
|
151
|
+
existing2.agentInitiator = existing2.agentInitiator ?? slot.agentInitiator;
|
|
152
|
+
mergeContexts(existing2.contexts, slot.contexts);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
agents.set(slot.userId, {
|
|
156
|
+
key: slot.userId,
|
|
157
|
+
userId: slot.userId,
|
|
158
|
+
name: slot.name,
|
|
159
|
+
agentInitiator: slot.agentInitiator,
|
|
160
|
+
contexts: [...slot.contexts],
|
|
161
|
+
lastIntentAt: slot.lastIntentAt
|
|
162
|
+
});
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const key = slot.authUserId ?? slot.userId;
|
|
166
|
+
const existing = humans.get(key);
|
|
167
|
+
if (existing) {
|
|
168
|
+
existing.connections += 1;
|
|
169
|
+
existing.lastIntentAt = maxIntent(existing.lastIntentAt, slot.lastIntentAt);
|
|
170
|
+
existing.hidden = existing.hidden && slot.hidden;
|
|
171
|
+
existing.manualAway = existing.manualAway || slot.manualAway;
|
|
172
|
+
existing.email = existing.email ?? slot.email;
|
|
173
|
+
existing.avatarUrl = existing.avatarUrl ?? slot.avatarUrl;
|
|
174
|
+
existing.color = existing.color ?? slot.color;
|
|
175
|
+
mergeContexts(existing.contexts, slot.contexts);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
humans.set(key, {
|
|
179
|
+
key,
|
|
180
|
+
authUserId: slot.authUserId,
|
|
181
|
+
userId: slot.userId,
|
|
182
|
+
name: slot.name,
|
|
183
|
+
email: slot.email,
|
|
184
|
+
avatarUrl: slot.avatarUrl,
|
|
185
|
+
color: slot.color,
|
|
186
|
+
contexts: [...slot.contexts],
|
|
187
|
+
lastIntentAt: slot.lastIntentAt,
|
|
188
|
+
hidden: slot.hidden,
|
|
189
|
+
manualAway: slot.manualAway,
|
|
190
|
+
connections: 1
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
humans: [...humans.values()].map((human) => ({ ...human, contexts: human.contexts.map(toPresenceContext) })).sort(byName),
|
|
195
|
+
agents: [...agents.values()].map((agent) => ({ ...agent, contexts: agent.contexts.map(toPresenceContext) })).sort(byName)
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function derivePresenceState({
|
|
199
|
+
lastIntentAt,
|
|
200
|
+
hidden,
|
|
201
|
+
manualAway,
|
|
202
|
+
now
|
|
203
|
+
}) {
|
|
204
|
+
if (manualAway) return "away";
|
|
205
|
+
if (lastIntentAt === null) return "idle";
|
|
206
|
+
const elapsed = Math.max(0, now - lastIntentAt);
|
|
207
|
+
if (elapsed <= PRESENCE_ACTIVE_WINDOW_MS) return hidden ? "idle" : "active";
|
|
208
|
+
if (elapsed <= PRESENCE_IDLE_WINDOW_MS) return "idle";
|
|
209
|
+
return "away";
|
|
210
|
+
}
|
|
211
|
+
export {
|
|
212
|
+
MAX_PRESENCE_CONTEXTS_PER_SLOT,
|
|
213
|
+
MAX_PRESENCE_STRING_CHARS,
|
|
214
|
+
PRESENCE_ACTIVE_WINDOW_MS,
|
|
215
|
+
PRESENCE_CONTEXT_TYPE,
|
|
216
|
+
PRESENCE_IDLE_WINDOW_MS,
|
|
217
|
+
derivePresenceState,
|
|
218
|
+
mergePresenceStates
|
|
219
|
+
};
|
|
220
|
+
//# sourceMappingURL=presence-slots.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"presence-slots.js","sources":["../../src/collab/presence-slots.ts"],"sourcesContent":["/**\n * Pure decoding of workspace awareness slots into typed presence entries.\n *\n * Every connected Olympus tab (and every agent run, published by the backend)\n * holds one slot in the workspace awareness room. Keryx narrows what each\n * recipient receives (a document the viewer may not open collapses into a\n * coarse `project` context, agents with nothing visible are dropped), so the\n * slots seen here are already scoped to the token holder. This module treats\n * every field as untrusted anyway: a slot is JSON a browser published, and a\n * malformed one must be dropped rather than crash the feed or merge every\n * anonymous tab into one phantom person.\n *\n * The shape mirrors Olympus's `WorkspaceAwarenessSlot`\n * (`olympus/src/store/workspaceAwarenessStore.ts`) and the bounds mirror\n * Agora's observer (`agora/agora/services/presence/awareness_slots.py`).\n * No React, no network — the merge and the intent ladder are testable directly.\n */\n\nexport const PRESENCE_CONTEXT_TYPE = {\n ASSET: 'asset',\n SESSION: 'session',\n /** Server-written only: \"somewhere in that project\", never which document. */\n PROJECT: 'project',\n} as const;\n\nexport type PresenceContextType =\n (typeof PRESENCE_CONTEXT_TYPE)[keyof typeof PRESENCE_CONTEXT_TYPE];\n\n/** Contexts kept per slot; anything past this is publisher junk. */\nexport const MAX_PRESENCE_CONTEXTS_PER_SLOT = 64;\n/** Characters kept per string field. */\nexport const MAX_PRESENCE_STRING_CHARS = 512;\n\n/**\n * Intent ladder thresholds, mirroring `agora/agora/services/presence/types.py`\n * and `olympus/src/sections/presence-monitor/presence-state.ts`. Readers\n * recompute the state from `lastIntentAt` on every tick so it decays smoothly\n * instead of stepping when a snapshot lands.\n */\nexport const PRESENCE_ACTIVE_WINDOW_MS = 60_000;\nexport const PRESENCE_IDLE_WINDOW_MS = 300_000;\n\nexport type PresenceState = 'active' | 'idle' | 'away';\n\nexport interface PresenceContext {\n type: PresenceContextType;\n /** Asset id, session thread id, or project asset id depending on `type`. */\n guid: string;\n /** Human-readable location inside the asset (\"Slide 4\", \"Sheet1!B14\"), when the tab reported one. */\n locationLabel: string | null;\n /** Server-written: projects containing this document that the viewer may also open. */\n projects: string[];\n /** Server-written on `project` contexts: the viewer may not open the underlying document. */\n coarse: boolean;\n}\n\nexport interface PresenceHuman {\n /** Identity the person's tabs merge by: the server-attested id when present, else the self-reported one. */\n key: string;\n /** Agora user id stamped by Keryx from the publishing socket's token; null on unstamped slots. */\n authUserId: string | null;\n /** Self-reported user identifier (the email, for Olympus tabs). */\n userId: string;\n name: string;\n email: string | null;\n avatarUrl: string | null;\n color: string | null;\n /** Union of open contexts across the person's tabs, deduped by type + guid. */\n contexts: PresenceContext[];\n /** Most recent self-reported input across the person's tabs (epoch ms, publisher clock); null when none reported. */\n lastIntentAt: number | null;\n /** True only when every one of the person's tabs reports itself backgrounded. */\n hidden: boolean;\n /** True when any tab carries the manual \"away\" status set in Settings. */\n manualAway: boolean;\n /** Number of awareness slots (tabs) merged into this entry. */\n connections: number;\n}\n\nexport interface PresenceAgent {\n /** The agent slot's own id (`agent:<run>`); runs never collapse by publisher. */\n key: string;\n userId: string;\n name: string;\n /** The person the run works for; what a reader shows as \"Athena for …\". */\n agentInitiator: { userId: string | null; email: string | null; name: string | null } | null;\n contexts: PresenceContext[];\n lastIntentAt: number | null;\n}\n\nexport interface PresenceRoster {\n humans: PresenceHuman[];\n agents: PresenceAgent[];\n}\n\ninterface ParsedContext extends PresenceContext {\n /** Publisher-reported ms of the last anchor change; breaks ties when tabs share a context. */\n lastAnchorChangeAt: number | null;\n}\n\ninterface ParsedSlot {\n kind: 'human' | 'agent';\n userId: string;\n authUserId: string | null;\n name: string;\n email: string | null;\n color: string | null;\n avatarUrl: string | null;\n agentInitiator: PresenceAgent['agentInitiator'];\n contexts: ParsedContext[];\n hidden: boolean;\n lastIntentAt: number | null;\n manualAway: boolean;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value);\n}\n\n/** Accept a non-empty string, trimmed and truncated. Anything else becomes null. */\nfunction cleanString(value: unknown): string | null {\n if (typeof value !== 'string') return null;\n const trimmed = value.trim();\n if (!trimmed) return null;\n return trimmed.slice(0, MAX_PRESENCE_STRING_CHARS);\n}\n\n/** Accept a plausible epoch-milliseconds number, else null. */\nfunction cleanEpochMs(value: unknown): number | null {\n if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;\n return value;\n}\n\nfunction cleanContextType(value: unknown): PresenceContextType | null {\n if (\n value === PRESENCE_CONTEXT_TYPE.ASSET ||\n value === PRESENCE_CONTEXT_TYPE.SESSION ||\n value === PRESENCE_CONTEXT_TYPE.PROJECT\n ) {\n return value;\n }\n return null;\n}\n\nfunction cleanStringList(value: unknown): string[] {\n if (!Array.isArray(value)) return [];\n const out: string[] = [];\n for (const item of value) {\n const cleaned = cleanString(item);\n if (cleaned === null || out.includes(cleaned)) continue;\n out.push(cleaned);\n if (out.length >= MAX_PRESENCE_CONTEXTS_PER_SLOT) break;\n }\n return out;\n}\n\nfunction parseContext(raw: unknown): ParsedContext | null {\n if (!isRecord(raw)) return null;\n const type = cleanContextType(raw.type);\n const guid = cleanString(raw.guid);\n if (type === null || guid === null) return null;\n return {\n type,\n guid,\n locationLabel: cleanString(raw.locationLabel),\n projects: cleanStringList(raw.projects),\n coarse: raw.coarse === true,\n lastAnchorChangeAt: cleanEpochMs(raw.lastAnchorChangeAt),\n };\n}\n\nfunction parseContexts(raw: unknown): ParsedContext[] {\n if (!Array.isArray(raw)) return [];\n const seen = new Set<string>();\n const contexts: ParsedContext[] = [];\n for (const item of raw) {\n const parsed = parseContext(item);\n if (parsed === null) continue;\n const key = `${parsed.type}:${parsed.guid}`;\n if (seen.has(key)) continue;\n seen.add(key);\n contexts.push(parsed);\n if (contexts.length >= MAX_PRESENCE_CONTEXTS_PER_SLOT) break;\n }\n return contexts;\n}\n\nfunction parseAgentInitiator(raw: unknown): PresenceAgent['agentInitiator'] {\n if (!isRecord(raw)) return null;\n const initiator = {\n userId: cleanString(raw.userId),\n email: cleanString(raw.email),\n name: cleanString(raw.name),\n };\n if (initiator.userId === null && initiator.email === null && initiator.name === null) {\n return null;\n }\n return initiator;\n}\n\n/**\n * Validate one slot. Returns null for anything that cannot be attributed to a\n * stable identity: a slot with neither `userId` nor `email` would otherwise\n * merge every anonymous tab in the workspace into one phantom participant.\n * A missing or unrecognised `kind` reads as human — an older client is far\n * likelier than an agent, and mislabelling a person would hide them.\n */\nfunction parseSlot(raw: unknown): ParsedSlot | null {\n if (!isRecord(raw)) return null;\n const user = raw.user;\n if (!isRecord(user)) return null;\n const email = cleanString(user.email);\n const userId = cleanString(user.userId) ?? email;\n if (userId === null) return null;\n return {\n kind: user.kind === 'agent' ? 'agent' : 'human',\n userId,\n authUserId: cleanString(user.authUserId),\n name: cleanString(user.name) ?? userId,\n email,\n color: cleanString(user.color),\n avatarUrl: cleanString(user.avatarUrl),\n agentInitiator: parseAgentInitiator(user.agentInitiator),\n contexts: parseContexts(raw.activeContexts),\n hidden: raw.hidden === true,\n lastIntentAt: cleanEpochMs(raw.lastIntentAt),\n manualAway: raw.status === 'away',\n };\n}\n\n/**\n * Fold one slot's contexts into an already-seen participant's list, keeping\n * the entry with the most recent anchor activity for a duplicate (type, guid).\n */\nfunction mergeContexts(existing: ParsedContext[], incoming: readonly ParsedContext[]): void {\n const index = new Map<string, number>();\n existing.forEach((context, position) => {\n index.set(`${context.type}:${context.guid}`, position);\n });\n for (const context of incoming) {\n const key = `${context.type}:${context.guid}`;\n const position = index.get(key);\n if (position === undefined) {\n existing.push(context);\n index.set(key, existing.length - 1);\n continue;\n }\n const prior = existing[position];\n if (prior && (context.lastAnchorChangeAt ?? 0) > (prior.lastAnchorChangeAt ?? 0)) {\n existing[position] = context;\n }\n }\n}\n\nfunction maxIntent(a: number | null, b: number | null): number | null {\n if (a === null) return b;\n if (b === null) return a;\n return Math.max(a, b);\n}\n\nfunction toPresenceContext(context: ParsedContext): PresenceContext {\n return {\n type: context.type,\n guid: context.guid,\n locationLabel: context.locationLabel,\n projects: context.projects,\n coarse: context.coarse,\n };\n}\n\ninterface HumanAccumulator extends Omit<PresenceHuman, 'contexts'> {\n contexts: ParsedContext[];\n}\n\ninterface AgentAccumulator extends Omit<PresenceAgent, 'contexts'> {\n contexts: ParsedContext[];\n}\n\nfunction byName<T extends { name: string; key: string }>(a: T, b: T): number {\n const names = a.name.localeCompare(b.name);\n return names !== 0 ? names : a.key.localeCompare(b.key);\n}\n\n/**\n * Merge the awareness room's slots into one entry per person and one per\n * agent run.\n *\n * Humans merge by the server-attested `authUserId` when Keryx stamped one\n * (a tab that merely *claims* a colleague's `userId` gets its own row, with its\n * real identity on it) and by the self-reported `userId` only for unstamped\n * slots — the same rule as Agora's observer and Olympus's store. A person is\n * as active as their most active tab, backgrounded only when every tab is,\n * and away whenever any tab carries the manual flag. Agents keep their own\n * slot id: every run is published by the same service identity, so merging\n * by publisher would collapse concurrent runs into one.\n */\nexport function mergePresenceStates(states: ReadonlyMap<number, unknown>): PresenceRoster {\n const humans = new Map<string, HumanAccumulator>();\n const agents = new Map<string, AgentAccumulator>();\n\n for (const raw of states.values()) {\n const slot = parseSlot(raw);\n if (slot === null) continue;\n\n if (slot.kind === 'agent') {\n const existing = agents.get(slot.userId);\n if (existing) {\n existing.lastIntentAt = maxIntent(existing.lastIntentAt, slot.lastIntentAt);\n existing.agentInitiator = existing.agentInitiator ?? slot.agentInitiator;\n mergeContexts(existing.contexts, slot.contexts);\n continue;\n }\n agents.set(slot.userId, {\n key: slot.userId,\n userId: slot.userId,\n name: slot.name,\n agentInitiator: slot.agentInitiator,\n contexts: [...slot.contexts],\n lastIntentAt: slot.lastIntentAt,\n });\n continue;\n }\n\n const key = slot.authUserId ?? slot.userId;\n const existing = humans.get(key);\n if (existing) {\n existing.connections += 1;\n existing.lastIntentAt = maxIntent(existing.lastIntentAt, slot.lastIntentAt);\n existing.hidden = existing.hidden && slot.hidden;\n existing.manualAway = existing.manualAway || slot.manualAway;\n // A person with tabs in two surfaces publishes identity from both;\n // prefer whichever slot actually carried the optional fields.\n existing.email = existing.email ?? slot.email;\n existing.avatarUrl = existing.avatarUrl ?? slot.avatarUrl;\n existing.color = existing.color ?? slot.color;\n mergeContexts(existing.contexts, slot.contexts);\n continue;\n }\n humans.set(key, {\n key,\n authUserId: slot.authUserId,\n userId: slot.userId,\n name: slot.name,\n email: slot.email,\n avatarUrl: slot.avatarUrl,\n color: slot.color,\n contexts: [...slot.contexts],\n lastIntentAt: slot.lastIntentAt,\n hidden: slot.hidden,\n manualAway: slot.manualAway,\n connections: 1,\n });\n }\n\n return {\n humans: [...humans.values()]\n .map((human) => ({ ...human, contexts: human.contexts.map(toPresenceContext) }))\n .sort(byName),\n agents: [...agents.values()]\n .map((agent) => ({ ...agent, contexts: agent.contexts.map(toPresenceContext) }))\n .sort(byName),\n };\n}\n\n/**\n * Where a person sits on the intent ladder right now.\n *\n * The manual \"away\" status overrides the ladder. A person who never reported\n * an input is `idle` rather than `away` — absence of evidence is not evidence\n * of leaving. Every tab backgrounded (`hidden`) caps the result below\n * `active`: whatever they did a moment ago, they are demonstrably not looking\n * at Athena now; it does not force `away`, because alt-tabbing to check\n * something is not leaving. A `lastIntentAt` ahead of `now` (a publisher whose\n * clock runs fast) counts as \"just acted\" rather than a negative elapsed.\n */\nexport function derivePresenceState({\n lastIntentAt,\n hidden,\n manualAway,\n now,\n}: {\n lastIntentAt: number | null;\n hidden: boolean;\n manualAway: boolean;\n now: number;\n}): PresenceState {\n if (manualAway) return 'away';\n if (lastIntentAt === null) return 'idle';\n const elapsed = Math.max(0, now - lastIntentAt);\n if (elapsed <= PRESENCE_ACTIVE_WINDOW_MS) return hidden ? 'idle' : 'active';\n if (elapsed <= PRESENCE_IDLE_WINDOW_MS) return 'idle';\n return 'away';\n}\n"],"names":["existing"],"mappings":"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"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
|
+
const REFRESH_HEADROOM_MS = 5 * 60 * 1e3;
|
|
4
|
+
const MIN_REFRESH_DELAY_MS = 30 * 1e3;
|
|
5
|
+
const MAX_CONSECUTIVE_REMINTS = 2;
|
|
6
|
+
function refreshDelayMs({ expiresAtMs, now }) {
|
|
7
|
+
return Math.max(expiresAtMs - now - REFRESH_HEADROOM_MS, MIN_REFRESH_DELAY_MS);
|
|
8
|
+
}
|
|
9
|
+
exports.MAX_CONSECUTIVE_REMINTS = MAX_CONSECUTIVE_REMINTS;
|
|
10
|
+
exports.MIN_REFRESH_DELAY_MS = MIN_REFRESH_DELAY_MS;
|
|
11
|
+
exports.REFRESH_HEADROOM_MS = REFRESH_HEADROOM_MS;
|
|
12
|
+
exports.refreshDelayMs = refreshDelayMs;
|
|
13
|
+
//# sourceMappingURL=token-refresh.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-refresh.cjs","sources":["../../src/collab/token-refresh.ts"],"sourcesContent":["/**\n * Token-refresh policy shared by every Keryx client in this package.\n *\n * Both the Generic Doc client and the workspace-presence observer mint a\n * short-lived room-bound token and must replace it before it expires. The\n * constants live here so the two clients cannot drift apart: a presence feed\n * that re-minted on a different schedule than a document would be a second\n * policy to reason about during an incident, for no benefit.\n */\n\n/** Re-mint this long before token expiry (clamped to a floor for short TTLs). */\nexport const REFRESH_HEADROOM_MS = 5 * 60 * 1000;\n\n/** Never schedule a refresh sooner than this, however short the grant. */\nexport const MIN_REFRESH_DELAY_MS = 30 * 1000;\n\n/** Consecutive 4401→mint cycles allowed without a successful connect between. */\nexport const MAX_CONSECUTIVE_REMINTS = 2;\n\n/** Milliseconds until the scheduled re-mint for a grant expiring at `expiresAtMs`. */\nexport function refreshDelayMs({ expiresAtMs, now }: { expiresAtMs: number; now: number }): number {\n return Math.max(expiresAtMs - now - REFRESH_HEADROOM_MS, MIN_REFRESH_DELAY_MS);\n}\n"],"names":[],"mappings":";;AAWO,MAAM,sBAAsB,IAAI,KAAK;AAGrC,MAAM,uBAAuB,KAAK;AAGlC,MAAM,0BAA0B;AAGhC,SAAS,eAAe,EAAE,aAAa,OAAqD;AACjG,SAAO,KAAK,IAAI,cAAc,MAAM,qBAAqB,oBAAoB;AAC/E;;;;;"}
|