@foldspace_npm/harness 0.1.17 → 0.1.19

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.
Files changed (47) hide show
  1. package/CLAUDE.md +9 -5
  2. package/bin/attach.mjs +37 -9
  3. package/bin/observe.mjs +4 -3
  4. package/package.json +1 -1
  5. package/recipes/INDEX.md +6 -2
  6. package/recipes/account-overview/README.md +26 -0
  7. package/recipes/account-overview/agent/accounts.ts +159 -0
  8. package/recipes/account-overview/agent/actions/show_account_overview.ts +61 -0
  9. package/recipes/account-overview/agent/api/accounts.ts +59 -0
  10. package/recipes/account-overview/agent/views/brand.ts +22 -0
  11. package/recipes/account-overview/agent/views/overview.ts +303 -0
  12. package/recipes/account-overview/fixtures/overview.empty.json +12 -0
  13. package/recipes/account-overview/fixtures/overview.ok.json +30 -0
  14. package/recipes/account-overview/fixtures/overview.unsigned.json +9 -0
  15. package/recipes/account-overview/recipe.json +10 -0
  16. package/recipes/opportunities-at-risk/README.md +26 -0
  17. package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +49 -0
  18. package/recipes/opportunities-at-risk/agent/api/opportunities.ts +30 -0
  19. package/recipes/opportunities-at-risk/agent/opportunities.ts +75 -0
  20. package/recipes/opportunities-at-risk/agent/views/at-risk.ts +115 -0
  21. package/recipes/opportunities-at-risk/agent/views/brand.ts +20 -0
  22. package/recipes/opportunities-at-risk/fixtures/at-risk.empty.json +4 -0
  23. package/recipes/opportunities-at-risk/fixtures/at-risk.ok.json +25 -0
  24. package/recipes/opportunities-at-risk/fixtures/at-risk.unsigned.json +3 -0
  25. package/recipes/opportunities-at-risk/recipe.json +10 -0
  26. package/recipes/prepare-for-a-meeting/README.md +27 -0
  27. package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +70 -0
  28. package/recipes/prepare-for-a-meeting/agent/api/meetings.ts +24 -0
  29. package/recipes/prepare-for-a-meeting/agent/meetings.ts +66 -0
  30. package/recipes/prepare-for-a-meeting/fixtures/prep.ok.json +24 -0
  31. package/recipes/prepare-for-a-meeting/fixtures/prep.unsigned.json +9 -0
  32. package/recipes/prepare-for-a-meeting/recipe.json +10 -0
  33. package/recipes/update-meeting-notes/README.md +22 -0
  34. package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +36 -0
  35. package/recipes/update-meeting-notes/agent/api/meetings.ts +22 -0
  36. package/recipes/update-meeting-notes/fixtures/note.ok.json +3 -0
  37. package/recipes/update-meeting-notes/recipe.json +10 -0
  38. package/recipes/upload-contacts/README.md +25 -0
  39. package/recipes/upload-contacts/agent/actions/upload_contacts.ts +77 -0
  40. package/recipes/upload-contacts/agent/api/contacts.ts +29 -0
  41. package/recipes/upload-contacts/agent/contacts.ts +133 -0
  42. package/recipes/upload-contacts/agent/views/brand.ts +21 -0
  43. package/recipes/upload-contacts/agent/views/uploader.ts +394 -0
  44. package/recipes/upload-contacts/fixtures/import.ok.json +9 -0
  45. package/recipes/upload-contacts/recipe.json +10 -0
  46. package/src/attach-preflight.mjs +9 -0
  47. package/src/keep-focus.mjs +49 -0
package/CLAUDE.md CHANGED
@@ -77,8 +77,8 @@ at the app or testing in it, run `npx foldspace badge working` (a bar across
77
77
  the top of the window says Foldspace is building here, leave it alone) and say
78
78
  so in chat: *"I'm going to work in that Chrome window for a few minutes -
79
79
  please leave it alone until I tell you it's ready."* When it is their turn,
80
- `npx foldspace badge ready --text "Try asking: <the question this experience
81
- answers>"` and the same line in chat. `foldspace badge label` puts the small
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
82
  corner tag back; `off` removes it, for a screenshot they will share.
83
83
 
84
84
  Navigation, uploads, extraction and anything that writes are a later session.
@@ -438,10 +438,14 @@ placeholder path and fields with what you observed. They match the harness
438
438
  version you have installed, and every one is built and run on each release.
439
439
 
440
440
  L0 → `who-is-the-user`. L2 → `find-by-name`, and `pick-from-a-list` when the
441
- user has to choose. App does not use a bearer token → `swap-the-login-method`.
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`.
442
446
  Each says how many production builds carry the pattern and what they learned.
443
- If nothing fits — there is no L1 card recipe yet say so and build from
444
- `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`.
445
449
 
446
450
  ## What you already have
447
451
 
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,
@@ -222,9 +223,12 @@ const SDK_PATTERNS = [
222
223
 
223
224
  // The badge has three states, set from outside with \`foldspace badge\`:
224
225
  // label - the small corner tag (default): who is attached, which mode
225
- // working - a bar across the top: the agent is building in this window,
226
- // please leave it alone
227
- // ready - a bar: it is the human's turn, with the question to try
226
+ // working - a pill at the foot of the window: the agent is building in this
227
+ // window, please leave it alone
228
+ // ready - the same pill: it is the human's turn, with the question to try
229
+ // The pill sits bottom-left, never across the top: a bar there covered the
230
+ // app's own header and menu (seen on a CRM, 2026-09-22). The agent's panel
231
+ // is on the right, so bottom-left is the corner nothing else uses.
228
232
  // The state lives in sessionStorage so it survives navigation inside the
229
233
  // test window; \`foldspace badge\` writes it and the script redraws.
230
234
  const badgeSrc = `(() => {
@@ -246,11 +250,35 @@ const badgeSrc = `(() => {
246
250
  b.dataset.fsState = want;
247
251
  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);";
248
252
  if (s.state === "working") {
249
- b.style.cssText = base + "right:0;background:#3247F2;padding:9px 14px;font-size:12px;letter-spacing:.04em;text-align:center;";
250
- b.textContent = s.text || "Foldspace is building your agent on this page. Please leave this window alone until Claude says it is ready.";
253
+ // Attention, not decoration: a pulsing marker, the ask in bold, and an
254
+ // animated line underneath so it reads as "in progress" at a glance.
255
+ b.style.cssText = base + "top:auto;bottom:16px;left:16px;right:auto;max-width:min(560px,calc(100vw - 32px));border-radius:8px;overflow:hidden;background:#1F2A8A;padding:10px 14px 13px;font-size:13px;letter-spacing:.02em;text-align:left;line-height:1.3;";
256
+ b.innerHTML =
257
+ '<style>@keyframes fsPulse{0%,100%{opacity:1}50%{opacity:.25}}@keyframes fsSweep{0%{left:-30%}100%{left:100%}}' +
258
+ '#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}' +
259
+ '#foldspace-dev-badge .fs-line{position:absolute;left:0;right:0;bottom:0;height:3px;background:rgba(255,255,255,.15);overflow:hidden}' +
260
+ '#foldspace-dev-badge .fs-line::after{content:"";position:absolute;top:0;width:30%;height:100%;background:#FFB300;animation:fsSweep 1.4s linear infinite}</style>' +
261
+ '<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.' +
262
+ (s.text ? " " + s.text.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" })[c]) : "") +
263
+ '<span class="fs-line"></span>';
251
264
  } else if (s.state === "ready") {
252
- b.style.cssText = base + "right:0;background:#0F9D58;padding:9px 14px;font-size:12px;letter-spacing:.04em;text-align:center;";
253
- b.textContent = s.text || "Ready - your turn. Open your agent and try it.";
265
+ // Calm, and useful: the question to try, with a Copy button so nobody
266
+ // retypes it into the panel. The bar accepts clicks only on that button.
267
+ const esc = (t) => String(t).replace(/[<>&"]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" })[c]);
268
+ const question = s.text ? esc(s.text) : "";
269
+ b.style.cssText = base + "top:auto;bottom:16px;left:16px;right:auto;max-width:min(560px,calc(100vw - 32px));border-radius:8px;overflow:hidden;background:#0F9D58;padding:10px 14px;font-size:13px;letter-spacing:.02em;text-align:left;line-height:1.3;pointer-events:none;";
270
+ b.innerHTML =
271
+ '<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}' +
272
+ '#foldspace-dev-badge .fs-copy:hover{background:rgba(255,255,255,.15)}</style>' +
273
+ '<b>Ready - your turn.</b> ' +
274
+ (question ? 'Try asking: <span class="fs-q">' + question + '</span><button class="fs-copy" type="button">Copy</button>' : "Open your agent and try it.");
275
+ const btn = b.querySelector(".fs-copy");
276
+ if (btn) btn.addEventListener("click", () => {
277
+ const q = b.querySelector(".fs-q")?.textContent || "";
278
+ const done = () => { btn.textContent = "Copied"; setTimeout(() => { btn.textContent = "Copy"; }, 1500); };
279
+ if (navigator.clipboard?.writeText) navigator.clipboard.writeText(q).then(done, done);
280
+ else { const ta = document.createElement("textarea"); ta.value = q; document.body.appendChild(ta); ta.select(); try { document.execCommand("copy"); } catch (e) {} ta.remove(); done(); }
281
+ });
254
282
  } else {
255
283
  b.style.cssText = base + "background:#3247F2;padding:5px 10px;border-bottom-right-radius:4px;";
256
284
  b.textContent = LABEL;
@@ -724,7 +752,7 @@ async function prepare(ws, sessionId, url) {
724
752
  ),
725
753
  });
726
754
  console.log(` attached -> ${url}`);
727
- await call(ws, "Page.reload", { ignoreCache: true }, sessionId);
755
+ await withoutStealingFocus(() => call(ws, "Page.reload", { ignoreCache: true }, sessionId));
728
756
 
729
757
  {
730
758
  const verification = await verifyPreparedPage(ws, sessionId);
@@ -1039,7 +1067,7 @@ async function cleanupPreparedSession(
1039
1067
  // identity aliases alike.
1040
1068
  if (reload) {
1041
1069
  try {
1042
- await call(socket, "Page.reload", { ignoreCache: true }, sessionId);
1070
+ await withoutStealingFocus(() => call(socket, "Page.reload", { ignoreCache: true }, sessionId));
1043
1071
  } catch (error) {
1044
1072
  failures.push(`restore reload: ${error instanceof Error ? error.message : String(error)}`);
1045
1073
  }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
package/recipes/INDEX.md CHANGED
@@ -9,8 +9,12 @@ Read this whole table, then open the recipe closest to the outcome.
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
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 |
11
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 |
12
17
 
13
18
  **Not here yet — no production build proves it:** the user's own plan or
14
19
  settings drawn as a card (L1). In production that data is sent to `identify`
15
- (`who-is-the-user`), not rendered. Navigation (L3), writes (L4), uploads and
16
- 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
+ }
@@ -0,0 +1,61 @@
1
+ // View-only card. Do not set awaitUserInput: the model is told only that the
2
+ // information was displayed, so the card itself carries every fact. A failed
3
+ // or signed-out call is drawn with renderFailure, never as a row of zeros.
4
+
5
+ import { belongsToSignedInUser, toOverview, type AccountOverview } from "../accounts";
6
+ import { getAccountOverview } from "../api/accounts";
7
+ import { renderFailure, type FailureReason } from "../utils";
8
+ import { mountAccountOverview } from "../views/overview";
9
+
10
+ type ShowResult = {
11
+ success: boolean;
12
+ message?: string;
13
+ error?: string;
14
+ reason?: FailureReason;
15
+ data?: AccountOverview | { accountId: string };
16
+ };
17
+
18
+ function isOverview(data: NonNullable<ShowResult["data"]>): data is AccountOverview {
19
+ return "account" in data;
20
+ }
21
+
22
+ export const show_account_overview = {
23
+ execute: async (params: { accountId?: string }): Promise<ShowResult> => {
24
+ const accountId = typeof params?.accountId === "string" ? params.accountId.trim() : "";
25
+ if (!accountId) {
26
+ return { success: false, error: "accountId was not provided.", reason: "config" };
27
+ }
28
+
29
+ const res = await getAccountOverview(accountId);
30
+ if (!res.ok) {
31
+ console.warn("[show_account_overview]", res.status, res.reason, res.detail);
32
+ return { success: false, error: res.error, reason: res.reason, data: { accountId } };
33
+ }
34
+
35
+ if (!belongsToSignedInUser(res.data)) {
36
+ return { success: false, error: "Not signed in.", reason: "signed_out", data: { accountId } };
37
+ }
38
+
39
+ // `message` never reaches the model for a view-only action; it is for the console.
40
+ return { success: true, message: "Displayed.", data: toOverview(res.data) };
41
+ },
42
+
43
+ render: (result: ShowResult | undefined, host: HTMLElement, header: HTMLElement) => {
44
+ if (!result?.success || !result.data || !isOverview(result.data)) {
45
+ const accountId = result?.data && "accountId" in result.data ? result.data.accountId : "";
46
+ renderFailure(
47
+ host,
48
+ {
49
+ ok: false,
50
+ status: 0,
51
+ error: result?.error ?? "The account could not be loaded.",
52
+ reason: result?.reason ?? "server",
53
+ },
54
+ { notFound: accountId ? `No account ${accountId}.` : "No account." },
55
+ );
56
+ return;
57
+ }
58
+
59
+ mountAccountOverview(host, header, result.data);
60
+ },
61
+ };
@@ -0,0 +1,59 @@
1
+ // Mock API. These paths are not a real product. Find an API that returns a
2
+ // similar shape — one account, its opportunities, contacts, open cases, and
3
+ // recent meetings — and replace the path and the envelope with what you
4
+ // observed. `__observe_me` cannot succeed until you do.
5
+
6
+ import { apiFetch, type ApiResult } from "../utils";
7
+
8
+ export type AccountRecord = {
9
+ id?: string | null;
10
+ name?: string | null;
11
+ industry?: string | null;
12
+ website?: string | null;
13
+ phone?: string | null;
14
+ revenue?: number | null;
15
+ employeeCount?: number | null;
16
+ };
17
+
18
+ export type OpportunityRecord = {
19
+ id?: string | null;
20
+ name?: string | null;
21
+ value?: number | null;
22
+ stage?: string | null;
23
+ closeDate?: string | null;
24
+ };
25
+
26
+ export type ContactRecord = {
27
+ id?: string | null;
28
+ name?: string | null;
29
+ email?: string | null;
30
+ phone?: string | null;
31
+ title?: string | null;
32
+ };
33
+
34
+ export type CaseRecord = {
35
+ id?: string | null;
36
+ title?: string | null;
37
+ priority?: string | null;
38
+ status?: string | null;
39
+ createdAt?: string | null;
40
+ };
41
+
42
+ export type MeetingRecord = {
43
+ id?: string | null;
44
+ title?: string | null;
45
+ startTime?: string | null;
46
+ attendees?: unknown[] | null;
47
+ };
48
+
49
+ export type OverviewPayload = {
50
+ account?: AccountRecord | null;
51
+ opportunities?: OpportunityRecord[] | null;
52
+ contacts?: ContactRecord[] | null;
53
+ openCases?: CaseRecord[] | null;
54
+ recentMeetings?: MeetingRecord[] | null;
55
+ };
56
+
57
+ export async function getAccountOverview(accountId: string): Promise<ApiResult<OverviewPayload>> {
58
+ return apiFetch<OverviewPayload>(`/__observe_me/accounts/${encodeURIComponent(accountId)}/overview`);
59
+ }
@@ -0,0 +1,22 @@
1
+ // Palette sampled from the production build this recipe came from (a dark CRM).
2
+ // Re-sample these from the host page when that product's brand differs.
3
+ // Record where each value came from in docs/app-profile.md.
4
+
5
+ export const brand = {
6
+ surface: "#0E1729",
7
+ headerStart: "#1a2332",
8
+ border: "#344256",
9
+ text: "#E0E0E0",
10
+ textStrong: "#FFFFFF",
11
+ muted: "#94A3B8",
12
+ faint: "#6B7280",
13
+ accent: "#60A5FA",
14
+ accentDeep: "#3B82F6",
15
+ money: "#34D399",
16
+ deals: "#60A5FA",
17
+ contacts: "#A78BFA",
18
+ cases: "#FBBF24",
19
+ row: "rgba(255, 255, 255, 0.03)",
20
+ font: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
21
+ radius: "8px",
22
+ } as const;