@panphora/clay-agent 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +81 -2
- package/bin/clay-agent.js +9 -0
- package/package.json +50 -4
- package/src/actions.js +288 -0
- package/src/cli.js +521 -0
- package/src/credentials.js +137 -0
- package/src/discovery.js +76 -0
- package/src/errors.js +90 -0
- package/src/hosts/hyperclay-adapter.js +85 -0
- package/src/hosts/hyperclay.js +210 -0
- package/src/hosts/whoami.js +56 -0
- package/src/html/edit.js +103 -0
- package/src/html/find.js +157 -0
- package/src/html/outline.js +154 -0
- package/src/html/regex-worker.js +17 -0
- package/src/html/select.js +54 -0
- package/src/html/source.js +73 -0
- package/src/mcp.js +296 -0
- package/src/runtime.js +46 -0
- package/src/session.js +69 -0
- package/src/target.js +209 -0
package/src/discovery.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { homeDir } from './credentials.js';
|
|
5
|
+
|
|
6
|
+
export function userConfigDir(env = process.env, platform = process.platform) {
|
|
7
|
+
if (platform === 'darwin') {
|
|
8
|
+
return path.join(homeDir(env), 'Library', 'Application Support');
|
|
9
|
+
}
|
|
10
|
+
if (platform === 'win32') {
|
|
11
|
+
return env.APPDATA || path.join(homeDir(env), 'AppData', 'Roaming');
|
|
12
|
+
}
|
|
13
|
+
return env.XDG_CONFIG_HOME || path.join(homeDir(env), '.config');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isPositiveInteger(value) {
|
|
17
|
+
return Number.isInteger(value) && value > 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function readJson(file) {
|
|
21
|
+
let text;
|
|
22
|
+
try {
|
|
23
|
+
text = await fs.readFile(file, 'utf8');
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(text);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isObject(value) {
|
|
35
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function htmlClay(env = process.env, platform = process.platform) {
|
|
39
|
+
const file = path.join(userConfigDir(env, platform), 'htmlclay', 'config.json');
|
|
40
|
+
const data = await readJson(file);
|
|
41
|
+
if (!isObject(data) || !isPositiveInteger(data.port)) return null;
|
|
42
|
+
return { port: data.port };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function defaultIsAlive(pid) {
|
|
46
|
+
try {
|
|
47
|
+
process.kill(pid, 0);
|
|
48
|
+
return true;
|
|
49
|
+
} catch (error) {
|
|
50
|
+
return error.code === 'EPERM';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function hyperclayLocal(env = process.env, platform = process.platform, { isAlive = defaultIsAlive } = {}) {
|
|
55
|
+
const file = path.join(userConfigDir(env, platform), 'Hyperclay Local', 'served-roots.json');
|
|
56
|
+
const data = await readJson(file);
|
|
57
|
+
if (!isObject(data)) return null;
|
|
58
|
+
if (data.v !== 1) return null;
|
|
59
|
+
if (typeof data.pid !== 'number') return null;
|
|
60
|
+
if (!Array.isArray(data.roots)) return null;
|
|
61
|
+
if (!isAlive(data.pid)) return null;
|
|
62
|
+
|
|
63
|
+
const roots = [];
|
|
64
|
+
for (const root of data.roots) {
|
|
65
|
+
if (!isObject(root)) continue;
|
|
66
|
+
if (typeof root.path !== 'string' || !path.isAbsolute(root.path)) continue;
|
|
67
|
+
if (!isPositiveInteger(root.port)) continue;
|
|
68
|
+
roots.push({ path: root.path, port: root.port });
|
|
69
|
+
}
|
|
70
|
+
return { roots };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function discover(env = process.env, platform = process.platform, options = {}) {
|
|
74
|
+
const [clay, local] = await Promise.all([htmlClay(env, platform), hyperclayLocal(env, platform, options)]);
|
|
75
|
+
return { htmlClay: clay, hyperclayLocal: local };
|
|
76
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// One error type for both front doors. The MCP server turns it into `isError` with
|
|
2
|
+
// `{ error: { code, message, hint } }`; the CLI prints it and exits with `exitCode(code)`.
|
|
3
|
+
|
|
4
|
+
export class ClayError extends Error {
|
|
5
|
+
constructor(code, message, { hint = null, status = null, etag = null, retryable = false, details = null } = {}) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'ClayError';
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.hint = hint;
|
|
10
|
+
this.status = status;
|
|
11
|
+
this.etag = etag;
|
|
12
|
+
this.retryable = retryable;
|
|
13
|
+
this.details = details;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
toJSON() {
|
|
17
|
+
const error = { code: this.code, message: this.message };
|
|
18
|
+
if (this.hint) error.hint = this.hint;
|
|
19
|
+
if (this.status) error.status = this.status;
|
|
20
|
+
if (this.etag) error.etag = this.etag;
|
|
21
|
+
if (this.retryable) error.retryable = true;
|
|
22
|
+
if (this.details) error.details = this.details;
|
|
23
|
+
return redact({ error });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const KEY_PATTERN = /hcak_[0-9a-f]{64}/gi;
|
|
28
|
+
|
|
29
|
+
function redactString(text) {
|
|
30
|
+
return text.replace(KEY_PATTERN, (key) => `${key.slice(0, 13)}\u2026`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A key never reaches output: keep the prefix the dashboard shows, drop the rest.
|
|
34
|
+
export function redact(value) {
|
|
35
|
+
if (typeof value === 'string') return redactString(value);
|
|
36
|
+
if (Array.isArray(value)) return value.map(redact);
|
|
37
|
+
if (value && typeof value === 'object') {
|
|
38
|
+
const result = {};
|
|
39
|
+
for (const [name, item] of Object.entries(value)) {
|
|
40
|
+
result[redactString(name)] = redact(item);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 0 done, 1 refused or failed, 2 conflict, 3 host unreachable,
|
|
48
|
+
// 4 not signed in or bad key, 5 outcome unknown.
|
|
49
|
+
const EXIT = {
|
|
50
|
+
conflict: 2,
|
|
51
|
+
'host-unavailable': 3,
|
|
52
|
+
unauthorized: 4,
|
|
53
|
+
'key-missing': 4,
|
|
54
|
+
'key-revoked': 4,
|
|
55
|
+
'key-expired': 4,
|
|
56
|
+
'invalid-key': 4,
|
|
57
|
+
'outcome-unknown': 5,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function exitCode(code) {
|
|
61
|
+
return EXIT[code] ?? 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const NETWORK_CODES = new Set([
|
|
65
|
+
'ECONNREFUSED',
|
|
66
|
+
'ECONNRESET',
|
|
67
|
+
'ENOTFOUND',
|
|
68
|
+
'EAI_AGAIN',
|
|
69
|
+
'ENETUNREACH',
|
|
70
|
+
'ETIMEDOUT',
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
function isNetworkError(error) {
|
|
74
|
+
for (const code of [error?.code, error?.cause?.code]) {
|
|
75
|
+
if (typeof code !== 'string') continue;
|
|
76
|
+
if (NETWORK_CODES.has(code) || code.startsWith('UND_ERR_')) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function asClayError(error) {
|
|
82
|
+
if (error instanceof ClayError) return error;
|
|
83
|
+
if (isNetworkError(error)) {
|
|
84
|
+
return new ClayError('host-unavailable', 'Could not reach the host.', {
|
|
85
|
+
hint: 'Check your internet connection, or open the desktop app if this is a local document, then try again.',
|
|
86
|
+
retryable: true,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
return new ClayError('internal', 'Something went wrong inside clay-agent.');
|
|
90
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { ClayError } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
const PLAIN_USERNAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
4
|
+
|
|
5
|
+
function stringId(value) {
|
|
6
|
+
return value === undefined || value === null ? null : String(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function sameUrl(left, right) {
|
|
10
|
+
const normalize = (url) => String(url ?? '').replace(/\/$/, '').replace(/\.html$/, '');
|
|
11
|
+
return normalize(left) === normalize(right);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function hyperclayAdapter(client) {
|
|
15
|
+
const byUrl = new Map();
|
|
16
|
+
|
|
17
|
+
async function locate(target) {
|
|
18
|
+
if (target.url) {
|
|
19
|
+
let found = byUrl.get(target.url);
|
|
20
|
+
if (!found) {
|
|
21
|
+
const listed = await client.list({ url: target.url });
|
|
22
|
+
const match = (listed?.documents ?? []).find((doc) => sameUrl(doc.url, target.url));
|
|
23
|
+
if (!match) {
|
|
24
|
+
throw new ClayError('not-found', 'No document at that URL that this key can reach.');
|
|
25
|
+
}
|
|
26
|
+
found = { id: match.id, ref: `hyperclay:${match.id}` };
|
|
27
|
+
byUrl.set(target.url, found);
|
|
28
|
+
}
|
|
29
|
+
return { id: found.id, ref: found.ref };
|
|
30
|
+
}
|
|
31
|
+
return { id: target.id, ref: target.ref };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function list({ where, cursor } = {}) {
|
|
35
|
+
const query = { cursor };
|
|
36
|
+
if (where !== undefined && where !== null && where !== '') {
|
|
37
|
+
if (String(where).includes('hcak_')) {
|
|
38
|
+
throw new ClayError('invalid-argument', 'Do not put an agent key in a document search.');
|
|
39
|
+
}
|
|
40
|
+
if (PLAIN_USERNAME.test(where)) query.account = where;
|
|
41
|
+
else query.url = where;
|
|
42
|
+
}
|
|
43
|
+
const result = await client.list(query);
|
|
44
|
+
const documents = (result?.documents ?? []).map((doc) => ({
|
|
45
|
+
ref: `hyperclay:${doc.id}`,
|
|
46
|
+
host: 'hyperclay',
|
|
47
|
+
name: doc.name,
|
|
48
|
+
url: doc.url,
|
|
49
|
+
path: doc.path,
|
|
50
|
+
updatedAt: doc.updatedAt,
|
|
51
|
+
}));
|
|
52
|
+
return { documents, nextCursor: stringId(result?.nextCursor) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
locate,
|
|
57
|
+
list,
|
|
58
|
+
read(id, { ifNoneMatch } = {}) {
|
|
59
|
+
return client.read(id, { ifNoneMatch });
|
|
60
|
+
},
|
|
61
|
+
readData(id) {
|
|
62
|
+
return client.readData(id);
|
|
63
|
+
},
|
|
64
|
+
writeData(id, { data, etag } = {}) {
|
|
65
|
+
return client.writeData(id, { data, etag });
|
|
66
|
+
},
|
|
67
|
+
save(id, { html, etag } = {}) {
|
|
68
|
+
return client.save(id, { html, etag });
|
|
69
|
+
},
|
|
70
|
+
async versions(id, { before } = {}) {
|
|
71
|
+
const result = await client.versions(id, { before });
|
|
72
|
+
return {
|
|
73
|
+
...result,
|
|
74
|
+
versions: (result?.versions ?? []).map((version) => ({ ...version, id: stringId(version?.id) })),
|
|
75
|
+
nextBefore: stringId(result?.nextBefore),
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
version(id, versionId) {
|
|
79
|
+
return client.version(id, versionId);
|
|
80
|
+
},
|
|
81
|
+
restore(id, versionId, { etag } = {}) {
|
|
82
|
+
return client.restore(id, versionId, { etag });
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { ClayError, asClayError, redact } from '../errors.js';
|
|
4
|
+
|
|
5
|
+
const KEY_HINT =
|
|
6
|
+
'Make an agent key in the dashboard (Account, then Agent keys), then run: npx -y @panphora/clay-agent login';
|
|
7
|
+
|
|
8
|
+
const HINTS = {
|
|
9
|
+
conflict: 'The document changed after you read it. Read it again and redo the change.',
|
|
10
|
+
'precondition-required': 'Read the document first, then send its etag.',
|
|
11
|
+
'read-first': 'Read the document first, then send its etag.',
|
|
12
|
+
unauthorized: KEY_HINT,
|
|
13
|
+
'key-revoked': KEY_HINT,
|
|
14
|
+
'key-expired': KEY_HINT,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function hyperclayClient({ base, getKey, fetchImpl = fetch, timeoutMs = 30000 } = {}) {
|
|
18
|
+
const root = `${String(base ?? '').replace(/\/+$/, '')}/_/agent/v1`;
|
|
19
|
+
|
|
20
|
+
function requireEtag(method, etag) {
|
|
21
|
+
if (!etag) {
|
|
22
|
+
throw new ClayError('read-first', `${method} needs the etag from a read.`, {
|
|
23
|
+
hint: HINTS['read-first'],
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return etag;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function lostOutcome() {
|
|
30
|
+
return new ClayError('outcome-unknown', 'The change may or may not have been saved.', {
|
|
31
|
+
hint: 'Read the document again before trying anything else.',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isTimeout(error) {
|
|
36
|
+
return error?.name === 'TimeoutError' || error?.cause?.name === 'TimeoutError';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function tookTooLong() {
|
|
40
|
+
return new ClayError('host-unavailable', 'The host did not answer in time.', {
|
|
41
|
+
hint: 'Check your connection, then try again.',
|
|
42
|
+
retryable: true,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function notSent(error) {
|
|
47
|
+
const code = error?.code;
|
|
48
|
+
return code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ENETUNREACH';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function request(pathname, { method = 'GET', query = null, body, etag, ifNoneMatch } = {}) {
|
|
52
|
+
const key = getKey ? await getKey() : null;
|
|
53
|
+
if (!key) {
|
|
54
|
+
throw new ClayError('key-missing', 'Not signed in to hyperclay.com.', { hint: KEY_HINT });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const write = method !== 'GET';
|
|
58
|
+
const url = new URL(`${root}${pathname}`);
|
|
59
|
+
|
|
60
|
+
if (query) {
|
|
61
|
+
for (const [name, value] of Object.entries(query)) {
|
|
62
|
+
if (value === undefined || value === null) continue;
|
|
63
|
+
url.searchParams.set(name, String(value));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const headers = {
|
|
68
|
+
Authorization: `Bearer ${key}`,
|
|
69
|
+
Accept: 'application/json',
|
|
70
|
+
};
|
|
71
|
+
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
|
72
|
+
if (etag) headers['If-Match'] = etag;
|
|
73
|
+
if (ifNoneMatch) headers['If-None-Match'] = ifNoneMatch;
|
|
74
|
+
|
|
75
|
+
let response;
|
|
76
|
+
try {
|
|
77
|
+
response = await fetchImpl(url, {
|
|
78
|
+
method,
|
|
79
|
+
headers,
|
|
80
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
81
|
+
redirect: 'error',
|
|
82
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
83
|
+
});
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (isTimeout(error)) {
|
|
86
|
+
if (write) throw lostOutcome();
|
|
87
|
+
throw tookTooLong();
|
|
88
|
+
}
|
|
89
|
+
const cause = error?.cause ?? error;
|
|
90
|
+
if (write && !notSent(cause)) throw lostOutcome();
|
|
91
|
+
throw asClayError(cause);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let text;
|
|
95
|
+
try {
|
|
96
|
+
text = await response.text();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (write) throw lostOutcome();
|
|
99
|
+
if (isTimeout(error)) throw tookTooLong();
|
|
100
|
+
throw asClayError(error?.cause ?? error);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let parsed = null;
|
|
104
|
+
if (text) {
|
|
105
|
+
try {
|
|
106
|
+
parsed = JSON.parse(text);
|
|
107
|
+
} catch {
|
|
108
|
+
parsed = null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const detail = parsed?.error;
|
|
113
|
+
|
|
114
|
+
if (response.status === 304) {
|
|
115
|
+
return { notModified: true, etag: ifNoneMatch ?? null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (response.ok) {
|
|
119
|
+
if (parsed === null) {
|
|
120
|
+
if (write) throw lostOutcome();
|
|
121
|
+
throw new ClayError('host-error', redact(`hyperclay.com answered ${response.status}.`), {
|
|
122
|
+
status: response.status,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
if (write && typeof parsed.etag !== 'string') throw lostOutcome();
|
|
126
|
+
return parsed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (detail?.code) {
|
|
130
|
+
const details = {};
|
|
131
|
+
for (const [name, value] of Object.entries(detail)) {
|
|
132
|
+
if (name === 'code' || name === 'message' || name === 'etag') continue;
|
|
133
|
+
details[name] = value;
|
|
134
|
+
}
|
|
135
|
+
const retryable = response.status === 429 || response.status === 503;
|
|
136
|
+
if (retryable) {
|
|
137
|
+
const retryAfter = response.headers.get('retry-after');
|
|
138
|
+
const seconds = Number(retryAfter);
|
|
139
|
+
if (retryAfter !== null && Number.isFinite(seconds)) details.retryAfter = seconds;
|
|
140
|
+
}
|
|
141
|
+
throw new ClayError(detail.code, redact(detail.message || 'hyperclay.com refused this request.'), {
|
|
142
|
+
hint: HINTS[detail.code] ?? null,
|
|
143
|
+
status: response.status,
|
|
144
|
+
etag: detail.etag ?? null,
|
|
145
|
+
retryable,
|
|
146
|
+
details: Object.keys(details).length ? details : null,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (write && response.status >= 500) throw lostOutcome();
|
|
151
|
+
|
|
152
|
+
if (response.status === 404) {
|
|
153
|
+
throw new ClayError('not-found', 'This host does not have agent keys turned on.', {
|
|
154
|
+
status: response.status,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
throw new ClayError('host-error', redact(`hyperclay.com answered ${response.status}.`), {
|
|
159
|
+
status: response.status,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
list({ account, url, cursor, limit } = {}) {
|
|
165
|
+
return request('/documents', { query: { account, url, cursor, limit } });
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
read(id, { ifNoneMatch } = {}) {
|
|
169
|
+
return request(`/documents/${encodeURIComponent(id)}`, { ifNoneMatch });
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
readData(id) {
|
|
173
|
+
return request(`/documents/${encodeURIComponent(id)}/data`);
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
versions(id, { before } = {}) {
|
|
177
|
+
return request(`/documents/${encodeURIComponent(id)}/versions`, { query: { before } });
|
|
178
|
+
},
|
|
179
|
+
|
|
180
|
+
version(id, versionId) {
|
|
181
|
+
return request(`/documents/${encodeURIComponent(id)}/versions/${encodeURIComponent(versionId)}`);
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async save(id, { html, etag } = {}) {
|
|
185
|
+
const match = requireEtag('Saving', etag);
|
|
186
|
+
return request(`/documents/${encodeURIComponent(id)}`, {
|
|
187
|
+
method: 'PUT',
|
|
188
|
+
etag: match,
|
|
189
|
+
body: { html, operationId: randomUUID() },
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
async writeData(id, { data, etag } = {}) {
|
|
194
|
+
const match = requireEtag('Writing data', etag);
|
|
195
|
+
return request(`/documents/${encodeURIComponent(id)}/data`, {
|
|
196
|
+
method: 'POST',
|
|
197
|
+
etag: match,
|
|
198
|
+
body: { data, operationId: randomUUID() },
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
async restore(id, versionId, { etag } = {}) {
|
|
203
|
+
const match = requireEtag('Restoring', etag);
|
|
204
|
+
return request(
|
|
205
|
+
`/documents/${encodeURIComponent(id)}/versions/${encodeURIComponent(versionId)}/restore`,
|
|
206
|
+
{ method: 'POST', etag: match, body: { operationId: randomUUID() } },
|
|
207
|
+
);
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { ClayError, asClayError, redact } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
const TIMEOUT_MS = 30000;
|
|
4
|
+
|
|
5
|
+
function isTimeout(error) {
|
|
6
|
+
return error?.name === 'TimeoutError' || error?.cause?.name === 'TimeoutError';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function whoami(base, key) {
|
|
10
|
+
let response;
|
|
11
|
+
try {
|
|
12
|
+
response = await fetch(`${base}/_/agent/v1/whoami`, {
|
|
13
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
14
|
+
redirect: 'error',
|
|
15
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
16
|
+
});
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (isTimeout(error)) {
|
|
19
|
+
throw new ClayError('host-unavailable', 'The host did not answer in time.', {
|
|
20
|
+
hint: 'Check your connection, then try again.',
|
|
21
|
+
retryable: true,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
throw asClayError(error?.cause ?? error);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (response.status === 200) {
|
|
28
|
+
try {
|
|
29
|
+
return await response.json();
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw asClayError(error);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let body = null;
|
|
36
|
+
try {
|
|
37
|
+
body = await response.json();
|
|
38
|
+
} catch {
|
|
39
|
+
body = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const error = body?.error;
|
|
43
|
+
if (error?.code) {
|
|
44
|
+
throw new ClayError(error.code, redact(error.message || 'The host refused this agent key.'), {
|
|
45
|
+
status: response.status,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (response.status === 404) {
|
|
50
|
+
throw new ClayError('not-found', 'This host does not have agent keys turned on.');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
throw new ClayError('internal', redact(`The host answered with ${response.status}.`), {
|
|
54
|
+
status: response.status,
|
|
55
|
+
});
|
|
56
|
+
}
|
package/src/html/edit.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { ClayError } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
const MAX_EDITS = 50;
|
|
4
|
+
const OFFSET_SAMPLE = 5;
|
|
5
|
+
|
|
6
|
+
function invalid(message, hint) {
|
|
7
|
+
return new ClayError('invalid-edits', message, { hint });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function locate(html, needle) {
|
|
11
|
+
const offsets = [];
|
|
12
|
+
let from = 0;
|
|
13
|
+
for (;;) {
|
|
14
|
+
const start = html.indexOf(needle, from);
|
|
15
|
+
if (start === -1) break;
|
|
16
|
+
offsets.push(start);
|
|
17
|
+
from = start + 1;
|
|
18
|
+
}
|
|
19
|
+
return offsets;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function checkShape(edits) {
|
|
23
|
+
if (!Array.isArray(edits) || edits.length < 1 || edits.length > MAX_EDITS) {
|
|
24
|
+
throw invalid(
|
|
25
|
+
`\`edits\` must hold between 1 and ${MAX_EDITS} edits.`,
|
|
26
|
+
'Pass an array of `{ find, replace }` objects, optionally with a 1-based `occurrence`.',
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
for (const edit of edits) {
|
|
30
|
+
if (!edit || typeof edit !== 'object' || Array.isArray(edit)) {
|
|
31
|
+
throw invalid('Each edit must be an object with `find` and `replace`.', 'Pass `{ find, replace }`.');
|
|
32
|
+
}
|
|
33
|
+
if (typeof edit.find !== 'string' || edit.find.length === 0) {
|
|
34
|
+
throw invalid('Each edit needs a non-empty `find` string.', 'Copy the exact text from a read.');
|
|
35
|
+
}
|
|
36
|
+
if (typeof edit.replace !== 'string') {
|
|
37
|
+
throw invalid('Each edit needs a `replace` string.', 'Use an empty string to delete the text.');
|
|
38
|
+
}
|
|
39
|
+
if (edit.occurrence !== undefined && (!Number.isInteger(edit.occurrence) || edit.occurrence < 1)) {
|
|
40
|
+
throw invalid('`occurrence` must be a whole number counting from 1.', 'Pass `occurrence: 2` for the second match.');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function applyEdits(html, edits) {
|
|
46
|
+
checkShape(edits);
|
|
47
|
+
const planned = [];
|
|
48
|
+
for (let index = 0; index < edits.length; index += 1) {
|
|
49
|
+
const edit = edits[index];
|
|
50
|
+
const offsets = locate(html, edit.find);
|
|
51
|
+
if (offsets.length === 0) {
|
|
52
|
+
throw new ClayError('edit-not-found', `Edit ${index + 1} did not match any text.`, {
|
|
53
|
+
hint: 'Read the document again and copy the text exactly, including whitespace.',
|
|
54
|
+
details: { index },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
let offset;
|
|
58
|
+
if (edit.occurrence === undefined) {
|
|
59
|
+
if (offsets.length > 1) {
|
|
60
|
+
throw new ClayError(
|
|
61
|
+
'edit-not-unique',
|
|
62
|
+
`Edit ${index + 1} matches ${offsets.length} places, so it is ambiguous.`,
|
|
63
|
+
{
|
|
64
|
+
hint: 'Add surrounding text to `find` so it is unique, or pass `occurrence` to pick one.',
|
|
65
|
+
details: { index, count: offsets.length, offsets: offsets.slice(0, OFFSET_SAMPLE) },
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
offset = offsets[0];
|
|
70
|
+
} else {
|
|
71
|
+
if (edit.occurrence > offsets.length) {
|
|
72
|
+
throw new ClayError(
|
|
73
|
+
'edit-not-found',
|
|
74
|
+
`Edit ${index + 1} asked for occurrence ${edit.occurrence}, but the text occurs ${offsets.length} times.`,
|
|
75
|
+
{
|
|
76
|
+
hint: 'Read the document again and count the occurrences, or drop `occurrence` when the text is unique.',
|
|
77
|
+
details: { index },
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
offset = offsets[edit.occurrence - 1];
|
|
82
|
+
}
|
|
83
|
+
planned.push({ index, start: offset, end: offset + edit.find.length, replace: edit.replace });
|
|
84
|
+
}
|
|
85
|
+
for (let a = 0; a < planned.length; a += 1) {
|
|
86
|
+
for (let b = a + 1; b < planned.length; b += 1) {
|
|
87
|
+
const one = planned[a];
|
|
88
|
+
const two = planned[b];
|
|
89
|
+
if (one.start < two.end && two.start < one.end) {
|
|
90
|
+
throw new ClayError('edits-overlap', `Edits ${one.index + 1} and ${two.index + 1} overlap.`, {
|
|
91
|
+
hint: 'Give each edit its own non-overlapping text, or merge them into one edit.',
|
|
92
|
+
details: { indexes: [one.index, two.index] },
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
planned.sort((one, two) => two.start - one.start);
|
|
98
|
+
let output = html;
|
|
99
|
+
for (const edit of planned) {
|
|
100
|
+
output = output.slice(0, edit.start) + edit.replace + output.slice(edit.end);
|
|
101
|
+
}
|
|
102
|
+
return { html: output, applied: planned.length };
|
|
103
|
+
}
|