@saadnvd1/agent-os 0.3.761 → 0.3.762

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.
@@ -13,8 +13,9 @@ import {
13
13
  type McpSdkServerConfigWithInstance,
14
14
  } from "@anthropic-ai/claude-agent-sdk";
15
15
  import {
16
- createArtifact,
16
+ saveArtifact,
17
17
  MAX_HTML_BYTES,
18
+ MAX_NOTE_CHARS,
18
19
  MAX_TITLE_CHARS,
19
20
  type Artifact,
20
21
  } from "./store";
@@ -84,20 +85,31 @@ export interface VisualsContext {
84
85
  // Saves the page and shows it in the conversation.
85
86
  export async function renderPage(
86
87
  ctx: VisualsContext,
87
- args: { html?: string; path?: string; title: string; height?: number }
88
+ args: {
89
+ html?: string;
90
+ path?: string;
91
+ title: string;
92
+ height?: number;
93
+ update?: string;
94
+ note?: string;
95
+ }
88
96
  ): Promise<CallToolResult> {
89
97
  try {
90
- const artifact = createArtifact(
98
+ const { artifact, created } = saveArtifact(
91
99
  ctx.sessionId,
92
100
  args.title,
93
- readSource(args, ctx.cwd)
101
+ readSource(args, ctx.cwd),
102
+ { update: args.update, note: args.note, sameTitle: true }
94
103
  );
95
- ctx.onRender(artifact, args.height);
104
+ if (created) ctx.onRender(artifact, args.height);
105
+ const name = `"${artifact.title}"${artifact.version > 1 ? ` v${artifact.version}` : ""}`;
96
106
  return {
97
107
  content: [
98
108
  {
99
109
  type: "text",
100
- text: `Shown in the chat as "${artifact.title}" (artifact ${artifact.id}).`,
110
+ text: created
111
+ ? `Shown in the chat as ${name} (artifact ${artifact.id}). To change it, call html_render again with update: "${artifact.id}"; every version is kept.`
112
+ : `Unchanged: the page is the same as ${name} (artifact ${artifact.id}), already in the chat.`,
101
113
  },
102
114
  ],
103
115
  };
@@ -171,6 +183,17 @@ export function visualsTools(
171
183
  .describe(
172
184
  "Cap on the frame's height in CSS pixels; longer pages scroll inside it. Defaults to fitting the page."
173
185
  ),
186
+ update: z
187
+ .string()
188
+ .optional()
189
+ .describe(
190
+ "The artifact id of a page you already showed in this chat, to show a new version of it. A page with the same title is updated too. Every version is kept."
191
+ ),
192
+ note: z
193
+ .string()
194
+ .max(MAX_NOTE_CHARS)
195
+ .optional()
196
+ .describe("For a new version: one line on what changed."),
174
197
  },
175
198
  (args) => renderPage(ctx, args)
176
199
  ),
@@ -0,0 +1,73 @@
1
+ // A page's versions for the version switcher: each with its note, size and
2
+ // the message that asked for it.
3
+
4
+ import { db } from "../db";
5
+ import { getArtifact, listVersions, type Artifact } from "./store";
6
+ import {
7
+ pageActions,
8
+ type ArtifactVersion,
9
+ type PageVersions,
10
+ } from "./actions";
11
+
12
+ const PROMPT_CHARS = 80;
13
+
14
+ const sqliteMs = (t: string) =>
15
+ Date.parse(t.includes("T") ? t : `${t.replace(" ", "T")}Z`) || 0;
16
+
17
+ // The user message before the chat item that showed this version.
18
+ function turnOf(a: Artifact): ArtifactVersion["turn"] {
19
+ const row = db
20
+ .prepare(
21
+ `SELECT u.item_id AS itemId,
22
+ json_extract(u.data, '$.text') AS text,
23
+ (SELECT COUNT(*) FROM chat_items c
24
+ WHERE c.session_id = u.session_id AND c.kind = 'user'
25
+ AND c.seq <= u.seq) AS idx
26
+ FROM chat_items u
27
+ WHERE u.session_id = ? AND u.kind = 'user'
28
+ AND u.seq < (SELECT seq FROM chat_items
29
+ WHERE session_id = ? AND item_id = ?)
30
+ ORDER BY u.seq DESC LIMIT 1`
31
+ )
32
+ .get(a.session_id, a.session_id, `artifact-${a.id}`) as
33
+ { itemId: string; text: string | null; idx: number } | undefined;
34
+ if (!row) return undefined;
35
+ const prompt = (row.text ?? "").replace(/\s+/g, " ").trim();
36
+ return {
37
+ itemId: row.itemId,
38
+ index: row.idx,
39
+ prompt:
40
+ prompt.length > PROMPT_CHARS
41
+ ? `${prompt.slice(0, PROMPT_CHARS - 1)}…`
42
+ : prompt,
43
+ };
44
+ }
45
+
46
+ export function toVersion(a: Artifact): ArtifactVersion {
47
+ const turn = turnOf(a);
48
+ return {
49
+ id: a.id,
50
+ version: a.version,
51
+ title: a.title,
52
+ ...(a.note && { note: a.note }),
53
+ createdAt: sqliteMs(a.created_at),
54
+ ...(a.bytes != null && { bytes: a.bytes }),
55
+ ...(turn && { turn }),
56
+ };
57
+ }
58
+
59
+ // Every version of the page that artifactId is a version of.
60
+ export function pageVersions(
61
+ artifactId: string,
62
+ ctx: { pdf: boolean }
63
+ ): PageVersions | undefined {
64
+ const a = getArtifact(artifactId);
65
+ if (!a) return undefined;
66
+ const versions = listVersions(a.page_id).map(toVersion);
67
+ return {
68
+ pageId: a.page_id,
69
+ latestId: versions[versions.length - 1]?.id ?? a.id,
70
+ versions,
71
+ actions: pageActions(ctx),
72
+ };
73
+ }
@@ -254,6 +254,10 @@ export type ChatItem =
254
254
  kind: "artifact";
255
255
  artifactId: string;
256
256
  title: string;
257
+ // The page it's a version of, and which; absent before versions (v1 of
258
+ // its own page).
259
+ pageId?: string;
260
+ version?: number;
257
261
  // A cap on the frame's height; it fits the page otherwise.
258
262
  height?: number;
259
263
  })
@@ -215,6 +215,8 @@ export class ChatHost {
215
215
  kind: "artifact",
216
216
  artifactId: artifact.id,
217
217
  title: artifact.title,
218
+ pageId: artifact.page_id,
219
+ version: artifact.version,
218
220
  height,
219
221
  createdAt: Date.now(),
220
222
  }),
@@ -0,0 +1,28 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261029T1200_artifact_versions",
5
+ up: (db) => {
6
+ // Each artifacts row is one version of a page; rows sharing page_id are
7
+ // that page's history. Versions are only ever added, never rewritten.
8
+ const cols = new Set(
9
+ (
10
+ db.prepare(`PRAGMA table_info(artifacts)`).all() as { name: string }[]
11
+ ).map((c) => c.name)
12
+ );
13
+ const add = (name: string, type: string) => {
14
+ if (!cols.has(name))
15
+ db.exec(`ALTER TABLE artifacts ADD COLUMN ${name} ${type}`);
16
+ };
17
+ add("page_id", "TEXT");
18
+ add("version", "INTEGER NOT NULL DEFAULT 1");
19
+ // The agent's one line on what changed.
20
+ add("note", "TEXT");
21
+ add("bytes", "INTEGER");
22
+ add("sha256", "TEXT");
23
+ db.exec(`UPDATE artifacts SET page_id = id WHERE page_id IS NULL`);
24
+ db.exec(
25
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_artifacts_page_version ON artifacts(page_id, version)`
26
+ );
27
+ },
28
+ } satisfies Migration;
@@ -124,6 +124,7 @@ const READS = [
124
124
  "usage",
125
125
  "vendor/mermaid",
126
126
  "artifacts/[id]",
127
+ "artifacts/[id]/versions",
127
128
  "git/status",
128
129
  "git/file-content",
129
130
  "git/history",
@@ -12,7 +12,7 @@ import { db, queries, type Session } from "../db";
12
12
  import { flushItems } from "../chat/store";
13
13
  import { git } from "../git-exec";
14
14
  import { PLAN_FILE } from "../models/plan-step";
15
- import { listArtifacts } from "../artifacts/store";
15
+ import { listArtifacts, type Artifact } from "../artifacts/store";
16
16
  import { allowedRoots, within } from "./access";
17
17
  import { SCREENSHOT_DIR } from "../tasks/ui-brief";
18
18
  import {
@@ -51,6 +51,7 @@ const ITEMS_SQL = `
51
51
  json_extract(data, '$.input.filePath') AS file_path_camel,
52
52
  json_extract(data, '$.input.changes') AS changes,
53
53
  json_extract(data, '$.artifactId') AS artifact_id,
54
+ json_extract(data, '$.pageId') AS page_id,
54
55
  json_extract(data, '$.title') AS title,
55
56
  json_extract(data, '$.taskId') AS task_id,
56
57
  json_extract(data, '$.outputFile') AS output_file,
@@ -202,16 +203,37 @@ async function build(session: Session): Promise<Entry[]> {
202
203
  const keptBase = sessionKeptDir(session.id);
203
204
  const tracked = new Set(manifest?.tracked);
204
205
  const kept = new Set(manifest?.kept);
205
- const pages = new Map(listArtifacts(session.id).map((a) => [a.id, a.path]));
206
+ const saved = listArtifacts(session.id);
207
+ const byId = new Map(saved.map((a) => [a.id, a]));
208
+ // Each page's newest version and how many it has.
209
+ const newest = new Map<string, Artifact>();
210
+ const count = new Map<string, number>();
211
+ for (const a of saved) {
212
+ count.set(a.page_id, (count.get(a.page_id) ?? 0) + 1);
213
+ if ((newest.get(a.page_id)?.version ?? 0) < a.version)
214
+ newest.set(a.page_id, a);
215
+ }
206
216
  const entries = await Promise.all(
207
217
  candidates.map(async (c): Promise<Entry> => {
208
218
  if (c.source.type === "page") {
209
- const page = pages.get(c.source.artifactId);
210
- const size = page ? await sizeOf(page) : undefined;
211
- return toArtifact(
212
- c,
219
+ const seen = byId.get(c.source.artifactId);
220
+ const page = seen && (newest.get(seen.page_id) ?? seen);
221
+ const size = page ? await sizeOf(page.path) : undefined;
222
+ const c2 = page
223
+ ? { ...c, source: { type: "page" as const, artifactId: page.id } }
224
+ : c;
225
+ const entry = toArtifact(
226
+ c2,
213
227
  size === undefined ? { state: "gone" } : { state: "live", size }
214
228
  );
229
+ if (page)
230
+ Object.assign(entry.artifact, {
231
+ name: page.title,
232
+ artifactId: page.id,
233
+ version: page.version,
234
+ versionCount: count.get(page.page_id) ?? 1,
235
+ });
236
+ return entry;
215
237
  }
216
238
  const file = filePathOf(c);
217
239
  if (!file) return toArtifact(c, null);
@@ -29,6 +29,7 @@ export interface ItemRow {
29
29
  file_path_camel?: string | null;
30
30
  changes?: string | null;
31
31
  artifact_id?: string | null;
32
+ page_id?: string | null;
32
33
  title?: string | null;
33
34
  task_id?: string | null;
34
35
  output_file?: string | null;
@@ -177,7 +178,8 @@ export function fromItems(
177
178
  });
178
179
  }
179
180
  } else if (row.kind === "artifact" && row.artifact_id) {
180
- const key = `page:${row.artifact_id}`;
181
+ // One row per page: a later version replaces an earlier one.
182
+ const key = `page:${row.page_id || row.artifact_id}`;
181
183
  out.set(key, {
182
184
  key,
183
185
  kind: "page",
@@ -1,5 +1,7 @@
1
1
  // What a session produced, as the Artifacts view lists it. Client-safe.
2
2
 
3
+ import type { ArtifactAction } from "../artifacts/actions";
4
+
3
5
  export type ArtifactKind =
4
6
  "plan" | "file" | "page" | "image" | "log" | "pr" | "run" | "session-log";
5
7
 
@@ -30,6 +32,13 @@ export interface SessionArtifact {
30
32
  folder?: string;
31
33
  // A link out (the PR on GitHub).
32
34
  url?: string;
35
+ // A page: its latest version's artifact id, which version that is, and
36
+ // how many there are.
37
+ artifactId?: string;
38
+ version?: number;
39
+ versionCount?: number;
40
+ // What the viewer can do with it; set by the server.
41
+ actions?: ArtifactAction[];
33
42
  }
34
43
 
35
44
  export const openable = (a: Pick<SessionArtifact, "state" | "preview">) =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.761",
3
+ "version": "0.3.762",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
package/stores/shareUi.ts CHANGED
@@ -3,12 +3,16 @@ import type { ShareTarget } from "@/data/shares";
3
3
 
4
4
  // What the share dialog is open on: a chat or an artifact, started from a
5
5
  // menu, the pane or the artifact card.
6
+ // A page's version, when it has more than one, is named in the title; the
7
+ // share is of that exact version.
8
+ export type ShareUiTarget = ShareTarget & { title: string; version?: number };
9
+
6
10
  export const shareUi = proxy<{
7
- target: (ShareTarget & { title: string }) | null;
11
+ target: ShareUiTarget | null;
8
12
  }>({ target: null });
9
13
 
10
14
  export const shareUiActions = {
11
- open: (target: ShareTarget & { title: string }) => {
15
+ open: (target: ShareUiTarget) => {
12
16
  shareUi.target = target;
13
17
  },
14
18
  close: () => {