@agent-native/core 0.84.55 → 0.84.57
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/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/docs/content/locales/ar-SA/template-analytics.mdx +2 -0
- package/corpus/core/docs/content/locales/ar-SA/template-clips.mdx +2 -0
- package/corpus/core/docs/content/locales/de-DE/template-analytics.mdx +2 -0
- package/corpus/core/docs/content/locales/de-DE/template-clips.mdx +2 -0
- package/corpus/core/docs/content/locales/es-ES/template-analytics.mdx +2 -0
- package/corpus/core/docs/content/locales/es-ES/template-clips.mdx +2 -0
- package/corpus/core/docs/content/locales/fr-FR/template-analytics.mdx +2 -0
- package/corpus/core/docs/content/locales/fr-FR/template-clips.mdx +2 -0
- package/corpus/core/docs/content/locales/hi-IN/template-analytics.mdx +2 -0
- package/corpus/core/docs/content/locales/hi-IN/template-clips.mdx +2 -0
- package/corpus/core/docs/content/locales/ja-JP/template-analytics.mdx +2 -0
- package/corpus/core/docs/content/locales/ja-JP/template-clips.mdx +2 -0
- package/corpus/core/docs/content/locales/ko-KR/template-analytics.mdx +2 -0
- package/corpus/core/docs/content/locales/ko-KR/template-clips.mdx +2 -0
- package/corpus/core/docs/content/locales/pt-BR/template-analytics.mdx +2 -0
- package/corpus/core/docs/content/locales/pt-BR/template-clips.mdx +2 -0
- package/corpus/core/docs/content/locales/zh-CN/template-analytics.mdx +2 -0
- package/corpus/core/docs/content/locales/zh-CN/template-clips.mdx +2 -0
- package/corpus/core/docs/content/locales/zh-TW/template-analytics.mdx +2 -0
- package/corpus/core/docs/content/locales/zh-TW/template-clips.mdx +2 -0
- package/corpus/core/docs/content/template-analytics.mdx +2 -0
- package/corpus/core/docs/content/template-clips.mdx +22 -10
- package/corpus/core/docs/content/tracking.mdx +2 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/cli/recap.ts +146 -5
- package/corpus/templates/analytics/.agents/skills/session-replay/SKILL.md +8 -0
- package/corpus/templates/analytics/AGENTS.md +5 -0
- package/corpus/templates/analytics/actions/create-session-replay-agent-link.ts +31 -0
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/analytics/app/i18n-data.ts +20 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionDetailPage.tsx +65 -3
- package/corpus/templates/analytics/changelog/2026-07-02-session-replays-can-be-copied-as-temporary-private-links-for-agents.md +6 -0
- package/corpus/templates/analytics/server/handlers/session-replay.ts +78 -0
- package/corpus/templates/analytics/server/lib/session-replay-agent-context.ts +313 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +198 -3
- package/corpus/templates/analytics/server/routes/[...page].get.ts +104 -1
- package/corpus/templates/analytics/server/routes/api/session-replay/agent-context.json.get.ts +48 -0
- package/corpus/templates/analytics/server/routes/api/session-replay/agent-events.json.get.ts +63 -0
- package/corpus/templates/analytics/shared/session-replay-agent-access.ts +10 -0
- package/corpus/templates/clips/.agents/skills/video-sharing/SKILL.md +14 -8
- package/corpus/templates/clips/AGENTS.md +3 -2
- package/corpus/templates/clips/actions/create-recording-agent-link.ts +90 -0
- package/corpus/templates/clips/app/components/player/share-dialog.tsx +43 -46
- package/corpus/templates/clips/app/components/sharing/share-ui.tsx +5 -3
- package/corpus/templates/clips/app/i18n/ar-SA.ts +1 -1
- package/corpus/templates/clips/app/i18n/de-DE.ts +1 -1
- package/corpus/templates/clips/app/i18n/en-US.ts +1 -1
- package/corpus/templates/clips/app/i18n/es-ES.ts +1 -1
- package/corpus/templates/clips/app/i18n/fr-FR.ts +1 -1
- package/corpus/templates/clips/app/i18n/hi-IN.ts +1 -1
- package/corpus/templates/clips/app/i18n/ja-JP.ts +1 -1
- package/corpus/templates/clips/app/i18n/ko-KR.ts +1 -1
- package/corpus/templates/clips/app/i18n/pt-BR.ts +1 -1
- package/corpus/templates/clips/app/i18n/zh-CN.ts +1 -1
- package/corpus/templates/clips/app/i18n/zh-TW.ts +1 -1
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +100 -46
- package/corpus/templates/clips/changelog/2026-07-02-private-recordings-can-be-shared-with-agents-through-temporary-links-without-making-them-public.md +6 -0
- package/corpus/templates/clips/server/lib/public-agent-context.ts +26 -13
- package/corpus/templates/clips/server/routes/api/agent-context.json.get.ts +2 -1
- package/corpus/templates/clips/server/routes/api/agent-frame.jpg.get.ts +2 -1
- package/corpus/templates/clips/server/routes/api/agent-transcript.json.get.ts +2 -1
- package/corpus/templates/clips/server/routes/api/public-recording.get.ts +7 -2
- package/corpus/templates/clips/shared/agent-context.ts +5 -0
- package/corpus/templates/plan/actions/create-visual-recap.ts +39 -0
- package/corpus/templates/plan/actions/list-visual-plans.ts +2 -0
- package/corpus/templates/plan/actions/view-screen.ts +2 -0
- package/corpus/templates/plan/app/pages/PlansPage.tsx +279 -114
- package/corpus/templates/plan/changelog/2026-07-02-plan-comment-shortcuts-now-open-comment-mode-from-the-keyboa.md +6 -0
- package/corpus/templates/plan/server/db/schema.ts +3 -0
- package/corpus/templates/plan/server/lib/comment-notifications.ts +21 -2
- package/corpus/templates/plan/server/plans.ts +6 -0
- package/corpus/templates/plan/server/plugins/db.ts +11 -0
- package/corpus/templates/plan/shared/comment-context.ts +1 -0
- package/corpus/templates/plan/shared/types.ts +4 -0
- package/dist/cli/recap.d.ts +14 -0
- package/dist/cli/recap.d.ts.map +1 -1
- package/dist/cli/recap.js +88 -5
- package/dist/cli/recap.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +4 -4
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/docs/content/locales/ar-SA/template-analytics.mdx +2 -0
- package/docs/content/locales/ar-SA/template-clips.mdx +2 -0
- package/docs/content/locales/de-DE/template-analytics.mdx +2 -0
- package/docs/content/locales/de-DE/template-clips.mdx +2 -0
- package/docs/content/locales/es-ES/template-analytics.mdx +2 -0
- package/docs/content/locales/es-ES/template-clips.mdx +2 -0
- package/docs/content/locales/fr-FR/template-analytics.mdx +2 -0
- package/docs/content/locales/fr-FR/template-clips.mdx +2 -0
- package/docs/content/locales/hi-IN/template-analytics.mdx +2 -0
- package/docs/content/locales/hi-IN/template-clips.mdx +2 -0
- package/docs/content/locales/ja-JP/template-analytics.mdx +2 -0
- package/docs/content/locales/ja-JP/template-clips.mdx +2 -0
- package/docs/content/locales/ko-KR/template-analytics.mdx +2 -0
- package/docs/content/locales/ko-KR/template-clips.mdx +2 -0
- package/docs/content/locales/pt-BR/template-analytics.mdx +2 -0
- package/docs/content/locales/pt-BR/template-clips.mdx +2 -0
- package/docs/content/locales/zh-CN/template-analytics.mdx +2 -0
- package/docs/content/locales/zh-CN/template-clips.mdx +2 -0
- package/docs/content/locales/zh-TW/template-analytics.mdx +2 -0
- package/docs/content/locales/zh-TW/template-clips.mdx +2 -0
- package/docs/content/template-analytics.mdx +2 -0
- package/docs/content/template-clips.mdx +22 -10
- package/docs/content/tracking.mdx +2 -0
- package/package.json +1 -1
|
@@ -144,6 +144,70 @@ export interface SessionRecordingSummary {
|
|
|
144
144
|
canManage?: boolean;
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
export interface AgentSessionRecordingSummary {
|
|
148
|
+
id: string;
|
|
149
|
+
clientRecordingId: string;
|
|
150
|
+
sessionId: string;
|
|
151
|
+
userId: string | null;
|
|
152
|
+
anonymousId: string | null;
|
|
153
|
+
userKey: string | null;
|
|
154
|
+
startedAt: string;
|
|
155
|
+
endedAt: string | null;
|
|
156
|
+
durationMs: number | null;
|
|
157
|
+
chunkCount: number;
|
|
158
|
+
eventCount: number;
|
|
159
|
+
totalBytes: number;
|
|
160
|
+
pageCount: number;
|
|
161
|
+
errorCount: number;
|
|
162
|
+
rageClickCount: number;
|
|
163
|
+
privacyMode: string;
|
|
164
|
+
firstUrl: string | null;
|
|
165
|
+
lastUrl: string | null;
|
|
166
|
+
path: string | null;
|
|
167
|
+
hostname: string | null;
|
|
168
|
+
referrer: string | null;
|
|
169
|
+
app: string | null;
|
|
170
|
+
template: string | null;
|
|
171
|
+
status: "active" | "completed";
|
|
172
|
+
createdAt: string;
|
|
173
|
+
updatedAt: string;
|
|
174
|
+
lastIngestedAt: string | null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function compactSessionRecordingSummary(
|
|
178
|
+
recording: SessionRecordingSummary,
|
|
179
|
+
): AgentSessionRecordingSummary {
|
|
180
|
+
return {
|
|
181
|
+
id: recording.id,
|
|
182
|
+
clientRecordingId: recording.clientRecordingId,
|
|
183
|
+
sessionId: recording.sessionId,
|
|
184
|
+
userId: recording.userId,
|
|
185
|
+
anonymousId: recording.anonymousId,
|
|
186
|
+
userKey: recording.userKey,
|
|
187
|
+
startedAt: recording.startedAt,
|
|
188
|
+
endedAt: recording.endedAt,
|
|
189
|
+
durationMs: recording.durationMs,
|
|
190
|
+
chunkCount: recording.chunkCount,
|
|
191
|
+
eventCount: recording.eventCount,
|
|
192
|
+
totalBytes: recording.totalBytes,
|
|
193
|
+
pageCount: recording.pageCount,
|
|
194
|
+
errorCount: recording.errorCount,
|
|
195
|
+
rageClickCount: recording.rageClickCount,
|
|
196
|
+
privacyMode: recording.privacyMode,
|
|
197
|
+
firstUrl: recording.firstUrl,
|
|
198
|
+
lastUrl: recording.lastUrl,
|
|
199
|
+
path: recording.path,
|
|
200
|
+
hostname: recording.hostname,
|
|
201
|
+
referrer: recording.referrer,
|
|
202
|
+
app: recording.app,
|
|
203
|
+
template: recording.template,
|
|
204
|
+
status: recording.status,
|
|
205
|
+
createdAt: recording.createdAt,
|
|
206
|
+
updatedAt: recording.updatedAt,
|
|
207
|
+
lastIngestedAt: recording.lastIngestedAt,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
147
211
|
const MAX_REPLAY_CHUNKS_PER_REQUEST = 20;
|
|
148
212
|
const MAX_REPLAY_CHUNKS_PER_RECORDING = 2_000;
|
|
149
213
|
const MAX_INLINE_REPLAY_CHUNK_BYTES = 256 * 1024;
|
|
@@ -1524,6 +1588,22 @@ export async function getSessionReplaySummary(
|
|
|
1524
1588
|
return rowToSessionRecordingSummary(access.resource, access.role);
|
|
1525
1589
|
}
|
|
1526
1590
|
|
|
1591
|
+
export async function getSessionReplayTokenizedSummary(
|
|
1592
|
+
recordingId: string,
|
|
1593
|
+
): Promise<SessionRecordingSummary> {
|
|
1594
|
+
const db = getDb() as any;
|
|
1595
|
+
// guard:allow-unscoped -- called only after verifySessionReplayAgentAccess(recordingId, token) verifies a signed, recording-scoped agent_access token.
|
|
1596
|
+
const [row] = await db
|
|
1597
|
+
.select()
|
|
1598
|
+
.from(schema.sessionRecordings)
|
|
1599
|
+
.where(eq(schema.sessionRecordings.id, recordingId))
|
|
1600
|
+
.limit(1);
|
|
1601
|
+
if (!row || !isVisibleSessionRecording(row)) {
|
|
1602
|
+
throw replayError("Session recording not found", 404);
|
|
1603
|
+
}
|
|
1604
|
+
return rowToSessionRecordingSummary(row, "viewer");
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1527
1607
|
function parseInlineReplayEvents(inlineData: string): unknown[] {
|
|
1528
1608
|
try {
|
|
1529
1609
|
const parsed = JSON.parse(inlineData);
|
|
@@ -1563,8 +1643,47 @@ export async function getSessionReplayManifest(
|
|
|
1563
1643
|
}>;
|
|
1564
1644
|
}> {
|
|
1565
1645
|
const recording = await getSessionReplaySummary(recordingId, scope);
|
|
1646
|
+
return getSessionReplayManifestForRecording(recording);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
export async function getSessionReplayTokenizedManifest(
|
|
1650
|
+
recordingId: string,
|
|
1651
|
+
): Promise<{
|
|
1652
|
+
recording: AgentSessionRecordingSummary;
|
|
1653
|
+
chunks: Array<{
|
|
1654
|
+
seq: number;
|
|
1655
|
+
checksum: string;
|
|
1656
|
+
byteLength: number;
|
|
1657
|
+
eventCount: number;
|
|
1658
|
+
startedAt: string | null;
|
|
1659
|
+
endedAt: string | null;
|
|
1660
|
+
bytesPath: string;
|
|
1661
|
+
}>;
|
|
1662
|
+
}> {
|
|
1663
|
+
const recording = await getSessionReplayTokenizedSummary(recordingId);
|
|
1664
|
+
const manifest = await getSessionReplayManifestForRecording(recording);
|
|
1665
|
+
return {
|
|
1666
|
+
...manifest,
|
|
1667
|
+
recording: compactSessionRecordingSummary(manifest.recording),
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
async function getSessionReplayManifestForRecording(
|
|
1672
|
+
recording: SessionRecordingSummary,
|
|
1673
|
+
): Promise<{
|
|
1674
|
+
recording: SessionRecordingSummary;
|
|
1675
|
+
chunks: Array<{
|
|
1676
|
+
seq: number;
|
|
1677
|
+
checksum: string;
|
|
1678
|
+
byteLength: number;
|
|
1679
|
+
eventCount: number;
|
|
1680
|
+
startedAt: string | null;
|
|
1681
|
+
endedAt: string | null;
|
|
1682
|
+
bytesPath: string;
|
|
1683
|
+
}>;
|
|
1684
|
+
}> {
|
|
1566
1685
|
const db = getDb() as any;
|
|
1567
|
-
// guard:allow-unscoped -- chunk rows are loaded only after resolveAccess("session-recording", recordingId) verifies
|
|
1686
|
+
// guard:allow-unscoped -- chunk rows are loaded only after resolveAccess("session-recording", recordingId) or a scoped agent token verifies access; chunks are not directly shareable resources.
|
|
1568
1687
|
const rows = await db
|
|
1569
1688
|
.select()
|
|
1570
1689
|
.from(schema.sessionReplayChunks)
|
|
@@ -1599,8 +1718,39 @@ export async function readSessionReplayChunkBytes(
|
|
|
1599
1718
|
json: string;
|
|
1600
1719
|
}> {
|
|
1601
1720
|
const recording = await getSessionReplaySummary(recordingId, scope);
|
|
1721
|
+
return readSessionReplayChunkBytesForRecording(recording, seq);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
export async function readSessionReplayTokenizedChunkBytes(
|
|
1725
|
+
recordingId: string,
|
|
1726
|
+
seq: number,
|
|
1727
|
+
): Promise<{
|
|
1728
|
+
recording: AgentSessionRecordingSummary;
|
|
1729
|
+
seq: number;
|
|
1730
|
+
checksum: string;
|
|
1731
|
+
/** Decompressed replay-chunk JSON text (a serialized rrweb events array). */
|
|
1732
|
+
json: string;
|
|
1733
|
+
}> {
|
|
1734
|
+
const recording = await getSessionReplayTokenizedSummary(recordingId);
|
|
1735
|
+
const chunk = await readSessionReplayChunkBytesForRecording(recording, seq);
|
|
1736
|
+
return {
|
|
1737
|
+
...chunk,
|
|
1738
|
+
recording: compactSessionRecordingSummary(chunk.recording),
|
|
1739
|
+
};
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
async function readSessionReplayChunkBytesForRecording(
|
|
1743
|
+
recording: SessionRecordingSummary,
|
|
1744
|
+
seq: number,
|
|
1745
|
+
): Promise<{
|
|
1746
|
+
recording: SessionRecordingSummary;
|
|
1747
|
+
seq: number;
|
|
1748
|
+
checksum: string;
|
|
1749
|
+
/** Decompressed replay-chunk JSON text (a serialized rrweb events array). */
|
|
1750
|
+
json: string;
|
|
1751
|
+
}> {
|
|
1602
1752
|
const db = getDb() as any;
|
|
1603
|
-
// guard:allow-unscoped -- chunk rows are loaded only after resolveAccess("session-recording", recordingId) verifies
|
|
1753
|
+
// guard:allow-unscoped -- chunk rows are loaded only after resolveAccess("session-recording", recordingId) or a scoped agent token verifies access; chunks are not directly shareable resources.
|
|
1604
1754
|
const [row] = await db
|
|
1605
1755
|
.select()
|
|
1606
1756
|
.from(schema.sessionReplayChunks)
|
|
@@ -1660,6 +1810,51 @@ export async function getSessionReplayEvents(
|
|
|
1660
1810
|
unavailableChunks: number;
|
|
1661
1811
|
}> {
|
|
1662
1812
|
const recording = await getSessionReplaySummary(recordingId, scope);
|
|
1813
|
+
return getSessionReplayEventsForRecording(recording, options);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
export async function getSessionReplayTokenizedEvents(
|
|
1817
|
+
recordingId: string,
|
|
1818
|
+
options: SessionReplayEventReadOptions = {},
|
|
1819
|
+
): Promise<{
|
|
1820
|
+
recording: AgentSessionRecordingSummary;
|
|
1821
|
+
chunks: Array<{
|
|
1822
|
+
seq: number;
|
|
1823
|
+
checksum: string;
|
|
1824
|
+
byteLength: number;
|
|
1825
|
+
eventCount: number;
|
|
1826
|
+
events: unknown[];
|
|
1827
|
+
unavailable?: boolean;
|
|
1828
|
+
}>;
|
|
1829
|
+
eventCount: number;
|
|
1830
|
+
truncated: boolean;
|
|
1831
|
+
unavailableChunks: number;
|
|
1832
|
+
}> {
|
|
1833
|
+
const recording = await getSessionReplayTokenizedSummary(recordingId);
|
|
1834
|
+
const result = await getSessionReplayEventsForRecording(recording, options);
|
|
1835
|
+
return {
|
|
1836
|
+
...result,
|
|
1837
|
+
recording: compactSessionRecordingSummary(result.recording),
|
|
1838
|
+
};
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
async function getSessionReplayEventsForRecording(
|
|
1842
|
+
recording: SessionRecordingSummary,
|
|
1843
|
+
options: SessionReplayEventReadOptions = {},
|
|
1844
|
+
): Promise<{
|
|
1845
|
+
recording: SessionRecordingSummary;
|
|
1846
|
+
chunks: Array<{
|
|
1847
|
+
seq: number;
|
|
1848
|
+
checksum: string;
|
|
1849
|
+
byteLength: number;
|
|
1850
|
+
eventCount: number;
|
|
1851
|
+
events: unknown[];
|
|
1852
|
+
unavailable?: boolean;
|
|
1853
|
+
}>;
|
|
1854
|
+
eventCount: number;
|
|
1855
|
+
truncated: boolean;
|
|
1856
|
+
unavailableChunks: number;
|
|
1857
|
+
}> {
|
|
1663
1858
|
const maxEvents = Math.min(
|
|
1664
1859
|
MAX_REPLAY_EVENTS_READ,
|
|
1665
1860
|
Math.max(1, options.limit ?? MAX_REPLAY_EVENTS_READ),
|
|
@@ -1675,7 +1870,7 @@ export async function getSessionReplayEvents(
|
|
|
1675
1870
|
}
|
|
1676
1871
|
|
|
1677
1872
|
const db = getDb() as any;
|
|
1678
|
-
// guard:allow-unscoped -- chunk rows are loaded only after resolveAccess("session-recording", recordingId) verifies
|
|
1873
|
+
// guard:allow-unscoped -- chunk rows are loaded only after resolveAccess("session-recording", recordingId) or a scoped agent token verifies access; chunks are not directly shareable resources.
|
|
1679
1874
|
const rows = await db
|
|
1680
1875
|
.select()
|
|
1681
1876
|
.from(schema.sessionReplayChunks)
|
|
@@ -1,5 +1,108 @@
|
|
|
1
1
|
import { createH3SSRHandler } from "@agent-native/core/server/ssr-handler";
|
|
2
|
+
import {
|
|
3
|
+
defineEventHandler,
|
|
4
|
+
getQuery,
|
|
5
|
+
getRequestURL,
|
|
6
|
+
setResponseHeader,
|
|
7
|
+
} from "h3";
|
|
2
8
|
|
|
3
|
-
|
|
9
|
+
import {
|
|
10
|
+
buildSessionReplayAgentContext,
|
|
11
|
+
safeJsonForHtml,
|
|
12
|
+
SESSION_REPLAY_AGENT_ACCESS_PARAM,
|
|
13
|
+
} from "../lib/session-replay-agent-context.js";
|
|
14
|
+
|
|
15
|
+
const ssrHandler = createH3SSRHandler(
|
|
4
16
|
() => import("virtual:react-router/server-build"),
|
|
5
17
|
);
|
|
18
|
+
|
|
19
|
+
function configuredAppBasePath(): string {
|
|
20
|
+
const raw = process.env.VITE_APP_BASE_PATH || process.env.APP_BASE_PATH || "";
|
|
21
|
+
if (!raw || raw === "/") return "";
|
|
22
|
+
const normalized = raw.startsWith("/") ? raw : `/${raw}`;
|
|
23
|
+
return normalized.replace(/\/+$/, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stripAppBasePath(pathname: string): string {
|
|
27
|
+
const basePath = configuredAppBasePath();
|
|
28
|
+
if (!basePath) return pathname;
|
|
29
|
+
if (pathname === basePath) return "/";
|
|
30
|
+
if (pathname.startsWith(`${basePath}/`)) {
|
|
31
|
+
return pathname.slice(basePath.length) || "/";
|
|
32
|
+
}
|
|
33
|
+
return pathname;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sessionRecordingIdFromPath(pathname: string): string | null {
|
|
37
|
+
const match = stripAppBasePath(pathname).match(/^\/sessions\/([^/]+)\/?$/);
|
|
38
|
+
if (!match?.[1]) return null;
|
|
39
|
+
try {
|
|
40
|
+
return decodeURIComponent(match[1]);
|
|
41
|
+
} catch {
|
|
42
|
+
return match[1];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function queryString(value: unknown): string {
|
|
47
|
+
if (typeof value === "string") return value;
|
|
48
|
+
if (Array.isArray(value) && typeof value[0] === "string") return value[0];
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function buildAgentDiscoveryScript(event: any): Promise<string | null> {
|
|
53
|
+
const requestUrl = getRequestURL(event);
|
|
54
|
+
const recordingId = sessionRecordingIdFromPath(requestUrl.pathname);
|
|
55
|
+
if (!recordingId) return null;
|
|
56
|
+
|
|
57
|
+
const query = getQuery(event);
|
|
58
|
+
const token = queryString(query[SESSION_REPLAY_AGENT_ACCESS_PARAM]);
|
|
59
|
+
if (!token) return null;
|
|
60
|
+
|
|
61
|
+
const context = await buildSessionReplayAgentContext({
|
|
62
|
+
recordingId,
|
|
63
|
+
token,
|
|
64
|
+
origin: requestUrl.origin,
|
|
65
|
+
includeTimeline: false,
|
|
66
|
+
}).catch(() => null);
|
|
67
|
+
if (!context) return null;
|
|
68
|
+
|
|
69
|
+
return `<script type="application/agent-native+json" id="analytics-session-replay-agent-context">${safeJsonForHtml(
|
|
70
|
+
context,
|
|
71
|
+
)}</script>`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function injectAgentDiscovery(html: string, script: string): string {
|
|
75
|
+
if (html.includes('id="analytics-session-replay-agent-context"')) {
|
|
76
|
+
return html;
|
|
77
|
+
}
|
|
78
|
+
if (html.includes("</head>")) {
|
|
79
|
+
return html.replace("</head>", `${script}</head>`);
|
|
80
|
+
}
|
|
81
|
+
if (html.includes("</body>")) {
|
|
82
|
+
return html.replace("</body>", `${script}</body>`);
|
|
83
|
+
}
|
|
84
|
+
return `${html}${script}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default defineEventHandler(async (event) => {
|
|
88
|
+
const response = (await ssrHandler(event)) as Response;
|
|
89
|
+
const script = await buildAgentDiscoveryScript(event);
|
|
90
|
+
if (!script) return response;
|
|
91
|
+
|
|
92
|
+
const contentType = response.headers.get("content-type") ?? "";
|
|
93
|
+
if (!contentType.includes("text/html")) return response;
|
|
94
|
+
|
|
95
|
+
const html = await response.text();
|
|
96
|
+
const headers = new Headers(response.headers);
|
|
97
|
+
headers.delete("content-length");
|
|
98
|
+
headers.set("Cache-Control", "private, max-age=0, no-store");
|
|
99
|
+
headers.set("Referrer-Policy", "no-referrer");
|
|
100
|
+
setResponseHeader(event, "Cache-Control", "private, max-age=0, no-store");
|
|
101
|
+
setResponseHeader(event, "Referrer-Policy", "no-referrer");
|
|
102
|
+
|
|
103
|
+
return new Response(injectAgentDiscovery(html, script), {
|
|
104
|
+
status: response.status,
|
|
105
|
+
statusText: response.statusText,
|
|
106
|
+
headers,
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineEventHandler,
|
|
3
|
+
getQuery,
|
|
4
|
+
getRequestURL,
|
|
5
|
+
setResponseHeader,
|
|
6
|
+
setResponseStatus,
|
|
7
|
+
} from "h3";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
buildSessionReplayAgentContext,
|
|
11
|
+
SESSION_REPLAY_AGENT_ACCESS_PARAM,
|
|
12
|
+
} from "../../../lib/session-replay-agent-context.js";
|
|
13
|
+
|
|
14
|
+
function queryString(value: unknown): string {
|
|
15
|
+
if (typeof value === "string") return value;
|
|
16
|
+
if (Array.isArray(value) && typeof value[0] === "string") return value[0];
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function applyAgentJsonHeaders(event: any) {
|
|
21
|
+
setResponseHeader(event, "Content-Type", "application/json; charset=utf-8");
|
|
22
|
+
setResponseHeader(event, "X-Content-Type-Options", "nosniff");
|
|
23
|
+
setResponseHeader(event, "Referrer-Policy", "no-referrer");
|
|
24
|
+
setResponseHeader(event, "Cache-Control", "private, max-age=0, no-store");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default defineEventHandler(async (event) => {
|
|
28
|
+
applyAgentJsonHeaders(event);
|
|
29
|
+
const query = getQuery(event);
|
|
30
|
+
const id = queryString(query.id);
|
|
31
|
+
const token = queryString(query[SESSION_REPLAY_AGENT_ACCESS_PARAM]);
|
|
32
|
+
|
|
33
|
+
if (!id || !token) {
|
|
34
|
+
setResponseStatus(event, 400);
|
|
35
|
+
return { error: "id and agent access token are required" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
return await buildSessionReplayAgentContext({
|
|
40
|
+
recordingId: id,
|
|
41
|
+
token,
|
|
42
|
+
origin: getRequestURL(event).origin,
|
|
43
|
+
});
|
|
44
|
+
} catch (error: any) {
|
|
45
|
+
setResponseStatus(event, error?.statusCode ?? 400);
|
|
46
|
+
return { error: error?.message || String(error) };
|
|
47
|
+
}
|
|
48
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineEventHandler,
|
|
3
|
+
getQuery,
|
|
4
|
+
setResponseHeader,
|
|
5
|
+
setResponseStatus,
|
|
6
|
+
} from "h3";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
SESSION_REPLAY_AGENT_ACCESS_PARAM,
|
|
10
|
+
verifySessionReplayAgentAccess,
|
|
11
|
+
} from "../../../lib/session-replay-agent-context.js";
|
|
12
|
+
import {
|
|
13
|
+
getSessionReplayTokenizedEvents,
|
|
14
|
+
type SessionReplayEventReadOptions,
|
|
15
|
+
} from "../../../lib/session-replay.js";
|
|
16
|
+
|
|
17
|
+
function queryString(value: unknown): string {
|
|
18
|
+
if (typeof value === "string") return value;
|
|
19
|
+
if (Array.isArray(value) && typeof value[0] === "string") return value[0];
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function queryInt(value: unknown): number | undefined {
|
|
24
|
+
const raw = queryString(value);
|
|
25
|
+
if (!raw || !/^\d+$/.test(raw)) return undefined;
|
|
26
|
+
return Number(raw);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function applyAgentJsonHeaders(event: any) {
|
|
30
|
+
setResponseHeader(event, "Content-Type", "application/json; charset=utf-8");
|
|
31
|
+
setResponseHeader(event, "X-Content-Type-Options", "nosniff");
|
|
32
|
+
setResponseHeader(event, "Referrer-Policy", "no-referrer");
|
|
33
|
+
setResponseHeader(event, "Cache-Control", "private, max-age=0, no-store");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default defineEventHandler(async (event) => {
|
|
37
|
+
applyAgentJsonHeaders(event);
|
|
38
|
+
const query = getQuery(event);
|
|
39
|
+
const id = queryString(query.id);
|
|
40
|
+
const token = queryString(query[SESSION_REPLAY_AGENT_ACCESS_PARAM]);
|
|
41
|
+
|
|
42
|
+
if (!id || !token) {
|
|
43
|
+
setResponseStatus(event, 400);
|
|
44
|
+
return { error: "id and agent access token are required" };
|
|
45
|
+
}
|
|
46
|
+
if (!verifySessionReplayAgentAccess(id, token)) {
|
|
47
|
+
setResponseStatus(event, 401);
|
|
48
|
+
return { error: "Invalid or expired agent access" };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const options: SessionReplayEventReadOptions = {
|
|
52
|
+
startSeq: queryInt(query.startSeq),
|
|
53
|
+
endSeq: queryInt(query.endSeq),
|
|
54
|
+
limit: queryInt(query.limit),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
return await getSessionReplayTokenizedEvents(id, options);
|
|
59
|
+
} catch (error: any) {
|
|
60
|
+
setResponseStatus(event, error?.statusCode ?? 400);
|
|
61
|
+
return { error: error?.message || String(error) };
|
|
62
|
+
}
|
|
63
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const SESSION_REPLAY_AGENT_ACCESS_PARAM = "agent_access";
|
|
2
|
+
|
|
3
|
+
const SESSION_REPLAY_AGENT_ACCESS_TOKEN_PREFIX =
|
|
4
|
+
"analytics-session-replay-agent-context";
|
|
5
|
+
|
|
6
|
+
export function sessionReplayAgentAccessTokenResourceId(
|
|
7
|
+
recordingId: string,
|
|
8
|
+
): string {
|
|
9
|
+
return `${SESSION_REPLAY_AGENT_ACCESS_TOKEN_PREFIX}:${recordingId}`;
|
|
10
|
+
}
|
|
@@ -156,9 +156,10 @@ Required Slack app setup:
|
|
|
156
156
|
- Deploy secrets: `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, and
|
|
157
157
|
`SLACK_SIGNING_SECRET`
|
|
158
158
|
|
|
159
|
-
## Agent-readable
|
|
159
|
+
## Agent-readable clips
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
Recordings can expose URLs meant for external agents without handing over raw
|
|
162
|
+
video bytes:
|
|
162
163
|
|
|
163
164
|
| Endpoint | Meaning |
|
|
164
165
|
| -------- | ------- |
|
|
@@ -166,9 +167,14 @@ Public recordings also expose URLs meant for external agents:
|
|
|
166
167
|
| `/api/agent-transcript.json?id=<recordingId>` | Timestamped transcript segments with `startMs`, `endMs`, `timestamp`, `range`, `text`, and optional `source` |
|
|
167
168
|
| `/api/agent-frame.jpg?id=<recordingId>&atMs=<ms>` | JPEG frame extracted from the video at the requested original-video timestamp |
|
|
168
169
|
|
|
169
|
-
These endpoints follow the same access model as `/api/public-recording
|
|
170
|
+
These endpoints follow the same access model as `/api/public-recording`, plus a
|
|
171
|
+
temporary agent-link path:
|
|
170
172
|
|
|
171
173
|
- Non-public clips return not found to anonymous callers.
|
|
174
|
+
- `create-recording-agent-link` resolves normal recording access, rejects
|
|
175
|
+
archived or trashed recordings, then mints a two-hour `agent_access` URL for
|
|
176
|
+
`/share/:recordingId`. The share page SSR advertises the agent context URL,
|
|
177
|
+
and the JSON endpoints accept the same scoped token.
|
|
172
178
|
- Expired clips return expired.
|
|
173
179
|
- Password-protected clips require `password=<pw>` once; successful JSON
|
|
174
180
|
responses include short-lived tokenized links so the plaintext password is not
|
|
@@ -183,11 +189,11 @@ These endpoints follow the same access model as `/api/public-recording`:
|
|
|
183
189
|
- Frame extraction must use the checked recording media path and must not expose
|
|
184
190
|
raw provider URLs.
|
|
185
191
|
|
|
186
|
-
The share popover's "Share with agents" field should copy
|
|
187
|
-
not raw transcript text. Its "Copy agent prompt"
|
|
188
|
-
instructions to fetch transcripts, frames, and
|
|
189
|
-
still point agents at the context response so
|
|
190
|
-
context they need.
|
|
192
|
+
The share popover's "Share with agents" field should copy an agent context URL
|
|
193
|
+
or tokenized share page URL, not raw transcript text. Its "Copy agent prompt"
|
|
194
|
+
field may wrap that URL with instructions to fetch transcripts, frames, and
|
|
195
|
+
browser diagnostics, but it should still point agents at the context response so
|
|
196
|
+
they can fetch only the visual context they need.
|
|
191
197
|
|
|
192
198
|
## View counting
|
|
193
199
|
|
|
@@ -132,8 +132,9 @@ Detailed media, meeting, dictation, editing, and sharing rules live in
|
|
|
132
132
|
- `navigate` moves the UI to recording/library/meeting/share surfaces.
|
|
133
133
|
- Use data actions for full transcripts and media metadata.
|
|
134
134
|
- For the in-app Clips agent, prefer `get-recording-player-data` for full
|
|
135
|
-
private/authenticated recording context.
|
|
136
|
-
|
|
135
|
+
private/authenticated recording context. When preparing a link for another
|
|
136
|
+
agent outside Clips, use `create-recording-agent-link`; it mints a two-hour
|
|
137
|
+
`agent_access` share URL without changing recording visibility.
|
|
137
138
|
|
|
138
139
|
## Skills
|
|
139
140
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import {
|
|
3
|
+
getRequestContext,
|
|
4
|
+
getRequestUserEmail,
|
|
5
|
+
signShortLivedToken,
|
|
6
|
+
} from "@agent-native/core/server";
|
|
7
|
+
import { ForbiddenError, resolveAccess } from "@agent-native/core/sharing";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
CLIPS_AGENT_ACCESS_PARAM,
|
|
12
|
+
CLIPS_AGENT_ACCESS_TTL_SECONDS,
|
|
13
|
+
getServerAppBasePath,
|
|
14
|
+
} from "../server/lib/public-agent-context.js";
|
|
15
|
+
import {
|
|
16
|
+
agentAccessTokenResourceId,
|
|
17
|
+
buildAgentApiUrls,
|
|
18
|
+
} from "../shared/agent-context.js";
|
|
19
|
+
|
|
20
|
+
function appOrigin(): string {
|
|
21
|
+
const origin =
|
|
22
|
+
getRequestContext()?.requestOrigin ||
|
|
23
|
+
process.env.APP_URL ||
|
|
24
|
+
process.env.BETTER_AUTH_URL ||
|
|
25
|
+
"http://localhost:3000";
|
|
26
|
+
try {
|
|
27
|
+
return new URL(origin).origin;
|
|
28
|
+
} catch {
|
|
29
|
+
return "http://localhost:3000";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function appendAgentAccess(url: string, token: string): string {
|
|
34
|
+
const sep = url.includes("?") ? "&" : "?";
|
|
35
|
+
return `${url}${sep}${CLIPS_AGENT_ACCESS_PARAM}=${encodeURIComponent(token)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default defineAction({
|
|
39
|
+
description:
|
|
40
|
+
"Create a temporary private agent-readable link for one Clips recording. The URL is scoped to that recording and expires after two hours.",
|
|
41
|
+
schema: z.object({
|
|
42
|
+
recordingId: z.string().describe("Recording ID"),
|
|
43
|
+
}),
|
|
44
|
+
readOnly: true,
|
|
45
|
+
run: async (args) => {
|
|
46
|
+
const access = await resolveAccess("recording", args.recordingId);
|
|
47
|
+
if (!access) {
|
|
48
|
+
throw new ForbiddenError(`No access to recording ${args.recordingId}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const recording = access.resource as {
|
|
52
|
+
id: string;
|
|
53
|
+
archivedAt?: string | null;
|
|
54
|
+
trashedAt?: string | null;
|
|
55
|
+
};
|
|
56
|
+
if (recording.archivedAt || recording.trashedAt) {
|
|
57
|
+
throw new ForbiddenError(
|
|
58
|
+
`Recording ${args.recordingId} is not shareable`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const token = signShortLivedToken({
|
|
63
|
+
resourceId: agentAccessTokenResourceId(recording.id),
|
|
64
|
+
viewerEmail: getRequestUserEmail() || undefined,
|
|
65
|
+
ttlSeconds: CLIPS_AGENT_ACCESS_TTL_SECONDS,
|
|
66
|
+
});
|
|
67
|
+
const origin = appOrigin();
|
|
68
|
+
const basePath = getServerAppBasePath();
|
|
69
|
+
const expiresAt = new Date(
|
|
70
|
+
Date.now() + CLIPS_AGENT_ACCESS_TTL_SECONDS * 1000,
|
|
71
|
+
).toISOString();
|
|
72
|
+
const pageUrl = appendAgentAccess(
|
|
73
|
+
`${origin}${basePath}/share/${encodeURIComponent(recording.id)}`,
|
|
74
|
+
token,
|
|
75
|
+
);
|
|
76
|
+
const api = buildAgentApiUrls(recording.id, {
|
|
77
|
+
origin,
|
|
78
|
+
basePath,
|
|
79
|
+
token,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
recordingId: recording.id,
|
|
84
|
+
url: pageUrl,
|
|
85
|
+
contextUrl: api.contextUrl,
|
|
86
|
+
expiresAt,
|
|
87
|
+
ttlSeconds: CLIPS_AGENT_ACCESS_TTL_SECONDS,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
});
|