@rien7/agent-center-host 0.0.0-stage → 0.1.1
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 +335 -2
- package/dist/agent-tokens.js +105 -0
- package/dist/center-tools.js +882 -0
- package/dist/cli.js +242 -0
- package/dist/config.js +86 -0
- package/dist/core-client.js +151 -0
- package/dist/credentials.js +117 -0
- package/dist/crew-tools.js +115 -0
- package/dist/driver.js +87 -0
- package/dist/host.js +435 -0
- package/dist/index.js +10 -0
- package/dist/login.js +103 -0
- package/dist/memory.js +163 -0
- package/dist/oar-driver.js +1331 -0
- package/dist/prompt.js +82 -0
- package/dist/tool-titles.js +157 -0
- package/dist/wire.js +6 -0
- package/package.json +46 -4
package/dist/login.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent-host login`: the same RFC 8628 device flow the apps use
|
|
3
|
+
* (packages/data/src/device-login.ts): register a public client, ask for a device code
|
|
4
|
+
* with scope `agent:host`, show the 4-digit pairing code, poll /oauth/token until the
|
|
5
|
+
* owner approves the request in the app, then store the tokens.
|
|
6
|
+
*/
|
|
7
|
+
import { deviceGrantType } from "./credentials.js";
|
|
8
|
+
export function normalizeServer(server) {
|
|
9
|
+
const url = new URL(server.trim());
|
|
10
|
+
if (url.username ||
|
|
11
|
+
url.password ||
|
|
12
|
+
url.search ||
|
|
13
|
+
url.hash ||
|
|
14
|
+
url.pathname !== "/" ||
|
|
15
|
+
(url.protocol !== "https:" &&
|
|
16
|
+
!(url.protocol === "http:" &&
|
|
17
|
+
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))))
|
|
18
|
+
throw new Error("Use an HTTPS server origin (HTTP only for localhost), without a path");
|
|
19
|
+
return url.origin;
|
|
20
|
+
}
|
|
21
|
+
async function post(fetcher, url, body, type = "application/x-www-form-urlencoded") {
|
|
22
|
+
const response = await fetcher(url, {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": type },
|
|
25
|
+
body,
|
|
26
|
+
});
|
|
27
|
+
const data = (await response.json().catch(() => ({})));
|
|
28
|
+
return { response, data };
|
|
29
|
+
}
|
|
30
|
+
export async function deviceLogin(o) {
|
|
31
|
+
const fetcher = o.fetch ?? fetch;
|
|
32
|
+
const sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
33
|
+
const base = normalizeServer(o.server);
|
|
34
|
+
const register = async () => {
|
|
35
|
+
const { response, data } = await post(fetcher, `${base}/oauth/register`, JSON.stringify({
|
|
36
|
+
client_name: o.clientName,
|
|
37
|
+
grant_types: [deviceGrantType, "refresh_token"],
|
|
38
|
+
token_endpoint_auth_method: "none",
|
|
39
|
+
}), "application/json");
|
|
40
|
+
if (!response.ok || typeof data.client_id !== "string")
|
|
41
|
+
throw new Error(`Client registration failed: ${String(data.error_description ?? response.status)}`);
|
|
42
|
+
return data.client_id;
|
|
43
|
+
};
|
|
44
|
+
let clientId = o.clientId ?? (await register());
|
|
45
|
+
const authorize = () => post(fetcher, `${base}/oauth/device_authorization`, new URLSearchParams({
|
|
46
|
+
client_id: clientId,
|
|
47
|
+
scope: o.scope ?? "agent:host",
|
|
48
|
+
}).toString());
|
|
49
|
+
let { response, data } = await authorize();
|
|
50
|
+
// The server may have dropped a client that never obtained a grant.
|
|
51
|
+
if (o.clientId &&
|
|
52
|
+
!response.ok &&
|
|
53
|
+
["invalid_client", "unauthorized_client"].includes(String(data.error))) {
|
|
54
|
+
clientId = await register();
|
|
55
|
+
({ response, data } = await authorize());
|
|
56
|
+
}
|
|
57
|
+
if (!response.ok || typeof data.device_code !== "string")
|
|
58
|
+
throw new Error(`Login request failed: ${String(data.error_description ?? data.error ?? response.status)}`);
|
|
59
|
+
o.prompt({
|
|
60
|
+
userCode: String(data.user_code),
|
|
61
|
+
verificationUri: String(data.verification_uri),
|
|
62
|
+
});
|
|
63
|
+
let interval = Math.max(5, Number(data.interval ?? 5)) * 1000;
|
|
64
|
+
const expires = Date.now() + Number(data.expires_in ?? 600) * 1000;
|
|
65
|
+
for (;;) {
|
|
66
|
+
await sleep(interval);
|
|
67
|
+
if (o.signal?.aborted)
|
|
68
|
+
throw new Error("Login cancelled");
|
|
69
|
+
if (Date.now() >= expires)
|
|
70
|
+
throw new Error("The login request expired; run agent-host login again");
|
|
71
|
+
let result;
|
|
72
|
+
try {
|
|
73
|
+
result = await post(fetcher, `${base}/oauth/token`, new URLSearchParams({
|
|
74
|
+
client_id: clientId,
|
|
75
|
+
grant_type: deviceGrantType,
|
|
76
|
+
device_code: data.device_code,
|
|
77
|
+
}).toString());
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
interval = Math.min(60_000, interval * 2);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const tokens = result.data;
|
|
84
|
+
if (result.response.ok) {
|
|
85
|
+
if (typeof tokens.access_token !== "string" ||
|
|
86
|
+
typeof tokens.refresh_token !== "string")
|
|
87
|
+
throw new Error("The server returned no usable tokens");
|
|
88
|
+
return {
|
|
89
|
+
server: base,
|
|
90
|
+
clientId,
|
|
91
|
+
accessToken: tokens.access_token,
|
|
92
|
+
refreshToken: tokens.refresh_token,
|
|
93
|
+
accessExpiresAt: Date.now() + Number(tokens.expires_in ?? 3600) * 1000,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (tokens.error === "slow_down")
|
|
97
|
+
interval += 5000;
|
|
98
|
+
else if (tokens.error !== "authorization_pending")
|
|
99
|
+
throw new Error(tokens.error === "access_denied"
|
|
100
|
+
? "The login request was denied"
|
|
101
|
+
: `Login failed: ${String(tokens.error_description ?? tokens.error)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
package/dist/memory.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An agent's memory files: `<workdir>/agents/<agentId>/memory/` on the host (the
|
|
3
|
+
* runtime's file tools edit them), mirrored to Center (`agent.memory_file.*`, host token).
|
|
4
|
+
*
|
|
5
|
+
* - start: pull the server's files (the server wins), push local-only files, and write a
|
|
6
|
+
* template MEMORY.md for a new agent.
|
|
7
|
+
* - after each turn and on fs.watch (debounced): push changed files with ifVersion; on a
|
|
8
|
+
* conflict the server's copy overwrites the local one (logged).
|
|
9
|
+
*
|
|
10
|
+
* Deleting a file locally doesn't delete it on Center (there is no delete op yet); the
|
|
11
|
+
* next start brings it back.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { watch } from "node:fs";
|
|
15
|
+
import { mkdir, readdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
16
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
17
|
+
import { CoreError } from "./core-client.js";
|
|
18
|
+
export const memoryIndex = "MEMORY.md";
|
|
19
|
+
const maxContent = 200_000;
|
|
20
|
+
/** MEMORY.md injected into each turn, at most this many characters. */
|
|
21
|
+
export const memoryPromptChars = 8_000;
|
|
22
|
+
export const memoryTemplate = (name) => `# ${name} 的记忆
|
|
23
|
+
|
|
24
|
+
这是长期记忆的索引,每轮对话开头都会读到。保持简短:每条一行,细节写进 notes/ 下的文件并在这里链接。
|
|
25
|
+
|
|
26
|
+
## 关于用户
|
|
27
|
+
|
|
28
|
+
## 约定与偏好
|
|
29
|
+
|
|
30
|
+
## 笔记索引
|
|
31
|
+
`;
|
|
32
|
+
const hash = (text) => createHash("sha256").update(text).digest("hex");
|
|
33
|
+
const posix = (path) => path.split(sep).join("/");
|
|
34
|
+
export class MemorySync {
|
|
35
|
+
dir;
|
|
36
|
+
client;
|
|
37
|
+
agentId;
|
|
38
|
+
agentName;
|
|
39
|
+
updatedBy;
|
|
40
|
+
log;
|
|
41
|
+
/** path → what the server has (version, content hash). */
|
|
42
|
+
known = new Map();
|
|
43
|
+
watcher = null;
|
|
44
|
+
timer = null;
|
|
45
|
+
chain = Promise.resolve();
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.client = options.client;
|
|
48
|
+
this.agentId = options.agentId;
|
|
49
|
+
this.agentName = options.agentName;
|
|
50
|
+
this.dir = join(options.agentDir, "memory");
|
|
51
|
+
this.updatedBy = options.updatedBy ?? "agent-host";
|
|
52
|
+
this.log = options.log ?? (() => { });
|
|
53
|
+
}
|
|
54
|
+
/** Pull, push local-only files, create the template; then watch. */
|
|
55
|
+
async start() {
|
|
56
|
+
await mkdir(join(this.dir, "notes"), { recursive: true });
|
|
57
|
+
const remote = await this.client.memoryList(this.agentId);
|
|
58
|
+
for (const info of remote) {
|
|
59
|
+
const file = await this.client.memoryGet(this.agentId, info.path);
|
|
60
|
+
await this.writeLocal(file.path, file.content);
|
|
61
|
+
this.known.set(file.path, {
|
|
62
|
+
version: file.version,
|
|
63
|
+
hash: hash(file.content),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
const local = await this.localFiles();
|
|
67
|
+
if (!remote.length && !local.includes(memoryIndex))
|
|
68
|
+
await this.writeLocal(memoryIndex, memoryTemplate(this.agentName));
|
|
69
|
+
await this.sync();
|
|
70
|
+
try {
|
|
71
|
+
this.watcher = watch(this.dir, { recursive: true }, () => this.soon());
|
|
72
|
+
this.watcher.on("error", () => { });
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
this.log(`memory watch unavailable (${error.message}); syncing after turns only`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
stop() {
|
|
79
|
+
this.watcher?.close();
|
|
80
|
+
this.watcher = null;
|
|
81
|
+
if (this.timer)
|
|
82
|
+
clearTimeout(this.timer);
|
|
83
|
+
return this.sync();
|
|
84
|
+
}
|
|
85
|
+
/** MEMORY.md for the prompt (clipped), or null when empty. */
|
|
86
|
+
async index() {
|
|
87
|
+
const text = await readFile(join(this.dir, memoryIndex), "utf8").catch(() => "");
|
|
88
|
+
if (!text.trim())
|
|
89
|
+
return null;
|
|
90
|
+
return text.length > memoryPromptChars
|
|
91
|
+
? `${text.slice(0, memoryPromptChars)}\n…(MEMORY.md 过长,后面被截掉了;请精简它)`
|
|
92
|
+
: text;
|
|
93
|
+
}
|
|
94
|
+
soon() {
|
|
95
|
+
if (this.timer)
|
|
96
|
+
clearTimeout(this.timer);
|
|
97
|
+
this.timer = setTimeout(() => {
|
|
98
|
+
this.timer = null;
|
|
99
|
+
this.sync();
|
|
100
|
+
}, 500);
|
|
101
|
+
}
|
|
102
|
+
/** Push every local file that differs from what the server has. Serialized. */
|
|
103
|
+
sync() {
|
|
104
|
+
this.chain = this.chain
|
|
105
|
+
.then(() => this.pushChanged())
|
|
106
|
+
.catch((error) => this.log(`memory sync failed (${error.message})`));
|
|
107
|
+
return this.chain;
|
|
108
|
+
}
|
|
109
|
+
async localFiles() {
|
|
110
|
+
const entries = await readdir(this.dir, {
|
|
111
|
+
recursive: true,
|
|
112
|
+
withFileTypes: true,
|
|
113
|
+
}).catch(() => []);
|
|
114
|
+
return entries
|
|
115
|
+
.filter((e) => e.isFile() && e.name.endsWith(".md"))
|
|
116
|
+
.map((e) => posix(relative(this.dir, join(e.parentPath ?? this.dir, e.name))))
|
|
117
|
+
.filter((p) => !p.startsWith(".") && !p.includes("/."));
|
|
118
|
+
}
|
|
119
|
+
async pushChanged() {
|
|
120
|
+
for (const path of await this.localFiles()) {
|
|
121
|
+
const content = await readFile(join(this.dir, path), "utf8").catch(() => null);
|
|
122
|
+
if (content === null)
|
|
123
|
+
continue;
|
|
124
|
+
const known = this.known.get(path);
|
|
125
|
+
if (known?.hash === hash(content))
|
|
126
|
+
continue;
|
|
127
|
+
if (content.length > maxContent) {
|
|
128
|
+
this.log(`memory ${path} is over ${maxContent} characters; not synced`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const saved = await this.client.memoryPut({
|
|
133
|
+
agentId: this.agentId,
|
|
134
|
+
path,
|
|
135
|
+
content,
|
|
136
|
+
...(known ? { ifVersion: known.version } : {}),
|
|
137
|
+
updatedBy: this.updatedBy,
|
|
138
|
+
});
|
|
139
|
+
this.known.set(path, { version: saved.version, hash: hash(content) });
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (error instanceof CoreError && error.status === 409) {
|
|
143
|
+
const server = await this.client.memoryGet(this.agentId, path);
|
|
144
|
+
await this.writeLocal(path, server.content);
|
|
145
|
+
this.known.set(path, {
|
|
146
|
+
version: server.version,
|
|
147
|
+
hash: hash(server.content),
|
|
148
|
+
});
|
|
149
|
+
this.log(`memory ${path}: Center has a newer version (${server.version}); local edit replaced`);
|
|
150
|
+
}
|
|
151
|
+
else
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async writeLocal(path, content) {
|
|
157
|
+
const target = join(this.dir, path);
|
|
158
|
+
await mkdir(dirname(target), { recursive: true });
|
|
159
|
+
const temporary = `${target}.${process.pid}.tmp`;
|
|
160
|
+
await writeFile(temporary, content);
|
|
161
|
+
await rename(temporary, target);
|
|
162
|
+
}
|
|
163
|
+
}
|