slidemind 0.3.20 → 0.4.1
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 +592 -16
- package/LICENSE +4 -0
- package/README.md +203 -8
- package/THIRD-PARTY-NOTICES.md +201 -0
- package/bin/slidemind.mjs +50 -2
- package/package.json +5 -3
- package/skills/create-slides/SKILL.md +484 -886
- package/skills/search/SKILL.md +46 -23
- package/src/bridge/build-and-push.mjs +548 -73
- package/src/bridge/bundle.mjs +100 -31
- package/src/bridge/capture.mjs +14 -17
- package/src/bridge/client/brands.mjs +157 -18
- package/src/bridge/client/engine-routing.mjs +48 -13
- package/src/bridge/client/engine-search.mjs +24 -1
- package/src/bridge/client/render.mjs +27 -1
- package/src/bridge/client/transport.mjs +499 -23
- package/src/bridge/client.mjs +10 -0
- package/src/bridge/config.mjs +67 -6
- package/src/bridge/engine-download.mjs +0 -10
- package/src/bridge/graph-auth.mjs +55 -3
- package/src/bridge/image-intent-envelope.mjs +239 -0
- package/src/bridge/package-preview.mjs +67 -0
- package/src/bridge/sharepoint.mjs +914 -0
- package/src/builders/asset-fetch.mjs +203 -0
- package/src/builders/block-dividers.mjs +83 -0
- package/src/builders/build.mjs +171 -87
- package/src/builders/card-height.mjs +294 -0
- package/src/builders/chart-hole.mjs +183 -0
- package/src/builders/chart-jsx.mjs +410 -0
- package/src/builders/chart-labels.mjs +326 -0
- package/src/builders/concept-geometry.mjs +370 -0
- package/src/builders/concept-intent.mjs +200 -0
- package/src/builders/concept-layout.mjs +79 -0
- package/src/builders/concept-lines.mjs +509 -0
- package/src/builders/concept-lists.mjs +270 -0
- package/src/builders/concept-marks.mjs +84 -0
- package/src/builders/concept-relationships.mjs +12 -0
- package/src/builders/concept-spec.mjs +645 -0
- package/src/builders/concept-text.mjs +140 -0
- package/src/builders/concept-waves.mjs +57 -0
- package/src/builders/copy-fit.mjs +139 -0
- package/src/builders/design-tokens.mjs +347 -0
- package/src/builders/draft-check.mjs +552 -340
- package/src/builders/draft-lines.mjs +369 -0
- package/src/builders/edit-route.mjs +29 -0
- package/src/builders/figure-sketch.mjs +375 -0
- package/src/builders/fold-sets.mjs +639 -0
- package/src/builders/gate-server.mjs +9 -4
- package/src/builders/gate-session.js +273 -12
- package/src/builders/gate-shell.js +1494 -0
- package/src/builders/image-intent-check.mjs +308 -0
- package/src/builders/image-use-reconcile.mjs +107 -0
- package/src/builders/kit-helpers.mjs +121 -0
- package/src/builders/layout-names.mjs +32 -0
- package/src/builders/legacy-reference-uses.mjs +179 -0
- package/src/builders/line-fit.mjs +224 -0
- package/src/builders/line-native.mjs +895 -0
- package/src/builders/line-relations.mjs +230 -0
- package/src/builders/lint-fix.mjs +117 -9
- package/src/builders/lint.mjs +25 -20
- package/src/builders/list-jsx.mjs +88 -0
- package/src/builders/placement.mjs +133 -0
- package/src/builders/plan-checks.mjs +135 -0
- package/src/builders/plan-comments.mjs +19 -0
- package/src/builders/plan-handlers.mjs +95 -0
- package/src/builders/plan-markdown-edit.mjs +3 -3
- package/src/builders/plan-overlay.js +1530 -1608
- package/src/builders/plan.mjs +689 -186
- package/src/builders/preset-definitions.json +1 -0
- package/src/builders/preset-eval.mjs +244 -0
- package/src/builders/preview.html +51 -120
- package/src/builders/red-thread-backbone.mjs +135 -36
- package/src/builders/repair.mjs +205 -0
- package/src/builders/repeat-groups.mjs +540 -0
- package/src/builders/resolve-skill.mjs +5 -4
- package/src/builders/review-overlay.js +296 -293
- package/src/builders/review.mjs +305 -84
- package/src/builders/rule-shift.mjs +96 -0
- package/src/builders/seat.mjs +354 -0
- package/src/builders/settle-driver.mjs +236 -0
- package/src/builders/settle.mjs +1448 -0
- package/src/builders/slide-prep.mjs +67 -0
- package/src/builders/spec-assets.mjs +236 -0
- package/src/builders/spec-match.mjs +56 -0
- package/src/builders/stack.mjs +413 -0
- package/src/builders/storyboard-markdown.mjs +70 -27
- package/src/builders/storyboard-schema.mjs +54 -114
- package/src/builders/text-blocks.mjs +212 -0
- package/src/builders/text-card-fit.mjs +552 -0
- package/src/builders/text-stack.mjs +276 -0
- package/src/builders/transcribe.mjs +1069 -0
- package/src/cli/assets.mjs +164 -0
- package/src/cli/common.mjs +173 -12
- package/src/cli/concept-spec-support.mjs +76 -0
- package/src/cli/concepts.mjs +1002 -103
- package/src/cli/deck.mjs +401 -65
- package/src/cli/diagnose.mjs +4 -2
- package/src/cli/engine-helpers.mjs +17 -10
- package/src/cli/engine-results.mjs +76 -3
- package/src/cli/help.mjs +232 -28
- package/src/cli/install.mjs +9 -0
- package/src/cli/maintenance.mjs +104 -0
- package/src/cli/read.mjs +2 -1
- package/src/cli/ready.mjs +6 -3
- package/src/cli/record.mjs +522 -0
- package/src/cli/remote.mjs +818 -0
- package/src/cli/revision.mjs +686 -0
- package/src/cli/search.mjs +30 -11
- package/src/cli/session.mjs +56 -13
- package/src/cli/setup.mjs +46 -2
- package/src/cli/status.mjs +142 -33
- package/src/cli/tasks.mjs +246 -0
- package/src/cli/transcribe.mjs +854 -0
- package/src/cli/workspace-ui.mjs +260 -0
- package/src/cli/workspace.mjs +212 -34
- package/src/install-channel.mjs +64 -7
- package/src/lib/adopt.mjs +346 -25
- package/src/lib/concept-spec-binding.mjs +96 -0
- package/src/lib/conversions.mjs +70 -6
- package/src/lib/convert-folded.mjs +107 -0
- package/src/lib/deck-paths.mjs +418 -0
- package/src/lib/durable-ids.mjs +47 -0
- package/src/lib/edit-operations.mjs +316 -0
- package/src/lib/edit-state.mjs +1244 -0
- package/src/lib/edit-transitions.mjs +120 -0
- package/src/lib/extensions.mjs +501 -0
- package/src/lib/image-assets.mjs +465 -0
- package/src/lib/image-derivatives.mjs +234 -0
- package/src/lib/image-intent-fingerprint.mjs +70 -0
- package/src/lib/image-intent-tags.mjs +176 -0
- package/src/lib/image-intent.mjs +802 -0
- package/src/lib/image-use-overrides.mjs +279 -0
- package/src/lib/inline-components.mjs +613 -0
- package/src/lib/lint-converted.mjs +68 -4
- package/src/lib/merge-json.mjs +163 -0
- package/src/lib/overwrite-guard.mjs +3 -3
- package/src/lib/presentation-context.mjs +531 -0
- package/src/lib/preservation-tags.mjs +157 -0
- package/src/lib/preservation-verdict.mjs +1342 -0
- package/src/lib/publish.mjs +1557 -0
- package/src/lib/rebase.mjs +945 -0
- package/src/lib/reconcile-ack.mjs +4 -6
- package/src/lib/reconcile.mjs +62 -24
- package/src/lib/record-client.mjs +175 -0
- package/src/lib/record-state.mjs +151 -0
- package/src/lib/remote.mjs +661 -0
- package/src/lib/revision.mjs +1210 -0
- package/src/lib/slide-guids.mjs +4 -6
- package/src/lib/slide-preservation.mjs +423 -0
- package/src/lib/slide-selection.mjs +9 -0
- package/src/lib/workspace-transaction.mjs +117 -0
- package/src/pptx/content-digest.mjs +334 -0
- package/src/pptx/deck-assemble.mjs +1278 -0
- package/src/pptx/fill/engine.mjs +2 -3
- package/src/pptx/fill/freeform.mjs +6 -3
- package/src/pptx/ooxml.mjs +20 -0
- package/src/pptx/package-evidence.mjs +383 -0
- package/src/pptx/tags.mjs +163 -11
- package/src/update-check.mjs +22 -4
- package/src/workspace/adapters.mjs +222 -0
- package/src/workspace/auth.mjs +256 -0
- package/src/workspace/autostart.mjs +204 -0
- package/src/workspace/daemon.mjs +360 -0
- package/src/workspace/discovery.mjs +180 -0
- package/src/workspace/edit.mjs +167 -0
- package/src/workspace/events.mjs +52 -0
- package/src/workspace/gate-client.mjs +290 -0
- package/src/workspace/handback.mjs +73 -0
- package/src/workspace/lifecycle.mjs +635 -0
- package/src/workspace/operations.mjs +200 -0
- package/src/workspace/pick-folder.mjs +152 -0
- package/src/workspace/registry.mjs +359 -0
- package/src/workspace/remote-links.mjs +397 -0
- package/src/workspace/remote-status.mjs +359 -0
- package/src/workspace/routes.mjs +1331 -0
- package/src/workspace/server.mjs +159 -0
- package/src/workspace/sync.mjs +1119 -0
- package/src/workspace/task-actions.mjs +215 -0
- package/src/workspace/tasks.mjs +507 -0
- package/src/workspace/ui/app.js +1886 -0
- package/src/workspace/ui/index.html +141 -0
- package/src/workspace/ui/plan-bridge.js +120 -0
- package/src/workspace/ui/sharepoint.svg +59 -0
- package/src/workspace/ui/styles.css +423 -0
- package/src/builders/visual-families.mjs +0 -46
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// Edit generations for the persistent workspace: one local save, under the
|
|
2
|
+
// deck's operation, that knows what the editor started from.
|
|
3
|
+
//
|
|
4
|
+
// Every editor submits `base` — the hash and value it loaded — and what it
|
|
5
|
+
// wants now. Under the lock the CURRENT plan is re-read (through the installed
|
|
6
|
+
// adapter, or plan.md for a generic deck), so a write another tool made in the
|
|
7
|
+
// meantime is noticed rather than overwritten: if it differs from the last
|
|
8
|
+
// accepted snapshot (`state/workspace-edit.json`) the raw value is backed up
|
|
9
|
+
// in the operation record before anything is accepted. A stale base is merged
|
|
10
|
+
// three ways with the shared policy (merge-json.mjs); a same-field clash
|
|
11
|
+
// returns `local_conflict` with every input and writes nothing.
|
|
12
|
+
//
|
|
13
|
+
// The commit itself is the adapter's `save-local` (the pack applies its own
|
|
14
|
+
// amendment/validation rules) or a plain plan.md write. What the adapter
|
|
15
|
+
// refuses is refused here — the snapshot is not advanced and nothing is left
|
|
16
|
+
// for the sync queue to pick up. Network is not this module's business.
|
|
17
|
+
|
|
18
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
import { Merge3Error, merge3 } from "../lib/merge-json.mjs";
|
|
23
|
+
import { dispatchAdapter } from "./adapters.mjs";
|
|
24
|
+
import { runDeckOperation } from "./operations.mjs";
|
|
25
|
+
|
|
26
|
+
export const EDIT_FILE = "workspace-edit.json";
|
|
27
|
+
export const SCHEMA_VERSION = 1;
|
|
28
|
+
export const GENERIC_ADAPTER = "core";
|
|
29
|
+
|
|
30
|
+
export function editPath(workspaceDir) {
|
|
31
|
+
return path.join(workspaceDir, "state", EDIT_FILE);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The last accepted editor snapshot: `{ adapterId, hash, value, updatedAt }`, `{ malformed, raw }` (kept on disk), or null. */
|
|
35
|
+
export function readAcceptedEdit(workspaceDir) {
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = fs.readFileSync(editPath(workspaceDir), "utf8");
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err?.code === "ENOENT") return null;
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(raw);
|
|
45
|
+
if (parsed?.schemaVersion === SCHEMA_VERSION && typeof parsed.hash === "string") return parsed;
|
|
46
|
+
} catch {
|
|
47
|
+
/* fall through */
|
|
48
|
+
}
|
|
49
|
+
return { malformed: true, raw };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeAtomic(file, data) {
|
|
53
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
54
|
+
const tmp = `${file}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
55
|
+
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
56
|
+
fs.renameSync(tmp, file);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A generic plan is the whole plan.md string, hashed as UTF-8 bytes (contracts §7). */
|
|
60
|
+
export const markdownHash = (text) => `sha256:${createHash("sha256").update(text, "utf8").digest("hex")}`;
|
|
61
|
+
|
|
62
|
+
const refusal = (code, message, extra = {}) => ({ ok: false, code, message, ...extra });
|
|
63
|
+
const deckRootOf = (workspaceDir) => (path.basename(workspaceDir) === ".slidemind" ? path.dirname(workspaceDir) : workspaceDir);
|
|
64
|
+
|
|
65
|
+
// The two ways to read and commit a plan. `adapter` is `{ id, entry }` from
|
|
66
|
+
// resolveWorkspaceAdapter, or null for a generic plan.md deck.
|
|
67
|
+
function editor(workspaceDir, adapter, operationId) {
|
|
68
|
+
if (adapter?.entry) {
|
|
69
|
+
const deckRoot = deckRootOf(workspaceDir);
|
|
70
|
+
const call = (method, payload, ownerToken) => dispatchAdapter(adapter.entry, { method, deckRoot, payload, operationId, ownerToken });
|
|
71
|
+
return {
|
|
72
|
+
id: adapter.id,
|
|
73
|
+
async read(token) {
|
|
74
|
+
const answer = await call("read", {}, token);
|
|
75
|
+
if (answer.status !== "ok") return { refused: refusal(answer.error.code, answer.error.message, { details: answer.data ?? undefined }) };
|
|
76
|
+
return { value: answer.data.value, hash: answer.data.hash };
|
|
77
|
+
},
|
|
78
|
+
async commit(current, value, token) {
|
|
79
|
+
const answer = await call("save-local", { base: current, value }, token);
|
|
80
|
+
if (answer.status !== "ok") return { refused: refusal(answer.error.code, answer.error.message, { details: answer.data ?? undefined }) };
|
|
81
|
+
return { value: answer.data.value, hash: answer.data.hash };
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const file = path.join(workspaceDir, "plan.md");
|
|
86
|
+
return {
|
|
87
|
+
id: GENERIC_ADAPTER,
|
|
88
|
+
async read() {
|
|
89
|
+
let text;
|
|
90
|
+
try {
|
|
91
|
+
text = fs.readFileSync(file, "utf8");
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (err?.code !== "ENOENT") throw err;
|
|
94
|
+
return { refused: refusal("no_plan", `no plan.md in ${workspaceDir}`) };
|
|
95
|
+
}
|
|
96
|
+
return { value: text, hash: markdownHash(text) };
|
|
97
|
+
},
|
|
98
|
+
async commit(_current, value) {
|
|
99
|
+
if (typeof value !== "string") return { refused: refusal("validation_failed", "a generic plan is the plan.md text") };
|
|
100
|
+
const tmp = `${file}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
|
|
101
|
+
fs.writeFileSync(tmp, value, "utf8");
|
|
102
|
+
fs.renameSync(tmp, file);
|
|
103
|
+
return { value, hash: markdownHash(value) };
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Save an editor's value locally.
|
|
110
|
+
*
|
|
111
|
+
* `{ ok: true, value, hash, savedLocally: true, rawBackup? }` on commit —
|
|
112
|
+
* `rawBackup` is the plan as found on disk when it was not the last accepted
|
|
113
|
+
* snapshot (an agent's direct edit), also noted in the operation record.
|
|
114
|
+
* `{ ok: false, code: "local_conflict", current, base, mine, merged, conflicts,
|
|
115
|
+
* hash }` when the stale base cannot be merged; nothing written. Any other
|
|
116
|
+
* `{ ok: false, code }` is the adapter's refusal (`validation_failed`, …) or a
|
|
117
|
+
* bad request — the snapshot is not advanced. Lock and recovery failures throw
|
|
118
|
+
* as runDeckOperation does (`deck_busy`, `recovery_required`).
|
|
119
|
+
*
|
|
120
|
+
* `owner` is runDeckOperation's: the daemon passes `false`.
|
|
121
|
+
*/
|
|
122
|
+
export function savePlanLocal({ workspaceDir, adapter = null, operationId = randomUUID(), base, value, owner }) {
|
|
123
|
+
if (!base || typeof base.hash !== "string") return Promise.resolve(refusal("validation_failed", "base.hash is required: the version the editor loaded"));
|
|
124
|
+
const ed = editor(workspaceDir, adapter, operationId);
|
|
125
|
+
return runDeckOperation(workspaceDir, { operationId, kind: "save-local", expected: { base: base.hash }, owner }, async (op) => {
|
|
126
|
+
const current = await ed.read(op.token);
|
|
127
|
+
if (current.refused) return current.refused;
|
|
128
|
+
|
|
129
|
+
const accepted = readAcceptedEdit(workspaceDir);
|
|
130
|
+
let rawBackup;
|
|
131
|
+
if (accepted && !accepted.malformed && accepted.hash !== current.hash) {
|
|
132
|
+
rawBackup = { hash: current.hash, value: current.value };
|
|
133
|
+
op.note({ rawBackup });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let mine = value;
|
|
137
|
+
if (base.hash !== current.hash) {
|
|
138
|
+
if (base.value === undefined) return refusal("validation_failed", "base.value is required when the record moved since it was loaded");
|
|
139
|
+
let out;
|
|
140
|
+
try {
|
|
141
|
+
out = merge3(base.value, current.value, value);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
if (err instanceof Merge3Error) return refusal("validation_failed", err.message, { path: err.path });
|
|
144
|
+
throw err;
|
|
145
|
+
}
|
|
146
|
+
if (out.conflicts.length) {
|
|
147
|
+
return { ok: false, code: "local_conflict", current: current.value, base: base.value, mine: value, merged: out.merged, conflicts: out.conflicts, hash: current.hash };
|
|
148
|
+
}
|
|
149
|
+
mine = out.merged;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
op.note({ before: current.hash });
|
|
153
|
+
const committed = await ed.commit({ hash: current.hash, value: current.value }, mine, op.token);
|
|
154
|
+
if (committed.refused) return committed.refused;
|
|
155
|
+
op.note({ after: committed.hash });
|
|
156
|
+
writeAtomic(editPath(workspaceDir), {
|
|
157
|
+
schemaVersion: SCHEMA_VERSION,
|
|
158
|
+
adapterId: ed.id,
|
|
159
|
+
hash: committed.hash,
|
|
160
|
+
value: committed.value,
|
|
161
|
+
updatedAt: new Date().toISOString(),
|
|
162
|
+
});
|
|
163
|
+
// Accepted and adapter-owned: the sync queue picks it up (generic plan.md is never sent — contracts §7).
|
|
164
|
+
if (adapter?.entry || adapter?.read) (await import("./sync.mjs")).scheduleSync(workspaceDir, { hash: committed.hash });
|
|
165
|
+
return { ok: true, value: committed.value, hash: committed.hash, previous: current.hash, savedLocally: true, ...(rawBackup ? { rawBackup } : {}) };
|
|
166
|
+
});
|
|
167
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Advisory invalidations over SSE. `{ type, deckId?, taskId?, generation }`
|
|
2
|
+
// says "re-GET this"; it never carries state, and nothing is replayed on
|
|
3
|
+
// reconnect — a client that reconnects fetches fresh state, so a lost event
|
|
4
|
+
// costs one refresh, never correctness. Heartbeat comment every 25s keeps
|
|
5
|
+
// proxies and browsers from closing an idle stream.
|
|
6
|
+
|
|
7
|
+
export const HEARTBEAT_MS = 25_000;
|
|
8
|
+
|
|
9
|
+
export function createEvents() {
|
|
10
|
+
const clients = new Set();
|
|
11
|
+
let generation = 0;
|
|
12
|
+
return {
|
|
13
|
+
get generation() {
|
|
14
|
+
return generation;
|
|
15
|
+
},
|
|
16
|
+
get clientCount() {
|
|
17
|
+
return clients.size;
|
|
18
|
+
},
|
|
19
|
+
invalidate(event) {
|
|
20
|
+
generation += 1;
|
|
21
|
+
const line = `data: ${JSON.stringify({ ...event, generation })}\n\n`;
|
|
22
|
+
for (const res of clients) res.write(line);
|
|
23
|
+
return generation;
|
|
24
|
+
},
|
|
25
|
+
subscribe(req, res) {
|
|
26
|
+
res.writeHead(200, {
|
|
27
|
+
"Content-Type": "text/event-stream",
|
|
28
|
+
"Cache-Control": "no-store",
|
|
29
|
+
Connection: "keep-alive",
|
|
30
|
+
"X-Accel-Buffering": "no",
|
|
31
|
+
});
|
|
32
|
+
res.write(`: connected ${generation}\n\n`);
|
|
33
|
+
clients.add(res);
|
|
34
|
+
const beat = setInterval(() => res.write(": ping\n\n"), HEARTBEAT_MS);
|
|
35
|
+
beat.unref();
|
|
36
|
+
const drop = () => {
|
|
37
|
+
clearInterval(beat);
|
|
38
|
+
clients.delete(res);
|
|
39
|
+
};
|
|
40
|
+
req.on("close", drop);
|
|
41
|
+
res.on("close", drop);
|
|
42
|
+
},
|
|
43
|
+
close() {
|
|
44
|
+
for (const res of clients) res.end();
|
|
45
|
+
clients.clear();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The daemon's one bus; modules that commit something call `invalidate`. */
|
|
51
|
+
export const events = createEvents();
|
|
52
|
+
export const invalidate = (event) => events.invalidate(event);
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// The agent's side of a gate when the human answers in the workspace.
|
|
2
|
+
//
|
|
3
|
+
// `slidemind plan <deck>` and `slidemind review <deck>` are the ONLY gate
|
|
4
|
+
// commands an agent runs. They ask through the workspace and keep the
|
|
5
|
+
// standalone contract's `DECISION: approved | comments | pending` stdout, from
|
|
6
|
+
// the same formatters. One run:
|
|
7
|
+
//
|
|
8
|
+
// 1. hands over an answer the human already gave and the agent has not
|
|
9
|
+
// received (`pickedUpAt` is the receipt) — this is the receive command the
|
|
10
|
+
// user's pasted message names;
|
|
11
|
+
// 2. else answers "approved" at once when the content is already approved;
|
|
12
|
+
// 3. else asks (a deduped task bound to the exact content), tells an open
|
|
13
|
+
// page, prints ASK + SAY and returns `DECISION: pending` AT ONCE. It never
|
|
14
|
+
// waits: nothing can reach an agent whose turn has ended, so the agent ends
|
|
15
|
+
// its turn with the SAY line and the user's pasted message brings it back
|
|
16
|
+
// (doc 21 → *Tasks and agent continuation*, handback.mjs).
|
|
17
|
+
//
|
|
18
|
+
// A workspace that cannot be used is a blocker the agent reports, never a
|
|
19
|
+
// standalone page on another port: that second page was the second link this
|
|
20
|
+
// protocol removed. Nothing here opens a browser.
|
|
21
|
+
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
|
|
25
|
+
import { deckRoot as rootOf, deckSlug, planPageOwner, resolveWorkspace } from "../lib/deck-paths.mjs";
|
|
26
|
+
import { daemonRequest, ensureRunning, readDescriptor, registerDeck } from "./lifecycle.mjs";
|
|
27
|
+
import { markdownHash, readAcceptedEdit } from "./edit.mjs";
|
|
28
|
+
import { askLines, viewOf } from "./handback.mjs";
|
|
29
|
+
import { deriveTaskSubject } from "./task-actions.mjs";
|
|
30
|
+
import { getTask, listTasks, markPickedUp, requestTask } from "./tasks.mjs";
|
|
31
|
+
|
|
32
|
+
const NOUN = { "plan-approval": "plan", "preview-review": "preview" };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Does this gate ask through the workspace? Always — `slidemind setup` keeps one
|
|
36
|
+
* running — except where nobody is there to answer (auto-approving headless
|
|
37
|
+
* runs) or the user rolled back to the standalone gates (SLIDEMIND_WORKSPACE=0).
|
|
38
|
+
* A plan an extension owns keeps its own review flow (`tasks request`).
|
|
39
|
+
*/
|
|
40
|
+
export function gateUsesWorkspace(deckDir, kind) {
|
|
41
|
+
if (process.env.SLIDEMIND_GATE_AUTOAPPROVE === "1") return false;
|
|
42
|
+
if (process.env.SLIDEMIND_WORKSPACE === "0") return false;
|
|
43
|
+
if (kind === "plan-approval" && planPageOwner(resolveWorkspace(deckDir)).owner !== "core") return false;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SANDBOX_CODES = new Set(["EPERM", "EACCES", "EROFS"]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Why a question could not be asked, in the words the agent passes on or acts
|
|
51
|
+
* on. Every one is a blocker (exit 2), never a fallback page.
|
|
52
|
+
*/
|
|
53
|
+
export function askBlocker(err, deckRoot) {
|
|
54
|
+
if (err?.code === "workspace_sandboxed") {
|
|
55
|
+
return {
|
|
56
|
+
code: "workspace_sandboxed",
|
|
57
|
+
message:
|
|
58
|
+
"this sandboxed session cannot reach or start the slidemind workspace. " +
|
|
59
|
+
"Run this same command again outside the sandbox (in Codex: with escalated permissions) — it starts the workspace if needed and asks.",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (SANDBOX_CODES.has(err?.code)) {
|
|
63
|
+
return {
|
|
64
|
+
code: "deck_not_writable",
|
|
65
|
+
message:
|
|
66
|
+
`this session may not write into ${deckRoot} (${err.code}). Decks belong in ./decks of your working folder; ` +
|
|
67
|
+
"if the user chose this folder on purpose, run this same command again outside the sandbox.",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return { code: err?.code ?? "workspace_unavailable", message: `the slidemind workspace could not be used: ${err?.message ?? err}` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A re-run of the review gate while its question about THIS build is still
|
|
75
|
+
* open — the agent waiting in a loop — needs no rebuild: the preview the user
|
|
76
|
+
* is looking at was built from exactly these slides (the task's subject is
|
|
77
|
+
* their hash) and nothing under slides/ or assets/ is newer than it.
|
|
78
|
+
*/
|
|
79
|
+
export async function openPreviewQuestionIsCurrent(deckDir) {
|
|
80
|
+
const ws = resolveWorkspace(deckDir);
|
|
81
|
+
let tasks;
|
|
82
|
+
try {
|
|
83
|
+
tasks = listTasks(rootOf(ws)).filter((t) => t.kind === "preview-review" && t.status === "pending");
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (!tasks.length) return false;
|
|
88
|
+
const { previewHash } = await import("../builders/review.mjs");
|
|
89
|
+
const { previewPath } = await import("../lib/deck-paths.mjs");
|
|
90
|
+
let hash;
|
|
91
|
+
let builtAt;
|
|
92
|
+
try {
|
|
93
|
+
hash = previewHash(ws);
|
|
94
|
+
builtAt = fs.statSync(previewPath(ws, "presentation.html")).mtimeMs;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
if (!tasks.some((t) => t.subject?.hash === hash)) return false;
|
|
99
|
+
// the build's own stamp says what it was built from (renames and deletes included)
|
|
100
|
+
const { previewBuiltFrom } = await import("../builders/build.mjs");
|
|
101
|
+
const builtFrom = previewBuiltFrom(ws);
|
|
102
|
+
if (builtFrom) return builtFrom === hash;
|
|
103
|
+
const newer = (dir) => {
|
|
104
|
+
let entries = [];
|
|
105
|
+
try {
|
|
106
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return entries.some((e) => (e.isDirectory() ? newer(path.join(dir, e.name)) : e.isFile() && fs.statSync(path.join(dir, e.name)).mtimeMs > builtAt));
|
|
111
|
+
};
|
|
112
|
+
return !newer(path.join(ws, "slides")) && !newer(path.join(ws, "assets"));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Is plan.md, as it stands, the version the user last saved in the workspace's
|
|
117
|
+
* editor? Then the plan the agent gets back is the user's edit, not its own
|
|
118
|
+
* draft — even when no question was open while they edited — and it must be
|
|
119
|
+
* re-read before authoring.
|
|
120
|
+
*/
|
|
121
|
+
export function planLastSavedInPage(deckDir) {
|
|
122
|
+
const ws = resolveWorkspace(deckDir);
|
|
123
|
+
try {
|
|
124
|
+
const accepted = readAcceptedEdit(ws);
|
|
125
|
+
if (!accepted || accepted.malformed) return false;
|
|
126
|
+
return accepted.hash === markdownHash(fs.readFileSync(path.join(ws, "plan.md"), "utf8"));
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* An answer on the preview the agent has not received, which this run will hand
|
|
134
|
+
* over as it is (comments, or an approval of these very slides): nothing needs
|
|
135
|
+
* rebuilding first — a build that fails now must not keep the user's answer from
|
|
136
|
+
* the agent. An approval of slides that changed since is not handed over (the
|
|
137
|
+
* run asks about the new ones), so it still builds.
|
|
138
|
+
*/
|
|
139
|
+
export async function previewAnswerWaiting(deckDir) {
|
|
140
|
+
const ws = resolveWorkspace(deckDir);
|
|
141
|
+
let answer;
|
|
142
|
+
try {
|
|
143
|
+
answer = unreceived(listTasks(rootOf(ws)).filter((t) => t.kind === "preview-review"));
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
if (!answer) return false;
|
|
148
|
+
if (answer.response?.action !== "approve") return true;
|
|
149
|
+
try {
|
|
150
|
+
const { previewHash } = await import("../builders/review.mjs");
|
|
151
|
+
const now = previewHash(ws);
|
|
152
|
+
return answer.subject?.hash === now || answer.evidence?.hash === now;
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const ago = (iso) => {
|
|
159
|
+
const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
|
|
160
|
+
return s < 90 ? `${s}s` : `${Math.round(s / 60)} min`;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** A workspace route through the one CLI→daemon helper; an HTTP error throws with its code and status. */
|
|
164
|
+
async function daemonFetch(p, opts = {}) {
|
|
165
|
+
const { status, body } = await daemonRequest(p, opts);
|
|
166
|
+
if (status < 200 || status >= 300 || !body?.ok) {
|
|
167
|
+
throw Object.assign(new Error(body?.error?.message ?? `workspace answered HTTP ${status}`), { code: body?.error?.code ?? "workspace_error", status });
|
|
168
|
+
}
|
|
169
|
+
return body;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The signed-in link to the view a question is answered in (valid every time it
|
|
174
|
+
* is clicked, in any browser — Codex's own pane included), or the plain page
|
|
175
|
+
* when the workspace does not hand out its key.
|
|
176
|
+
*/
|
|
177
|
+
async function askLink(deckId, kind) {
|
|
178
|
+
const d = readDescriptor();
|
|
179
|
+
const plain = `${d.url}/deck/${deckId}/${viewOf(kind)}`;
|
|
180
|
+
try {
|
|
181
|
+
const { token } = await daemonFetch("/bootstrap", { method: "POST" });
|
|
182
|
+
return { link: `${plain}#bootstrap=${token}`, plain };
|
|
183
|
+
} catch {
|
|
184
|
+
return { link: plain, plain };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The newest answer of this kind the agent has not received yet (older unreceived ones are history). */
|
|
189
|
+
function unreceived(tasks) {
|
|
190
|
+
const answered = tasks.filter((t) => t.status === "completed");
|
|
191
|
+
return answered[0] && !answered[0].pickedUpAt ? answered[0] : null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Run one gate through the workspace. Resolves
|
|
196
|
+
* { blocked: { code, message } } the question could not be asked — report it
|
|
197
|
+
* { refused } `refuseOpen` turned a new question down (already printed)
|
|
198
|
+
* { decision: "approve"|"comments"|"pending", task }
|
|
199
|
+
*
|
|
200
|
+
* `format(task, { note })` prints the decision exactly as the standalone gate
|
|
201
|
+
* would (the caller owns the wording: plan.mjs / review.mjs); `approvedNow()`
|
|
202
|
+
* says whether the current content is already approved; `refuseOpen()`, when
|
|
203
|
+
* given, returns a refusal for asking about the content as it stands, or null.
|
|
204
|
+
*/
|
|
205
|
+
export async function runWorkspaceGate({ deckDir, kind, format, approvedNow, refuseOpen = null, out = process.stdout }) {
|
|
206
|
+
const ws = resolveWorkspace(deckDir);
|
|
207
|
+
const root = rootOf(ws);
|
|
208
|
+
const ofKind = () => listTasks(root).filter((t) => t.kind === kind);
|
|
209
|
+
|
|
210
|
+
// 1. The answer the user's pasted message came for (or one given while no agent ran).
|
|
211
|
+
const handOver = async (task, note = null) => {
|
|
212
|
+
// Print FIRST, then record the receipt: a run killed in between shows the
|
|
213
|
+
// answer again next time (harmless), never loses it.
|
|
214
|
+
const done = getTask(root, task.id) ?? task;
|
|
215
|
+
format(done, { note });
|
|
216
|
+
// The receipt tells the page "your agent picked it up" and keeps a seen
|
|
217
|
+
// comments round from coming back. Older unreceived answers are history: received too.
|
|
218
|
+
let failed = null;
|
|
219
|
+
for (const t of ofKind().filter((x) => x.status === "completed" && !x.pickedUpAt)) {
|
|
220
|
+
for (let attempt = 0; ; attempt++) {
|
|
221
|
+
try {
|
|
222
|
+
await markPickedUp(root, t.id);
|
|
223
|
+
break;
|
|
224
|
+
} catch (e) {
|
|
225
|
+
if (e?.code === "deck_busy" && attempt < 5) {
|
|
226
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
failed = e;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (failed) {
|
|
235
|
+
out.write(
|
|
236
|
+
failed.code === "deck_busy" && !process.env.SLIDEMIND_OWNER
|
|
237
|
+
? "NOTE: the deck is held by your edit session, so this answer could not be marked as received and the next run will show it again. Run slidemind commands with SLIDEMIND_OWNER exported (the token `revision begin` printed).\n"
|
|
238
|
+
: `NOTE: this answer could not be marked as received (${failed.message}); the next run may show it again — act on it once.\n`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return { decision: done.response?.action === "approve" ? "approve" : "comments", task: done };
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const { subject, adapterId } = await deriveTaskSubject({ deckRoot: root, adapter: null, kind });
|
|
245
|
+
const waiting = unreceived(ofKind());
|
|
246
|
+
if (waiting) {
|
|
247
|
+
const sameContent = waiting.subject?.hash === subject.hash || waiting.evidence?.hash === subject.hash;
|
|
248
|
+
if (sameContent || waiting.response?.action !== "approve") {
|
|
249
|
+
return handOver(waiting, sameContent ? null : `The user answered on an earlier version of the ${NOUN[kind]} (${ago(waiting.response.at)} ago); you changed it since.`);
|
|
250
|
+
}
|
|
251
|
+
// an approval of a version that no longer exists covers nothing: note it and ask about what is there now
|
|
252
|
+
try {
|
|
253
|
+
await markPickedUp(root, waiting.id);
|
|
254
|
+
} catch {
|
|
255
|
+
// the note below still stands
|
|
256
|
+
}
|
|
257
|
+
out.write(`NOTE: the user approved an earlier version of the ${NOUN[kind]}; you changed it since, so this asks about the current one.\n`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 2. Already approved, unchanged since: nothing to ask.
|
|
261
|
+
if (approvedNow()) {
|
|
262
|
+
format({ kind, status: "completed", response: { action: "approve" }, subject }, { note: "Already approved — this exact version was signed off earlier; nothing was asked again." });
|
|
263
|
+
return { decision: "approve", task: null };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// 3. Ask, and return. Steps 1 and 2 read the deck's own task store and need no
|
|
267
|
+
// workspace — an answer the user already gave reaches the agent even where the
|
|
268
|
+
// workspace cannot run. Asking needs the one the user answers in. Only a NEW
|
|
269
|
+
// question is checked (`refuseOpen`): an answer above is handed over as given.
|
|
270
|
+
const refused = refuseOpen?.() ?? null;
|
|
271
|
+
if (refused) {
|
|
272
|
+
out.write(refused);
|
|
273
|
+
return { refused };
|
|
274
|
+
}
|
|
275
|
+
let entry;
|
|
276
|
+
let task;
|
|
277
|
+
try {
|
|
278
|
+
await ensureRunning();
|
|
279
|
+
entry = await registerDeck(root);
|
|
280
|
+
// deduped: the same content asked twice is one question
|
|
281
|
+
task = (await requestTask(root, { kind, adapterId, subject })).task;
|
|
282
|
+
} catch (err) {
|
|
283
|
+
return { blocked: askBlocker(err, root) };
|
|
284
|
+
}
|
|
285
|
+
const { link } = await askLink(entry.id, kind);
|
|
286
|
+
// an open page shows the question at once; nothing opens a browser
|
|
287
|
+
await daemonFetch(`/decks/${entry.id}/attention`, { method: "POST", body: { kind, taskId: task.id } }).catch(() => {});
|
|
288
|
+
out.write(askLines(kind, link));
|
|
289
|
+
return { decision: "pending", task };
|
|
290
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The words of the hand-back (doc 21 → *Tasks and agent continuation*). Nothing
|
|
2
|
+
// reaches an agent whose turn has ended, so every question has one shape:
|
|
3
|
+
//
|
|
4
|
+
// the agent's command prints ASK + SAY and returns → the agent ends its turn
|
|
5
|
+
// with the SAY line → the user decides in the workspace, and the click copies
|
|
6
|
+
// the message below → the user pastes it → the agent runs the receive
|
|
7
|
+
// command the message names.
|
|
8
|
+
//
|
|
9
|
+
// One module owns every one of those words, so the CLI (gate-client, `tasks
|
|
10
|
+
// request`, `ready`) and the page (the ack the routes return) never drift.
|
|
11
|
+
|
|
12
|
+
/** The deck view a question is answered in — what its link opens. */
|
|
13
|
+
export const VIEW_OF_KIND = {
|
|
14
|
+
"plan-approval": "plan",
|
|
15
|
+
"plan-edit": "plan",
|
|
16
|
+
"sync-conflict": "plan",
|
|
17
|
+
"preview-review": "preview",
|
|
18
|
+
};
|
|
19
|
+
export const viewOf = (kind) => VIEW_OF_KIND[kind] ?? "plan";
|
|
20
|
+
|
|
21
|
+
const CORE_GATE = { "plan-approval": "plan", "preview-review": "review" };
|
|
22
|
+
|
|
23
|
+
/** Is this task answered through the CLI's own gate (a core deck), not a pack's task flow? */
|
|
24
|
+
export const isCoreGate = (task) => Boolean(CORE_GATE[task?.kind]) && (!task?.adapterId || task.adapterId === "core");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The one command that receives an answer: the gate for a core deck (it prints
|
|
28
|
+
* the decision and writes the receipt), `tasks get` for everything else.
|
|
29
|
+
*/
|
|
30
|
+
export function receiveCommand({ kind, adapterId = null, deckPath, taskId = null }) {
|
|
31
|
+
if (isCoreGate({ kind, adapterId })) return `slidemind ${CORE_GATE[kind]} ${JSON.stringify(deckPath)}`;
|
|
32
|
+
return `slidemind tasks get ${JSON.stringify(deckPath)} ${taskId}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SUBJECT = {
|
|
36
|
+
"plan-approval": "Your plan is ready for review",
|
|
37
|
+
"plan-edit": "Your plan is ready for your edits",
|
|
38
|
+
"preview-review": "Your slides are ready for review",
|
|
39
|
+
"sync-conflict": "Your shared plan needs a decision",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The agent's closing line, word for word: the link and what happens after the user is done. */
|
|
43
|
+
export function sayLine(kind, link) {
|
|
44
|
+
return `${SUBJECT[kind] ?? "A question is waiting for you"} in the slidemind workspace: ${link} — when you're done there, the page gives you a message to paste back here.`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What the asking command prints after the question is recorded. */
|
|
48
|
+
export function askLines(kind, link) {
|
|
49
|
+
return (
|
|
50
|
+
`ASK: ${link}\n` +
|
|
51
|
+
`SAY: ${sayLine(kind, link)}\n` +
|
|
52
|
+
"DECISION: pending\n" +
|
|
53
|
+
"Asked. End your turn now with the SAY line, word for word. Do not wait, poll or run this again: " +
|
|
54
|
+
"the user answers in the workspace and pastes back a message that tells you how to receive the answer.\n"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const NOUN = { "plan-approval": "Plan", "plan-edit": "Plan", "sync-conflict": "Shared plan", "preview-review": "Slides" };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The message the page copies after the user decided — one line, complete on
|
|
62
|
+
* its own (what was decided + the command that receives it), so it works in the
|
|
63
|
+
* same chat, after a summarization and in a new chat. `action` is the task
|
|
64
|
+
* action the user took (approve / request-changes / comment / done / resolve).
|
|
65
|
+
*/
|
|
66
|
+
export function pasteMessage({ kind, action, receive }) {
|
|
67
|
+
const noun = NOUN[kind] ?? "Deck";
|
|
68
|
+
const run = `\`${receive}\``;
|
|
69
|
+
if (action === "approve") return `${noun} approved. Continue: ${run}`;
|
|
70
|
+
if (action === "done") return `${noun} edits done. Continue: ${run}`;
|
|
71
|
+
if (action === "resolve") return `${noun} conflict resolved. Continue: ${run}`;
|
|
72
|
+
return `Changes requested on the ${noun.toLowerCase()}. Read them: ${run}`;
|
|
73
|
+
}
|