@foldspace_npm/harness 0.1.16 → 0.1.18
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.md +55 -21
- package/README.md +1 -1
- package/bin/attach.mjs +73 -20
- package/bin/badge.mjs +50 -0
- package/bin/inject.mjs +2 -2
- package/bin/observe.mjs +4 -3
- package/package.json +1 -1
- package/recipes/INDEX.md +7 -2
- package/recipes/account-overview/README.md +26 -0
- package/recipes/account-overview/agent/accounts.ts +159 -0
- package/recipes/account-overview/agent/actions/show_account_overview.ts +61 -0
- package/recipes/account-overview/agent/api/accounts.ts +59 -0
- package/recipes/account-overview/agent/views/brand.ts +22 -0
- package/recipes/account-overview/agent/views/overview.ts +303 -0
- package/recipes/account-overview/fixtures/overview.empty.json +12 -0
- package/recipes/account-overview/fixtures/overview.ok.json +30 -0
- package/recipes/account-overview/fixtures/overview.unsigned.json +9 -0
- package/recipes/account-overview/recipe.json +10 -0
- package/recipes/bottom-bar/README.md +43 -0
- package/recipes/bottom-bar/agent/bottomBar.ts +94 -0
- package/recipes/bottom-bar/fixtures/configuration.sent.json +17 -0
- package/recipes/bottom-bar/recipe.json +9 -0
- package/recipes/opportunities-at-risk/README.md +26 -0
- package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +49 -0
- package/recipes/opportunities-at-risk/agent/api/opportunities.ts +30 -0
- package/recipes/opportunities-at-risk/agent/opportunities.ts +75 -0
- package/recipes/opportunities-at-risk/agent/views/at-risk.ts +115 -0
- package/recipes/opportunities-at-risk/agent/views/brand.ts +20 -0
- package/recipes/opportunities-at-risk/fixtures/at-risk.empty.json +4 -0
- package/recipes/opportunities-at-risk/fixtures/at-risk.ok.json +25 -0
- package/recipes/opportunities-at-risk/fixtures/at-risk.unsigned.json +3 -0
- package/recipes/opportunities-at-risk/recipe.json +10 -0
- package/recipes/prepare-for-a-meeting/README.md +27 -0
- package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +70 -0
- package/recipes/prepare-for-a-meeting/agent/api/meetings.ts +24 -0
- package/recipes/prepare-for-a-meeting/agent/meetings.ts +66 -0
- package/recipes/prepare-for-a-meeting/fixtures/prep.ok.json +24 -0
- package/recipes/prepare-for-a-meeting/fixtures/prep.unsigned.json +9 -0
- package/recipes/prepare-for-a-meeting/recipe.json +10 -0
- package/recipes/update-meeting-notes/README.md +22 -0
- package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +36 -0
- package/recipes/update-meeting-notes/agent/api/meetings.ts +22 -0
- package/recipes/update-meeting-notes/fixtures/note.ok.json +3 -0
- package/recipes/update-meeting-notes/recipe.json +10 -0
- package/recipes/upload-contacts/README.md +25 -0
- package/recipes/upload-contacts/agent/actions/upload_contacts.ts +77 -0
- package/recipes/upload-contacts/agent/api/contacts.ts +29 -0
- package/recipes/upload-contacts/agent/contacts.ts +133 -0
- package/recipes/upload-contacts/agent/views/brand.ts +21 -0
- package/recipes/upload-contacts/agent/views/uploader.ts +394 -0
- package/recipes/upload-contacts/fixtures/import.ok.json +9 -0
- package/recipes/upload-contacts/recipe.json +10 -0
- package/src/attach-preflight.mjs +9 -0
- package/src/badge-core.mjs +22 -0
- package/src/cli-registry.mjs +32 -3
- package/src/keep-focus.mjs +49 -0
package/CLAUDE.md
CHANGED
|
@@ -71,11 +71,15 @@ current CLI contract — risk, prerequisites, effects, next step.
|
|
|
71
71
|
so and offer the next one. On an empty account the useful first action is
|
|
72
72
|
one that creates data - say so instead.
|
|
73
73
|
|
|
74
|
-
**Say when the test window is yours, and when it is theirs
|
|
75
|
-
human share one Chrome window. Before you start looking
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
**Say when the test window is yours, and when it is theirs - on the page and
|
|
75
|
+
in chat.** You and the human share one Chrome window. Before you start looking
|
|
76
|
+
at the app or testing in it, run `npx foldspace badge working` (a bar across
|
|
77
|
+
the top of the window says Foldspace is building here, leave it alone) and say
|
|
78
|
+
so in chat: *"I'm going to work in that Chrome window for a few minutes -
|
|
79
|
+
please leave it alone until I tell you it's ready."* When it is their turn,
|
|
80
|
+
`npx foldspace badge ready --text "<the question this experience answers>"` -
|
|
81
|
+
the bar adds "Try asking:" and a Copy button itself - and say the same in chat. `foldspace badge label` puts the small
|
|
82
|
+
corner tag back; `off` removes it, for a screenshot they will share.
|
|
79
83
|
|
|
80
84
|
Navigation, uploads, extraction and anything that writes are a later session.
|
|
81
85
|
|
|
@@ -207,11 +211,17 @@ with how you established each.
|
|
|
207
211
|
| Navigation route | **yes** | `isActive: true` |
|
|
208
212
|
|
|
209
213
|
Publishing a **new** object is inert; publishing an **edit to a live one** is
|
|
210
|
-
the deploy above. Navigation has
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
214
|
+
the deploy above. **Navigation has two switches, and both must be on.** Each
|
|
215
|
+
route has its own `isActive`, set when you create it (`create_navigation_route`
|
|
216
|
+
/ `bulk_create_navigation_routes`, `isActive: true`). The agent has one
|
|
217
|
+
agent-level switch, **off by default on a new subscription**: `publish_navigation`
|
|
218
|
+
turns it on, `unpublish_navigation` off, and neither touches the routes' own
|
|
219
|
+
`isActive`. `list_navigation_routes` shows both. There is no MCP tool to flip a
|
|
220
|
+
single route's `isActive` after creation (it was disabled until Agent Studio
|
|
221
|
+
can reverse it) - create it right, or `update_navigation_route`, which replaces
|
|
222
|
+
the whole route. Route descriptions are a retrieval surface: write what the
|
|
223
|
+
page is *for*, in the user's words, and pair every parameterised route with the
|
|
224
|
+
lookup action that produces its parameter — or ship neither.
|
|
215
225
|
|
|
216
226
|
**Ask before publishing.** It is required only so the copilot can call the
|
|
217
227
|
action, and it is a live product change when the agent has real users.
|
|
@@ -247,6 +257,23 @@ copilot, read the daemon log for `[actions]` SDK callback and local
|
|
|
247
257
|
execute/render lines. Those record names, statuses, durations and parameter keys
|
|
248
258
|
only — never results or error bodies.
|
|
249
259
|
|
|
260
|
+
## Done, and what you say at the end
|
|
261
|
+
|
|
262
|
+
A first session is done when the customer has seen one experience answer from
|
|
263
|
+
their own data, in the test window, and been invited to try it. Not when the
|
|
264
|
+
gates pass, and not when there is nothing left to improve.
|
|
265
|
+
|
|
266
|
+
If they ask *"how do I put this in front of my users?"*, the honest answer is:
|
|
267
|
+
|
|
268
|
+
> What you've seen runs in a test window on your machine. Putting it in front
|
|
269
|
+
> of your users is a separate step, and the Foldspace team does it with you.
|
|
270
|
+
> Reach them through the website - <https://foldspace.ai/demo> - or at
|
|
271
|
+
> <support@foldspace.ai>. Everything built today carries over: the actions are
|
|
272
|
+
> already in your Foldspace account, and the code is in this folder.
|
|
273
|
+
|
|
274
|
+
Never run `foldspace deploy`, never install cloud tooling, never ask them for
|
|
275
|
+
cloud credentials: `deploy` is Foldspace-internal and no customer can run it.
|
|
276
|
+
|
|
250
277
|
## Verification gates
|
|
251
278
|
|
|
252
279
|
Do not report success without all six:
|
|
@@ -288,14 +315,17 @@ Do not report success without all six:
|
|
|
288
315
|
MCP (`search_docs`, `fetch_page`) before asserting how the platform behaves;
|
|
289
316
|
cite the page, or say plainly that the docs do not cover it and write down
|
|
290
317
|
what you learned.
|
|
291
|
-
4. **
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
318
|
+
4. **Test through the agent, then let them.** In a first session, talk to the
|
|
319
|
+
agent yourself to prove an experience - type the real question into its
|
|
320
|
+
panel in the test window and read the reply - and only then invite the
|
|
321
|
+
customer to try it. Those conversations appear in their Foldspace dashboard,
|
|
322
|
+
and that is wanted: seeing their own conversations recorded is part of what
|
|
323
|
+
they came to see. Never pass `--test-mode` in a first session.
|
|
324
|
+
5. **Test mode is for an agent that already has real users.** Then `attach
|
|
325
|
+
--test-mode` keeps your conversations out of their data. It is not a safety
|
|
326
|
+
net: publishing still changes the live agent. Working on an agent in
|
|
327
|
+
production - test mode, pinned action versions, what may be published - is
|
|
328
|
+
a separate playbook, not this one.
|
|
299
329
|
6. **The human is not necessarily a developer.** Never hand over a terminal
|
|
300
330
|
line, a stack trace, or a choice between flags. Name experiences by outcome
|
|
301
331
|
— *"show my plan and credits"*, not `get_account_status`. Their two jobs:
|
|
@@ -408,10 +438,14 @@ placeholder path and fields with what you observed. They match the harness
|
|
|
408
438
|
version you have installed, and every one is built and run on each release.
|
|
409
439
|
|
|
410
440
|
L0 → `who-is-the-user`. L2 → `find-by-name`, and `pick-from-a-list` when the
|
|
411
|
-
user has to choose.
|
|
441
|
+
user has to choose. Show one account on a card → `account-overview`. Upload a
|
|
442
|
+
CSV of contacts → `upload-contacts`. Save a note on a meeting →
|
|
443
|
+
`update-meeting-notes`. Prepare for a meeting → `prepare-for-a-meeting`. The records that need
|
|
444
|
+
attention, with reasons → `opportunities-at-risk`.
|
|
445
|
+
App does not use a bearer token → `swap-the-login-method`.
|
|
412
446
|
Each says how many production builds carry the pattern and what they learned.
|
|
413
|
-
If nothing fits — there is no L1 card recipe yet
|
|
414
|
-
`agent/actions/_example.ts`.
|
|
447
|
+
If nothing fits — there is no L1 card recipe yet, and no navigation recipe —
|
|
448
|
+
say so and build from `agent/actions/_example.ts`.
|
|
415
449
|
|
|
416
450
|
## What you already have
|
|
417
451
|
|
package/README.md
CHANGED
|
@@ -252,7 +252,7 @@ and **neither had all five**:
|
|
|
252
252
|
| SDK load guard + retry | On an app with no `document.body` at document-start the append throws; the old guard tested for the stub, so nothing retried and the agent silently never loaded |
|
|
253
253
|
| Worker release on auto-attach | Auto-attach pauses every worker until the attaching client releases it — the harness froze the app's own workers for the whole session |
|
|
254
254
|
| `agentIds` enumeration | `agent({apiName})` returns the OVERLAY handle; arming it on an embedded copilot leaves real conversations untagged |
|
|
255
|
-
| `--
|
|
255
|
+
| `--test-mode` (off by default) | Test mode was armed unconditionally, hiding exactly what initial setup needs to see; now it is opt-in |
|
|
256
256
|
| Honest badge | It asserted `TEST MODE` whether or not test mode was on |
|
|
257
257
|
|
|
258
258
|
Two further changes came from packaging it: the bundle is **injected** rather
|
package/bin/attach.mjs
CHANGED
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from "../src/attach-preflight.mjs";
|
|
45
45
|
import { serializeDiagnostic } from "../src/diagnostics.mjs";
|
|
46
46
|
import { recordEvent } from "../src/session-events.mjs";
|
|
47
|
+
import { withoutStealingFocus } from "../src/keep-focus.mjs";
|
|
47
48
|
import {
|
|
48
49
|
ERROR_CODES,
|
|
49
50
|
createLifecycleResult,
|
|
@@ -135,11 +136,13 @@ try {
|
|
|
135
136
|
}
|
|
136
137
|
const bootstrap = attachMode === ATTACH_MODES.BOOTSTRAP;
|
|
137
138
|
const replace = attachMode === ATTACH_MODES.REPLACE;
|
|
138
|
-
// Test mode keeps conversations out of the customer's default list.
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
139
|
+
// Test mode keeps conversations out of the customer's default list. It is OFF
|
|
140
|
+
// by default: in a first session the customer's own conversations appearing in
|
|
141
|
+
// their dashboard is the point - they see what Foldspace records - and an
|
|
142
|
+
// orange TEST MODE label would imply a safety (nothing published) that does
|
|
143
|
+
// not exist. --test-mode opts in, for an agent that already has real users.
|
|
144
|
+
// --no-test-mode is still accepted so older instructions keep working.
|
|
145
|
+
const noTestMode = !process.argv.includes("--test-mode");
|
|
143
146
|
// The badge used to hardcode "TEST MODE" whether or not test mode was on — a
|
|
144
147
|
// string that asserts you are safe while you are not. It now states what is
|
|
145
148
|
// actually true. --no-badge drops it entirely, for recording a demo.
|
|
@@ -171,8 +174,8 @@ const agentKey = cfgTarget.overrideKey || `EU-${productId}-1-1`;
|
|
|
171
174
|
const sdkUrl = cfgTarget.sdkUrl || cfg.sdkUrl;
|
|
172
175
|
const actionTarget = { productId, agentApiName };
|
|
173
176
|
const badgeText = noTestMode
|
|
174
|
-
? `FOLDSPACE
|
|
175
|
-
: `FOLDSPACE
|
|
177
|
+
? `FOLDSPACE AGENT BUILDER \u00b7 ${agentApiName} \u00b7 ${attachMode} \u00b7 LIVE`
|
|
178
|
+
: `FOLDSPACE AGENT BUILDER \u00b7 ${agentApiName} \u00b7 ${attachMode} \u00b7 TEST MODE`;
|
|
176
179
|
|
|
177
180
|
// Refuse to run against an uninitialised template. Without this, attach injects
|
|
178
181
|
// the literal placeholder as an agent api name and the page fails with an opaque
|
|
@@ -218,19 +221,69 @@ const SDK_PATTERNS = [
|
|
|
218
221
|
"*://*/*eucera.js*",
|
|
219
222
|
];
|
|
220
223
|
|
|
224
|
+
// The badge has three states, set from outside with \`foldspace badge\`:
|
|
225
|
+
// label - the small corner tag (default): who is attached, which mode
|
|
226
|
+
// working - a bar across the top: the agent is building in this window,
|
|
227
|
+
// please leave it alone
|
|
228
|
+
// ready - a bar: it is the human's turn, with the question to try
|
|
229
|
+
// The state lives in sessionStorage so it survives navigation inside the
|
|
230
|
+
// test window; \`foldspace badge\` writes it and the script redraws.
|
|
221
231
|
const badgeSrc = `(() => {
|
|
222
232
|
if (window.top !== window.self) return;
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
b.style.cssText = "position:fixed;top:0;left:0;z-index:2147483647;background:#3247F2;color:#fff;font:700 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.12em;padding:5px 10px;border-bottom-right-radius:4px;pointer-events:none;box-shadow:0 1px 6px rgba(0,0,0,.35)";
|
|
229
|
-
document.body.appendChild(b);
|
|
233
|
+
const LABEL = ${JSON.stringify(badgeText)};
|
|
234
|
+
const KEY = "__foldspace_badge__";
|
|
235
|
+
const read = () => {
|
|
236
|
+
try { return JSON.parse(sessionStorage.getItem(KEY) || "null") || { state: "label" }; }
|
|
237
|
+
catch (e) { return { state: "label" }; }
|
|
230
238
|
};
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
239
|
+
const draw = () => {
|
|
240
|
+
if (!document.body) return;
|
|
241
|
+
const s = read();
|
|
242
|
+
if (s.state === "off") { document.getElementById("foldspace-dev-badge")?.remove(); return; }
|
|
243
|
+
let b = document.getElementById("foldspace-dev-badge");
|
|
244
|
+
if (!b) { b = document.createElement("div"); b.id = "foldspace-dev-badge"; document.body.appendChild(b); }
|
|
245
|
+
const want = s.state + "|" + (s.text || "");
|
|
246
|
+
if (b.dataset.fsState === want) return;
|
|
247
|
+
b.dataset.fsState = want;
|
|
248
|
+
const base = "position:fixed;top:0;left:0;z-index:2147483647;color:#fff;font:700 10px/1 ui-monospace,Menlo,monospace;letter-spacing:.12em;pointer-events:none;box-shadow:0 1px 6px rgba(0,0,0,.35);";
|
|
249
|
+
if (s.state === "working") {
|
|
250
|
+
// Attention, not decoration: a pulsing marker, the ask in bold, and an
|
|
251
|
+
// animated line underneath so it reads as "in progress" at a glance.
|
|
252
|
+
b.style.cssText = base + "right:0;background:#1F2A8A;padding:10px 14px 12px;font-size:13px;letter-spacing:.02em;text-align:center;line-height:1.3;";
|
|
253
|
+
b.innerHTML =
|
|
254
|
+
'<style>@keyframes fsPulse{0%,100%{opacity:1}50%{opacity:.25}}@keyframes fsSweep{0%{left:-30%}100%{left:100%}}' +
|
|
255
|
+
'#foldspace-dev-badge .fs-dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#FFB300;margin-right:10px;vertical-align:middle;animation:fsPulse 1s ease-in-out infinite}' +
|
|
256
|
+
'#foldspace-dev-badge .fs-line{position:absolute;left:0;right:0;bottom:0;height:3px;background:rgba(255,255,255,.15);overflow:hidden}' +
|
|
257
|
+
'#foldspace-dev-badge .fs-line::after{content:"";position:absolute;top:0;width:30%;height:100%;background:#FFB300;animation:fsSweep 1.4s linear infinite}</style>' +
|
|
258
|
+
'<span class="fs-dot"></span>Foldspace is building your agent on this page. <b style="color:#FFD54F">Please leave this window alone</b> until Claude says it is ready.' +
|
|
259
|
+
(s.text ? " " + s.text.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" })[c]) : "") +
|
|
260
|
+
'<span class="fs-line"></span>';
|
|
261
|
+
} else if (s.state === "ready") {
|
|
262
|
+
// Calm, and useful: the question to try, with a Copy button so nobody
|
|
263
|
+
// retypes it into the panel. The bar accepts clicks only on that button.
|
|
264
|
+
const esc = (t) => String(t).replace(/[<>&"]/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """ })[c]);
|
|
265
|
+
const question = s.text ? esc(s.text) : "";
|
|
266
|
+
b.style.cssText = base + "right:0;background:#0F9D58;padding:10px 14px;font-size:13px;letter-spacing:.02em;text-align:center;line-height:1.3;pointer-events:none;";
|
|
267
|
+
b.innerHTML =
|
|
268
|
+
'<style>#foldspace-dev-badge .fs-copy{pointer-events:auto;margin-left:12px;padding:4px 10px;border:1px solid rgba(255,255,255,.7);border-radius:4px;background:transparent;color:#fff;font:inherit;font-size:12px;cursor:pointer;letter-spacing:0}' +
|
|
269
|
+
'#foldspace-dev-badge .fs-copy:hover{background:rgba(255,255,255,.15)}</style>' +
|
|
270
|
+
'<b>Ready - your turn.</b> ' +
|
|
271
|
+
(question ? 'Try asking: <span class="fs-q">' + question + '</span><button class="fs-copy" type="button">Copy</button>' : "Open your agent and try it.");
|
|
272
|
+
const btn = b.querySelector(".fs-copy");
|
|
273
|
+
if (btn) btn.addEventListener("click", () => {
|
|
274
|
+
const q = b.querySelector(".fs-q")?.textContent || "";
|
|
275
|
+
const done = () => { btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy"; }, 1500); };
|
|
276
|
+
if (navigator.clipboard?.writeText) navigator.clipboard.writeText(q).then(done, done);
|
|
277
|
+
else { const ta = document.createElement("textarea"); ta.value = q; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); } catch (e) {} ta.remove(); done(); }
|
|
278
|
+
});
|
|
279
|
+
} else {
|
|
280
|
+
b.style.cssText = base + "background:#3247F2;padding:5px 10px;border-bottom-right-radius:4px;";
|
|
281
|
+
b.textContent = LABEL;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
draw();
|
|
285
|
+
document.addEventListener("DOMContentLoaded", draw);
|
|
286
|
+
setInterval(draw, 1000);
|
|
234
287
|
})();`;
|
|
235
288
|
|
|
236
289
|
// Flag every local session as test traffic so it is excluded from analytics and
|
|
@@ -696,7 +749,7 @@ async function prepare(ws, sessionId, url) {
|
|
|
696
749
|
),
|
|
697
750
|
});
|
|
698
751
|
console.log(` attached -> ${url}`);
|
|
699
|
-
await call(ws, "Page.reload", { ignoreCache: true }, sessionId);
|
|
752
|
+
await withoutStealingFocus(() => call(ws, "Page.reload", { ignoreCache: true }, sessionId));
|
|
700
753
|
|
|
701
754
|
{
|
|
702
755
|
const verification = await verifyPreparedPage(ws, sessionId);
|
|
@@ -951,7 +1004,7 @@ const ver = await connect(owned.version);
|
|
|
951
1004
|
console.log(`Attached to ${ver.Browser} on :${port}`);
|
|
952
1005
|
console.log(`Mode: ${attachMode}`);
|
|
953
1006
|
console.log(`Agent: ${agentApiName} (product ${productId}, ${agentMode})`);
|
|
954
|
-
console.log(`Test: ${noTestMode ? "
|
|
1007
|
+
console.log(`Test: ${noTestMode ? "off — conversations appear in the dashboard (pass --test-mode to hide them)" : "on"}`);
|
|
955
1008
|
console.log(`Hosts: ${hostPatterns.join(", ")}`);
|
|
956
1009
|
console.log(`Serving: ${path.relative(root, bundlePath)}`);
|
|
957
1010
|
console.log(
|
|
@@ -1011,7 +1064,7 @@ async function cleanupPreparedSession(
|
|
|
1011
1064
|
// identity aliases alike.
|
|
1012
1065
|
if (reload) {
|
|
1013
1066
|
try {
|
|
1014
|
-
await call(socket, "Page.reload", { ignoreCache: true }, sessionId);
|
|
1067
|
+
await withoutStealingFocus(() => call(socket, "Page.reload", { ignoreCache: true }, sessionId));
|
|
1015
1068
|
} catch (error) {
|
|
1016
1069
|
failures.push(`restore reload: ${error instanceof Error ? error.message : String(error)}`);
|
|
1017
1070
|
}
|
package/bin/badge.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* foldspace badge <working|ready|label|off> [--text "<what to show>"]
|
|
4
|
+
*
|
|
5
|
+
* Sets the banner the attach badge draws across the top of the test window.
|
|
6
|
+
* The agent and the human share that one Chrome window, so the banner says
|
|
7
|
+
* whose turn it is:
|
|
8
|
+
* working - "Foldspace is building your agent on this page. Please leave
|
|
9
|
+
* this window alone until Claude says it is ready."
|
|
10
|
+
* ready - "Ready - your turn. Open your agent and try it." (pass --text
|
|
11
|
+
* with the question to try)
|
|
12
|
+
* label - back to the small corner tag
|
|
13
|
+
* off - remove it
|
|
14
|
+
* The state is kept in the page's sessionStorage, so it survives navigation;
|
|
15
|
+
* attach's badge script redraws within a second. Nothing else on the page is
|
|
16
|
+
* touched.
|
|
17
|
+
*/
|
|
18
|
+
import { CdpPage, parseArgs, printJson } from "../src/cdp-client.mjs";
|
|
19
|
+
import { resolveProjectDir } from "../src/upgrade.mjs";
|
|
20
|
+
import { badgePayload } from "../src/badge-core.mjs";
|
|
21
|
+
|
|
22
|
+
const projectDir = resolveProjectDir();
|
|
23
|
+
const { flags, rest } = parseArgs(process.argv.slice(2), ["text"]);
|
|
24
|
+
|
|
25
|
+
let payload;
|
|
26
|
+
try {
|
|
27
|
+
const unknown = Object.keys(flags).filter((name) => name !== "text");
|
|
28
|
+
if (unknown.length) throw new Error(`unknown option '--${unknown[0]}'`);
|
|
29
|
+
if (rest.length > 1) throw new Error(`unexpected argument '${rest[1]}'`);
|
|
30
|
+
payload = badgePayload(rest[0], flags.text);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error(`badge: ${error instanceof Error ? error.message : String(error)}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let page;
|
|
37
|
+
try {
|
|
38
|
+
page = await CdpPage.open({ projectDir });
|
|
39
|
+
const shown = await page.evaluate(
|
|
40
|
+
`sessionStorage.setItem("__foldspace_badge__", ${JSON.stringify(JSON.stringify(payload))});
|
|
41
|
+
const b = document.getElementById("foldspace-dev-badge");
|
|
42
|
+
return { url: location.href.split(/[?#]/)[0], hadBadge: Boolean(b) };`,
|
|
43
|
+
);
|
|
44
|
+
printJson({ ok: true, state: payload.state, ...(payload.text ? { text: payload.text } : {}), ...shown });
|
|
45
|
+
} catch (error) {
|
|
46
|
+
printJson({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
47
|
+
process.exit(1);
|
|
48
|
+
} finally {
|
|
49
|
+
page?.close();
|
|
50
|
+
}
|
package/bin/inject.mjs
CHANGED
|
@@ -144,7 +144,7 @@ fs.writeFileSync(
|
|
|
144
144
|
path.join(themeDir, "manifest.json"),
|
|
145
145
|
JSON.stringify({
|
|
146
146
|
manifest_version: 3,
|
|
147
|
-
name: "Foldspace
|
|
147
|
+
name: "Foldspace Agent Builder Theme",
|
|
148
148
|
version: "1.0",
|
|
149
149
|
description: "Paints the dev browser Foldspace blue.",
|
|
150
150
|
theme: {
|
|
@@ -197,7 +197,7 @@ function nameProfile() {
|
|
|
197
197
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
198
198
|
fs.writeFileSync(file, JSON.stringify(data));
|
|
199
199
|
};
|
|
200
|
-
const label = `Foldspace
|
|
200
|
+
const label = `Foldspace Agent Builder \u2014 ${target.name}`;
|
|
201
201
|
writeMerged(path.join(profileDir, "Local State"), (d) => {
|
|
202
202
|
d.profile = d.profile || {};
|
|
203
203
|
d.profile.info_cache = d.profile.info_cache || {};
|
package/bin/observe.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import fs from "node:fs";
|
|
|
22
22
|
import path from "node:path";
|
|
23
23
|
|
|
24
24
|
import { CdpPage, parseArgs, printJson, readLaunchState, sleep } from "../src/cdp-client.mjs";
|
|
25
|
+
import { withoutStealingFocus } from "../src/keep-focus.mjs";
|
|
25
26
|
import {
|
|
26
27
|
NAV_ITEM_SELECTOR,
|
|
27
28
|
NAV_SCOPE_SELECTOR,
|
|
@@ -330,7 +331,7 @@ async function runScreen(page, flags) {
|
|
|
330
331
|
let reached;
|
|
331
332
|
|
|
332
333
|
if (destination) {
|
|
333
|
-
await page.send("Page.navigate", { url: destination.url });
|
|
334
|
+
await withoutStealingFocus(() => page.send("Page.navigate", { url: destination.url }));
|
|
334
335
|
reached = { how: "goto", path: destination.path };
|
|
335
336
|
if (flags.click !== undefined) await network.settle(Math.min(waitMs, 3000));
|
|
336
337
|
}
|
|
@@ -360,7 +361,7 @@ async function runScreen(page, flags) {
|
|
|
360
361
|
}
|
|
361
362
|
if (!reached) {
|
|
362
363
|
// Nothing to reach: reload so this screen's own requests are seen.
|
|
363
|
-
await page.send("Page.reload");
|
|
364
|
+
await withoutStealingFocus(() => page.send("Page.reload"));
|
|
364
365
|
reached = { how: "reload" };
|
|
365
366
|
}
|
|
366
367
|
await network.settle(waitMs);
|
|
@@ -456,7 +457,7 @@ async function runAuth(page, flags) {
|
|
|
456
457
|
const waitMs = numberFlag(flags.wait, 5000, "wait");
|
|
457
458
|
await page.send("Network.enable");
|
|
458
459
|
const network = recordNetwork(page);
|
|
459
|
-
await page.send("Page.reload");
|
|
460
|
+
await withoutStealingFocus(() => page.send("Page.reload"));
|
|
460
461
|
await network.settle(waitMs);
|
|
461
462
|
|
|
462
463
|
const url = await page.evaluate("return location.href;");
|
package/package.json
CHANGED
package/recipes/INDEX.md
CHANGED
|
@@ -7,9 +7,14 @@ Read this whole table, then open the recipe closest to the outcome.
|
|
|
7
7
|
| [`who-is-the-user`](who-is-the-user/) | **L0** | Foldspace knows who is signed in — id, email, name, role, subscription — so conversations and analytics are not anonymous | Runs once at start-up. No action, no card | 4 production builds |
|
|
8
8
|
| [`find-by-name`](find-by-name/) | **L2** | The agent turns a name the user said into the id the next action needs; a count when no name is given; every tie when the name is ambiguous; near-misses when nothing matches | A data action. **No card** | 3 production builds |
|
|
9
9
|
| [`pick-from-a-list`](pick-from-a-list/) | **L2** | A clickable list, shown **only** when the user has to choose — with "none of these" and cancel | An action with a card that waits for the user | 1 production build |
|
|
10
|
+
| [`bottom-bar`](bottom-bar/) | **L0** | The agent's resting entry point is a bar at the foot of the page whose starters change with the screen the user is on — configured from code, the way customers do it | Page set-up, runs once at start-up | 2 |
|
|
10
11
|
| [`swap-the-login-method`](swap-the-login-method/) | any | `apiFetch` for an app that authenticates with a custom header, or with cookies, instead of a bearer token | A replacement `agent/utils.ts` | 2 production builds (3 builds, 3 different schemes) |
|
|
12
|
+
| [`account-overview`](account-overview/) | **L2** | One account on a card: identity, pipeline total, contacts, cases, meetings. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
|
|
13
|
+
| [`upload-contacts`](upload-contacts/) | **L4** | A CSV is previewed, then written only after the user chooses Import | An action with a card that waits for the user | 1 production build |
|
|
14
|
+
| [`update-meeting-notes`](update-meeting-notes/) | **L4** | A note is saved on a meeting and the new note id comes back | A write. **No card** | 1 production build |
|
|
15
|
+
| [`prepare-for-a-meeting`](prepare-for-a-meeting/) | **L2** | Meeting facts are gathered in code, then a task agent writes the summary | A data action plus a task agent. **No card** | 1 production build |
|
|
16
|
+
| [`opportunities-at-risk`](opportunities-at-risk/) | **L2** | Every open opportunity the app flags as at risk, on one card: count, total, and why each one is at risk. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
|
|
11
17
|
|
|
12
18
|
**Not here yet — no production build proves it:** the user's own plan or
|
|
13
19
|
settings drawn as a card (L1). In production that data is sent to `identify`
|
|
14
|
-
(`who-is-the-user`), not rendered. Navigation (L3)
|
|
15
|
-
task agents are also still to come.
|
|
20
|
+
(`who-is-the-user`), not rendered. Navigation (L3) is also still to come.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Account overview — L2
|
|
2
|
+
|
|
3
|
+
**The user says** "show me this account". **The user sees** the account card: identity, phone, revenue, a metrics bar, and tabs for opportunities, meetings, cases, and contacts.
|
|
4
|
+
|
|
5
|
+
**Proven by 1 production build.**
|
|
6
|
+
|
|
7
|
+
This card is the base layout. Copy it, then replace the `/__observe_me` routes with the app's own paths and re-sample `brand.ts` if the product is not this dark theme.
|
|
8
|
+
|
|
9
|
+
The card is view-only. `awaitUserInput` stays off. For a component that only shows something, the model is told that the information was displayed — nothing `execute` returned and nothing the card drew. One tab is on screen at a time; an empty tab says so. A signed-out or failed call is drawn with `renderFailure`, never as zeros. A 200 full of nulls is signed out when the account has no id and no name.
|
|
10
|
+
|
|
11
|
+
In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId`.
|
|
12
|
+
|
|
13
|
+
## Adapt it
|
|
14
|
+
|
|
15
|
+
| File | Change |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `agent/api/accounts.ts` | Mock path and envelope. Find a real API that returns a similar shape |
|
|
18
|
+
| `agent/accounts.ts` | Which fields a summary carries. The pipeline total is summed here |
|
|
19
|
+
| `agent/views/overview.ts` | The card. Routes under `/__observe_me` are placeholders for the app's own paths. Re-sample `brand.ts` when the host product is not this dark theme |
|
|
20
|
+
|
|
21
|
+
## What that build learned the hard way
|
|
22
|
+
|
|
23
|
+
- **The card is the experience.** A rich object returned from `execute` does not reach the model when the action only draws.
|
|
24
|
+
- **Add the money in code.** Line items summed by the model eventually come out wrong. The card shows the total the code computed.
|
|
25
|
+
- **The links only work where the app listens.** "Open Account", the rows and "View All" post a message the build's single-page app routed on; other apps do not. ⚠️ Wire that message in the host app, or replace the links with a navigation route.
|
|
26
|
+
- **A signed-out response often looks empty.** Zeros and nulls on a 200 are not an account with no pipeline. Check for an id and a name before drawing numbers.
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Totals are computed here, from the API's own numbers. The model does not add them.
|
|
2
|
+
|
|
3
|
+
import type { OverviewPayload } from "./api/accounts";
|
|
4
|
+
|
|
5
|
+
export type AccountSummary = {
|
|
6
|
+
id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
industry: string | null;
|
|
9
|
+
website: string | null;
|
|
10
|
+
phone: string | null;
|
|
11
|
+
revenue: number | null;
|
|
12
|
+
employeeCount: number | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type OpportunitySummary = {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
value: number;
|
|
19
|
+
stage: string | null;
|
|
20
|
+
closeDate: string | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ContactSummary = {
|
|
24
|
+
id: string;
|
|
25
|
+
name: string;
|
|
26
|
+
email: string | null;
|
|
27
|
+
phone: string | null;
|
|
28
|
+
title: string | null;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type CaseSummary = {
|
|
32
|
+
id: string;
|
|
33
|
+
title: string;
|
|
34
|
+
priority: string | null;
|
|
35
|
+
status: string | null;
|
|
36
|
+
createdAt: string | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type MeetingSummary = {
|
|
40
|
+
id: string;
|
|
41
|
+
title: string;
|
|
42
|
+
startTime: string | null;
|
|
43
|
+
attendees: number;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type AccountMetrics = {
|
|
47
|
+
openOpportunities: number;
|
|
48
|
+
pipelineValue: number;
|
|
49
|
+
contacts: number;
|
|
50
|
+
openCases: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type AccountOverview = {
|
|
54
|
+
account: AccountSummary;
|
|
55
|
+
metrics: AccountMetrics;
|
|
56
|
+
opportunities: OpportunitySummary[];
|
|
57
|
+
contacts: ContactSummary[];
|
|
58
|
+
openCases: CaseSummary[];
|
|
59
|
+
recentMeetings: MeetingSummary[];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** A 200 full of nulls is not an account. Zeros are only real once an id and a name are present. */
|
|
63
|
+
export function belongsToSignedInUser(payload: OverviewPayload): boolean {
|
|
64
|
+
const id = payload.account?.id;
|
|
65
|
+
const name = payload.account?.name;
|
|
66
|
+
return typeof id === "string" && id.trim() !== "" && typeof name === "string" && name.trim() !== "";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function formatMoney(value: number): string {
|
|
70
|
+
const rounded = Math.round(value);
|
|
71
|
+
const sign = rounded < 0 ? "-" : "";
|
|
72
|
+
const digits = String(Math.abs(rounded));
|
|
73
|
+
const grouped = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
74
|
+
return `${sign}$${grouped}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The metrics bar shortens the pipeline: $18K, $2.4M. Row amounts stay full. */
|
|
78
|
+
export function formatPipeline(value: number): string {
|
|
79
|
+
const amount = Math.abs(value);
|
|
80
|
+
const sign = value < 0 ? "-" : "";
|
|
81
|
+
if (amount >= 1_000_000) return `${sign}$${(amount / 1_000_000).toFixed(1)}M`;
|
|
82
|
+
if (amount >= 1000) return `${sign}$${(amount / 1000).toFixed(0)}K`;
|
|
83
|
+
return `${sign}$${amount.toFixed(0)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function formatWhen(iso: string | null, withTime = false): string | null {
|
|
87
|
+
if (!iso) return null;
|
|
88
|
+
const date = new Date(iso);
|
|
89
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
90
|
+
const day = date.toLocaleDateString();
|
|
91
|
+
if (!withTime) return day;
|
|
92
|
+
const time = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
|
93
|
+
return `${day} at ${time}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function titleCase(value: string): string {
|
|
97
|
+
return value
|
|
98
|
+
.split(/[_\s]+/)
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
101
|
+
.join(" ");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const text = (value: string | null | undefined, fallback: string) => value?.trim() || fallback;
|
|
105
|
+
|
|
106
|
+
export function toOverview(payload: OverviewPayload): AccountOverview {
|
|
107
|
+
const account = payload.account ?? {};
|
|
108
|
+
const opportunities = (payload.opportunities ?? []).map((row) => ({
|
|
109
|
+
id: row.id?.trim() || "",
|
|
110
|
+
name: text(row.name, "Untitled"),
|
|
111
|
+
value: typeof row.value === "number" && Number.isFinite(row.value) ? row.value : 0,
|
|
112
|
+
stage: row.stage?.trim() || null,
|
|
113
|
+
closeDate: row.closeDate?.trim() || null,
|
|
114
|
+
}));
|
|
115
|
+
const contacts = (payload.contacts ?? []).map((row) => ({
|
|
116
|
+
id: row.id?.trim() || "",
|
|
117
|
+
name: text(row.name, "Untitled"),
|
|
118
|
+
email: row.email?.trim() || null,
|
|
119
|
+
phone: row.phone?.trim() || null,
|
|
120
|
+
title: row.title?.trim() || null,
|
|
121
|
+
}));
|
|
122
|
+
const openCases = (payload.openCases ?? []).map((row) => ({
|
|
123
|
+
id: row.id?.trim() || "",
|
|
124
|
+
title: text(row.title, "Untitled"),
|
|
125
|
+
priority: row.priority?.trim() || null,
|
|
126
|
+
status: row.status?.trim() || null,
|
|
127
|
+
createdAt: row.createdAt?.trim() || null,
|
|
128
|
+
}));
|
|
129
|
+
const recentMeetings = (payload.recentMeetings ?? []).map((row) => ({
|
|
130
|
+
id: row.id?.trim() || "",
|
|
131
|
+
title: text(row.title, "Untitled"),
|
|
132
|
+
startTime: row.startTime?.trim() || null,
|
|
133
|
+
attendees: Array.isArray(row.attendees) ? row.attendees.length : 0,
|
|
134
|
+
}));
|
|
135
|
+
const revenue = account.revenue;
|
|
136
|
+
const employees = account.employeeCount;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
account: {
|
|
140
|
+
id: String(account.id),
|
|
141
|
+
name: String(account.name).trim(),
|
|
142
|
+
industry: account.industry?.trim() || null,
|
|
143
|
+
website: account.website?.trim() || null,
|
|
144
|
+
phone: account.phone?.trim() || null,
|
|
145
|
+
revenue: typeof revenue === "number" && Number.isFinite(revenue) ? revenue : null,
|
|
146
|
+
employeeCount: typeof employees === "number" && Number.isFinite(employees) ? employees : null,
|
|
147
|
+
},
|
|
148
|
+
metrics: {
|
|
149
|
+
openOpportunities: opportunities.length,
|
|
150
|
+
pipelineValue: opportunities.reduce((sum, row) => sum + row.value, 0),
|
|
151
|
+
contacts: contacts.length,
|
|
152
|
+
openCases: openCases.length,
|
|
153
|
+
},
|
|
154
|
+
opportunities,
|
|
155
|
+
contacts,
|
|
156
|
+
openCases,
|
|
157
|
+
recentMeetings,
|
|
158
|
+
};
|
|
159
|
+
}
|