@foldspace_npm/harness 0.1.22 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +113 -36
- package/bin/ask.mjs +345 -0
- package/bin/attach.mjs +29 -1
- package/bin/observe.mjs +42 -8
- package/package.json +1 -1
- package/recipes/INDEX.md +1 -0
- package/recipes/bottom-bar/recipe.json +5 -1
- package/recipes/several-hosts/README.md +35 -0
- package/recipes/several-hosts/agent/hosts.ts +146 -0
- package/recipes/several-hosts/fixtures/nav-record.json +5 -0
- package/recipes/several-hosts/recipe.json +10 -0
- package/recipes/who-is-the-user/README.md +4 -3
- package/src/ask-core.mjs +195 -0
- package/src/attach-daemon.mjs +27 -0
- package/src/cdp-client.mjs +6 -1
- package/src/cli-registry.mjs +30 -1
- package/src/init.mjs +8 -1
- package/src/observe-core.mjs +96 -1
- package/src/runtime/render.ts +8 -1
- package/templates/agent-starter/claude-settings.json +21 -0
- package/templates/agent-starter/docs/app-profile.md +10 -0
package/bin/observe.mjs
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* foldspace observe pages
|
|
8
8
|
* foldspace observe menu
|
|
9
|
-
* foldspace observe screen [--click "<label>"] [--goto /path] [--match "deal stage"] [--wait 4000]
|
|
9
|
+
* foldspace observe screen [--click "<label>"] [--goto /path | https://<one of this project's hosts>/path] [--match "deal stage"] [--wait 4000]
|
|
10
10
|
* foldspace observe read <path-or-url>
|
|
11
11
|
* foldspace observe auth [--wait 5000]
|
|
12
12
|
* foldspace observe styles
|
|
@@ -41,7 +41,9 @@ import {
|
|
|
41
41
|
rankCandidates,
|
|
42
42
|
readHint,
|
|
43
43
|
refuseWriteFlags,
|
|
44
|
-
|
|
44
|
+
gotoDestination,
|
|
45
|
+
pageCoverage,
|
|
46
|
+
uncoveredSiblings,
|
|
45
47
|
summarizeJson,
|
|
46
48
|
toHex,
|
|
47
49
|
} from "../src/observe-core.mjs";
|
|
@@ -294,10 +296,33 @@ async function runPages() {
|
|
|
294
296
|
`The inject Chrome is not answering on :${state.port}. It was closed; run \`npm run inject\` again.`,
|
|
295
297
|
);
|
|
296
298
|
}
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
const real = targets.filter(
|
|
300
|
+
(target) => target.type === "page" && !String(target.url).startsWith("devtools://"),
|
|
301
|
+
);
|
|
302
|
+
const pages = real.map((target) => {
|
|
303
|
+
const coverage = /^https?:/.test(String(target.url)) ? pageCoverage(target.url, state.hosts) : { covered: true };
|
|
304
|
+
return {
|
|
305
|
+
url: printableUrl(target.url),
|
|
306
|
+
title: target.title,
|
|
307
|
+
...(coverage.covered ? {} : { covered: false }),
|
|
308
|
+
};
|
|
309
|
+
});
|
|
310
|
+
// A tab on a host of the same product that this project does not name: every
|
|
311
|
+
// command would refuse it, and the agent would not appear there.
|
|
312
|
+
const off = real
|
|
313
|
+
.map((target) => pageCoverage(target.url, state.hosts))
|
|
314
|
+
.find((coverage) => !coverage.covered && coverage.pattern);
|
|
315
|
+
return {
|
|
316
|
+
ok: true,
|
|
317
|
+
pages,
|
|
318
|
+
...(off
|
|
319
|
+
? {
|
|
320
|
+
hostsHint:
|
|
321
|
+
`The window is on ${off.host}, which this project does not cover (hosts: ${state.hosts.join(", ")}). ` +
|
|
322
|
+
`If it is part of the product, add "${off.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`,
|
|
323
|
+
}
|
|
324
|
+
: {}),
|
|
325
|
+
};
|
|
301
326
|
}
|
|
302
327
|
|
|
303
328
|
async function runMenu(page) {
|
|
@@ -311,11 +336,20 @@ async function runMenu(page) {
|
|
|
311
336
|
items.push(publicItem(entry));
|
|
312
337
|
}
|
|
313
338
|
const url = await page.evaluate("return location.href;");
|
|
339
|
+
// Links that leave this host but stay in the product (analyze., admin. ...).
|
|
340
|
+
// Looked for across the whole page: product tiles are often not navigation.
|
|
341
|
+
const leaving = await page
|
|
342
|
+
.evaluate(
|
|
343
|
+
`return [...document.querySelectorAll("a[href]")].map((a) => a.href).filter((href) => /^https?:/.test(href)).slice(0, 400);`,
|
|
344
|
+
)
|
|
345
|
+
.catch(() => []);
|
|
346
|
+
const siblings = uncoveredSiblings(Array.isArray(leaving) ? leaving : [], url, page.info.hosts);
|
|
314
347
|
return {
|
|
315
348
|
ok: true,
|
|
316
349
|
url: printableUrl(url),
|
|
317
350
|
count: items.length,
|
|
318
351
|
items,
|
|
352
|
+
...(siblings ? { otherHosts: siblings.hosts, hostsHint: siblings.hint } : {}),
|
|
319
353
|
...(items.length ? {} : { note: "No visible navigation found. The app may still be loading, or showing a login page." }),
|
|
320
354
|
};
|
|
321
355
|
}
|
|
@@ -324,7 +358,7 @@ async function runScreen(page, flags) {
|
|
|
324
358
|
const waitMs = numberFlag(flags.wait, 4000, "wait");
|
|
325
359
|
const words = parseMatchWords(flags.match);
|
|
326
360
|
const before = await page.evaluate("return location.href;");
|
|
327
|
-
const destination = flags.goto !== undefined ?
|
|
361
|
+
const destination = flags.goto !== undefined ? gotoDestination(flags.goto, before, page.info.hosts) : null;
|
|
328
362
|
|
|
329
363
|
await page.send("Network.enable");
|
|
330
364
|
const network = recordNetwork(page);
|
|
@@ -332,7 +366,7 @@ async function runScreen(page, flags) {
|
|
|
332
366
|
|
|
333
367
|
if (destination) {
|
|
334
368
|
await withoutStealingFocus(() => page.send("Page.navigate", { url: destination.url }));
|
|
335
|
-
reached = { how: "goto", path: destination.path };
|
|
369
|
+
reached = { how: "goto", path: destination.path, ...(destination.host ? { host: destination.host } : {}) };
|
|
336
370
|
if (flags.click !== undefined) await network.settle(Math.min(waitMs, 3000));
|
|
337
371
|
}
|
|
338
372
|
if (flags.click !== undefined) {
|
package/package.json
CHANGED
package/recipes/INDEX.md
CHANGED
|
@@ -13,6 +13,7 @@ Read this whole table, then open the recipe closest to the outcome.
|
|
|
13
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
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
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
|
+
| [`several-hosts`](several-hosts/) | any | The product lives on several hosts (`www.`, `analyze.`, `admin.` ...): an action asked from the wrong host says which page it needs, and the conversation carries on after the agent moves the user there. **The resume is a registered workaround** (PLG-5962) | Page set-up, plus a guard each action calls | 1 production build |
|
|
16
17
|
| [`records-list`](records-list/) | **L2** | The user's records on one card: a row each with name, two facts and a number, matched to the agent's white panel. The model does not see the card | A view-only card. **No** `awaitUserInput` | 1 production build |
|
|
17
18
|
| [`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 |
|
|
18
19
|
|
|
@@ -5,5 +5,9 @@
|
|
|
5
5
|
"kind": "page-setup",
|
|
6
6
|
"entry": "agent/bottomBar.ts",
|
|
7
7
|
"outcome": "The agent's resting entry point is a bar at the bottom of the page whose starters change with the page the user is on",
|
|
8
|
-
"provenBy": 2
|
|
8
|
+
"provenBy": 2,
|
|
9
|
+
"setup": {
|
|
10
|
+
"import": "installBottomBar",
|
|
11
|
+
"from": "../bottomBar"
|
|
12
|
+
}
|
|
9
13
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# One product, several hosts
|
|
2
|
+
|
|
3
|
+
**The product lives on more than one host** — `www.`, `analyze.`, `communicate.`, `admin.` under one domain — **and the agent should work on all of them.**
|
|
4
|
+
|
|
5
|
+
**Proven by 1 production build**, which met each part of this the hard way, one at a time.
|
|
6
|
+
|
|
7
|
+
## Find out at the start
|
|
8
|
+
|
|
9
|
+
`npx foldspace observe menu` names the hosts a page links to that belong to the same product and that this project does not cover (`otherHosts`, `hostsHint`). `init` covers only the host you gave it. Tell the human which hosts you found and ask for a yes before adding the pattern (`"*.example.com"`) to `hosts` in `foldspace.dev.json`; then `npm run inject` and attach again. After that you open screens on the other hosts yourself: `npx foldspace observe screen --goto https://analyze.example.com/reports`.
|
|
10
|
+
|
|
11
|
+
## What this recipe gives
|
|
12
|
+
|
|
13
|
+
| Piece | What it does |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `wrongHost(tool)` | First line of an action. On the right host: `null`. On another: `{ status: "wrong_page", required_page, current_page }`, and no request is made |
|
|
16
|
+
| `installResumeAcrossHosts()` | **A registered workaround.** After the agent moves the user to the right host, the conversation carries on there by itself |
|
|
17
|
+
|
|
18
|
+
In Agent Studio, each action's instructions say: *on `wrong_page`, navigate to that tool's route, then call the action again.* Give each tool a navigation route with a full address.
|
|
19
|
+
|
|
20
|
+
## Adapt it
|
|
21
|
+
|
|
22
|
+
| File | Change |
|
|
23
|
+
|---|---|
|
|
24
|
+
| `agent/hosts.ts` → `TOOLS` | The product's tools and how to recognise each one's host (and path, if two tools share a host) |
|
|
25
|
+
| `agent/actions/index.ts` | `installResumeAcrossHosts()` once, next to the registry |
|
|
26
|
+
| each action | `const wrong = wrongHost("reports"); if (wrong) return { success: true, ...wrong };` before any request |
|
|
27
|
+
| `docs/app-profile.md` → Workarounds | One row for the resume workaround: what, why, `agent/hosts.ts`, verified or assumed, "goes when PLG-5962 ships" |
|
|
28
|
+
|
|
29
|
+
## What that build learned the hard way
|
|
30
|
+
|
|
31
|
+
- **An action can only read the host it runs on.** Asked from Analyze, a Communicate action failed its first read in 150 ms. That is the browser, not the app.
|
|
32
|
+
- **Who the user is, and their conversations, do carry across hosts** — Foldspace keeps the identity on the shared domain. What does not carry is the agent *continuing its turn* after it navigated: the note it leaves itself is kept per host, and the page that is leaving reports the navigation as finished.
|
|
33
|
+
- **Reopening the conversation is not enough.** The first attempt restored the note on the new host; the right chat opened and nothing happened. It took a message to start the next turn — it shows as a user bubble, "Continue on this page."
|
|
34
|
+
- **Ask the browser for the shared domain.** Counting dots gets `.co.uk` wrong.
|
|
35
|
+
- **Remove it when the SDK carries on by itself.** This is Foldspace's to fix, not the customer's code to keep.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// One product, several hosts (www. / analyze. / communicate. / admin. ...).
|
|
2
|
+
//
|
|
3
|
+
// Two facts decide everything here:
|
|
4
|
+
// 1. An action runs inside the page, and a page may only read its own host.
|
|
5
|
+
// Asked from the wrong host, the request cannot succeed - so say which
|
|
6
|
+
// page is needed instead of failing (`wrongHost`).
|
|
7
|
+
// 2. WORKAROUND - registered in docs/app-profile.md, goes when PLG-5962 ships.
|
|
8
|
+
// When the agent moves the user to another host, the SDK does not carry
|
|
9
|
+
// on with the turn: its resume note is kept per host, and the page that
|
|
10
|
+
// is leaving reports the navigation as done. The user and the
|
|
11
|
+
// conversation do carry over; only the "carry on" is lost. So the
|
|
12
|
+
// conversation id is handed across in a 60-second cookie on the domain
|
|
13
|
+
// the hosts share, and the new page reopens it and starts the next turn
|
|
14
|
+
// (`installResumeAcrossHosts`). Established on a live build, 2026-09-25.
|
|
15
|
+
|
|
16
|
+
import { getAgent } from "./utils";
|
|
17
|
+
|
|
18
|
+
/** ADAPT: the tools of this product and the host each one lives on. */
|
|
19
|
+
export const TOOLS: Record<string, { label: string; onThisHost: (hostname: string, path: string) => boolean }> = {
|
|
20
|
+
reports: { label: "Reports", onThisHost: (hostname) => hostname.startsWith("analyze.") },
|
|
21
|
+
messages: { label: "Messages", onThisHost: (hostname) => hostname.startsWith("communicate.") },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type WrongPage = { status: "wrong_page"; required_page: string; current_page: string };
|
|
25
|
+
|
|
26
|
+
let resumeExpected = false;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* First line of every action that reads or writes one tool: null on the right
|
|
30
|
+
* host, otherwise the state to hand back - no request is made. The action's
|
|
31
|
+
* Studio instructions say: on `wrong_page`, navigate to that tool's route,
|
|
32
|
+
* then call the action again.
|
|
33
|
+
*/
|
|
34
|
+
export function wrongHost(tool: keyof typeof TOOLS): WrongPage | null {
|
|
35
|
+
const wanted = TOOLS[tool];
|
|
36
|
+
if (!wanted || wanted.onThisHost(window.location.hostname, window.location.pathname)) return null;
|
|
37
|
+
resumeExpected = true;
|
|
38
|
+
return { status: "wrong_page", required_page: wanted.label, current_page: window.location.hostname };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------- the workaround
|
|
42
|
+
|
|
43
|
+
const SDK_NAV_KEY = "_eucera_nav"; // the SDK's own resume note (per host)
|
|
44
|
+
const COOKIE = "fs_resume";
|
|
45
|
+
const TTL_SECONDS = 60;
|
|
46
|
+
export const RESUME_MESSAGE = "Continue on this page.";
|
|
47
|
+
|
|
48
|
+
type Carried = { conversationId: string; origin: string; expiry: number };
|
|
49
|
+
|
|
50
|
+
function readCookie(name: string): string | null {
|
|
51
|
+
const hit = document.cookie.split("; ").find((entry) => entry.startsWith(`${name}=`));
|
|
52
|
+
return hit ? hit.slice(name.length + 1) : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The widest domain the browser lets this page set a cookie on - the domain
|
|
57
|
+
* the product's hosts share. Asked of the browser, the way the SDK does it:
|
|
58
|
+
* guessing from the number of dots gets `.co.uk` wrong.
|
|
59
|
+
*/
|
|
60
|
+
export function sharedCookieDomain(): string | null {
|
|
61
|
+
const labels = window.location.hostname.split(".");
|
|
62
|
+
if (labels.length < 2) return null;
|
|
63
|
+
const probe = "fs_domain_probe";
|
|
64
|
+
for (let from = labels.length - 1; from >= 0; from -= 1) {
|
|
65
|
+
const domain = labels.slice(from).join(".");
|
|
66
|
+
document.cookie = `${probe}=1; domain=${domain}; path=/; max-age=30`;
|
|
67
|
+
if (readCookie(probe) === "1") {
|
|
68
|
+
document.cookie = `${probe}=; domain=${domain}; path=/; max-age=0`;
|
|
69
|
+
return domain;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The page that is leaving: hand the conversation id to the next host. */
|
|
76
|
+
function carryOut(): void {
|
|
77
|
+
if (!resumeExpected) return;
|
|
78
|
+
try {
|
|
79
|
+
const raw = window.localStorage.getItem(SDK_NAV_KEY);
|
|
80
|
+
if (!raw) return;
|
|
81
|
+
const record = JSON.parse(raw) as { payload?: { conversationId?: string }; normalizedUrl?: string };
|
|
82
|
+
const conversationId = record.payload?.conversationId;
|
|
83
|
+
if (!conversationId || !record.normalizedUrl || !/^https?:\/\//.test(record.normalizedUrl)) return;
|
|
84
|
+
const target = new URL(record.normalizedUrl);
|
|
85
|
+
const domain = sharedCookieDomain();
|
|
86
|
+
// Only to another host of the same product - never off the shared domain.
|
|
87
|
+
if (!domain || target.origin === window.location.origin) return;
|
|
88
|
+
if (target.hostname !== domain && !target.hostname.endsWith(`.${domain}`)) return;
|
|
89
|
+
const carried: Carried = { conversationId, origin: target.origin, expiry: Date.now() + TTL_SECONDS * 1000 };
|
|
90
|
+
const secure = window.location.protocol === "https:" ? "; secure" : "";
|
|
91
|
+
document.cookie = `${COOKIE}=${encodeURIComponent(JSON.stringify(carried))}; domain=${domain}; path=/; max-age=${TTL_SECONDS}; samesite=lax${secure}`;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.warn("[several-hosts] could not hand the conversation over", error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function waitForAgent(timeoutMs: number): Promise<any | null> {
|
|
98
|
+
const started = Date.now();
|
|
99
|
+
return new Promise((resolve) => {
|
|
100
|
+
const tick = () => {
|
|
101
|
+
const agent = getAgent();
|
|
102
|
+
if (agent && typeof agent.message === "function") return resolve(agent);
|
|
103
|
+
if (Date.now() - started > timeoutMs) return resolve(null);
|
|
104
|
+
setTimeout(tick, 250);
|
|
105
|
+
};
|
|
106
|
+
tick();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The page that arrives: reopen the conversation and start its next turn. */
|
|
111
|
+
async function bringIn(waitMs: number, settleMs: number): Promise<"resumed" | "nothing" | "no_agent"> {
|
|
112
|
+
try {
|
|
113
|
+
const value = readCookie(COOKIE);
|
|
114
|
+
if (!value) return "nothing";
|
|
115
|
+
const domain = sharedCookieDomain();
|
|
116
|
+
// Used once: whatever happens next, the note is gone.
|
|
117
|
+
if (domain) document.cookie = `${COOKIE}=; domain=${domain}; path=/; max-age=0`;
|
|
118
|
+
const carried = JSON.parse(decodeURIComponent(value)) as Carried;
|
|
119
|
+
if (carried.origin !== window.location.origin || carried.expiry < Date.now()) return "nothing";
|
|
120
|
+
const agent = await waitForAgent(waitMs);
|
|
121
|
+
if (!agent) {
|
|
122
|
+
console.warn("[several-hosts] the agent did not appear; the conversation was not resumed");
|
|
123
|
+
return "no_agent";
|
|
124
|
+
}
|
|
125
|
+
// The chat frame has to load before it can switch conversation and send.
|
|
126
|
+
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
|
127
|
+
agent.selectConversation(carried.conversationId);
|
|
128
|
+
agent.message(RESUME_MESSAGE);
|
|
129
|
+
return "resumed";
|
|
130
|
+
} catch (error) {
|
|
131
|
+
console.warn("[several-hosts] could not resume the conversation", error);
|
|
132
|
+
return "nothing";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let installed: Promise<"resumed" | "nothing" | "no_agent"> | null = null;
|
|
137
|
+
|
|
138
|
+
/** Call once from agent/actions/index.ts, next to the registry. Safe to call twice. */
|
|
139
|
+
export function installResumeAcrossHosts({ waitMs = 20_000, settleMs = 2_500 } = {}): Promise<
|
|
140
|
+
"resumed" | "nothing" | "no_agent"
|
|
141
|
+
> {
|
|
142
|
+
if (installed) return installed;
|
|
143
|
+
installed = bringIn(waitMs, settleMs);
|
|
144
|
+
window.addEventListener("pagehide", carryOut);
|
|
145
|
+
return installed;
|
|
146
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "One product, several hosts",
|
|
3
|
+
"level": "any",
|
|
4
|
+
"family": "several-hosts",
|
|
5
|
+
"kind": "page-setup",
|
|
6
|
+
"entry": "agent/hosts.ts",
|
|
7
|
+
"setup": { "import": "installResumeAcrossHosts", "from": "../hosts" },
|
|
8
|
+
"outcome": "An action asked from the wrong host says so instead of failing, and the conversation carries on after the agent moves the user to the right host",
|
|
9
|
+
"provenBy": 1
|
|
10
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# Who is the user — L0
|
|
2
2
|
|
|
3
|
-
L0 is
|
|
4
|
-
|
|
5
|
-
analytics
|
|
3
|
+
L0 is the agent appearing. This recipe comes right after the human has tried
|
|
4
|
+
the first experience: Foldspace learns who it is talking to, so conversations,
|
|
5
|
+
analytics rows and segments stop being anonymous, and the agent has real-time
|
|
6
|
+
context about the user.
|
|
6
7
|
|
|
7
8
|
**Proven by 4 production builds**, which found the identity in three different
|
|
8
9
|
places — so look before you assume:
|
package/src/ask-core.mjs
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Pure helpers behind `foldspace ask`: deciding when the assistant has finished
|
|
2
|
+
// replying, reading what attach logged about the handlers, and deciding whether
|
|
3
|
+
// to ask again. No I/O here, so every judgement call is unit-tested.
|
|
4
|
+
|
|
5
|
+
/** Collapse whitespace the way the panel text is collapsed before comparing. */
|
|
6
|
+
export function squash(text) {
|
|
7
|
+
return String(text ?? "")
|
|
8
|
+
.replace(/\s+/g, " ")
|
|
9
|
+
.trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// The reply is "whatever follows the question in the panel". A short prefix
|
|
13
|
+
// survives truncation and the panel re-wrapping a long question bubble.
|
|
14
|
+
export function anchorFor(question) {
|
|
15
|
+
return squash(question).slice(0, 24);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Text after the LAST occurrence of the question: earlier turns are ignored. */
|
|
19
|
+
export function replyAfter(panelText, question) {
|
|
20
|
+
const panel = squash(panelText);
|
|
21
|
+
const anchor = anchorFor(question);
|
|
22
|
+
if (!anchor) return "";
|
|
23
|
+
const at = panel.lastIndexOf(anchor);
|
|
24
|
+
if (at < 0) return "";
|
|
25
|
+
const rest = panel.slice(at);
|
|
26
|
+
// Drop the whole echoed question when it is there, else just the anchor.
|
|
27
|
+
const full = squash(question);
|
|
28
|
+
const tail = rest.startsWith(full) ? rest.slice(full.length) : rest.slice(anchor.length);
|
|
29
|
+
return stripPanelChrome(tail);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The panel's footer sits after the last message in document order, so it
|
|
33
|
+
// would otherwise be read as the end of every reply.
|
|
34
|
+
export function stripPanelChrome(text) {
|
|
35
|
+
return squash(String(text ?? "").replace(/[\u200b-\u200d\ufeff]/g, ""))
|
|
36
|
+
.replace(/\s*Powered by Foldspace(?:\.ai)?\s*$/i, "")
|
|
37
|
+
.trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PENDING_PATTERN =
|
|
41
|
+
/thinking|working on it|one moment|generating|ready to go|\.\.\.\s*$|…\s*$/i;
|
|
42
|
+
|
|
43
|
+
/** A reply whose tail is a progress phrase is still being written. */
|
|
44
|
+
export function looksPending(reply) {
|
|
45
|
+
return PENDING_PATTERN.test(squash(reply).slice(-60));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Feed one observation per poll. Settled means a real reply that stopped
|
|
50
|
+
* changing: the same text and the same cards on `stableNeeded` consecutive
|
|
51
|
+
* polls after the first sighting, and not a progress phrase. A streaming reply
|
|
52
|
+
* and a card that paints late both reset the count.
|
|
53
|
+
*/
|
|
54
|
+
export function createSettleTracker({ stableNeeded = 2, minChars = 2 } = {}) {
|
|
55
|
+
let last = null;
|
|
56
|
+
let stable = 0;
|
|
57
|
+
return {
|
|
58
|
+
observe({ reply = "", cards = [] } = {}) {
|
|
59
|
+
const snapshot = `${reply}|${cards.join("|")}`;
|
|
60
|
+
const answered = (reply.length >= minChars || cards.some(Boolean)) && !looksPending(reply);
|
|
61
|
+
if (answered && snapshot === last) stable += 1;
|
|
62
|
+
else stable = 0;
|
|
63
|
+
last = snapshot;
|
|
64
|
+
return { settled: stable >= stableNeeded, stable };
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const ACTION_LINE = /\[actions\]\s+([\w-]+):(execute|render)(?:\s+(\S+))?\s+(started|succeeded|failed)\b/;
|
|
70
|
+
|
|
71
|
+
/** One `[actions] local-handler:execute my_action succeeded 12ms` line. */
|
|
72
|
+
export function parseActionLine(line) {
|
|
73
|
+
const match = ACTION_LINE.exec(String(line ?? ""));
|
|
74
|
+
if (!match) return null;
|
|
75
|
+
return {
|
|
76
|
+
source: match[1],
|
|
77
|
+
phase: match[2],
|
|
78
|
+
actionName: match[3] || null,
|
|
79
|
+
status: match[4],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function phaseOutcome(events, phase) {
|
|
84
|
+
const mine = events.filter((event) => event.phase === phase);
|
|
85
|
+
// A failure anywhere in the turn is the headline, even if a later call passed.
|
|
86
|
+
if (mine.some((event) => event.status === "failed")) return "failed";
|
|
87
|
+
if (mine.some((event) => event.status === "succeeded")) return "succeeded";
|
|
88
|
+
// "started" with no end yet: the handler fired but has not reported.
|
|
89
|
+
return mine.length ? "started" : "absent";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Summarise the attach log text written since the ask began.
|
|
94
|
+
* `logText` of null/"" (no daemon log) reads as all "absent".
|
|
95
|
+
*/
|
|
96
|
+
export function summarizeHandlerLog(logText) {
|
|
97
|
+
// The daemon log indents these and may prefix them; keep from the tag onward.
|
|
98
|
+
const lines = String(logText ?? "")
|
|
99
|
+
.split("\n")
|
|
100
|
+
.filter((line) => parseActionLine(line))
|
|
101
|
+
.map((line) => line.slice(line.indexOf("[actions]")).trim());
|
|
102
|
+
const events = lines.map(parseActionLine);
|
|
103
|
+
const actions = [...new Set(events.map((event) => event.actionName).filter(Boolean))];
|
|
104
|
+
return {
|
|
105
|
+
execute: phaseOutcome(events, "execute"),
|
|
106
|
+
render: phaseOutcome(events, "render"),
|
|
107
|
+
actions,
|
|
108
|
+
lines: lines.slice(-20),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function actionFired(handler, hasCard) {
|
|
113
|
+
return Boolean(hasCard) || handler?.execute !== "absent" || handler?.render !== "absent";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Re-ask only for publish lag: the assistant answered from knowledge because
|
|
118
|
+
* the platform has not picked the new action up yet. A handler that ran and
|
|
119
|
+
* FAILED is a bug to read, not something another ask will fix.
|
|
120
|
+
*/
|
|
121
|
+
export function shouldRetry({ handler, hasCard, attempts, retries }) {
|
|
122
|
+
if (!(retries > 0) || attempts > retries) return false;
|
|
123
|
+
return !actionFired(handler, hasCard);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Positive number from a flag, else the default; never NaN into a sleep. */
|
|
127
|
+
export function numberFlag(value, fallback) {
|
|
128
|
+
const parsed = Number(value);
|
|
129
|
+
return Number.isFinite(parsed) && parsed >= 0 && value !== true && value !== undefined
|
|
130
|
+
? parsed
|
|
131
|
+
: fallback;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- `foldspace run`: describe what a handler returned without printing it ----
|
|
135
|
+
|
|
136
|
+
function typeOf(value) {
|
|
137
|
+
if (value === null) return "null";
|
|
138
|
+
if (Array.isArray(value)) return "array";
|
|
139
|
+
return typeof value;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Breadth-first so `items` wins over `meta.pages[0].items`; the first array of
|
|
143
|
+
// records is what a card will draw.
|
|
144
|
+
function findRows(value, maxDepth = 3) {
|
|
145
|
+
const queue = [{ node: value, path: "$", depth: 0 }];
|
|
146
|
+
while (queue.length) {
|
|
147
|
+
const { node, path, depth } = queue.shift();
|
|
148
|
+
if (Array.isArray(node)) return { path, count: node.length, first: node[0] };
|
|
149
|
+
if (!node || typeof node !== "object" || depth >= maxDepth) continue;
|
|
150
|
+
for (const [key, child] of Object.entries(node)) {
|
|
151
|
+
queue.push({ node: child, path: path === "$" ? key : `${path}.${key}`, depth: depth + 1 });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const short = (value) => (typeof value === "string" ? value.slice(0, 200) : null);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The SHAPE of a handler's return value: enough to tell "it answered with 12
|
|
161
|
+
* rows of {id,name}" from "it returned a clean failure", and no customer data.
|
|
162
|
+
*/
|
|
163
|
+
export function describeReturn(value) {
|
|
164
|
+
const type = typeOf(value);
|
|
165
|
+
const isObject = type === "object";
|
|
166
|
+
const rows = findRows(value);
|
|
167
|
+
const firstRow = rows?.first;
|
|
168
|
+
return {
|
|
169
|
+
type,
|
|
170
|
+
keys: isObject ? Object.keys(value).slice(0, 50) : [],
|
|
171
|
+
okFlag: isObject && typeof value.ok === "boolean" ? value.ok : null,
|
|
172
|
+
reason: isObject ? short(value.reason) : null,
|
|
173
|
+
error: isObject ? short(value.error) : null,
|
|
174
|
+
rows: rows ? { path: rows.path, count: rows.count } : null,
|
|
175
|
+
fieldNames:
|
|
176
|
+
firstRow && typeof firstRow === "object" && !Array.isArray(firstRow)
|
|
177
|
+
? Object.keys(firstRow).slice(0, 50)
|
|
178
|
+
: [],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* runAction only executes data handlers. For an action with `render` the SDK
|
|
184
|
+
* rejects with `Action "x" is a render action and cannot be executed silently`
|
|
185
|
+
* (it needs a conversation to draw into); recognise that so the caller is
|
|
186
|
+
* pointed at `foldspace ask` instead of a stack trace.
|
|
187
|
+
*/
|
|
188
|
+
export function refusedAsNotSilent(message) {
|
|
189
|
+
return typeof message === "string" && /render action|executed silently/i.test(message);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** `Action "x" not found`: the key is not in the bundle the page loaded. */
|
|
193
|
+
export function refusedAsUnknown(message) {
|
|
194
|
+
return typeof message === "string" && /\bnot found\b/i.test(message);
|
|
195
|
+
}
|
package/src/attach-daemon.mjs
CHANGED
|
@@ -8,6 +8,8 @@ export const ATTACH_DAEMON_LOG_FILE = "attach-daemon.log";
|
|
|
8
8
|
const CONTROL_FLAGS = ["--daemon", "--status", "--stop"];
|
|
9
9
|
const LIFECYCLE_PATTERN =
|
|
10
10
|
/\[lifecycle\] inspect_registration:(registration_ok|registration_mismatch)/g;
|
|
11
|
+
// The page was refused before registration: no SDK on it, or someone else's.
|
|
12
|
+
const REJECTED_PATTERN = /\[lifecycle\] prepare_page:rejected (SDK_NOT_FOUND|REGISTRATION_MISMATCH)/g;
|
|
11
13
|
|
|
12
14
|
export function projectRoot(env = process.env, cwd = process.cwd()) {
|
|
13
15
|
return env.FOLDSPACE_PROJECT_DIR || cwd;
|
|
@@ -59,6 +61,26 @@ export function parseRegistrationLifecycle(logText) {
|
|
|
59
61
|
return last;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/** The last page rejection in the log, or null. */
|
|
65
|
+
export function parseRejection(logText) {
|
|
66
|
+
let last = null;
|
|
67
|
+
for (const match of String(logText ?? "").matchAll(REJECTED_PATTERN)) {
|
|
68
|
+
last = { code: match[1] };
|
|
69
|
+
}
|
|
70
|
+
return last;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** What to run next, in one line, when attach refused the page. */
|
|
74
|
+
export function rejectionAdvice(code) {
|
|
75
|
+
if (code === "SDK_NOT_FOUND") {
|
|
76
|
+
return "The page has no Foldspace code. Run: npx foldspace attach --bootstrap --daemon";
|
|
77
|
+
}
|
|
78
|
+
if (code === "REGISTRATION_MISMATCH") {
|
|
79
|
+
return "The page carries a different Foldspace agent. Run: npx foldspace attach --replace --daemon";
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
62
84
|
export function readDaemonState(root) {
|
|
63
85
|
const { stateFile } = daemonPaths(root);
|
|
64
86
|
try {
|
|
@@ -107,6 +129,11 @@ export async function waitForRegistration({
|
|
|
107
129
|
}
|
|
108
130
|
const registration = parseRegistrationLifecycle(text);
|
|
109
131
|
if (registration) return registration;
|
|
132
|
+
const rejection = parseRejection(text);
|
|
133
|
+
if (rejection) {
|
|
134
|
+
const advice = rejectionAdvice(rejection.code);
|
|
135
|
+
throw new Error(advice ? `${advice} (${rejection.code}; log: ${logFile})` : `Attach refused the page: ${rejection.code}. Check ${logFile}`);
|
|
136
|
+
}
|
|
110
137
|
if (!isAlive(pid)) {
|
|
111
138
|
throw new Error(
|
|
112
139
|
`Attach daemon exited before inspect_registration. Check ${logFile}`,
|
package/src/cdp-client.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import path from "node:path";
|
|
|
14
14
|
import { assertOwnedCdp, chromeProfileDir, ownershipErrorMessage } from "./cdp-ownership.mjs";
|
|
15
15
|
import { resolveProjectDir } from "./upgrade.mjs";
|
|
16
16
|
|
|
17
|
+
import { pageCoverage } from "./observe-core.mjs";
|
|
17
18
|
export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
18
19
|
|
|
19
20
|
/** Where inject recorded the Chrome it launched. Never guess a port. */
|
|
@@ -76,8 +77,12 @@ export function choosePage(pages, hosts = []) {
|
|
|
76
77
|
const match = real.find((page) => hostMatches(page.url, hosts));
|
|
77
78
|
if (match) return match;
|
|
78
79
|
if (hosts.length) {
|
|
80
|
+
const off = real.map((page) => pageCoverage(page.url, hosts)).find((coverage) => coverage.pattern);
|
|
79
81
|
throw new Error(
|
|
80
|
-
|
|
82
|
+
off
|
|
83
|
+
? `The test window is on ${off.host}, which this project does not cover (hosts: ${hosts.join(", ")}). ` +
|
|
84
|
+
`If it is part of the product, add "${off.pattern}" to "hosts" in foldspace.dev.json - with the human's yes - then run inject and attach again.`
|
|
85
|
+
: `The test window has no tab on ${hosts.join(", ")}. Open the product there (or finish signing in), then try again.`,
|
|
81
86
|
);
|
|
82
87
|
}
|
|
83
88
|
return real[0];
|
package/src/cli-registry.mjs
CHANGED
|
@@ -320,6 +320,35 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
320
320
|
],
|
|
321
321
|
next: ["Record what you established in docs/app-profile.md", "Write the handler"],
|
|
322
322
|
}),
|
|
323
|
+
Object.freeze({
|
|
324
|
+
name: "ask",
|
|
325
|
+
entry: "ask.mjs",
|
|
326
|
+
group: "verify",
|
|
327
|
+
summary: "Ask the visible agent a question through its panel; get the reply, card text, what the handlers did, and a screenshot",
|
|
328
|
+
usage: "foldspace ask \"<question>\" [--retry <n>] [--every <s>] [--timeout <s>]",
|
|
329
|
+
risk: "browser-session",
|
|
330
|
+
environment: "local-chrome",
|
|
331
|
+
environmentVariables: ["FOLDSPACE_PROJECT_DIR", "CDP_PORT"],
|
|
332
|
+
capabilities: ["browser.cdp", "page.evaluate"],
|
|
333
|
+
positionals: [
|
|
334
|
+
Object.freeze({ name: "question", required: true, description: "What an end user would type" }),
|
|
335
|
+
],
|
|
336
|
+
options: [
|
|
337
|
+
value("--retry", "n", "Ask again when no action fired (a newly published action can take a minute to be picked up)", { default: "0" }),
|
|
338
|
+
value("--every", "seconds", "Pause between retries", { default: "20" }),
|
|
339
|
+
value("--timeout", "seconds", "Wait this long for a reply", { default: "90" }),
|
|
340
|
+
],
|
|
341
|
+
prerequisites: ["foldspace attach is running, so the handlers on the page are this project's build"],
|
|
342
|
+
effects: [
|
|
343
|
+
"Types into the panel the way a person does; touches nothing inside the SDK",
|
|
344
|
+
"Creates a real conversation: it appears in the customer's Foldspace dashboard (test traffic only under attach --test-mode)",
|
|
345
|
+
"Saves panel and full-page screenshots under .foldspace-dev/ask/",
|
|
346
|
+
],
|
|
347
|
+
next: [
|
|
348
|
+
"Read reply, cards and handler.execute / handler.render; look at the screenshot",
|
|
349
|
+
"Only then: foldspace badge ready, and invite the customer to try it",
|
|
350
|
+
],
|
|
351
|
+
}),
|
|
323
352
|
Object.freeze({
|
|
324
353
|
name: "badge",
|
|
325
354
|
entry: "badge.mjs",
|
|
@@ -447,7 +476,7 @@ export function createCliRegistry({ packageName, packageVersion, env = process.e
|
|
|
447
476
|
},
|
|
448
477
|
protocolVersion: HARNESS_PROTOCOL_VERSION,
|
|
449
478
|
workflow: ["init", "build", "inject", "attach"],
|
|
450
|
-
verifyWorkflow: ["observe"],
|
|
479
|
+
verifyWorkflow: ["observe", "ask"],
|
|
451
480
|
publishWorkflow: showInternal ? ["deploy"] : [],
|
|
452
481
|
capabilities: CAPABILITY_CATALOGUE.map(([id, description]) => ({
|
|
453
482
|
id,
|