@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,189 @@
|
|
|
1
|
+
// Which part of a chat a share covers. Pure, imports nothing outside itself,
|
|
2
|
+
// so the dialog and the server resolve a range the same way.
|
|
3
|
+
|
|
4
|
+
/** A range of messages by id, both ends included. */
|
|
5
|
+
export interface ShareRange {
|
|
6
|
+
fromId: string;
|
|
7
|
+
toId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RangeRow {
|
|
11
|
+
id: string;
|
|
12
|
+
type: string;
|
|
13
|
+
createdAt?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type RangePreset = "last" | "last5" | "today" | "all";
|
|
17
|
+
|
|
18
|
+
export const PRESETS: { id: RangePreset; label: string }[] = [
|
|
19
|
+
{ id: "last", label: "Last exchange" },
|
|
20
|
+
{ id: "last5", label: "Last 5 exchanges" },
|
|
21
|
+
{ id: "today", label: "Today" },
|
|
22
|
+
{ id: "all", label: "Whole chat" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export const EARLIER_NOT_SHARED = "Earlier messages not shared";
|
|
26
|
+
export const LATER_NOT_SHARED = "Later messages not shared";
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_PRESET: RangePreset = "last";
|
|
29
|
+
|
|
30
|
+
/** Positions in `ids` that a range covers, or null when an end isn't there or they're backwards. A missing end means the chat's own start or end. */
|
|
31
|
+
export function windowOf(
|
|
32
|
+
ids: string[],
|
|
33
|
+
fromId?: string,
|
|
34
|
+
toId?: string
|
|
35
|
+
): { start: number; end: number } | null {
|
|
36
|
+
const start = fromId === undefined ? 0 : ids.indexOf(fromId);
|
|
37
|
+
const end = toId === undefined ? ids.length - 1 : ids.indexOf(toId);
|
|
38
|
+
if (start < 0 || end < 0 || start > end) return null;
|
|
39
|
+
return { start, end };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Where each exchange begins: a user prompt. Rows before the first prompt are an exchange of their own. */
|
|
43
|
+
export function exchangeStarts(rows: RangeRow[]): number[] {
|
|
44
|
+
const starts = rows.flatMap((r, i) => (r.type === "user" ? [i] : []));
|
|
45
|
+
if (rows.length && starts[0] !== 0) starts.unshift(0);
|
|
46
|
+
return starts;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const startOfDay = (now: Date) =>
|
|
50
|
+
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
51
|
+
|
|
52
|
+
/** The range a preset means, or null when it has nothing to pick (no messages today). */
|
|
53
|
+
export function presetRange(
|
|
54
|
+
rows: RangeRow[],
|
|
55
|
+
preset: RangePreset,
|
|
56
|
+
now: Date
|
|
57
|
+
): ShareRange | null {
|
|
58
|
+
if (!rows.length) return null;
|
|
59
|
+
const last = rows[rows.length - 1].id;
|
|
60
|
+
const starts = exchangeStarts(rows);
|
|
61
|
+
let from: number;
|
|
62
|
+
switch (preset) {
|
|
63
|
+
case "last":
|
|
64
|
+
from = starts[starts.length - 1];
|
|
65
|
+
break;
|
|
66
|
+
case "last5":
|
|
67
|
+
from = starts[Math.max(0, starts.length - 5)];
|
|
68
|
+
break;
|
|
69
|
+
case "all":
|
|
70
|
+
from = 0;
|
|
71
|
+
break;
|
|
72
|
+
case "today": {
|
|
73
|
+
const day = startOfDay(now);
|
|
74
|
+
const todays = rows
|
|
75
|
+
.map((r, i) => ({ r, i }))
|
|
76
|
+
.filter(({ r }) => (r.createdAt ?? 0) >= day);
|
|
77
|
+
if (!todays.length) return null;
|
|
78
|
+
from = (todays.find(({ r }) => r.type === "user") ?? todays[0]).i;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return { fromId: rows[from].id, toId: last };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The preset a range equals, or null for a custom one. */
|
|
86
|
+
export function activePreset(
|
|
87
|
+
rows: RangeRow[],
|
|
88
|
+
range: ShareRange | null,
|
|
89
|
+
now: Date
|
|
90
|
+
): RangePreset | null {
|
|
91
|
+
if (!range) return null;
|
|
92
|
+
return (
|
|
93
|
+
PRESETS.find((p) => {
|
|
94
|
+
const r = presetRange(rows, p.id, now);
|
|
95
|
+
return r && r.fromId === range.fromId && r.toId === range.toId;
|
|
96
|
+
})?.id ?? null
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** From a message to the latest one. */
|
|
101
|
+
export function rangeFromHere(rows: RangeRow[], id: string): ShareRange | null {
|
|
102
|
+
return rows.some((r) => r.id === id)
|
|
103
|
+
? { fromId: id, toId: rows[rows.length - 1].id }
|
|
104
|
+
: null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A click sets the range to one message; shift-click stretches it from the anchor to the clicked one, either direction. */
|
|
108
|
+
export function clickRange(
|
|
109
|
+
rows: RangeRow[],
|
|
110
|
+
anchorId: string | null,
|
|
111
|
+
clickedId: string,
|
|
112
|
+
extend: boolean
|
|
113
|
+
): ShareRange {
|
|
114
|
+
const a = extend && anchorId ? rows.findIndex((r) => r.id === anchorId) : -1;
|
|
115
|
+
const b = rows.findIndex((r) => r.id === clickedId);
|
|
116
|
+
if (a < 0 || b < 0) return { fromId: clickedId, toId: clickedId };
|
|
117
|
+
const [lo, hi] = a <= b ? [a, b] : [b, a];
|
|
118
|
+
return { fromId: rows[lo].id, toId: rows[hi].id };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Rows the range covers (all of them when there is none yet). */
|
|
122
|
+
export function inRange<T extends RangeRow>(
|
|
123
|
+
rows: T[],
|
|
124
|
+
range: ShareRange | null
|
|
125
|
+
): T[] {
|
|
126
|
+
if (!range) return rows;
|
|
127
|
+
const w = windowOf(
|
|
128
|
+
rows.map((r) => r.id),
|
|
129
|
+
range.fromId,
|
|
130
|
+
range.toId
|
|
131
|
+
);
|
|
132
|
+
return w ? rows.slice(w.start, w.end + 1) : [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const clock = (d: Date) => {
|
|
136
|
+
const h = d.getHours();
|
|
137
|
+
const m = String(d.getMinutes()).padStart(2, "0");
|
|
138
|
+
return { text: `${h % 12 || 12}:${m}`, ampm: h < 12 ? "AM" : "PM" };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const sameDay = (a: Date, b: Date) =>
|
|
142
|
+
a.getFullYear() === b.getFullYear() &&
|
|
143
|
+
a.getMonth() === b.getMonth() &&
|
|
144
|
+
a.getDate() === b.getDate();
|
|
145
|
+
|
|
146
|
+
const MONTHS = [
|
|
147
|
+
"Jan",
|
|
148
|
+
"Feb",
|
|
149
|
+
"Mar",
|
|
150
|
+
"Apr",
|
|
151
|
+
"May",
|
|
152
|
+
"Jun",
|
|
153
|
+
"Jul",
|
|
154
|
+
"Aug",
|
|
155
|
+
"Sep",
|
|
156
|
+
"Oct",
|
|
157
|
+
"Nov",
|
|
158
|
+
"Dec",
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
const dayLabel = (d: Date) => `${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
|
162
|
+
|
|
163
|
+
/** "2:14–2:40 PM", "11:50 AM–12:10 PM", or with dates when it crosses a day. */
|
|
164
|
+
export function timeSpan(from: number, to: number): string {
|
|
165
|
+
const a = new Date(from);
|
|
166
|
+
const b = new Date(to);
|
|
167
|
+
const ca = clock(a);
|
|
168
|
+
const cb = clock(b);
|
|
169
|
+
if (!sameDay(a, b))
|
|
170
|
+
return `${dayLabel(a)}, ${ca.text} ${ca.ampm} – ${dayLabel(b)}, ${cb.text} ${cb.ampm}`;
|
|
171
|
+
if (ca.text === cb.text && ca.ampm === cb.ampm)
|
|
172
|
+
return `${ca.text} ${ca.ampm}`;
|
|
173
|
+
return ca.ampm === cb.ampm
|
|
174
|
+
? `${ca.text}–${cb.text} ${cb.ampm}`
|
|
175
|
+
: `${ca.text} ${ca.ampm}–${cb.text} ${cb.ampm}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** "Sharing 6 of 120 messages · 2:14–2:40 PM". */
|
|
179
|
+
export function rangeSummary(total: number, shared: RangeRow[]): string {
|
|
180
|
+
if (!shared.length) return "Nothing selected to share";
|
|
181
|
+
const noun = total === 1 ? "message" : "messages";
|
|
182
|
+
const times = shared
|
|
183
|
+
.map((r) => r.createdAt)
|
|
184
|
+
.filter((t): t is number => typeof t === "number" && t > 0);
|
|
185
|
+
const base = `Sharing ${shared.length} of ${total} ${noun}`;
|
|
186
|
+
return times.length
|
|
187
|
+
? `${base} · ${timeSpan(Math.min(...times), Math.max(...times))}`
|
|
188
|
+
: base;
|
|
189
|
+
}
|
package/lib/share/redact.ts
CHANGED
|
@@ -18,6 +18,15 @@ import {
|
|
|
18
18
|
BASE64_RUN,
|
|
19
19
|
looksRandom,
|
|
20
20
|
} from "@/lib/orchestrator/untrusted";
|
|
21
|
+
import {
|
|
22
|
+
BASIC_AUTH,
|
|
23
|
+
CLI_FLAG,
|
|
24
|
+
DATA_URI,
|
|
25
|
+
KEY_VALUE,
|
|
26
|
+
MYSQL_P,
|
|
27
|
+
TOKEN_RUN,
|
|
28
|
+
isHighEntropyToken,
|
|
29
|
+
} from "./secret-patterns";
|
|
21
30
|
import { HOME } from "@/lib/session-audit/normalize";
|
|
22
31
|
|
|
23
32
|
export type Kind = "secret" | "path" | "ip" | "host" | "email";
|
|
@@ -59,7 +68,8 @@ const SECRET = "[secret]";
|
|
|
59
68
|
const BEARER = /(\bbearer\s+)(?!\[secret\])[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
60
69
|
const INLINE_ASSIGNMENT =
|
|
61
70
|
/(\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS|PWD|CREDENTIALS?|AUTH)[A-Z0-9_]*=)(?!\[secret\])[^\s'"]+/g;
|
|
62
|
-
|
|
71
|
+
// Up to 64 hex characters is a git sha or a hash, not a key.
|
|
72
|
+
const LONG_HEX = /\b[a-fA-F0-9]{65,}\b/g;
|
|
63
73
|
|
|
64
74
|
const IPV4 =
|
|
65
75
|
/(?<![\w.])(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)(?![\w.]*\w)/g;
|
|
@@ -104,6 +114,10 @@ export function redactForShare(
|
|
|
104
114
|
let t = input;
|
|
105
115
|
const on = (k: Kind) => !opts.only || opts.only.includes(k);
|
|
106
116
|
|
|
117
|
+
// Inline base64 (images) is content, not a credential: set aside, put back.
|
|
118
|
+
const uris: string[] = [];
|
|
119
|
+
t = t.replace(DATA_URI, (m) => `\u0000${uris.push(m) - 1}\u0000`);
|
|
120
|
+
|
|
107
121
|
// 1. Credentials by shape.
|
|
108
122
|
t = sub(t, PRIVATE_KEY_BLOCK, SECRET, hits, "secret");
|
|
109
123
|
t = sub(
|
|
@@ -132,9 +146,16 @@ export function redactForShare(
|
|
|
132
146
|
"secret",
|
|
133
147
|
(m) => m.endsWith(SECRET)
|
|
134
148
|
);
|
|
149
|
+
const keepValue = (_m: string, k: string, v: string) =>
|
|
150
|
+
`${k}${/^["']/.test(v) ? v[0] + SECRET + v[0] : SECRET}`;
|
|
151
|
+
t = sub(t, KEY_VALUE, keepValue, hits, "secret");
|
|
152
|
+
t = sub(t, CLI_FLAG, keepValue, hits, "secret");
|
|
153
|
+
t = sub(t, MYSQL_P, (_m, k) => `${k}${SECRET}`, hits, "secret");
|
|
154
|
+
t = sub(t, BASIC_AUTH, (_m, k) => `${k}${SECRET}`, hits, "secret");
|
|
135
155
|
for (const re of SECRET_TOKENS) t = sub(t, re, SECRET, hits, "secret");
|
|
136
156
|
t = sub(t, LONG_HEX, SECRET, hits, "secret");
|
|
137
157
|
t = sub(t, BASE64_RUN, SECRET, hits, "secret", (m) => !looksRandom(m));
|
|
158
|
+
t = sub(t, TOKEN_RUN, SECRET, hits, "secret", (m) => !isHighEntropyToken(m));
|
|
138
159
|
|
|
139
160
|
// 2. This machine's own secrets, wherever they appear.
|
|
140
161
|
for (const value of ctx.secrets) {
|
|
@@ -192,5 +213,6 @@ export function redactForShare(
|
|
|
192
213
|
// 6. Email addresses.
|
|
193
214
|
if (on("email")) t = sub(t, EMAIL, "[email]", hits, "email");
|
|
194
215
|
|
|
216
|
+
t = t.replace(/\u0000(\d+)\u0000/g, (_m, i) => uris[Number(i)]);
|
|
195
217
|
return { text: t, hits };
|
|
196
218
|
}
|
package/lib/share/render.tsx
CHANGED
|
@@ -14,6 +14,7 @@ import remarkGfm from "remark-gfm";
|
|
|
14
14
|
import { Prism as Highlighter } from "react-syntax-highlighter";
|
|
15
15
|
import type { Snapshot, SnapshotItem } from "./snapshot";
|
|
16
16
|
import { STYLES } from "./styles";
|
|
17
|
+
import { EARLIER_NOT_SHARED, LATER_NOT_SHARED } from "./range";
|
|
17
18
|
|
|
18
19
|
export const MAX_SHARE_BYTES = 5 * 1024 * 1024;
|
|
19
20
|
|
|
@@ -123,9 +124,11 @@ export function renderChatSnapshot(
|
|
|
123
124
|
): string {
|
|
124
125
|
const body = renderToStaticMarkup(
|
|
125
126
|
<>
|
|
127
|
+
{snapshot.cutBefore && <div className="cut">{EARLIER_NOT_SHARED}</div>}
|
|
126
128
|
{snapshot.items.map((item) => (
|
|
127
129
|
<Item key={item.id} item={item} />
|
|
128
130
|
))}
|
|
131
|
+
{snapshot.cutAfter && <div className="cut">{LATER_NOT_SHARED}</div>}
|
|
129
132
|
</>
|
|
130
133
|
);
|
|
131
134
|
const date = opts.sharedAt.slice(0, 10);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic credential shapes for redactForShare: key/value secrets in any
|
|
3
|
+
* syntax, Basic auth headers, mysql -p, and high-entropy tokens. Pure data
|
|
4
|
+
* and predicates; redact.ts applies them and counts the hits.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const WORDS =
|
|
8
|
+
"password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|client[_-]?secret|auth|private[_-]?key";
|
|
9
|
+
|
|
10
|
+
// A value that is a type, a placeholder or a call, not a credential.
|
|
11
|
+
const NOT_A_VALUE =
|
|
12
|
+
"(?![\"']?\\[secret\\])" +
|
|
13
|
+
"(?!(?:string|number|boolean|any|unknown|null|undefined|true|false|none|void|required|optional)\\b)" +
|
|
14
|
+
"(?![$<{(\\[%]|\\.\\.\\.)(?![\\w.]+\\()";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `password=x`, `"api_key": "x"`, `token: x`, `export DB_PASSWORD=x`,
|
|
18
|
+
* `--password=x`. The key may carry any prefix (GITHUB_TOKEN) but must end
|
|
19
|
+
* in the word, so `author` and `max_tokens` stay. Group 1 is kept.
|
|
20
|
+
*/
|
|
21
|
+
export const KEY_VALUE = new RegExp(
|
|
22
|
+
`((?<![\\w.-])[\\w.-]*(?:${WORDS})["']?[ \\t]*[:=][ \\t]*)` +
|
|
23
|
+
`${NOT_A_VALUE}("[^"\\n]*"|'[^'\\n]*'|[^\\s,;'"&}\\])]+)`,
|
|
24
|
+
"gi"
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
/** `--password value`, `--api-key value` (space separated). */
|
|
28
|
+
export const CLI_FLAG = new RegExp(
|
|
29
|
+
`(--[\\w-]*?(?:${WORDS})[ \\t]+)(?!-)${NOT_A_VALUE}("[^"\\n]*"|'[^'\\n]*'|\\S+)`,
|
|
30
|
+
"gi"
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* mysql-family `-pSECRET` (attached: `-p secret` would be a database name).
|
|
35
|
+
* psql's -p is a port, so it is left alone.
|
|
36
|
+
*/
|
|
37
|
+
export const MYSQL_P =
|
|
38
|
+
/(\b(?:mysql|mysqldump|mysqladmin|mariadb)\b[^\n|;&]*?\s-p)(?=\S)(\S+)/gi;
|
|
39
|
+
|
|
40
|
+
/** `Authorization: Basic <x>` (Bearer is handled in redact.ts). */
|
|
41
|
+
export const BASIC_AUTH =
|
|
42
|
+
/(\bauthorization["']?\s*[:=]\s*["']?basic\s+)[A-Za-z0-9+/=._~-]{4,}/gi;
|
|
43
|
+
|
|
44
|
+
/** A base64 data URI, such as an inline image. Kept as is: not a credential. */
|
|
45
|
+
export const DATA_URI =
|
|
46
|
+
/data:[\w.+-]+\/[\w.+-]+(?:;[\w=.-]+)*;base64,[A-Za-z0-9+/=_-]+/g;
|
|
47
|
+
|
|
48
|
+
/** Candidate runs for the entropy test. */
|
|
49
|
+
export const TOKEN_RUN = /(?<![A-Za-z0-9_\-+/=])[A-Za-z0-9_\-+/=]{32,}/g;
|
|
50
|
+
|
|
51
|
+
export const ENTROPY_MIN = 4.0;
|
|
52
|
+
|
|
53
|
+
export function shannon(s: string): number {
|
|
54
|
+
const counts = new Map<string, number>();
|
|
55
|
+
for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
56
|
+
let h = 0;
|
|
57
|
+
for (const n of counts.values())
|
|
58
|
+
h -= (n / s.length) * Math.log2(n / s.length);
|
|
59
|
+
return h;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
63
|
+
|
|
64
|
+
function looksLikePath(s: string): boolean {
|
|
65
|
+
if (/^(?:\.{0,2}\/|~\/)/.test(s)) return true;
|
|
66
|
+
const parts = s.split("/");
|
|
67
|
+
return parts.length > 2 && parts.every((p) => p.length >= 2);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** True when a run is a random-looking token rather than prose, a hash or a path. */
|
|
71
|
+
export function isHighEntropyToken(s: string): boolean {
|
|
72
|
+
if (/^[0-9a-f]+$/i.test(s) && s.length <= 64) return false; // git sha, md5/sha256
|
|
73
|
+
if (UUID.test(s)) return false;
|
|
74
|
+
if (looksLikePath(s)) return false;
|
|
75
|
+
if (!/[a-z]/.test(s) || !/[A-Z]/.test(s) || !/[0-9]/.test(s)) return false;
|
|
76
|
+
return shannon(s) > ENTROPY_MIN;
|
|
77
|
+
}
|
package/lib/share/service.ts
CHANGED
|
@@ -16,7 +16,11 @@ import {
|
|
|
16
16
|
renderChatSnapshot,
|
|
17
17
|
} from "./render";
|
|
18
18
|
import { artifactToMarkdown, chatToMarkdown } from "./markdown";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
RangeGone,
|
|
21
|
+
buildArtifactSnapshot,
|
|
22
|
+
buildChatSnapshot,
|
|
23
|
+
} from "./snapshot";
|
|
20
24
|
|
|
21
25
|
export interface ShareRequest {
|
|
22
26
|
kind: "chat" | "artifact";
|
|
@@ -25,6 +29,9 @@ export interface ShareRequest {
|
|
|
25
29
|
includeTools: boolean;
|
|
26
30
|
/** Messages the reader left out in the preview. */
|
|
27
31
|
dropIds?: string[];
|
|
32
|
+
/** The part of the chat shared: from this message to that one, ends included. */
|
|
33
|
+
fromId?: string;
|
|
34
|
+
toId?: string;
|
|
28
35
|
follow?: "latest";
|
|
29
36
|
sharedAt: string;
|
|
30
37
|
}
|
|
@@ -34,6 +41,7 @@ export interface ItemRow {
|
|
|
34
41
|
type: string;
|
|
35
42
|
tools: boolean;
|
|
36
43
|
excerpt: string;
|
|
44
|
+
createdAt?: number;
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
export interface Page {
|
|
@@ -87,6 +95,15 @@ export function parseRequest(raw: unknown, sharedAt?: unknown): ShareRequest {
|
|
|
87
95
|
throw new BadShare("dropIds must be a list of ids", 400);
|
|
88
96
|
dropIds = b.dropIds as string[];
|
|
89
97
|
}
|
|
98
|
+
const end = (key: "fromId" | "toId") => {
|
|
99
|
+
const v = b[key];
|
|
100
|
+
if (v === undefined) return undefined;
|
|
101
|
+
if (typeof v !== "string" || !v || v.length > 200)
|
|
102
|
+
throw new BadShare(`${key} must be a message id`, 400);
|
|
103
|
+
return v;
|
|
104
|
+
};
|
|
105
|
+
const fromId = end("fromId");
|
|
106
|
+
const toId = end("toId");
|
|
90
107
|
if (b.follow !== undefined && b.follow !== "latest")
|
|
91
108
|
throw new BadShare("follow must be latest", 400);
|
|
92
109
|
if (b.follow && kind !== "artifact")
|
|
@@ -100,11 +117,41 @@ export function parseRequest(raw: unknown, sharedAt?: unknown): ShareRequest {
|
|
|
100
117
|
artifactId,
|
|
101
118
|
includeTools: b.includeTools === true,
|
|
102
119
|
dropIds,
|
|
120
|
+
fromId,
|
|
121
|
+
toId,
|
|
103
122
|
follow: b.follow === "latest" ? "latest" : undefined,
|
|
104
123
|
sharedAt: at,
|
|
105
124
|
};
|
|
106
125
|
}
|
|
107
126
|
|
|
127
|
+
const rowOf = ({ id, type, tools, excerpt, createdAt }: ItemRow): ItemRow => ({
|
|
128
|
+
id,
|
|
129
|
+
type,
|
|
130
|
+
tools,
|
|
131
|
+
excerpt,
|
|
132
|
+
createdAt,
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
function chatSession(sessionId: string) {
|
|
136
|
+
let session;
|
|
137
|
+
try {
|
|
138
|
+
session = getSession(sessionId);
|
|
139
|
+
} catch {
|
|
140
|
+
throw new BadShare("No such chat", 404);
|
|
141
|
+
}
|
|
142
|
+
if (session.view !== "chat")
|
|
143
|
+
throw new BadShare("Only chats can be shared", 400);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Every shareable message of a chat, tool rows included, in order. */
|
|
147
|
+
export async function listChatItems(req: ShareRequest): Promise<ItemRow[]> {
|
|
148
|
+
chatSession(req.sessionId!);
|
|
149
|
+
const all = await buildChatSnapshot(req.sessionId!, shareContext(), {
|
|
150
|
+
includeTools: true,
|
|
151
|
+
});
|
|
152
|
+
return all.items.map(rowOf);
|
|
153
|
+
}
|
|
154
|
+
|
|
108
155
|
const sha256 = (s: string) => createHash("sha256").update(s).digest("hex");
|
|
109
156
|
|
|
110
157
|
/** Binds both parts: neither can change without the hash changing. */
|
|
@@ -120,29 +167,27 @@ export async function buildPage(req: ShareRequest): Promise<Page> {
|
|
|
120
167
|
let items: ItemRow[] = [];
|
|
121
168
|
|
|
122
169
|
if (req.kind === "chat") {
|
|
123
|
-
|
|
124
|
-
try {
|
|
125
|
-
session = getSession(req.sessionId!);
|
|
126
|
-
} catch {
|
|
127
|
-
throw new BadShare("No such chat", 404);
|
|
128
|
-
}
|
|
129
|
-
if (session.view !== "chat")
|
|
130
|
-
throw new BadShare("Only chats can be shared", 400);
|
|
170
|
+
chatSession(req.sessionId!);
|
|
131
171
|
const all = await buildChatSnapshot(req.sessionId!, ctx, {
|
|
132
172
|
includeTools: req.includeTools,
|
|
133
173
|
});
|
|
134
|
-
items = all.items.map(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
? await buildChatSnapshot(req.sessionId!, ctx, {
|
|
174
|
+
items = all.items.map(rowOf);
|
|
175
|
+
const narrowed =
|
|
176
|
+
req.dropIds?.length || req.fromId !== undefined || req.toId !== undefined;
|
|
177
|
+
let snap = all;
|
|
178
|
+
if (narrowed) {
|
|
179
|
+
try {
|
|
180
|
+
snap = await buildChatSnapshot(req.sessionId!, ctx, {
|
|
142
181
|
includeTools: req.includeTools,
|
|
143
182
|
dropIds: req.dropIds,
|
|
144
|
-
|
|
145
|
-
|
|
183
|
+
fromId: req.fromId,
|
|
184
|
+
toId: req.toId,
|
|
185
|
+
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
if (err instanceof RangeGone) throw new BadShare(err.message, 409);
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
146
191
|
if (!snap.items.length)
|
|
147
192
|
throw new BadShare("Nothing to share: every message is left out", 400);
|
|
148
193
|
html = renderChatSnapshot(snap, { sharedAt: req.sharedAt });
|
package/lib/share/snapshot.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
type Hits,
|
|
16
16
|
type ShareContext,
|
|
17
17
|
} from "./redact";
|
|
18
|
+
import { windowOf } from "./range";
|
|
18
19
|
|
|
19
20
|
export type SnapshotType =
|
|
20
21
|
"user" | "assistant" | "plan" | "error" | "divider" | "artifact" | "detail";
|
|
@@ -32,6 +33,7 @@ export interface SnapshotItem {
|
|
|
32
33
|
excerpt: string;
|
|
33
34
|
/** Hidden unless "include tool calls and thinking" is on. */
|
|
34
35
|
tools: boolean;
|
|
36
|
+
createdAt?: number;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
export interface Snapshot {
|
|
@@ -39,9 +41,19 @@ export interface Snapshot {
|
|
|
39
41
|
items: SnapshotItem[];
|
|
40
42
|
/** Whether the tools group was left out, to say so on the page. */
|
|
41
43
|
toolsHidden: boolean;
|
|
44
|
+
/** Messages before / after the shared range were left out, to say so. */
|
|
45
|
+
cutBefore?: boolean;
|
|
46
|
+
cutAfter?: boolean;
|
|
42
47
|
hits: Hits;
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
/** The range names a message that is no longer in the chat. */
|
|
51
|
+
export class RangeGone extends Error {
|
|
52
|
+
constructor() {
|
|
53
|
+
super("That part of the chat changed; pick the range again.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
export const MAX_TOOL_INPUT = 4_000;
|
|
46
58
|
export const MAX_TOOL_OUTPUT = 20_000;
|
|
47
59
|
export const HIDDEN_FILE = "[contents of a secret file hidden]";
|
|
@@ -189,7 +201,12 @@ export function toRaw(item: ChatItem): Raw | null {
|
|
|
189
201
|
export async function buildChatSnapshot(
|
|
190
202
|
sessionId: string,
|
|
191
203
|
ctx: ShareContext,
|
|
192
|
-
opts: {
|
|
204
|
+
opts: {
|
|
205
|
+
includeTools: boolean;
|
|
206
|
+
dropIds?: string[];
|
|
207
|
+
fromId?: string;
|
|
208
|
+
toId?: string;
|
|
209
|
+
}
|
|
193
210
|
): Promise<Snapshot> {
|
|
194
211
|
const session = getSession(sessionId);
|
|
195
212
|
const dropped = new Set(opts.dropIds ?? []);
|
|
@@ -201,13 +218,31 @@ export async function buildChatSnapshot(
|
|
|
201
218
|
};
|
|
202
219
|
|
|
203
220
|
const items: SnapshotItem[] = [];
|
|
204
|
-
|
|
221
|
+
const all = visible(listItems(sessionId));
|
|
222
|
+
let window: { start: number; end: number } | null = null;
|
|
223
|
+
if (opts.fromId !== undefined || opts.toId !== undefined) {
|
|
224
|
+
window = windowOf(
|
|
225
|
+
all.map((i) => i.id),
|
|
226
|
+
opts.fromId,
|
|
227
|
+
opts.toId
|
|
228
|
+
);
|
|
229
|
+
if (!window) throw new RangeGone();
|
|
230
|
+
}
|
|
231
|
+
let cutBefore = false;
|
|
232
|
+
let cutAfter = false;
|
|
233
|
+
for (const [at, item] of all.entries()) {
|
|
205
234
|
const raw = toRaw(item);
|
|
206
235
|
if (!raw) continue;
|
|
207
236
|
if (raw.tools && !opts.includeTools) continue;
|
|
237
|
+
if (window && (at < window.start || at > window.end)) {
|
|
238
|
+
if (at < window.start) cutBefore = true;
|
|
239
|
+
else cutAfter = true;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
208
242
|
if (dropped.has(raw.id)) continue;
|
|
209
243
|
const row: SnapshotItem = {
|
|
210
244
|
...raw,
|
|
245
|
+
createdAt: item.createdAt,
|
|
211
246
|
label: raw.label === undefined ? undefined : take(raw.label),
|
|
212
247
|
text: take(raw.text),
|
|
213
248
|
excerpt: "",
|
|
@@ -225,6 +260,8 @@ export async function buildChatSnapshot(
|
|
|
225
260
|
title: take(session.name).slice(0, 120) || "Chat",
|
|
226
261
|
items,
|
|
227
262
|
toolsHidden: !opts.includeTools,
|
|
263
|
+
cutBefore,
|
|
264
|
+
cutAfter,
|
|
228
265
|
hits,
|
|
229
266
|
};
|
|
230
267
|
}
|
package/lib/share/styles.ts
CHANGED
|
@@ -56,6 +56,8 @@ h1.title{margin:0 0 4px;font-size:20px;line-height:1.3}
|
|
|
56
56
|
.plan>.kicker{margin-top:12px}
|
|
57
57
|
.kicker{font-size:12px;font-weight:600;color:hsl(var(--muted-foreground));text-transform:uppercase;letter-spacing:.04em}
|
|
58
58
|
.error{color:hsl(var(--destructive));border:1px solid hsl(var(--destructive)/.4);border-radius:10px;padding:8px 14px;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
59
|
+
.cut{display:flex;align-items:center;gap:12px;color:hsl(var(--muted-foreground));font-size:12px;font-style:italic}
|
|
60
|
+
.cut::before,.cut::after{content:"";flex:1;border-top:1px dashed hsl(var(--border))}
|
|
59
61
|
.divider{display:flex;align-items:center;gap:12px;color:hsl(var(--muted-foreground));font-size:12px}
|
|
60
62
|
.divider::before,.divider::after{content:"";flex:1;border-top:1px solid hsl(var(--border))}
|
|
61
63
|
details.tool{border:1px solid hsl(var(--border));border-radius:10px;background:hsl(var(--card))}
|
package/lib/share/view.ts
CHANGED
|
@@ -15,6 +15,11 @@ export interface PreviewRequest extends ShareTarget {
|
|
|
15
15
|
includeTools: boolean;
|
|
16
16
|
/** Messages left out of the share. */
|
|
17
17
|
dropIds?: string[];
|
|
18
|
+
/** Only the messages from this one to `toId`, both included; either end open when absent. */
|
|
19
|
+
fromId?: string;
|
|
20
|
+
toId?: string;
|
|
21
|
+
/** Just the list of messages, no page: what the range picker needs first. */
|
|
22
|
+
listOnly?: boolean;
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
export interface PreviewItem {
|
|
@@ -22,11 +27,17 @@ export interface PreviewItem {
|
|
|
22
27
|
type: string;
|
|
23
28
|
tools: boolean;
|
|
24
29
|
excerpt: string;
|
|
30
|
+
createdAt?: number;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
/** Download formats a server can make; the client lists what it is told. */
|
|
28
34
|
export type ShareFormat = "pdf" | "html" | "md";
|
|
29
35
|
|
|
36
|
+
export interface MessageList {
|
|
37
|
+
/** Every shareable message in order, tool rows included. */
|
|
38
|
+
items: PreviewItem[];
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
export interface SharePreview {
|
|
31
42
|
html: string;
|
|
32
43
|
markdown: string;
|
package/lib/sidebar/row-menu.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type RowMenuItem =
|
|
|
17
17
|
| "copy-link"
|
|
18
18
|
| "share"
|
|
19
19
|
| "export"
|
|
20
|
+
| "invite-live"
|
|
20
21
|
| "fork"
|
|
21
22
|
| "fresh-start"
|
|
22
23
|
| "new-conversation"
|
|
@@ -127,6 +128,7 @@ export function rowMenu(input: RowMenuInput): RowMenuPlan {
|
|
|
127
128
|
...group("share", [
|
|
128
129
|
"copy-link",
|
|
129
130
|
s.view === "chat" && "share",
|
|
131
|
+
s.view === "chat" && "invite-live",
|
|
130
132
|
s.view === "chat" && "export",
|
|
131
133
|
]),
|
|
132
134
|
...group("restart", [
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
import { lanEnabled } from "./lib/security/network-settings";
|
|
55
55
|
import { handlePeerRpc, isPeerRpc, rpcDeps } from "./lib/peer/rpc-server";
|
|
56
56
|
import { startConnect } from "./lib/connect/serve";
|
|
57
|
+
import { startLive } from "./lib/live/service";
|
|
57
58
|
import { serverPort } from "./lib/connect/config";
|
|
58
59
|
import { startUpdateChecks, updateChecksEnabled } from "./lib/updates/check";
|
|
59
60
|
import {
|
|
@@ -399,6 +400,7 @@ app.prepare().then(async () => {
|
|
|
399
400
|
? { hostname: () => null }
|
|
400
401
|
: startConnect({ onRequest, onUpgrade }, port);
|
|
401
402
|
policy.machineHost = () => connect.hostname();
|
|
403
|
+
if (!demo) startLive();
|
|
402
404
|
// HTTPS on the tailnet (its own port) for passkeys and other secure-origin APIs.
|
|
403
405
|
if (
|
|
404
406
|
!demo &&
|
package/stores/liveUi.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { proxy } from "valtio";
|
|
2
|
+
|
|
3
|
+
// The invite dialog: which chat it is open on, started from a menu.
|
|
4
|
+
export const liveUi = proxy<{
|
|
5
|
+
target: { sessionId: string; title: string } | null;
|
|
6
|
+
}>({
|
|
7
|
+
target: null,
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const liveUiActions = {
|
|
11
|
+
open: (sessionId: string, title: string) => {
|
|
12
|
+
liveUi.target = { sessionId, title };
|
|
13
|
+
},
|
|
14
|
+
close: () => {
|
|
15
|
+
liveUi.target = null;
|
|
16
|
+
},
|
|
17
|
+
};
|