@saadnvd1/agent-os 0.3.782 → 0.3.786
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/app/api/hosts/[id]/agents/route.ts +6 -4
- package/app/api/machine/route.ts +5 -3
- package/app/api/whats-new/route.ts +47 -0
- package/components/Chat/ChatPanel.tsx +2 -0
- package/components/Chat/ModelPicker.tsx +82 -42
- package/components/Chat/composer/ComposerBody.tsx +3 -0
- package/components/Chat/composer/Toolbar.tsx +3 -0
- package/components/Draft/AgentModelChip.test.ts +1 -0
- package/components/Draft/AgentModelChip.tsx +36 -93
- package/components/Draft/Chip.tsx +27 -0
- package/components/Draft/DraftChips.tsx +5 -1
- package/components/Draft/DraftPanel.tsx +0 -21
- package/components/Draft/catalogBlocker.ts +19 -0
- package/components/Draft/picker/AgentModelPicker.tsx +152 -0
- package/components/Draft/picker/AgentRail.tsx +145 -0
- package/components/Draft/picker/ModelList.tsx +136 -0
- package/components/Draft/picker/ModelNote.tsx +15 -0
- package/components/Draft/picker/PickerSheet.tsx +61 -0
- package/components/Draft/picker/SameAsLast.tsx +52 -0
- package/components/Draft/picker/SearchField.tsx +48 -0
- package/components/Draft/picker/picker.test.ts +152 -0
- package/components/Draft/picker/sections.test.ts +115 -0
- package/components/Draft/picker/sections.ts +121 -0
- package/components/Draft/picker/useListKeys.ts +54 -0
- package/components/Draft/picker/useModelSearch.ts +66 -0
- package/components/SidebarFooter.tsx +3 -1
- package/components/Updates/UpdateBanner.test.ts +1 -1
- package/components/Updates/UpdateBanner.tsx +27 -13
- package/components/WhatsNew/WhatsNewButton.tsx +36 -0
- package/components/WhatsNew/WhatsNewDialog.tsx +169 -0
- package/components/WhatsNew/WhatsNewHost.tsx +17 -0
- package/components/WhatsNew/WhatsNewRows.tsx +62 -0
- package/components/WhatsNew/index.ts +2 -0
- package/components/ui/dialog.tsx +12 -1
- package/content/whats-new.json +1773 -0
- package/data/chat/useChat.ts +3 -0
- package/data/machines/index.ts +13 -4
- package/data/machines/keys.ts +2 -1
- package/data/whats-new/index.ts +44 -0
- package/hooks/useWhatsNewSeen.ts +50 -0
- package/lib/chat/drivers/copilot.ts +9 -6
- package/lib/chat/drivers/opencode.ts +1 -1
- package/lib/chat/drivers/pi.ts +1 -1
- package/lib/chat/events.ts +6 -0
- package/lib/chat/settings.ts +2 -0
- package/lib/machines/catalog.ts +5 -1
- package/lib/machines/recent.ts +31 -0
- package/lib/machines/remote.ts +28 -9
- package/lib/machines/report.ts +7 -2
- package/lib/machines/types.ts +12 -0
- package/lib/orchestrator/review-prompt.ts +2 -0
- package/lib/security/demo.ts +2 -0
- package/lib/tasks/brief.ts +1 -0
- package/lib/whats-new/ahead.ts +28 -0
- package/lib/whats-new/areas.ts +76 -0
- package/lib/whats-new/claude.ts +35 -0
- package/lib/whats-new/collect.ts +68 -0
- package/lib/whats-new/generate.ts +107 -0
- package/lib/whats-new/lint.ts +69 -0
- package/lib/whats-new/load.ts +59 -0
- package/lib/whats-new/rows.ts +79 -0
- package/lib/whats-new/schema.ts +41 -0
- package/lib/whats-new/summarize.ts +91 -0
- package/lib/whats-new/unread.ts +14 -0
- package/package.json +2 -1
- package/scripts/autodeploy +28 -0
- package/scripts/autodeploy.test.ts +53 -0
- package/scripts/whats-new.ts +118 -0
- package/stores/whatsNew.ts +16 -0
- package/components/Draft/draftModels.test.ts +0 -31
- package/components/Draft/draftModels.ts +0 -17
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { areaFor, collectCommits, type Git } from "./collect";
|
|
4
|
+
import { lintEntry } from "./lint";
|
|
5
|
+
import { fileSchema, type Entry } from "./schema";
|
|
6
|
+
import { authorLine, fillGaps, type Ask, type Line } from "./summarize";
|
|
7
|
+
import { WHATS_NEW_FILE } from "./load";
|
|
8
|
+
|
|
9
|
+
export interface PrInfo {
|
|
10
|
+
body: string;
|
|
11
|
+
url: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface GenerateDeps {
|
|
15
|
+
git: Git;
|
|
16
|
+
/** Bodies and links of merged PRs; throws when it can't ask, so nothing is guessed. */
|
|
17
|
+
prs: (numbers: number[]) => Map<number, PrInfo>;
|
|
18
|
+
ask: Ask;
|
|
19
|
+
blocklist: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface GenerateResult {
|
|
23
|
+
added: Entry[];
|
|
24
|
+
/** Merges that had a line but couldn't ship, with the reason. */
|
|
25
|
+
dropped: { pr: number; reason: string }[];
|
|
26
|
+
skipped: number;
|
|
27
|
+
head: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// `raw` is what is written back for existing entries, so a field the schema
|
|
31
|
+
// doesn't know about survives a run too.
|
|
32
|
+
export function readFile(root: string): { entries: Entry[]; raw: unknown[] } {
|
|
33
|
+
try {
|
|
34
|
+
const json = JSON.parse(
|
|
35
|
+
fs.readFileSync(path.join(root, WHATS_NEW_FILE), "utf-8")
|
|
36
|
+
);
|
|
37
|
+
return { entries: fileSchema.parse(json).entries, raw: json.entries };
|
|
38
|
+
} catch (e) {
|
|
39
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT")
|
|
40
|
+
return { entries: [], raw: [] };
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* New entries for merges since `through` (or the last `sinceDays`). Append
|
|
47
|
+
* only: an id already in the file is never read, changed or reordered, so a
|
|
48
|
+
* hand edit or "hidden" survives. Writes the file only when there is
|
|
49
|
+
* something to add.
|
|
50
|
+
*/
|
|
51
|
+
export async function generate(
|
|
52
|
+
root: string,
|
|
53
|
+
opts: { through?: string | null; sinceDays: number; write: boolean },
|
|
54
|
+
deps: GenerateDeps
|
|
55
|
+
): Promise<GenerateResult> {
|
|
56
|
+
const { entries, raw } = readFile(root);
|
|
57
|
+
const head = deps.git(["rev-parse", "HEAD"]).trim();
|
|
58
|
+
const commits = collectCommits(
|
|
59
|
+
deps.git,
|
|
60
|
+
opts,
|
|
61
|
+
new Set(entries.map((e) => e.id)),
|
|
62
|
+
WHATS_NEW_FILE
|
|
63
|
+
);
|
|
64
|
+
const result: GenerateResult = { added: [], dropped: [], skipped: 0, head };
|
|
65
|
+
if (!commits.length) return result;
|
|
66
|
+
|
|
67
|
+
const prs = deps.prs(commits.map((c) => c.pr));
|
|
68
|
+
const lines = new Map<number, Line>();
|
|
69
|
+
const gaps = [];
|
|
70
|
+
for (const c of commits) {
|
|
71
|
+
const line = authorLine(prs.get(c.pr)?.body);
|
|
72
|
+
if (line) lines.set(c.pr, line);
|
|
73
|
+
else gaps.push({ n: c.pr, type: c.type, title: c.title });
|
|
74
|
+
}
|
|
75
|
+
if (gaps.length)
|
|
76
|
+
for (const [n, line] of await fillGaps(gaps, deps.ask)) lines.set(n, line);
|
|
77
|
+
|
|
78
|
+
for (const c of commits) {
|
|
79
|
+
const line = lines.get(c.pr);
|
|
80
|
+
if (!line || "skip" in line) {
|
|
81
|
+
result.skipped++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const entry: Entry = {
|
|
85
|
+
id: c.pr,
|
|
86
|
+
date: c.date,
|
|
87
|
+
kind: line.kind,
|
|
88
|
+
area: areaFor(c),
|
|
89
|
+
text: line.text,
|
|
90
|
+
...(prs.get(c.pr)?.url && { url: prs.get(c.pr)!.url }),
|
|
91
|
+
};
|
|
92
|
+
const bad = lintEntry(entry, deps.blocklist);
|
|
93
|
+
if (bad) result.dropped.push({ pr: c.pr, reason: bad });
|
|
94
|
+
else result.added.push(entry);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (opts.write && result.added.length) {
|
|
98
|
+
const file = path.join(root, WHATS_NEW_FILE);
|
|
99
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
100
|
+
fs.writeFileSync(
|
|
101
|
+
file,
|
|
102
|
+
JSON.stringify({ v: 1, entries: [...raw, ...result.added] }, null, 2) +
|
|
103
|
+
"\n"
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { agentosHome } from "@/lib/agentos-home";
|
|
4
|
+
import type { Entry } from "./schema";
|
|
5
|
+
|
|
6
|
+
// The repo is public, so this checks only generic shapes. Names that must
|
|
7
|
+
// never appear (other products) live in a private list outside the repo and
|
|
8
|
+
// are passed in.
|
|
9
|
+
const GENERIC: [string, RegExp][] = [
|
|
10
|
+
["a user path", /\/Users\/|~\/|\/home\/\w|(?:^|[\s(])\.[a-z][\w-]*\//i],
|
|
11
|
+
["a file path", /\b[\w.-]+\/[\w.-]+\/[\w./-]+|\/[\w.-]+\/[\w.-]+/],
|
|
12
|
+
[
|
|
13
|
+
"a file name",
|
|
14
|
+
/\b[\w-]+\.(?:tsx?|jsx?|mjs|json|md|sh|py|sql|ya?ml|toml)\b/i,
|
|
15
|
+
],
|
|
16
|
+
["an IP address", /\b\d{1,3}(?:\.\d{1,3}){3}\b/],
|
|
17
|
+
[
|
|
18
|
+
"a hostname",
|
|
19
|
+
/\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:ts\.net|local|internal|lan|home|com|io|net|app|dev|org|ai|co)\b/i,
|
|
20
|
+
],
|
|
21
|
+
["a token", /\b(?:gh[pousr]_|github_pat_|npm_|sk-|xox[bp]-)[\w-]{8,}/],
|
|
22
|
+
["a long secret-looking string", /\b[A-Za-z0-9_-]{32,}\b/],
|
|
23
|
+
["tailnet", /tailnet|tailscale/i],
|
|
24
|
+
["a CVE", /\bCVE-\d{4}-\d+/i],
|
|
25
|
+
["a vulnerability", /vulnerab|exploit|\bRCE\b/i],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
|
+
|
|
30
|
+
/** Why this text can't ship, or null when it can. */
|
|
31
|
+
export function lintText(
|
|
32
|
+
text: string,
|
|
33
|
+
blocklist: string[] = []
|
|
34
|
+
): string | null {
|
|
35
|
+
for (const [what, re] of GENERIC)
|
|
36
|
+
if (re.test(text)) return `contains ${what}`;
|
|
37
|
+
for (const name of blocklist) {
|
|
38
|
+
if (
|
|
39
|
+
new RegExp(
|
|
40
|
+
`(?<![\\p{L}\\p{N}])${escape(name)}(?![\\p{L}\\p{N}])`,
|
|
41
|
+
"iu"
|
|
42
|
+
).test(text)
|
|
43
|
+
)
|
|
44
|
+
return "contains a blocked name";
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const lintEntry = (e: Entry, blocklist: string[] = []) =>
|
|
50
|
+
lintText(e.text, blocklist) ?? (e.note ? lintText(e.note, blocklist) : null);
|
|
51
|
+
|
|
52
|
+
const BLOCKLIST_HEADER =
|
|
53
|
+
"# One name per line (any name that must never appear in the\n# changelog). Matched whole-word, any case. Lives outside the repo on purpose.\n";
|
|
54
|
+
|
|
55
|
+
export const blocklistPath = () => agentosHome("whats-new-blocklist.txt");
|
|
56
|
+
|
|
57
|
+
/** The private list; created empty (with a comment) when it is missing. */
|
|
58
|
+
export function readBlocklist(file: string = blocklistPath()): string[] {
|
|
59
|
+
if (!fs.existsSync(file)) {
|
|
60
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
61
|
+
fs.writeFileSync(file, BLOCKLIST_HEADER);
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return fs
|
|
65
|
+
.readFileSync(file, "utf-8")
|
|
66
|
+
.split("\n")
|
|
67
|
+
.map((l) => l.trim())
|
|
68
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
69
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { fileSchema, type Entry, type Head, type Page } from "./schema";
|
|
4
|
+
|
|
5
|
+
export const WHATS_NEW_FILE = "content/whats-new.json";
|
|
6
|
+
|
|
7
|
+
const fileAt = (root: string) => path.join(root, WHATS_NEW_FILE);
|
|
8
|
+
|
|
9
|
+
let cached: { file: string; mtime: number; entries: Entry[] } | null = null;
|
|
10
|
+
|
|
11
|
+
// Newest first: by day, then by PR number within it. The file is append-only
|
|
12
|
+
// and in the order the generator wrote it, so the order is made here.
|
|
13
|
+
export const byNewest = (a: Entry, b: Entry) =>
|
|
14
|
+
a.date === b.date ? b.id - a.id : a.date < b.date ? 1 : -1;
|
|
15
|
+
|
|
16
|
+
/** Every shown entry, newest first. A missing or unreadable file is empty. */
|
|
17
|
+
export function loadEntries(root: string = process.cwd()): Entry[] {
|
|
18
|
+
const file = fileAt(root);
|
|
19
|
+
try {
|
|
20
|
+
const mtime = fs.statSync(file).mtimeMs;
|
|
21
|
+
if (cached?.file === file && cached.mtime === mtime) return cached.entries;
|
|
22
|
+
const parsed = fileSchema.safeParse(
|
|
23
|
+
JSON.parse(fs.readFileSync(file, "utf-8"))
|
|
24
|
+
);
|
|
25
|
+
const entries = parsed.success
|
|
26
|
+
? parsed.data.entries.filter((e) => !e.hidden).sort(byNewest)
|
|
27
|
+
: [];
|
|
28
|
+
cached = { file, mtime, entries };
|
|
29
|
+
return entries;
|
|
30
|
+
} catch {
|
|
31
|
+
return [];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const RECENT = 10;
|
|
36
|
+
|
|
37
|
+
export function headOf(entries: Entry[]): Head {
|
|
38
|
+
return {
|
|
39
|
+
latestId: entries.reduce((m, e) => Math.max(m, e.id), 0),
|
|
40
|
+
latestDate: entries[0]?.date ?? null,
|
|
41
|
+
count: entries.length,
|
|
42
|
+
recent: entries.slice(0, RECENT).map((e) => e.id),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `limit` entries older than the `before` day (all days when it is unset). */
|
|
47
|
+
export function pageOf(
|
|
48
|
+
entries: Entry[],
|
|
49
|
+
{ before, limit }: { before?: string | null; limit: number }
|
|
50
|
+
): Page {
|
|
51
|
+
const older = before ? entries.filter((e) => e.date < before) : entries;
|
|
52
|
+
const slice = older.slice(0, limit);
|
|
53
|
+
// Never split a day across pages: the next page starts at a day boundary.
|
|
54
|
+
const last = slice[slice.length - 1];
|
|
55
|
+
const rest = older.slice(limit);
|
|
56
|
+
while (last && rest.length && rest[0].date === last.date)
|
|
57
|
+
slice.push(rest.shift()!);
|
|
58
|
+
return { entries: slice, hasMore: rest.length > 0 };
|
|
59
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Entry } from "./schema";
|
|
2
|
+
|
|
3
|
+
export type Row =
|
|
4
|
+
| { type: "heading"; key: string; label: string }
|
|
5
|
+
| { type: "day"; key: string; label: string }
|
|
6
|
+
| { type: "entry"; key: string; entry: Entry }
|
|
7
|
+
| { type: "divider"; key: string; label: string };
|
|
8
|
+
|
|
9
|
+
const MONTHS = [
|
|
10
|
+
"Jan",
|
|
11
|
+
"Feb",
|
|
12
|
+
"Mar",
|
|
13
|
+
"Apr",
|
|
14
|
+
"May",
|
|
15
|
+
"Jun",
|
|
16
|
+
"Jul",
|
|
17
|
+
"Aug",
|
|
18
|
+
"Sep",
|
|
19
|
+
"Oct",
|
|
20
|
+
"Nov",
|
|
21
|
+
"Dec",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const day = (iso: string) => {
|
|
25
|
+
const [y, m, d] = iso.split("-").map(Number);
|
|
26
|
+
return new Date(y, m - 1, d);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const isoOf = (d: Date) =>
|
|
30
|
+
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
31
|
+
|
|
32
|
+
/** "Today", "Yesterday" or "Oct 27" (with the year when it isn't this one). */
|
|
33
|
+
export function dayLabel(iso: string, now: Date = new Date()): string {
|
|
34
|
+
if (iso === isoOf(now)) return "Today";
|
|
35
|
+
const y = new Date(now);
|
|
36
|
+
y.setDate(y.getDate() - 1);
|
|
37
|
+
if (iso === isoOf(y)) return "Yesterday";
|
|
38
|
+
const d = day(iso);
|
|
39
|
+
const base = `${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
|
40
|
+
return d.getFullYear() === now.getFullYear()
|
|
41
|
+
? base
|
|
42
|
+
: `${base}, ${d.getFullYear()}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Day headings with entries under them, newest first. When some entries are
|
|
47
|
+
* newer than `seen`, a divider marks where the last visit ended.
|
|
48
|
+
*/
|
|
49
|
+
export function buildRows(
|
|
50
|
+
entries: Entry[],
|
|
51
|
+
seen: number | null,
|
|
52
|
+
now: Date = new Date()
|
|
53
|
+
): Row[] {
|
|
54
|
+
const rows: Row[] = [];
|
|
55
|
+
const anyUnread = seen !== null && entries.some((e) => e.id > seen);
|
|
56
|
+
let current: string | null = null;
|
|
57
|
+
let divided = false;
|
|
58
|
+
for (const e of entries) {
|
|
59
|
+
if (anyUnread && !divided && e.id <= seen!) {
|
|
60
|
+
divided = true;
|
|
61
|
+
current = null;
|
|
62
|
+
rows.push({
|
|
63
|
+
type: "divider",
|
|
64
|
+
key: "divider",
|
|
65
|
+
label: `You last looked ${dayLabel(e.date, now)}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (e.date !== current) {
|
|
69
|
+
current = e.date;
|
|
70
|
+
rows.push({
|
|
71
|
+
type: "day",
|
|
72
|
+
key: `day-${e.date}-${rows.length}`,
|
|
73
|
+
label: dayLabel(e.date, now),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
rows.push({ type: "entry", key: `e-${e.id}`, entry: e });
|
|
77
|
+
}
|
|
78
|
+
return rows;
|
|
79
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const KINDS = ["new", "improved", "fixed"] as const;
|
|
4
|
+
export type Kind = (typeof KINDS)[number];
|
|
5
|
+
|
|
6
|
+
export const KIND_LABEL: Record<Kind, string> = {
|
|
7
|
+
new: "New",
|
|
8
|
+
improved: "Improved",
|
|
9
|
+
fixed: "Fixed",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const entrySchema = z.object({
|
|
13
|
+
id: z.number().int().positive(),
|
|
14
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
15
|
+
kind: z.enum(KINDS),
|
|
16
|
+
area: z.string().min(1).max(40),
|
|
17
|
+
text: z.string().min(1).max(280),
|
|
18
|
+
note: z.string().max(400).optional(),
|
|
19
|
+
url: z.string().optional(),
|
|
20
|
+
hidden: z.boolean().optional(),
|
|
21
|
+
});
|
|
22
|
+
export type Entry = z.infer<typeof entrySchema>;
|
|
23
|
+
|
|
24
|
+
export const fileSchema = z.object({
|
|
25
|
+
v: z.literal(1),
|
|
26
|
+
entries: z.array(entrySchema),
|
|
27
|
+
});
|
|
28
|
+
export type WhatsNewFile = z.infer<typeof fileSchema>;
|
|
29
|
+
|
|
30
|
+
export interface Head {
|
|
31
|
+
latestId: number;
|
|
32
|
+
latestDate: string | null;
|
|
33
|
+
count: number;
|
|
34
|
+
/** Ids of the newest few, so the footer can count unread without the list. */
|
|
35
|
+
recent: number[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Page {
|
|
39
|
+
entries: Entry[];
|
|
40
|
+
hasMore: boolean;
|
|
41
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { KINDS, type Kind } from "./schema";
|
|
3
|
+
|
|
4
|
+
export type Line = { skip: true } | { kind: Kind; text: string };
|
|
5
|
+
|
|
6
|
+
const LINE = /^\s*Whats-new:\s*(.+?)\s*$/gim;
|
|
7
|
+
const KIND_BY_NAME: Record<string, Kind> = {
|
|
8
|
+
new: "new",
|
|
9
|
+
improved: "improved",
|
|
10
|
+
fixed: "fixed",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const tidy = (text: string) => {
|
|
14
|
+
const t = text.replace(/\s+/g, " ").trim();
|
|
15
|
+
return /[.!?]$/.test(t) ? t : `${t}.`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The author's line from a PR body (the last one wins); null when absent or malformed. */
|
|
19
|
+
export function authorLine(body: string | null | undefined): Line | null {
|
|
20
|
+
if (!body) return null;
|
|
21
|
+
let last: string | undefined;
|
|
22
|
+
for (const m of body.slice(0, 65_536).matchAll(LINE)) last = m[1];
|
|
23
|
+
if (!last) return null;
|
|
24
|
+
if (/^skip\.?$/i.test(last)) return { skip: true };
|
|
25
|
+
const m = /^(new|improved|fixed)\s*[—–:-]+\s*(.+)$/i.exec(last);
|
|
26
|
+
if (!m) return null;
|
|
27
|
+
return { kind: KIND_BY_NAME[m[1].toLowerCase()], text: tidy(m[2]) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const GAP_SYSTEM = `You write one line of a changelog for people who use a coding-agent app. You get a JSON list of merged changes, each only a commit subject. Answer with a JSON array and nothing else: one object per item, {"n": <n>, "kind": "new" | "improved" | "fixed", "text": "<sentence>"}, or {"n": <n>, "skip": true} when a user would not see the change.
|
|
31
|
+
|
|
32
|
+
Rules for text: one plain sentence under 140 characters that says what a user can now do or what stopped going wrong. Say nothing the subject does not say. No jargon, no file or function names, no paths, no emoji, no dashes used as punctuation, never the word "improved", and no marketing words. "new" is something that did not exist, "fixed" is something that was broken, "improved" is something that already existed and works better.`;
|
|
33
|
+
|
|
34
|
+
export interface GapItem {
|
|
35
|
+
n: number;
|
|
36
|
+
type: string;
|
|
37
|
+
title: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const answer = z.array(
|
|
41
|
+
z.union([
|
|
42
|
+
z.object({ n: z.number(), skip: z.literal(true) }),
|
|
43
|
+
z.object({
|
|
44
|
+
n: z.number(),
|
|
45
|
+
kind: z.enum(KINDS),
|
|
46
|
+
text: z.string().min(1).max(400),
|
|
47
|
+
}),
|
|
48
|
+
])
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
export function parseAnswer(raw: string): Map<number, Line> {
|
|
52
|
+
const out = new Map<number, Line>();
|
|
53
|
+
const start = raw.indexOf("[");
|
|
54
|
+
const end = raw.lastIndexOf("]");
|
|
55
|
+
if (start < 0 || end < start) return out;
|
|
56
|
+
let json: unknown;
|
|
57
|
+
try {
|
|
58
|
+
json = JSON.parse(raw.slice(start, end + 1));
|
|
59
|
+
} catch {
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
const parsed = answer.safeParse(json);
|
|
63
|
+
if (!parsed.success) return out;
|
|
64
|
+
for (const r of parsed.data)
|
|
65
|
+
out.set(
|
|
66
|
+
r.n,
|
|
67
|
+
"skip" in r ? { skip: true } : { kind: r.kind, text: tidy(r.text) }
|
|
68
|
+
);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type Ask = (system: string, prompt: string) => Promise<string>;
|
|
73
|
+
|
|
74
|
+
const BATCH = 80;
|
|
75
|
+
|
|
76
|
+
/** Haiku sees the commit subject only: never the diff and never the PR body. */
|
|
77
|
+
export async function fillGaps(
|
|
78
|
+
items: GapItem[],
|
|
79
|
+
ask: Ask
|
|
80
|
+
): Promise<Map<number, Line>> {
|
|
81
|
+
const out = new Map<number, Line>();
|
|
82
|
+
for (let i = 0; i < items.length; i += BATCH) {
|
|
83
|
+
const batch = items.slice(i, i + BATCH);
|
|
84
|
+
const prompt = JSON.stringify(
|
|
85
|
+
batch.map(({ n, type, title }) => ({ n, type, subject: title }))
|
|
86
|
+
);
|
|
87
|
+
for (const [n, line] of parseAnswer(await ask(GAP_SYSTEM, prompt)))
|
|
88
|
+
if (batch.some((b) => b.n === n)) out.set(n, line);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Read/unread is local to a browser: the id of the newest entry seen.
|
|
2
|
+
// Pure, so a native client can use the same rules.
|
|
3
|
+
|
|
4
|
+
export const SEEN_KEY = "aos:whatsnew:seen";
|
|
5
|
+
export const UNREAD_CAP = 9;
|
|
6
|
+
|
|
7
|
+
/** How many of the newest entries are newer than the seen id (capped by the head's list). */
|
|
8
|
+
export function unreadCount(recent: number[], seen: number | null): number {
|
|
9
|
+
if (seen === null) return 0;
|
|
10
|
+
return recent.filter((id) => id > seen).length;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const unreadLabel = (n: number) =>
|
|
14
|
+
n > UNREAD_CAP ? `${UNREAD_CAP}+` : String(n);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saadnvd1/agent-os",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.786",
|
|
4
4
|
"description": "Self-hosted web UI for managing Claude Code sessions",
|
|
5
5
|
"bin": {
|
|
6
6
|
"agent-os": "scripts/agent-os"
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"app/",
|
|
10
10
|
"components/",
|
|
11
|
+
"content/",
|
|
11
12
|
"contexts/",
|
|
12
13
|
"data/",
|
|
13
14
|
"hooks/",
|
package/scripts/autodeploy
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
# HELD <sha> <seconds> left in the restart gap (since <last restart sha>)
|
|
14
14
|
# Another machine (a server) runs it with AGENTOS_PUBLISH=0: deploy
|
|
15
15
|
# only, and its own AGENTOS_RESTART. Only one machine publishes to npm.
|
|
16
|
+
# The machine that publishes also writes the What's New entries for what
|
|
17
|
+
# merged (scripts/whats-new, before the publish, so the package carries them)
|
|
18
|
+
# and pushes them as a commit that touches only content/whats-new.json. Such a
|
|
19
|
+
# commit is pulled but never rebuilt or restarted for, and the generator
|
|
20
|
+
# ignores it, so it can't loop. AGENTOS_WHATS_NEW=0 turns the step off.
|
|
16
21
|
set -euo pipefail
|
|
17
22
|
cd "$(dirname "$0")/.."
|
|
18
23
|
|
|
@@ -42,6 +47,18 @@ published=$(cat "$state/published" 2>/dev/null || true)
|
|
|
42
47
|
[ "$head" = "$deployed" ] && [ "$head" = "$published" ] && exit 0
|
|
43
48
|
log() { echo "$(date '+%Y-%m-%d %-I:%M %p') $*"; }
|
|
44
49
|
|
|
50
|
+
# A head that differs from the deployed one only in the changelog file needs
|
|
51
|
+
# a pull, not a build or a restart: the file is read from disk when asked.
|
|
52
|
+
changelog=content/whats-new.json
|
|
53
|
+
if [ "$head" != "$deployed" ] && [ -n "$deployed" ] &&
|
|
54
|
+
[ "$(git diff --name-only "$deployed" "$head" 2>/dev/null)" = "$changelog" ] &&
|
|
55
|
+
[ -z "$(git status --porcelain --untracked-files=no)" ] &&
|
|
56
|
+
git merge -q --ff-only "$head" 2>/dev/null; then
|
|
57
|
+
deployed=$head
|
|
58
|
+
git rev-parse HEAD > "$state/deployed"
|
|
59
|
+
[ "$head" = "$published" ] && exit 0
|
|
60
|
+
fi
|
|
61
|
+
|
|
45
62
|
# Merges land in bursts: one that's under AGENTOS_SETTLE seconds old (2
|
|
46
63
|
# minutes) waits for the next run, so a burst is one build and one restart.
|
|
47
64
|
# Its commit time is the merge's, so this never holds a deploy forever.
|
|
@@ -104,6 +121,17 @@ if [ "$head" != "$deployed" ]; then
|
|
|
104
121
|
scripts/sync-cli-links || log "CLI link sync failed"
|
|
105
122
|
fi
|
|
106
123
|
|
|
124
|
+
# The changelog is written before the publish, from the same checkout, so the
|
|
125
|
+
# package carries it. Best effort: a failure here never holds up the publish.
|
|
126
|
+
if [ "${AGENTOS_PUBLISH:-1}" != 0 ] && [ "${AGENTOS_WHATS_NEW:-1}" != 0 ] &&
|
|
127
|
+
[ "$head" != "$published" ] && [ "$head" = "$(git rev-parse HEAD)" ]; then
|
|
128
|
+
if ! node --import tsx scripts/whats-new.ts --commit \
|
|
129
|
+
--through-file "$state/whats-new-through" >> "$state/whats-new.log" 2>&1; then
|
|
130
|
+
log "What's New step failed; see $state/whats-new.log"
|
|
131
|
+
fi
|
|
132
|
+
head=$(git rev-parse HEAD)
|
|
133
|
+
fi
|
|
134
|
+
|
|
107
135
|
# Publishing waits minutes for npm to serve a version, so it runs on its own
|
|
108
136
|
# with its own lock and never holds up the next deploy.
|
|
109
137
|
if [ "$head" != "$published" ] && mkdir "$state/publish.lock" 2>/dev/null; then
|
|
@@ -113,6 +113,59 @@ describe("autodeploy restart gap", () => {
|
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
describe("autodeploy changelog commits", () => {
|
|
117
|
+
const changelog = (t: ReturnType<typeof setup>, also?: string) => {
|
|
118
|
+
const work = path.join(t.root, "work");
|
|
119
|
+
fs.mkdirSync(path.join(work, "content"), { recursive: true });
|
|
120
|
+
fs.writeFileSync(path.join(work, "content/whats-new.json"), "{}\n");
|
|
121
|
+
if (also) fs.appendFileSync(path.join(work, "f"), also + "\n");
|
|
122
|
+
sh(work, "add", "-A");
|
|
123
|
+
sh(
|
|
124
|
+
work,
|
|
125
|
+
"-c",
|
|
126
|
+
"user.name=t",
|
|
127
|
+
"-c",
|
|
128
|
+
"user.email=t@t",
|
|
129
|
+
"commit",
|
|
130
|
+
"-qm",
|
|
131
|
+
"chore(whats-new): add 1 entries"
|
|
132
|
+
);
|
|
133
|
+
sh(work, "push", "-q", "origin", "main");
|
|
134
|
+
return sh(work, "rev-parse", "HEAD");
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
it("pulls a commit that touches only the changelog without a restart", () => {
|
|
138
|
+
const t = setup();
|
|
139
|
+
const a = t.commit("a");
|
|
140
|
+
t.run();
|
|
141
|
+
expect(t.restarts()).toBe(1);
|
|
142
|
+
|
|
143
|
+
const b = changelog(t);
|
|
144
|
+
expect(t.run()).toBe("");
|
|
145
|
+
expect(t.deployed()).toBe(b);
|
|
146
|
+
expect(t.deployed()).not.toBe(a);
|
|
147
|
+
expect(t.restarts()).toBe(1);
|
|
148
|
+
expect(fs.existsSync(path.join(t.clone, "content/whats-new.json"))).toBe(
|
|
149
|
+
true
|
|
150
|
+
);
|
|
151
|
+
expect(t.run()).toBe("");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("still redeploys a commit that touches the changelog and something else", () => {
|
|
155
|
+
const t = setup();
|
|
156
|
+
t.commit("a");
|
|
157
|
+
t.run();
|
|
158
|
+
const c = changelog(t, "more");
|
|
159
|
+
fs.writeFileSync(
|
|
160
|
+
path.join(t.state, "restarted"),
|
|
161
|
+
`${Math.floor(Date.now() / 1000) - 3601}\n`
|
|
162
|
+
);
|
|
163
|
+
expect(t.run()).toContain("Deploying");
|
|
164
|
+
expect(t.deployed()).toBe(c);
|
|
165
|
+
expect(t.restarts()).toBe(2);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
116
169
|
describe("autodeploy CLI links", () => {
|
|
117
170
|
it("repoints a link into another clone at the live checkout after a deploy", () => {
|
|
118
171
|
const t = setup();
|