@foldspace_npm/harness 0.1.26 → 0.1.28

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 (120) hide show
  1. package/CLAUDE.md +402 -204
  2. package/README.md +84 -36
  3. package/bin/ask.mjs +19 -9
  4. package/bin/attach.mjs +91 -56
  5. package/bin/badge.mjs +28 -8
  6. package/bin/cli.mjs +23 -6
  7. package/bin/inject.mjs +3 -4
  8. package/bin/observe.mjs +22 -6
  9. package/bin/published.mjs +141 -0
  10. package/package.json +6 -2
  11. package/recipes/INDEX.md +13 -8
  12. package/recipes/README.md +30 -7
  13. package/recipes/account-overview/README.md +6 -6
  14. package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
  15. package/recipes/account-overview/agent/views/brand.ts +23 -14
  16. package/recipes/account-overview/agent/views/overview.ts +13 -17
  17. package/recipes/bottom-bar/README.md +12 -9
  18. package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
  19. package/recipes/create-a-record/README.md +97 -0
  20. package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
  21. package/recipes/create-a-record/agent/api/projects.ts +62 -0
  22. package/recipes/create-a-record/agent/projects.ts +145 -0
  23. package/recipes/create-a-record/agent/views/approval.ts +181 -0
  24. package/recipes/create-a-record/agent/views/brand.ts +35 -0
  25. package/recipes/create-a-record/agent/views/fields.ts +252 -0
  26. package/recipes/create-a-record/agent/views/project-card.ts +253 -0
  27. package/recipes/create-a-record/agent/views/steps.ts +92 -0
  28. package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
  29. package/recipes/create-a-record/fixtures/project.created.json +1 -0
  30. package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
  31. package/recipes/create-a-record/recipe.json +13 -0
  32. package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
  33. package/recipes/find-by-name/README.md +3 -1
  34. package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
  35. package/recipes/lookup-then-view/README.md +73 -0
  36. package/recipes/lookup-then-view/agent/accounts.ts +174 -0
  37. package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
  38. package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
  39. package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
  40. package/recipes/lookup-then-view/agent/views/account.ts +183 -0
  41. package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
  42. package/recipes/lookup-then-view/agent/views/format.ts +73 -0
  43. package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
  44. package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
  45. package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
  46. package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
  47. package/recipes/lookup-then-view/recipe.json +11 -0
  48. package/recipes/lookup-then-view/studio/find_account.json +10 -0
  49. package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
  50. package/recipes/opportunities-at-risk/README.md +5 -5
  51. package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
  52. package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
  53. package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
  54. package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
  55. package/recipes/pick-from-a-list/README.md +7 -6
  56. package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
  57. package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
  58. package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
  59. package/recipes/prepare-for-a-meeting/README.md +4 -4
  60. package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
  61. package/recipes/records-list/README.md +6 -6
  62. package/recipes/records-list/agent/accounts.ts +3 -1
  63. package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
  64. package/recipes/records-list/agent/views/brand.ts +26 -14
  65. package/recipes/server-rendered-forms/README.md +35 -0
  66. package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
  67. package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
  68. package/recipes/server-rendered-forms/agent/forms.ts +192 -0
  69. package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
  70. package/recipes/server-rendered-forms/fixtures/form.html +17 -0
  71. package/recipes/server-rendered-forms/fixtures/list.html +9 -0
  72. package/recipes/server-rendered-forms/fixtures/record.html +4 -0
  73. package/recipes/server-rendered-forms/recipe.json +10 -0
  74. package/recipes/several-hosts/README.md +10 -13
  75. package/recipes/several-hosts/agent/hosts.ts +19 -136
  76. package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
  77. package/recipes/several-hosts/recipe.json +3 -3
  78. package/recipes/swap-the-login-method/README.md +16 -5
  79. package/recipes/swap-the-login-method/agent/utils.ts +52 -3
  80. package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
  81. package/recipes/update-meeting-notes/README.md +10 -4
  82. package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
  83. package/recipes/upload-contacts/README.md +5 -5
  84. package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
  85. package/recipes/upload-contacts/agent/views/brand.ts +24 -14
  86. package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
  87. package/recipes/who-is-the-user/README.md +7 -3
  88. package/src/ask-core.mjs +16 -4
  89. package/src/attach-daemon.mjs +9 -2
  90. package/src/attach-helpers.mjs +105 -0
  91. package/src/attach-preflight.mjs +34 -11
  92. package/src/badge-core.mjs +1 -1
  93. package/src/cdp-client.mjs +15 -19
  94. package/src/cdp-ownership.mjs +41 -20
  95. package/src/cli-help.mjs +7 -5
  96. package/src/cli-registry.mjs +102 -47
  97. package/src/diagnostics.mjs +1 -1
  98. package/src/init.mjs +5 -3
  99. package/src/keep-focus.mjs +17 -13
  100. package/src/lint/index.mjs +8 -1
  101. package/src/lint/rules.mjs +240 -43
  102. package/src/observe-core.mjs +163 -34
  103. package/src/published-core.mjs +392 -0
  104. package/src/region.mjs +1 -1
  105. package/src/runtime/agent.ts +16 -12
  106. package/src/runtime/http.ts +25 -15
  107. package/src/runtime/match.ts +1 -1
  108. package/src/runtime/render.ts +4 -4
  109. package/src/transports/README.md +2 -2
  110. package/src/upgrade.mjs +33 -10
  111. package/templates/agent-starter/CLAUDE.md +8 -5
  112. package/templates/agent-starter/README.md +32 -33
  113. package/templates/agent-starter/agent/actions/_example.ts +4 -4
  114. package/templates/agent-starter/agent/actions/index.ts +2 -2
  115. package/templates/agent-starter/agent/constants.ts +4 -3
  116. package/templates/agent-starter/agent/utils.ts +15 -18
  117. package/templates/agent-starter/claude-settings.json +2 -1
  118. package/templates/agent-starter/docs/app-profile.md +19 -14
  119. package/templates/agent-starter/foldspace.dev.json +1 -1
  120. package/recipes/several-hosts/fixtures/nav-record.json +0 -5
@@ -0,0 +1,160 @@
1
+ // The step-by-step version: one question per card, then a final review card. Copy it
2
+ // over agent/actions/create_project.ts when the README's "Which version" says so.
3
+ //
4
+ // 1. The deciding question - its answer decides which fields come next.
5
+ // 2. Every field that applies, one per card, pre-filled from what the user said. They
6
+ // confirm or change each one with Continue.
7
+ // 3. The review card: every value together; all but the first answer can still be edited.
8
+ // Only Create project writes.
9
+ //
10
+ // A step is skipped only when there is nothing to ask (one possible value, or a field
11
+ // that does not apply). Every step has Cancel. An input card: render uses callback, so
12
+ // the agent waits until callback runs, and every path calls it exactly once. Never set
13
+ // awaitUserInput - it is not needed.
14
+
15
+ import { projectFields, proposeProject, text, type Params, type Proposal } from "../projects";
16
+ import { matchOption, type Field, type Option, type Values } from "../views/fields";
17
+ import { KICKER, drawFailed, footerOf, mountCancelled, mountLoadFailure, mountProjectCard, type Callback } from "../views/project-card";
18
+ import { runSteps } from "../views/steps";
19
+
20
+ /** The deciding question. Each option says what choosing it means, so it is an option card. */
21
+ const KINDS: Option[] = [
22
+ { value: "client", label: "Client project", caption: "Billed to a client. Asks who the client is and the hourly rate." },
23
+ { value: "internal", label: "Internal project", caption: "Work for your own team. No client, no billing." },
24
+ ];
25
+
26
+ /** Each field's step asks a question. A field missing here uses its label. */
27
+ const QUESTIONS: Record<string, string> = {
28
+ kind: "What type of project is this?",
29
+ name: "What should the project be called?",
30
+ key: "What short key should it have?",
31
+ owner: "Who owns it?",
32
+ client: "Who is the client?",
33
+ hourly_rate: "What is the hourly rate?",
34
+ status: "What status does it start in?",
35
+ description: "Anything to add as a description?",
36
+ private: "Should only members see it?",
37
+ notify_members: "Should members get an email when it is created?",
38
+ };
39
+
40
+ const RATE_PATTERN = /^\d+(\.\d{1,2})?$/;
41
+
42
+ type StepParams = Params & { kind?: string; client?: string; hourly_rate?: string | number };
43
+
44
+ type StepProposal = Proposal & {
45
+ /** "" when the user's words did not settle it: the first step starts with nothing chosen. */
46
+ kind?: string;
47
+ client?: string;
48
+ hourly_rate?: string;
49
+ };
50
+
51
+ /** The fields only one kind of project has. */
52
+ function fieldsFor(kind: string, proposal: StepProposal): Field[] {
53
+ if (kind !== "client") return [];
54
+ return [
55
+ { key: "client", label: "Client", kind: "text", value: proposal.client ?? "", maxLength: 120, required: true },
56
+ {
57
+ key: "hourly_rate",
58
+ label: "Hourly rate",
59
+ kind: "text",
60
+ value: proposal.hourly_rate ?? "",
61
+ maxLength: 10,
62
+ hint: "Optional. A number, e.g. 150.",
63
+ check: (value) => (RATE_PATTERN.test(value) ? null : "A number, e.g. 150 or 150.50."),
64
+ },
65
+ ];
66
+ }
67
+
68
+ /** The proposal as the steps left it: what the review card starts from. */
69
+ function answered(proposal: StepProposal, values: Values): StepProposal {
70
+ const str = (key: string, fallback?: string) => (values[key] === undefined ? fallback : String(values[key]));
71
+ return {
72
+ ...proposal,
73
+ name: str("name", proposal.name),
74
+ key: str("key", proposal.key),
75
+ owner_value: str("owner", proposal.owner_value),
76
+ status: str("status", proposal.status),
77
+ description: str("description", proposal.description),
78
+ private: values.private === undefined ? proposal.private : values.private === true,
79
+ notify_members: values.notify_members === undefined ? proposal.notify_members : values.notify_members === true,
80
+ client: str("client", proposal.client),
81
+ hourly_rate: str("hourly_rate", proposal.hourly_rate),
82
+ };
83
+ }
84
+
85
+ export const create_project = {
86
+ // Reads and proposes, exactly as the all-at-once version does, plus the deciding answer,
87
+ // the client and the rate when the user already gave them. Writes nothing.
88
+ execute: async (params: StepParams = {}): Promise<StepProposal> => {
89
+ const proposal = await proposeProject(params);
90
+ if (!proposal.success) return proposal;
91
+ const rate = String(params.hourly_rate ?? "").trim();
92
+ return {
93
+ ...proposal,
94
+ kind: matchOption(text(params.kind, 40), KINDS),
95
+ client: text(params.client, 120),
96
+ hourly_rate: RATE_PATTERN.test(rate) ? rate : "",
97
+ };
98
+ },
99
+
100
+ render: (proposal: StepProposal | undefined, host: HTMLElement, header: HTMLElement, callback: Callback) => {
101
+ try {
102
+ if (!proposal?.success) return mountLoadFailure(host, proposal, callback);
103
+ const footer = footerOf(proposal);
104
+ const cancel = () => mountCancelled(host, header, proposal, callback);
105
+ const failed = (error: unknown) => drawFailed(host, callback, error);
106
+ const guarded = (draw: () => void) => {
107
+ try {
108
+ draw();
109
+ } catch (error) {
110
+ failed(error);
111
+ }
112
+ };
113
+
114
+ // 1. The deciding question. Pre-chosen only when the user said it.
115
+ runSteps(host, header, {
116
+ kicker: KICKER,
117
+ footer,
118
+ lastLabel: "Continue",
119
+ steps: [{ question: QUESTIONS.kind, field: { key: "kind", label: "Type", kind: "choice", value: proposal.kind ?? "", options: KINDS, required: true } }],
120
+ onCancel: cancel,
121
+ onFailed: failed,
122
+ onDone: (chosen, kindEdited) =>
123
+ guarded(() => {
124
+ const kind = String(chosen.kind);
125
+ const label = KINDS.find((option) => option.value === kind)?.label ?? KICKER;
126
+ const extra = fieldsFor(kind, proposal);
127
+
128
+ // 2. Every field that applies to this kind, one per card.
129
+ runSteps(host, header, {
130
+ kicker: label,
131
+ footer,
132
+ firstNumber: 2,
133
+ steps: projectFields(proposal, extra).map((field) => ({ question: QUESTIONS[field.key] ?? field.label, field })),
134
+ onCancel: cancel,
135
+ onFailed: failed,
136
+ onDone: (values, fieldsEdited) =>
137
+ guarded(() => {
138
+ // 3. The review card: every value together, all but the kind editable (it is a fact
139
+ // there), written only on Create project.
140
+ const reviewed = answered(proposal, values);
141
+ mountProjectCard(host, header, reviewed, callback, {
142
+ kicker: `${label} · Review`,
143
+ fields: fieldsFor(kind, reviewed),
144
+ facts: [["Type", label]],
145
+ edited: [...kindEdited, ...fieldsEdited],
146
+ draft: (final) => ({
147
+ kind,
148
+ clientName: kind === "client" ? String(final.client ?? "").trim() || null : null,
149
+ hourlyRate: kind === "client" && final.hourly_rate ? Number(final.hourly_rate) : null,
150
+ }),
151
+ });
152
+ }),
153
+ });
154
+ }),
155
+ });
156
+ } catch (error) {
157
+ drawFailed(host, callback, error);
158
+ }
159
+ },
160
+ };
@@ -9,7 +9,9 @@ user genuinely has to choose, that is a different action —
9
9
  [`pick-from-a-list`](../pick-from-a-list/).
10
10
 
11
11
  In Agent Studio: an action with key `find_project_id` and one optional string
12
- parameter `name` — *the name, or part of the name, the user said*.
12
+ parameter `name` - *the name, or part of the name, the user said*. Instructions
13
+ to set: "No emoji. Quote totals and counts exactly as returned; never work them
14
+ out."
13
15
 
14
16
  ## What it returns
15
17
 
@@ -1,7 +1,7 @@
1
1
  // Resolver: a name the user said → the id every other project action needs.
2
2
  //
3
3
  // It answers questions and never asks one. "How many projects do I have?" gets
4
- // a count, not a widget demanding a selection — asking is choose_project.
4
+ // a count, not a card demanding a selection - asking is choose_project.
5
5
 
6
6
  import { listProjects } from "../api/projects";
7
7
  import { classify, MATCH_RANK, nearMatches, toSummary, type MatchType, type ProjectSummary } from "../projects";
@@ -0,0 +1,73 @@
1
+ # Look up, then show - L2
2
+
3
+ **The user says** "show me Northwind". **The user sees** Northwind's card, and was asked nothing, because there is one Northwind. "Show me Acme", with two Acmes: a list to pick from, with search and pages, and one click opens the card. A name nobody has: the same list, the closest names first (from the first 50 accounts).
4
+
5
+ **Proven by 1 production build** (a CRM: account lookup, account overview). This recipe keeps its shape and fixes what it got wrong (**What that build learned the hard way**, below).
6
+
7
+ ## Two actions, and why they chain by themselves
8
+
9
+ | Action | Input | What makes the chain |
10
+ |---|---|---|
11
+ | `find_account` | `name`, **required**: "exactly as the user said it" | It is the only thing that produces an account id |
12
+ | `show_account_overview` | `accountId`, **required**: "never guessed and never taken from the user's words: get it from `find_account`" | The agent sees a required id the user cannot give, and the action that produces it |
13
+
14
+ **Why this lookup asks.** The user asked to *see* one account, so choosing which one is part of the answer. When an id only feeds another question or a write, keep the two jobs apart instead: `find-by-name` answers and never asks, and `pick-from-a-list` asks when the user has to choose.
15
+
16
+ **Only the id is required on the card's action.** Do not add a required account *name* to it as well. The agent would fill both from the user's sentence and skip the lookup.
17
+
18
+ `studio/*.json` are the two `create_action` payloads, word for word. Change only the entity's name (**Adapt it**); add `agent_id`, and pass `parameters` as a JSON string.
19
+
20
+ | `find_account` answers | What the agent does (Studio instructions) | The user sees |
21
+ |---|---|---|
22
+ | `found` + id | calls `show_account_overview`, no question | the card, straight away |
23
+ | `chosen` + id | the same | the list, one click, then the card |
24
+ | `none_of_these` | asks which account they mean | a question |
25
+ | `cancelled` | stops | nothing more |
26
+ | `failed` | says so in one short sentence and stops | the card, saying why |
27
+
28
+ `find_account` is an input card: its `render` uses `callback`, and that is what makes the agent wait for the answer. Every path calls it exactly once - at once on one match or a failure, and from the list when the user chooses, picks "None of these" or cancels. `show_account_overview` is a view card: its `render` takes no `callback` or `cancel`, so once the card is drawn the agent gets "Shown to the user. The user has not acted on it." plus everything `execute` returned - the facts the card draws from (every open deal, person and open case, not only the three on screen), or the failure the card draws. That is how the agent can answer a question about the account it just showed. To change what the agent gets, change `execute`, not the card. Neither action sets `awaitUserInput`.
29
+
30
+ ## The pick-one card (`views/pick-one.ts`)
31
+
32
+ The card for "which one did you mean?". It is not specific to accounts; use it for any record.
33
+
34
+ | | |
35
+ |---|---|
36
+ | Long lists | one page on screen (`pageSize`, 8 in `find_account`); Previous / Next, "9-16 of 143" |
37
+ | Search | asks the app again as the user types (after 250 ms); the newest answer wins |
38
+ | One click | clicking a row chooses it. `confirm: true` adds an Open button instead, for choices that should be deliberate |
39
+ | No match | the closest names first (`find_account` picks them from the first 50 accounts, `WIDE`), "Browse all" behind them |
40
+ | Answers | a choice, "None of these" or "Cancel" - exactly one, ever |
41
+ | Afterwards | the card shrinks to "Chosen: Northwind Traders", so the history reads right |
42
+
43
+ ## The account card (`views/account.ts`)
44
+
45
+ A view card, laid out to be read in five seconds at chat width:
46
+
47
+ | Part | Shows | Rules |
48
+ |---|---|---|
49
+ | Who | name, industry, size, place, website, owner | The website is shown as plain text, not a link: the card never sends the user out of the app |
50
+ | The money | open pipeline, weighted pipeline, open deals, open cases | Short in the tile ($1.2M) with the exact amount in the tooltip. Weighted is each deal times its probability. Both totals are in whole units, as the card shows them. All of it is computed in code |
51
+ | Deals | the biggest open deals | Stage as a chip; "Closes in 12 days", or "Close date passed …" in amber; the amount right-aligned in full, probability under it |
52
+ | People | the primary contact first | Name, then title · email · phone, as text |
53
+ | Open cases | critical and urgent ones marked red, high ones amber | Shown only when there are any |
54
+
55
+ Missing is "-", never 0: a deal amount nobody entered is not $0. Money uses the account's own currency, without cents. Formatting lives in `views/format.ts`, so every card in the project says it the same way.
56
+
57
+ ## Adapt it
58
+
59
+ | File | Change |
60
+ |---|---|
61
+ | `agent/api/accounts.ts` | The app's account search (name, page) and record call. If the search has no paging, page the result here |
62
+ | `agent/accounts.ts` | Which fields a row and the card carry. `resolve` stays |
63
+ | `agent/views/brand.ts` | A placeholder: keep the one `npx foldspace observe styles --write` wrote - it has every key the cards read. If the project has none yet, run that command first; do not copy this one |
64
+ | `studio/*.json` | The entity's name in the words; the inputs and the rules stay |
65
+
66
+ ## What that build learned the hard way
67
+
68
+ - **Declare the input fully, or the chain does not happen.** An id marked optional, or described only as "the id", leaves the agent guessing, or asking the user for an id they do not have.
69
+ - **Ties go to the user.** Two Acmes never resolve to the first one. Only an exact name, or the only result, answers at once.
70
+ - **The user chooses on a card, not in chat.** That build listed several matches as numbered text with the raw ids, and the agent asked in chat; the pick-one list replaces both.
71
+ - **Nothing found is an answer**, never a failure and never "try again" - that build's failure told the agent to "try a shorter search term".
72
+ - **Send the agent only what it needs**: the answer carries the id and the name; the list rows carry industry and place - never the whole row.
73
+ - **The chain lives in Agent Studio**, not in the tool response. "Now call show_account_overview" inside a response is an instruction in data (**Rules**, 1).
@@ -0,0 +1,174 @@
1
+ // Turning what the API returns into what the agent and the cards need.
2
+ // Totals are computed here, from the API's own numbers - never by the agent.
3
+
4
+ import type { AccountRow, OverviewPayload } from "./api/accounts";
5
+ import { rankBy } from "./utils";
6
+
7
+ /** Only what is needed to tell two accounts apart - never the whole row. */
8
+ export type AccountSummary = { accountId: string; name: string; industry: string | null; place: string | null };
9
+
10
+ export function toSummary(row: AccountRow): AccountSummary {
11
+ const place = [row.city, row.state].map((part) => part?.trim()).filter(Boolean).join(", ");
12
+ return { accountId: row.id, name: row.name, industry: row.industry?.trim() || null, place: place || null };
13
+ }
14
+
15
+ type Strength = "exact" | "starts_with" | "contains";
16
+ const RANK: Record<Strength, number> = { exact: 0, starts_with: 1, contains: 2 };
17
+
18
+ function strength(name: string, query: string): Strength | null {
19
+ const a = name.trim().toLowerCase();
20
+ const b = query.trim().toLowerCase();
21
+ if (!b) return null;
22
+ if (a === b) return "exact";
23
+ if (a.startsWith(b)) return "starts_with";
24
+ if (a.includes(b)) return "contains";
25
+ return null;
26
+ }
27
+
28
+ /**
29
+ * One when a single account matches best; many when the best matches tie -
30
+ * showing the wrong account is worse than asking; none when nothing matched.
31
+ * `total` is the server's own count, so "many" is right even past one page.
32
+ */
33
+ export function resolve(rows: AccountSummary[], query: string, total = rows.length): "one" | "many" | "none" {
34
+ if (!rows.length) return "none";
35
+ if (total > rows.length) return "many";
36
+ const ranked = rows
37
+ .map((account) => strength(account.name, query))
38
+ .filter((at): at is Strength => at !== null)
39
+ .sort((a, b) => RANK[a] - RANK[b]);
40
+ if (!ranked.length) return rows.length === 1 ? "one" : "many";
41
+ return ranked.filter((at) => at === ranked[0]).length === 1 && (ranked[0] === "exact" || rows.length === 1) ? "one" : "many";
42
+ }
43
+
44
+ /** How alike two words are, 0..1: shared letter pairs (Dice). "northwnd" and "northwind" score 0.8. */
45
+ export function likeness(a: string, b: string): number {
46
+ const pairs = (word: string) => {
47
+ const clean = word.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
48
+ const out: string[] = [];
49
+ for (let i = 0; i < clean.length - 1; i += 1) out.push(clean.slice(i, i + 2));
50
+ return out;
51
+ };
52
+ const left = pairs(a);
53
+ const right = pairs(b);
54
+ if (!left.length || !right.length) return 0;
55
+ const pool = [...right];
56
+ let shared = 0;
57
+ for (const pair of left) {
58
+ const at = pool.indexOf(pair);
59
+ if (at >= 0) {
60
+ shared += 1;
61
+ pool.splice(at, 1);
62
+ }
63
+ }
64
+ return (2 * shared) / (left.length + right.length);
65
+ }
66
+
67
+ /**
68
+ * A typo, a half-remembered word: the closest names, as suggestions - never
69
+ * as a resolution. The SDK's fuzzy match first (through rankBy); when it has
70
+ * nothing - or the SDK is not on the page - each name and each word in it is
71
+ * compared with what the user typed.
72
+ */
73
+ export function nearMatches(rows: AccountSummary[], query: string, limit = 5): AccountSummary[] {
74
+ const ranked = rankBy(rows, query, { searchKeys: ["name"], idOf: (row) => row.accountId, limit });
75
+ if (ranked.length) return ranked;
76
+ return rows
77
+ .map((row) => ({ row, score: Math.max(likeness(row.name, query), ...row.name.split(/\s+/).map((word) => likeness(word, query))) }))
78
+ .filter((entry) => entry.score >= 0.5)
79
+ .sort((x, y) => y.score - x.score)
80
+ .slice(0, limit)
81
+ .map((entry) => entry.row);
82
+ }
83
+
84
+ // ---------------------------------------------------------------- the overview
85
+
86
+ export type Deal = { name: string; amount: number | null; stage: string | null; probability: number | null; closeDate: string | null };
87
+ export type Person = { name: string; title: string | null; email: string | null; phone: string | null; primary: boolean };
88
+ export type Case = { title: string; priority: string | null; createdAt: string | null };
89
+
90
+ export type Overview = {
91
+ account: {
92
+ accountId: string;
93
+ name: string;
94
+ industry: string | null;
95
+ website: string | null;
96
+ place: string | null;
97
+ employeeCount: number | null;
98
+ currency: string;
99
+ owner: string | null;
100
+ };
101
+ metrics: {
102
+ openPipeline: number;
103
+ weightedPipeline: number;
104
+ openDeals: number;
105
+ openCases: number;
106
+ criticalCases: number;
107
+ };
108
+ deals: Deal[];
109
+ people: Person[];
110
+ cases: Case[];
111
+ };
112
+
113
+ const OPEN = (stage: string | null) => !/^closed/i.test(stage ?? "");
114
+ const num = (value: unknown): number | null => {
115
+ const parsed = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
116
+ return typeof parsed === "number" && Number.isFinite(parsed) ? parsed : null;
117
+ };
118
+ const text = (value: string | null | undefined) => value?.trim() || null;
119
+
120
+ /** Critical or urgent. High is not: the card marks it amber, and it is not counted as critical. */
121
+ export const isCritical = (priority: string | null): boolean => /^(critical|urgent)$/i.test(priority?.trim() ?? "");
122
+
123
+ /** A 200 without both an id and a name is a signed-out answer, not an empty account. */
124
+ export function isAccount(payload: OverviewPayload): boolean {
125
+ return Boolean(text(payload.account?.id) && text(payload.account?.name));
126
+ }
127
+
128
+ export function toOverview(payload: OverviewPayload): Overview {
129
+ const a = payload.account ?? {};
130
+ const deals: Deal[] = (payload.opportunities ?? []).map((row) => ({
131
+ name: text(row.name) ?? "Untitled deal",
132
+ amount: num(row.amount),
133
+ stage: text(row.stage),
134
+ probability: num(row.probability),
135
+ closeDate: text(row.closeDate),
136
+ }));
137
+ const open = deals.filter((deal) => OPEN(deal.stage));
138
+ const people: Person[] = (payload.contacts ?? [])
139
+ .map((row) => ({
140
+ name: [text(row.firstName), text(row.lastName)].filter(Boolean).join(" ") || "Unnamed contact",
141
+ title: text(row.title),
142
+ email: text(row.email),
143
+ phone: text(row.phone),
144
+ primary: row.primary === true,
145
+ }))
146
+ .sort((x, y) => Number(y.primary) - Number(x.primary));
147
+ const cases: Case[] = (payload.cases ?? [])
148
+ .filter((row) => !/^(closed|resolved)/i.test(row.status ?? ""))
149
+ .map((row) => ({ title: text(row.title) ?? "Untitled case", priority: text(row.priority), createdAt: text(row.createdAt) }));
150
+ const place = [text(a.city), text(a.state)].filter(Boolean).join(", ");
151
+ return {
152
+ account: {
153
+ accountId: String(a.id).trim(),
154
+ name: String(a.name).trim(),
155
+ industry: text(a.industry),
156
+ website: text(a.website),
157
+ place: place || null,
158
+ employeeCount: num(a.employeeCount),
159
+ currency: /^[A-Z]{3}$/.test(text(a.currency) ?? "") ? (text(a.currency) as string) : "USD",
160
+ owner: text(a.owner?.name),
161
+ },
162
+ metrics: {
163
+ // Whole units, as the card shows them: the agent quotes these exactly.
164
+ openPipeline: Math.round(open.reduce((sum, deal) => sum + (deal.amount ?? 0), 0)),
165
+ weightedPipeline: Math.round(open.reduce((sum, deal) => sum + (deal.amount ?? 0) * ((deal.probability ?? 0) / 100), 0)),
166
+ openDeals: open.length,
167
+ openCases: cases.length,
168
+ criticalCases: cases.filter((row) => isCritical(row.priority)).length,
169
+ },
170
+ deals: open.sort((x, y) => (y.amount ?? 0) - (x.amount ?? 0)),
171
+ people,
172
+ cases,
173
+ };
174
+ }
@@ -0,0 +1,108 @@
1
+ // The lookup half. A name in; the account's id out - and when the name is not
2
+ // enough, the user chooses.
3
+ //
4
+ // one clear match -> answers "found" at once; nothing to click
5
+ // several matches -> the pick-one list: search, pages, one click
6
+ // no match -> the same list, closest names (of the first 50) first,
7
+ // all accounts behind
8
+ //
9
+ // Whatever happens, the answer is state - found, chosen, none_of_these,
10
+ // cancelled or failed - never an instruction. Agent Studio holds what to do
11
+ // next: on found or chosen, show_account_overview with the id, without asking.
12
+ // An input card: render uses callback, and that is what makes the agent wait
13
+ // for the answer. Every path calls it exactly once. No awaitUserInput.
14
+
15
+ import { searchAccounts } from "../api/accounts";
16
+ import { nearMatches, resolve, toSummary, type AccountSummary } from "../accounts";
17
+ import { renderFailure, type FailureReason } from "../utils";
18
+ import { mountPickOne, type PickRow } from "../views/pick-one";
19
+
20
+ /** A page that fits in the chat panel without scrolling it away. */
21
+ export const PAGE_SIZE = 8;
22
+ /** How many accounts the closest names are picked from when nothing matched. */
23
+ const WIDE = 50;
24
+
25
+ type Lookup =
26
+ | { status: "one"; query: string; account: AccountSummary }
27
+ | { status: "many"; query: string; total: number; accounts: AccountSummary[] }
28
+ | { status: "none"; query: string; suggested: AccountSummary[]; total: number; accounts: AccountSummary[] };
29
+
30
+ type FindResult = { success: boolean; error?: string; reason?: FailureReason; data?: Lookup };
31
+ type Callback = (value: unknown, disableOnSubmit?: boolean) => void;
32
+
33
+ const toRow = (account: AccountSummary): PickRow => ({
34
+ id: account.accountId,
35
+ primary: account.name,
36
+ secondary: [account.industry, account.place].filter(Boolean).join(" · ") || null,
37
+ });
38
+
39
+ export const find_account = {
40
+ execute: async (params: { name?: string }): Promise<FindResult> => {
41
+ const query = typeof params?.name === "string" ? params.name.trim() : "";
42
+ if (!query) return { success: false, error: "No account name was given.", reason: "config" };
43
+
44
+ const res = await searchAccounts({ search: query, limit: PAGE_SIZE });
45
+ if (!res.ok) {
46
+ console.warn("[find_account]", res.status, res.reason, res.detail);
47
+ return { success: false, error: res.error, reason: res.reason };
48
+ }
49
+ const accounts = res.data.accounts.map(toSummary);
50
+ const status = resolve(accounts, query, res.data.total);
51
+ if (status === "one") {
52
+ // The exact name when there is one - the app may list it after a longer name.
53
+ const account = accounts.find((row) => row.name.trim().toLowerCase() === query.toLowerCase()) ?? accounts[0];
54
+ return { success: true, data: { status, query, account } };
55
+ }
56
+ if (status === "many") return { success: true, data: { status, query, total: res.data.total, accounts } };
57
+
58
+ // Nothing matched: the closest names among the first WIDE accounts, then
59
+ // every account behind them.
60
+ const wide = await searchAccounts({ limit: WIDE });
61
+ if (!wide.ok) return { success: false, error: wide.error, reason: wide.reason };
62
+ const everyone = wide.data.accounts.map(toSummary);
63
+ return {
64
+ success: true,
65
+ data: { status, query, suggested: nearMatches(everyone, query), total: wide.data.total, accounts: everyone.slice(0, PAGE_SIZE) },
66
+ };
67
+ },
68
+
69
+ render: (result: FindResult | undefined, host: HTMLElement, header: HTMLElement, callback: Callback) => {
70
+ if (!result?.success || !result.data) {
71
+ renderFailure(host, {
72
+ ok: false,
73
+ status: 0,
74
+ error: result?.error ?? "The accounts could not be loaded.",
75
+ reason: result?.reason ?? "server",
76
+ });
77
+ callback({ success: false, status: "failed", reason: result?.reason ?? "server" }, true);
78
+ return;
79
+ }
80
+ const found = result.data;
81
+
82
+ if (found.status === "one") {
83
+ const line = document.createElement("div");
84
+ line.textContent = `Found: ${found.account.name}`;
85
+ host.textContent = "";
86
+ host.append(line);
87
+ callback({ success: true, status: "found", accountId: found.account.accountId, name: found.account.name }, true);
88
+ return;
89
+ }
90
+
91
+ mountPickOne(host, header, {
92
+ title: "Which account?",
93
+ note: found.status === "many" ? `${found.total} matches for "${found.query}"` : `No account matches "${found.query}"`,
94
+ query: found.status === "many" ? found.query : "",
95
+ placeholder: "Search accounts",
96
+ initial: { rows: found.accounts.map(toRow), total: found.total },
97
+ suggested: found.status === "none" ? found.suggested.map(toRow) : undefined,
98
+ pageSize: PAGE_SIZE,
99
+ fetchPage: async (query, offset) => {
100
+ const page = await searchAccounts({ search: query, offset, limit: PAGE_SIZE });
101
+ return page.ok ? { rows: page.data.accounts.map(toSummary).map(toRow), total: page.data.total } : { error: page.error };
102
+ },
103
+ onChoose: (row) => callback({ success: true, status: "chosen", accountId: row.id, name: row.primary }, true),
104
+ onNone: () => callback({ success: true, status: "none_of_these", query: found.query }, true),
105
+ onCancel: () => callback({ success: true, status: "cancelled" }, true),
106
+ });
107
+ },
108
+ };
@@ -0,0 +1,42 @@
1
+ // The view half. Its one input, accountId, is required, and its description in
2
+ // Agent Studio says the id comes from find_account - that is what makes the
3
+ // agent look a name up first, by itself. A view card: render takes no callback
4
+ // or cancel, and the action sets no awaitUserInput. Once the card is drawn, the
5
+ // agent is told it was shown and gets everything execute returned - so execute
6
+ // returns the facts the card draws from (every open deal, person and open case,
7
+ // not only the three on screen), or the failure the card draws instead.
8
+
9
+ import { getAccountOverview } from "../api/accounts";
10
+ import { isAccount, toOverview, type Overview } from "../accounts";
11
+ import { renderFailure, type FailureReason } from "../utils";
12
+ import { mountAccount } from "../views/account";
13
+
14
+ type ShowResult = { success: boolean; error?: string; reason?: FailureReason; data?: Overview };
15
+
16
+ export const show_account_overview = {
17
+ execute: async (params: { accountId?: string }): Promise<ShowResult> => {
18
+ const accountId = typeof params?.accountId === "string" ? params.accountId.trim() : "";
19
+ if (!accountId) return { success: false, error: "No account was chosen.", reason: "config" };
20
+ const res = await getAccountOverview(accountId);
21
+ if (!res.ok) {
22
+ // The card's own words for a missing account, so the agent gets what the card shows.
23
+ const error = res.reason === "not_found" ? "That account could not be found." : res.error;
24
+ return { success: false, error, reason: res.reason };
25
+ }
26
+ if (!isAccount(res.data)) return { success: false, error: "Not signed in.", reason: "signed_out" };
27
+ return { success: true, data: toOverview(res.data) };
28
+ },
29
+
30
+ render: (result: ShowResult | undefined, host: HTMLElement, header: HTMLElement) => {
31
+ if (!result?.success || !result.data) {
32
+ renderFailure(host, {
33
+ ok: false,
34
+ status: 0,
35
+ error: result?.error ?? "The account could not be loaded.",
36
+ reason: result?.reason ?? "server",
37
+ });
38
+ return;
39
+ }
40
+ mountAccount(host, header, result.data);
41
+ },
42
+ };
@@ -0,0 +1,68 @@
1
+ // Mock API. These paths are not a real product. Find the app's own account
2
+ // search and its account record call, and replace paths and envelopes with
3
+ // what you observed. `__observe_me` cannot succeed until you do.
4
+ //
5
+ // The search takes a name and a page. When the app's search has no paging
6
+ // (it returns every match at once), keep the call and cut the page here -
7
+ // `rows.slice(offset, offset + limit)`, with `total` the count of all matches.
8
+
9
+ import { apiFetch, type ApiResult } from "../utils";
10
+
11
+ export type AccountRow = {
12
+ id: string;
13
+ name: string;
14
+ industry?: string | null;
15
+ city?: string | null;
16
+ state?: string | null;
17
+ };
18
+
19
+ export type AccountPage = { accounts: AccountRow[]; total: number };
20
+
21
+ export async function searchAccounts(
22
+ options: { search?: string; offset?: number; limit?: number } = {},
23
+ ): Promise<ApiResult<AccountPage>> {
24
+ const query = new URLSearchParams();
25
+ if (options.search) query.set("search", options.search);
26
+ query.set("offset", String(Math.max(0, options.offset ?? 0)));
27
+ query.set("limit", String(options.limit && options.limit > 0 ? options.limit : 20));
28
+ const res = await apiFetch<{ items?: AccountRow[]; total?: number }>(`/__observe_me/accounts?${query}`);
29
+ if (!res.ok) return res;
30
+ const rows = Array.isArray(res.data?.items) ? res.data.items : [];
31
+ return { ok: true, status: res.status, data: { accounts: rows, total: typeof res.data?.total === "number" ? res.data.total : rows.length } };
32
+ }
33
+
34
+ export type OverviewPayload = {
35
+ account?: {
36
+ id?: string | null;
37
+ name?: string | null;
38
+ industry?: string | null;
39
+ website?: string | null;
40
+ city?: string | null;
41
+ state?: string | null;
42
+ employeeCount?: number | null;
43
+ currency?: string | null;
44
+ owner?: { name?: string | null; email?: string | null } | null;
45
+ } | null;
46
+ opportunities?: Array<{
47
+ id?: string | null;
48
+ name?: string | null;
49
+ amount?: number | string | null;
50
+ stage?: string | null;
51
+ probability?: number | null;
52
+ closeDate?: string | null;
53
+ }> | null;
54
+ contacts?: Array<{
55
+ id?: string | null;
56
+ firstName?: string | null;
57
+ lastName?: string | null;
58
+ title?: string | null;
59
+ email?: string | null;
60
+ phone?: string | null;
61
+ primary?: boolean | null;
62
+ }> | null;
63
+ cases?: Array<{ id?: string | null; title?: string | null; priority?: string | null; status?: string | null; createdAt?: string | null }> | null;
64
+ };
65
+
66
+ export async function getAccountOverview(accountId: string): Promise<ApiResult<OverviewPayload>> {
67
+ return apiFetch<OverviewPayload>(`/__observe_me/accounts/${encodeURIComponent(accountId)}/overview`);
68
+ }