slidemind 0.2.3 → 0.3.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +65 -15
- package/README.md +36 -2
- package/bin/slidemind.mjs +1609 -225
- package/install.ps1 +70 -0
- package/install.sh +65 -0
- package/package.json +3 -1
- package/skills/create-slides/SKILL.md +47 -19
- package/skills/search/SKILL.md +26 -18
- package/src/bridge/build-and-push.mjs +431 -38
- package/src/bridge/capture.mjs +12 -1
- package/src/bridge/client.mjs +1214 -107
- package/src/bridge/config.mjs +193 -1
- package/src/bridge/engine-download.mjs +61 -8
- package/src/bridge/graph-auth.mjs +859 -0
- package/src/bridge/http-json.mjs +15 -6
- package/src/bridge/secrets.mjs +75 -0
- package/src/bridge/setup-claim.mjs +6 -0
- package/src/builders/build.mjs +3 -1
- package/src/builders/gate-server.mjs +156 -0
- package/src/builders/gate-session.js +355 -0
- package/src/builders/plan-overlay.js +507 -225
- package/src/builders/plan.mjs +169 -98
- package/src/builders/resolve-skill.mjs +64 -2
- package/src/builders/review-overlay.js +390 -302
- package/src/builders/review.mjs +136 -50
- package/src/lib/adopt.mjs +407 -0
- package/src/lib/conversions.mjs +263 -0
- package/src/lib/overwrite-guard.mjs +91 -0
- package/src/lib/reconcile-ack.mjs +46 -0
- package/src/lib/reconcile.mjs +306 -0
- package/src/lib/slide-guids.mjs +143 -0
- package/src/pptx/fill/engine.mjs +181 -135
- package/src/pptx/hidden-ole.mjs +22 -1
- package/src/pptx/slide-swap.mjs +23 -1
- package/src/pptx/tags.mjs +676 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// Shared browser-side session machinery for the CLI's two blocking gates —
|
|
2
|
+
// the plan sign-off gate (plan-overlay.js) and the spatial preview gate
|
|
3
|
+
// (review-overlay.js). Both overlays used to carry byte-identical copies of the
|
|
4
|
+
// sessionStorage ledger, the backlog flush, the round-transition detection, the
|
|
5
|
+
// round-ready announcement and the heartbeat poll; a fix landed in one and not
|
|
6
|
+
// the other is exactly how the two drifted apart. This module is the ONE copy.
|
|
7
|
+
//
|
|
8
|
+
// It is plain dependency-free ESM so a test can `import` it directly, and the
|
|
9
|
+
// gates inline it into their page (import-free, `export ` stripped) as
|
|
10
|
+
// window.__SLIDEMIND_GATE__ — see bundleGateSession() in gate-server.mjs.
|
|
11
|
+
//
|
|
12
|
+
// STATE OWNERSHIP: the overlay owns its `ledger` array and its UI mode flags;
|
|
13
|
+
// this module owns the STORAGE and the wire. Every function takes the ledger it
|
|
14
|
+
// operates on, so nothing here has to be kept in sync with the overlay's vars.
|
|
15
|
+
//
|
|
16
|
+
// ROUND TRANSITION: driven by the server's per-run nonce (CFG.round, minted by
|
|
17
|
+
// gate-server's createHeartbeat), NOT by a content hash. A content hash is not
|
|
18
|
+
// a reliable round marker — hashRender() returns "" when slides/ can't be read,
|
|
19
|
+
// and a revision round that changed no file produces an identical hash — so a
|
|
20
|
+
// hash-driven transition silently fails to flip "sent" rows to "addressed" and
|
|
21
|
+
// (with the old approve guard) permanently disabled Approve. Content hashes are
|
|
22
|
+
// still fine for the COSMETIC "updated" badges, which is all the plan gate uses
|
|
23
|
+
// its cached previous body for.
|
|
24
|
+
|
|
25
|
+
// The wire shape each gate's server persists and prints. Per-gate because the
|
|
26
|
+
// anchors differ (spatial pin vs. slide-anchored comment), shared here because
|
|
27
|
+
// flushBacklog posts it and the gate's normalizer has to accept it.
|
|
28
|
+
const WIRE = {
|
|
29
|
+
// Preview pins carry FULL-PRECISION 0..1 fractions. Rounding to whole percent
|
|
30
|
+
// on the wire cost ±6px on a 1280px stage and collapsed sub-1% offsets; the
|
|
31
|
+
// harness output is the only place a percent is the right unit.
|
|
32
|
+
review(c) {
|
|
33
|
+
return {
|
|
34
|
+
id: c.id,
|
|
35
|
+
round: c.round,
|
|
36
|
+
jsxPath: c.jsxPath,
|
|
37
|
+
resolvedTarget: c.resolvedTarget,
|
|
38
|
+
x: c.x,
|
|
39
|
+
y: c.y,
|
|
40
|
+
text: c.text,
|
|
41
|
+
status: "sent",
|
|
42
|
+
at: c.at,
|
|
43
|
+
};
|
|
44
|
+
},
|
|
45
|
+
plan(c) {
|
|
46
|
+
return {
|
|
47
|
+
id: c.id,
|
|
48
|
+
round: c.round,
|
|
49
|
+
scope: c.scope,
|
|
50
|
+
slideIndex: c.slideIndex,
|
|
51
|
+
slideHeading: c.slideHeading,
|
|
52
|
+
text: c.text,
|
|
53
|
+
at: c.at,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// How long an unsent draft may sit un-persisted. The draft write used to run on
|
|
59
|
+
// every keystroke; with the plan body split out of the context blob the payload
|
|
60
|
+
// is tiny, but a debounce still keeps typing off the storage path entirely.
|
|
61
|
+
const DRAFT_DEBOUNCE_MS = 300;
|
|
62
|
+
|
|
63
|
+
export function createGateSession(cfg) {
|
|
64
|
+
const name = cfg.name; // "review" | "plan" — storage prefix + wire shape
|
|
65
|
+
const deck = cfg.deck || "";
|
|
66
|
+
const store = cfg.storage;
|
|
67
|
+
const doFetch = cfg.fetch;
|
|
68
|
+
const wire = cfg.wire || WIRE[name];
|
|
69
|
+
// Deck-scoped, NOT round-scoped: the rows carry their own round, and keying
|
|
70
|
+
// the store by the round would strand the queue on exactly the reload that
|
|
71
|
+
// has to flush it (a new round's key differs by definition).
|
|
72
|
+
const LEDGER_KEY = "slidemind-" + name + "-ledger:" + deck;
|
|
73
|
+
const CTX_KEY = "slidemind-" + name + "-ctx:" + deck;
|
|
74
|
+
const ROUND_READY_FLAG = "slidemind-" + name + "-round-ready";
|
|
75
|
+
let draftTimer = null;
|
|
76
|
+
let pollTimer = null;
|
|
77
|
+
|
|
78
|
+
function read(key, fallback) {
|
|
79
|
+
try {
|
|
80
|
+
const raw = store.getItem(key);
|
|
81
|
+
return raw ? JSON.parse(raw) : fallback;
|
|
82
|
+
} catch {
|
|
83
|
+
return fallback; // private mode / storage blocked — degrade to in-memory
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function write(key, value) {
|
|
87
|
+
try {
|
|
88
|
+
store.setItem(key, JSON.stringify(value));
|
|
89
|
+
} catch {
|
|
90
|
+
/* storage blocked — the session just doesn't survive a reload */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function saveLedger(ledger) {
|
|
94
|
+
write(LEDGER_KEY, ledger);
|
|
95
|
+
}
|
|
96
|
+
// The context the NEXT round restores from. The server nonce rides along as
|
|
97
|
+
// the round marker; everything else is the caller's (selection, draft, slide).
|
|
98
|
+
// Deliberately SMALL — anything page-sized (the plan body) belongs in its own
|
|
99
|
+
// key written once per page load, not in the blob a keystroke rewrites.
|
|
100
|
+
function saveCtx(extra) {
|
|
101
|
+
if (draftTimer) {
|
|
102
|
+
clearTimeout(draftTimer);
|
|
103
|
+
draftTimer = null;
|
|
104
|
+
}
|
|
105
|
+
write(CTX_KEY, Object.assign({ nonce: cfg.round || "" }, extra || {}));
|
|
106
|
+
}
|
|
107
|
+
// Same write, coalesced — for the per-keystroke callers.
|
|
108
|
+
function saveCtxSoon(extra) {
|
|
109
|
+
if (draftTimer) clearTimeout(draftTimer);
|
|
110
|
+
draftTimer = setTimeout(function () {
|
|
111
|
+
draftTimer = null;
|
|
112
|
+
write(CTX_KEY, Object.assign({ nonce: cfg.round || "" }, extra || {}));
|
|
113
|
+
}, DRAFT_DEBOUNCE_MS);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The backlog: written in this tab, not yet delivered to any gate run.
|
|
117
|
+
function queued(ledger) {
|
|
118
|
+
return (ledger || []).filter(function (c) {
|
|
119
|
+
return c.status === "queued";
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Restore this tab's session and work out whether THIS load is a new round.
|
|
124
|
+
// Returns the ledger plus the round state; the caller applies its own ctx
|
|
125
|
+
// fields from the returned `ctx` and its own round-arrival cosmetics.
|
|
126
|
+
function restore(seed) {
|
|
127
|
+
let ledger = read(LEDGER_KEY, []) || [];
|
|
128
|
+
if (!Array.isArray(ledger)) ledger = [];
|
|
129
|
+
let ctx = read(CTX_KEY, null);
|
|
130
|
+
if (!ctx || typeof ctx !== "object") ctx = null;
|
|
131
|
+
let roundNo = ctx && typeof ctx.round === "number" ? ctx.round : 1;
|
|
132
|
+
// A different SERVER nonce = a genuinely newer run served this page, i.e. a
|
|
133
|
+
// new round. Same nonce = a plain refresh of the round already on screen.
|
|
134
|
+
const newRound = !!(ctx && ctx.nonce && cfg.round && ctx.nonce !== cfg.round);
|
|
135
|
+
if (newRound) {
|
|
136
|
+
roundNo += 1;
|
|
137
|
+
ledger.forEach(function (c) {
|
|
138
|
+
if (c.status === "sent") {
|
|
139
|
+
c.status = "addressed";
|
|
140
|
+
c.addressedInRound = roundNo;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// Seed a tab with no session of its own (a fresh window) from the deck's
|
|
145
|
+
// persisted round. These are the rows the agent has JUST been handed — they
|
|
146
|
+
// are "sent", not "addressed": nothing has answered them yet, and labelling
|
|
147
|
+
// them addressed claimed a fix that never happened. Session rows win: they
|
|
148
|
+
// carry live status.
|
|
149
|
+
(seed || []).forEach(function (s) {
|
|
150
|
+
if (!s || !s.id) return;
|
|
151
|
+
for (let i = 0; i < ledger.length; i++) if (ledger[i].id === s.id) return;
|
|
152
|
+
const row = {};
|
|
153
|
+
for (const k in s) row[k] = s[k];
|
|
154
|
+
row.status = s.status === "addressed" ? "addressed" : "sent";
|
|
155
|
+
ledger.push(row);
|
|
156
|
+
});
|
|
157
|
+
// Queued rows mean the agent is still working on the round they were
|
|
158
|
+
// written against — this load is a drain round, not a park.
|
|
159
|
+
return { ledger, roundNo, working: queued(ledger).length > 0, newRound, ctx };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// POST the queued backlog to the gate. A non-empty backlog resolves that run
|
|
163
|
+
// at once (drain round), so the rows flip to "sent". A failure (the command
|
|
164
|
+
// already exited) leaves them queued for the next run — nothing is dropped.
|
|
165
|
+
function flushBacklog(ledger) {
|
|
166
|
+
const pending = queued(ledger);
|
|
167
|
+
if (!pending.length) return Promise.resolve(false);
|
|
168
|
+
return doFetch(cfg.backlogEndpoint, {
|
|
169
|
+
method: "POST",
|
|
170
|
+
headers: { "Content-Type": "application/json" },
|
|
171
|
+
body: JSON.stringify({ comments: pending.map(wire) }),
|
|
172
|
+
})
|
|
173
|
+
.then(function (r) {
|
|
174
|
+
return r.ok ? r.json() : null;
|
|
175
|
+
})
|
|
176
|
+
.then(function (data) {
|
|
177
|
+
if (!data || data.state !== "drained") return false;
|
|
178
|
+
pending.forEach(function (c) {
|
|
179
|
+
c.status = "sent";
|
|
180
|
+
});
|
|
181
|
+
saveLedger(ledger);
|
|
182
|
+
return true;
|
|
183
|
+
})
|
|
184
|
+
.catch(function () {
|
|
185
|
+
return false; // gate gone — the rows stay queued for the next run
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Where a row stands, for the ledger rail / sidebar chip.
|
|
190
|
+
function statusLabel(c) {
|
|
191
|
+
if (c.status === "queued") return "queued";
|
|
192
|
+
if (c.status === "addressed") {
|
|
193
|
+
return "addressed" + (c.addressedInRound ? " · round " + c.addressedInRound : "");
|
|
194
|
+
}
|
|
195
|
+
return cfg.roundInSentLabel ? "sent · round " + c.round : "sent";
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------- next-round attention ----------
|
|
199
|
+
// A background tab cannot steal focus (browser design), but it CAN fire a
|
|
200
|
+
// system notification whose click is allowed to focus it, and flag itself in
|
|
201
|
+
// the tab bar until it is looked at. The permission prompt only shows inside
|
|
202
|
+
// a user gesture, so it is requested at the send click; the "announce" wish
|
|
203
|
+
// survives the reload via sessionStorage (the reload wipes the JS context).
|
|
204
|
+
function notifyPermission() {
|
|
205
|
+
try {
|
|
206
|
+
return "Notification" in window ? Notification.permission : "unsupported";
|
|
207
|
+
} catch {
|
|
208
|
+
return "unsupported";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function requestNotifyPermission() {
|
|
212
|
+
try {
|
|
213
|
+
if (!("Notification" in window)) return Promise.resolve("unsupported");
|
|
214
|
+
if (Notification.permission !== "default") return Promise.resolve(Notification.permission);
|
|
215
|
+
return Promise.resolve(Notification.requestPermission());
|
|
216
|
+
} catch {
|
|
217
|
+
return Promise.resolve("unsupported");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function markRoundReadyAndReload() {
|
|
221
|
+
try {
|
|
222
|
+
store.setItem(ROUND_READY_FLAG, "1");
|
|
223
|
+
} catch {
|
|
224
|
+
/* storage blocked — the reload still lands, just without the announce */
|
|
225
|
+
}
|
|
226
|
+
location.reload();
|
|
227
|
+
}
|
|
228
|
+
// Always consumed at boot (a drain round must not leave the flag armed for a
|
|
229
|
+
// later parked load), but only ACTED on when this round parks.
|
|
230
|
+
function consumeRoundFlag() {
|
|
231
|
+
try {
|
|
232
|
+
const flagged = store.getItem(ROUND_READY_FLAG) === "1";
|
|
233
|
+
if (flagged) store.removeItem(ROUND_READY_FLAG);
|
|
234
|
+
return flagged;
|
|
235
|
+
} catch {
|
|
236
|
+
return false; // no storage — nothing to announce
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function announceNewRound(flagged) {
|
|
240
|
+
if (!flagged) return;
|
|
241
|
+
try {
|
|
242
|
+
window.focus(); // best-effort; most browsers ignore it for background tabs
|
|
243
|
+
} catch {
|
|
244
|
+
/* ignore */
|
|
245
|
+
}
|
|
246
|
+
if (!document.hidden) return; // user is already looking at it
|
|
247
|
+
const origTitle = document.title;
|
|
248
|
+
const restoreTitle = function () {
|
|
249
|
+
document.title = origTitle;
|
|
250
|
+
document.removeEventListener("visibilitychange", onVis);
|
|
251
|
+
window.removeEventListener("focus", restoreTitle);
|
|
252
|
+
};
|
|
253
|
+
const onVis = function () {
|
|
254
|
+
if (!document.hidden) restoreTitle();
|
|
255
|
+
};
|
|
256
|
+
document.title = cfg.attentionTitle;
|
|
257
|
+
document.addEventListener("visibilitychange", onVis);
|
|
258
|
+
window.addEventListener("focus", restoreTitle);
|
|
259
|
+
try {
|
|
260
|
+
if (notifyPermission() === "granted") {
|
|
261
|
+
const n = new Notification(cfg.notifyTitle, {
|
|
262
|
+
body: cfg.notifyBody,
|
|
263
|
+
tag: "slidemind-" + name + "-round",
|
|
264
|
+
});
|
|
265
|
+
n.onclick = function () {
|
|
266
|
+
try {
|
|
267
|
+
window.focus();
|
|
268
|
+
} catch {
|
|
269
|
+
/* ignore */
|
|
270
|
+
}
|
|
271
|
+
n.close();
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
/* notification construction can throw on some platforms — title stands */
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// After a comments round the tab stays open and polls the heartbeat so the
|
|
280
|
+
// NEXT run (which binds the same deterministic port) reloads this tab instead
|
|
281
|
+
// of opening a fresh browser window. The poll reports this tab's round nonce
|
|
282
|
+
// and its visibility: only a VISIBLE tab may claim the next round for reuse —
|
|
283
|
+
// a tab buried in a background window must not swallow the round invisibly,
|
|
284
|
+
// so the server opens a fresh browser tab and answers `action: "close"` here,
|
|
285
|
+
// which retires this tab rather than reloading it into a hidden duplicate.
|
|
286
|
+
function startNextRoundPolling(handlers) {
|
|
287
|
+
if (pollTimer) return; // one poll per page life (working mode can re-enter)
|
|
288
|
+
const startedAt = Date.now();
|
|
289
|
+
const ttl = cfg.pollTtlMs || 35 * 60 * 1000;
|
|
290
|
+
const hb = cfg.heartbeat;
|
|
291
|
+
const timer = setInterval(function () {
|
|
292
|
+
if (Date.now() - startedAt > ttl) {
|
|
293
|
+
clearInterval(timer);
|
|
294
|
+
pollTimer = null;
|
|
295
|
+
handlers.onEnded();
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const visible = document.visibilityState === "visible" ? "1" : "0";
|
|
299
|
+
const url =
|
|
300
|
+
hb +
|
|
301
|
+
(hb.indexOf("?") < 0 ? "?" : "&") +
|
|
302
|
+
"round=" +
|
|
303
|
+
encodeURIComponent(cfg.round || "") +
|
|
304
|
+
"&visible=" +
|
|
305
|
+
visible;
|
|
306
|
+
doFetch(url, { cache: "no-store" })
|
|
307
|
+
.then(function (r) {
|
|
308
|
+
return r.ok ? r.json() : null;
|
|
309
|
+
})
|
|
310
|
+
.then(function (data) {
|
|
311
|
+
// Act only on a genuine NEWER round of THIS gate: shape-guard the
|
|
312
|
+
// service marker (a foreign app may have taken the port) and require
|
|
313
|
+
// a different nonce (same nonce = this run's own orphan).
|
|
314
|
+
if (!data || data.service !== cfg.service || !data.round) return;
|
|
315
|
+
if (data.round === cfg.round) return;
|
|
316
|
+
if (data.action === "close") {
|
|
317
|
+
clearInterval(timer);
|
|
318
|
+
pollTimer = null;
|
|
319
|
+
handlers.onSuperseded();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (data.action === "wait") return; // grace window deciding — keep polling
|
|
323
|
+
clearInterval(timer);
|
|
324
|
+
pollTimer = null;
|
|
325
|
+
markRoundReadyAndReload();
|
|
326
|
+
})
|
|
327
|
+
.catch(function () {
|
|
328
|
+
/* server down between rounds — keep polling */
|
|
329
|
+
});
|
|
330
|
+
}, cfg.pollMs || 1000);
|
|
331
|
+
pollTimer = timer;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
LEDGER_KEY,
|
|
336
|
+
CTX_KEY,
|
|
337
|
+
ROUND_READY_FLAG,
|
|
338
|
+
wire,
|
|
339
|
+
read,
|
|
340
|
+
write,
|
|
341
|
+
saveLedger,
|
|
342
|
+
saveCtx,
|
|
343
|
+
saveCtxSoon,
|
|
344
|
+
queued,
|
|
345
|
+
restore,
|
|
346
|
+
flushBacklog,
|
|
347
|
+
statusLabel,
|
|
348
|
+
notifyPermission,
|
|
349
|
+
requestNotifyPermission,
|
|
350
|
+
markRoundReadyAndReload,
|
|
351
|
+
consumeRoundFlag,
|
|
352
|
+
announceNewRound,
|
|
353
|
+
startNextRoundPolling,
|
|
354
|
+
};
|
|
355
|
+
}
|