@foldspace_npm/harness 0.1.2 → 0.1.3

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.
@@ -0,0 +1,181 @@
1
+ // Browser-safe request/result contracts shared by local CDP and future
2
+ // extension adapters. This module describes evidence; it does not inspect a
3
+ // page or select a transport.
4
+ export const HARNESS_PROTOCOL_VERSION = 1;
5
+
6
+ export const CAPABILITIES = Object.freeze({
7
+ PAGE_EVALUATE: "page.evaluate",
8
+ PAGE_MUTATE: "page.mutate",
9
+ ARTIFACT_LOAD: "artifact.load",
10
+ CDP: "browser.cdp",
11
+ });
12
+
13
+ export const ERROR_CODES = Object.freeze({
14
+ INVALID_REQUEST: "INVALID_REQUEST",
15
+ CAPABILITY_DISABLED: "CAPABILITY_DISABLED",
16
+ SDK_NOT_FOUND: "SDK_NOT_FOUND",
17
+ AGENT_NOT_FOUND: "AGENT_NOT_FOUND",
18
+ REGISTRATION_MISMATCH: "REGISTRATION_MISMATCH",
19
+ DIAGNOSTIC_FAILED: "DIAGNOSTIC_FAILED",
20
+ TIMEOUT: "TIMEOUT",
21
+ EVIDENCE_LIMIT: "EVIDENCE_LIMIT",
22
+ TRANSPORT_ERROR: "TRANSPORT_ERROR",
23
+ });
24
+
25
+ export const LIFECYCLE_OPERATIONS = Object.freeze([
26
+ "prepare_page",
27
+ "load_artifact",
28
+ "inspect_registration",
29
+ "run_diagnostic",
30
+ "cleanup",
31
+ ]);
32
+
33
+ export const PROTOCOL_LIMITS = Object.freeze({
34
+ maxRequestBytes: 16 * 1024,
35
+ maxResultBytes: 64 * 1024,
36
+ maxIdentifierLength: 128,
37
+ });
38
+
39
+ const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
40
+
41
+ function isRecord(value) {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+
45
+ function jsonByteLength(value, label) {
46
+ let json;
47
+ try {
48
+ json = JSON.stringify(value);
49
+ } catch {
50
+ throw new TypeError(`${label} must be JSON serializable`);
51
+ }
52
+ if (json === undefined) {
53
+ throw new TypeError(`${label} must be JSON serializable`);
54
+ }
55
+ return new TextEncoder().encode(json).length;
56
+ }
57
+
58
+ function assertIdentifier(value, label) {
59
+ if (
60
+ typeof value !== "string" ||
61
+ value.length === 0 ||
62
+ value.length > PROTOCOL_LIMITS.maxIdentifierLength ||
63
+ !identifierPattern.test(value)
64
+ ) {
65
+ throw new TypeError(
66
+ `${label} must be a non-empty identifier of at most ${PROTOCOL_LIMITS.maxIdentifierLength} characters`,
67
+ );
68
+ }
69
+ }
70
+
71
+ export function createDiagnosticRequest({
72
+ id,
73
+ name,
74
+ args = {},
75
+ timeoutMs = 10_000,
76
+ }) {
77
+ assertIdentifier(id, "Diagnostic request id");
78
+ assertIdentifier(name, "Diagnostic name");
79
+ if (!isRecord(args)) {
80
+ throw new TypeError("Diagnostic args must be an object");
81
+ }
82
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) {
83
+ throw new TypeError("Diagnostic timeoutMs must be an integer between 1 and 60000");
84
+ }
85
+
86
+ const request = {
87
+ protocolVersion: HARNESS_PROTOCOL_VERSION,
88
+ id,
89
+ name,
90
+ args,
91
+ timeoutMs,
92
+ };
93
+ if (jsonByteLength(request, "Diagnostic request") > PROTOCOL_LIMITS.maxRequestBytes) {
94
+ throw new RangeError("Diagnostic request exceeds the protocol size limit");
95
+ }
96
+ return request;
97
+ }
98
+
99
+ export function diagnosticSuccess(request, result, metadata = {}) {
100
+ if (!isRecord(metadata)) {
101
+ throw new TypeError("Diagnostic metadata must be an object");
102
+ }
103
+ const response = {
104
+ protocolVersion: HARNESS_PROTOCOL_VERSION,
105
+ id: request.id,
106
+ name: request.name,
107
+ ok: true,
108
+ result,
109
+ metadata,
110
+ };
111
+ if (jsonByteLength(response, "Diagnostic result") > PROTOCOL_LIMITS.maxResultBytes) {
112
+ return diagnosticFailure(request, {
113
+ code: ERROR_CODES.EVIDENCE_LIMIT,
114
+ message: "Diagnostic result exceeded the protocol size limit",
115
+ });
116
+ }
117
+ return response;
118
+ }
119
+
120
+ export function diagnosticFailure(request, error) {
121
+ const code = Object.values(ERROR_CODES).includes(error?.code)
122
+ ? error.code
123
+ : ERROR_CODES.DIAGNOSTIC_FAILED;
124
+ const message =
125
+ typeof error?.message === "string" && error.message.trim()
126
+ ? error.message.slice(0, 2_000)
127
+ : "Diagnostic failed";
128
+
129
+ return {
130
+ protocolVersion: HARNESS_PROTOCOL_VERSION,
131
+ id: request.id,
132
+ name: request.name,
133
+ ok: false,
134
+ error: { code, message },
135
+ };
136
+ }
137
+
138
+ export function createLifecycleResult({
139
+ operation,
140
+ ok,
141
+ state,
142
+ error,
143
+ details = {},
144
+ }) {
145
+ if (!LIFECYCLE_OPERATIONS.includes(operation)) {
146
+ throw new TypeError(`Unknown lifecycle operation: ${operation}`);
147
+ }
148
+ if (typeof ok !== "boolean") {
149
+ throw new TypeError("Lifecycle ok must be a boolean");
150
+ }
151
+ assertIdentifier(state, "Lifecycle state");
152
+ if (!isRecord(details)) {
153
+ throw new TypeError("Lifecycle details must be an object");
154
+ }
155
+
156
+ const result = {
157
+ protocolVersion: HARNESS_PROTOCOL_VERSION,
158
+ operation,
159
+ ok,
160
+ state,
161
+ details,
162
+ ...(ok
163
+ ? {}
164
+ : {
165
+ error: {
166
+ code: Object.values(ERROR_CODES).includes(error?.code)
167
+ ? error.code
168
+ : ERROR_CODES.TRANSPORT_ERROR,
169
+ message:
170
+ typeof error?.message === "string" && error.message.trim()
171
+ ? error.message.slice(0, 2_000)
172
+ : "Lifecycle operation failed",
173
+ },
174
+ }),
175
+ };
176
+
177
+ if (jsonByteLength(result, "Lifecycle result") > PROTOCOL_LIMITS.maxResultBytes) {
178
+ throw new RangeError("Lifecycle result exceeds the protocol size limit");
179
+ }
180
+ return result;
181
+ }
@@ -0,0 +1,133 @@
1
+ import { ACTION_LOG_PREFIX } from "./action-observer.mjs";
2
+
3
+ function normalizeConsoleText(value) {
4
+ return String(value ?? "")
5
+ .replace(/\b[0-9a-f]{8}-[0-9a-f-]{27,}\b/gi, "<id>")
6
+ .replace(/\b(?:bearer\s+)?[A-Za-z0-9_-]{40,}\b/gi, "<redacted>")
7
+ .replace(/([?&](?:token|key|secret|code)=)[^&\s]+/gi, "$1<redacted>")
8
+ .replace(/\s+/g, " ")
9
+ .trim()
10
+ .slice(0, 1_000);
11
+ }
12
+
13
+ export function parseActionObservation(text) {
14
+ const value = String(text ?? "");
15
+ if (!value.startsWith(`${ACTION_LOG_PREFIX} `)) return null;
16
+ try {
17
+ const parsed = JSON.parse(value.slice(ACTION_LOG_PREFIX.length + 1));
18
+ return parsed && typeof parsed === "object" ? parsed : null;
19
+ } catch {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ export function formatActionObservation(event) {
25
+ const action = event.actionName ? ` ${event.actionName}` : "";
26
+ const duration = Number.isFinite(event.durationMs)
27
+ ? ` ${event.durationMs}ms`
28
+ : "";
29
+ const keys =
30
+ Array.isArray(event.parameterKeys) && event.parameterKeys.length
31
+ ? ` keys=[${event.parameterKeys.join(",")}]`
32
+ : "";
33
+ if (event.phase === "registration") {
34
+ const count = Array.isArray(event.actionNames)
35
+ ? event.actionNames.length
36
+ : 0;
37
+ return `[actions] local artifact ${event.status} (${count} action${count === 1 ? "" : "s"})`;
38
+ }
39
+ return `[actions] ${event.source}:${event.phase}${action} ${event.status}${duration}${keys}`;
40
+ }
41
+
42
+ export function createSessionCollector({
43
+ maxErrorGroups = 50,
44
+ maxActionEvents = 200,
45
+ } = {}) {
46
+ const errorGroups = new Map();
47
+ const actionEvents = [];
48
+ let droppedErrorGroups = 0;
49
+ let droppedActionEvents = 0;
50
+
51
+ function recordConsole({ level = "log", text = "" } = {}) {
52
+ const action = parseActionObservation(text);
53
+ if (action) {
54
+ if (actionEvents.length >= maxActionEvents) {
55
+ actionEvents.shift();
56
+ droppedActionEvents += 1;
57
+ }
58
+ actionEvents.push(action);
59
+ return action;
60
+ }
61
+
62
+ const normalized = normalizeConsoleText(text);
63
+ const normalizedLevel = String(level).toLowerCase();
64
+ const isFailure =
65
+ normalizedLevel === "error" ||
66
+ normalizedLevel === "warning" ||
67
+ normalizedLevel === "warn" ||
68
+ /\b(?:error|failed|failure|status\s+[45]\d\d)\b/i.test(normalized);
69
+ if (!normalized || !isFailure) return null;
70
+
71
+ const key = `${normalizedLevel}:${normalized}`;
72
+ const existing = errorGroups.get(key);
73
+ if (existing) {
74
+ existing.count += 1;
75
+ } else if (errorGroups.size < maxErrorGroups) {
76
+ errorGroups.set(key, {
77
+ level: normalizedLevel,
78
+ text: normalized,
79
+ count: 1,
80
+ });
81
+ } else {
82
+ droppedErrorGroups += 1;
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function snapshot() {
88
+ return {
89
+ actionEvents: actionEvents.map((event) => ({ ...event })),
90
+ errorGroups: Array.from(errorGroups.values()).map((group) => ({
91
+ ...group,
92
+ })),
93
+ droppedActionEvents,
94
+ droppedErrorGroups,
95
+ };
96
+ }
97
+
98
+ return { recordConsole, snapshot };
99
+ }
100
+
101
+ export function formatSessionSummary(snapshot) {
102
+ const lines = [];
103
+ if (snapshot.actionEvents.length) {
104
+ lines.push(
105
+ `Action observations (${snapshot.actionEvents.length}${
106
+ snapshot.droppedActionEvents
107
+ ? `, ${snapshot.droppedActionEvents} older dropped`
108
+ : ""
109
+ }):`,
110
+ );
111
+ for (const event of snapshot.actionEvents.slice(-20)) {
112
+ lines.push(` ${formatActionObservation(event)}`);
113
+ }
114
+ }
115
+ if (snapshot.errorGroups.length) {
116
+ lines.push(
117
+ `Page failures (${snapshot.errorGroups.length} group${
118
+ snapshot.errorGroups.length === 1 ? "" : "s"
119
+ }):`,
120
+ );
121
+ for (const group of snapshot.errorGroups) {
122
+ lines.push(
123
+ ` [${group.level} x${group.count}] ${group.text}`,
124
+ );
125
+ }
126
+ if (snapshot.droppedErrorGroups) {
127
+ lines.push(
128
+ ` ${snapshot.droppedErrorGroups} additional failure group(s) omitted`,
129
+ );
130
+ }
131
+ }
132
+ return lines.join("\n");
133
+ }
@@ -8,10 +8,13 @@ changes there.
8
8
 
9
9
  ## Before building
10
10
 
11
- 1. Connect Product MCP and verify that `list_agents` works.
12
- 2. Inspect the selected agent with `get_agent_settings`, `list_actions`, and
11
+ 1. Run `npx foldspace help --json` before an unfamiliar harness operation.
12
+ Treat its risk, prerequisites, effects, and next-step fields as the current
13
+ CLI contract.
14
+ 2. Connect Product MCP and verify that `list_agents` works.
15
+ 3. Inspect the selected agent with `get_agent_settings`, `list_actions`, and
13
16
  `list_task_agents`.
14
- 3. Use `discover_actions` to identify candidate experiences, then let the user
17
+ 4. Use `discover_actions` to identify candidate experiences, then let the user
15
18
  choose what to build.
16
19
 
17
20
  ## Build workflow
@@ -51,12 +54,18 @@ npm run attach
51
54
  ```
52
55
 
53
56
  `inject` launches an isolated Chrome profile and records its debug port.
54
- `attach` loads the SDK and the local `dist/index.js` bundle. Use
55
- `npm run attach -- --bootstrap` when the target page does not already embed the
56
- Foldspace SDK.
57
+ It does not generate or load an application extension. `attach` prepares the
58
+ page and loads the local `dist/index.js` bundle through CDP.
57
59
 
58
- The attach log must report `actions attached: N` before treating the page as
59
- running local action code.
60
+ Use the default swap only when the page already has the configured product and
61
+ agent. Use `--bootstrap` only when the page has no Foldspace SDK, and
62
+ `--replace` when it embeds a different product or agent, or when an SDK is
63
+ present without the configured agent.
64
+
65
+ The attach log must report `inspect_registration:registration_ok` before
66
+ treating the page as registered. Prove the experience through the visible agent
67
+ and require both an SDK action callback and local execute/render evidence; do
68
+ not invoke the handler directly.
60
69
 
61
70
  ## Verification gates
62
71
 
@@ -76,9 +85,23 @@ Do not report success without all six:
76
85
  - `agent/constants.ts` — agent, product, and domain identifiers
77
86
  - `agent/utils.ts` — Foldspace agent lookup
78
87
  - `foldspace.dev.json` — local harness target configuration
88
+ - `docs/` — optional coding-agent learnings; create on demand
79
89
 
80
90
  Do not introduce another bundler or bundle format.
81
91
 
92
+ ## Agent learnings
93
+
94
+ Use `docs/` to record durable, repository-specific lessons so later sessions do
95
+ not repeat the same mistakes.
96
+
97
+ - Before similar work, read any relevant notes already in `docs/`.
98
+ - After non-obvious discoveries, add a short note covering verified gotchas,
99
+ failed approaches, design rationale, or useful verification commands.
100
+ - Keep notes concise and evidence-based. Create `docs/` when the first note is
101
+ useful; do not leave an empty directory.
102
+ - Do not store secrets, cookies, HAR files, browser storage, or other transient
103
+ session data in `docs/`.
104
+
82
105
  ## Safety
83
106
 
84
107
  - Do not commit secrets, cookies, HAR files, browser storage, or
@@ -26,22 +26,45 @@ agent/
26
26
  api/ one file per HTTP helper
27
27
  constants.ts product, agent, and domain identifiers
28
28
  utils.ts Foldspace agent lookup
29
+ docs/ optional notes for coding-agent learnings (create when needed)
29
30
  foldspace.dev.json
30
31
  CLAUDE.md
31
32
  ```
32
33
 
34
+ ## Agent learnings
35
+
36
+ Coding agents may keep durable, repository-specific lessons under `docs/` so
37
+ later sessions do not repeat the same mistakes. Create the folder when the first
38
+ note is useful; do not commit an empty `docs/` directory.
39
+
40
+ Good notes cover verified gotchas, failed approaches, design rationale, and
41
+ useful verification commands. Keep them concise and evidence-based. Before
42
+ similar work, read any relevant notes in `docs/`. Do not store secrets, cookies,
43
+ HAR files, browser storage, or other transient session data there.
44
+
33
45
  ## Commands
34
46
 
35
47
  ```bash
48
+ npx foldspace help # discover commands, risks, and next steps
36
49
  npm run build # create dist/index.js
37
50
  npm run dev # rebuild dist/index.js on changes
38
51
  npm run inject # launch the dedicated Chrome profile
39
- npm run attach # load the SDK and local actions over CDP
52
+ npm run attach # load local actions and observe the agent over CDP
40
53
  ```
41
54
 
42
55
  Run `inject` before `attach`. Sign in to the product in the Chrome window that
43
- `inject` opens. For a product that does not already embed the Foldspace SDK, run
44
- `npm run attach -- --bootstrap`.
56
+ `inject` opens. `inject` does not generate an application extension; `attach`
57
+ loads `dist/index.js` directly through CDP.
58
+
59
+ Choose the attach mode from the state of the target page:
60
+
61
+ - Default swap: the page already has the configured product and agent.
62
+ - `npm run attach -- --bootstrap`: the page has no Foldspace SDK.
63
+ - `npm run attach -- --replace`: the page has a different product or agent, or
64
+ an SDK without the configured agent.
65
+
66
+ Run `npx foldspace help attach` for full requirements and safety options. Coding
67
+ agents can read the versioned contract with `npx foldspace help --json`.
45
68
 
46
69
  ## Add an action
47
70
 
@@ -2,7 +2,6 @@
2
2
  "$comment": "Generated by foldspace init. Use the bare product ID, not the EU-… SDK key.",
3
3
  "defaultTarget": "app",
4
4
  "sdkUrl": "https://script.eucerahive.io/web/sdk/foldspace.js",
5
- "localActionsUrl": "http://localhost:3007/dist/index.js",
6
5
  "targets": {
7
6
  "app": {
8
7
  "name": "App",
@@ -10,8 +9,7 @@
10
9
  "productId": {{PRODUCT_ID_JSON}},
11
10
  "agentApiName": {{AGENT_API_NAME_JSON}},
12
11
  "hosts": {{HOSTS_JSON}},
13
- "mode": "OVERLAY",
14
- "loadLocally": true
12
+ "mode": "OVERLAY"
15
13
  }
16
14
  }
17
15
  }
@@ -1,90 +0,0 @@
1
- import { execFileSync } from "child_process";
2
- import fs from "fs";
3
- import path from "path";
4
- import { fileURLToPath } from "url";
5
-
6
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
- const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
8
- const indexPath = path.join(projectDir, "extension", "index.js");
9
-
10
- const LOAD_LOCALLY_TRUE = "const LOAD_LOCALLY = true;";
11
- const LOAD_LOCALLY_FALSE = "const LOAD_LOCALLY = false;";
12
- const REMOTE_ENV_DEV = 'const REMOTE_ACTIONS_ENV = "DEV";';
13
- const REMOTE_ENV_PROD = 'const REMOTE_ACTIONS_ENV = "PROD";';
14
-
15
- function parseArgs(argv) {
16
- for (let i = 0; i < argv.length; i++) {
17
- if (argv[i] === "--env" && argv[i + 1]) return argv[++i];
18
- }
19
- return null;
20
- }
21
-
22
- const env = parseArgs(process.argv.slice(2));
23
- if (!env || (env !== "dev" && env !== "prod")) {
24
- console.error('buildExtension: --env <dev|prod> is required');
25
- process.exit(1);
26
- }
27
-
28
- function replaceInIndex(from, to, label) {
29
- const source = fs.readFileSync(indexPath, "utf8");
30
-
31
- if (!source.includes(from)) {
32
- if (source.includes(to)) {
33
- console.log(`buildExtension: ${label} already set`);
34
- return;
35
- }
36
- console.error(
37
- `buildExtension: expected ${label} pattern not found in index.js`,
38
- );
39
- process.exit(1);
40
- }
41
-
42
- fs.writeFileSync(indexPath, source.replace(from, to), "utf8");
43
- console.log(`buildExtension: ${label} → ${to}`);
44
- }
45
-
46
- function setLoadLocally(value) {
47
- if (!fs.existsSync(indexPath)) {
48
- console.error("buildExtension: extension/index.js not found");
49
- process.exit(1);
50
- }
51
- const from = value ? LOAD_LOCALLY_FALSE : LOAD_LOCALLY_TRUE;
52
- const to = value ? LOAD_LOCALLY_TRUE : LOAD_LOCALLY_FALSE;
53
- replaceInIndex(from, to, "LOAD_LOCALLY");
54
- }
55
-
56
- function setRemoteActionsEnv(targetEnv) {
57
- const from = targetEnv === "dev" ? REMOTE_ENV_PROD : REMOTE_ENV_DEV;
58
- const to = targetEnv === "dev" ? REMOTE_ENV_DEV : REMOTE_ENV_PROD;
59
- replaceInIndex(from, to, "REMOTE_ACTIONS_ENV");
60
- }
61
-
62
- let failed = false;
63
-
64
- try {
65
- execFileSync("tsx", ["scripts/build.ts"], {
66
- cwd: projectDir,
67
- stdio: "inherit",
68
- });
69
-
70
- setLoadLocally(false);
71
- setRemoteActionsEnv(env);
72
-
73
- execFileSync("node", ["scripts/packageExtension.mjs"], {
74
- cwd: projectDir,
75
- stdio: "inherit",
76
- });
77
- } catch {
78
- failed = true;
79
- } finally {
80
- try {
81
- setLoadLocally(true);
82
- setRemoteActionsEnv("prod");
83
- } catch {
84
- failed = true;
85
- }
86
- }
87
-
88
- if (failed) {
89
- process.exit(1);
90
- }
@@ -1,29 +0,0 @@
1
- import { execFileSync } from "child_process";
2
- import fs from "fs";
3
- import path from "path";
4
- import { fileURLToPath } from "url";
5
-
6
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
- const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
8
- const projectName = path.basename(projectDir);
9
- const extensionDir = path.join(projectDir, "extension");
10
- const zipPath = path.join(projectDir, `${projectName}-extension.zip`);
11
-
12
- if (!fs.existsSync(extensionDir)) {
13
- console.error("packageExtension: extension/ directory not found");
14
- process.exit(1);
15
- }
16
-
17
- try {
18
- if (fs.existsSync(zipPath)) {
19
- fs.rmSync(zipPath);
20
- }
21
- execFileSync("zip", ["-r", zipPath, "."], {
22
- cwd: extensionDir,
23
- stdio: "inherit",
24
- });
25
- console.log(`packageExtension: created ${zipPath}`);
26
- } catch {
27
- console.error("packageExtension: failed to create extension.zip");
28
- process.exit(1);
29
- }