@agent-native/core 0.84.21 → 0.84.23
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 +16 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/model-config.ts +1 -1
- package/corpus/core/src/cli/skills.ts +17 -3
- package/corpus/core/src/client/AssistantChat.tsx +27 -4
- package/corpus/core/src/client/guided-questions.tsx +11 -6
- package/corpus/core/src/client/tool-display.ts +8 -0
- package/corpus/core/src/file-upload/builder.ts +7 -2
- package/corpus/core/src/notifications/channels.ts +235 -38
- package/corpus/core/src/notifications/index.ts +1 -0
- package/corpus/core/src/notifications/registry.ts +13 -1
- package/corpus/core/src/server/analytics.ts +32 -6
- package/corpus/core/src/server/ssr-handler.ts +31 -12
- package/corpus/core/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/core/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/analytics/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/analytics/AGENTS.md +21 -0
- package/corpus/templates/analytics/README.md +35 -0
- package/corpus/templates/analytics/actions/delete-analytics-alert-rule.ts +23 -0
- package/corpus/templates/analytics/actions/list-analytics-alert-rules.ts +20 -0
- package/corpus/templates/analytics/actions/run-analytics-alerts.ts +23 -0
- package/corpus/templates/analytics/actions/save-analytics-alert-rule.ts +69 -0
- package/corpus/templates/analytics/changelog/2026-07-01-analytics-can-alert-when-first-party-events-spike.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-01-session-replays-play-back-in-production-instead-of-showing.md +6 -0
- package/corpus/templates/analytics/netlify.toml +3 -0
- package/corpus/templates/analytics/scripts/emit-netlify-dashboard-report-cron.ts +126 -0
- package/corpus/templates/analytics/server/db/schema.ts +55 -0
- package/corpus/templates/analytics/server/handlers/session-replay.ts +5 -2
- package/corpus/templates/analytics/server/jobs/analytics-alerts.ts +74 -0
- package/corpus/templates/analytics/server/lib/analytics-alerts.ts +803 -0
- package/corpus/templates/analytics/server/lib/session-replay.ts +10 -3
- package/corpus/templates/analytics/server/plugins/analytics-alert-jobs.ts +54 -0
- package/corpus/templates/analytics/server/plugins/db.ts +100 -0
- package/corpus/templates/analytics/server/routes/api/analytics-alerts/run.post.ts +60 -0
- package/corpus/templates/assets/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/assets/README.md +30 -8
- package/corpus/templates/brain/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/brain/README.md +20 -552
- package/corpus/templates/calendar/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/calendar/README.md +22 -28
- package/corpus/templates/chat/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/chat/README.md +32 -0
- package/corpus/templates/clips/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/clips/.agents/skills/recording/SKILL.md +30 -1
- package/corpus/templates/clips/README.md +37 -0
- package/corpus/templates/clips/actions/finalize-recording.ts +84 -4
- package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +256 -0
- package/corpus/templates/clips/actions/reprocess-recording.ts +160 -0
- package/corpus/templates/clips/app/components/library/library-grid.tsx +18 -7
- package/corpus/templates/clips/app/components/library/recording-card.tsx +42 -5
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +41 -1
- package/corpus/templates/clips/changelog/2026-07-01-move-to-folder-now-opens-folder-choices-directly-instead-of.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-01-shared-clips-now-start-playing-and-scrub-instantly-instead-o.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/clips/server/lib/video-remux.ts +222 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +37 -0
- package/corpus/templates/content/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/content/AGENTS.md +7 -3
- package/corpus/templates/content/README.md +22 -34
- package/corpus/templates/content/actions/list-trashed-content-databases.ts +4 -0
- package/corpus/templates/content/app/components/editor/SlashCommandMenu.tsx +58 -6
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +257 -24
- package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +41 -15
- package/corpus/templates/content/app/i18n/zh-TW.ts +25 -7
- package/corpus/templates/content/app/i18n-data.ts +278 -40
- package/corpus/templates/content/changelog/2026-06-30-database-once-again-creates-an-inline-database-in-the-curren.md +6 -0
- package/corpus/templates/content/shared/api.ts +1 -0
- package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +75 -19
- package/corpus/templates/design/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/design/AGENTS.md +7 -6
- package/corpus/templates/design/README.md +33 -0
- package/corpus/templates/design/actions/delete-file.ts +86 -10
- package/corpus/templates/design/actions/get-design-snapshot.ts +13 -0
- package/corpus/templates/design/actions/present-design-variants.ts +11 -13
- package/corpus/templates/design/app/hooks/use-question-flow.ts +2 -2
- package/corpus/templates/design/app/pages/DesignEditor.tsx +13 -3
- package/corpus/templates/design/changelog/2026-07-01-design-chat-no-longer-shows-an-empty-state-selection-chip-in-the-composer.md +6 -0
- package/corpus/templates/dispatch/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/dispatch/README.md +34 -0
- package/corpus/templates/forms/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/forms/README.md +34 -0
- package/corpus/templates/macros/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/macros/README.md +28 -0
- package/corpus/templates/mail/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/mail/README.md +35 -0
- package/corpus/templates/plan/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/plan/README.md +30 -118
- package/corpus/templates/slides/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/slides/README.md +34 -0
- package/corpus/templates/videos/.agents/skills/frontend-design/SKILL.md +2 -0
- package/corpus/templates/videos/README.md +22 -201
- package/dist/agent/model-config.js +1 -1
- package/dist/agent/model-config.js.map +1 -1
- package/dist/cli/skills.d.ts.map +1 -1
- package/dist/cli/skills.js +17 -3
- package/dist/cli/skills.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +28 -4
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/guided-questions.d.ts.map +1 -1
- package/dist/client/guided-questions.js +10 -6
- package/dist/client/guided-questions.js.map +1 -1
- package/dist/client/tool-display.d.ts.map +1 -1
- package/dist/client/tool-display.js +8 -0
- package/dist/client/tool-display.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +6 -2
- package/dist/file-upload/builder.js.map +1 -1
- package/dist/notifications/channels.d.ts +10 -0
- package/dist/notifications/channels.d.ts.map +1 -1
- package/dist/notifications/channels.js +172 -20
- package/dist/notifications/channels.js.map +1 -1
- package/dist/notifications/index.d.ts +1 -1
- package/dist/notifications/index.d.ts.map +1 -1
- package/dist/notifications/index.js +1 -1
- package/dist/notifications/index.js.map +1 -1
- package/dist/notifications/registry.d.ts +5 -0
- package/dist/notifications/registry.d.ts.map +1 -1
- package/dist/notifications/registry.js +4 -1
- package/dist/notifications/registry.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/analytics.d.ts +16 -0
- package/dist/server/analytics.d.ts.map +1 -1
- package/dist/server/analytics.js +30 -7
- package/dist/server/analytics.js.map +1 -1
- package/dist/server/ssr-handler.d.ts.map +1 -1
- package/dist/server/ssr-handler.js +28 -12
- package/dist/server/ssr-handler.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
- package/dist/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
- package/package.json +1 -1
- package/src/templates/default/.agents/skills/frontend-design/SKILL.md +2 -0
- package/src/templates/workspace-core/.agents/skills/frontend-design/SKILL.md +2 -0
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
getBrowserTabId,
|
|
3
|
-
sendToAgentChat,
|
|
4
3
|
setClientAppState,
|
|
5
4
|
useSession,
|
|
6
5
|
useT,
|
|
@@ -225,6 +224,21 @@ export function LibraryGrid({
|
|
|
225
224
|
}
|
|
226
225
|
};
|
|
227
226
|
|
|
227
|
+
const moveSingle = async (
|
|
228
|
+
rec: RecordingSummary,
|
|
229
|
+
targetFolderId: string | null,
|
|
230
|
+
) => {
|
|
231
|
+
try {
|
|
232
|
+
await moveRecording.mutateAsync({
|
|
233
|
+
id: rec.id,
|
|
234
|
+
folderId: targetFolderId,
|
|
235
|
+
});
|
|
236
|
+
toast.success(t("libraryGrid.clipsMoved", { count: 1 }));
|
|
237
|
+
} catch (err: any) {
|
|
238
|
+
toast.error(err?.message ?? t("libraryGrid.moveFailed"));
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
|
|
228
242
|
const openRenameDialog = (rec: RecordingSummary) => {
|
|
229
243
|
setRenamingRec(rec);
|
|
230
244
|
setRenameValue(isDefaultTitle(rec.title) ? "" : (rec.title ?? ""));
|
|
@@ -385,12 +399,9 @@ export function LibraryGrid({
|
|
|
385
399
|
? openRenameDialog
|
|
386
400
|
: undefined
|
|
387
401
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
background: false,
|
|
392
|
-
});
|
|
393
|
-
}}
|
|
402
|
+
moveTargets={moveTargets}
|
|
403
|
+
onMove={canMoveSelection ? moveSingle : undefined}
|
|
404
|
+
isMovePending={moveRecording.isPending}
|
|
394
405
|
onTrash={(rec) => {
|
|
395
406
|
trashRecording.mutate(
|
|
396
407
|
{ id: rec.id },
|
|
@@ -24,6 +24,9 @@ import {
|
|
|
24
24
|
DropdownMenuContent,
|
|
25
25
|
DropdownMenuItem,
|
|
26
26
|
DropdownMenuSeparator,
|
|
27
|
+
DropdownMenuSub,
|
|
28
|
+
DropdownMenuSubContent,
|
|
29
|
+
DropdownMenuSubTrigger,
|
|
27
30
|
DropdownMenuTrigger,
|
|
28
31
|
} from "@/components/ui/dropdown-menu";
|
|
29
32
|
import { isDefaultTitle } from "@/hooks/use-auto-title";
|
|
@@ -32,6 +35,8 @@ import { isStaleRecordingUpload } from "@/lib/recording-status";
|
|
|
32
35
|
import { isStorageSetupFailureReason } from "@/lib/storage-failures";
|
|
33
36
|
import { cn } from "@/lib/utils";
|
|
34
37
|
|
|
38
|
+
import type { BulkMoveTarget } from "./bulk-action-toolbar";
|
|
39
|
+
|
|
35
40
|
function formatDuration(ms: number): string {
|
|
36
41
|
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
37
42
|
const m = Math.floor(totalSeconds / 60);
|
|
@@ -59,7 +64,9 @@ interface RecordingCardProps {
|
|
|
59
64
|
selectionMode?: boolean;
|
|
60
65
|
onToggleSelect?: (id: string) => void;
|
|
61
66
|
onShare?: (rec: RecordingSummary) => void;
|
|
62
|
-
onMove?: (rec: RecordingSummary) => void;
|
|
67
|
+
onMove?: (rec: RecordingSummary, folderId: string | null) => void;
|
|
68
|
+
moveTargets?: BulkMoveTarget[];
|
|
69
|
+
isMovePending?: boolean;
|
|
63
70
|
onRename?: (rec: RecordingSummary) => void;
|
|
64
71
|
onArchive?: (rec: RecordingSummary) => void;
|
|
65
72
|
onTrash?: (rec: RecordingSummary) => void;
|
|
@@ -73,6 +80,8 @@ export function RecordingCard({
|
|
|
73
80
|
onToggleSelect,
|
|
74
81
|
onShare,
|
|
75
82
|
onMove,
|
|
83
|
+
moveTargets = [],
|
|
84
|
+
isMovePending = false,
|
|
76
85
|
onRename,
|
|
77
86
|
onArchive,
|
|
78
87
|
onTrash,
|
|
@@ -112,6 +121,7 @@ export function RecordingCard({
|
|
|
112
121
|
/native recording|native fullscreen|screencapture|avconvert/i.test(
|
|
113
122
|
recording.failureReason ?? "",
|
|
114
123
|
);
|
|
124
|
+
const canMove = Boolean(onMove && moveTargets.length > 0);
|
|
115
125
|
|
|
116
126
|
const displayThumbnail = useMemo(() => {
|
|
117
127
|
if (hovered && recording.animatedThumbnailUrl)
|
|
@@ -332,10 +342,37 @@ export function RecordingCard({
|
|
|
332
342
|
<IconShare className="h-4 w-4 me-2" />{" "}
|
|
333
343
|
{t("recordingPage.share")}
|
|
334
344
|
</DropdownMenuItem>
|
|
335
|
-
|
|
336
|
-
<
|
|
337
|
-
|
|
338
|
-
|
|
345
|
+
{canMove ? (
|
|
346
|
+
<DropdownMenuSub>
|
|
347
|
+
<DropdownMenuSubTrigger>
|
|
348
|
+
<IconFolder className="h-4 w-4 me-2" />{" "}
|
|
349
|
+
{t("clipsFinalRaw.moveToFolder")}
|
|
350
|
+
</DropdownMenuSubTrigger>
|
|
351
|
+
<DropdownMenuSubContent className="w-64">
|
|
352
|
+
{moveTargets.map((target, index) => (
|
|
353
|
+
<DropdownMenuItem
|
|
354
|
+
key={target.id ?? `root-${index}`}
|
|
355
|
+
disabled={target.disabled || isMovePending}
|
|
356
|
+
onSelect={() => onMove?.(recording, target.id)}
|
|
357
|
+
>
|
|
358
|
+
<span
|
|
359
|
+
className="truncate"
|
|
360
|
+
style={{
|
|
361
|
+
paddingInlineStart: (target.depth ?? 0) * 12,
|
|
362
|
+
}}
|
|
363
|
+
>
|
|
364
|
+
{target.name}
|
|
365
|
+
</span>
|
|
366
|
+
{target.disabled && (
|
|
367
|
+
<span className="ms-auto text-xs text-muted-foreground">
|
|
368
|
+
{t("clipsFinalRaw.current")}
|
|
369
|
+
</span>
|
|
370
|
+
)}
|
|
371
|
+
</DropdownMenuItem>
|
|
372
|
+
))}
|
|
373
|
+
</DropdownMenuSubContent>
|
|
374
|
+
</DropdownMenuSub>
|
|
375
|
+
) : null}
|
|
339
376
|
{onRename ? (
|
|
340
377
|
<>
|
|
341
378
|
<DropdownMenuSeparator />
|
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
* but no React state lives here — callers subscribe via `onState`, `onChunk`,
|
|
7
7
|
* and `onError`.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
appBasePath,
|
|
11
|
+
captureClientException,
|
|
12
|
+
trackEvent,
|
|
13
|
+
} from "@agent-native/core/client";
|
|
10
14
|
import { waitForReadyRecordingAfterFinalizeError } from "@shared/finalize-recovery";
|
|
11
15
|
import {
|
|
12
16
|
chunkUploadUrl,
|
|
@@ -354,6 +358,19 @@ function isRetryableChunkUploadStatus(status: number): boolean {
|
|
|
354
358
|
return RETRYABLE_CHUNK_UPLOAD_STATUSES.has(status);
|
|
355
359
|
}
|
|
356
360
|
|
|
361
|
+
function trackClipUploadBlockingFailure(props: Record<string, unknown>): void {
|
|
362
|
+
try {
|
|
363
|
+
trackEvent("clips_upload_blocking_failure", {
|
|
364
|
+
app: "clips",
|
|
365
|
+
template: "clips",
|
|
366
|
+
surface: "web_recorder",
|
|
367
|
+
...props,
|
|
368
|
+
});
|
|
369
|
+
} catch {
|
|
370
|
+
// Analytics should never change recording behavior.
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
357
374
|
function retryDelayMs(attempt: number): number {
|
|
358
375
|
return attempt === 1 ? 500 : 1500;
|
|
359
376
|
}
|
|
@@ -1775,6 +1792,15 @@ export class RecorderEngine {
|
|
|
1775
1792
|
}
|
|
1776
1793
|
|
|
1777
1794
|
if (!res) {
|
|
1795
|
+
trackClipUploadBlockingFailure({
|
|
1796
|
+
stage: "chunk_upload",
|
|
1797
|
+
failureKind: "no_response",
|
|
1798
|
+
recordingId: this.opts.recordingId,
|
|
1799
|
+
chunkIndex: index,
|
|
1800
|
+
isFinal: extra.isFinal === true,
|
|
1801
|
+
chunkBytes: blob.size,
|
|
1802
|
+
uploadMode: this.opts.uploadMode,
|
|
1803
|
+
});
|
|
1778
1804
|
throw new Error(`Chunk ${index} upload failed: no response`);
|
|
1779
1805
|
}
|
|
1780
1806
|
|
|
@@ -1791,6 +1817,20 @@ export class RecorderEngine {
|
|
|
1791
1817
|
const recovered = await this.recoverReadyAfterFinalUploadError();
|
|
1792
1818
|
if (recovered) return recovered;
|
|
1793
1819
|
}
|
|
1820
|
+
trackClipUploadBlockingFailure({
|
|
1821
|
+
stage: "chunk_upload",
|
|
1822
|
+
failureKind: "http_error",
|
|
1823
|
+
recordingId: this.opts.recordingId,
|
|
1824
|
+
chunkIndex: index,
|
|
1825
|
+
isFinal: extra.isFinal === true,
|
|
1826
|
+
httpStatus: res.status,
|
|
1827
|
+
statusText: res.statusText,
|
|
1828
|
+
chunkBytes: blob.size,
|
|
1829
|
+
uploadMode: this.opts.uploadMode,
|
|
1830
|
+
finalUploadRecoveryAttempted:
|
|
1831
|
+
extra.isFinal === true &&
|
|
1832
|
+
this.isFinalUploadRecoveryCandidate(res.status, err),
|
|
1833
|
+
});
|
|
1794
1834
|
// Capture rich context to Sentry BEFORE throwing — when this hits
|
|
1795
1835
|
// production we want enough breadcrumbs in the event to debug a
|
|
1796
1836
|
// "Builder.io upload failed (500)" without re-running the upload.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Agent-Native Clips",
|
|
4
4
|
"short_name": "Clips",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.11",
|
|
6
6
|
"description": "Start Clips recordings from Chrome, capture optional diagnostics, and preview Clips links on GitHub.",
|
|
7
7
|
"minimum_chrome_version": "116",
|
|
8
8
|
"action": {
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make recorded video seekable so browsers can start playback immediately and
|
|
3
|
+
* scrub without re-buffering the whole file.
|
|
4
|
+
*
|
|
5
|
+
* Two problems this fixes, both produced by `MediaRecorder` output:
|
|
6
|
+
* - MP4: the `moov` metadata atom is written AFTER `mdat`, so a player must
|
|
7
|
+
* download the entire file before it can start / seek. We relocate it with
|
|
8
|
+
* the pure-TypeScript {@link applyFaststart} (no ffmpeg needed).
|
|
9
|
+
* - WebM: MediaRecorder emits a "live" stream with no Cues (seek index) and an
|
|
10
|
+
* unknown Segment duration, so Chrome refuses to honor `currentTime = X`
|
|
11
|
+
* seeks and has to scan/download to move around. A cheap `ffmpeg -c copy`
|
|
12
|
+
* remux rewrites the container with a SeekHead + Cues index and a real
|
|
13
|
+
* duration — no re-encode, so it's fast and lossless.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is best-effort: on any failure (ffmpeg missing, bad input,
|
|
16
|
+
* timeout) we return the ORIGINAL bytes with `changed: false`, so callers never
|
|
17
|
+
* regress relative to uploading the raw recording.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
23
|
+
import { createRequire } from "node:module";
|
|
24
|
+
import { tmpdir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
import { applyFaststart, hasPlayableMp4Metadata } from "./faststart.js";
|
|
28
|
+
|
|
29
|
+
const REMUX_TIMEOUT_MS = 120_000;
|
|
30
|
+
const STDERR_LIMIT = 16 * 1024;
|
|
31
|
+
const MAX_CONCURRENT_REMUXES = 2;
|
|
32
|
+
// EBML magic that every valid Matroska/WebM file starts with.
|
|
33
|
+
const EBML_MAGIC = [0x1a, 0x45, 0xdf, 0xa3];
|
|
34
|
+
|
|
35
|
+
const requireFromThisFile = createRequire(import.meta.url);
|
|
36
|
+
let cachedFfmpegStaticPath: string | null | undefined;
|
|
37
|
+
let activeRemuxes = 0;
|
|
38
|
+
const remuxWaiters: Array<() => void> = [];
|
|
39
|
+
|
|
40
|
+
export type VideoFormat = "webm" | "mp4";
|
|
41
|
+
|
|
42
|
+
export interface SeekableResult {
|
|
43
|
+
/** The seekable bytes, or the original bytes when nothing changed. */
|
|
44
|
+
bytes: Uint8Array;
|
|
45
|
+
/** True when the returned bytes differ from the input. */
|
|
46
|
+
changed: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ffmpegCommand(): string {
|
|
50
|
+
if (process.env.FFMPEG_PATH) return process.env.FFMPEG_PATH;
|
|
51
|
+
return resolveFfmpegStaticPath() ?? "ffmpeg";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveFfmpegStaticPath(): string | null {
|
|
55
|
+
if (cachedFfmpegStaticPath !== undefined) return cachedFfmpegStaticPath;
|
|
56
|
+
try {
|
|
57
|
+
const resolved = requireFromThisFile("ffmpeg-static");
|
|
58
|
+
cachedFfmpegStaticPath =
|
|
59
|
+
typeof resolved === "string" && resolved && existsSync(resolved)
|
|
60
|
+
? resolved
|
|
61
|
+
: null;
|
|
62
|
+
} catch {
|
|
63
|
+
cachedFfmpegStaticPath = null;
|
|
64
|
+
}
|
|
65
|
+
return cachedFfmpegStaticPath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Whether a server-side ffmpeg binary is resolvable. */
|
|
69
|
+
export function isFfmpegAvailable(): boolean {
|
|
70
|
+
return Boolean(process.env.FFMPEG_PATH) || resolveFfmpegStaticPath() !== null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function startsWithMagic(bytes: Uint8Array, magic: number[]): boolean {
|
|
74
|
+
if (bytes.byteLength < magic.length) return false;
|
|
75
|
+
for (let i = 0; i < magic.length; i++) {
|
|
76
|
+
if (bytes[i] !== magic[i]) return false;
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function runFfmpeg(args: string[]): Promise<void> {
|
|
82
|
+
await new Promise<void>((resolve, reject) => {
|
|
83
|
+
const child = spawn(ffmpegCommand(), args, {
|
|
84
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
85
|
+
});
|
|
86
|
+
let stderr = "";
|
|
87
|
+
const timeout = setTimeout(() => {
|
|
88
|
+
child.kill("SIGKILL");
|
|
89
|
+
reject(new Error(`ffmpeg remux timed out\n${stderr}`));
|
|
90
|
+
}, REMUX_TIMEOUT_MS);
|
|
91
|
+
|
|
92
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
93
|
+
stderr = (stderr + chunk.toString("utf8")).slice(-STDERR_LIMIT);
|
|
94
|
+
});
|
|
95
|
+
child.on("error", (err) => {
|
|
96
|
+
clearTimeout(timeout);
|
|
97
|
+
reject(new Error(`${err.message}\n${stderr}`));
|
|
98
|
+
});
|
|
99
|
+
child.on("close", (code) => {
|
|
100
|
+
clearTimeout(timeout);
|
|
101
|
+
if (code === 0) {
|
|
102
|
+
resolve();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
reject(new Error(`ffmpeg exited with code ${code}\n${stderr}`));
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function withRemuxSlot<T>(fn: () => Promise<T>): Promise<T> {
|
|
111
|
+
if (activeRemuxes >= MAX_CONCURRENT_REMUXES) {
|
|
112
|
+
await new Promise<void>((resolve) => remuxWaiters.push(resolve));
|
|
113
|
+
}
|
|
114
|
+
activeRemuxes += 1;
|
|
115
|
+
try {
|
|
116
|
+
return await fn();
|
|
117
|
+
} finally {
|
|
118
|
+
activeRemuxes = Math.max(0, activeRemuxes - 1);
|
|
119
|
+
remuxWaiters.shift()?.();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Rewrite a WebM/Matroska file with a SeekHead + Cues index and a real
|
|
125
|
+
* duration via a lossless `ffmpeg -c copy` remux. Returns the original bytes
|
|
126
|
+
* unchanged when ffmpeg is unavailable, the input isn't WebM, or anything
|
|
127
|
+
* goes wrong.
|
|
128
|
+
*/
|
|
129
|
+
export async function remuxWebmToSeekable(
|
|
130
|
+
mediaBytes: Uint8Array,
|
|
131
|
+
): Promise<SeekableResult> {
|
|
132
|
+
const unchanged: SeekableResult = { bytes: mediaBytes, changed: false };
|
|
133
|
+
|
|
134
|
+
if (mediaBytes.byteLength === 0) return unchanged;
|
|
135
|
+
if (!startsWithMagic(mediaBytes, EBML_MAGIC)) return unchanged;
|
|
136
|
+
if (!isFfmpegAvailable()) return unchanged;
|
|
137
|
+
|
|
138
|
+
const dir = await mkdtemp(join(tmpdir(), "clips-remux-"));
|
|
139
|
+
const inputPath = join(dir, "input.webm");
|
|
140
|
+
const outputPath = join(dir, "output.webm");
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await writeFile(inputPath, mediaBytes);
|
|
144
|
+
await withRemuxSlot(() =>
|
|
145
|
+
runFfmpeg([
|
|
146
|
+
"-hide_banner",
|
|
147
|
+
"-loglevel",
|
|
148
|
+
"error",
|
|
149
|
+
"-nostdin",
|
|
150
|
+
"-y",
|
|
151
|
+
// Regenerate presentation timestamps so a live MediaRecorder stream
|
|
152
|
+
// gets a coherent, seekable timeline in the remuxed output.
|
|
153
|
+
"-fflags",
|
|
154
|
+
"+genpts",
|
|
155
|
+
"-i",
|
|
156
|
+
inputPath,
|
|
157
|
+
// Stream-copy every track: no re-encode, so this stays fast + lossless.
|
|
158
|
+
"-map",
|
|
159
|
+
"0",
|
|
160
|
+
"-c",
|
|
161
|
+
"copy",
|
|
162
|
+
"-f",
|
|
163
|
+
"webm",
|
|
164
|
+
outputPath,
|
|
165
|
+
]),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const info = await stat(outputPath).catch(() => null);
|
|
169
|
+
if (!info || info.size === 0) return unchanged;
|
|
170
|
+
|
|
171
|
+
const out = new Uint8Array(await readFile(outputPath));
|
|
172
|
+
// Validate the muxer actually produced a WebM before trusting it.
|
|
173
|
+
if (!startsWithMagic(out, EBML_MAGIC)) return unchanged;
|
|
174
|
+
|
|
175
|
+
return { bytes: out, changed: true };
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.warn("[video-remux] webm remux failed, keeping original", {
|
|
178
|
+
err: err instanceof Error ? err.message : String(err),
|
|
179
|
+
});
|
|
180
|
+
return unchanged;
|
|
181
|
+
} finally {
|
|
182
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Make an MP4 start-playable by moving its `moov` atom ahead of `mdat`. Pure
|
|
188
|
+
* TypeScript — no ffmpeg. Returns the original bytes when already faststarted
|
|
189
|
+
* or when the result would fail metadata validation.
|
|
190
|
+
*/
|
|
191
|
+
export function faststartMp4(mediaBytes: Uint8Array): SeekableResult {
|
|
192
|
+
if (mediaBytes.byteLength === 0) return { bytes: mediaBytes, changed: false };
|
|
193
|
+
try {
|
|
194
|
+
const out = applyFaststart(mediaBytes);
|
|
195
|
+
if (out === mediaBytes) return { bytes: mediaBytes, changed: false };
|
|
196
|
+
if (!hasPlayableMp4Metadata(out)) {
|
|
197
|
+
return { bytes: mediaBytes, changed: false };
|
|
198
|
+
}
|
|
199
|
+
return { bytes: out, changed: true };
|
|
200
|
+
} catch (err) {
|
|
201
|
+
console.warn("[video-remux] mp4 faststart failed, keeping original", {
|
|
202
|
+
err: err instanceof Error ? err.message : String(err),
|
|
203
|
+
});
|
|
204
|
+
return { bytes: mediaBytes, changed: false };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Make recorded media seekable based on its container format. Dispatches to
|
|
210
|
+
* {@link faststartMp4} for MP4 and {@link remuxWebmToSeekable} for WebM.
|
|
211
|
+
* Always resolves; unknown formats and failures return the input unchanged.
|
|
212
|
+
*/
|
|
213
|
+
export async function makeSeekable(input: {
|
|
214
|
+
mediaBytes: Uint8Array;
|
|
215
|
+
videoFormat: VideoFormat;
|
|
216
|
+
}): Promise<SeekableResult> {
|
|
217
|
+
if (input.videoFormat === "mp4") return faststartMp4(input.mediaBytes);
|
|
218
|
+
if (input.videoFormat === "webm") {
|
|
219
|
+
return remuxWebmToSeekable(input.mediaBytes);
|
|
220
|
+
}
|
|
221
|
+
return { bytes: input.mediaBytes, changed: false };
|
|
222
|
+
}
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
} from "@agent-native/core/application-state";
|
|
20
20
|
import { getActiveFileUploadProvider } from "@agent-native/core/file-upload";
|
|
21
21
|
import { runWithRequestContext } from "@agent-native/core/server";
|
|
22
|
+
import { track } from "@agent-native/core/tracking";
|
|
22
23
|
import { normalizeChunkUploadNumber } from "@shared/recording-core.js";
|
|
23
24
|
import { MAX_UPLOAD_BYTES as MAX_RECORDING_UPLOAD_BYTES } from "@shared/upload-limits.js";
|
|
24
25
|
import { and, eq } from "drizzle-orm";
|
|
@@ -95,6 +96,26 @@ function stateNumber(
|
|
|
95
96
|
return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
function trackUploadBlockingFailure(
|
|
100
|
+
ownerEmail: string,
|
|
101
|
+
properties: Record<string, unknown>,
|
|
102
|
+
): void {
|
|
103
|
+
try {
|
|
104
|
+
track(
|
|
105
|
+
"clips_upload_blocking_failure",
|
|
106
|
+
{
|
|
107
|
+
app: "clips",
|
|
108
|
+
template: "clips",
|
|
109
|
+
surface: "server_upload",
|
|
110
|
+
...properties,
|
|
111
|
+
},
|
|
112
|
+
{ userId: ownerEmail },
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
// Best-effort analytics must never change upload behavior.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
98
119
|
export default defineEventHandler(async (event: H3Event) => {
|
|
99
120
|
const recordingId = getRouterParam(event, "recordingId");
|
|
100
121
|
if (!recordingId) {
|
|
@@ -189,6 +210,7 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
189
210
|
isFinal,
|
|
190
211
|
mimeType,
|
|
191
212
|
query,
|
|
213
|
+
ownerEmail,
|
|
192
214
|
);
|
|
193
215
|
}
|
|
194
216
|
|
|
@@ -507,6 +529,13 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
507
529
|
hasCamera: committed.hasCamera,
|
|
508
530
|
};
|
|
509
531
|
}
|
|
532
|
+
trackUploadBlockingFailure(ownerEmail, {
|
|
533
|
+
stage: "finalize_recording",
|
|
534
|
+
failureKind: "finalize_error",
|
|
535
|
+
recordingId,
|
|
536
|
+
uploadMode: "buffered",
|
|
537
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
538
|
+
});
|
|
510
539
|
await db
|
|
511
540
|
.update(schema.recordings)
|
|
512
541
|
.set({
|
|
@@ -566,6 +595,7 @@ async function handleResumableChunk(
|
|
|
566
595
|
isFinal: boolean,
|
|
567
596
|
mimeType: string,
|
|
568
597
|
query: Record<string, unknown>,
|
|
598
|
+
ownerEmail: string,
|
|
569
599
|
) {
|
|
570
600
|
const uploadProvider = getActiveFileUploadProvider();
|
|
571
601
|
console.log(
|
|
@@ -681,6 +711,13 @@ async function handleResumableChunk(
|
|
|
681
711
|
return { ok: true, finalized: true, ...result };
|
|
682
712
|
} catch (err) {
|
|
683
713
|
console.error(`[resumable-chunk-${recordingId}] finalize failed:`, err);
|
|
714
|
+
trackUploadBlockingFailure(ownerEmail, {
|
|
715
|
+
stage: "finalize_recording",
|
|
716
|
+
failureKind: "finalize_error",
|
|
717
|
+
recordingId,
|
|
718
|
+
uploadMode: "resumable",
|
|
719
|
+
errorMessage: err instanceof Error ? err.message : String(err),
|
|
720
|
+
});
|
|
684
721
|
setResponseStatus(event, 500);
|
|
685
722
|
return {
|
|
686
723
|
ok: false,
|
|
@@ -53,6 +53,8 @@ Default to Apple/Linear-level restraint: make the primary workflow obvious, then
|
|
|
53
53
|
- **Visual assets**: Websites, games, and object-focused pages need real or generated media when images help users understand the subject.
|
|
54
54
|
- **Responsive fit**: Text must not overflow buttons, cards, tabs, sidebars, or fixed-format tools. Use stable dimensions for boards, grids, toolbars, and counters.
|
|
55
55
|
|
|
56
|
+
**Beat convergence, not just defaults.** You sample toward the "on-distribution" center, so naming what to avoid is not enough: every "don't" needs a "do", or you converge on the next safe option (ban Inter and you reach for Roboto; ban purple gradients and you reach for Space Grotesk + a teal accent on every screen). Commit to one named direction, pair any reference with the reason it fits ("Linear: the quiet confidence of its spacing" — a bare "Linear" collapses back to the average), and match implementation effort to the vision: maximalist wants elaborate motion and effects, minimal wants restraint and precise spacing. When building on an existing app, inspect its tokens/type/components first and treat any drift back to a default as a missing token to pin, not something to re-prompt.
|
|
57
|
+
|
|
56
58
|
## Agent-Native UI Rules
|
|
57
59
|
|
|
58
60
|
- Agent-native apps use React, Vite, Tailwind CSS, shadcn/ui, and `@tabler/icons-react`.
|
|
@@ -108,7 +108,10 @@ cd templates/content && pnpm action <name> [args]
|
|
|
108
108
|
| `list-local-component-files` | | List registered local MDX component source files |
|
|
109
109
|
| `write-local-component-file` | `--workspaceId <id> --path <relative-component-path> --content <source>` | Create or update a file in a registered local `components/` folder |
|
|
110
110
|
| `create-content-database` | `[--documentId <id>] [--parentId <id>] [--title <text>]` | Create a database page or convert an existing page into a database |
|
|
111
|
+
| `create-inline-content-database` | `--hostDocumentId <id> [--title <text>]` | Create a database owned by an inline database block in the host document |
|
|
111
112
|
| `get-content-database` | `--databaseId <id>` or `--documentId <id>` | Get a database table with property schema and item pages |
|
|
113
|
+
| `list-trashed-content-databases` | | List soft-deleted databases visible in the sidebar Trash surface |
|
|
114
|
+
| `restore-content-database` | `--databaseId <id>` | Restore a soft-deleted database from the sidebar Trash surface |
|
|
112
115
|
| `get-content-database-source` | `--databaseId <id>` or `--documentId <id>` | Inspect local/no-source or source-backed status, mappings, row identity, freshness, and change sets |
|
|
113
116
|
| `attach-content-database-source` | `--databaseId <id>` or `--documentId <id> [--sourceType mock\|builder-cms] [--sourceName] [--sourceTable] [--relationshipMode items\|details] [--join <json>]` | Attach a source binding; use `items` to add more rows and `details` to match a source onto existing rows |
|
|
114
117
|
| `change-content-database-source-role` | `--databaseId <id>` or `--documentId <id> --sourceId <id> --relationshipMode items\|details [--join <json>]` | Change an attached source between adding rows and adding matched detail columns without removing the source |
|
|
@@ -569,9 +572,10 @@ columns updates that row page's grouping property through
|
|
|
569
572
|
multi-select, users can add a new board group from the board itself; this
|
|
570
573
|
appends a new option to the grouped property definition.
|
|
571
574
|
Use
|
|
572
|
-
`create-content-database`, `
|
|
573
|
-
`
|
|
574
|
-
`
|
|
575
|
+
`create-content-database`, `create-inline-content-database`,
|
|
576
|
+
`get-content-database`, `list-trashed-content-databases`,
|
|
577
|
+
`restore-content-database`, `add-database-item`, `duplicate-database-item`,
|
|
578
|
+
`duplicate-database-items`, `delete-database-items`, `move-database-item`,
|
|
575
579
|
`update-content-database-view`, `list-document-properties`,
|
|
576
580
|
`configure-document-property`, `set-document-property`,
|
|
577
581
|
`duplicate-document-property`, and `delete-document-property`; do not edit
|
|
@@ -1,46 +1,34 @@
|
|
|
1
1
|
# Content
|
|
2
2
|
|
|
3
|
-
Open-source Obsidian for MDX
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Open-source Obsidian for MDX — an agent-native, local-file document workspace and
|
|
4
|
+
a self-hostable alternative to Notion and Obsidian. Edit local Markdown/MDX files,
|
|
5
|
+
build Notion-style databases, and write with an AI agent.
|
|
6
|
+
|
|
7
|
+
**Live app: [content.agent-native.com](https://content.agent-native.com)**
|
|
8
|
+
|
|
9
|
+
Open a doc and ask "rewrite this paragraph to be more concise" or "create a page
|
|
10
|
+
called Q4 Planning with sub-pages for Goals, Metrics, and Risks" — the same result
|
|
11
|
+
whether you do it yourself or ask. Documents can live in your SQL database or as
|
|
12
|
+
local Markdown/MDX files.
|
|
6
13
|
|
|
7
14
|
## Features
|
|
8
15
|
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
- Agent can create, read, update, and search documents
|
|
16
|
-
- Auto-save with debouncing
|
|
17
|
-
- Dark/light theme
|
|
16
|
+
- Rich-text editor (Tiptap) with slash commands and hierarchical pages.
|
|
17
|
+
- Notion-style databases with table, board, calendar, gallery, and timeline views.
|
|
18
|
+
- Local Markdown/MDX file editing plus custom interactive MDX components.
|
|
19
|
+
- Inline comments, version history, and full-text search.
|
|
20
|
+
- Private-by-default sharing with per-user / per-org grants and public pages.
|
|
21
|
+
- Optional Notion and Builder.io sync.
|
|
18
22
|
|
|
19
|
-
##
|
|
23
|
+
## Develop locally
|
|
24
|
+
|
|
25
|
+
Scaffold your own copy and run it:
|
|
20
26
|
|
|
21
27
|
```bash
|
|
28
|
+
npx @agent-native/core@latest create my-content --standalone --template content
|
|
29
|
+
cd my-content
|
|
22
30
|
pnpm install
|
|
23
31
|
pnpm dev
|
|
24
32
|
```
|
|
25
33
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
## Data
|
|
29
|
-
|
|
30
|
-
Documents are stored in the app's SQL database. Local development defaults to SQLite at `data/app.db`; deployed apps should set `DATABASE_URL` to a persistent SQL database. The agent should use content actions for normal document operations and reserve `db-query` / `db-exec` for inspection or maintenance.
|
|
31
|
-
|
|
32
|
-
## Enable Builder live writes
|
|
33
|
-
|
|
34
|
-
Connect Builder through the existing Builder Connect flow, the same connection used by the AI assistant. Once connected, Content resolves the key automatically for the user, or for org owners/admins through the org connection. There is no separate key entry. In local development, `BUILDER_PRIVATE_KEY` and `BUILDER_PUBLIC_KEY` in `.env.local` also work; see `DEVELOPING.md` for local env opt-in details.
|
|
35
|
-
|
|
36
|
-
Live writes are only allowed for the safe write model `agent-native-blog-article-test` (`BUILDER_CMS_SAFE_WRITE_MODEL`). Other Builder models stay read-only by design.
|
|
37
|
-
|
|
38
|
-
Write access is an intentional per-source choice from the source panel selector. Sources start at `read_only`, can move to `stage_only` to push approved changes as Builder autosave revisions, and can move to `publish_updates` for state-preserving live writes. Autosave staging stays quiet; update-in-place and publish writes trigger Builder webhooks so downstream rebuilds can run.
|
|
39
|
-
|
|
40
|
-
In `publish_updates`, Content PATCHes the Builder entry in place and preserves its current publication state: published entries stay published and drafts stay draft. Content never sends a `published` field, so a normal content push cannot publish or unpublish. Builder merges the field changes and preserves the entry envelope, including scheduling and targeting.
|
|
41
|
-
|
|
42
|
-
Publication transitions are explicit per-row choices in the review/diff, only when the source enables **Allow publish/unpublish per item**. Publishing a draft or unpublishing a published entry is never a bulk default, and unpublish requires confirmation because it takes live content down. New Builder entries are created as drafts.
|
|
43
|
-
|
|
44
|
-
**Push all approved (N)** runs approved rows in a concurrency-capped batch. It continues after individual errors, reports per-item `succeeded`, `blocked`, or `failed` status, and can be resumed because already-succeeded items are skipped.
|
|
45
|
-
|
|
46
|
-
Before each write, Content reads the target's current state from Builder. A write blocks if the entry changed or was deleted since the diff, or if a requested publish/unpublish transition no longer matches the real Builder state. Body diffs remain non-executable for now; they are planned for a later slice.
|
|
34
|
+
Full docs: [agent-native.com/docs/template-content](https://agent-native.com/docs/template-content).
|
|
@@ -33,6 +33,7 @@ export default defineAction({
|
|
|
33
33
|
ownerDocumentId: schema.contentDatabases.ownerDocumentId,
|
|
34
34
|
deletedAt: schema.contentDatabases.deletedAt,
|
|
35
35
|
documentTitle: schema.documents.title,
|
|
36
|
+
documentParentId: schema.documents.parentId,
|
|
36
37
|
})
|
|
37
38
|
.from(schema.contentDatabases)
|
|
38
39
|
.innerJoin(
|
|
@@ -80,6 +81,9 @@ export default defineAction({
|
|
|
80
81
|
documentId: row.documentId,
|
|
81
82
|
ownerDocumentId: row.ownerDocumentId,
|
|
82
83
|
deletedAt: row.deletedAt!,
|
|
84
|
+
canPermanentlyDelete:
|
|
85
|
+
row.ownerDocumentId === null ||
|
|
86
|
+
row.documentParentId !== row.ownerDocumentId,
|
|
83
87
|
})),
|
|
84
88
|
};
|
|
85
89
|
},
|