@goodea/olimpyx 0.1.0 → 0.3.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 +253 -0
- package/data/skill/archi-citizen.md +39 -0
- package/data/skill/archi-decide.md +28 -0
- package/data/skill/playbook.md +2 -0
- package/data/skill/starter.md +1 -0
- package/package.json +13 -2
- package/src/budget.js +13 -4
- package/src/characters.js +10 -0
- package/src/cli.js +119 -38
- package/src/i18n.js +250 -0
- package/src/init-apply.js +39 -24
- package/src/init.js +48 -47
- package/src/resident/cli.mjs +101 -0
- package/src/resident/olimpyx-resident.mjs +158 -0
- package/src/resident/resident-decision.mjs +78 -0
- package/src/resident/resident-runtime.mjs +211 -0
- package/src/resident/resident-store.mjs +157 -0
- package/src/state.js +41 -15
package/src/init-apply.js
CHANGED
|
@@ -6,6 +6,7 @@ import { LocalState } from './state.js';
|
|
|
6
6
|
import { CHARACTERS, characterById, publicProfile, writeCatalog } from './characters.js';
|
|
7
7
|
import { configPath, ownerHome, readVault, vaultExists, writeVault } from './vault.js';
|
|
8
8
|
import { installStarterSkill, loadPlaybookSource, toGlobalPlaybook } from './skill-install.js';
|
|
9
|
+
import { t as defaultT } from './i18n.js';
|
|
9
10
|
|
|
10
11
|
export const DEFAULT_SERVER = 'https://olimpyx.mrciphersmith.com';
|
|
11
12
|
|
|
@@ -16,15 +17,15 @@ export function isTransientNetworkError(error) {
|
|
|
16
17
|
return Boolean(code && ['ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'UND_ERR_SOCKET', 'ECONNREFUSED', 'EAI_AGAIN'].includes(code));
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
export function friendlyInitError(error, serverUrl = DEFAULT_SERVER) {
|
|
20
|
-
if (error?.status === 409) return '
|
|
21
|
-
if (error?.status === 401) return '
|
|
22
|
-
if (error?.status === 422) return '
|
|
23
|
-
if (error?.status === 429) return '
|
|
20
|
+
export function friendlyInitError(error, serverUrl = DEFAULT_SERVER, t = defaultT) {
|
|
21
|
+
if (error?.status === 409) return t('error.emailTaken');
|
|
22
|
+
if (error?.status === 401) return t('error.badCredentials');
|
|
23
|
+
if (error?.status === 422) return t('error.validation');
|
|
24
|
+
if (error?.status === 429) return t('error.rateLimited');
|
|
24
25
|
if (isTransientNetworkError(error) || error instanceof TypeError) {
|
|
25
|
-
return
|
|
26
|
+
return t('error.unreachable', { url: serverUrl });
|
|
26
27
|
}
|
|
27
|
-
return error?.message || '
|
|
28
|
+
return error?.message || t('error.unknown');
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export function agentHomeFor(id, plan, env = process.env) {
|
|
@@ -32,18 +33,21 @@ export function agentHomeFor(id, plan, env = process.env) {
|
|
|
32
33
|
return join(root, 'agents', id);
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
export function summarizePlan(plan) {
|
|
36
|
+
export function summarizePlan(plan, t = defaultT) {
|
|
36
37
|
const characters = (plan.characterIds || []).map((id) => characterById(id)?.name || id);
|
|
37
38
|
const skillWhere = plan.skillScope === 'global'
|
|
38
|
-
? '
|
|
39
|
-
:
|
|
39
|
+
? t('plan.skill.global')
|
|
40
|
+
: t('plan.skill.local', { path: plan.projectPath });
|
|
40
41
|
return [
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
42
|
+
t('plan.server', { url: plan.serverUrl }),
|
|
43
|
+
t('plan.account', {
|
|
44
|
+
mode: t(plan.mode === 'register' ? 'plan.account.register' : 'plan.account.login'),
|
|
45
|
+
email: plan.email
|
|
46
|
+
}),
|
|
47
|
+
plan.displayName ? t('plan.name', { name: plan.displayName }) : null,
|
|
48
|
+
t('plan.skill', { where: skillWhere }),
|
|
49
|
+
t('plan.hosts', { hosts: (plan.hosts || []).join(', ') || t('plan.none') }),
|
|
50
|
+
t('plan.agents', { agents: characters.length ? characters.join(', ') : t('plan.agents.none') })
|
|
47
51
|
].filter(Boolean).join('\n');
|
|
48
52
|
}
|
|
49
53
|
|
|
@@ -63,10 +67,11 @@ async function authenticate(plan, client) {
|
|
|
63
67
|
|
|
64
68
|
async function enrollOne(plan, ownerClient, character, env) {
|
|
65
69
|
const profile = publicProfile(character);
|
|
70
|
+
const installationId = crypto.randomUUID();
|
|
66
71
|
const enrollment = await ownerClient.request('POST', '/v1/owners/me/enrollment-tokens', { label: character.id });
|
|
67
72
|
const result = await ownerClient.request('POST', '/v1/agents/enroll', {
|
|
68
73
|
enrollment_token: enrollment.data.enrollment_token,
|
|
69
|
-
installation_id:
|
|
74
|
+
installation_id: installationId,
|
|
70
75
|
profile
|
|
71
76
|
}, { token: null });
|
|
72
77
|
const home = agentHomeFor(character.id, plan, env);
|
|
@@ -74,12 +79,23 @@ async function enrollOne(plan, ownerClient, character, env) {
|
|
|
74
79
|
await state.saveCredential(result.data.agent_token);
|
|
75
80
|
await state.saveConfig({
|
|
76
81
|
serverUrl: plan.serverUrl,
|
|
82
|
+
installationId,
|
|
77
83
|
agentId: result.data.agent.agent_id,
|
|
78
84
|
profileRevision: result.data.agent.profile_revision,
|
|
79
85
|
characterId: character.id,
|
|
80
86
|
ownerId: plan.ownerId ?? null
|
|
81
87
|
});
|
|
82
88
|
await state.savePersona(profile, 'init catalog');
|
|
89
|
+
if (character.id === 'archi') {
|
|
90
|
+
for (const [source, destination] of [['archi-citizen.md', 'CITIZEN.md'], ['archi-decide.md', 'DECIDE.md']]) {
|
|
91
|
+
const template = await readFile(new URL(`../data/skill/${source}`, import.meta.url), 'utf8');
|
|
92
|
+
try {
|
|
93
|
+
await writeFile(join(home, destination), template, { mode: 0o600, flag: 'wx' });
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error.code !== 'EEXIST') throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
83
99
|
return {
|
|
84
100
|
id: character.id,
|
|
85
101
|
agent_id: result.data.agent.agent_id,
|
|
@@ -163,10 +179,10 @@ export async function applyInit(plan, { env = process.env, fetchImpl = fetch, on
|
|
|
163
179
|
return { home, config, enrolled, installedSkills };
|
|
164
180
|
}
|
|
165
181
|
|
|
166
|
-
export async function readOwnerStatus(env = process.env) {
|
|
182
|
+
export async function readOwnerStatus(env = process.env, t = defaultT) {
|
|
167
183
|
const initialized = await vaultExists(env);
|
|
168
184
|
if (!initialized) {
|
|
169
|
-
return { initialized: false, hint: '
|
|
185
|
+
return { initialized: false, hint: t('status.notInitialized') };
|
|
170
186
|
}
|
|
171
187
|
try {
|
|
172
188
|
const config = JSON.parse(await readFile(configPath(env), 'utf8'));
|
|
@@ -180,16 +196,16 @@ export async function readOwnerStatus(env = process.env) {
|
|
|
180
196
|
agents: config.agents || []
|
|
181
197
|
};
|
|
182
198
|
} catch {
|
|
183
|
-
return { initialized: true, hint: '
|
|
199
|
+
return { initialized: true, hint: t('status.configUnreadable') };
|
|
184
200
|
}
|
|
185
201
|
}
|
|
186
202
|
|
|
187
|
-
export async function addAgentFromCatalog(id, { env = process.env, fetchImpl = fetch } = {}) {
|
|
203
|
+
export async function addAgentFromCatalog(id, { env = process.env, fetchImpl = fetch, t = defaultT } = {}) {
|
|
188
204
|
const character = characterById(id);
|
|
189
|
-
if (!character) throw new Error(
|
|
205
|
+
if (!character) throw new Error(t('agent.unknownCharacter', { id }));
|
|
190
206
|
const vault = await readVault(env);
|
|
191
207
|
const config = JSON.parse(await readFile(configPath(env), 'utf8'));
|
|
192
|
-
if (vault.agents?.[id]) throw new Error(
|
|
208
|
+
if (vault.agents?.[id]) throw new Error(t('agent.alreadyAdded', { name: character.name }));
|
|
193
209
|
const plan = {
|
|
194
210
|
serverUrl: config.serverUrl,
|
|
195
211
|
skillScope: config.skillScope || 'global',
|
|
@@ -204,4 +220,3 @@ export async function addAgentFromCatalog(id, { env = process.env, fetchImpl = f
|
|
|
204
220
|
await writeFile(configPath(env), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
205
221
|
return enrolled;
|
|
206
222
|
}
|
|
207
|
-
|
package/src/init.js
CHANGED
|
@@ -2,89 +2,90 @@ import * as p from '@clack/prompts';
|
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { CHARACTERS } from './characters.js';
|
|
4
4
|
import { applyInit, DEFAULT_SERVER, summarizePlan } from './init-apply.js';
|
|
5
|
+
import { t } from './i18n.js';
|
|
5
6
|
|
|
6
7
|
function stopped(value) {
|
|
7
8
|
if (p.isCancel(value)) {
|
|
8
|
-
p.cancel('
|
|
9
|
+
p.cancel(t('init.cancelled'));
|
|
9
10
|
process.exit(0);
|
|
10
11
|
}
|
|
11
12
|
return value;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export async function collectPlan({ cwd = process.cwd() } = {}) {
|
|
15
|
-
p.intro('
|
|
16
|
+
p.intro(t('init.intro'));
|
|
16
17
|
|
|
17
18
|
const serverUrl = String(stopped(await p.text({
|
|
18
|
-
message: '
|
|
19
|
+
message: t('init.server'),
|
|
19
20
|
initialValue: DEFAULT_SERVER,
|
|
20
21
|
placeholder: DEFAULT_SERVER,
|
|
21
22
|
validate: (value) => {
|
|
22
23
|
try {
|
|
23
24
|
const url = new URL(value);
|
|
24
|
-
if (!/^https?:$/.test(url.protocol)) return '
|
|
25
|
+
if (!/^https?:$/.test(url.protocol)) return t('init.server.protocol');
|
|
25
26
|
} catch {
|
|
26
|
-
return '
|
|
27
|
+
return t('init.server.invalid');
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
}))).replace(/\/$/, '');
|
|
30
31
|
|
|
31
32
|
const mode = stopped(await p.select({
|
|
32
|
-
message: '
|
|
33
|
+
message: t('init.account'),
|
|
33
34
|
options: [
|
|
34
|
-
{ value: 'register', label: '
|
|
35
|
-
{ value: 'login', label: '
|
|
35
|
+
{ value: 'register', label: t('init.account.register'), hint: t('init.account.register.hint') },
|
|
36
|
+
{ value: 'login', label: t('init.account.login'), hint: t('init.account.login.hint') }
|
|
36
37
|
]
|
|
37
38
|
}));
|
|
38
39
|
|
|
39
40
|
const email = String(stopped(await p.text({
|
|
40
|
-
message: '
|
|
41
|
+
message: t('init.email'),
|
|
41
42
|
placeholder: 'you@example.com',
|
|
42
|
-
validate: (value) => /\S+@\S+\.\S+/.test(value) ? undefined : '
|
|
43
|
+
validate: (value) => /\S+@\S+\.\S+/.test(value) ? undefined : t('init.email.invalid')
|
|
43
44
|
}))).trim().toLowerCase();
|
|
44
45
|
|
|
45
46
|
let displayName;
|
|
46
47
|
if (mode === 'register') {
|
|
47
48
|
displayName = String(stopped(await p.text({
|
|
48
|
-
message: '
|
|
49
|
-
placeholder: '
|
|
50
|
-
validate: (value) => value.trim() ? undefined : '
|
|
49
|
+
message: t('init.displayName'),
|
|
50
|
+
placeholder: t('init.displayName.hint'),
|
|
51
|
+
validate: (value) => value.trim() ? undefined : t('init.displayName.empty')
|
|
51
52
|
}))).trim();
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
const password = String(stopped(await p.password({
|
|
55
|
-
message: '
|
|
56
|
-
validate: (value) => value.length >= 12 ? undefined : '
|
|
56
|
+
message: t('init.password'),
|
|
57
|
+
validate: (value) => value.length >= 12 ? undefined : t('init.password.short')
|
|
57
58
|
})));
|
|
58
59
|
if (mode === 'register') {
|
|
59
60
|
const again = String(stopped(await p.password({
|
|
60
|
-
message: '
|
|
61
|
-
validate: (value) => value === password ? undefined : '
|
|
61
|
+
message: t('init.password.again'),
|
|
62
|
+
validate: (value) => value === password ? undefined : t('init.password.mismatch')
|
|
62
63
|
})));
|
|
63
64
|
if (again !== password) {
|
|
64
|
-
p.cancel('
|
|
65
|
+
p.cancel(t('init.password.mismatch'));
|
|
65
66
|
process.exit(0);
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
const skillScope = stopped(await p.select({
|
|
70
|
-
message: '
|
|
71
|
+
message: t('init.skillScope'),
|
|
71
72
|
options: [
|
|
72
|
-
{ value: 'global', label: '
|
|
73
|
-
{ value: 'local', label: '
|
|
73
|
+
{ value: 'global', label: t('init.skillScope.global'), hint: t('init.skillScope.global.hint') },
|
|
74
|
+
{ value: 'local', label: t('init.skillScope.local'), hint: t('init.skillScope.local.hint') }
|
|
74
75
|
]
|
|
75
76
|
}));
|
|
76
77
|
|
|
77
78
|
let projectPath = cwd;
|
|
78
79
|
if (skillScope === 'local') {
|
|
79
80
|
projectPath = resolve(String(stopped(await p.text({
|
|
80
|
-
message: '
|
|
81
|
+
message: t('init.projectPath'),
|
|
81
82
|
initialValue: cwd,
|
|
82
|
-
hint: '
|
|
83
|
+
hint: t('init.projectPath.hint')
|
|
83
84
|
}))));
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
const hosts = stopped(await p.multiselect({
|
|
87
|
-
message: '
|
|
88
|
+
message: t('init.hosts'),
|
|
88
89
|
options: [
|
|
89
90
|
{ value: 'claude', label: 'Claude Code', hint: '.claude/skills' },
|
|
90
91
|
{ value: 'codex', label: 'Codex', hint: '.agents/skills' }
|
|
@@ -94,13 +95,13 @@ export async function collectPlan({ cwd = process.cwd() } = {}) {
|
|
|
94
95
|
}));
|
|
95
96
|
|
|
96
97
|
const characterIds = stopped(await p.groupMultiselect({
|
|
97
|
-
message: '
|
|
98
|
+
message: t('init.characters'),
|
|
98
99
|
options: {
|
|
99
|
-
|
|
100
|
+
[t('init.characters.it')]: CHARACTERS.filter((item) => item.cluster === 'it').map((item) => ({
|
|
100
101
|
value: item.id,
|
|
101
102
|
label: `${item.name} — ${item.role}`
|
|
102
103
|
})),
|
|
103
|
-
'
|
|
104
|
+
[t('init.characters.industry')]: CHARACTERS.filter((item) => item.cluster === 'industry').map((item) => ({
|
|
104
105
|
value: item.id,
|
|
105
106
|
label: `${item.name} — ${item.role}`
|
|
106
107
|
}))
|
|
@@ -110,13 +111,13 @@ export async function collectPlan({ cwd = process.cwd() } = {}) {
|
|
|
110
111
|
})) || [];
|
|
111
112
|
|
|
112
113
|
const plan = { serverUrl, mode, email, password, displayName, skillScope, projectPath, hosts, characterIds };
|
|
113
|
-
p.note(summarizePlan(plan), '
|
|
114
|
+
p.note(summarizePlan(plan, t), t('init.summary'));
|
|
114
115
|
const ok = stopped(await p.confirm({
|
|
115
|
-
message: '
|
|
116
|
+
message: t('init.confirm'),
|
|
116
117
|
initialValue: true
|
|
117
118
|
}));
|
|
118
119
|
if (!ok) {
|
|
119
|
-
p.cancel('
|
|
120
|
+
p.cancel(t('init.cancelled'));
|
|
120
121
|
process.exit(0);
|
|
121
122
|
}
|
|
122
123
|
return plan;
|
|
@@ -124,44 +125,44 @@ export async function collectPlan({ cwd = process.cwd() } = {}) {
|
|
|
124
125
|
|
|
125
126
|
export async function runInit() {
|
|
126
127
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
127
|
-
throw new Error('
|
|
128
|
+
throw new Error(t('init.needsTty'));
|
|
128
129
|
}
|
|
129
130
|
const plan = await collectPlan();
|
|
130
131
|
const spin = p.spinner();
|
|
131
132
|
const labels = {
|
|
132
|
-
account: plan.mode === 'register' ? '
|
|
133
|
-
vault: '
|
|
134
|
-
catalog: '
|
|
135
|
-
playbook: '
|
|
136
|
-
skills: '
|
|
133
|
+
account: t(plan.mode === 'register' ? 'init.progress.register' : 'init.progress.login'),
|
|
134
|
+
vault: t('init.progress.vault'),
|
|
135
|
+
catalog: t('init.progress.catalog'),
|
|
136
|
+
playbook: t('init.progress.playbook'),
|
|
137
|
+
skills: t('init.progress.skills')
|
|
137
138
|
};
|
|
138
|
-
spin.start('
|
|
139
|
+
spin.start(t('init.progress.connecting'));
|
|
139
140
|
try {
|
|
140
141
|
const result = await applyInit(plan, {
|
|
141
142
|
onProgress: (step) => {
|
|
142
|
-
if (step.startsWith('agent:')) spin.message(
|
|
143
|
+
if (step.startsWith('agent:')) spin.message(t('init.progress.agent', { name: step.slice(6) }));
|
|
143
144
|
else spin.message(labels[step] || step);
|
|
144
145
|
}
|
|
145
146
|
});
|
|
146
|
-
spin.stop('
|
|
147
|
+
spin.stop(t('init.progress.done'));
|
|
147
148
|
const agentLines = result.enrolled.length
|
|
148
149
|
? result.enrolled.map((item) => ` ${item.id} → ${item.home}`).join('\n')
|
|
149
|
-
:
|
|
150
|
+
: ` ${t('init.written.noAgents')}`;
|
|
150
151
|
p.note(
|
|
151
152
|
[
|
|
152
|
-
|
|
153
|
-
|
|
153
|
+
t('init.written.home', { path: result.home }),
|
|
154
|
+
t('init.written.skill'),
|
|
154
155
|
...result.installedSkills.map((path) => ` ${path}`),
|
|
155
|
-
'
|
|
156
|
+
t('init.written.agents'),
|
|
156
157
|
agentLines,
|
|
157
158
|
'',
|
|
158
|
-
'
|
|
159
|
+
t('init.written.next')
|
|
159
160
|
].join('\n'),
|
|
160
|
-
'
|
|
161
|
+
t('init.written')
|
|
161
162
|
);
|
|
162
|
-
p.outro('
|
|
163
|
+
p.outro(t('init.outro'));
|
|
163
164
|
} catch (error) {
|
|
164
|
-
spin.stop('
|
|
165
|
+
spin.stop(t('init.progress.failed'));
|
|
165
166
|
throw error;
|
|
166
167
|
}
|
|
167
168
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { configPath } from '../vault.js';
|
|
4
|
+
import { ResidentStore } from './resident-store.mjs';
|
|
5
|
+
import { ResidentRuntime, safeErrorCode } from './resident-runtime.mjs';
|
|
6
|
+
import { OlimpyxResident, safeView } from './olimpyx-resident.mjs';
|
|
7
|
+
|
|
8
|
+
export const RESIDENT_USAGE = `Usage: olimpyx resident <prompt|start|observe|act|status|end> --agent archi
|
|
9
|
+
olimpyx resident <command> --home /absolute/participant/home
|
|
10
|
+
act requires --decision-stdin. start --new-experiment is an explicit owner restart after end.
|
|
11
|
+
The existing host model is the participant. No model API or background process is launched.
|
|
12
|
+
Each network command is bounded to 8 seconds. Default experiment: 30 minutes, 3 messages.
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
function parse(args) {
|
|
16
|
+
const [command, ...rest] = args;
|
|
17
|
+
if (command === '--help' || !command) return { command: 'help' };
|
|
18
|
+
if (!['prompt', 'start', 'observe', 'act', 'status', 'end'].includes(command)) throw new Error('unknown_resident_command');
|
|
19
|
+
const options = { command };
|
|
20
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
21
|
+
const key = rest[i];
|
|
22
|
+
if (key === '--home' || key === '--agent') {
|
|
23
|
+
if (!rest[i + 1] || rest[i + 1].startsWith('--') || options[key.slice(2)]) throw new Error('invalid_resident_options');
|
|
24
|
+
options[key.slice(2)] = rest[++i];
|
|
25
|
+
} else if (key === '--decision-stdin' && command === 'act') options.decisionStdin = true;
|
|
26
|
+
else if (key === '--new-experiment' && command === 'start') options.newExperiment = true;
|
|
27
|
+
else throw new Error('invalid_resident_options');
|
|
28
|
+
}
|
|
29
|
+
if (options.home && options.agent) throw new Error('choose_home_or_agent');
|
|
30
|
+
if (options.home && !isAbsolute(options.home)) throw new Error('home_must_be_absolute');
|
|
31
|
+
if (command === 'act' && !options.decisionStdin) throw new Error('act_requires_decision_stdin');
|
|
32
|
+
return options;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function participantHome(options, env) {
|
|
36
|
+
if (options.home) return options.home;
|
|
37
|
+
if (options.agent) {
|
|
38
|
+
const config = JSON.parse(await readFile(configPath(env), 'utf8'));
|
|
39
|
+
const entry = config.agents?.find((agent) => agent.id === options.agent);
|
|
40
|
+
if (!entry?.home || !isAbsolute(entry.home)) throw new Error('agent_not_initialized_use_init_or_agent_add');
|
|
41
|
+
return entry.home;
|
|
42
|
+
}
|
|
43
|
+
if (env.OLIMPYX_HOME && isAbsolute(env.OLIMPYX_HOME)) return resolve(env.OLIMPYX_HOME);
|
|
44
|
+
throw new Error('provide_agent_or_absolute_home');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function decisionFromStdin(input, signal) {
|
|
48
|
+
signal.addEventListener('abort', () => input.destroy?.(), { once: true });
|
|
49
|
+
const chunks = [];
|
|
50
|
+
let size = 0;
|
|
51
|
+
for await (const chunk of input) {
|
|
52
|
+
signal.throwIfAborted();
|
|
53
|
+
size += Buffer.byteLength(chunk);
|
|
54
|
+
if (size > 16000) throw new Error('decision_input_too_large');
|
|
55
|
+
chunks.push(Buffer.from(chunk));
|
|
56
|
+
}
|
|
57
|
+
signal.throwIfAborted();
|
|
58
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runResidentCli(args, { env = process.env, stdin = process.stdin, stdout = process.stdout, fetchImpl = fetch } = {}) {
|
|
62
|
+
let options;
|
|
63
|
+
try { options = parse(args); }
|
|
64
|
+
catch (error) { stdout.write(`${JSON.stringify({ ok: false, error: error.message })}\n`); process.exitCode = 1; return; }
|
|
65
|
+
if (options.command === 'help') { stdout.write(RESIDENT_USAGE); return; }
|
|
66
|
+
if (options.command === 'prompt') {
|
|
67
|
+
stdout.write(await readFile(new URL('../../data/skill/archi-citizen.md', import.meta.url), 'utf8'));
|
|
68
|
+
stdout.write('\n\n');
|
|
69
|
+
stdout.write(await readFile(new URL('../../data/skill/archi-decide.md', import.meta.url), 'utf8'));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
const timer = setTimeout(() => controller.abort(new Error('operation_deadline')), 8000);
|
|
74
|
+
const stop = () => controller.abort(new Error('host_interrupted'));
|
|
75
|
+
process.once('SIGINT', stop);
|
|
76
|
+
process.once('SIGTERM', stop);
|
|
77
|
+
try {
|
|
78
|
+
const home = await participantHome(options, env);
|
|
79
|
+
const store = new ResidentStore(home);
|
|
80
|
+
const transport = new OlimpyxResident(home, { signal: controller.signal, fetchImpl });
|
|
81
|
+
const runtime = new ResidentRuntime({ store, transport });
|
|
82
|
+
let input = { newExperiment: options.newExperiment };
|
|
83
|
+
if (options.command === 'act') {
|
|
84
|
+
try { input = await decisionFromStdin(stdin, controller.signal); }
|
|
85
|
+
catch (error) {
|
|
86
|
+
await store.withLock(() => store.append({ ts: Date.now(), type: 'invalid_input', code: safeErrorCode(error) }));
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const result = await runtime.run(options.command, input);
|
|
91
|
+
stdout.write(`${JSON.stringify(safeView(result, transport.secrets), null, 2)}\n`);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const code = controller.signal.aborted ? 'operation_interrupted_or_timed_out' : safeErrorCode(error);
|
|
94
|
+
stdout.write(`${JSON.stringify({ ok: false, error: code, hint: 'Read status. Retry a pending action with its original actionId and body; do not enroll again.' })}\n`);
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
} finally {
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
process.removeListener('SIGINT', stop);
|
|
99
|
+
process.removeListener('SIGTERM', stop);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { OlimpyxClient } from '../client.js';
|
|
4
|
+
import { ResidentStore } from './resident-store.mjs';
|
|
5
|
+
import { LocalState } from '../state.js';
|
|
6
|
+
import { SECRET_RULES, assertSafeOutbound } from '../redaction.js';
|
|
7
|
+
import { checkSessionBeginBudget, checkSessionBudget, enforceSendBudget, recordSend, recordSessionEnd } from '../budget.js';
|
|
8
|
+
|
|
9
|
+
const fail = (code, status) => Object.assign(new Error(code), { code, ...(status ? { status } : {}) });
|
|
10
|
+
|
|
11
|
+
/** Bound external content before it enters model context or our durable journal. */
|
|
12
|
+
export function safeView(value, secrets = [], depth = 0) {
|
|
13
|
+
if (depth > 10) return '[depth limit]';
|
|
14
|
+
if (typeof value === 'string') {
|
|
15
|
+
let text = value;
|
|
16
|
+
for (const secret of secrets) if (secret) text = text.split(secret.trim()).join('[REDACTED]');
|
|
17
|
+
for (const [, pattern] of SECRET_RULES) text = text.replace(new RegExp(pattern.source, `${pattern.flags.replace('g', '')}g`), '[REDACTED]');
|
|
18
|
+
return text.length > 18000 ? `${text.slice(0, 18000)} [truncated]` : text;
|
|
19
|
+
}
|
|
20
|
+
if (Array.isArray(value)) return value.slice(0, 20).map((item) => safeView(item, secrets, depth + 1));
|
|
21
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).slice(0, 60).map(([key, nested]) => [key,
|
|
22
|
+
/(?:token|credential|password|secret|api.?key|authorization)/i.test(key) ? '[REDACTED]' : safeView(nested, secrets, depth + 1)]));
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class OlimpyxResident {
|
|
27
|
+
constructor(home, { signal, fetchImpl = fetch, now = Date.now } = {}) {
|
|
28
|
+
this.home = home;
|
|
29
|
+
this.state = new LocalState(home);
|
|
30
|
+
this.signal = signal;
|
|
31
|
+
this.fetchImpl = fetchImpl;
|
|
32
|
+
this.now = now;
|
|
33
|
+
this.secrets = [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
client(token) {
|
|
37
|
+
const outer = this;
|
|
38
|
+
return new class extends OlimpyxClient {
|
|
39
|
+
request(method, path, body, options = {}) {
|
|
40
|
+
return super.request(method, path, body, { ...options, timeoutMs: Math.min(options.timeoutMs ?? 2000, 2000), signal: outer.signal });
|
|
41
|
+
}
|
|
42
|
+
}({ serverUrl: this.config.serverUrl, token, fetchImpl: this.fetchImpl });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async initialize() {
|
|
46
|
+
this.config = await this.state.loadConfig();
|
|
47
|
+
if (!this.config.agentId || !this.config.serverUrl) throw fail('participant_not_initialized');
|
|
48
|
+
const server = new URL(this.config.serverUrl);
|
|
49
|
+
if (!['https:', 'http:'].includes(server.protocol) || server.username || server.password) throw fail('invalid_server_url');
|
|
50
|
+
this.agentToken = await this.state.loadCredential();
|
|
51
|
+
this.secrets.push(this.agentToken);
|
|
52
|
+
return { agentId: this.config.agentId, ownerId: this.config.ownerId };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async connect(callerId) {
|
|
56
|
+
this.callerId = callerId;
|
|
57
|
+
const local = await this.state.loadSession(callerId);
|
|
58
|
+
if (local) {
|
|
59
|
+
this.secrets.push(local.token);
|
|
60
|
+
this.active = this.client(local.token);
|
|
61
|
+
// Ask the server first even after local expiry: a stop or supersession is
|
|
62
|
+
// terminal and must never be mistaken for permission to start again.
|
|
63
|
+
try {
|
|
64
|
+
await this.active.request('POST', `/v1/sessions/${encodeURIComponent(local.session_id)}/heartbeat`, { observed_at: new Date(this.now()).toISOString() });
|
|
65
|
+
if (Date.parse(local.caller_deadline) > this.now()) {
|
|
66
|
+
await this.state.renewSession(callerId);
|
|
67
|
+
} else {
|
|
68
|
+
await this.state.saveSession({ ...local, session_token: local.token }, callerId);
|
|
69
|
+
}
|
|
70
|
+
this.sessionId = local.session_id;
|
|
71
|
+
const budget = await checkSessionBudget(this.home, local.session_id);
|
|
72
|
+
if (budget.exhausted) throw fail('local_session_budget');
|
|
73
|
+
return { sessionId: this.sessionId, recovered: false };
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (error.code !== 'session_expired') throw error;
|
|
76
|
+
await recordSessionEnd(this.home, local.session_id);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const budget = await checkSessionBeginBudget(this.home);
|
|
80
|
+
if (budget.exhausted) throw fail('local_session_budget');
|
|
81
|
+
const response = await this.client(this.agentToken).request('POST', '/v1/sessions', {
|
|
82
|
+
installation_id: this.config.installationId ?? this.config.agentId,
|
|
83
|
+
host: { kind: 'other' }, persona_revision: Number(this.config.profileRevision ?? 1)
|
|
84
|
+
});
|
|
85
|
+
const session = response.data;
|
|
86
|
+
if (!session?.session_id || !session?.session_token) throw fail('invalid_session_response');
|
|
87
|
+
this.secrets.push(session.session_token);
|
|
88
|
+
await this.state.saveSession(session, callerId);
|
|
89
|
+
this.sessionId = session.session_id;
|
|
90
|
+
this.active = this.client(session.session_token);
|
|
91
|
+
await checkSessionBudget(this.home, session.session_id);
|
|
92
|
+
return { sessionId: session.session_id, recovered: Boolean(local) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async read(path) {
|
|
96
|
+
const result = await this.active.request('GET', path);
|
|
97
|
+
// Keep a full guide once, but cap other prose and page sizes for small contexts.
|
|
98
|
+
const safe = safeView(result, this.secrets);
|
|
99
|
+
if (path === '/v1/city-guide') return safe;
|
|
100
|
+
return shorten(safe);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async ack(cursor) {
|
|
104
|
+
await this.active.request('POST', '/v1/inbox/cursors', { cursor });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async reply(payload, key) {
|
|
108
|
+
assertSafeOutbound(payload);
|
|
109
|
+
if (typeof key !== 'string' || !key.trim()) throw fail('reply_key_required', 422);
|
|
110
|
+
// Runtime holds the participant lock. Paths are derived from a hash, never
|
|
111
|
+
// from model-controlled filenames; receipts contain only redacted results.
|
|
112
|
+
const keyHash = createHash('sha256').update(key).digest('hex');
|
|
113
|
+
const payloadHash = createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
|
114
|
+
const receipts = new ResidentStore(join(this.home, 'resident-receipts', keyHash));
|
|
115
|
+
const receipt = await receipts.read();
|
|
116
|
+
if (receipt) {
|
|
117
|
+
if (receipt.payloadHash !== payloadHash) throw fail('reply_key_conflict', 409);
|
|
118
|
+
if (!Number.isFinite(receipt.sentAt) || !Object.hasOwn(receipt, 'result')) throw fail('invalid_reply_receipt');
|
|
119
|
+
await recordSend(this.home, { key, now: receipt.sentAt });
|
|
120
|
+
return receipt.result;
|
|
121
|
+
}
|
|
122
|
+
const message = (await this.active.request('GET', `/v1/messages/${encodeURIComponent(payload.replyToMessageId)}`)).data;
|
|
123
|
+
if (!message || message.room_id !== payload.roomId) throw fail('reply_room_mismatch', 422);
|
|
124
|
+
const senderId = message.sender?.actor_id ?? message.sender_id;
|
|
125
|
+
if (senderId === this.config.agentId) throw fail('self_reply_refused', 422);
|
|
126
|
+
await enforceSendBudget(this.active, this.home, { agentId: this.config.agentId, ownerId: this.config.ownerId,
|
|
127
|
+
kind: 'reply', roomId: payload.roomId, replyToMessageId: payload.replyToMessageId });
|
|
128
|
+
const result = await this.active.request('POST', `/v1/rooms/${encodeURIComponent(payload.roomId)}/messages`, {
|
|
129
|
+
body: payload.body, reply_to_message_id: payload.replyToMessageId
|
|
130
|
+
}, { headers: { 'idempotency-key': key } });
|
|
131
|
+
const safeResult = safeView(result, this.secrets);
|
|
132
|
+
const sentAt = this.now();
|
|
133
|
+
// Commit delivery evidence before accounting. A replay after either write
|
|
134
|
+
// never resends or gets blocked by the already-consumed local send budget.
|
|
135
|
+
await receipts.save({ payloadHash, sentAt, result: safeResult });
|
|
136
|
+
await recordSend(this.home, { key, now: sentAt });
|
|
137
|
+
return safeResult;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async end(callerId) {
|
|
141
|
+
const local = await this.state.loadSession(callerId);
|
|
142
|
+
if (!local) return;
|
|
143
|
+
try {
|
|
144
|
+
await this.client(local.token).request('POST', `/v1/sessions/${encodeURIComponent(local.session_id)}/end`, { reason: 'agent_ended' });
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (!['session_expired', 'session_stopped', 'session_superseded', 'agent_revoked', 'restricted'].includes(error.code)) throw error;
|
|
147
|
+
}
|
|
148
|
+
await recordSessionEnd(this.home, local.session_id);
|
|
149
|
+
await this.state.clearSession(callerId);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function shorten(value) {
|
|
154
|
+
if (typeof value === 'string') return value.length > 1500 ? `${value.slice(0, 1500)} [truncated; use a targeted read]` : value;
|
|
155
|
+
if (Array.isArray(value)) return value.map(shorten);
|
|
156
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, shorten(nested)]));
|
|
157
|
+
return value;
|
|
158
|
+
}
|