@saadnvd1/agent-os 0.3.612 → 0.3.614
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 +1 -0
- package/app/api/learning-cards/route.test.ts +31 -0
- package/app/api/learning-cards/route.ts +7 -0
- package/app/api/orchestrators/[workspaceId]/intake/route.test.ts +223 -0
- package/app/api/orchestrators/[workspaceId]/intake/route.ts +73 -0
- package/app/api/voice/vocab/route.test.ts +28 -0
- package/app/api/voice/vocab/route.ts +24 -0
- package/components/Chat/ChatPanel.tsx +5 -2
- package/components/Chat/EventRow.tsx +2 -0
- package/components/LearningCards/LearningCard.tsx +21 -4
- package/components/Orchestrator/NeedsYouStrip.tsx +77 -38
- package/components/SessionList/RowParts.tsx +16 -0
- package/components/SessionList/SessionRow.tsx +4 -1
- package/components/SessionList/SessionShelves.tsx +9 -1
- package/data/learning-cards/index.ts +44 -10
- package/data/push/topics.ts +3 -0
- package/hooks/useNeedsYou.ts +4 -1
- package/lib/chat/events.ts +3 -1
- package/lib/chat/origin.ts +2 -1
- package/lib/chat/settings.ts +6 -0
- package/lib/config/client-config-types.ts +47 -0
- package/lib/config/client-config.ts +18 -0
- package/lib/db/changes.ts +3 -0
- package/lib/db/index.ts +2 -1
- package/lib/db/migrations/20261014T2300_learning_cards_watch.ts +18 -0
- package/lib/db/path.ts +7 -0
- package/lib/intake/intake.ts +250 -0
- package/lib/intake/limits.ts +30 -0
- package/lib/intake/message.ts +188 -0
- package/lib/intake/sniff.ts +59 -0
- package/lib/learning-cards/__fixtures__/db.ts +1 -1
- package/lib/learning-cards/analyze.ts +29 -22
- package/lib/learning-cards/index.ts +25 -2
- package/lib/learning-cards/store.ts +22 -0
- package/lib/learning-cards/types.ts +23 -0
- package/lib/orchestrator/ask-view.ts +4 -0
- package/lib/orchestrator/needs-you-items.ts +20 -0
- package/lib/orchestrator/overview.ts +3 -0
- package/lib/voice/vocab.ts +83 -0
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Lightbulb } from "lucide-react";
|
|
3
4
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
4
5
|
import type { SidebarRow } from "@/lib/sidebar/shelves";
|
|
5
6
|
import { cn } from "@/lib/utils";
|
|
@@ -45,6 +46,21 @@ export function UnreadDot() {
|
|
|
45
46
|
);
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
// A learning card is waiting on this session.
|
|
50
|
+
export function CardBadge({ count }: { count: number }) {
|
|
51
|
+
return (
|
|
52
|
+
<span
|
|
53
|
+
data-tip="Learning card waiting: open the session to keep or drop it"
|
|
54
|
+
className="shrink-0"
|
|
55
|
+
>
|
|
56
|
+
<Lightbulb
|
|
57
|
+
aria-label={count > 1 ? `${count} learning cards` : "Learning card"}
|
|
58
|
+
className="h-3 w-3 text-amber-600 dark:text-amber-400"
|
|
59
|
+
/>
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
48
64
|
export function RowRename({
|
|
49
65
|
initial,
|
|
50
66
|
onDone,
|
|
@@ -14,6 +14,7 @@ import { compactTimeAgo } from "@/lib/session-meta";
|
|
|
14
14
|
import { wakeLabel } from "@/lib/sidebar/snooze";
|
|
15
15
|
import { sameRow, type SidebarRow } from "@/lib/sidebar/shelves";
|
|
16
16
|
import { rowStatusBadge } from "@/lib/sidebar/row-badge";
|
|
17
|
+
import { usePendingCardSessions } from "@/data/learning-cards";
|
|
17
18
|
import { useMinuteTick } from "@/hooks/useMinuteTick";
|
|
18
19
|
import { selectionStore, selectionActions } from "@/stores/sessionSelection";
|
|
19
20
|
import { cn } from "@/lib/utils";
|
|
@@ -21,7 +22,7 @@ import { QuickChangeRow } from "./QuickChangeRow";
|
|
|
21
22
|
import { useRowContext } from "./RowContext";
|
|
22
23
|
import { SessionRowMenu } from "./SessionRowMenu";
|
|
23
24
|
import { RowShell, type RowMenuKind } from "./RowShell";
|
|
24
|
-
import { NestRail, RowDot, RowRename, UnreadDot } from "./RowParts";
|
|
25
|
+
import { CardBadge, NestRail, RowDot, RowRename, UnreadDot } from "./RowParts";
|
|
25
26
|
import { RowSubtitle } from "./RowSubtitle";
|
|
26
27
|
import { OrchestratorAsks } from "./OrchestratorAsks";
|
|
27
28
|
|
|
@@ -63,6 +64,7 @@ export const SessionRow = memo(function SessionRow({
|
|
|
63
64
|
};
|
|
64
65
|
|
|
65
66
|
const badge = rowStatusBadge(row);
|
|
67
|
+
const cardCount = usePendingCardSessions().get(session.id) ?? 0;
|
|
66
68
|
|
|
67
69
|
const menu = (kind: RowMenuKind) => (
|
|
68
70
|
<SessionRowMenu
|
|
@@ -102,6 +104,7 @@ export const SessionRow = memo(function SessionRow({
|
|
|
102
104
|
/>
|
|
103
105
|
</span>
|
|
104
106
|
)}
|
|
107
|
+
{cardCount > 0 && <CardBadge count={cardCount} />}
|
|
105
108
|
{row.unread && !badge && <UnreadDot />}
|
|
106
109
|
{isFork(session) && (
|
|
107
110
|
<GitFork className="text-muted-foreground/60 h-3 w-3 shrink-0" />
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import type { MachineGroup } from "@/lib/sidebar/machines";
|
|
12
12
|
import { sidebarUiActions } from "@/stores/sidebarUi";
|
|
13
13
|
import { cn } from "@/lib/utils";
|
|
14
|
+
import { usePendingCardSessions } from "@/data/learning-cards";
|
|
14
15
|
import { SessionRow } from "./SessionRow";
|
|
15
16
|
import { OrchestratorPinRow, OrchestratorStartRow } from "./OrchestratorPinRow";
|
|
16
17
|
import { QueuedRow } from "./QueuedRow";
|
|
@@ -125,7 +126,14 @@ export function SessionShelves({
|
|
|
125
126
|
}) {
|
|
126
127
|
const shown = shelves.done.slice(0, doneLimit(donePages));
|
|
127
128
|
const hidden = shelves.done.length - shown.length;
|
|
128
|
-
const
|
|
129
|
+
const pendingCards = usePendingCardSessions();
|
|
130
|
+
const doneCards = shelves.done.reduce(
|
|
131
|
+
(n, r) => n + (pendingCards.get(r.session.id) ?? 0),
|
|
132
|
+
0
|
|
133
|
+
);
|
|
134
|
+
const summary =
|
|
135
|
+
doneSummary(shelves.done).text +
|
|
136
|
+
(doneCards ? ` · ${doneCards} card${doneCards > 1 ? "s" : ""}` : "");
|
|
129
137
|
// Live work above, what's resting below a hairline.
|
|
130
138
|
const live =
|
|
131
139
|
shelves.orchestrators.length +
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
2
|
import { toast } from "sonner";
|
|
3
|
-
import
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import type { Card, PendingCardView } from "@/lib/learning-cards/types";
|
|
4
5
|
import { useExperiment } from "../experiments";
|
|
6
|
+
import { orchestratorKeys } from "../orchestrators";
|
|
7
|
+
import { usePollWhenOffline } from "../push/connection";
|
|
5
8
|
|
|
6
|
-
export type { Card };
|
|
9
|
+
export type { Card, PendingCardView };
|
|
7
10
|
|
|
8
11
|
export interface CardEdits {
|
|
9
12
|
lesson?: string;
|
|
@@ -12,7 +15,10 @@ export interface CardEdits {
|
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export const learningCardsKeys = {
|
|
15
|
-
|
|
18
|
+
all: ["learning-cards"] as const,
|
|
19
|
+
pending: ["learning-cards", "pending"] as const,
|
|
20
|
+
session: (sessionId: string) =>
|
|
21
|
+
["learning-cards", "session", sessionId] as const,
|
|
16
22
|
};
|
|
17
23
|
|
|
18
24
|
async function json<T>(res: Response): Promise<T> {
|
|
@@ -36,11 +42,39 @@ export function useLearningCards(sessionId: string) {
|
|
|
36
42
|
).cards,
|
|
37
43
|
enabled,
|
|
38
44
|
staleTime: 10_000,
|
|
39
|
-
|
|
45
|
+
// Pushed when a card is made or decided; this is the offline fallback.
|
|
46
|
+
refetchInterval: usePollWhenOffline(60_000),
|
|
40
47
|
});
|
|
41
48
|
}
|
|
42
49
|
|
|
43
|
-
|
|
50
|
+
// Every pending card, for the sidebar's badges. One request for the list.
|
|
51
|
+
export function usePendingCards() {
|
|
52
|
+
const enabled = useExperiment("learning-cards");
|
|
53
|
+
return useQuery({
|
|
54
|
+
queryKey: learningCardsKeys.pending,
|
|
55
|
+
queryFn: async () =>
|
|
56
|
+
(
|
|
57
|
+
await json<{ cards: PendingCardView[] }>(
|
|
58
|
+
await fetch("/api/learning-cards")
|
|
59
|
+
)
|
|
60
|
+
).cards,
|
|
61
|
+
enabled,
|
|
62
|
+
staleTime: 10_000,
|
|
63
|
+
refetchInterval: usePollWhenOffline(60_000),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function usePendingCardSessions(): Map<string, number> {
|
|
68
|
+
const { data } = usePendingCards();
|
|
69
|
+
return useMemo(() => {
|
|
70
|
+
const m = new Map<string, number>();
|
|
71
|
+
for (const c of data ?? [])
|
|
72
|
+
m.set(c.sessionId, (m.get(c.sessionId) ?? 0) + 1);
|
|
73
|
+
return m;
|
|
74
|
+
}, [data]);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function useDecide() {
|
|
44
78
|
const queryClient = useQueryClient();
|
|
45
79
|
return useMutation({
|
|
46
80
|
mutationFn: async (
|
|
@@ -54,9 +88,9 @@ function useDecide(sessionId: string) {
|
|
|
54
88
|
})
|
|
55
89
|
),
|
|
56
90
|
onSuccess: (_d, v) => {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
91
|
+
// The chat's card, the sidebar badge and the Needs-you strip.
|
|
92
|
+
queryClient.invalidateQueries({ queryKey: learningCardsKeys.all });
|
|
93
|
+
queryClient.invalidateQueries({ queryKey: orchestratorKeys.all });
|
|
60
94
|
// A saved recipe or a new rule shows up in their own lists.
|
|
61
95
|
if (v.action === "apply") {
|
|
62
96
|
queryClient.invalidateQueries({ queryKey: ["recipes"] });
|
|
@@ -68,8 +102,8 @@ function useDecide(sessionId: string) {
|
|
|
68
102
|
});
|
|
69
103
|
}
|
|
70
104
|
|
|
71
|
-
export const useApplyCard = (
|
|
72
|
-
const m = useDecide(
|
|
105
|
+
export const useApplyCard = () => {
|
|
106
|
+
const m = useDecide();
|
|
73
107
|
return {
|
|
74
108
|
...m,
|
|
75
109
|
apply: (id: number, edits: CardEdits = {}) =>
|
package/data/push/topics.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { featureKeys } from "../features";
|
|
|
17
17
|
import { archivedKeys } from "../done";
|
|
18
18
|
import { awayKeys } from "../away";
|
|
19
19
|
import { bookmarkKeys } from "../bookmarks/keys";
|
|
20
|
+
import { learningCardsKeys } from "../learning-cards";
|
|
20
21
|
|
|
21
22
|
// What each pushed topic (a table that changed, lib/db/changes.ts, or a
|
|
22
23
|
// pushed view) means the browser should refetch. Only queries on screen
|
|
@@ -73,6 +74,8 @@ function keysFor(topic: string): QueryKey[] {
|
|
|
73
74
|
return [awayKeys.all];
|
|
74
75
|
case "dev_servers":
|
|
75
76
|
return [devServerKeys.all];
|
|
77
|
+
case "learning_cards":
|
|
78
|
+
return [learningCardsKeys.all, orchestratorKeys.all];
|
|
76
79
|
case "chat_bookmarks":
|
|
77
80
|
return [bookmarkKeys.all];
|
|
78
81
|
default:
|
package/hooks/useNeedsYou.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { useSessionsQuery } from "@/data/sessions/queries";
|
|
|
6
6
|
import { useProjectsQuery } from "@/data/projects/queries";
|
|
7
7
|
import { fetchStatuses } from "@/data/statuses/queries";
|
|
8
8
|
import { statusKeys } from "@/data/sessions/keys";
|
|
9
|
+
import { useExperiment } from "@/data/experiments";
|
|
9
10
|
import { useOrchestratorOverview } from "@/data/orchestrators";
|
|
10
11
|
import {
|
|
11
12
|
needsYouItems,
|
|
@@ -20,6 +21,7 @@ export function useNeedsYou(
|
|
|
20
21
|
chatWaiting: boolean
|
|
21
22
|
): NeedsYouItem[] {
|
|
22
23
|
const overview = useOrchestratorOverview(workspaceId);
|
|
24
|
+
const cardsOn = useExperiment("learning-cards");
|
|
23
25
|
const { data: sessions } = useSessionsQuery();
|
|
24
26
|
const { data: projects = [] } = useProjectsQuery();
|
|
25
27
|
const { data: statuses } = useQuery({
|
|
@@ -45,9 +47,10 @@ export function useNeedsYou(
|
|
|
45
47
|
questions: overview?.questions ?? [],
|
|
46
48
|
dismissed: overview?.dismissed ?? [],
|
|
47
49
|
tasks,
|
|
50
|
+
cards: cardsOn ? (overview?.cards ?? []) : [],
|
|
48
51
|
chatWaiting,
|
|
49
52
|
chatKey: `approval:${workspaceId}`,
|
|
50
53
|
}),
|
|
51
|
-
[overview, tasks, chatWaiting, workspaceId]
|
|
54
|
+
[overview, tasks, cardsOn, chatWaiting, workspaceId]
|
|
52
55
|
);
|
|
53
56
|
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -68,7 +68,9 @@ export type ChatOriginKind =
|
|
|
68
68
|
// AgentOS itself: the load monitor, stacks.
|
|
69
69
|
| "system"
|
|
70
70
|
// The reader's own decision, made outside the composer (a plan carried out).
|
|
71
|
-
| "decision"
|
|
71
|
+
| "decision"
|
|
72
|
+
// The reader's phone: a shake report or something shared to AgentOS.
|
|
73
|
+
| "device";
|
|
72
74
|
|
|
73
75
|
export interface ChatOrigin {
|
|
74
76
|
kind: ChatOriginKind;
|
package/lib/chat/origin.ts
CHANGED
|
@@ -24,7 +24,8 @@ function fromText(text: string): ChatOrigin | null {
|
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// What sent a user item, or null when the reader typed it.
|
|
27
|
+
// What sent a user item, or null when the reader typed it. A "device" origin
|
|
28
|
+
// is only ever set by the server (lib/intake), so it is never read from text.
|
|
28
29
|
export function originOf(item: UserItem): ChatOrigin | null {
|
|
29
30
|
if (item.origin) return item.origin;
|
|
30
31
|
if (item.peer)
|
package/lib/chat/settings.ts
CHANGED
|
@@ -66,6 +66,12 @@ function visibleCommands(caps: Capabilities): ChatCommand[] {
|
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// The commands already loaded for a session's folder; never loads them.
|
|
70
|
+
export function cachedCommands(session: Session): ChatCommand[] {
|
|
71
|
+
const caps = registry.caps.get(capsKey(session));
|
|
72
|
+
return caps ? visibleCommands(caps) : [];
|
|
73
|
+
}
|
|
74
|
+
|
|
69
75
|
export function emitCapabilities(session: Session, listener?: Listener): void {
|
|
70
76
|
const caps = registry.caps.get(capsKey(session));
|
|
71
77
|
if (!caps) return;
|
|
@@ -26,4 +26,51 @@ export interface ClientConfig {
|
|
|
26
26
|
taskStates: Record<string, LabelTone>;
|
|
27
27
|
runStatuses: Record<string, LabelTone>;
|
|
28
28
|
runTriggers: Record<string, string>;
|
|
29
|
+
features: ClientFeatures;
|
|
30
|
+
intake: IntakeLimits;
|
|
31
|
+
attachments: { max: number };
|
|
32
|
+
voice: VoiceLimits;
|
|
29
33
|
}
|
|
34
|
+
|
|
35
|
+
// Server kill switches for the phone's new sends.
|
|
36
|
+
export interface ClientFeatures {
|
|
37
|
+
shakeToReport: boolean;
|
|
38
|
+
shareIntake: boolean;
|
|
39
|
+
voice: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface IntakeLimits {
|
|
43
|
+
maxBytes: number;
|
|
44
|
+
maxFiles: number;
|
|
45
|
+
maxInlineImages: number;
|
|
46
|
+
maxNoteChars: number;
|
|
47
|
+
screenshotMaxEdge: number;
|
|
48
|
+
logSeconds: number;
|
|
49
|
+
logMaxLines: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface VoiceLimits {
|
|
53
|
+
maxPhrases: number;
|
|
54
|
+
holdMinMs: number;
|
|
55
|
+
cancelSlidePx: number;
|
|
56
|
+
speakMaxChars: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const INTAKE_LIMITS: IntakeLimits = {
|
|
60
|
+
maxBytes: 25_000_000,
|
|
61
|
+
maxFiles: 8,
|
|
62
|
+
maxInlineImages: 2,
|
|
63
|
+
maxNoteChars: 280,
|
|
64
|
+
screenshotMaxEdge: 1600,
|
|
65
|
+
logSeconds: 60,
|
|
66
|
+
logMaxLines: 200,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const ATTACHMENT_LIMITS = { max: 8 };
|
|
70
|
+
|
|
71
|
+
export const VOICE_LIMITS: VoiceLimits = {
|
|
72
|
+
maxPhrases: 100,
|
|
73
|
+
holdMinMs: 250,
|
|
74
|
+
cancelSlidePx: 80,
|
|
75
|
+
speakMaxChars: 300,
|
|
76
|
+
};
|
|
@@ -7,12 +7,26 @@ import {
|
|
|
7
7
|
import { TASK_STATE_LABELS } from "../tasks/state-labels";
|
|
8
8
|
import { cachedCheck } from "../updates/check";
|
|
9
9
|
import {
|
|
10
|
+
ATTACHMENT_LIMITS,
|
|
10
11
|
CLIENT_CONFIG_VERSION,
|
|
12
|
+
INTAKE_LIMITS,
|
|
13
|
+
VOICE_LIMITS,
|
|
14
|
+
type ClientFeatures,
|
|
11
15
|
type ClientConfig,
|
|
12
16
|
} from "./client-config-types";
|
|
13
17
|
|
|
14
18
|
const startedAt = new Date(Date.now() - process.uptime() * 1000).toISOString();
|
|
15
19
|
|
|
20
|
+
// Off when its env switch says so, for a server that wants it dark.
|
|
21
|
+
export function featuresNow(): ClientFeatures {
|
|
22
|
+
const intake = process.env.AGENTOS_INTAKE !== "off";
|
|
23
|
+
return {
|
|
24
|
+
shakeToReport: intake,
|
|
25
|
+
shareIntake: intake,
|
|
26
|
+
voice: process.env.AGENTOS_VOICE !== "off",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
export function clientConfig(): ClientConfig {
|
|
17
31
|
return {
|
|
18
32
|
v: CLIENT_CONFIG_VERSION,
|
|
@@ -24,5 +38,9 @@ export function clientConfig(): ClientConfig {
|
|
|
24
38
|
taskStates: TASK_STATE_LABELS,
|
|
25
39
|
runStatuses: RUN_STATUS_LABELS,
|
|
26
40
|
runTriggers: RUN_TRIGGER_LABELS,
|
|
41
|
+
features: featuresNow(),
|
|
42
|
+
intake: INTAKE_LIMITS,
|
|
43
|
+
attachments: ATTACHMENT_LIMITS,
|
|
44
|
+
voice: VOICE_LIMITS,
|
|
27
45
|
};
|
|
28
46
|
}
|
package/lib/db/changes.ts
CHANGED
|
@@ -43,6 +43,8 @@ export const TABLES_WATCHED_80 = [
|
|
|
43
43
|
|
|
44
44
|
export const TABLES_WATCHED_CHAT_BOOKMARKS = ["chat_bookmarks"] as const;
|
|
45
45
|
|
|
46
|
+
export const TABLES_WATCHED_LEARNING_CARDS = ["learning_cards"] as const;
|
|
47
|
+
|
|
46
48
|
// Every table any migration watches, for the browser's topic map to cover.
|
|
47
49
|
export const WATCHED_TABLES: readonly string[] = [
|
|
48
50
|
...TABLES_WATCHED_45,
|
|
@@ -52,6 +54,7 @@ export const WATCHED_TABLES: readonly string[] = [
|
|
|
52
54
|
...TABLES_WATCHED_78,
|
|
53
55
|
...TABLES_WATCHED_80,
|
|
54
56
|
...TABLES_WATCHED_CHAT_BOOKMARKS,
|
|
57
|
+
...TABLES_WATCHED_LEARNING_CARDS,
|
|
55
58
|
];
|
|
56
59
|
|
|
57
60
|
export function installChangeTriggers(
|
package/lib/db/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import path from "path";
|
|
|
3
3
|
import fs from "fs";
|
|
4
4
|
import { createSchema } from "./schema";
|
|
5
5
|
import { runMigrations } from "./migrations";
|
|
6
|
+
import { dbPath } from "./path";
|
|
6
7
|
|
|
7
8
|
// Re-export types and queries
|
|
8
9
|
export * from "./types";
|
|
@@ -11,7 +12,7 @@ export * from "./lumifyhub";
|
|
|
11
12
|
export * from "./lumifyhub-docs";
|
|
12
13
|
export * from "./stacks";
|
|
13
14
|
|
|
14
|
-
const DB_PATH =
|
|
15
|
+
const DB_PATH = dbPath();
|
|
15
16
|
const LOCK_PATH = DB_PATH + ".init-lock";
|
|
16
17
|
|
|
17
18
|
// Simple file-based lock for initialization
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
import {
|
|
3
|
+
installChangeTriggers,
|
|
4
|
+
TABLES_WATCHED_LEARNING_CARDS,
|
|
5
|
+
} from "../changes";
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
key: "20261014T2300_learning_cards_watch",
|
|
9
|
+
up: (db) => {
|
|
10
|
+
// A card decided in one place disappears from every open screen.
|
|
11
|
+
// Additive: an index and change triggers.
|
|
12
|
+
db.exec(
|
|
13
|
+
`CREATE INDEX IF NOT EXISTS idx_learning_cards_pending
|
|
14
|
+
ON learning_cards(status, created_at)`
|
|
15
|
+
);
|
|
16
|
+
installChangeTriggers(db, TABLES_WATCHED_LEARNING_CARDS);
|
|
17
|
+
},
|
|
18
|
+
} satisfies Migration;
|
package/lib/db/path.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
|
|
3
|
+
export const dbPath = (): string =>
|
|
4
|
+
process.env.DB_PATH || path.join(process.cwd(), "agent-os.db");
|
|
5
|
+
|
|
6
|
+
// Where AgentOS keeps its own files beside the database.
|
|
7
|
+
export const dataRoot = (): string => path.dirname(path.resolve(dbPath()));
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { dataRoot } from "../db/path";
|
|
4
|
+
import type { ChatImage } from "../chat/events";
|
|
5
|
+
import { chatState, sendChat } from "../chat/runner";
|
|
6
|
+
import { ensureOrchestrator, getOrchestrator } from "../orchestrator/home";
|
|
7
|
+
import { getWorkspace } from "../workspaces";
|
|
8
|
+
import { INTAKE_LIMITS, MAX_INLINE_IMAGE_BYTES } from "./limits";
|
|
9
|
+
import { buildIntakeMessage, type IntakeMeta } from "./message";
|
|
10
|
+
import { sniff } from "./sniff";
|
|
11
|
+
|
|
12
|
+
export class IntakeError extends Error {
|
|
13
|
+
constructor(
|
|
14
|
+
public status: number,
|
|
15
|
+
message: string
|
|
16
|
+
) {
|
|
17
|
+
super(message);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface IntakeResult {
|
|
22
|
+
id: string;
|
|
23
|
+
sessionId: string | null;
|
|
24
|
+
delivered: "sent" | "queued";
|
|
25
|
+
dir: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IntakeFile {
|
|
29
|
+
name: string;
|
|
30
|
+
data: Buffer;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ID = /^[A-Za-z0-9-]{8,64}$/;
|
|
34
|
+
const DAY = 86_400_000;
|
|
35
|
+
|
|
36
|
+
const segment = (s: string) => s.replace(/[^\w-]/g, "_");
|
|
37
|
+
const day = (t: number) => new Date(t).toISOString().slice(0, 10);
|
|
38
|
+
|
|
39
|
+
const workspaceRoot = (workspaceId: string) =>
|
|
40
|
+
path.join(dataRoot(), "intake", segment(workspaceId));
|
|
41
|
+
|
|
42
|
+
/** A phone's filename as one safe path segment. */
|
|
43
|
+
export function safeName(name: string, taken: Set<string>, n: number): string {
|
|
44
|
+
let base = path.basename(name.replace(/\\/g, "/"));
|
|
45
|
+
base = base
|
|
46
|
+
.replace(/[^a-zA-Z0-9.-]/g, "_")
|
|
47
|
+
.replace(/^\.+/, "")
|
|
48
|
+
.slice(0, 80);
|
|
49
|
+
if (!base) base = `file-${n}`;
|
|
50
|
+
let out = base;
|
|
51
|
+
for (let i = 2; taken.has(out); i++) {
|
|
52
|
+
const dot = base.lastIndexOf(".");
|
|
53
|
+
out =
|
|
54
|
+
dot > 0 ? `${base.slice(0, dot)}-${i}${base.slice(dot)}` : `${base}-${i}`;
|
|
55
|
+
}
|
|
56
|
+
taken.add(out);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function checkMeta(meta: IntakeMeta) {
|
|
61
|
+
if (!meta || typeof meta !== "object") throw new IntakeError(400, "Bad meta");
|
|
62
|
+
if (typeof meta.clientId !== "string" || !ID.test(meta.clientId))
|
|
63
|
+
throw new IntakeError(400, "clientId must be a uuid");
|
|
64
|
+
if (meta.kind !== "report" && meta.kind !== "share")
|
|
65
|
+
throw new IntakeError(400, "kind must be report or share");
|
|
66
|
+
if (meta.note !== undefined) {
|
|
67
|
+
if (typeof meta.note !== "string") throw new IntakeError(400, "Bad note");
|
|
68
|
+
if (meta.note.length > INTAKE_LIMITS.maxNoteChars)
|
|
69
|
+
throw new IntakeError(413, "Note is too long");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// An earlier request with this clientId, today or just before midnight.
|
|
74
|
+
function existing(
|
|
75
|
+
workspaceId: string,
|
|
76
|
+
clientId: string,
|
|
77
|
+
now: number
|
|
78
|
+
): string | null {
|
|
79
|
+
for (const t of [now, now - DAY]) {
|
|
80
|
+
const dir = path.join(workspaceRoot(workspaceId), day(t), clientId);
|
|
81
|
+
if (fs.existsSync(dir)) return dir;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function earlier(dir: string, clientId: string, workspaceId: string) {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(
|
|
89
|
+
fs.readFileSync(path.join(dir, "result.json"), "utf8")
|
|
90
|
+
) as IntakeResult;
|
|
91
|
+
} catch {
|
|
92
|
+
// The first request is still delivering.
|
|
93
|
+
return {
|
|
94
|
+
id: clientId,
|
|
95
|
+
sessionId: getOrchestrator(workspaceId)?.id ?? null,
|
|
96
|
+
delivered: "queued",
|
|
97
|
+
dir,
|
|
98
|
+
} satisfies IntakeResult;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Saves what a phone sent and hands it to the workspace's orchestrator. */
|
|
103
|
+
export async function receiveIntake(input: {
|
|
104
|
+
workspaceId: string;
|
|
105
|
+
deviceName: string;
|
|
106
|
+
meta: IntakeMeta;
|
|
107
|
+
files: IntakeFile[];
|
|
108
|
+
now?: number;
|
|
109
|
+
}): Promise<IntakeResult & { duplicate: boolean }> {
|
|
110
|
+
const { workspaceId, deviceName, meta, files } = input;
|
|
111
|
+
const now = input.now ?? Date.now();
|
|
112
|
+
if (!getWorkspace(workspaceId))
|
|
113
|
+
throw new IntakeError(404, "Unknown workspace");
|
|
114
|
+
checkMeta(meta);
|
|
115
|
+
if (files.length > INTAKE_LIMITS.maxFiles)
|
|
116
|
+
throw new IntakeError(413, "Too many files");
|
|
117
|
+
const total = files.reduce((n, f) => n + f.data.length, 0);
|
|
118
|
+
if (total > INTAKE_LIMITS.maxBytes) throw new IntakeError(413, "Too large");
|
|
119
|
+
const sniffed = files.map((f) => {
|
|
120
|
+
const kind = sniff(f.data, f.name);
|
|
121
|
+
if (!kind)
|
|
122
|
+
throw new IntakeError(415, `Can't take ${path.basename(f.name)}`);
|
|
123
|
+
return kind;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const prior = existing(workspaceId, meta.clientId, now);
|
|
127
|
+
if (prior)
|
|
128
|
+
return { ...earlier(prior, meta.clientId, workspaceId), duplicate: true };
|
|
129
|
+
|
|
130
|
+
const parent = path.join(workspaceRoot(workspaceId), day(now));
|
|
131
|
+
const dir = path.join(parent, meta.clientId);
|
|
132
|
+
fs.mkdirSync(parent, { recursive: true, mode: 0o700 });
|
|
133
|
+
try {
|
|
134
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
135
|
+
} catch (e) {
|
|
136
|
+
if ((e as NodeJS.ErrnoException).code === "EEXIST")
|
|
137
|
+
return { ...earlier(dir, meta.clientId, workspaceId), duplicate: true };
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const write = (name: string, data: string | Buffer) =>
|
|
143
|
+
fs.writeFileSync(path.join(dir, name), data, { mode: 0o600 });
|
|
144
|
+
write(
|
|
145
|
+
"meta.json",
|
|
146
|
+
JSON.stringify({ ...meta, deviceName, receivedAt: now }, null, 2)
|
|
147
|
+
);
|
|
148
|
+
if (meta.kind === "report")
|
|
149
|
+
write("report.json", JSON.stringify(meta.report ?? {}, null, 2));
|
|
150
|
+
const taken = new Set([
|
|
151
|
+
"meta.json",
|
|
152
|
+
"report.json",
|
|
153
|
+
"logs.txt",
|
|
154
|
+
"result.json",
|
|
155
|
+
]);
|
|
156
|
+
let logs: string | undefined;
|
|
157
|
+
const saved: { path: string; name: string; i: number }[] = [];
|
|
158
|
+
files.forEach((f, i) => {
|
|
159
|
+
if (f.name === "logs.txt" && logs === undefined) {
|
|
160
|
+
logs = f.data
|
|
161
|
+
.toString("utf8")
|
|
162
|
+
.split(/\r?\n/)
|
|
163
|
+
.slice(-INTAKE_LIMITS.logMaxLines)
|
|
164
|
+
.join("\n");
|
|
165
|
+
write("logs.txt", logs);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const name = safeName(f.name, taken, i + 1);
|
|
169
|
+
write(name, f.data);
|
|
170
|
+
saved.push({ path: path.join(dir, name), name, i });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const images: ChatImage[] = saved
|
|
174
|
+
.filter(
|
|
175
|
+
(s) =>
|
|
176
|
+
sniffed[s.i].kind === "image" &&
|
|
177
|
+
sniffed[s.i].mediaType !== "image/heic" &&
|
|
178
|
+
files[s.i].data.length <= MAX_INLINE_IMAGE_BYTES
|
|
179
|
+
)
|
|
180
|
+
.sort(
|
|
181
|
+
(a, b) =>
|
|
182
|
+
Number(/^screenshot/.test(b.name)) -
|
|
183
|
+
Number(/^screenshot/.test(a.name))
|
|
184
|
+
)
|
|
185
|
+
.slice(0, INTAKE_LIMITS.maxInlineImages)
|
|
186
|
+
.map((s) => ({
|
|
187
|
+
mediaType: sniffed[s.i].mediaType,
|
|
188
|
+
data: files[s.i].data.toString("base64"),
|
|
189
|
+
}));
|
|
190
|
+
|
|
191
|
+
const { text, body } = buildIntakeMessage({
|
|
192
|
+
meta,
|
|
193
|
+
deviceName,
|
|
194
|
+
dir,
|
|
195
|
+
files: saved.map((s) => s.path),
|
|
196
|
+
logs,
|
|
197
|
+
});
|
|
198
|
+
const orchestrator = ensureOrchestrator(workspaceId);
|
|
199
|
+
const state = chatState(orchestrator.id);
|
|
200
|
+
const delivered =
|
|
201
|
+
state === "running" || state === "waiting" ? "queued" : "sent";
|
|
202
|
+
await sendChat(orchestrator.id, {
|
|
203
|
+
id: `intake-${meta.clientId}`,
|
|
204
|
+
text,
|
|
205
|
+
images: images.length ? images : undefined,
|
|
206
|
+
queue: true,
|
|
207
|
+
origin: { kind: "device", label: deviceName, body },
|
|
208
|
+
});
|
|
209
|
+
const result: IntakeResult = {
|
|
210
|
+
id: meta.clientId,
|
|
211
|
+
sessionId: orchestrator.id,
|
|
212
|
+
delivered,
|
|
213
|
+
dir,
|
|
214
|
+
};
|
|
215
|
+
write("result.json", JSON.stringify(result));
|
|
216
|
+
return { ...result, duplicate: false };
|
|
217
|
+
} catch (e) {
|
|
218
|
+
// So a retry can deliver it.
|
|
219
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
220
|
+
throw e;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The request's form, refused once its body passes `max` bytes. */
|
|
225
|
+
export async function cappedFormData(
|
|
226
|
+
request: Request,
|
|
227
|
+
max: number
|
|
228
|
+
): Promise<FormData> {
|
|
229
|
+
const declared = Number(request.headers.get("content-length") ?? 0);
|
|
230
|
+
if (declared > max) throw new IntakeError(413, "Too large");
|
|
231
|
+
if (!request.body) throw new IntakeError(400, "No body");
|
|
232
|
+
let size = 0;
|
|
233
|
+
const counted = request.body.pipeThrough(
|
|
234
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
235
|
+
transform(chunk, controller) {
|
|
236
|
+
size += chunk.byteLength;
|
|
237
|
+
if (size > max) controller.error(new IntakeError(413, "Too large"));
|
|
238
|
+
else controller.enqueue(chunk);
|
|
239
|
+
},
|
|
240
|
+
})
|
|
241
|
+
);
|
|
242
|
+
try {
|
|
243
|
+
return await new Response(counted, {
|
|
244
|
+
headers: { "content-type": request.headers.get("content-type") ?? "" },
|
|
245
|
+
}).formData();
|
|
246
|
+
} catch (e) {
|
|
247
|
+
if (e instanceof IntakeError) throw e;
|
|
248
|
+
throw new IntakeError(size > max ? 413 : 400, "Bad upload");
|
|
249
|
+
}
|
|
250
|
+
}
|