@foldspace_npm/harness 0.1.8 → 0.1.9
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/README.md +10 -0
- package/package.json +8 -1
- package/src/runtime/agent.ts +66 -0
- package/src/runtime/config.ts +78 -0
- package/src/runtime/http.ts +373 -0
- package/src/runtime/index.ts +36 -0
- package/src/runtime/match.ts +87 -0
- package/src/runtime/render.ts +97 -0
- package/templates/agent-starter/CLAUDE.md +40 -5
- package/templates/agent-starter/agent/actions/_example.ts +20 -25
- package/templates/agent-starter/agent/actions/index.ts +9 -1
- package/templates/agent-starter/agent/constants.ts +25 -0
- package/templates/agent-starter/agent/utils.ts +59 -12
- package/templates/agent-starter/tsconfig.json +1 -0
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ constraint this package exists to protect.
|
|
|
27
27
|
|
|
28
28
|
- `agent/actions/*` and `agent/api/*` — a handler is `fetch` plus `runTask`,
|
|
29
29
|
nothing more. The actions built for Figma run unmodified in either track.
|
|
30
|
+
- `agent/utils.ts` — configures and re-exports `@foldspace_npm/harness/runtime`
|
|
30
31
|
- `foldspace build` — esbuild → `dist/index.js`
|
|
31
32
|
- `foldspace deploy` — publish to `agent/actions/<env>/<productId>/<agentApiName>`
|
|
32
33
|
- fixtures and tests
|
|
@@ -150,6 +151,15 @@ v1 flags:
|
|
|
150
151
|
|
|
151
152
|
Put action instructions in Agent Studio or MCP. `foldspace lint` cannot see that copy.
|
|
152
153
|
|
|
154
|
+
### Runtime helpers
|
|
155
|
+
|
|
156
|
+
Tenant actions import from `agent/utils.ts`, which configures and re-exports
|
|
157
|
+
`@foldspace_npm/harness/runtime`. That code is bundled into `dist/index.js` and
|
|
158
|
+
runs in the customer's page — it is not a CLI command.
|
|
159
|
+
|
|
160
|
+
`API_BASE` and `AUTH_SOURCE` stamp empty. Fill them from a captured XHR, not a
|
|
161
|
+
guess. Custom auth headers stay a tenant override of one function in `utils.ts`.
|
|
162
|
+
|
|
153
163
|
### Choose an attach mode
|
|
154
164
|
|
|
155
165
|
- **Swap (default):** the page already uses the configured product and agent.
|
package/package.json
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldspace_npm/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Build and verify portable Foldspace action artifacts against a live app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"foldspace": "bin/cli.mjs",
|
|
8
8
|
"harness": "bin/cli.mjs"
|
|
9
9
|
},
|
|
10
|
+
"exports": {
|
|
11
|
+
"./runtime": {
|
|
12
|
+
"types": "./src/runtime/index.ts",
|
|
13
|
+
"default": "./src/runtime/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
10
17
|
"scripts": {
|
|
11
18
|
"test": "node --test test/*.test.mjs"
|
|
12
19
|
},
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getConfig } from "./config";
|
|
2
|
+
|
|
3
|
+
let cached: any | null = null;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Foldspace SDK handle for this agent's **overlay** instance.
|
|
7
|
+
*
|
|
8
|
+
* Takes no `mode` on purpose: `foldspace.agent({ apiName })` returns the overlay
|
|
9
|
+
* handle. On an app that embeds the copilot in-page that is not the instance
|
|
10
|
+
* serving the chat, so arming only it succeeds and leaves real conversations
|
|
11
|
+
* untagged. For a specific instance, enumerate `window.foldspace.agentIds()`
|
|
12
|
+
* (`"<mode>-<apiName>"`) and call `foldspace.agent({ apiName, mode })`.
|
|
13
|
+
*
|
|
14
|
+
* This is a thin wrapper around the SDK, not a second agent implementation.
|
|
15
|
+
* Cached after the first successful lookup.
|
|
16
|
+
*
|
|
17
|
+
* @returns The overlay agent, or `null` if the SDK is not on the page
|
|
18
|
+
*/
|
|
19
|
+
export function getAgent(): any | null {
|
|
20
|
+
if (cached) return cached;
|
|
21
|
+
cached =
|
|
22
|
+
(window as any).foldspace?.agent({ apiName: getConfig().agentApiName }) ??
|
|
23
|
+
null;
|
|
24
|
+
return cached;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Arm every Foldspace instance on the page for test mode and remote actions.
|
|
29
|
+
*
|
|
30
|
+
* Calls SDK `setTestMode` and `setConfiguration({ remoteActionsSettings })`.
|
|
31
|
+
* Local `foldspace attach` already injects test-mode arming without putting it
|
|
32
|
+
* in `dist/index.js`. Prefer that. Importing this helper from an action can
|
|
33
|
+
* ship those calls into the production CDN bundle.
|
|
34
|
+
*
|
|
35
|
+
* Do not call from `execute`. Partial `setConfiguration` has collapsed the
|
|
36
|
+
* widget to 0×0 in a real build.
|
|
37
|
+
*
|
|
38
|
+
* @param testMode - When true, mark conversations as test traffic
|
|
39
|
+
* @returns Instance ids that armed vs failed
|
|
40
|
+
*/
|
|
41
|
+
export function armAllInstances(testMode = true): {
|
|
42
|
+
armed: string[];
|
|
43
|
+
failed: string[];
|
|
44
|
+
} {
|
|
45
|
+
const fs = (window as any).foldspace;
|
|
46
|
+
const armed: string[] = [];
|
|
47
|
+
const failed: string[] = [];
|
|
48
|
+
if (!fs || typeof fs.agentIds !== "function") return { armed, failed };
|
|
49
|
+
|
|
50
|
+
for (const id of fs.agentIds() as string[]) {
|
|
51
|
+
const cut = id.indexOf("-");
|
|
52
|
+
if (cut < 1) continue;
|
|
53
|
+
try {
|
|
54
|
+
const inst = fs.agent({
|
|
55
|
+
apiName: id.slice(cut + 1),
|
|
56
|
+
mode: id.slice(0, cut).toUpperCase(),
|
|
57
|
+
});
|
|
58
|
+
inst.setTestMode(testMode);
|
|
59
|
+
inst.setConfiguration({ remoteActionsSettings: { enabled: true } });
|
|
60
|
+
armed.push(id);
|
|
61
|
+
} catch {
|
|
62
|
+
failed.push(id);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { armed, failed };
|
|
66
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-specific facts for the page runtime. Set once from `agent/utils.ts`.
|
|
3
|
+
*
|
|
4
|
+
* This module cannot import the client's `constants.ts`, so the client pushes
|
|
5
|
+
* values in with {@link configure}. Nothing here is a Foldspace cloud setting.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Where the customer app keeps its own session token — not a Foldspace token.
|
|
10
|
+
*
|
|
11
|
+
* Custom headers (not Bearer) are out of scope: override `apiFetch` in
|
|
12
|
+
* `agent/utils.ts` instead of adding a third `kind`.
|
|
13
|
+
*/
|
|
14
|
+
export interface AuthSource {
|
|
15
|
+
/** Where the live page stores the token. */
|
|
16
|
+
kind: "localStorage" | "cookie";
|
|
17
|
+
/** The localStorage key or cookie name observed on the page. */
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Values {@link configure} stores for the rest of the runtime.
|
|
23
|
+
*
|
|
24
|
+
* Stamp `apiBase` and `authSource` empty until you capture a real XHR. A
|
|
25
|
+
* guessed host or Bearer vs cookie looks like success and publishes to the
|
|
26
|
+
* wrong place.
|
|
27
|
+
*/
|
|
28
|
+
export interface RuntimeConfig {
|
|
29
|
+
/** Foldspace agent apiName, e.g. `joist-agent`. */
|
|
30
|
+
agentApiName: string;
|
|
31
|
+
/**
|
|
32
|
+
* Prefix for `apiFetch` / `apiFetchBinary`. Include an explicit port if the
|
|
33
|
+
* app uses one — omitting `:443` can fail CORS.
|
|
34
|
+
*/
|
|
35
|
+
apiBase?: string;
|
|
36
|
+
/**
|
|
37
|
+
* Prefix for `publicFetch` when the caller does not pass an origin.
|
|
38
|
+
* Marketing / unauthenticated hosts only.
|
|
39
|
+
*/
|
|
40
|
+
publicOrigin?: string;
|
|
41
|
+
/** Session location for `getAuthToken`. Omit until observed. */
|
|
42
|
+
authSource?: AuthSource;
|
|
43
|
+
/** `fetch` timeout in ms. Defaults to 15_000 in `configure`. */
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let config: RuntimeConfig | null = null;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Store tenant config for this page bundle. Call once from `agent/utils.ts`
|
|
51
|
+
* at module load — not from an action `execute`.
|
|
52
|
+
*
|
|
53
|
+
* Replaces the previous object; it does not merge. A second call clobbers the
|
|
54
|
+
* first. `timeoutMs` defaults to 15_000 if omitted.
|
|
55
|
+
*
|
|
56
|
+
* @param next - Tenant ids and observed API/auth facts
|
|
57
|
+
*/
|
|
58
|
+
export function configure(next: RuntimeConfig): void {
|
|
59
|
+
config = { timeoutMs: 15_000, ...next };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Read the config {@link configure} stored.
|
|
64
|
+
*
|
|
65
|
+
* Throws if `configure` never ran (imported the runtime without going through
|
|
66
|
+
* `agent/utils.ts`). Empty `apiBase` / `authSource` after configure is valid;
|
|
67
|
+
* `apiFetch` returns `{ ok: false }` for those instead of throwing.
|
|
68
|
+
*
|
|
69
|
+
* @returns The singleton config for this bundle
|
|
70
|
+
*/
|
|
71
|
+
export function getConfig(): RuntimeConfig {
|
|
72
|
+
if (!config) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
"harness runtime is not configured — call configure() from agent/utils.ts",
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
return config;
|
|
78
|
+
}
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
// HTTP against the customer's own API, from inside their signed-in page.
|
|
2
|
+
//
|
|
3
|
+
// Lifted from the Transparency Catalog build. Errors are RETURNED, never
|
|
4
|
+
// thrown, and never carry the token or a raw API error body back to the agent.
|
|
5
|
+
//
|
|
6
|
+
// Two of three early builds used Authorization: Bearer from localStorage, not
|
|
7
|
+
// cookies. Reach for credentials: "include" only when you have observed the
|
|
8
|
+
// app doing that. Custom headers (Joist-style) belong in a tenant override of
|
|
9
|
+
// apiFetch, not here.
|
|
10
|
+
|
|
11
|
+
import { getConfig } from "./config";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Mask a secret for logs. Never log a raw token.
|
|
15
|
+
*
|
|
16
|
+
* @param value - Token or other secret; null/empty becomes `"<empty>"`
|
|
17
|
+
* @returns `"<redacted:N chars>"` or `"<empty>"`
|
|
18
|
+
*/
|
|
19
|
+
export function redact(value: string | null | undefined): string {
|
|
20
|
+
if (!value) return "<empty>";
|
|
21
|
+
return `<redacted:${value.length} chars>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readLocalStorageValue(key: string): string | null {
|
|
25
|
+
const raw = window.localStorage.getItem(key);
|
|
26
|
+
if (!raw) return null;
|
|
27
|
+
try {
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
if (typeof parsed === "string") return parsed;
|
|
30
|
+
if (parsed && typeof parsed === "object") {
|
|
31
|
+
return parsed.access_token ?? parsed.token ?? parsed.value ?? null;
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
return raw;
|
|
35
|
+
}
|
|
36
|
+
return raw;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readCookie(name: string): string | null {
|
|
40
|
+
const prefix = `${name}=`;
|
|
41
|
+
for (const part of document.cookie.split(";")) {
|
|
42
|
+
const c = part.trim();
|
|
43
|
+
if (c.startsWith(prefix)) {
|
|
44
|
+
return decodeURIComponent(c.slice(prefix.length)) || null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read the customer app's session token from `authSource`.
|
|
52
|
+
*
|
|
53
|
+
* Returns null when unsigned-in, `AUTH_SOURCE` is unset, or the key is empty.
|
|
54
|
+
* This is the host app token, not a Foldspace credential.
|
|
55
|
+
*
|
|
56
|
+
* @returns The token string, or `null`
|
|
57
|
+
*/
|
|
58
|
+
export function getAuthToken(): string | null {
|
|
59
|
+
const source = getConfig().authSource;
|
|
60
|
+
if (!source?.name) return null;
|
|
61
|
+
if (source.kind === "cookie") return readCookie(source.name);
|
|
62
|
+
return readLocalStorageValue(source.name);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Decode a JWT payload without verifying the signature.
|
|
67
|
+
*
|
|
68
|
+
* Identity hints only (email, sub). Never use this for authorization.
|
|
69
|
+
*
|
|
70
|
+
* @param token - Compact JWT from {@link getAuthToken}
|
|
71
|
+
* @returns Parsed payload, or `null` if the token is not a JWT
|
|
72
|
+
*/
|
|
73
|
+
export function parseJwt<T = Record<string, unknown>>(token: string): T | null {
|
|
74
|
+
try {
|
|
75
|
+
const [, payload] = token.split(".");
|
|
76
|
+
if (!payload) return null;
|
|
77
|
+
const json = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
|
|
78
|
+
return JSON.parse(json) as T;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Successful JSON/binary response from {@link apiFetch}, {@link apiFetchBinary},
|
|
86
|
+
* or {@link publicFetch}.
|
|
87
|
+
*/
|
|
88
|
+
export interface ApiSuccess<T> {
|
|
89
|
+
ok: true;
|
|
90
|
+
/** HTTP status (typically 200). */
|
|
91
|
+
status: number;
|
|
92
|
+
/** Parsed JSON (`apiFetch` / `publicFetch`) or bytes (`apiFetchBinary`). */
|
|
93
|
+
data: T;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Failed request. Errors are **returned**, never thrown.
|
|
98
|
+
*
|
|
99
|
+
* Show `error` to a person. Log `detail` to the console only — never put
|
|
100
|
+
* `detail` in an action return value; Foldspace may quote that to the user.
|
|
101
|
+
*
|
|
102
|
+
* `unreachable` is true when the browser never got a response (DNS, CORS,
|
|
103
|
+
* offline, connection refused). Timeouts set `unreachable: false` and
|
|
104
|
+
* `error: "Request timed out."`. Empty `API_BASE` / `AUTH_SOURCE` also
|
|
105
|
+
* return `{ ok: false }` with status 0 — they do not throw.
|
|
106
|
+
*/
|
|
107
|
+
export interface ApiFailure {
|
|
108
|
+
ok: false;
|
|
109
|
+
/** HTTP status, or `0` when there was no response / config was missing. */
|
|
110
|
+
status: number;
|
|
111
|
+
/** Safe, user-facing sentence. Pass this to `renderError`. */
|
|
112
|
+
error: string;
|
|
113
|
+
/** Truncated API body for the console. Never show this in the widget. */
|
|
114
|
+
detail?: string;
|
|
115
|
+
/**
|
|
116
|
+
* True when the network itself failed (not HTTP 4xx/5xx, not abort).
|
|
117
|
+
* Use this to distinguish "server said no" from "could not reach host".
|
|
118
|
+
*/
|
|
119
|
+
unreachable?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Discriminated result of a customer-API call. Branch on `ok`. */
|
|
123
|
+
export type ApiResult<T> = ApiSuccess<T> | ApiFailure;
|
|
124
|
+
|
|
125
|
+
function missingConfigResult(): ApiResult<never> | null {
|
|
126
|
+
const config = getConfig();
|
|
127
|
+
if (!config.apiBase) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
status: 0,
|
|
131
|
+
error: "API_BASE is not set — capture the app's XHR first.",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
if (!config.authSource) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
status: 0,
|
|
138
|
+
error: "AUTH_SOURCE is not set — capture the app's session first.",
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function failureDetail(parsed: unknown): string | undefined {
|
|
145
|
+
if (parsed && typeof parsed === "object") {
|
|
146
|
+
const m = (parsed as { message?: unknown }).message;
|
|
147
|
+
if (typeof m === "string") return m.slice(0, 300);
|
|
148
|
+
} else if (typeof parsed === "string" && parsed.trim()) {
|
|
149
|
+
return parsed.trim().slice(0, 300);
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Authenticated JSON request against `apiBase` with `Authorization: Bearer`.
|
|
156
|
+
*
|
|
157
|
+
* Matches the common pattern (two of three early builds). If the live page
|
|
158
|
+
* uses cookies, custom headers, or no Bearer, **stop re-exporting this** from
|
|
159
|
+
* `agent/utils.ts` and write a local `apiFetch` instead.
|
|
160
|
+
*
|
|
161
|
+
* Empty `API_BASE` / `AUTH_SOURCE` return `{ ok: false }` so a guessed host
|
|
162
|
+
* cannot look like success. Errors never throw and never include the token.
|
|
163
|
+
*
|
|
164
|
+
* @param path - Path under `apiBase`, including a leading `/`
|
|
165
|
+
* @param init - Extra `fetch` options; your headers merge over Bearer/JSON
|
|
166
|
+
* @param timeoutMs - Override the configured timeout (default 15s)
|
|
167
|
+
*/
|
|
168
|
+
export async function apiFetch<T = unknown>(
|
|
169
|
+
path: string,
|
|
170
|
+
init: RequestInit = {},
|
|
171
|
+
timeoutMs?: number,
|
|
172
|
+
): Promise<ApiResult<T>> {
|
|
173
|
+
const missing = missingConfigResult();
|
|
174
|
+
if (missing) return missing;
|
|
175
|
+
|
|
176
|
+
const token = getAuthToken();
|
|
177
|
+
if (!token) return { ok: false, status: 401, error: "Not signed in." };
|
|
178
|
+
|
|
179
|
+
const controller = new AbortController();
|
|
180
|
+
const timer = setTimeout(
|
|
181
|
+
() => controller.abort(),
|
|
182
|
+
timeoutMs ?? getConfig().timeoutMs ?? 15_000,
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const res = await fetch(`${getConfig().apiBase}${path}`, {
|
|
187
|
+
...init,
|
|
188
|
+
signal: controller.signal,
|
|
189
|
+
headers: {
|
|
190
|
+
"Content-Type": "application/json",
|
|
191
|
+
Authorization: `Bearer ${token}`,
|
|
192
|
+
...(init.headers ?? {}),
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const text = await res.text();
|
|
197
|
+
let parsed: unknown = text;
|
|
198
|
+
try {
|
|
199
|
+
parsed = JSON.parse(text);
|
|
200
|
+
} catch {
|
|
201
|
+
/* non-JSON; keep the raw text for the status check */
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
status: res.status,
|
|
208
|
+
error:
|
|
209
|
+
res.status === 401
|
|
210
|
+
? "Your session expired. Sign in again."
|
|
211
|
+
: "That request could not be completed.",
|
|
212
|
+
detail: failureDetail(parsed),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { ok: true, status: res.status, data: parsed as T };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
const aborted = error instanceof Error && error.name === "AbortError";
|
|
219
|
+
return {
|
|
220
|
+
ok: false,
|
|
221
|
+
status: 0,
|
|
222
|
+
unreachable: !aborted,
|
|
223
|
+
error: aborted ? "Request timed out." : "Could not reach the server.",
|
|
224
|
+
};
|
|
225
|
+
} finally {
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Run an async worker over a list with a cap on in-flight work.
|
|
232
|
+
*
|
|
233
|
+
* Use this for N+1 fetches after a list call (export, hydrate rows). A large
|
|
234
|
+
* list without a cap can open dozens of sockets at once.
|
|
235
|
+
*
|
|
236
|
+
* @param items - Inputs to process
|
|
237
|
+
* @param limit - Max concurrent `worker` calls
|
|
238
|
+
* @param worker - Async mapper; results stay in input order
|
|
239
|
+
*/
|
|
240
|
+
export async function mapWithConcurrency<T, R>(
|
|
241
|
+
items: T[],
|
|
242
|
+
limit: number,
|
|
243
|
+
worker: (item: T, index: number) => Promise<R>,
|
|
244
|
+
): Promise<R[]> {
|
|
245
|
+
const results: R[] = new Array(items.length);
|
|
246
|
+
let cursor = 0;
|
|
247
|
+
const runners = Array.from(
|
|
248
|
+
{ length: Math.min(limit, items.length) || 0 },
|
|
249
|
+
async () => {
|
|
250
|
+
while (cursor < items.length) {
|
|
251
|
+
const i = cursor++;
|
|
252
|
+
results[i] = await worker(items[i], i);
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
await Promise.all(runners);
|
|
257
|
+
return results;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Same auth and `apiBase` as {@link apiFetch}, but returns raw bytes.
|
|
262
|
+
*
|
|
263
|
+
* Use for file downloads / exports, not JSON endpoints.
|
|
264
|
+
*
|
|
265
|
+
* @param path - Path under `apiBase`, including a leading `/`
|
|
266
|
+
* @param init - Extra `fetch` options
|
|
267
|
+
* @param timeoutMs - Override the configured timeout (default 15s)
|
|
268
|
+
*/
|
|
269
|
+
export async function apiFetchBinary(
|
|
270
|
+
path: string,
|
|
271
|
+
init: RequestInit = {},
|
|
272
|
+
timeoutMs?: number,
|
|
273
|
+
): Promise<ApiResult<Uint8Array>> {
|
|
274
|
+
const missing = missingConfigResult();
|
|
275
|
+
if (missing) return missing;
|
|
276
|
+
|
|
277
|
+
const token = getAuthToken();
|
|
278
|
+
if (!token) return { ok: false, status: 401, error: "Not signed in." };
|
|
279
|
+
|
|
280
|
+
const controller = new AbortController();
|
|
281
|
+
const timer = setTimeout(
|
|
282
|
+
() => controller.abort(),
|
|
283
|
+
timeoutMs ?? getConfig().timeoutMs ?? 15_000,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
try {
|
|
287
|
+
const res = await fetch(`${getConfig().apiBase}${path}`, {
|
|
288
|
+
...init,
|
|
289
|
+
signal: controller.signal,
|
|
290
|
+
headers: { Authorization: `Bearer ${token}`, ...(init.headers ?? {}) },
|
|
291
|
+
});
|
|
292
|
+
if (!res.ok) {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
status: res.status,
|
|
296
|
+
error:
|
|
297
|
+
res.status === 401
|
|
298
|
+
? "Your session expired. Sign in again."
|
|
299
|
+
: "That file could not be returned.",
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
status: res.status,
|
|
305
|
+
data: new Uint8Array(await res.arrayBuffer()),
|
|
306
|
+
};
|
|
307
|
+
} catch (error) {
|
|
308
|
+
const aborted = error instanceof Error && error.name === "AbortError";
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
status: 0,
|
|
312
|
+
unreachable: !aborted,
|
|
313
|
+
error: aborted ? "Download timed out." : "Could not reach the server.",
|
|
314
|
+
};
|
|
315
|
+
} finally {
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Unauthenticated JSON GET. No Bearer token.
|
|
322
|
+
*
|
|
323
|
+
* Origin is the second argument, or `RuntimeConfig.publicOrigin`. Path first
|
|
324
|
+
* (Transparency Catalog calling convention). Use for marketing / public APIs,
|
|
325
|
+
* not the signed-in product API.
|
|
326
|
+
*
|
|
327
|
+
* @param path - Path under the public origin
|
|
328
|
+
* @param origin - Override `publicOrigin` for this call
|
|
329
|
+
* @param timeoutMs - Override the configured timeout (default 15s)
|
|
330
|
+
*/
|
|
331
|
+
export async function publicFetch<T = unknown>(
|
|
332
|
+
path: string,
|
|
333
|
+
origin?: string,
|
|
334
|
+
timeoutMs?: number,
|
|
335
|
+
): Promise<ApiResult<T>> {
|
|
336
|
+
const base = origin ?? getConfig().publicOrigin;
|
|
337
|
+
if (!base) {
|
|
338
|
+
return {
|
|
339
|
+
ok: false,
|
|
340
|
+
status: 0,
|
|
341
|
+
error: "No public origin is configured for this app.",
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const controller = new AbortController();
|
|
345
|
+
const timer = setTimeout(
|
|
346
|
+
() => controller.abort(),
|
|
347
|
+
timeoutMs ?? getConfig().timeoutMs ?? 15_000,
|
|
348
|
+
);
|
|
349
|
+
try {
|
|
350
|
+
const res = await fetch(`${base}${path}`, {
|
|
351
|
+
credentials: "include",
|
|
352
|
+
signal: controller.signal,
|
|
353
|
+
});
|
|
354
|
+
if (!res.ok) {
|
|
355
|
+
return {
|
|
356
|
+
ok: false,
|
|
357
|
+
status: res.status,
|
|
358
|
+
error: "That request could not be completed.",
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
return { ok: true, status: res.status, data: (await res.json()) as T };
|
|
362
|
+
} catch (error) {
|
|
363
|
+
const aborted = error instanceof Error && error.name === "AbortError";
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
status: 0,
|
|
367
|
+
unreachable: !aborted,
|
|
368
|
+
error: aborted ? "The request timed out." : "Could not reach that host.",
|
|
369
|
+
};
|
|
370
|
+
} finally {
|
|
371
|
+
clearTimeout(timer);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page-runtime helpers compiled into the customer's bundle via esbuild.
|
|
3
|
+
*
|
|
4
|
+
* Source is TypeScript on purpose: what you read here is what ships. Import
|
|
5
|
+
* these from `agent/utils.ts` in the client repo, not from this package path
|
|
6
|
+
* inside an action file.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { configure, getConfig } from "./config";
|
|
12
|
+
export type { RuntimeConfig, AuthSource } from "./config";
|
|
13
|
+
|
|
14
|
+
export { getAgent, armAllInstances } from "./agent";
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
redact,
|
|
18
|
+
getAuthToken,
|
|
19
|
+
parseJwt,
|
|
20
|
+
apiFetch,
|
|
21
|
+
apiFetchBinary,
|
|
22
|
+
publicFetch,
|
|
23
|
+
mapWithConcurrency,
|
|
24
|
+
} from "./http";
|
|
25
|
+
export type { ApiResult, ApiSuccess, ApiFailure } from "./http";
|
|
26
|
+
|
|
27
|
+
export { rankBy } from "./match";
|
|
28
|
+
export type { FuzzyMatch, RankByOptions } from "./match";
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
renderLoading,
|
|
32
|
+
renderEmpty,
|
|
33
|
+
renderError,
|
|
34
|
+
renderFatal,
|
|
35
|
+
} from "./render";
|
|
36
|
+
export type { ViewHost } from "./render";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rank an in-memory list against what a user typed.
|
|
3
|
+
*
|
|
4
|
+
* Substring hits first (they meant that name), then the Foldspace SDK's
|
|
5
|
+
* `searchInList` (Fuse) for typos. Do not use this for domain ranking
|
|
6
|
+
* (invoices, coverage, MasterFormat).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getAgent } from "./agent";
|
|
10
|
+
|
|
11
|
+
/** One SDK fuzzy hit. `score` is lower-is-better Fuse scoring. */
|
|
12
|
+
export interface FuzzyMatch<T> {
|
|
13
|
+
item: T;
|
|
14
|
+
score?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Options for {@link rankBy}.
|
|
19
|
+
*/
|
|
20
|
+
export interface RankByOptions<T> {
|
|
21
|
+
/** Fields to match, in priority order. */
|
|
22
|
+
searchKeys: (keyof T & string)[];
|
|
23
|
+
/** Stable identity so substring and fuzzy hits can be merged. */
|
|
24
|
+
idOf: (item: T) => string | number;
|
|
25
|
+
/** Max rows to return. Default 10. */
|
|
26
|
+
limit?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Drop SDK hits with score at or above this. Default 0.45. Lower is a
|
|
29
|
+
* closer Fuse match.
|
|
30
|
+
*/
|
|
31
|
+
plausible?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Lower is better in the SDK's scoring. Above this, a hit is noise. */
|
|
35
|
+
const PLAUSIBLE = 0.45;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rank records against a user's phrase.
|
|
39
|
+
*
|
|
40
|
+
* If `searchInList` is missing, the substring pass still answers — a missing
|
|
41
|
+
* SDK method degrades the result rather than breaking the action.
|
|
42
|
+
*
|
|
43
|
+
* @param items - Records already loaded (e.g. from {@link apiFetch})
|
|
44
|
+
* @param query - What the user typed
|
|
45
|
+
* @param opts - Fields, identity, optional limit
|
|
46
|
+
* @returns Up to `limit` items, substring matches first
|
|
47
|
+
*/
|
|
48
|
+
export function rankBy<T>(
|
|
49
|
+
items: T[],
|
|
50
|
+
query: string,
|
|
51
|
+
opts: RankByOptions<T>,
|
|
52
|
+
): T[] {
|
|
53
|
+
const limit = opts.limit ?? 10;
|
|
54
|
+
const q = query.trim();
|
|
55
|
+
if (!q) return items.slice(0, limit);
|
|
56
|
+
|
|
57
|
+
const needle = q.toLowerCase();
|
|
58
|
+
const substring = items.filter((item) =>
|
|
59
|
+
opts.searchKeys.some((k) =>
|
|
60
|
+
String(item[k] ?? "")
|
|
61
|
+
.toLowerCase()
|
|
62
|
+
.includes(needle),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
let fuzzy: T[] = [];
|
|
67
|
+
try {
|
|
68
|
+
const hits: FuzzyMatch<T>[] =
|
|
69
|
+
getAgent()?.searchInList?.(items, q, { searchKeys: opts.searchKeys }) ??
|
|
70
|
+
[];
|
|
71
|
+
fuzzy = hits
|
|
72
|
+
.filter((h) => (h.score ?? 1) < (opts.plausible ?? PLAUSIBLE))
|
|
73
|
+
.map((h) => h.item);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.warn("[foldspace] fuzzy search unavailable:", err);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const seen = new Set(substring.map(opts.idOf));
|
|
79
|
+
const merged = [...substring];
|
|
80
|
+
for (const item of fuzzy) {
|
|
81
|
+
const id = opts.idOf(item);
|
|
82
|
+
if (seen.has(id)) continue;
|
|
83
|
+
seen.add(id);
|
|
84
|
+
merged.push(item);
|
|
85
|
+
}
|
|
86
|
+
return merged.slice(0, limit);
|
|
87
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unstyled chatterblock states. Tenant brand belongs in `agent/views/`.
|
|
3
|
+
*
|
|
4
|
+
* An action's `render` output cannot be typechecked. Look at a frame. Never
|
|
5
|
+
* use `innerHTML` with API data.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The `host` argument Foldspace passes to `render`, or `{ container }` from
|
|
10
|
+
* older tenant helpers. Either works.
|
|
11
|
+
*/
|
|
12
|
+
export type ViewHost = HTMLElement | { container: HTMLElement };
|
|
13
|
+
|
|
14
|
+
function containerOf(host: ViewHost): HTMLElement {
|
|
15
|
+
return host instanceof HTMLElement ? host : host.container;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function el(tag: string, className: string, text?: string): HTMLElement {
|
|
19
|
+
const node = document.createElement(tag);
|
|
20
|
+
node.className = className;
|
|
21
|
+
if (text !== undefined) node.textContent = text;
|
|
22
|
+
return node;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function replace(host: ViewHost, node: HTMLElement): void {
|
|
26
|
+
const container = containerOf(host);
|
|
27
|
+
container.textContent = "";
|
|
28
|
+
container.appendChild(node);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* In-progress chatterblock. Unstyled scaffolding — replace with branded UI
|
|
33
|
+
* when the card is the experience.
|
|
34
|
+
*
|
|
35
|
+
* @param host - Foldspace `render` host
|
|
36
|
+
* @param message - Short status text. Default `"Working..."`.
|
|
37
|
+
*/
|
|
38
|
+
export function renderLoading(host: ViewHost, message = "Working..."): void {
|
|
39
|
+
replace(host, el("div", "fs-state fs-loading", message));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Nothing matched, but nothing went wrong. Say what was searched for — an empty
|
|
44
|
+
* panel that does not name the query reads as a failure.
|
|
45
|
+
*
|
|
46
|
+
* @param host - Foldspace `render` host
|
|
47
|
+
* @param message - Include the query, e.g. `No estimates matching "Webb"`
|
|
48
|
+
*/
|
|
49
|
+
export function renderEmpty(host: ViewHost, message: string): void {
|
|
50
|
+
replace(host, el("div", "fs-state fs-empty", message));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Recoverable error: the user can retry or narrow. Pass `ApiResult.error`,
|
|
55
|
+
* never `detail` — `detail` is the API's own words and is for the console.
|
|
56
|
+
*
|
|
57
|
+
* @param host - Foldspace `render` host
|
|
58
|
+
* @param message - Safe, user-facing sentence
|
|
59
|
+
* @param onRetry - Optional click handler for a "Try again" button
|
|
60
|
+
*/
|
|
61
|
+
export function renderError(
|
|
62
|
+
host: ViewHost,
|
|
63
|
+
message: string,
|
|
64
|
+
onRetry?: () => void,
|
|
65
|
+
): void {
|
|
66
|
+
const wrap = el("div", "fs-state fs-error");
|
|
67
|
+
wrap.appendChild(el("p", "fs-error-message", message));
|
|
68
|
+
if (onRetry) {
|
|
69
|
+
const btn = el("button", "fs-retry", "Try again") as HTMLButtonElement;
|
|
70
|
+
btn.addEventListener("click", onRetry);
|
|
71
|
+
wrap.appendChild(btn);
|
|
72
|
+
}
|
|
73
|
+
replace(host, wrap);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Unrecoverable: retrying cannot help (signed out, host refused). Optional
|
|
78
|
+
* same-tab link — do not open a second tab out of the host app.
|
|
79
|
+
*
|
|
80
|
+
* @param host - Foldspace `render` host
|
|
81
|
+
* @param message - Safe, user-facing sentence
|
|
82
|
+
* @param action - Optional `{ label, href }` rendered as a same-tab `<a>`
|
|
83
|
+
*/
|
|
84
|
+
export function renderFatal(
|
|
85
|
+
host: ViewHost,
|
|
86
|
+
message: string,
|
|
87
|
+
action?: { label: string; href: string },
|
|
88
|
+
): void {
|
|
89
|
+
const wrap = el("div", "fs-state fs-fatal");
|
|
90
|
+
wrap.appendChild(el("p", "fs-error-message", message));
|
|
91
|
+
if (action) {
|
|
92
|
+
const a = el("a", "fs-fatal-action", action.label) as HTMLAnchorElement;
|
|
93
|
+
a.href = action.href;
|
|
94
|
+
wrap.appendChild(a);
|
|
95
|
+
}
|
|
96
|
+
replace(host, wrap);
|
|
97
|
+
}
|
|
@@ -35,6 +35,8 @@ from actions that read or mutate a selected resource.
|
|
|
35
35
|
4. Create action metadata as a draft. `generate_action_handler` works from the
|
|
36
36
|
draft schema; do not publish yet.
|
|
37
37
|
5. Implement the handler using the observed request and response shapes.
|
|
38
|
+
Import helpers from `agent/utils.ts` — inspect that file before writing
|
|
39
|
+
another `fetch` or rank helper.
|
|
38
40
|
6. Register the handler in `agent/actions/index.ts` and build (`foldspace build` lints first).
|
|
39
41
|
7. Run `npx foldspace attach --daemon` (add `--bootstrap` or `--replace` when
|
|
40
42
|
the page requires it). An empty local registry is valid if you only want to
|
|
@@ -47,8 +49,13 @@ from actions that read or mutate a selected resource.
|
|
|
47
49
|
|
|
48
50
|
Actions execute in the user's signed-in browser session.
|
|
49
51
|
|
|
50
|
-
-
|
|
51
|
-
`
|
|
52
|
+
- Import HTTP, ranking, and widget helpers from `../utils`, not a new
|
|
53
|
+
`agent/api.ts` copy of `fetch`.
|
|
54
|
+
- Do not guess `API_BASE` or `AUTH_SOURCE`. Capture at least one real 200
|
|
55
|
+
(and the auth header the page actually sends) before filling them in.
|
|
56
|
+
`credentials: "include"` is correct only when you have observed the app
|
|
57
|
+
using cookies that way. Many apps use `Authorization: Bearer` from
|
|
58
|
+
`localStorage` instead; some use a custom header.
|
|
52
59
|
- Never guess endpoints or schemas. Capture at least one real 200 before
|
|
53
60
|
implementing a parser. Do not implement a path that was not observed.
|
|
54
61
|
- Verify that the user is signed in before observing a workflow.
|
|
@@ -56,7 +63,8 @@ Actions execute in the user's signed-in browser session.
|
|
|
56
63
|
ask the user to sign in.
|
|
57
64
|
- Validate parameters and return sanitized errors. Return **data only** — never
|
|
58
65
|
`directive`, `instructions`, or a paragraph telling the copilot what to say.
|
|
59
|
-
Action and agent instructions live in Agent Studio / MCP.
|
|
66
|
+
Action and agent instructions live in Agent Studio / MCP. Never return
|
|
67
|
+
`ApiResult.detail` from `execute` — it is for the console.
|
|
60
68
|
- Actions that return data the user will inspect should include a `render`
|
|
61
69
|
function for in-chat UI (a chatterblock). If it makes more sense to output the data
|
|
62
70
|
in a UI component instead of text then consider using render to show a component.
|
|
@@ -159,13 +167,40 @@ Do not report success without all six:
|
|
|
159
167
|
- `agent/actions/` — one handler per action (`execute`, optional `render`),
|
|
160
168
|
registered in `index.ts`
|
|
161
169
|
- `agent/api/` — one HTTP helper per endpoint
|
|
162
|
-
- `agent/constants.ts` — agent, product,
|
|
163
|
-
|
|
170
|
+
- `agent/constants.ts` — agent, product, domain, plus empty `API_BASE` /
|
|
171
|
+
`AUTH_SOURCE` and `LOAD_MODE`
|
|
172
|
+
- `agent/utils.ts` — configure the harness runtime and re-export it. **This is
|
|
173
|
+
the only import surface for actions.**
|
|
164
174
|
- `foldspace.dev.json` — local harness target configuration
|
|
165
175
|
- `docs/app-profile.md` — what is known about this app, and how it was established
|
|
166
176
|
|
|
167
177
|
Do not introduce another bundler or bundle format.
|
|
168
178
|
|
|
179
|
+
## What you already have
|
|
180
|
+
|
|
181
|
+
Import from `../utils`. Inspect that file before implementing another
|
|
182
|
+
general-purpose helper. MCP `generate_action_handler` may still emit a
|
|
183
|
+
skeleton that does not import it — fix the import when you implement.
|
|
184
|
+
|
|
185
|
+
| Helper | Use when | Do not use when |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| `apiFetch` / `apiFetchBinary` | The app's own JSON/file API, after a real 200 | Public marketing hosts (`publicFetch`); custom auth headers |
|
|
188
|
+
| `publicFetch` | Unauthenticated / marketing origin | Signed-in product APIs |
|
|
189
|
+
| `getAuthToken` / `parseJwt` | `AUTH_SOURCE` is `localStorage` or `cookie` | Custom header schemes — override `apiFetch` in `utils.ts` |
|
|
190
|
+
| `rankBy` | User typed a name; API search is exact or ignored | Domain ranking (invoices, coverage, MasterFormat) |
|
|
191
|
+
| `renderLoading` / `Empty` / `Error` / `Fatal` | Chatterblock empty/error paths | Branded cards — those stay in `agent/views/` |
|
|
192
|
+
| `getAgent` | Talking to Foldspace | You need a specific instance — then `agentIds()` |
|
|
193
|
+
| `armAllInstances` | Attach/bootstrap setup | Inside `execute` (can ship into `dist`; attach already arms test mode) |
|
|
194
|
+
| `redact` / `mapWithConcurrency` | Logging tokens; batching fetches | — |
|
|
195
|
+
|
|
196
|
+
If the observed auth is not Bearer + `localStorage`/`cookie`, stop re-exporting
|
|
197
|
+
that one function and keep the rest:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
export { getAgent, rankBy, renderEmpty } from "@foldspace_npm/harness/runtime";
|
|
201
|
+
export { apiFetch } from "./api";
|
|
202
|
+
```
|
|
203
|
+
|
|
169
204
|
## Agent learnings
|
|
170
205
|
|
|
171
206
|
`docs/app-profile.md` is the durable record of this app. Fill it as you probe,
|
|
@@ -1,33 +1,28 @@
|
|
|
1
1
|
// Copy this file to <action_key>.ts and register it in index.ts.
|
|
2
2
|
// Do not register _example — it is not a real Agent Studio action.
|
|
3
|
+
//
|
|
4
|
+
// Import helpers from ../utils, not from @foldspace_npm/harness/runtime.
|
|
5
|
+
// The path below is a placeholder until you capture a real HTTP 200.
|
|
6
|
+
|
|
7
|
+
import { apiFetch, rankBy } from "../utils";
|
|
8
|
+
|
|
9
|
+
type Item = { id: string; name: string };
|
|
3
10
|
|
|
4
11
|
export const example_action = {
|
|
5
|
-
execute: async (params: {
|
|
6
|
-
const
|
|
7
|
-
if (!message) {
|
|
8
|
-
return { ok: false, error: "message is required" };
|
|
9
|
-
}
|
|
12
|
+
execute: async (params: { query?: string }) => {
|
|
13
|
+
const query = typeof params?.query === "string" ? params.query.trim() : "";
|
|
10
14
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const detail = error instanceof Error ? error.message : "unknown error";
|
|
15
|
-
return { ok: false, error: detail };
|
|
15
|
+
const result = await apiFetch<{ items?: Item[] }>("/__observe_me");
|
|
16
|
+
if (!result.ok) {
|
|
17
|
+
return { ok: false, error: result.error };
|
|
16
18
|
}
|
|
17
|
-
},
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
// return;
|
|
28
|
-
// }
|
|
29
|
-
// const card = document.createElement("div");
|
|
30
|
-
// card.textContent = data.echo;
|
|
31
|
-
// host.append(card);
|
|
32
|
-
// },
|
|
20
|
+
const items = result.data.items ?? [];
|
|
21
|
+
const matches = rankBy(items, query, {
|
|
22
|
+
searchKeys: ["name"],
|
|
23
|
+
idOf: (item) => item.id,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return { ok: true, matches };
|
|
27
|
+
},
|
|
33
28
|
};
|
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
// Registration here is the switch: the SDK transmits these to the server, and
|
|
4
4
|
// the server excludes any active action it does not receive. Comment an entry
|
|
5
5
|
// out and the copilot can no longer see it — no unpublishing required.
|
|
6
|
+
//
|
|
7
|
+
// LOAD_MODE "injected" publishes the registry for attach to swap. "embedded"
|
|
8
|
+
// skips that assignment because the host page already loads Foldspace.
|
|
9
|
+
|
|
10
|
+
import { LOAD_MODE } from "../constants";
|
|
6
11
|
|
|
7
12
|
const actions = {};
|
|
8
|
-
|
|
13
|
+
|
|
14
|
+
if (LOAD_MODE === "injected") {
|
|
15
|
+
(window as any).__FOLDSPACE_REMOTE_ACTIONS__ = actions;
|
|
16
|
+
}
|
|
9
17
|
|
|
10
18
|
export default actions;
|
|
@@ -1,3 +1,28 @@
|
|
|
1
1
|
export const AGENT_API_NAME = {{AGENT_API_NAME_JSON}};
|
|
2
2
|
export const PRODUCT_ID = {{PRODUCT_ID_JSON}};
|
|
3
3
|
export const APP_DOMAIN = {{APP_DOMAIN_JSON}};
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Empty until you capture the app's own XHR. A guessed host looks like it
|
|
7
|
+
* worked. Include an explicit port if the app uses one.
|
|
8
|
+
*/
|
|
9
|
+
export const API_BASE = "";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Set after observing where the session token lives, for example
|
|
13
|
+
* `{ kind: "localStorage", name: "access_token" }` or
|
|
14
|
+
* `{ kind: "cookie", name: "session" }`.
|
|
15
|
+
*
|
|
16
|
+
* Custom headers that are not Bearer belong in a local `apiFetch` override —
|
|
17
|
+
* see CLAUDE.md. Do not guess this value.
|
|
18
|
+
*/
|
|
19
|
+
export const AUTH_SOURCE:
|
|
20
|
+
| { kind: "localStorage" | "cookie"; name: string }
|
|
21
|
+
| undefined = undefined;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* "injected" assigns `window.__FOLDSPACE_REMOTE_ACTIONS__` so attach can swap
|
|
25
|
+
* the bundle. "embedded" does not — the page already publishes handlers
|
|
26
|
+
* through its own SDK snippet.
|
|
27
|
+
*/
|
|
28
|
+
export const LOAD_MODE: "injected" | "embedded" = "injected";
|
|
@@ -1,15 +1,62 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The seam between this repo and the harness page runtime.
|
|
3
|
+
*
|
|
4
|
+
* Actions import helpers from `../utils` (or `./utils`), never from
|
|
5
|
+
* `@foldspace_npm/harness/runtime` directly. Swapping an implementation —
|
|
6
|
+
* custom auth headers, a different `apiFetch`, branded loading UI — is a
|
|
7
|
+
* change to this file and nothing else. `configure` runs once at module load
|
|
8
|
+
* so `apiFetch` / `getAgent` can read tenant facts without importing
|
|
9
|
+
* `constants.ts` from the published package.
|
|
10
|
+
*
|
|
11
|
+
* ## What `export *` gives you
|
|
12
|
+
*
|
|
13
|
+
* HTTP (customer API, from the signed-in page):
|
|
14
|
+
* - `apiFetch` — JSON + `Authorization: Bearer`. Stop re-exporting this if
|
|
15
|
+
* the live app uses cookies, custom headers, or no Bearer (see below).
|
|
16
|
+
* - `apiFetchBinary` — same auth, raw bytes (file download).
|
|
17
|
+
* - `publicFetch` — no token; marketing / public origin only.
|
|
18
|
+
* - `getAuthToken` / `parseJwt` / `redact` — session read and safe logging.
|
|
19
|
+
* - `mapWithConcurrency` — cap parallel fetches over a list.
|
|
20
|
+
*
|
|
21
|
+
* Foldspace SDK (thin wrappers; the SDK is the source of truth):
|
|
22
|
+
* - `getAgent` — overlay handle for `searchInList` and similar.
|
|
23
|
+
* - `rankBy` — substring first, then SDK fuzzy match. Not for domain ranking.
|
|
24
|
+
* - `armAllInstances` — **attach/bootstrap only**, never from `execute`.
|
|
25
|
+
* Local `foldspace attach` already arms test mode. Importing this from an
|
|
26
|
+
* action can put those SDK calls in the production `dist` bundle.
|
|
27
|
+
*
|
|
28
|
+
* Widget chrome (unstyled; replace in `agent/views/` when brand matters):
|
|
29
|
+
* - `renderLoading` / `renderEmpty` / `renderError` / `renderFatal`
|
|
30
|
+
*
|
|
31
|
+
* Config (already called above; you rarely need these in an action):
|
|
32
|
+
* - `configure` / `getConfig`
|
|
33
|
+
*
|
|
34
|
+
* Leave `API_BASE` and `AUTH_SOURCE` empty in `constants.ts` until you capture
|
|
35
|
+
* a real XHR. Empty config makes `apiFetch` return `{ ok: false }` instead of
|
|
36
|
+
* guessing a host. Errors are returned, never thrown; show `result.error` in
|
|
37
|
+
* the widget and log `result.detail` only.
|
|
38
|
+
*
|
|
39
|
+
* ## Override example (custom auth — do not guess Bearer)
|
|
40
|
+
*
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { configure, apiFetch as defaultApiFetch, ... } from "@foldspace_npm/harness/runtime";
|
|
43
|
+
* // After configure(...):
|
|
44
|
+
* export async function apiFetch<T>(path: string, init: RequestInit = {}) {
|
|
45
|
+
* // observe the live request first, then copy its headers here
|
|
46
|
+
* return defaultApiFetch<T>(path, { ...init, headers: { ... } });
|
|
47
|
+
* }
|
|
48
|
+
* export { getAgent, rankBy, renderEmpty, renderError, renderFatal, renderLoading };
|
|
49
|
+
* // Do not `export *` if that would re-export the default apiFetch.
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
2
52
|
|
|
3
|
-
|
|
53
|
+
import { configure } from "@foldspace_npm/harness/runtime";
|
|
54
|
+
import { AGENT_API_NAME, API_BASE, AUTH_SOURCE } from "./constants";
|
|
4
55
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
56
|
+
configure({
|
|
57
|
+
agentApiName: AGENT_API_NAME,
|
|
58
|
+
apiBase: API_BASE,
|
|
59
|
+
authSource: AUTH_SOURCE,
|
|
60
|
+
});
|
|
9
61
|
|
|
10
|
-
|
|
11
|
-
apiName: AGENT_API_NAME,
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
return agent;
|
|
15
|
-
}
|
|
62
|
+
export * from "@foldspace_npm/harness/runtime";
|