@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/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 'Этот email уже зарегистрирован. Выберите вход.';
21
- if (error?.status === 401) return 'Неверный email или пароль.';
22
- if (error?.status === 422) return 'Проверьте поля: пароль не короче 12 символов, корректный email, имя не пустое.';
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 `Не удалось связаться с ${serverUrl}. Проверьте сеть и адрес сервера.`;
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
- ? 'глобально (~/.claude и ~/.agents)'
39
- : проекте ${plan.projectPath}`;
39
+ ? t('plan.skill.global')
40
+ : t('plan.skill.local', { path: plan.projectPath });
40
41
  return [
41
- `Сервер: ${plan.serverUrl}`,
42
- `Аккаунт: ${plan.mode === 'register' ? 'новая регистрация' : 'вход'} · ${plan.email}`,
43
- plan.displayName ? `Имя: ${plan.displayName}` : null,
44
- `Скилл: ${skillWhere}`,
45
- `Хосты: ${(plan.hosts || []).join(', ') || '—'}`,
46
- `Агенты: ${characters.length ? characters.join(', ') : 'пока никого'}`
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: crypto.randomUUID(),
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: 'Запустите olimpyx init' };
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: 'Vault есть, config.json не прочитан' };
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(`Нет персонажа «${id}». Смотрите olimpyx skill / каталог в ~/.olimpyx/characters/INDEX.md`);
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(`Агент ${character.name} уже добавлен`);
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('Olimpyx · подключение к городу');
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 'Нужен http или https';
25
+ if (!/^https?:$/.test(url.protocol)) return t('init.server.protocol');
25
26
  } catch {
26
- return 'Это не похоже на URL';
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: 'Создать новый', hint: 'email + пароль + имя' },
35
- { value: 'login', label: 'Войти', hint: 'уже регистрировались на сайте' }
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: 'Email',
41
+ message: t('init.email'),
41
42
  placeholder: 'you@example.com',
42
- validate: (value) => /\S+@\S+\.\S+/.test(value) ? undefined : 'Нужен обычный email'
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 : 'Не короче 12 символов'
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: 'Глобально', hint: '~/.claude/skills и ~/.agents/skills' },
73
- { value: 'local', label: 'В проект', hint: 'текущая папка, путь можно поправить' }
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: 'Enter — оставить текущий'
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: 'Базовые персонажи (пробел — выбрать, Enter — дальше)',
98
+ message: t('init.characters'),
98
99
  options: {
99
- IT: CHARACTERS.filter((item) => item.cluster === 'it').map((item) => ({
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
- 'Отрасли': CHARACTERS.filter((item) => item.cluster === 'industry').map((item) => ({
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: 'Записать vault, скилл и выбранных агентов?',
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('olimpyx init нужен интерактивный терминал. Запустите в обычном терминале, не из пайпа.');
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: 'Шифруем vault',
134
- catalog: 'Копируем каталог персонажей',
135
- playbook: 'Пишем 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(`Регистрируем ${step.slice(6)}`);
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
- : ' (никого — добавите позже: olimpyx agent add prometheus)';
150
+ : ` ${t('init.written.noAgents')}`;
150
151
  p.note(
151
152
  [
152
- `Дом владельца: ${result.home}`,
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
- 'Дальше: olimpyx status · olimpyx skill'
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
+ }