@foldspace_npm/harness 0.1.27 → 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 (118) hide show
  1. package/CLAUDE.md +301 -188
  2. package/README.md +54 -36
  3. package/bin/ask.mjs +9 -7
  4. package/bin/attach.mjs +80 -51
  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 +1 -1
  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 +3 -3
  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 +14 -19
  94. package/src/cdp-ownership.mjs +41 -20
  95. package/src/cli-help.mjs +7 -5
  96. package/src/cli-registry.mjs +65 -46
  97. package/src/diagnostics.mjs +1 -1
  98. package/src/init.mjs +5 -3
  99. package/src/lint/index.mjs +8 -1
  100. package/src/lint/rules.mjs +240 -43
  101. package/src/observe-core.mjs +163 -34
  102. package/src/region.mjs +1 -1
  103. package/src/runtime/agent.ts +16 -12
  104. package/src/runtime/http.ts +25 -15
  105. package/src/runtime/match.ts +1 -1
  106. package/src/runtime/render.ts +4 -4
  107. package/src/transports/README.md +2 -2
  108. package/src/upgrade.mjs +33 -10
  109. package/templates/agent-starter/CLAUDE.md +8 -5
  110. package/templates/agent-starter/README.md +32 -33
  111. package/templates/agent-starter/agent/actions/_example.ts +4 -4
  112. package/templates/agent-starter/agent/actions/index.ts +2 -2
  113. package/templates/agent-starter/agent/constants.ts +4 -3
  114. package/templates/agent-starter/agent/utils.ts +15 -18
  115. package/templates/agent-starter/claude-settings.json +1 -1
  116. package/templates/agent-starter/docs/app-profile.md +19 -14
  117. package/templates/agent-starter/foldspace.dev.json +1 -1
  118. package/recipes/several-hosts/fixtures/nav-record.json +0 -5
@@ -9,13 +9,29 @@ import { AGENT_API_NAME, API_BASE } from "./constants";
9
9
 
10
10
  configure({ agentApiName: AGENT_API_NAME, apiBase: API_BASE });
11
11
 
12
+ /**
13
+ * Headers the app's own requests carried besides the cookies. Add only what
14
+ * you OBSERVED: one build needed `x-requested-with: XMLHttpRequest`; most need
15
+ * none. Both `apiFetch` and `apiFetchBinary` send them.
16
+ */
17
+ const EXTRA_HEADERS: Record<string, string> = {};
18
+
12
19
  export async function apiFetch<T = unknown>(
13
20
  path: string,
14
21
  init: RequestInit = {},
15
22
  timeoutMs = 15_000,
16
23
  ): Promise<ApiResult<T>> {
24
+ // Empty API_BASE is a failure on purpose: a guessed host cannot look like
25
+ // success. `error` is drawn on the card and read by the agent - it says what
26
+ // is true; the builder's hint goes in `detail`, which only the console sees.
17
27
  if (!API_BASE) {
18
- return { ok: false, status: 0, reason: "config", error: "API_BASE is not set — capture the app's XHR first." };
28
+ return {
29
+ ok: false,
30
+ status: 0,
31
+ reason: "config",
32
+ error: "This is not set up yet.",
33
+ detail: "API_BASE is not set - capture the app's XHR first.",
34
+ };
19
35
  }
20
36
 
21
37
  const controller = new AbortController();
@@ -25,9 +41,7 @@ export async function apiFetch<T = unknown>(
25
41
  ...init,
26
42
  credentials: "include",
27
43
  signal: controller.signal,
28
- // Add only what you OBSERVED the app sending. One build needed
29
- // `x-requested-with: XMLHttpRequest`; most do not.
30
- headers: { accept: "application/json", "Content-Type": "application/json", ...(init.headers ?? {}) },
44
+ headers: { accept: "application/json", "Content-Type": "application/json", ...EXTRA_HEADERS, ...(init.headers ?? {}) },
31
45
  });
32
46
  const text = await res.text();
33
47
  let parsed: unknown = text;
@@ -45,8 +59,44 @@ export async function apiFetch<T = unknown>(
45
59
  }
46
60
  }
47
61
 
48
- // Everything else still comes from the harness. Named, NOT `export *` — that
49
- // would put the default apiFetch back next to this one.
62
+ /** The same sign-in as `apiFetch`, for a file: the answer comes back as bytes. */
63
+ export async function apiFetchBinary(
64
+ path: string,
65
+ init: RequestInit = {},
66
+ timeoutMs = 15_000,
67
+ ): Promise<ApiResult<Uint8Array>> {
68
+ if (!API_BASE) {
69
+ return {
70
+ ok: false,
71
+ status: 0,
72
+ reason: "config",
73
+ error: "This is not set up yet.",
74
+ detail: "API_BASE is not set - capture the app's XHR first.",
75
+ };
76
+ }
77
+
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
80
+ try {
81
+ const res = await fetch(`${API_BASE}${path}`, {
82
+ ...init,
83
+ credentials: "include",
84
+ signal: controller.signal,
85
+ headers: { ...EXTRA_HEADERS, ...(init.headers ?? {}) },
86
+ });
87
+ if (!res.ok) return httpFailure(res, "That file could not be returned.");
88
+ return { ok: true, status: res.status, data: new Uint8Array(await res.arrayBuffer()) };
89
+ } catch (error) {
90
+ return networkFailure(error, "Download timed out.", "Could not reach the server.");
91
+ } finally {
92
+ clearTimeout(timer);
93
+ }
94
+ }
95
+
96
+ // Everything else comes from the harness, by name. Give a helper that calls
97
+ // the app's API its own version above, as apiFetch and apiFetchBinary have:
98
+ // the harness's returns a config failure here, because this file sets no
99
+ // `authSource`. For the same reason getAuthToken returns null (see the README).
50
100
  export {
51
101
  getAgent,
52
102
  armAllInstances,
@@ -60,5 +110,8 @@ export {
60
110
  renderError,
61
111
  renderFatal,
62
112
  renderFailure,
113
+ httpFailure,
114
+ networkFailure,
115
+ getAuthToken,
63
116
  } from "@foldspace_npm/harness/runtime";
64
117
  export type { ApiResult, ApiFailure, FailureReason, ViewHost } from "@foldspace_npm/harness/runtime";
@@ -4,9 +4,15 @@
4
4
 
5
5
  **Proven by 1 production build.**
6
6
 
7
- A missing `meetingId` or empty notes fails and names the field that was absent. A missing meeting comes back as `not_found`. The saved result is the note id and the meeting id.
7
+ A missing `meetingId` or empty notes fails and says, in plain words, which one is missing. A missing meeting comes back as `not_found`. The saved result is the note id and the meeting id. A 2xx from the app with no note id comes back as `success: false`, `status: "unconfirmed"`, `reason: "server"`: it is not known whether the note was saved.
8
8
 
9
- In Agent Studio: an action with key `update_meeting_notes` and string parameters `meetingId`, `notes`, and optional `author`.
9
+ In Agent Studio: an action with key `update_meeting_notes` and three string parameters, described as:
10
+
11
+ - `meetingId`: "The meeting's id. Never taken from the user's words: get it from `<meetings action key>`."
12
+ - `notes`: "The text to save on the meeting."
13
+ - `author` (optional): "The name to put on the note, only when the user gives one - never the agent's own name; leave it out otherwise."
14
+
15
+ Replace `<meetings action key>` with the key of this agent's action that returns meeting ids; if the agent has none, build that action first - `find-by-name` has the shape. Instructions to set: "No emoji."
10
16
 
11
17
  ## Adapt it
12
18
 
@@ -18,5 +24,5 @@ In Agent Studio: an action with key `update_meeting_notes` and string parameters
18
24
  ## What that build learned the hard way
19
25
 
20
26
  - **Return the id the server created.** "Saved" without an id is not something the next action can use.
21
- - **Name the missing field.** An empty notes string and a missing meeting are different failures.
22
- - **This is a live write.** Ask before publishing an edit to an action that already has users.
27
+ - **Say which one is missing.** An empty note and a missing meeting are different failures.
28
+ - **This is a live write.** Running it - with `foldspace ask` or any other way - saves a real note in the signed-in account. Run it yourself only in an account the human named as a test environment; anywhere else, let the human type the question. Publishing it, and every later edit to it, needs the human's yes.
@@ -1,5 +1,6 @@
1
1
  // A write, with no card. The result is what was saved: the note id and the
2
- // meeting id. A missing field is named. A missing meeting is not_found.
2
+ // meeting id. A missing meeting id or note text is said in plain words. A
3
+ // missing meeting is not_found.
3
4
 
4
5
  import { saveMeetingNote } from "../api/meetings";
5
6
 
@@ -10,10 +11,10 @@ export const update_meeting_notes = {
10
11
  const author = typeof params?.author === "string" ? params.author.trim() : "";
11
12
 
12
13
  if (!meetingId) {
13
- return { success: false, error: "meetingId was not provided.", reason: "config" };
14
+ return { success: false, error: "No meeting was given.", reason: "config" };
14
15
  }
15
16
  if (!notes) {
16
- return { success: false, error: "notes were not provided.", reason: "config" };
17
+ return { success: false, error: "No note text was given.", reason: "config" };
17
18
  }
18
19
 
19
20
  const res = await saveMeetingNote(meetingId, notes, author || undefined);
@@ -22,9 +23,19 @@ export const update_meeting_notes = {
22
23
  return { success: false, error: res.error, reason: res.reason };
23
24
  }
24
25
 
25
- const noteId = res.data.note?.id?.trim() || "";
26
+ // A 2xx without an id is not a failure the app reported: the note may
27
+ // well be saved. Say exactly that, so it is neither retried as lost nor
28
+ // reported as saved. The body may be anything - `null`, or a numeric id.
29
+ const rawId = res.data?.note?.id as unknown;
30
+ const noteId = typeof rawId === "number" ? String(rawId) : typeof rawId === "string" ? rawId.trim() : "";
26
31
  if (!noteId) {
27
- return { success: false, error: "The note was not saved.", reason: "server" };
32
+ return {
33
+ success: false,
34
+ status: "unconfirmed",
35
+ error: "The app answered without a note id, so it is not known whether the note was saved.",
36
+ reason: "server",
37
+ data: { meetingId },
38
+ };
28
39
  }
29
40
 
30
41
  return {
@@ -1,12 +1,12 @@
1
1
  # Upload contacts — L4
2
2
 
3
- **The user says** "upload these contacts". **The user sees** the import card: a drop zone, a review table, then the result. Nothing is written until they choose Import. Done finishes the card.
3
+ **The user says** "upload these contacts". **The user sees** the import card: a drop zone, a review table, then the result. Nothing is written until they choose Import.
4
4
 
5
5
  **Proven by 1 production build.**
6
6
 
7
- `awaitUserInput` is set, so the agent is paused until `callback` runs. Cancel, an empty file, a failed import, and a finished import each call `callback`. The callback states what happened: created, skipped, and a reason on each skipped row.
7
+ An input card: `render` uses `callback`, so the agent waits until the card calls it. Cancel, an empty file, a failed import and a finished import each call `callback` once - a finished import as soon as it returns, with `false` so Done and View Contacts still work; those two buttons do not call back. Each callback states what happened; a finished import carries created, skipped, and a reason on each skipped row.
8
8
 
9
- In Agent Studio: an action with key `upload_contacts` and no parameters. The file comes from the card, not from the model.
9
+ In Agent Studio: an action with key `upload_contacts` and no parameters. The file comes from the card, not from the model. Instructions to set: "No emoji. Quote totals and counts exactly as returned; never work them out."
10
10
 
11
11
  ## Adapt it
12
12
 
@@ -14,12 +14,12 @@ In Agent Studio: an action with key `upload_contacts` and no parameters. The fil
14
14
  |---|---|
15
15
  | `agent/api/contacts.ts` | Mock import path. Find a real API that accepts a list of contacts and returns created and skipped rows |
16
16
  | `agent/contacts.ts` | The columns the file actually has |
17
- | `agent/views/uploader.ts` | The card. `brand.ts` matches the agent's white panel; put the agent's primary colour in `accent` |
17
+ | `agent/views/uploader.ts` | The card. `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 |
18
18
 
19
19
  ## What that build learned the hard way
20
20
 
21
21
  - **Preview before the write.** Choosing a file is not a request to create records. The write runs when they choose Import.
22
22
  - **Invalid rows stay out of the request.** They are reported as skipped, with the reason, and the valid rows are what get posted.
23
- - **Every path calls `callback`.** Cancel and an empty file included. Leaving the card up without calling it holds the conversation.
23
+ - **Every path calls `callback`.** Cancel and an empty file included. A path that never calls it leaves the agent waiting until the user presses stop.
24
24
  - **A failed import is not "Created 0".** Draw the failure and return its `reason`.
25
25
  - **"View Contacts" only works where the app listens.** The build's single-page app routed on a message the card posts; other apps do not. ⚠️ Keep the button only if you wire that message, or open the list with a navigation route.
@@ -1,6 +1,8 @@
1
- // The agent waits while the user picks a file, reviews the rows, and imports.
2
- // Calling callback is mandatory on every finished path. The write happens only
3
- // after Import. Done is what finishes the card.
1
+ // An input card: the user picks a file, reviews the rows, and imports.
2
+ // render uses callback, so the agent waits until it is called - exactly once
3
+ // on every path: Cancel, an empty file, a failed import, or as soon as the
4
+ // import returns (that one with `false`, so Done and View Contacts still
5
+ // work). The write happens only after Import.
4
6
 
5
7
  import type { ImportOutcome } from "../contacts";
6
8
  import { renderFailure, type ApiFailure, type FailureReason } from "../utils";
@@ -27,8 +29,6 @@ export const upload_contacts = {
27
29
  return { success: true, message: "A contact upload is on screen.", data: { cancelled: false } };
28
30
  },
29
31
 
30
- awaitUserInput: true,
31
-
32
32
  render: (
33
33
  result: UploadResult | undefined,
34
34
  host: HTMLElement,
@@ -63,8 +63,8 @@ export const upload_contacts = {
63
63
  true,
64
64
  );
65
65
  },
66
- onDone: (outcome) => {
67
- callback(done(outcome), true);
66
+ onImported: (outcome) => {
67
+ callback(done(outcome), false);
68
68
  },
69
69
  onFailed: (failure: ApiFailure) => {
70
70
  callback(
@@ -1,25 +1,35 @@
1
- // The card lives in the agent's panel, which is white by default (the agent's
2
- // theme: backgroundColor #FFFFFF, primaryColor for accents) - so the palette
3
- // matches the panel, not the page. Plain hex/rgba only: the panel saves every
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
- border: "#E5E7EB",
7
+ surface2: "#F5F6FA",
12
8
  text: "#374151",
13
- textStrong: "#111827",
14
9
  muted: "#6B7280",
15
- faint: "#9CA3AF",
10
+ border: "#E5E7EB",
16
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",
17
20
  accentDeep: "#2538D6",
18
- ok: "#047857",
21
+ accentSoft: "#EEF0FE",
22
+ headerStart: "#F0F1FE",
23
+ good: "#047857",
19
24
  warn: "#B45309",
25
+ warnInk: "#FEF3C7",
20
26
  bad: "#B91C1C",
21
- tableHead: "#F3F4F6",
27
+ primary: "#3248F2",
28
+ tint: "#F3F4F6",
22
29
  disabled: "#9CA3AF",
23
- font: 'Roboto, -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
24
- radius: "8px",
30
+ money: "#047857",
31
+ ok: "#047857",
32
+ deals: "#3248F2",
33
+ contacts: "#2538D6",
34
+ cases: "#B45309",
25
35
  } as const;
@@ -1,6 +1,7 @@
1
1
  // The import card from the production build. textContent only — never innerHTML
2
- // with file data. Styles go in `header`. The write runs only after Import.
3
- // Done is what finishes the card.
2
+ // with file data. Styles go in `header`. The write runs only after Import. The
3
+ // result goes back as soon as the import returns; Done and View Contacts do not
4
+ // call back.
4
5
 
5
6
  import { importContacts } from "../api/contacts";
6
7
  import { contactName, outcomeFrom, parseContactsCsv, toDraft, type ImportOutcome, type ParsedContact } from "../contacts";
@@ -67,12 +68,12 @@ const UPLOAD_PATH = "M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M
67
68
  const FILE_PATH = "M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z";
68
69
  const REVIEW_PATH = "M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4";
69
70
  const DONE_PATH = "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z";
70
- const UPLOAD_COPY = "Upload a CSV file with your contacts. The file should include columns for firstName, lastName, and email (required). Optional columns: phone, title, accountName.";
71
+ const UPLOAD_COPY = "Upload a CSV file of contacts. It needs first name, last name and email columns; phone, title and account (company) are optional.";
71
72
 
72
73
  export type UploadHandlers = {
73
74
  onCancel: () => void;
74
75
  onEmpty: () => void;
75
- onDone: (outcome: ImportOutcome) => void;
76
+ onImported: (outcome: ImportOutcome) => void;
76
77
  onFailed: (failure: ApiFailure) => void;
77
78
  };
78
79
 
@@ -229,7 +230,14 @@ export function mountContactUpload(host: HTMLElement, header: HTMLElement, handl
229
230
 
230
231
  const cancel = button("fs-up-btn fs-up-secondary", "Cancel");
231
232
  cancel.addEventListener("click", () => {
232
- report(() => mount(el("div", "fs-up")), () => handlers.onCancel());
233
+ report(
234
+ () => {
235
+ const closed = el("div", "fs-up");
236
+ closed.append(el("div", "fs-up-sub", "Upload cancelled. No contacts were imported."));
237
+ mount(closed);
238
+ },
239
+ () => handlers.onCancel(),
240
+ );
233
241
  });
234
242
 
235
243
  card.append(zone, actions(cancel));
@@ -291,7 +299,7 @@ export function mountContactUpload(host: HTMLElement, header: HTMLElement, handl
291
299
  parsed = [];
292
300
  showUpload();
293
301
  });
294
- const confirm = button("fs-up-btn fs-up-primary", `Import ${ready.length} Contacts`);
302
+ const confirm = button("fs-up-btn fs-up-primary", `Import ${ready.length} ${ready.length === 1 ? "Contact" : "Contacts"}`);
295
303
  confirm.disabled = ready.length === 0;
296
304
  confirm.addEventListener("click", async () => {
297
305
  if (settled || busy || ready.length === 0) return;
@@ -300,12 +308,16 @@ export function mountContactUpload(host: HTMLElement, header: HTMLElement, handl
300
308
  const res = await importContacts(ready.map(toDraft));
301
309
  if (!res.ok) {
302
310
  report(
303
- () => renderFailure(host, res, { notFound: "No import endpoint answered." }),
311
+ () => renderFailure(host, res, { notFound: "Contacts cannot be imported here." }),
304
312
  () => handlers.onFailed(res),
305
313
  );
306
314
  return;
307
315
  }
308
- showResults(outcomeFrom(parsed, res.data));
316
+ const outcome = outcomeFrom(parsed, res.data);
317
+ report(
318
+ () => showResults(outcome),
319
+ () => handlers.onImported(outcome),
320
+ );
309
321
  });
310
322
  card.append(actions(back, confirm));
311
323
  mount(card);
@@ -318,7 +330,7 @@ export function mountContactUpload(host: HTMLElement, header: HTMLElement, handl
318
330
  const head = el("div", "fs-up-head");
319
331
  head.append(row, el("div", "fs-up-sub", "Please wait while we import your contacts. This may take a moment for large files."));
320
332
  const wait = el("div", "fs-up-wait");
321
- wait.append(el("div", "fs-up-spin is-lg"), el("div", "fs-up-wait-text", `Processing ${count} contacts...`));
333
+ wait.append(el("div", "fs-up-spin is-lg"), el("div", "fs-up-wait-text", `Processing ${count} ${count === 1 ? "contact" : "contacts"}...`));
322
334
  card.append(head, wait);
323
335
  mount(card);
324
336
  };
@@ -377,16 +389,15 @@ export function mountContactUpload(host: HTMLElement, header: HTMLElement, handl
377
389
  card.append(section);
378
390
  }
379
391
 
380
- const finish = () => report(() => {}, () => handlers.onDone(outcome));
392
+ // The agent already has the result. These buttons only act on the card.
381
393
  const view = button("fs-up-btn fs-up-secondary", "View Contacts");
382
- view.addEventListener("click", () => {
383
- // callback first: a navigation that unloads the page must not lose it.
384
- finish();
385
- go("/__observe_me/contacts");
386
- });
394
+ view.addEventListener("click", () => go("/__observe_me/contacts"));
387
395
  const done = button("fs-up-btn fs-up-primary", "Done");
388
- done.addEventListener("click", finish);
389
- card.append(actions(view, done));
396
+ const buttons = actions(view, done);
397
+ done.addEventListener("click", () => {
398
+ buttons.textContent = "";
399
+ });
400
+ card.append(buttons);
390
401
  mount(card);
391
402
  };
392
403
 
@@ -30,8 +30,10 @@ import { identifyUser } from "../identify";
30
30
  identifyUser();
31
31
  ```
32
32
 
33
- Custom attributes (a plan name, a sign-up date) only land if they were created
34
- first in **Settings → Attribute Settings**; send them under their API Name.
33
+ Built-in user attributes need no setup: `id`, `email`, `name`, `role`,
34
+ `image`, `phone`, `timezone`, `signUpDate`. A custom one (a plan name, say)
35
+ lands only if it was created first in **Settings → Attribute Settings**; send
36
+ it under its API Name. <https://docs.foldspace.ai/user-guides/settings/attributes/>
35
37
 
36
38
  ## What those builds learned the hard way
37
39
 
@@ -49,7 +51,9 @@ first in **Settings → Attribute Settings**; send them under their API Name.
49
51
  - **A failed identify must not break the agent.** Every failure is silent to the
50
52
  user, and releases the flag so a later load can try again.
51
53
  - **Hide the agent until identify resolves, with a timeout** — and on a page
52
- with no signed-in user (the login page), do not show it at all.
54
+ with no signed-in user (the login page), do not show it at all. This recipe
55
+ does not do it; to add it, call `getAgent()?.hide()` /
56
+ `getAgent()?.show()` (<https://docs.foldspace.ai/reference/visibility-api/>).
53
57
  - **This is unsigned.** Anyone can call `foldspace.identify` from a console.
54
58
  Signing has to come from the customer's backend
55
59
  (<https://docs.foldspace.ai/security/overview/>); until then treat the
package/src/ask-core.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // Pure helpers behind `foldspace ask`: deciding when the assistant has finished
1
+ // Pure helpers behind `foldspace ask`: deciding when the agent has finished
2
2
  // replying, reading what attach logged about the handlers, and deciding whether
3
3
  // to ask again. No I/O here, so every judgement call is unit-tested.
4
4
 
@@ -126,7 +126,7 @@ export function actionFired(handler, hasCard) {
126
126
  }
127
127
 
128
128
  /**
129
- * Re-ask only for publish lag: the assistant answered from knowledge because
129
+ * Re-ask only for publish lag: the agent answered from knowledge because
130
130
  * the platform has not picked the new action up yet. A handler that ran and
131
131
  * FAILED is a bug to read, not something another ask will fix.
132
132
  */
@@ -143,7 +143,7 @@ export function numberFlag(value, fallback) {
143
143
  : fallback;
144
144
  }
145
145
 
146
- // ---- `foldspace run`: describe what a handler returned without printing it ----
146
+ // ---- Describe what a handler returned without printing it ----
147
147
 
148
148
  function typeOf(value) {
149
149
  if (value === null) return "null";
@@ -76,11 +76,16 @@ export function rejectionAdvice(code) {
76
76
  return "The page has no Foldspace code. Run: npx foldspace attach --bootstrap --daemon";
77
77
  }
78
78
  if (code === "REGISTRATION_MISMATCH") {
79
- return "The page carries a different Foldspace agent. Run: npx foldspace attach --replace --daemon";
79
+ return "The page's Foldspace agent is not the one this project is set up for (another agent or product, or one that could not be confirmed). Run: npx foldspace attach --replace --daemon";
80
80
  }
81
81
  return null;
82
82
  }
83
83
 
84
+ /** Where to read why registration did not match: attach prints it under the lifecycle line. */
85
+ export function mismatchNext(logPath) {
86
+ return `Read the lines under "[lifecycle] inspect_registration:registration_mismatch" in ${logPath}: they name what differs and the next step.`;
87
+ }
88
+
84
89
  export function readDaemonState(root) {
85
90
  const { stateFile } = daemonPaths(root);
86
91
  try {
@@ -162,7 +167,7 @@ function daemonReport(state, extra = {}) {
162
167
  }
163
168
  }
164
169
  const registration = parseRegistrationLifecycle(logText);
165
- return {
170
+ const report = {
166
171
  daemon: true,
167
172
  pid: state?.pid ?? null,
168
173
  logPath: state?.logPath ?? null,
@@ -171,6 +176,8 @@ function daemonReport(state, extra = {}) {
171
176
  : {}),
172
177
  ...extra,
173
178
  };
179
+ if (report.registration === "registration_mismatch") report.next = mismatchNext(report.logPath);
180
+ return report;
174
181
  }
175
182
 
176
183
  export async function stopAttachDaemon({
@@ -15,6 +15,19 @@ export function attachModeFromArgs(argv) {
15
15
  return ATTACH_MODES.SWAP;
16
16
  }
17
17
 
18
+ /**
19
+ * The flags to start attach again with, so a restart keeps what this one ran
20
+ * with: its mode, test mode (a restart without --test-mode turns it off) and
21
+ * --no-badge. Leading space; empty for a plain swap attach.
22
+ */
23
+ export function attachAgainFlags({ mode, testMode = false, noBadge = false } = {}) {
24
+ return (
25
+ (mode === ATTACH_MODES.REPLACE ? " --replace" : mode === ATTACH_MODES.BOOTSTRAP ? " --bootstrap" : "") +
26
+ (testMode === true ? " --test-mode" : "") +
27
+ (noBadge === true ? " --no-badge" : "")
28
+ );
29
+ }
30
+
18
31
  function present(value) {
19
32
  if (value == null) return null;
20
33
  const text = String(value);
@@ -172,6 +185,98 @@ export function actionRequestMatchesTarget(request, target) {
172
185
  );
173
186
  }
174
187
 
188
+ /**
189
+ * What the log says under `inspect_registration:registration_mismatch`, so
190
+ * the model reads the difference instead of hunting for it: whether the page's
191
+ * agent, product or panel is not this project's, which action names differ,
192
+ * the diagnostic error, and one next step. `details` is the lifecycle result's
193
+ * details; the log prints only `lines` and `next`, never the fields.
194
+ */
195
+ export function registrationMismatchReport(details = {}) {
196
+ const list = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === "string") : []);
197
+ const missing = list(details.missingActionNames);
198
+ const unexpected = list(details.unexpectedActionNames);
199
+ const agents = list(details.detectedAgentIds);
200
+ const products = list(details.detectedProductIds);
201
+ const expectedAgent = details.expectedAgentId;
202
+ const expectedProduct = details.expectedProductId;
203
+ const lines = [];
204
+ const noAgent = agents.length === 0;
205
+ const otherAgent = Boolean(expectedAgent) && !noAgent && !agents.includes(expectedAgent);
206
+ const moreAgents = Boolean(expectedAgent) && agents.length > 1 && agents.includes(expectedAgent);
207
+ const otherProduct = Boolean(expectedProduct) && products.length > 0 && !products.includes(expectedProduct);
208
+ const unknownProduct = Boolean(expectedProduct) && !noAgent && products.length === 0;
209
+ // Only --replace requires this project's panel to be the one visible.
210
+ const widget = details.mode === ATTACH_MODES.REPLACE && details.widget ? details.widget : null;
211
+ const visibleCount = widget ? Number(widget.visibleCount) || 0 : 0;
212
+
213
+ if (noAgent) lines.push(`Agent: no Foldspace agent started on the page; this project is set up for ${expectedAgent}.`);
214
+ if (otherAgent) lines.push(`Agent: this project is set up for ${expectedAgent}; the page has ${agents.join(", ")}.`);
215
+ if (moreAgents) lines.push(`Agent: the page has ${agents.join(", ")} - more than this project's ${expectedAgent}.`);
216
+ if (otherProduct) lines.push(`Product: this project is set up for ${expectedProduct}; the page has ${products.join(", ")}.`);
217
+ if (unknownProduct) {
218
+ lines.push(`Product: the page's agent does not say which product it is; this project is set up for ${expectedProduct}.`);
219
+ }
220
+ if (widget && visibleCount === 0) {
221
+ lines.push("Panel: no agent panel is visible on the page.");
222
+ } else if (widget && visibleCount > 1) {
223
+ lines.push(`Panel: ${visibleCount} agent panels are visible; --replace needs this project's to be the only one.`);
224
+ } else if (widget && widget.ownerMatches !== true) {
225
+ lines.push("Panel: the visible panel is not this project's agent panel.");
226
+ }
227
+ // The action checks run only once the agent, product and panel match.
228
+ if (!lines.length) {
229
+ if (details.actionObserverInstalled === false || details.actionCaptureCount === 0) {
230
+ lines.push("Actions: the page's agent never took this build's actions.");
231
+ }
232
+ if (details.actionNameLimitExceeded === true) {
233
+ lines.push("Actions: this build has more than 200 action names; attach checks at most 200, so it cannot confirm them.");
234
+ }
235
+ }
236
+ if (missing.length) {
237
+ lines.push(`Missing - in this build, not registered on the page's agent: ${missing.join(", ")}`);
238
+ }
239
+ if (unexpected.length) {
240
+ lines.push(`Unexpected - registered on the page's agent, not in this build: ${unexpected.join(", ")}`);
241
+ }
242
+ if (details.diagnosticError) lines.push(`Diagnostic error: ${details.diagnosticError}`);
243
+
244
+ const sameMode = attachAgainFlags(details);
245
+ // Switching to --replace keeps the rest of what this attach ran with.
246
+ const asReplace = attachAgainFlags({ ...details, mode: ATTACH_MODES.REPLACE });
247
+ const once =
248
+ `run \`npx foldspace attach --stop\`, then \`npx foldspace attach${sameMode} --daemon\` once; if the same lines come back, ` +
249
+ "tell the human in one sentence that the test window could not load the latest changes to their agent.";
250
+ let next;
251
+ if (noAgent && details.mode !== ATTACH_MODES.SWAP) {
252
+ // bootstrap and replace start the agent from foldspace.dev.json.
253
+ next =
254
+ "check `productId`, `agentApiName` (the Agent Key) and `region` in foldspace.dev.json against the agent in " +
255
+ "Agent Studio, and correct any that differ with the human's yes. Then run `npx foldspace attach --stop`, " +
256
+ `then \`npx foldspace attach${sameMode} --daemon\` once; if the same lines come back, tell the human in one ` +
257
+ "sentence that their agent could not be started in the test window.";
258
+ } else if ((otherAgent || moreAgents || otherProduct || unknownProduct) && details.mode !== ATTACH_MODES.REPLACE) {
259
+ next = `run \`npx foldspace attach --stop\`, then \`npx foldspace attach${asReplace} --daemon\`: the page's Foldspace agent is not this project's.`;
260
+ } else if (missing.length || unexpected.length) {
261
+ next =
262
+ "compare these names with agent/actions/index.ts. If the code changed since the last build: `npm run build`, " +
263
+ `\`npx foldspace attach --stop\`, then \`npx foldspace attach${sameMode} --daemon\`. If it did not, ${once}`;
264
+ } else {
265
+ if (!lines.length) {
266
+ lines.push(
267
+ `Details: ${JSON.stringify({
268
+ mode: details.mode ?? null,
269
+ actionObserverInstalled: details.actionObserverInstalled ?? null,
270
+ actionCaptureCount: details.actionCaptureCount ?? null,
271
+ agentFound: details.agentFound ?? null,
272
+ })}`,
273
+ );
274
+ }
275
+ next = once;
276
+ }
277
+ return { lines, next };
278
+ }
279
+
175
280
  export function registrationVerified(observation, registration) {
176
281
  return Boolean(
177
282
  observation?.installed === true &&