visualproof 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/README.md +99 -2
- package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
- package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
- package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
- package/dist/assets/latex/visualproof.sty +308 -0
- package/dist/extractor/Extract.lean +1180 -0
- package/dist/extractor/Usage.lean +900 -0
- package/dist/mcp/visualproof-mcp.mjs +476 -0
- package/dist/server/access.js +95 -0
- package/dist/server/agent.js +3235 -0
- package/dist/server/agentdocs.js +35 -0
- package/dist/server/agentlibrary.js +202 -0
- package/dist/server/agentnotes.js +701 -0
- package/dist/server/agentviews.js +703 -0
- package/dist/server/askfollow.js +286 -0
- package/dist/server/boardspots.js +159 -0
- package/dist/server/dirs.js +175 -0
- package/dist/server/doctor.js +186 -0
- package/dist/server/events.js +198 -0
- package/dist/server/export.js +420 -0
- package/dist/server/extract.js +204 -0
- package/dist/server/fetchurl.js +321 -0
- package/dist/server/figures.js +158 -0
- package/dist/server/history.js +741 -0
- package/dist/server/historyapi.js +304 -0
- package/dist/server/hover.js +595 -0
- package/dist/server/identity.js +114 -0
- package/dist/server/invites.js +241 -0
- package/dist/server/layoutthread.js +51 -0
- package/dist/server/library.js +600 -0
- package/dist/server/libraryroute.js +211 -0
- package/dist/server/live.js +1101 -0
- package/dist/server/lostfound.js +199 -0
- package/dist/server/main.js +1394 -0
- package/dist/server/mcptools.js +497 -0
- package/dist/server/pastdoc.js +73 -0
- package/dist/server/pipeline.js +156 -0
- package/dist/server/poppler.js +251 -0
- package/dist/server/presence.js +252 -0
- package/dist/server/project.js +222 -0
- package/dist/server/prompt.js +53 -0
- package/dist/server/readonly.js +152 -0
- package/dist/server/readonlygate.js +123 -0
- package/dist/server/renames.js +268 -0
- package/dist/server/review.js +334 -0
- package/dist/server/reviewapi.js +188 -0
- package/dist/server/snapshot.js +49 -0
- package/dist/server/sourcestore.js +473 -0
- package/dist/server/status.js +360 -0
- package/dist/server/talk.js +476 -0
- package/dist/server/tokens.js +221 -0
- package/dist/server/usage.js +501 -0
- package/dist/server/versions.js +82 -0
- package/dist/server/view.js +157 -0
- package/dist/server/views.js +1167 -0
- package/dist/shared/askfollow.js +46 -0
- package/dist/shared/board.js +332 -0
- package/dist/shared/build.js +2 -0
- package/dist/shared/collab.js +148 -0
- package/dist/shared/comment.js +285 -0
- package/dist/shared/context.js +157 -0
- package/dist/shared/doc/authors.js +98 -0
- package/dist/shared/doc/build.js +358 -0
- package/dist/shared/doc/latex.js +455 -0
- package/dist/shared/doc/markdown.js +159 -0
- package/dist/shared/doc/model.js +37 -0
- package/dist/shared/doc/order.js +107 -0
- package/dist/shared/doc/proof.js +272 -0
- package/dist/shared/doc/sections.js +93 -0
- package/dist/shared/doc/settings.js +75 -0
- package/dist/shared/edgecodec.js +102 -0
- package/dist/shared/editor.js +64 -0
- package/dist/shared/excerpt.js +87 -0
- package/dist/shared/graph.js +16 -0
- package/dist/shared/items.js +92 -0
- package/dist/shared/keys.js +203 -0
- package/dist/shared/layout.js +926 -0
- package/dist/shared/lexer.js +130 -0
- package/dist/shared/library.js +711 -0
- package/dist/shared/lostfound.js +150 -0
- package/dist/shared/moves.js +243 -0
- package/dist/shared/notecard.js +295 -0
- package/dist/shared/noteplace.js +505 -0
- package/dist/shared/palette.js +46 -0
- package/dist/shared/pastgraph.js +95 -0
- package/dist/shared/places.js +80 -0
- package/dist/shared/projectnotes.js +244 -0
- package/dist/shared/query.js +810 -0
- package/dist/shared/review.js +642 -0
- package/dist/shared/reviewqueue.js +333 -0
- package/dist/shared/ring.js +366 -0
- package/dist/shared/scope.js +255 -0
- package/dist/shared/scopeedit.js +81 -0
- package/dist/shared/search.js +191 -0
- package/dist/shared/separate.js +178 -0
- package/dist/shared/sharesnap.js +280 -0
- package/dist/shared/sticky.js +501 -0
- package/dist/shared/timeline.js +73 -0
- package/dist/shared/titles.js +298 -0
- package/dist/shared/viewgraph.js +139 -0
- package/dist/shared/views.js +859 -0
- package/dist/web/bundle/app.js +48733 -0
- package/dist/web/bundle/edges.js +409 -0
- package/dist/web/bundle/layout.js +1010 -0
- package/dist/web/bundle/pdfworker.js +48322 -0
- package/dist/web/index.html +239 -0
- package/dist/web/src/motion.js +430 -0
- package/dist/web/src/past.js +224 -0
- package/dist/web/src/scene.js +787 -0
- package/dist/web/style.css +2312 -0
- package/docs/agent-api/README.md +91 -0
- package/docs/agent-api/advice.md +108 -0
- package/docs/agent-api/export.md +120 -0
- package/docs/agent-api/following.md +140 -0
- package/docs/agent-api/history.md +142 -0
- package/docs/agent-api/library.md +65 -0
- package/docs/agent-api/mcp.md +104 -0
- package/docs/agent-api/notes.md +93 -0
- package/docs/agent-api/reading.md +166 -0
- package/docs/agent-api/requests.md +94 -0
- package/docs/agent-api/talking.md +100 -0
- package/docs/agent-api/tcb.md +135 -0
- package/docs/agent-api/views.md +124 -0
- package/docs/agent-api/words.md +56 -0
- package/package.json +39 -4
- package/test/toolchains.json +5 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Agent tokens (AgentSpec decision 7): sharing with an AI agent hands it a credential of its own,
|
|
2
|
+
// never the server's key. A person makes one from Share for one agent; the token fixes the agent's
|
|
3
|
+
// name and what it may do, lasts a few days, reaches the agents' API only (server/main.ts), and can
|
|
4
|
+
// be revoked from Share by anyone with a name.
|
|
5
|
+
//
|
|
6
|
+
// A token is `vpa_<id>_<secret>`: the prefix makes one recognisable wherever it is pasted or leaks
|
|
7
|
+
// (a log, a config file), the id names it in lists (it is no secret), and the secret is 32 random
|
|
8
|
+
// bytes. The server keeps the secret, in `<site>/agent-tokens.json` (readable by its owner alone),
|
|
9
|
+
// so that Share can show a token's prompt again while the token is in force (decided 2026-10-08).
|
|
10
|
+
// That file is kept as the access key is, by the same owner on the same machine, and is worth less:
|
|
11
|
+
// whoever can read it can read the key, and whoever has the key can open the page and make a token
|
|
12
|
+
// of their own. Tokens made before the secret was kept (only test tokens) are dropped when the file
|
|
13
|
+
// is read.
|
|
14
|
+
//
|
|
15
|
+
// The identity is the token's, not the request's: the name is the one the person gave, "<their
|
|
16
|
+
// name>'s AI" unless they wrote another, and stays the same for the token's life, being the name
|
|
17
|
+
// its advice is logged under (shared/review.ts, logFileName). Among the tokens in force no two have
|
|
18
|
+
// the same name: a second "Alice's AI" is "Alice's AI 2".
|
|
19
|
+
//
|
|
20
|
+
// When a token was last used is kept in memory at each request and written with the rest at most
|
|
21
|
+
// once a minute (and when the server stops): no disk write per request.
|
|
22
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
23
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
import { MAX_NAME, uniqueName, validName } from "../shared/collab.js";
|
|
26
|
+
export const TOKEN_PREFIX = "vpa_";
|
|
27
|
+
/** how long a token may last, in days; DEFAULT_DAYS (7) is the one the page offers when none is said */
|
|
28
|
+
export const TOKEN_DAYS = [1, 7, 30];
|
|
29
|
+
export const DEFAULT_DAYS = 7;
|
|
30
|
+
/** a record of a token that ended (expired or revoked) is kept this long, for the Share window's
|
|
31
|
+
* list to say so and a conversation to be named, then forgotten */
|
|
32
|
+
const KEEP_ENDED_MS = 30 * 24 * 3600 * 1000;
|
|
33
|
+
/** lastUsed is written at most this often */
|
|
34
|
+
const FLUSH_MS = 60_000;
|
|
35
|
+
const FILE = "agent-tokens.json";
|
|
36
|
+
const ID = /^[0-9a-f]{10}$/;
|
|
37
|
+
const SECRET = /^[A-Za-z0-9_-]{43}$/;
|
|
38
|
+
const digest = (s) => createHash("sha256").update(s).digest();
|
|
39
|
+
/** Whether `s` has the shape of an agent token (to tell it from the server's key in a Bearer). */
|
|
40
|
+
export function looksLikeToken(s) {
|
|
41
|
+
return s.startsWith(TOKEN_PREFIX);
|
|
42
|
+
}
|
|
43
|
+
/** The bearer credential a request carries, or null. */
|
|
44
|
+
export function bearerOf(auth) {
|
|
45
|
+
if (!auth || !/^Bearer\s/i.test(auth))
|
|
46
|
+
return null;
|
|
47
|
+
return auth.replace(/^Bearer\s+/i, "").trim() || null;
|
|
48
|
+
}
|
|
49
|
+
/** The token itself, from what is kept of it. */
|
|
50
|
+
const tokenOf = (r) => `${TOKEN_PREFIX}${r.id}_${r.secret}`;
|
|
51
|
+
export class AgentTokens {
|
|
52
|
+
file;
|
|
53
|
+
records = [];
|
|
54
|
+
dirty = false;
|
|
55
|
+
timer = null;
|
|
56
|
+
/** told when a token ended (revoked, or found expired): its agent's wait ends (server/live.ts,
|
|
57
|
+
* server/talk.ts) */
|
|
58
|
+
onEnded = () => { };
|
|
59
|
+
/** told when the tokens in force changed (made, revoked, expired): presence lists them */
|
|
60
|
+
onChanged = () => { };
|
|
61
|
+
/** `siteDir` null keeps the tokens in memory only (tests). */
|
|
62
|
+
constructor(siteDir) {
|
|
63
|
+
this.file = siteDir ? join(siteDir, FILE) : null;
|
|
64
|
+
if (this.file && existsSync(this.file)) {
|
|
65
|
+
try {
|
|
66
|
+
const data = JSON.parse(readFileSync(this.file, "utf8"));
|
|
67
|
+
if (Array.isArray(data.tokens))
|
|
68
|
+
this.records = data.tokens.filter(isRecord);
|
|
69
|
+
// what was dropped (a token kept without its secret) leaves the file too
|
|
70
|
+
if (Array.isArray(data.tokens) && this.records.length < data.tokens.length)
|
|
71
|
+
this.dirty = true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// a file that cannot be read lets no token in; it is kept aside, not overwritten blindly
|
|
75
|
+
renameSync(this.file, `${this.file}.broken-${Date.now()}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
this.prune();
|
|
79
|
+
this.flush();
|
|
80
|
+
if (this.file) {
|
|
81
|
+
this.timer = setInterval(() => {
|
|
82
|
+
this.expire();
|
|
83
|
+
this.flush();
|
|
84
|
+
}, FLUSH_MS);
|
|
85
|
+
this.timer.unref?.();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Make a token for an agent: the token (for its prompt) and its record. The name and level are
|
|
89
|
+
* checked by the caller (server/main.ts); the name is used as given, numbered when an agent in
|
|
90
|
+
* force has it already; `by` is a person's name. */
|
|
91
|
+
create(o) {
|
|
92
|
+
const now = o.now ?? Date.now();
|
|
93
|
+
this.expire(now);
|
|
94
|
+
let id;
|
|
95
|
+
do
|
|
96
|
+
id = randomBytes(5).toString("hex");
|
|
97
|
+
while (this.records.some((r) => r.id === id));
|
|
98
|
+
const record = {
|
|
99
|
+
id,
|
|
100
|
+
secret: randomBytes(32).toString("base64url"),
|
|
101
|
+
name: uniqueName(o.name, this.inForce(now).map((r) => r.name)),
|
|
102
|
+
level: o.level,
|
|
103
|
+
by: o.by,
|
|
104
|
+
created: new Date(now).toISOString(),
|
|
105
|
+
expires: new Date(now + o.days * 24 * 3600 * 1000).toISOString(),
|
|
106
|
+
revoked: null,
|
|
107
|
+
revokedBy: null,
|
|
108
|
+
lastUsed: null,
|
|
109
|
+
};
|
|
110
|
+
this.records.push(record);
|
|
111
|
+
this.save();
|
|
112
|
+
this.onChanged();
|
|
113
|
+
return { token: tokenOf(record), record };
|
|
114
|
+
}
|
|
115
|
+
/** A token in force, whole, for its prompt to be shown again (server/main.ts); null when there is
|
|
116
|
+
* no such token in force — an ended one is never shown. */
|
|
117
|
+
token(id, now = Date.now()) {
|
|
118
|
+
const r = this.inForce(now).find((x) => x.id === id);
|
|
119
|
+
return r ? { token: tokenOf(r), record: r } : null;
|
|
120
|
+
}
|
|
121
|
+
/** What a bearer credential is: a token in force (its use noted), one that ended, or none of
|
|
122
|
+
* ours. The two are compared in constant time, by their digests (of one length whatever was
|
|
123
|
+
* sent), whether or not the id is known. */
|
|
124
|
+
check(token, now = Date.now()) {
|
|
125
|
+
if (!looksLikeToken(token))
|
|
126
|
+
return null;
|
|
127
|
+
const id = token.slice(TOKEN_PREFIX.length, TOKEN_PREFIX.length + 10);
|
|
128
|
+
const r = ID.test(id) && token[TOKEN_PREFIX.length + 10] === "_" ? this.records.find((x) => x.id === id) : undefined;
|
|
129
|
+
const same = timingSafeEqual(digest(token), digest(r ? tokenOf(r) : "no such token"));
|
|
130
|
+
if (!r || !same)
|
|
131
|
+
return null;
|
|
132
|
+
if (r.revoked)
|
|
133
|
+
return { ended: "revoked", record: r };
|
|
134
|
+
if (Date.parse(r.expires) <= now) {
|
|
135
|
+
this.expire(now);
|
|
136
|
+
return { ended: "expired", record: r };
|
|
137
|
+
}
|
|
138
|
+
r.lastUsed = new Date(now).toISOString();
|
|
139
|
+
this.dirty = true;
|
|
140
|
+
return { ok: r };
|
|
141
|
+
}
|
|
142
|
+
/** The tokens in force, oldest first. */
|
|
143
|
+
inForce(now = Date.now()) {
|
|
144
|
+
return this.records.filter((r) => !r.revoked && Date.parse(r.expires) > now);
|
|
145
|
+
}
|
|
146
|
+
get(id) {
|
|
147
|
+
return this.records.find((r) => r.id === id);
|
|
148
|
+
}
|
|
149
|
+
/** Revoke a token in force: it lets nothing in from now on, and its agent's wait ends. False when
|
|
150
|
+
* there is no such token in force. */
|
|
151
|
+
revoke(id, by, now = Date.now()) {
|
|
152
|
+
const r = this.records.find((x) => x.id === id);
|
|
153
|
+
if (!r || r.revoked || Date.parse(r.expires) <= now)
|
|
154
|
+
return false;
|
|
155
|
+
r.revoked = new Date(now).toISOString();
|
|
156
|
+
r.revokedBy = by;
|
|
157
|
+
this.save();
|
|
158
|
+
this.onEnded(r);
|
|
159
|
+
this.onChanged();
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
/** Tokens that expired since last looked at: their agents' waits end, and presence lists them no
|
|
163
|
+
* more. */
|
|
164
|
+
expire(now = Date.now()) {
|
|
165
|
+
let any = false;
|
|
166
|
+
for (const r of this.records)
|
|
167
|
+
if (!r.revoked && Date.parse(r.expires) <= now && !ended.has(r)) {
|
|
168
|
+
ended.add(r);
|
|
169
|
+
this.onEnded(r);
|
|
170
|
+
any = true;
|
|
171
|
+
}
|
|
172
|
+
if (any)
|
|
173
|
+
this.onChanged();
|
|
174
|
+
}
|
|
175
|
+
/** Write lastUsed, if it changed since the last write. */
|
|
176
|
+
flush() {
|
|
177
|
+
if (this.dirty)
|
|
178
|
+
this.save();
|
|
179
|
+
}
|
|
180
|
+
/** Stop writing on a timer (tests, the server's end), writing what is left. */
|
|
181
|
+
close() {
|
|
182
|
+
if (this.timer)
|
|
183
|
+
clearInterval(this.timer);
|
|
184
|
+
this.timer = null;
|
|
185
|
+
this.flush();
|
|
186
|
+
}
|
|
187
|
+
/** A token as people and agents are shown it. */
|
|
188
|
+
static info(r) {
|
|
189
|
+
const { secret: _, ...rest } = r;
|
|
190
|
+
return rest;
|
|
191
|
+
}
|
|
192
|
+
prune(now = Date.now()) {
|
|
193
|
+
const before = this.records.length;
|
|
194
|
+
this.records = this.records.filter((r) => now - Date.parse(r.revoked ?? r.expires) < KEEP_ENDED_MS || (!r.revoked && Date.parse(r.expires) > now));
|
|
195
|
+
if (this.records.length !== before)
|
|
196
|
+
this.save();
|
|
197
|
+
}
|
|
198
|
+
save() {
|
|
199
|
+
this.dirty = false;
|
|
200
|
+
if (!this.file)
|
|
201
|
+
return;
|
|
202
|
+
const tmp = `${this.file}.tmp`;
|
|
203
|
+
writeFileSync(tmp, JSON.stringify({ tokens: this.records }, null, 1) + "\n", { mode: 0o600 });
|
|
204
|
+
renameSync(tmp, this.file);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** records whose expiry has been told (onEnded) once already */
|
|
208
|
+
const ended = new WeakSet();
|
|
209
|
+
/** Why a name an agent is to be given will not do; null when it will. */
|
|
210
|
+
export function invalidAgentName(s) {
|
|
211
|
+
if (typeof s !== "string" || !validName(s.trim()))
|
|
212
|
+
return `The agent's name is 1 to ${MAX_NAME} characters, without line breaks.`;
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
/** A record as this server keeps it. One kept before 2026-10-08 has a digest and no secret: it is
|
|
216
|
+
* not one, and is dropped. */
|
|
217
|
+
function isRecord(x) {
|
|
218
|
+
const r = x;
|
|
219
|
+
return !!r && typeof r === "object" && typeof r.id === "string" && ID.test(r.id) && typeof r.secret === "string" && SECRET.test(r.secret) &&
|
|
220
|
+
typeof r.name === "string" && typeof r.by === "string" && typeof r.expires === "string" && ["read", "advise", "full"].includes(r.level);
|
|
221
|
+
}
|