@saadnvd1/agent-os 0.3.770 → 0.3.777
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/live-shares/[id]/route.ts +72 -0
- package/app/api/live-shares/route.test.ts +45 -0
- package/app/api/live-shares/route.ts +40 -0
- package/app/api/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
- package/app/api/shares/preview/route.ts +6 -1
- package/app/api/shares/route.test.ts +51 -0
- package/app/page.tsx +6 -0
- package/components/Artifacts/ArtifactActions.tsx +27 -3
- package/components/Artifacts/ArtifactPreview.tsx +27 -12
- package/components/Artifacts/Compare/DiffView.tsx +116 -0
- package/components/Artifacts/Compare/SideBySide.tsx +60 -0
- package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
- package/components/Artifacts/Compare/index.tsx +191 -0
- package/components/Chat/ChatPanel.tsx +6 -0
- package/components/Chat/EventRow.tsx +6 -0
- package/components/Chat/Messages.tsx +6 -0
- package/components/Chat/ShareFromHere.tsx +39 -0
- package/components/CommandPalette/CommandPalette.tsx +5 -4
- package/components/CommandPalette/CommandsBody.tsx +97 -131
- package/components/CommandPalette/PaletteRows.tsx +79 -0
- package/components/CommandPalette/PaletteTabs.tsx +51 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
- package/components/CommandPalette/useAppCommands.ts +14 -0
- package/components/CommandPalette/useConversationRows.ts +55 -0
- package/components/Live/GuestPromptCard.tsx +64 -0
- package/components/Live/InviteDialog.tsx +225 -0
- package/components/Live/LiveChip.tsx +109 -0
- package/components/Live/LivePanel.tsx +81 -0
- package/components/Live/LobbyCard.tsx +64 -0
- package/components/Pane/MobileTabBar.tsx +8 -0
- package/components/Pane/PaneMenu.tsx +10 -0
- package/components/SessionList/SessionRowMenu.tsx +6 -0
- package/components/SessionList/useSessionSwitching.ts +2 -2
- package/components/Share/MessageList.tsx +87 -0
- package/components/Share/RangePicker.tsx +59 -0
- package/components/Share/ShareDialog.tsx +113 -52
- package/components/Shares/SharedLinksPanel.tsx +51 -0
- package/data/live/index.ts +2 -0
- package/data/live/keys.ts +4 -0
- package/data/live/queries.ts +81 -0
- package/data/session-artifacts/keys.ts +2 -0
- package/data/session-artifacts/queries.ts +25 -0
- package/data/shares/queries.ts +11 -0
- package/hooks/usePaletteCommands.ts +4 -1
- package/lib/artifacts/actions.ts +11 -1
- package/lib/artifacts/compare/compareVersions.ts +66 -0
- package/lib/artifacts/compare/diff.ts +178 -0
- package/lib/artifacts/compare/extractText.ts +64 -0
- package/lib/artifacts/compare/index.ts +11 -0
- package/lib/artifacts/compare/types.ts +44 -0
- package/lib/artifacts/match.ts +64 -0
- package/lib/artifacts/store.ts +39 -10
- package/lib/artifacts/tools.ts +9 -4
- package/lib/artifacts/versions.ts +2 -1
- package/lib/chat/events.ts +4 -1
- package/lib/chat/registry.ts +10 -0
- package/lib/connect/control-api.ts +44 -0
- package/lib/db/migrations/20261029T1400_live_shares.ts +42 -0
- package/lib/live/clean.ts +54 -0
- package/lib/live/frames.ts +132 -0
- package/lib/live/inbox.ts +225 -0
- package/lib/live/publisher.ts +84 -0
- package/lib/live/ring.ts +61 -0
- package/lib/live/runtime.ts +51 -0
- package/lib/live/service.ts +318 -0
- package/lib/live/socket.ts +293 -0
- package/lib/live/store.ts +149 -0
- package/lib/live/types.ts +39 -0
- package/lib/palette/registry.ts +71 -17
- package/lib/palette/sections.ts +143 -0
- package/lib/palette/tabs.ts +21 -0
- package/lib/palette/usage.ts +81 -0
- package/lib/security/demo.ts +3 -0
- package/lib/share/connect.ts +24 -0
- package/lib/share/markdown.ts +3 -0
- package/lib/share/range.ts +189 -0
- package/lib/share/redact.ts +23 -1
- package/lib/share/render.tsx +3 -0
- package/lib/share/secret-patterns.ts +77 -0
- package/lib/share/service.ts +64 -19
- package/lib/share/snapshot.ts +39 -2
- package/lib/share/styles.ts +2 -0
- package/lib/share/view.ts +11 -0
- package/lib/sidebar/row-menu.ts +2 -0
- package/package.json +1 -1
- package/server.ts +2 -0
- package/stores/liveUi.ts +17 -0
- package/stores/palette.ts +13 -11
- package/stores/shareUi.ts +6 -1
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Finds which earlier page a new render is a revision of, by its visible text.
|
|
2
|
+
// Used when an agent retitles a page without passing `update`.
|
|
3
|
+
|
|
4
|
+
// Word-5-gram overlap (Jaccard) of the visible text. A revision keeps most of
|
|
5
|
+
// its sentences, so it stays well above 0.6; two pages that only share a
|
|
6
|
+
// template (nav, footer, same CSS) have different text and land far below.
|
|
7
|
+
export const MATCH_THRESHOLD = 0.6;
|
|
8
|
+
// A clear winner: the runner-up must trail by this much, else it is a tie.
|
|
9
|
+
export const MATCH_MARGIN = 0.1;
|
|
10
|
+
// Too little text to tell documents apart.
|
|
11
|
+
export const MIN_TOKENS = 12;
|
|
12
|
+
export const SHINGLE = 5;
|
|
13
|
+
export const MAX_CANDIDATES = 20;
|
|
14
|
+
|
|
15
|
+
export function visibleText(html: string): string {
|
|
16
|
+
return html
|
|
17
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
18
|
+
.replace(/<(script|style|noscript|template|svg)\b[\s\S]*?<\/\1\s*>/gi, " ")
|
|
19
|
+
.replace(/<[^>]*>/g, " ")
|
|
20
|
+
.replace(/ /gi, " ")
|
|
21
|
+
.replace(/&/gi, "&")
|
|
22
|
+
.replace(/\s+/g, " ")
|
|
23
|
+
.trim()
|
|
24
|
+
.toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function tokens(html: string): string[] {
|
|
28
|
+
return visibleText(html).match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shingles(toks: string[]): Set<string> {
|
|
32
|
+
const out = new Set<string>();
|
|
33
|
+
const n = Math.min(SHINGLE, toks.length);
|
|
34
|
+
for (let i = 0; i + n <= toks.length; i++)
|
|
35
|
+
out.add(toks.slice(i, i + n).join(" "));
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function similarity(a: string, b: string): number {
|
|
40
|
+
const ta = tokens(a);
|
|
41
|
+
const tb = tokens(b);
|
|
42
|
+
if (ta.length < MIN_TOKENS || tb.length < MIN_TOKENS) return 0;
|
|
43
|
+
const sa = shingles(ta);
|
|
44
|
+
const sb = shingles(tb);
|
|
45
|
+
let shared = 0;
|
|
46
|
+
for (const s of sa) if (sb.has(s)) shared++;
|
|
47
|
+
const union = sa.size + sb.size - shared;
|
|
48
|
+
return union ? shared / union : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// The id of the one candidate that clearly is the same document, if any.
|
|
52
|
+
export function findRevisionOf<T extends { key: string; html: string }>(
|
|
53
|
+
html: string,
|
|
54
|
+
candidates: T[]
|
|
55
|
+
): string | undefined {
|
|
56
|
+
const scored = candidates
|
|
57
|
+
.slice(0, MAX_CANDIDATES)
|
|
58
|
+
.map((c) => ({ key: c.key, score: similarity(html, c.html) }))
|
|
59
|
+
.sort((a, b) => b.score - a.score);
|
|
60
|
+
const [best, next] = scored;
|
|
61
|
+
if (!best || best.score < MATCH_THRESHOLD) return undefined;
|
|
62
|
+
if (next && best.score - next.score < MATCH_MARGIN) return undefined;
|
|
63
|
+
return best.key;
|
|
64
|
+
}
|
package/lib/artifacts/store.ts
CHANGED
|
@@ -8,6 +8,7 @@ import fs from "fs";
|
|
|
8
8
|
import path from "path";
|
|
9
9
|
import { createHash, randomUUID } from "crypto";
|
|
10
10
|
import { db } from "../db";
|
|
11
|
+
import { findRevisionOf, MAX_CANDIDATES } from "./match";
|
|
11
12
|
|
|
12
13
|
export interface Artifact {
|
|
13
14
|
id: string;
|
|
@@ -29,6 +30,9 @@ export interface SaveOptions {
|
|
|
29
30
|
update?: string;
|
|
30
31
|
// With no update, the newest page in this session with the same title.
|
|
31
32
|
sameTitle?: boolean;
|
|
33
|
+
// With no update and no title match, the page in this session whose latest
|
|
34
|
+
// version has clearly the same text (see match.ts).
|
|
35
|
+
matchContent?: boolean;
|
|
32
36
|
note?: string;
|
|
33
37
|
}
|
|
34
38
|
|
|
@@ -78,16 +82,18 @@ export function saveArtifact(
|
|
|
78
82
|
if (bytes > MAX_HTML_BYTES)
|
|
79
83
|
throw new Error("The page is over 2 MB; make it smaller.");
|
|
80
84
|
const clean = oneLine(title, MAX_TITLE_CHARS) || "Untitled";
|
|
81
|
-
|
|
85
|
+
let note = opts.note ? oneLine(opts.note, MAX_NOTE_CHARS) || null : null;
|
|
82
86
|
const sha256 = createHash("sha256").update(html).digest("hex");
|
|
83
87
|
const id = randomUUID();
|
|
84
88
|
const dir = sessionDir(sessionId);
|
|
85
89
|
const file = path.join(dir, `${id}.html`);
|
|
86
90
|
return db.transaction((): Saved => {
|
|
87
|
-
const pageId = resolvePage(sessionId, clean, opts);
|
|
91
|
+
const { pageId, fuzzy } = resolvePage(sessionId, clean, html, opts);
|
|
88
92
|
const latest = pageId ? latestVersion(pageId) : undefined;
|
|
89
93
|
if (latest && latest.sha256 === sha256)
|
|
90
94
|
return { artifact: latest, created: false };
|
|
95
|
+
if (fuzzy && latest && !note && latest.title !== clean)
|
|
96
|
+
note = oneLine(`Retitled from '${latest.title}'`, MAX_NOTE_CHARS);
|
|
91
97
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
92
98
|
fs.writeFileSync(file, html, { mode: 0o600 });
|
|
93
99
|
db.prepare(
|
|
@@ -111,24 +117,47 @@ export function saveArtifact(
|
|
|
111
117
|
function resolvePage(
|
|
112
118
|
sessionId: string,
|
|
113
119
|
title: string,
|
|
120
|
+
html: string,
|
|
114
121
|
opts: SaveOptions
|
|
115
|
-
): string
|
|
122
|
+
): { pageId?: string; fuzzy?: boolean } {
|
|
116
123
|
if (opts.update) {
|
|
117
124
|
const base = getArtifact(opts.update);
|
|
118
125
|
if (!base || base.session_id !== sessionId)
|
|
119
126
|
throw new Error(
|
|
120
127
|
`No page ${opts.update} in this chat; leave out update to show a new page.`
|
|
121
128
|
);
|
|
122
|
-
return base.page_id;
|
|
129
|
+
return { pageId: base.page_id };
|
|
130
|
+
}
|
|
131
|
+
if (!opts.sameTitle && !opts.matchContent) return {};
|
|
132
|
+
// A page is named by its latest version, so an older title no longer finds it.
|
|
133
|
+
const latest = latestPerPage(sessionId, MAX_CANDIDATES);
|
|
134
|
+
if (opts.sameTitle) {
|
|
135
|
+
const lower = title.toLowerCase();
|
|
136
|
+
const same = latest.find((a) => a.title.toLowerCase() === lower);
|
|
137
|
+
if (same) return { pageId: same.page_id };
|
|
123
138
|
}
|
|
124
|
-
if (!opts.
|
|
125
|
-
const
|
|
139
|
+
if (!opts.matchContent) return {};
|
|
140
|
+
const candidates = latest.flatMap((a) => {
|
|
141
|
+
try {
|
|
142
|
+
return [{ key: a.page_id, html: fs.readFileSync(a.path, "utf8") }];
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
const pageId = findRevisionOf(html, candidates);
|
|
148
|
+
return pageId ? { pageId, fuzzy: true } : {};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The newest version of each page in a session, most recently shown first.
|
|
152
|
+
function latestPerPage(sessionId: string, limit: number): Artifact[] {
|
|
153
|
+
return db
|
|
126
154
|
.prepare(
|
|
127
|
-
`SELECT
|
|
128
|
-
|
|
155
|
+
`SELECT a.* FROM artifacts a
|
|
156
|
+
WHERE a.session_id = ?
|
|
157
|
+
AND a.version = (SELECT MAX(version) FROM artifacts WHERE page_id = a.page_id)
|
|
158
|
+
ORDER BY a.created_at DESC, a.rowid DESC LIMIT ?`
|
|
129
159
|
)
|
|
130
|
-
.
|
|
131
|
-
return row?.page_id;
|
|
160
|
+
.all(sessionId, limit) as Artifact[];
|
|
132
161
|
}
|
|
133
162
|
|
|
134
163
|
// Every version of a page, oldest first.
|
package/lib/artifacts/tools.ts
CHANGED
|
@@ -100,7 +100,12 @@ export async function renderPage(
|
|
|
100
100
|
ctx.sessionId,
|
|
101
101
|
args.title,
|
|
102
102
|
readSource(args, ctx.cwd),
|
|
103
|
-
{
|
|
103
|
+
{
|
|
104
|
+
update: args.update,
|
|
105
|
+
note: args.note,
|
|
106
|
+
sameTitle: true,
|
|
107
|
+
matchContent: true,
|
|
108
|
+
}
|
|
104
109
|
);
|
|
105
110
|
if (created) {
|
|
106
111
|
ctx.onRender(artifact, args.height);
|
|
@@ -112,7 +117,7 @@ export async function renderPage(
|
|
|
112
117
|
{
|
|
113
118
|
type: "text",
|
|
114
119
|
text: created
|
|
115
|
-
? `Shown in the chat as ${name} (artifact ${artifact.id}). To
|
|
120
|
+
? `Shown in the chat as ${name} (artifact ${artifact.id}). To revise this page, pass update: "${artifact.id}" to html_render, even if the title changes; every version is kept.`
|
|
116
121
|
: `Unchanged: the page is the same as ${name} (artifact ${artifact.id}), already in the chat.`,
|
|
117
122
|
},
|
|
118
123
|
],
|
|
@@ -191,7 +196,7 @@ export function visualsTools(
|
|
|
191
196
|
.string()
|
|
192
197
|
.optional()
|
|
193
198
|
.describe(
|
|
194
|
-
"
|
|
199
|
+
"When revising a page you already showed, pass its artifact id, even if the title changes, to show a new version of it. Without it a page is matched by title or similar content, and otherwise saved as a new page. Every version is kept."
|
|
195
200
|
),
|
|
196
201
|
note: z
|
|
197
202
|
.string()
|
|
@@ -207,7 +212,7 @@ export function visualsTools(
|
|
|
207
212
|
|
|
208
213
|
export const VISUALS_BRIEF = `## Showing visuals
|
|
209
214
|
|
|
210
|
-
When a chart, table, diagram or mockup would say more than prose, build a self-contained HTML page (a chart is an HTML page; a CDN library such as Chart.js is fine), check it with \`html_preview\` and fix what the screenshot and console show, then show it with \`html_render\` before your final reply. The reader sees the page above that reply, so add only what the page doesn't say. An SVG or image file you write shows inline when your reply links to it by its absolute path, as .`;
|
|
215
|
+
When a chart, table, diagram or mockup would say more than prose, build a self-contained HTML page (a chart is an HTML page; a CDN library such as Chart.js is fine), check it with \`html_preview\` and fix what the screenshot and console show, then show it with \`html_render\` before your final reply. The reader sees the page above that reply, so add only what the page doesn't say. To revise a page you already showed, call \`html_render\` again with \`update\` set to its artifact id (even if the title changes), so it becomes the next version, not a second page. An SVG or image file you write shows inline when your reply links to it by its absolute path, as .`;
|
|
211
216
|
|
|
212
217
|
// An orchestrator reads other sessions' text, so it gets the short form and
|
|
213
218
|
// is told what to keep out of a page.
|
|
@@ -66,8 +66,9 @@ export function pageVersions(
|
|
|
66
66
|
const versions = listVersions(a.page_id).map(toVersion);
|
|
67
67
|
return {
|
|
68
68
|
pageId: a.page_id,
|
|
69
|
+
title: versions[versions.length - 1]?.title ?? a.title,
|
|
69
70
|
latestId: versions[versions.length - 1]?.id ?? a.id,
|
|
70
71
|
versions,
|
|
71
|
-
actions: pageActions(ctx),
|
|
72
|
+
actions: pageActions({ ...ctx, versions: versions.length }),
|
|
72
73
|
};
|
|
73
74
|
}
|
package/lib/chat/events.ts
CHANGED
|
@@ -81,7 +81,10 @@ export type ChatOriginKind =
|
|
|
81
81
|
// The reader's own decision, made outside the composer (a plan carried out).
|
|
82
82
|
| "decision"
|
|
83
83
|
// The reader's phone: a shake report or something shared to AgentOS.
|
|
84
|
-
| "device"
|
|
84
|
+
| "device"
|
|
85
|
+
// A suggestion from a guest on a live link, sent by the reader after
|
|
86
|
+
// reading it. Its label is the guest's name.
|
|
87
|
+
| "guest";
|
|
85
88
|
|
|
86
89
|
export interface ChatOrigin {
|
|
87
90
|
kind: ChatOriginKind;
|
package/lib/chat/registry.ts
CHANGED
|
@@ -85,6 +85,8 @@ interface Registry {
|
|
|
85
85
|
caps: Map<string, Capabilities>;
|
|
86
86
|
// Told of every finished turn (lib/analyst), set by the server.
|
|
87
87
|
turnEndHook?: TurnEndHook;
|
|
88
|
+
// Read-only taps on every message of every conversation (lib/live).
|
|
89
|
+
taps?: Set<(sessionId: string, m: ChatServerMessage) => void>;
|
|
88
90
|
}
|
|
89
91
|
|
|
90
92
|
export type TurnEndHook = (
|
|
@@ -96,6 +98,13 @@ export function setTurnEndHook(fn: TurnEndHook): void {
|
|
|
96
98
|
registry.turnEndHook = fn;
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
export function addTap(
|
|
102
|
+
fn: (sessionId: string, m: ChatServerMessage) => void
|
|
103
|
+
): () => void {
|
|
104
|
+
(registry.taps ??= new Set()).add(fn);
|
|
105
|
+
return () => registry.taps?.delete(fn);
|
|
106
|
+
}
|
|
107
|
+
|
|
99
108
|
// Shared across every module instance in the process (custom server and the
|
|
100
109
|
// Next.js route bundles each load their own copy of this file).
|
|
101
110
|
const g = globalThis as unknown as { __agentosChat?: Registry };
|
|
@@ -120,6 +129,7 @@ export function emit(sessionId: string, m: ChatServerMessage): void {
|
|
|
120
129
|
touchActive(sessionId);
|
|
121
130
|
if (m.type === "item" && m.item.kind === "turn_end")
|
|
122
131
|
registry.turnEndHook?.(sessionId, m.item);
|
|
132
|
+
registry.taps?.forEach((tap) => tap(sessionId, m));
|
|
123
133
|
registry.listeners.get(sessionId)?.forEach((fn) => fn(m));
|
|
124
134
|
// Idle, running, waiting and back, and a finished turn (unread): the
|
|
125
135
|
// sidebar hears at once.
|
|
@@ -471,3 +471,47 @@ export async function revokeShare(
|
|
|
471
471
|
);
|
|
472
472
|
return shareFrom(await res.json().catch(() => null));
|
|
473
473
|
}
|
|
474
|
+
|
|
475
|
+
export type LiveExpiry = "1h" | "session";
|
|
476
|
+
|
|
477
|
+
export interface LiveShare {
|
|
478
|
+
id: string;
|
|
479
|
+
url: string;
|
|
480
|
+
title: string;
|
|
481
|
+
status: ShareStatus;
|
|
482
|
+
expires_at: string | null;
|
|
483
|
+
ends_with_session: boolean;
|
|
484
|
+
max_guests: number;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Mints a live link for one session; the answer carries the link, once. */
|
|
488
|
+
export async function createLiveShare(
|
|
489
|
+
config: ConnectConfig,
|
|
490
|
+
privateKey: string,
|
|
491
|
+
share: { title: string; expiresIn: LiveExpiry }
|
|
492
|
+
): Promise<LiveShare> {
|
|
493
|
+
const res = await signed(config, privateKey)(
|
|
494
|
+
"POST",
|
|
495
|
+
`/api/v1/machines/${config.machineId}/live-shares`,
|
|
496
|
+
{ title: share.title, expires_in: share.expiresIn }
|
|
497
|
+
);
|
|
498
|
+
const r = (await res.json().catch(() => null)) as Record<
|
|
499
|
+
string,
|
|
500
|
+
unknown
|
|
501
|
+
> | null;
|
|
502
|
+
if (
|
|
503
|
+
typeof r?.id !== "string" ||
|
|
504
|
+
typeof r.url !== "string" ||
|
|
505
|
+
!/^https:\/\//.test(r.url)
|
|
506
|
+
)
|
|
507
|
+
throw new Error("the Connect service sent no link");
|
|
508
|
+
return {
|
|
509
|
+
id: r.id,
|
|
510
|
+
url: r.url,
|
|
511
|
+
title: typeof r.title === "string" ? r.title : share.title,
|
|
512
|
+
status: (r.status as ShareStatus) ?? "active",
|
|
513
|
+
expires_at: typeof r.expires_at === "string" ? r.expires_at : null,
|
|
514
|
+
ends_with_session: r.ends_with_session === true,
|
|
515
|
+
max_guests: typeof r.max_guests === "number" ? r.max_guests : 0,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261029T1400_live_shares",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// One row per live link this machine minted. The id is Connect's share id.
|
|
7
|
+
db.exec(
|
|
8
|
+
`CREATE TABLE IF NOT EXISTS live_shares (
|
|
9
|
+
id TEXT PRIMARY KEY,
|
|
10
|
+
session_id TEXT NOT NULL,
|
|
11
|
+
title TEXT NOT NULL,
|
|
12
|
+
url TEXT NOT NULL,
|
|
13
|
+
ends_with_session INTEGER NOT NULL DEFAULT 0,
|
|
14
|
+
max_guests INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
created_at TEXT NOT NULL,
|
|
16
|
+
expires_at TEXT,
|
|
17
|
+
ended_at TEXT
|
|
18
|
+
)`
|
|
19
|
+
);
|
|
20
|
+
db.exec(
|
|
21
|
+
`CREATE INDEX IF NOT EXISTS idx_live_shares_session ON live_shares(session_id)`
|
|
22
|
+
);
|
|
23
|
+
// What happened on a share, kept on this machine only: who asked to join,
|
|
24
|
+
// who was let in, and each suggestion with the text actually sent.
|
|
25
|
+
db.exec(
|
|
26
|
+
`CREATE TABLE IF NOT EXISTS live_share_log (
|
|
27
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
28
|
+
share_id TEXT NOT NULL,
|
|
29
|
+
at INTEGER NOT NULL,
|
|
30
|
+
kind TEXT NOT NULL,
|
|
31
|
+
guest_id TEXT,
|
|
32
|
+
guest_name TEXT,
|
|
33
|
+
ref TEXT,
|
|
34
|
+
text TEXT,
|
|
35
|
+
sent_text TEXT
|
|
36
|
+
)`
|
|
37
|
+
);
|
|
38
|
+
db.exec(
|
|
39
|
+
`CREATE INDEX IF NOT EXISTS idx_live_share_log_share ON live_share_log(share_id, id)`
|
|
40
|
+
);
|
|
41
|
+
},
|
|
42
|
+
} satisfies Migration;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text from a guest is cleaned here again, on this machine, whatever Connect
|
|
3
|
+
* did to it: nothing from the wire is shown on a card or reaches the agent
|
|
4
|
+
* without passing through. Mirrors Connect's Live::Text.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const INVISIBLE =
|
|
8
|
+
/[\p{Cf}\p{Default_Ignorable_Code_Point}\p{Zl}\p{Zp}⠀\u{1D159}\u{E0000}-\u{E0FFF}]/gu;
|
|
9
|
+
const SPACES = /\p{Zs}/gu;
|
|
10
|
+
const CONTROL = /[\u0000-\u001F\u007F-\u009F]/g;
|
|
11
|
+
const CONTROL_KEEPING_NEWLINE = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g;
|
|
12
|
+
const MARK_RUN = /\p{M}{3,}/u;
|
|
13
|
+
const SCRIPTS = [
|
|
14
|
+
/\p{Script=Latin}/u,
|
|
15
|
+
/\p{Script=Cyrillic}/u,
|
|
16
|
+
/\p{Script=Greek}/u,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export const MAX_NAME = 40;
|
|
20
|
+
export const MAX_SUGGESTION = 2000;
|
|
21
|
+
|
|
22
|
+
const wellFormed = (s: string) => s.isWellFormed();
|
|
23
|
+
|
|
24
|
+
/** A guest's name: one line, 1-40 characters, or null. */
|
|
25
|
+
export function cleanName(input: unknown): string | null {
|
|
26
|
+
if (typeof input !== "string" || !wellFormed(input)) return null;
|
|
27
|
+
if (input.length > MAX_NAME * 8) return null;
|
|
28
|
+
const s = input
|
|
29
|
+
.normalize("NFC")
|
|
30
|
+
.replace(SPACES, " ")
|
|
31
|
+
.replace(INVISIBLE, "")
|
|
32
|
+
.replace(CONTROL, " ")
|
|
33
|
+
.replace(/\s+/g, " ")
|
|
34
|
+
.trim();
|
|
35
|
+
if (!s || [...s].length > MAX_NAME || !/[\p{L}\p{N}]/u.test(s)) return null;
|
|
36
|
+
if (MARK_RUN.test(s) || SCRIPTS.filter((r) => r.test(s)).length > 1)
|
|
37
|
+
return null;
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A block of text (a suggestion): newlines and tabs kept, or null. */
|
|
42
|
+
export function cleanBody(input: unknown, max = MAX_SUGGESTION): string | null {
|
|
43
|
+
if (typeof input !== "string" || !wellFormed(input)) return null;
|
|
44
|
+
if (input.length > max * 8) return null;
|
|
45
|
+
const s = input
|
|
46
|
+
.normalize("NFC")
|
|
47
|
+
.replace(/\r/g, "")
|
|
48
|
+
.replace(SPACES, " ")
|
|
49
|
+
.replace(INVISIBLE, "")
|
|
50
|
+
.replace(CONTROL_KEEPING_NEWLINE, "")
|
|
51
|
+
.trim();
|
|
52
|
+
if (!s || [...s].length > max) return null;
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What goes up to Connect for one chat item, or nothing. Which items a
|
|
3
|
+
* guest may see is decided by the shared snapshot rules (toRaw); this only
|
|
4
|
+
* shapes them into frames. Text is redacted whole, never as a delta.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChatItem, ChatState } from "../chat/events";
|
|
8
|
+
import { isSecretPath } from "../secret-paths";
|
|
9
|
+
import { toRaw, strings } from "../share/snapshot";
|
|
10
|
+
import { redactForShare, type ShareContext } from "./../share/redact";
|
|
11
|
+
|
|
12
|
+
export type ItemFrame =
|
|
13
|
+
| {
|
|
14
|
+
id: string;
|
|
15
|
+
kind: "user" | "assistant" | "note";
|
|
16
|
+
text: string;
|
|
17
|
+
from?: string;
|
|
18
|
+
at: number;
|
|
19
|
+
}
|
|
20
|
+
| {
|
|
21
|
+
id: string;
|
|
22
|
+
kind: "tool";
|
|
23
|
+
name: string;
|
|
24
|
+
summary: string;
|
|
25
|
+
status: "running" | "done" | "error";
|
|
26
|
+
at: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type LiveState = "working" | "idle" | "waiting_on_host";
|
|
30
|
+
|
|
31
|
+
export const MAX_TEXT_BYTES = 24 * 1024;
|
|
32
|
+
export const MAX_SUMMARY_BYTES = 200;
|
|
33
|
+
const MAX_NAME_BYTES = 64;
|
|
34
|
+
const MAX_ID = 64;
|
|
35
|
+
|
|
36
|
+
/** The longest prefix of `s` that fits in `max` bytes, cut on a character. */
|
|
37
|
+
export function clipBytes(s: string, max: number): string {
|
|
38
|
+
if (Buffer.byteLength(s) <= max) return s;
|
|
39
|
+
let out = "";
|
|
40
|
+
let size = 0;
|
|
41
|
+
for (const ch of s) {
|
|
42
|
+
const n = Buffer.byteLength(ch);
|
|
43
|
+
if (size + n > max) break;
|
|
44
|
+
out += ch;
|
|
45
|
+
size += n;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const oneLine = (s: string) => s.replace(/\s+/g, " ").trim();
|
|
51
|
+
|
|
52
|
+
export function stateOf(state: ChatState): LiveState {
|
|
53
|
+
if (state === "running") return "working";
|
|
54
|
+
if (state === "waiting") return "waiting_on_host";
|
|
55
|
+
return "idle";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Messages that were not typed by the reader (the agent bus, schedules,
|
|
59
|
+
// AgentOS itself) stay on this machine; a sent suggestion is the reader's.
|
|
60
|
+
function userFrom(item: Extract<ChatItem, { kind: "user" }>): string | null {
|
|
61
|
+
const o = item.origin;
|
|
62
|
+
if (!o) return item.peer || item.from ? null : "host";
|
|
63
|
+
if (o.kind === "guest") return `guest:${o.label}`;
|
|
64
|
+
if (o.kind === "decision") return "host";
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ItemMapper {
|
|
69
|
+
/** `text` replaces the item's own text (an assistant turn still streaming). */
|
|
70
|
+
(item: ChatItem, text?: string): ItemFrame | null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function itemMapper(ctx: ShareContext): ItemMapper {
|
|
74
|
+
const take = (s: string) => redactForShare(s, ctx).text;
|
|
75
|
+
return (item, text) => {
|
|
76
|
+
const at = item.createdAt ?? Date.now();
|
|
77
|
+
const id = item.id.slice(0, MAX_ID);
|
|
78
|
+
if (item.kind === "tool") {
|
|
79
|
+
const secret = strings(item.input).some(isSecretPath);
|
|
80
|
+
const label = secret ? item.name : item.title || item.name;
|
|
81
|
+
const status =
|
|
82
|
+
item.status === "running"
|
|
83
|
+
? "running"
|
|
84
|
+
: item.status === "done"
|
|
85
|
+
? "done"
|
|
86
|
+
: "error";
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
kind: "tool",
|
|
90
|
+
name: clipBytes(oneLine(take(item.name)), MAX_NAME_BYTES),
|
|
91
|
+
summary: clipBytes(oneLine(take(label)), MAX_SUMMARY_BYTES),
|
|
92
|
+
status,
|
|
93
|
+
at,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (item.kind === "todos") {
|
|
97
|
+
const done = item.todos.filter((t) => t.status === "completed").length;
|
|
98
|
+
return {
|
|
99
|
+
id,
|
|
100
|
+
kind: "note",
|
|
101
|
+
text: `To do: ${done} of ${item.todos.length} done`,
|
|
102
|
+
at,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
let from: string | undefined;
|
|
106
|
+
if (item.kind === "user") {
|
|
107
|
+
const f = userFrom(item);
|
|
108
|
+
if (!f) return null;
|
|
109
|
+
from = f;
|
|
110
|
+
}
|
|
111
|
+
const raw = toRaw(item);
|
|
112
|
+
if (!raw || raw.tools || raw.type === "artifact") return null;
|
|
113
|
+
const body = clipBytes(take(text ?? raw.text), MAX_TEXT_BYTES);
|
|
114
|
+
if (item.kind === "user")
|
|
115
|
+
return {
|
|
116
|
+
id,
|
|
117
|
+
kind: "user",
|
|
118
|
+
text: body,
|
|
119
|
+
from,
|
|
120
|
+
at,
|
|
121
|
+
};
|
|
122
|
+
if (item.kind === "assistant")
|
|
123
|
+
return body.trim() ? { id, kind: "assistant", text: body, at } : null;
|
|
124
|
+
// Plans, errors and the compaction divider are one-line notes.
|
|
125
|
+
return {
|
|
126
|
+
id,
|
|
127
|
+
kind: "note",
|
|
128
|
+
text: clipBytes(oneLine(body), 400) || "…",
|
|
129
|
+
at,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
}
|