@saadnvd1/agent-os 0.3.613 → 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.
@@ -0,0 +1,31 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { setExperiment } from "@/lib/experiments";
3
+ import { clearCards, seedSession } from "@/lib/learning-cards/__fixtures__/db";
4
+ import { insertCard } from "@/lib/learning-cards/store";
5
+ import { GET } from "./route";
6
+
7
+ beforeEach(() => {
8
+ clearCards();
9
+ setExperiment("learning-cards", true);
10
+ });
11
+
12
+ describe("GET /api/learning-cards", () => {
13
+ it("lists every pending card, and none while the experiment is off", async () => {
14
+ const s = seedSession();
15
+ insertCard({
16
+ sessionId: s.id,
17
+ workspaceId: s.ws.id,
18
+ kind: "lesson",
19
+ destination: "rule",
20
+ payload: { lesson: "Run tests first", destination: "rule", pairs: 1 },
21
+ });
22
+ const on = await (await GET()).json();
23
+ expect(on.cards).toHaveLength(1);
24
+ expect(on.cards[0]).toMatchObject({
25
+ sessionId: s.id,
26
+ title: "Run tests first",
27
+ });
28
+ setExperiment("learning-cards", false);
29
+ expect((await (await GET()).json()).cards).toEqual([]);
30
+ });
31
+ });
@@ -0,0 +1,7 @@
1
+ import { NextResponse } from "next/server";
2
+ import { pendingCards } from "@/lib/learning-cards";
3
+
4
+ // Every pending learning card, for the sidebar's badges.
5
+ export async function GET() {
6
+ return NextResponse.json({ cards: pendingCards() });
7
+ }
@@ -739,11 +739,14 @@ export function ChatPanel({
739
739
  onGo={(item) => {
740
740
  if (item.kind === "ask") needsYouFocusActions.ask(item.ask.id);
741
741
  else if (item.kind === "chat") toEnd();
742
- else if (item.kind === "task") {
742
+ else if (item.kind === "task" || item.kind === "card") {
743
743
  window.history.pushState(
744
744
  null,
745
745
  "",
746
- hrefWithSession(location.href, item.sessionId)
746
+ hrefWithSession(
747
+ location.href,
748
+ item.kind === "task" ? item.sessionId : item.card.sessionId
749
+ )
747
750
  );
748
751
  window.dispatchEvent(new PopStateEvent("popstate"));
749
752
  } else
@@ -149,12 +149,15 @@ function LessonRow({
149
149
 
150
150
  export function LearningCards({
151
151
  cards,
152
+ embedded = false,
152
153
  busy = false,
153
154
  onApply,
154
155
  onDismiss,
155
156
  onEditRecipe,
156
157
  }: {
157
158
  cards: Card[];
159
+ // Inside another surface (the Needs-you strip): no frame of its own.
160
+ embedded?: boolean;
158
161
  busy?: boolean;
159
162
  onApply: (
160
163
  id: number,
@@ -168,7 +171,11 @@ export function LearningCards({
168
171
  <div
169
172
  role="region"
170
173
  aria-label="Learning cards"
171
- className="bg-card popover-surface mb-2 flex flex-col gap-3 rounded-2xl p-3"
174
+ className={
175
+ embedded
176
+ ? "flex flex-col gap-3 px-3 pt-1 pb-3"
177
+ : "bg-card popover-surface mb-2 flex flex-col gap-3 rounded-2xl p-3"
178
+ }
172
179
  >
173
180
  {cards.map((c) =>
174
181
  c.kind === "recipe" ? (
@@ -195,12 +202,22 @@ export function LearningCards({
195
202
  }
196
203
 
197
204
  // On a finished session, above the composer. Nothing while the experiment is off.
198
- export function LearningCardsHost({ sessionId }: { sessionId: string }) {
205
+ export function LearningCardsHost({
206
+ sessionId,
207
+ only,
208
+ embedded,
209
+ }: {
210
+ sessionId: string;
211
+ // Just this card.
212
+ only?: number;
213
+ embedded?: boolean;
214
+ }) {
199
215
  const { data } = useLearningCards(sessionId);
200
- const { apply, dismiss, isPending } = useApplyCard(sessionId);
216
+ const { apply, dismiss, isPending } = useApplyCard();
201
217
  return (
202
218
  <LearningCards
203
- cards={data ?? []}
219
+ embedded={embedded}
220
+ cards={(data ?? []).filter((c) => only === undefined || c.id === only)}
204
221
  busy={isPending}
205
222
  onApply={apply}
206
223
  onDismiss={dismiss}
@@ -6,12 +6,15 @@ import {
6
6
  ChevronDown,
7
7
  CornerDownLeft,
8
8
  GitMerge,
9
+ Lightbulb,
9
10
  MessageCircleQuestion,
10
11
  UserRound,
11
12
  X,
12
13
  } from "lucide-react";
13
14
  import { toast } from "sonner";
15
+ import { useApplyCard } from "@/data/learning-cards";
14
16
  import { useDismissNeedsYou } from "@/data/orchestrators";
17
+ import { LearningCardsHost } from "@/components/LearningCards/LearningCard";
15
18
  import { useNeedsYou } from "@/hooks/useNeedsYou";
16
19
  import type { NeedsYouItem } from "@/lib/orchestrator/needs-you-items";
17
20
  import { cn } from "@/lib/utils";
@@ -21,6 +24,7 @@ const ICON = {
21
24
  question: MessageCircleQuestion,
22
25
  chat: MessageCircleQuestion,
23
26
  task: UserRound,
27
+ card: Lightbulb,
24
28
  } as const;
25
29
 
26
30
  const STORE = "agentos:needs-you-open";
@@ -42,6 +46,9 @@ export const NeedsYouStrip = memo(function NeedsYouStrip({
42
46
  }: NeedsYouActions & { workspaceId: string; chatWaiting: boolean }) {
43
47
  const items = useNeedsYou(workspaceId, chatWaiting);
44
48
  const dismiss = useDismissNeedsYou(workspaceId);
49
+ const cards = useApplyCard();
50
+ // The learning card opened in place.
51
+ const [openCard, setOpenCard] = useState<number | null>(null);
45
52
  // Open on a wide screen, closed on a phone, then as he left it. It renders
46
53
  // nothing until the orchestrator's data has loaded, so never on the server.
47
54
  const [open, setOpen] = useState(() => {
@@ -55,19 +62,21 @@ export const NeedsYouStrip = memo(function NeedsYouStrip({
55
62
  setOpen(!open);
56
63
  };
57
64
  const hide = (item: NeedsYouItem) =>
58
- dismiss.mutate(
59
- { key: item.key },
60
- {
61
- onSuccess: () =>
62
- toast("Hidden from Needs you", {
63
- action: {
64
- label: "Undo",
65
- onClick: () => dismiss.mutate({ key: item.key, undo: true }),
66
- },
67
- }),
68
- onError: (e) => toast.error(e.message),
69
- }
70
- );
65
+ item.kind === "card"
66
+ ? cards.dismiss(item.card.id)
67
+ : dismiss.mutate(
68
+ { key: item.key },
69
+ {
70
+ onSuccess: () =>
71
+ toast("Hidden from Needs you", {
72
+ action: {
73
+ label: "Undo",
74
+ onClick: () => dismiss.mutate({ key: item.key, undo: true }),
75
+ },
76
+ }),
77
+ onError: (e) => toast.error(e.message),
78
+ }
79
+ );
71
80
  const newest = items[0];
72
81
 
73
82
  return (
@@ -101,43 +110,73 @@ export const NeedsYouStrip = memo(function NeedsYouStrip({
101
110
  />
102
111
  </button>
103
112
  {open && (
104
- <ul className="max-h-48 divide-y divide-amber-500/15 overflow-y-auto border-t border-amber-500/15">
113
+ <ul
114
+ className={cn(
115
+ "divide-y divide-amber-500/15 overflow-y-auto border-t border-amber-500/15",
116
+ openCard === null ? "max-h-48" : "max-h-[60vh]"
117
+ )}
118
+ >
105
119
  {items.map((item) => {
106
120
  const Icon =
107
121
  item.kind === "ask" && item.ask.kind === "gate"
108
122
  ? GitMerge
109
123
  : ICON[item.kind];
124
+ const card = item.kind === "card" ? item.card : null;
125
+ const expanded = !!card && openCard === card.id;
110
126
  return (
111
- <li key={item.key} className="flex items-center">
112
- <button
113
- type="button"
114
- onClick={() => onGo(item)}
115
- className="hover:bg-foreground/[0.04] flex min-h-11 min-w-0 flex-1 items-center gap-2 px-3 text-left text-xs"
116
- >
117
- <Icon className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
118
- <span className="min-w-0 flex-1 truncate">{item.title}</span>
119
- <span className="text-muted-foreground/70 hidden max-w-[30%] shrink-0 truncate sm:block">
120
- {item.sub}
121
- </span>
122
- </button>
123
- {item.kind === "question" && (
127
+ <li key={item.key}>
128
+ <div className="flex items-center">
129
+ <button
130
+ type="button"
131
+ aria-expanded={card ? expanded : undefined}
132
+ onClick={() =>
133
+ card ? setOpenCard(expanded ? null : card.id) : onGo(item)
134
+ }
135
+ className="hover:bg-foreground/[0.04] flex min-h-11 min-w-0 flex-1 items-center gap-2 px-3 text-left text-xs"
136
+ >
137
+ <Icon className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
138
+ <span className="min-w-0 flex-1 truncate">
139
+ {item.title}
140
+ </span>
141
+ <span className="text-muted-foreground/70 hidden max-w-[30%] shrink-0 truncate sm:block">
142
+ {item.sub}
143
+ </span>
144
+ </button>
145
+ {item.kind === "question" && (
146
+ <button
147
+ type="button"
148
+ onClick={() => onReply(item)}
149
+ aria-label="Reply"
150
+ className="text-muted-foreground hover:text-foreground flex size-11 shrink-0 items-center justify-center"
151
+ >
152
+ <CornerDownLeft className="h-3.5 w-3.5" />
153
+ </button>
154
+ )}
124
155
  <button
125
156
  type="button"
126
- onClick={() => onReply(item)}
127
- aria-label="Reply"
157
+ onClick={() => hide(item)}
158
+ aria-label="Dismiss"
128
159
  className="text-muted-foreground hover:text-foreground flex size-11 shrink-0 items-center justify-center"
129
160
  >
130
- <CornerDownLeft className="h-3.5 w-3.5" />
161
+ <X className="h-3.5 w-3.5" />
131
162
  </button>
163
+ </div>
164
+ {card && expanded && (
165
+ <>
166
+ <LearningCardsHost
167
+ sessionId={card.sessionId}
168
+ only={card.id}
169
+ embedded
170
+ />
171
+ <button
172
+ type="button"
173
+ onClick={() => onGo(item)}
174
+ className="text-muted-foreground hover:text-foreground min-h-11 px-3 text-left text-xs underline-offset-2 hover:underline"
175
+ >
176
+ Open {card.sessionName}
177
+ </button>
178
+ </>
132
179
  )}
133
- <button
134
- type="button"
135
- onClick={() => hide(item)}
136
- aria-label="Dismiss"
137
- className="text-muted-foreground hover:text-foreground flex size-11 shrink-0 items-center justify-center"
138
- >
139
- <X className="h-3.5 w-3.5" />
140
- </button>
141
180
  </li>
142
181
  );
143
182
  })}
@@ -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 summary = doneSummary(shelves.done).text;
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 type { Card } from "@/lib/learning-cards/types";
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
- session: (sessionId: string) => ["learning-cards", sessionId] as const,
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
- refetchInterval: 30_000,
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
- function useDecide(sessionId: string) {
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
- queryClient.invalidateQueries({
58
- queryKey: learningCardsKeys.session(sessionId),
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 = (sessionId: string) => {
72
- const m = useDecide(sessionId);
105
+ export const useApplyCard = () => {
106
+ const m = useDecide();
73
107
  return {
74
108
  ...m,
75
109
  apply: (id: number, edits: CardEdits = {}) =>
@@ -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:
@@ -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/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(
@@ -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;
@@ -46,7 +46,7 @@ export function seedSession(
46
46
 
47
47
  export const clearCards = () =>
48
48
  db.exec(
49
- `DELETE FROM learning_cards; DELETE FROM experiment_usage; DELETE FROM orchestrator_notes`
49
+ `DELETE FROM learning_cards; DELETE FROM experiment_usage; DELETE FROM orchestrator_notes; DELETE FROM orchestrator_events`
50
50
  );
51
51
 
52
52
  export const uid = () => randomUUID();
@@ -4,7 +4,7 @@
4
4
  import os from "os";
5
5
  import { runClaude, type ClaudeRunner } from "../orchestrator/claude-cli";
6
6
  import { getOrchestrator } from "../orchestrator/home";
7
- import { addNote } from "../orchestrator/notes";
7
+ import { queueEventOnce } from "../orchestrator/events";
8
8
  import { untrusted, UNTRUSTED_RULE } from "../orchestrator/untrusted";
9
9
  import { isRemoteHost } from "../hosts";
10
10
  import { getProject } from "../projects";
@@ -17,7 +17,13 @@ import { readSession } from "./input";
17
17
  import { correctionPairs } from "./pairs";
18
18
  import { recipeSignals } from "./signals";
19
19
  import { insertCard } from "./store";
20
- import type { Destination, Pair, SessionInput } from "./types";
20
+ import {
21
+ cardTitle,
22
+ type Card,
23
+ type Destination,
24
+ type Pair,
25
+ type SessionInput,
26
+ } from "./types";
21
27
 
22
28
  const ID = "learning-cards" as const;
23
29
  const LESSON_MAX = 200;
@@ -136,7 +142,7 @@ export async function analyzeSession(
136
142
  detail: `signals: ${recipe.signals.join(",") || "none"}; pairs: ${pairs.length}`,
137
143
  });
138
144
 
139
- let made = 0;
145
+ const made: Card[] = [];
140
146
  if (recipe.hit && input.workspaceId) {
141
147
  try {
142
148
  recipePrefill(sessionId);
@@ -153,15 +159,15 @@ export async function analyzeSession(
153
159
  if (
154
160
  (out as { repeatable?: unknown } | null)?.repeatable === true &&
155
161
  draft
156
- )
157
- made += insertCard({
162
+ ) {
163
+ const card = insertCard({
158
164
  sessionId,
159
165
  workspaceId: input.workspaceId,
160
166
  kind: "recipe",
161
167
  payload: { signals: recipe.signals, ...draft },
162
- })
163
- ? 1
164
- : 0;
168
+ });
169
+ if (card) made.push(card);
170
+ }
165
171
  } catch (error) {
166
172
  console.warn("[learning-cards] recipe draft:", error);
167
173
  }
@@ -191,8 +197,8 @@ export async function analyzeSession(
191
197
  runner
192
198
  )) as Verdict | null;
193
199
  const ok = checkLesson(out, where);
194
- if (ok)
195
- made += insertCard({
200
+ if (ok) {
201
+ const card = insertCard({
196
202
  sessionId,
197
203
  workspaceId: input.workspaceId,
198
204
  kind: "lesson",
@@ -205,23 +211,24 @@ export async function analyzeSession(
205
211
  projectName: where.project?.name,
206
212
  pairs: pairs.length,
207
213
  },
208
- })
209
- ? 1
210
- : 0;
214
+ });
215
+ if (card) made.push(card);
216
+ }
211
217
  } catch (error) {
212
218
  console.warn("[learning-cards] lesson:", error);
213
219
  }
214
220
  }
215
221
 
216
- if (made && input.workspaceId && getOrchestrator(input.workspaceId))
217
- addNote(
218
- input.workspaceId,
219
- `Learning card on ${input.name}: ${
220
- made > 1
221
- ? "save as a recipe and keep a lesson"
222
- : "a suggestion to save or keep"
223
- }? Open the session to decide.`
224
- );
222
+ // One line per card, once: the key is the card, so a rerun or a restart
223
+ // tells the orchestrator nothing new.
224
+ if (input.workspaceId && getOrchestrator(input.workspaceId))
225
+ for (const card of made)
226
+ queueEventOnce(
227
+ input.workspaceId,
228
+ `learning-card:${card.id}`,
229
+ sessionId,
230
+ `Learning card on "${input.name.replace(/\s+/g, " ")}", waiting on Saad's Needs-you strip: ${untrusted("learning card", cardTitle(card))}`
231
+ );
225
232
  } finally {
226
233
  inFlight.delete(sessionId);
227
234
  }
@@ -1,7 +1,12 @@
1
1
  import { experimentOn } from "../experiments";
2
2
  import { countEvent } from "../experiments/meter";
3
- import { listCards, markShown } from "./store";
4
- import type { Card, RecipePayload } from "./types";
3
+ import { listCards, listPending, markShown } from "./store";
4
+ import {
5
+ cardTitle,
6
+ type Card,
7
+ type PendingCardView,
8
+ type RecipePayload,
9
+ } from "./types";
5
10
 
6
11
  const ID = "learning-cards" as const;
7
12
 
@@ -27,3 +32,21 @@ export function pendingRecipeDraft(sessionId: string) {
27
32
  const p = card?.payload as RecipePayload | undefined;
28
33
  return p ? { brief: p.brief, summary: p.summary } : undefined;
29
34
  }
35
+
36
+ // Every pending card, or one workspace's. Reading it is not seeing the card:
37
+ // nothing is stamped as shown. Nothing while the experiment is off.
38
+ export function pendingCards(workspaceId?: string): PendingCardView[] {
39
+ if (!experimentOn(ID)) return [];
40
+ return listPending()
41
+ .filter((c) => workspaceId === undefined || c.ws === workspaceId)
42
+ .map((c) => ({
43
+ id: c.id,
44
+ sessionId: c.session_id,
45
+ sessionName: c.session_name,
46
+ workspaceId: c.ws,
47
+ kind: c.kind,
48
+ title: cardTitle(c),
49
+ destination: c.destination,
50
+ createdAt: c.created_at,
51
+ }));
52
+ }
@@ -21,6 +21,28 @@ export function getCard(id: number): Card | null {
21
21
  return row ? toCard(row) : null;
22
22
  }
23
23
 
24
+ export type PendingRow = Card & { session_name: string; ws: string | null };
25
+
26
+ // Every pending card of a session that still exists, newest first.
27
+ export function listPending(): PendingRow[] {
28
+ const rows = db
29
+ .prepare(
30
+ `SELECT lc.*, s.name AS session_name,
31
+ COALESCE(lc.workspace_id, s.workspace_id, p.workspace_id) AS ws
32
+ FROM learning_cards lc
33
+ JOIN sessions s ON s.id = lc.session_id
34
+ LEFT JOIN projects p ON p.id = s.project_id
35
+ WHERE lc.status = 'pending'
36
+ ORDER BY lc.created_at DESC, lc.id DESC`
37
+ )
38
+ .all() as (Row & { session_name: string; ws: string | null })[];
39
+ return rows.map((r) => ({
40
+ ...toCard(r),
41
+ session_name: r.session_name,
42
+ ws: r.ws,
43
+ }));
44
+ }
45
+
24
46
  export function listCards(sessionId: string, status?: CardStatus): Card[] {
25
47
  const rows = db
26
48
  .prepare(
@@ -77,3 +77,26 @@ export interface Pair {
77
77
  before: string;
78
78
  after: string;
79
79
  }
80
+
81
+ // A pending card as a list shows it, without the whole payload.
82
+ export interface PendingCardView {
83
+ id: number;
84
+ sessionId: string;
85
+ sessionName: string;
86
+ workspaceId: string | null;
87
+ kind: CardKind;
88
+ // One line: the lesson, or the recipe's summary.
89
+ title: string;
90
+ destination: Destination | null;
91
+ createdAt: number;
92
+ }
93
+
94
+ const TITLE_MAX = 140;
95
+
96
+ export function cardTitle(c: Pick<Card, "kind" | "payload">): string {
97
+ const p = c.payload as Partial<RecipePayload & LessonPayload>;
98
+ const raw =
99
+ c.kind === "lesson" ? p.lesson : p.summary || p.brief?.split("\n")[0];
100
+ const line = (raw ?? "").replace(/\s+/g, " ").trim();
101
+ return line.length > TITLE_MAX ? `${line.slice(0, TITLE_MAX)}…` : line;
102
+ }
@@ -1,6 +1,8 @@
1
1
  // What a client sees of the orchestrator's asks. Client-safe (no database),
2
2
  // so any client (the web, an API client) can import it.
3
3
 
4
+ import type { PendingCardView } from "../learning-cards/types";
5
+
4
6
  export const ASK_KINDS = [
5
7
  "decision",
6
8
  "public",
@@ -49,4 +51,6 @@ export interface OrchestratorOverview {
49
51
  questions: QuestionView[];
50
52
  // Keys (ask:<id>, question:<id>) he hid from the Needs-you strip.
51
53
  dismissed: string[];
54
+ // Learning cards waiting on him; none while the experiment is off.
55
+ cards: PendingCardView[];
52
56
  }
@@ -2,6 +2,7 @@
2
2
  // Client-safe and pure, so the strip and its tests share one rule.
3
3
 
4
4
  import { NEED_LABEL, type SessionNeed } from "../sidebar/shelves";
5
+ import type { PendingCardView } from "../learning-cards/types";
5
6
  import type { AskView, QuestionView } from "./ask-view";
6
7
 
7
8
  export type NeedsYouItem =
@@ -21,6 +22,15 @@ export type NeedsYouItem =
21
22
  at: number;
22
23
  question: QuestionView;
23
24
  }
25
+ // A learning card a finished session left, waiting to be kept or dropped.
26
+ | {
27
+ key: string;
28
+ kind: "card";
29
+ title: string;
30
+ sub: string;
31
+ at: number;
32
+ card: PendingCardView;
33
+ }
24
34
  // The orchestrator's own chat waiting on an approval or an answer.
25
35
  | { key: string; kind: "chat"; title: string; sub: string; at: number }
26
36
  | {
@@ -56,6 +66,7 @@ export function needsYouItems(input: {
56
66
  questions: QuestionView[];
57
67
  dismissed: readonly string[];
58
68
  tasks?: TaskNeed[];
69
+ cards?: PendingCardView[];
59
70
  chatWaiting?: boolean;
60
71
  chatKey?: string;
61
72
  }): NeedsYouItem[] {
@@ -82,6 +93,15 @@ export function needsYouItems(input: {
82
93
  })
83
94
  ),
84
95
  ];
96
+ for (const card of input.cards ?? [])
97
+ items.push({
98
+ key: `card:${card.id}`,
99
+ kind: "card",
100
+ title: card.title,
101
+ sub: card.sessionName,
102
+ at: card.createdAt,
103
+ card,
104
+ });
85
105
  if (input.chatWaiting)
86
106
  items.push({
87
107
  key: input.chatKey ?? "approval:chat",
@@ -11,6 +11,7 @@ import { askBinding, openAsks, PRESENCE_KINDS } from "./asks";
11
11
  import type { OrchestratorOverview } from "./ask-view";
12
12
  import { getOrchestrator } from "./home";
13
13
  import { resolveStaleAsks } from "./ask-settle";
14
+ import { pendingCards } from "../learning-cards";
14
15
  import { dismissedKeys, openQuestions } from "./questions";
15
16
 
16
17
  export type { AskView, OrchestratorOverview } from "./ask-view";
@@ -33,6 +34,7 @@ function inReview(workspaceId: string): number {
33
34
  }
34
35
 
35
36
  export function orchestratorOverview(): OrchestratorOverview[] {
37
+ const cards = pendingCards();
36
38
  return listWorkspaces().map((w) => {
37
39
  // Asks about work that finished elsewhere never reach Saad's list.
38
40
  resolveStaleAsks(w.id);
@@ -60,6 +62,7 @@ export function orchestratorOverview(): OrchestratorOverview[] {
60
62
  }),
61
63
  questions: openQuestions(w.id),
62
64
  dismissed: dismissedKeys(w.id),
65
+ cards: cards.filter((c) => c.workspaceId === w.id),
63
66
  };
64
67
  });
65
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.613",
3
+ "version": "0.3.614",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"