@foldspace_npm/harness 0.1.27 → 0.1.29
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 +301 -188
- package/README.md +54 -36
- package/bin/ask.mjs +9 -7
- package/bin/attach.mjs +80 -51
- package/bin/badge.mjs +28 -8
- package/bin/build.ts +19 -6
- package/bin/cli.mjs +23 -6
- package/bin/inject.mjs +3 -4
- package/bin/observe.mjs +22 -6
- package/bin/published.mjs +1 -1
- package/package.json +6 -2
- package/recipes/INDEX.md +13 -8
- package/recipes/README.md +30 -7
- package/recipes/account-overview/README.md +6 -6
- package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
- package/recipes/account-overview/agent/views/brand.ts +23 -14
- package/recipes/account-overview/agent/views/overview.ts +13 -17
- package/recipes/bottom-bar/README.md +12 -9
- package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
- package/recipes/create-a-record/README.md +97 -0
- package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
- package/recipes/create-a-record/agent/api/projects.ts +62 -0
- package/recipes/create-a-record/agent/projects.ts +145 -0
- package/recipes/create-a-record/agent/views/approval.ts +181 -0
- package/recipes/create-a-record/agent/views/brand.ts +35 -0
- package/recipes/create-a-record/agent/views/fields.ts +252 -0
- package/recipes/create-a-record/agent/views/project-card.ts +253 -0
- package/recipes/create-a-record/agent/views/steps.ts +92 -0
- package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
- package/recipes/create-a-record/fixtures/project.created.json +1 -0
- package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
- package/recipes/create-a-record/recipe.json +13 -0
- package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
- package/recipes/find-by-name/README.md +3 -1
- package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
- package/recipes/lookup-then-view/README.md +73 -0
- package/recipes/lookup-then-view/agent/accounts.ts +174 -0
- package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
- package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
- package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
- package/recipes/lookup-then-view/agent/views/account.ts +183 -0
- package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
- package/recipes/lookup-then-view/agent/views/format.ts +73 -0
- package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
- package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
- package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
- package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
- package/recipes/lookup-then-view/recipe.json +11 -0
- package/recipes/lookup-then-view/studio/find_account.json +10 -0
- package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
- package/recipes/opportunities-at-risk/README.md +5 -5
- package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
- package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
- package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
- package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
- package/recipes/pick-from-a-list/README.md +7 -6
- package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
- package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
- package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
- package/recipes/prepare-for-a-meeting/README.md +4 -4
- package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
- package/recipes/records-list/README.md +6 -6
- package/recipes/records-list/agent/accounts.ts +3 -1
- package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
- package/recipes/records-list/agent/views/brand.ts +26 -14
- package/recipes/server-rendered-forms/README.md +35 -0
- package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
- package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
- package/recipes/server-rendered-forms/agent/forms.ts +192 -0
- package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
- package/recipes/server-rendered-forms/fixtures/form.html +17 -0
- package/recipes/server-rendered-forms/fixtures/list.html +9 -0
- package/recipes/server-rendered-forms/fixtures/record.html +4 -0
- package/recipes/server-rendered-forms/recipe.json +10 -0
- package/recipes/several-hosts/README.md +10 -13
- package/recipes/several-hosts/agent/hosts.ts +19 -136
- package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
- package/recipes/several-hosts/recipe.json +3 -3
- package/recipes/swap-the-login-method/README.md +16 -5
- package/recipes/swap-the-login-method/agent/utils.ts +52 -3
- package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
- package/recipes/update-meeting-notes/README.md +10 -4
- package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
- package/recipes/upload-contacts/README.md +5 -5
- package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
- package/recipes/upload-contacts/agent/views/brand.ts +24 -14
- package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
- package/recipes/who-is-the-user/README.md +7 -3
- package/src/ask-core.mjs +3 -3
- package/src/attach-daemon.mjs +9 -2
- package/src/attach-helpers.mjs +105 -0
- package/src/attach-preflight.mjs +34 -11
- package/src/badge-core.mjs +1 -1
- package/src/cdp-client.mjs +14 -19
- package/src/cdp-ownership.mjs +41 -20
- package/src/cli-help.mjs +7 -5
- package/src/cli-registry.mjs +65 -46
- package/src/diagnostics.mjs +1 -1
- package/src/init.mjs +5 -3
- package/src/lint/index.mjs +10 -1
- package/src/lint/rules.mjs +371 -35
- package/src/observe-core.mjs +163 -34
- package/src/region.mjs +1 -1
- package/src/runtime/agent.ts +16 -12
- package/src/runtime/http.ts +25 -15
- package/src/runtime/match.ts +1 -1
- package/src/runtime/render.ts +4 -4
- package/src/transports/README.md +2 -2
- package/src/upgrade.mjs +33 -10
- package/templates/agent-starter/CLAUDE.md +8 -5
- package/templates/agent-starter/README.md +32 -33
- package/templates/agent-starter/agent/actions/_example.ts +4 -4
- package/templates/agent-starter/agent/actions/index.ts +2 -2
- package/templates/agent-starter/agent/constants.ts +4 -3
- package/templates/agent-starter/agent/utils.ts +15 -18
- package/templates/agent-starter/claude-settings.json +1 -1
- package/templates/agent-starter/docs/app-profile.md +19 -14
- package/templates/agent-starter/foldspace.dev.json +1 -1
- package/recipes/several-hosts/fixtures/nav-record.json +0 -5
package/bin/build.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as esbuild from "esbuild";
|
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
-
import { lintProject, printLint } from "../src/lint/index.mjs";
|
|
5
|
+
import { bundleTurnsOnTestMode, lintProject, printLint } from "../src/lint/index.mjs";
|
|
6
6
|
|
|
7
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
|
|
@@ -19,6 +19,21 @@ function gateLint(): void {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const TEST_MODE_IN_BUNDLE =
|
|
23
|
+
"foldspace build: the bundle can turn test mode on - it uses `setTestMode` with something other than `false`. " +
|
|
24
|
+
"Any bundle can ship to production, where test mode would stop real users' conversations being tracked. " +
|
|
25
|
+
"Remove it: test mode is only `npx foldspace attach --test-mode`, which never touches the bundle. dist/index.js was not kept.";
|
|
26
|
+
|
|
27
|
+
// The net under lint's no-test-mode-in-bundle: read what was actually built.
|
|
28
|
+
function bundleIsSafe(): boolean {
|
|
29
|
+
const outfile = path.join(distDir, "index.js");
|
|
30
|
+
if (!fs.existsSync(outfile)) return true;
|
|
31
|
+
if (!bundleTurnsOnTestMode(fs.readFileSync(outfile, "utf8"))) return true;
|
|
32
|
+
fs.rmSync(outfile, { force: true });
|
|
33
|
+
console.error(TEST_MODE_IN_BUNDLE);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
function buildOptions(): esbuild.BuildOptions {
|
|
23
38
|
return {
|
|
24
39
|
entryPoints: [agentActionsEntry],
|
|
@@ -45,11 +60,8 @@ function rebuildPlugin(): esbuild.Plugin {
|
|
|
45
60
|
});
|
|
46
61
|
build.onEnd((result) => {
|
|
47
62
|
const time = new Date().toLocaleTimeString();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
} else {
|
|
51
|
-
console.log(`[${time}] Error: dist/index.js`);
|
|
52
|
-
}
|
|
63
|
+
const built = result.errors.length === 0 && bundleIsSafe();
|
|
64
|
+
console.log(`[${time}] ${built ? "Rebuilt" : "Error"}: dist/index.js`);
|
|
53
65
|
});
|
|
54
66
|
},
|
|
55
67
|
};
|
|
@@ -90,6 +102,7 @@ async function build() {
|
|
|
90
102
|
});
|
|
91
103
|
} else {
|
|
92
104
|
await esbuild.build(buildOptions());
|
|
105
|
+
if (!bundleIsSafe()) process.exit(1);
|
|
93
106
|
console.log("Built: dist/index.js");
|
|
94
107
|
console.log("\nBuild complete!");
|
|
95
108
|
}
|
package/bin/cli.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import fs from "node:fs";
|
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { runInit } from "../src/init.mjs";
|
|
8
|
+
import { isWriteFlag } from "../src/observe-core.mjs";
|
|
8
9
|
import {
|
|
9
10
|
checkForUpdate,
|
|
10
11
|
gateForCommand,
|
|
@@ -17,7 +18,6 @@ import {
|
|
|
17
18
|
runAttachControl,
|
|
18
19
|
} from "../src/attach-daemon.mjs";
|
|
19
20
|
import {
|
|
20
|
-
CLI_COMMANDS,
|
|
21
21
|
createCliRegistry,
|
|
22
22
|
commandByName,
|
|
23
23
|
} from "../src/cli-registry.mjs";
|
|
@@ -75,13 +75,17 @@ function printHelp(topic, json) {
|
|
|
75
75
|
);
|
|
76
76
|
return;
|
|
77
77
|
}
|
|
78
|
-
const update = checkForUpdate(resolveProjectDir());
|
|
79
78
|
if (json) {
|
|
80
|
-
|
|
79
|
+
// help --json is the first thing the manual reads, so it asks the registry
|
|
80
|
+
// now (as upgrade does) rather than trusting a 12-hour-old answer; offline,
|
|
81
|
+
// it falls back to that answer. One command's contract carries no update.
|
|
82
|
+
const payload = topic
|
|
83
|
+
? document
|
|
84
|
+
: { ...document, update: checkForUpdate(resolveProjectDir(), { preferFresh: true }) };
|
|
81
85
|
console.log(JSON.stringify(payload, null, 2));
|
|
82
86
|
return;
|
|
83
87
|
}
|
|
84
|
-
nagIfOutdated(
|
|
88
|
+
nagIfOutdated(checkForUpdate(resolveProjectDir()));
|
|
85
89
|
console.log(
|
|
86
90
|
topic ? renderCommandHelp(registry, topic) : renderGeneralHelp(registry),
|
|
87
91
|
);
|
|
@@ -112,7 +116,18 @@ function normalizeCommandArgs(command, args) {
|
|
|
112
116
|
const separator = token.indexOf("=");
|
|
113
117
|
const name = separator > -1 ? token.slice(0, separator) : token;
|
|
114
118
|
const match = options.get(name);
|
|
115
|
-
if (!match)
|
|
119
|
+
if (!match) {
|
|
120
|
+
// The reason matters more than the refusal: observe never writes, and
|
|
121
|
+
// always prints JSON. The write reason fits only `observe read`.
|
|
122
|
+
const verb = args.find((item) => !item.startsWith("--"));
|
|
123
|
+
throw new Error(
|
|
124
|
+
command.name === "observe" && name === "--json"
|
|
125
|
+
? "unknown option '--json': observe always prints JSON."
|
|
126
|
+
: command.name === "observe" && verb === "read" && isWriteFlag(name)
|
|
127
|
+
? `unknown option '${name}': observe read is GET only - it never sets a method, a header or a body.`
|
|
128
|
+
: `unknown option '${name}'`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
116
131
|
const { option, canonicalName } = match;
|
|
117
132
|
if (seen.has(canonicalName)) {
|
|
118
133
|
throw new Error(`option '${canonicalName}' was provided twice`);
|
|
@@ -233,7 +248,9 @@ if (!command || command === "--help" || command === "-h") {
|
|
|
233
248
|
}
|
|
234
249
|
}
|
|
235
250
|
} else {
|
|
236
|
-
|
|
251
|
+
// The visible commands only: listing deploy here would offer a coding agent
|
|
252
|
+
// the Foldspace-internal command help hides.
|
|
253
|
+
const names = registry.commands.map((entry) => entry.name).join(", ");
|
|
237
254
|
fail(
|
|
238
255
|
`unknown command '${command}'. Available commands: ${names}. ` +
|
|
239
256
|
`Run 'foldspace help'.`,
|
package/bin/inject.mjs
CHANGED
|
@@ -235,8 +235,8 @@ const chromeArgs = [
|
|
|
235
235
|
startUrl,
|
|
236
236
|
];
|
|
237
237
|
|
|
238
|
-
console.log(`\
|
|
239
|
-
console.log("
|
|
238
|
+
console.log(`\nOpening the test window - profile ${path.relative(root, profileDir)}, debugging port :${debugPort}.`);
|
|
239
|
+
console.log("The human signs in there; the profile keeps what the site remembers between runs.\n");
|
|
240
240
|
|
|
241
241
|
const child = spawn(chrome, chromeArgs, { detached: true, stdio: "ignore" });
|
|
242
242
|
child.unref();
|
|
@@ -254,8 +254,7 @@ if (!owned.ok) {
|
|
|
254
254
|
}
|
|
255
255
|
console.error(`inject: ${ownershipErrorMessage(owned.reason, {
|
|
256
256
|
port: debugPort,
|
|
257
|
-
|
|
258
|
-
root,
|
|
257
|
+
command: "inject",
|
|
259
258
|
})}`);
|
|
260
259
|
process.exit(1);
|
|
261
260
|
}
|
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 { ownershipErrorMessage } from "../src/cdp-ownership.mjs";
|
|
25
26
|
import { withoutStealingFocus } from "../src/keep-focus.mjs";
|
|
26
27
|
import { withWorkingBadge } from "../src/badge-auto.mjs";
|
|
27
28
|
import {
|
|
@@ -294,9 +295,7 @@ async function runPages() {
|
|
|
294
295
|
try {
|
|
295
296
|
targets = await (await fetch(`http://127.0.0.1:${state.port}/json/list`)).json();
|
|
296
297
|
} catch {
|
|
297
|
-
throw new Error(
|
|
298
|
-
`The inject Chrome is not answering on :${state.port}. It was closed; run \`npm run inject\` again.`,
|
|
299
|
-
);
|
|
298
|
+
throw new Error(ownershipErrorMessage("cdp_not_ready", { port: state.port }));
|
|
300
299
|
}
|
|
301
300
|
const real = targets.filter(
|
|
302
301
|
(target) => target.type === "page" && !String(target.url).startsWith("devtools://"),
|
|
@@ -321,7 +320,7 @@ async function runPages() {
|
|
|
321
320
|
? {
|
|
322
321
|
hostsHint:
|
|
323
322
|
`The window is on ${off.host}, which this project does not cover (hosts: ${state.hosts.join(", ")}). ` +
|
|
324
|
-
`If it is part of the product, add "${off.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach
|
|
323
|
+
`If it is part of the product, add "${off.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then \`npx foldspace attach --stop\`, \`npm run inject\` and \`npx foldspace attach --daemon\` with the flags it ran with. If it is not, ask the human to go back to the product in the test window.`,
|
|
325
324
|
}
|
|
326
325
|
: {}),
|
|
327
326
|
};
|
|
@@ -338,7 +337,7 @@ async function runMenu(page) {
|
|
|
338
337
|
items.push(publicItem(entry));
|
|
339
338
|
}
|
|
340
339
|
const url = await page.evaluate("return location.href;");
|
|
341
|
-
// Links that leave this host but stay in the product (
|
|
340
|
+
// Links that leave this host but stay in the product (reports., admin. ...).
|
|
342
341
|
// Looked for across the whole page: product tiles are often not navigation.
|
|
343
342
|
const leaving = await page
|
|
344
343
|
.evaluate(
|
|
@@ -768,7 +767,24 @@ async function runWaitLogin(flags) {
|
|
|
768
767
|
}
|
|
769
768
|
await sleep(1500);
|
|
770
769
|
}
|
|
771
|
-
|
|
770
|
+
// A last check that failed (window closed, or on a host this project does
|
|
771
|
+
// not cover) says what to do itself; asking the human would only loop.
|
|
772
|
+
const secs = Math.round(timeoutMs / 1000);
|
|
773
|
+
throw new ObserveExit(
|
|
774
|
+
{
|
|
775
|
+
ok: false,
|
|
776
|
+
loggedIn: false,
|
|
777
|
+
error: lastError
|
|
778
|
+
? `No sign-in seen in ${secs} seconds. The last check said: ${lastError}`
|
|
779
|
+
: `No sign-in seen in ${secs} seconds: the test window still shows a sign-in page (${url}). ` +
|
|
780
|
+
"Ask the human whether they managed to sign in there. If they have not, run " +
|
|
781
|
+
"`npx foldspace observe wait-login` again while they do; if they say they are in, run " +
|
|
782
|
+
"`npx foldspace observe screenshot` and look at the page.",
|
|
783
|
+
url,
|
|
784
|
+
...(lastError ? { lastError } : {}),
|
|
785
|
+
},
|
|
786
|
+
2,
|
|
787
|
+
);
|
|
772
788
|
}
|
|
773
789
|
|
|
774
790
|
// -------------------------------------------------------------------- main
|
package/bin/published.mjs
CHANGED
|
@@ -89,7 +89,7 @@ async function main() {
|
|
|
89
89
|
finish({ ok: false, environment: environment.name, reason: ATTACH_RUNNING }, 1);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
page = await CdpPage.open({ projectDir });
|
|
92
|
+
page = await CdpPage.open({ projectDir, command: "published" });
|
|
93
93
|
const agent = projectAgent(page.info.target, configTarget(page.info.targetName));
|
|
94
94
|
|
|
95
95
|
let seen = await readPage(agent);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldspace_npm/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"description": "Build and verify portable Foldspace action artifacts against a live app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
"./package.json": "./package.json"
|
|
16
16
|
},
|
|
17
17
|
"scripts": {
|
|
18
|
-
"test": "node --test test/*.test.mjs"
|
|
18
|
+
"test": "node --test test/*.test.mjs",
|
|
19
|
+
"prepublishOnly": "npm test"
|
|
19
20
|
},
|
|
20
21
|
"files": [
|
|
21
22
|
"bin",
|
|
@@ -35,5 +36,8 @@
|
|
|
35
36
|
},
|
|
36
37
|
"publishConfig": {
|
|
37
38
|
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"linkedom": "^0.18.13"
|
|
38
42
|
}
|
|
39
43
|
}
|
package/recipes/INDEX.md
CHANGED
|
@@ -6,16 +6,21 @@ Read this whole table, then open the recipe closest to the outcome.
|
|
|
6
6
|
|---|---|---|---|---|
|
|
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
|
-
| [`
|
|
10
|
-
| [`
|
|
11
|
-
| [`
|
|
12
|
-
| [`
|
|
13
|
-
| [`
|
|
9
|
+
| [`lookup-then-view`](lookup-then-view/) | **L2** | Show one account the user names - **start here**. One clear match: its card, no question. Several or none: the **pick-one** list - search, pages, one click - then the card: who it is, the money, open deals, people, open cases. The card action's **required** `accountId`, described as coming from the lookup, makes the agent call the lookup first. The app already hands over the id → `account-overview` | Two actions - an input card (`find_account`) and a view card (`show_account_overview`) - with their Agent Studio definitions word for word | 1 production build |
|
|
10
|
+
| [`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 input card: the agent waits for the user's answer | 1 production build |
|
|
11
|
+
| [`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 production builds |
|
|
12
|
+
| [`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 (a third uses the default bearer token) |
|
|
13
|
+
| [`account-overview`](account-overview/) | **L2** | One account on a card, when the app already hands over its id: identity, pipeline total, contacts, cases, meetings. The agent gets what the card shows. An account the user names → `lookup-then-view` | A view card: no `callback`, no `awaitUserInput` | 1 production build |
|
|
14
|
+
| [`upload-contacts`](upload-contacts/) | **L4** | A CSV is previewed, then written only after the user chooses Import | An input card: the agent waits for the user's answer | 1 production build |
|
|
15
|
+
| [`create-a-record`](create-a-record/) | **L4** | One record created from what the user said: an editable card, pre-filled, with dropdowns read from the app, written only after they approve. The new id comes back. **Two versions**: everything on one card (default), or one question per card and then a final review, for when an answer changes what is asked next - the README says which | An action with a card that waits for the user | 2 production builds |
|
|
14
16
|
| [`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
17
|
| [`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
|
-
| [`
|
|
17
|
-
| [`
|
|
18
|
-
| [`
|
|
18
|
+
| [`server-rendered-forms`](server-rendered-forms/) | **L4** | The product renders its pages on the server and has no JSON API: records are read off its pages, and a record is created through the same form a person fills in - fields found by label, the app's own validation messages back as data | A write. **No card of its own** - always behind approval: the approve button of a card, or `confirmation_required: true` | 1 production build |
|
|
19
|
+
| [`several-hosts`](several-hosts/) | any | The product lives on several hosts (`www.`, `app.`, `reports.`, `admin.` ...): an action asked from the wrong host says which page it needs. After the agent moves the user there, the SDK carries the user, their conversations and the agent's turn to that host when it shares the domain (PLG-5962) | A guard each action calls | 1 production build |
|
|
20
|
+
| [`records-list`](records-list/) | **L2** | The user's records on one card: a row each with name, two facts and a number, matched to the agent's white panel. The agent gets what the card shows | A view card: no `callback`, no `awaitUserInput` | 1 production build |
|
|
21
|
+
| [`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 agent gets what the card shows | A view card: no `callback`, no `awaitUserInput` | 1 production build |
|
|
22
|
+
|
|
23
|
+
Levels: L0 the agent is live · L1 the user's own settings · L2 look up records · L3 navigation · L4 a write.
|
|
19
24
|
|
|
20
25
|
**Not here yet — no production build proves it:** the user's own plan or
|
|
21
26
|
settings drawn as a card (L1). In production that data is sent to `identify`
|
package/recipes/README.md
CHANGED
|
@@ -13,11 +13,21 @@ of a customer session.
|
|
|
13
13
|
## Use one
|
|
14
14
|
|
|
15
15
|
1. Read `INDEX.md` and pick the closest outcome.
|
|
16
|
-
2. Copy its `agent/` files into your project's `agent/`
|
|
17
|
-
same, so the imports
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
`
|
|
16
|
+
2. Copy its `agent/` files into your project's `agent/` - the layout is the
|
|
17
|
+
same, so the imports resolve. Never copy its `agent/views/brand.ts`: keep
|
|
18
|
+
the one `npx foldspace observe styles --write` wrote, and if the project
|
|
19
|
+
has none yet, run that command first. Do not overwrite any other file that
|
|
20
|
+
already exists: `agent/accounts.ts`, `agent/api/accounts.ts` and
|
|
21
|
+
`agent/api/meetings.ts` differ between recipes - add what the new recipe
|
|
22
|
+
imports to the file you have. Where the two files define the same name
|
|
23
|
+
(`AccountRecord`, `formatMoney`), keep one: one type with the fields of
|
|
24
|
+
both, one copy of the helper. The one file a recipe replaces is
|
|
25
|
+
`agent/utils.ts`, from `swap-the-login-method`.
|
|
26
|
+
3. Replace every placeholder with what you **observed** in the live app - the
|
|
27
|
+
`__observe_me` paths and keys, and each default the recipe's **Adapt it**
|
|
28
|
+
table names (claim names, starters) - and record how you know in
|
|
29
|
+
`docs/app-profile.md`. The `__observe_me` ones cannot succeed; the defaults
|
|
30
|
+
can, so check each.
|
|
21
31
|
4. Register actions in `agent/actions/index.ts` under the **same key** as in
|
|
22
32
|
Agent Studio, then run the six gates.
|
|
23
33
|
|
|
@@ -29,10 +39,13 @@ Each recipe's README says how many production builds carry the pattern and what
|
|
|
29
39
|
those builds learned the hard way. **What is taken is the shape** — the order of
|
|
30
40
|
calls, what is returned to the agent, where the traps are. **What is not taken**
|
|
31
41
|
is any customer's name, endpoint, identifier, styling or code. Contracts are the
|
|
32
|
-
|
|
33
|
-
`render(data, host, header, callback, cancel)`
|
|
42
|
+
SDK's: `execute(params)` and
|
|
43
|
+
`render(data, host, header, callback, cancel, readyState)`
|
|
34
44
|
(<https://docs.foldspace.ai/guides/in-chat-ui/>), `foldspace.identify(context)`
|
|
35
45
|
(<https://docs.foldspace.ai/start/user-context/>), and the harness runtime.
|
|
46
|
+
Where the in-chat UI page says to set `awaitUserInput`, do not - the SDK waits
|
|
47
|
+
when `render` uses `callback` or `cancel` (see the card bullet below). That
|
|
48
|
+
page does not list `readyState`; CLAUDE.md describes it, and no recipe uses it.
|
|
36
49
|
|
|
37
50
|
Error handling is **not** taken from those builds. They predate `reason`: one
|
|
38
51
|
turns every failure into "unexpected error", another reports a missing session
|
|
@@ -42,5 +55,15 @@ as "no projects found". Recipes use the runtime's failure reasons instead.
|
|
|
42
55
|
|
|
43
56
|
- `execute` returns **data and facts** — never `detail`, never instructions.
|
|
44
57
|
- A missing or expired session is `signed_out`, not an empty result.
|
|
58
|
+
- A **view card** shows something - an account, a list. Its
|
|
59
|
+
`render(data, host, header)` uses no `callback` and no `cancel`, and the
|
|
60
|
+
agent gets at once "Shown to the user. The user has not acted on it." with
|
|
61
|
+
exactly what `execute` returned. So `execute` returns the facts the card
|
|
62
|
+
shows: to change what the agent gets, change `execute`, not the card. An
|
|
63
|
+
**input card** asks the user: it calls `callback` (or `cancel`) on every
|
|
64
|
+
path - choosing, "none of these", closing, failing - and the agent waits
|
|
65
|
+
until it does. Never set `awaitUserInput`: an input card does not need it,
|
|
66
|
+
and on a view card in headless mode the agent waits for a callback that
|
|
67
|
+
never comes.
|
|
45
68
|
- DOM is built with `textContent`, never `innerHTML` with API data, and styles
|
|
46
69
|
go in `header` — `host` lives inside the component's frame.
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
**Proven by 1 production build.**
|
|
6
6
|
|
|
7
|
-
This card is the base layout. Copy it, then replace the `/__observe_me` routes with the app's own paths.
|
|
7
|
+
This card is the base layout. Copy it, then replace the `/__observe_me` routes with the app's own paths. `brand.ts` here is a placeholder: keep the `agent/views/brand.ts` that `npx foldspace observe styles --write` wrote - it has every key the recipe cards read. If the project has none yet, run that command first; do not copy this one.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
A view card: `render` takes no `callback` and the action sets no `awaitUserInput`. As soon as the card is drawn, the agent gets "Shown to the user. The user has not acted on it." plus exactly what `execute` returned - the account, the totals and every row behind the tabs, or the failure (`success: false`, `error` and `reason`) - and it answers questions about the account from that. So `execute` returns the overview the card draws and nothing else. 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 is signed out unless the account has both an id and a name.
|
|
10
10
|
|
|
11
|
-
In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId
|
|
11
|
+
In Agent Studio: an action with key `show_account_overview` and one string parameter `accountId`, described as "The account's id. Never taken from the user's words: get it from `<lookup action key>`." Replace `<lookup action key>` with the key of this agent's action that returns account ids; if the agent has none, build that action first - `find-by-name` has the shape. Its `instructions`: "Answer follow-up questions about this account from the data you receive: quote money and counts exactly as given, and never add them up yourself. Right after the card, one short sentence at most - do not repeat what it shows. On a failure, the card already says why: say so in one short sentence and stop. No emoji."
|
|
12
12
|
|
|
13
13
|
## Adapt it
|
|
14
14
|
|
|
@@ -16,11 +16,11 @@ In Agent Studio: an action with key `show_account_overview` and one string param
|
|
|
16
16
|
|---|---|
|
|
17
17
|
| `agent/api/accounts.ts` | Mock path and envelope. Find a real API that returns a similar shape |
|
|
18
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
|
|
19
|
+
| `agent/views/overview.ts` | The card. Routes under `/__observe_me` are placeholders for the app's own paths |
|
|
20
20
|
|
|
21
21
|
## What that build learned the hard way
|
|
22
22
|
|
|
23
|
-
- **The
|
|
23
|
+
- **The agent answers from what `execute` returns.** A view card hands the agent everything `execute` returned, so `execute` returns the card's facts as fields, totals included. The build that proved this card ran before the SDK did that: its agent was told only that something was displayed, and could not answer a question about the account.
|
|
24
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.
|
|
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. The account's website is plain text, never a link out of the product.
|
|
26
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.
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// A view card: render takes no callback and the action sets no
|
|
2
|
+
// awaitUserInput. As soon as the card is drawn, the agent is told it was shown
|
|
3
|
+
// and gets everything execute returned - so execute returns the overview the
|
|
4
|
+
// card draws and nothing else, or the failure the card draws instead. A failed
|
|
3
5
|
// or signed-out call is drawn with renderFailure, never as a row of zeros.
|
|
4
6
|
|
|
5
7
|
import { belongsToSignedInUser, toOverview, type AccountOverview } from "../accounts";
|
|
@@ -9,40 +11,33 @@ import { mountAccountOverview } from "../views/overview";
|
|
|
9
11
|
|
|
10
12
|
type ShowResult = {
|
|
11
13
|
success: boolean;
|
|
12
|
-
message?: string;
|
|
13
14
|
error?: string;
|
|
14
15
|
reason?: FailureReason;
|
|
15
|
-
data?: AccountOverview
|
|
16
|
+
data?: AccountOverview;
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
function isOverview(data: NonNullable<ShowResult["data"]>): data is AccountOverview {
|
|
19
|
-
return "account" in data;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
19
|
export const show_account_overview = {
|
|
23
20
|
execute: async (params: { accountId?: string }): Promise<ShowResult> => {
|
|
24
21
|
const accountId = typeof params?.accountId === "string" ? params.accountId.trim() : "";
|
|
25
22
|
if (!accountId) {
|
|
26
|
-
return { success: false, error: "
|
|
23
|
+
return { success: false, error: "No account was given.", reason: "config" };
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
const res = await getAccountOverview(accountId);
|
|
30
27
|
if (!res.ok) {
|
|
31
28
|
console.warn("[show_account_overview]", res.status, res.reason, res.detail);
|
|
32
|
-
return { success: false, error: res.error, reason: res.reason
|
|
29
|
+
return { success: false, error: res.error, reason: res.reason };
|
|
33
30
|
}
|
|
34
31
|
|
|
35
32
|
if (!belongsToSignedInUser(res.data)) {
|
|
36
|
-
return { success: false, error: "Not signed in.", reason: "signed_out"
|
|
33
|
+
return { success: false, error: "Not signed in.", reason: "signed_out" };
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
|
|
40
|
-
return { success: true, message: "Displayed.", data: toOverview(res.data) };
|
|
36
|
+
return { success: true, data: toOverview(res.data) };
|
|
41
37
|
},
|
|
42
38
|
|
|
43
39
|
render: (result: ShowResult | undefined, host: HTMLElement, header: HTMLElement) => {
|
|
44
|
-
if (!result?.success || !result.data
|
|
45
|
-
const accountId = result?.data && "accountId" in result.data ? result.data.accountId : "";
|
|
40
|
+
if (!result?.success || !result.data) {
|
|
46
41
|
renderFailure(
|
|
47
42
|
host,
|
|
48
43
|
{
|
|
@@ -51,7 +46,7 @@ export const show_account_overview = {
|
|
|
51
46
|
error: result?.error ?? "The account could not be loaded.",
|
|
52
47
|
reason: result?.reason ?? "server",
|
|
53
48
|
},
|
|
54
|
-
{ notFound:
|
|
49
|
+
{ notFound: "That account could not be found." },
|
|
55
50
|
);
|
|
56
51
|
return;
|
|
57
52
|
}
|
|
@@ -1,26 +1,35 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// card to history as an image drawn with html2canvas, which cannot read
|
|
5
|
-
// oklch()/lab()/color-mix(). Take `accent` from the agent's own primaryColor
|
|
6
|
-
// (get_agent_settings); re-sample font and radius from the page if you want
|
|
7
|
-
// them to match; swap for a dark set only when the panel itself is dark.
|
|
1
|
+
// Placeholder with the keys of the agent/views/brand.ts that
|
|
2
|
+
// `npx foldspace observe styles --write` writes. When you copy this recipe,
|
|
3
|
+
// keep the project's own brand.ts; if it has none yet, run that command.
|
|
8
4
|
|
|
9
5
|
export const brand = {
|
|
10
6
|
surface: "#FFFFFF",
|
|
11
|
-
|
|
12
|
-
border: "#E5E7EB",
|
|
7
|
+
surface2: "#F5F6FA",
|
|
13
8
|
text: "#374151",
|
|
14
|
-
textStrong: "#111827",
|
|
15
9
|
muted: "#6B7280",
|
|
16
|
-
|
|
10
|
+
border: "#E5E7EB",
|
|
17
11
|
accent: "#3248F2",
|
|
12
|
+
accentText: "#FFFFFF",
|
|
13
|
+
font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
14
|
+
radius: "8px",
|
|
15
|
+
size: { base: "14px", small: "12px" },
|
|
16
|
+
textStrong: "#111827",
|
|
17
|
+
faint: "#9CA3AF",
|
|
18
|
+
row: "#F9FAFB",
|
|
19
|
+
tableHead: "#F3F4F6",
|
|
18
20
|
accentDeep: "#2538D6",
|
|
21
|
+
accentSoft: "#EEF0FE",
|
|
22
|
+
headerStart: "#F0F1FE",
|
|
23
|
+
good: "#047857",
|
|
24
|
+
warn: "#B45309",
|
|
25
|
+
warnInk: "#FEF3C7",
|
|
26
|
+
bad: "#B91C1C",
|
|
27
|
+
primary: "#3248F2",
|
|
28
|
+
tint: "#F3F4F6",
|
|
29
|
+
disabled: "#9CA3AF",
|
|
19
30
|
money: "#047857",
|
|
31
|
+
ok: "#047857",
|
|
20
32
|
deals: "#2563EB",
|
|
21
33
|
contacts: "#7C3AED",
|
|
22
34
|
cases: "#B45309",
|
|
23
|
-
row: "#F9FAFB",
|
|
24
|
-
font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
|
|
25
|
-
radius: "8px",
|
|
26
35
|
} as const;
|
|
@@ -34,7 +34,6 @@ const CSS = `
|
|
|
34
34
|
.fs-ov-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:12px}
|
|
35
35
|
.fs-ov-label{font-size:10px;color:${brand.muted};text-transform:uppercase;letter-spacing:.5px;margin-bottom:2px}
|
|
36
36
|
.fs-ov-value{font-size:12px;color:${brand.textStrong};font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
37
|
-
.fs-ov-value a{color:${brand.accent};text-decoration:none}
|
|
38
37
|
.fs-ov-metrics{display:grid;grid-template-columns:repeat(4, 1fr);gap:1px;background:${brand.border}}
|
|
39
38
|
.fs-ov-metric{padding:12px;background:${brand.surface};display:flex;flex-direction:column;gap:2px;text-align:center;align-items:center}
|
|
40
39
|
.fs-ov-metric b{font-size:20px;font-weight:600}
|
|
@@ -198,23 +197,20 @@ export function mountAccountOverview(host: HTMLElement, header: HTMLElement, ove
|
|
|
198
197
|
title.append(name, open);
|
|
199
198
|
head.append(title);
|
|
200
199
|
|
|
201
|
-
|
|
202
|
-
|
|
200
|
+
// What is known, and nothing else: a missing value reads "Not provided" in
|
|
201
|
+
// the grid below.
|
|
202
|
+
const known = [
|
|
203
|
+
account.industry,
|
|
204
|
+
account.employeeCount === null ? null : `${account.employeeCount.toLocaleString()} employees`,
|
|
205
|
+
].filter((fact): fact is string => Boolean(fact));
|
|
206
|
+
if (known.length) head.append(el("div", "fs-ov-sub", known.join(" • ")));
|
|
203
207
|
|
|
204
208
|
const grid = el("div", "fs-ov-grid");
|
|
205
209
|
grid.append(field("Phone", account.phone || "Not provided"));
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
attr(site, "target", "_blank");
|
|
211
|
-
website.append(site);
|
|
212
|
-
} else {
|
|
213
|
-
website.textContent = "Not provided";
|
|
214
|
-
}
|
|
215
|
-
grid.append(field("Website", website));
|
|
216
|
-
grid.append(field("Annual Revenue", account.revenue === null ? "Not disclosed" : formatMoney(account.revenue)));
|
|
217
|
-
grid.append(field("Employees", account.employeeCount === null ? "Not specified" : account.employeeCount.toLocaleString()));
|
|
210
|
+
// Plain text, never a link out of the product.
|
|
211
|
+
grid.append(field("Website", account.website || "Not provided"));
|
|
212
|
+
grid.append(field("Annual Revenue", account.revenue === null ? "Not provided" : formatMoney(account.revenue)));
|
|
213
|
+
grid.append(field("Employees", account.employeeCount === null ? "Not provided" : account.employeeCount.toLocaleString()));
|
|
218
214
|
head.append(grid);
|
|
219
215
|
card.append(head);
|
|
220
216
|
|
|
@@ -225,10 +221,10 @@ export function mountAccountOverview(host: HTMLElement, header: HTMLElement, ove
|
|
|
225
221
|
return item;
|
|
226
222
|
};
|
|
227
223
|
metricsBar.append(
|
|
228
|
-
metric("
|
|
224
|
+
metric("Opportunities", String(metrics.openOpportunities), "fs-ov-deals"),
|
|
229
225
|
metric("Pipeline", formatPipeline(metrics.pipelineValue), "fs-ov-pipeline"),
|
|
230
226
|
metric("Contacts", String(metrics.contacts), "fs-ov-contacts"),
|
|
231
|
-
metric("
|
|
227
|
+
metric("Cases", String(metrics.openCases), "fs-ov-cases"),
|
|
232
228
|
);
|
|
233
229
|
card.append(metricsBar);
|
|
234
230
|
|
|
@@ -14,8 +14,8 @@ product's own source, one from the agent project behind a feature flag).
|
|
|
14
14
|
| In `agent/bottomBar.ts` | Change |
|
|
15
15
|
|---|---|
|
|
16
16
|
| `PAGE_STARTERS` | This product's screens (path prefixes) and, for each, two or three questions **its actions can answer**. A starter for something the agent cannot do is worse than none |
|
|
17
|
-
| `DEFAULT_STARTERS` | What to show on any other page |
|
|
18
|
-
| `BOTTOM_BAR_SETTINGS` |
|
|
17
|
+
| `DEFAULT_STARTERS` | What to show on any other page. It replaces the agent-wide starters there |
|
|
18
|
+
| `BOTTOM_BAR_SETTINGS` | The docs' settings, except two: `enabled: true` (the docs default it to `false`, and the bar then never renders) and `maxVisibleStarters: 3` (the docs default 5). Change any of them only if the human asks |
|
|
19
19
|
|
|
20
20
|
Then call it from the bundle's entry point:
|
|
21
21
|
|
|
@@ -30,14 +30,17 @@ installBottomBar();
|
|
|
30
30
|
- **`setConversationStarters` replaces the whole set for the page** — both
|
|
31
31
|
groups. `null` restores Agent Studio's. So always pass the full list you
|
|
32
32
|
want shown.
|
|
33
|
-
- **In embedded mode the bar needs `openEmbeddedCallback`**, or
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
- **In embedded mode the bar needs `openEmbeddedCallback`**, or the bar does
|
|
34
|
+
not render at all. This recipe is for the overlay agent; see the docs for
|
|
35
|
+
embedded.
|
|
36
36
|
- **Route changes in a single-page app don't reload**, so the starters must be
|
|
37
|
-
re-applied on navigation
|
|
38
|
-
and listens to `popstate`.
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
re-applied on navigation - this recipe wraps `pushState` / `replaceState`
|
|
38
|
+
and listens to `popstate`. It brings the bar back to full only when the
|
|
39
|
+
starters change; a navigation that keeps the same starters leaves a bar
|
|
40
|
+
that has folded to compact (after `idleTimeoutMs`) as it is.
|
|
41
|
+
- **Dark mode**: the SDK's default colours do not follow a dark theme yet
|
|
42
|
+
(PLG-5891, in the backlog); a product in dark mode may need `theme` /
|
|
43
|
+
`darkModeSettings` set.
|
|
41
44
|
|
|
42
45
|
Docs: [Bottom Bar](https://docs.foldspace.ai/customize/bottom-bar/) ·
|
|
43
46
|
[Conversation starters](https://docs.foldspace.ai/customize/conversation-starters/)
|