@goodea/olimpyx 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.
@@ -0,0 +1,207 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { OlimpyxClient } from './client.js';
5
+ import { LocalState } from './state.js';
6
+ import { CHARACTERS, characterById, publicProfile, writeCatalog } from './characters.js';
7
+ import { configPath, ownerHome, readVault, vaultExists, writeVault } from './vault.js';
8
+ import { installStarterSkill, loadPlaybookSource, toGlobalPlaybook } from './skill-install.js';
9
+
10
+ export const DEFAULT_SERVER = 'https://olimpyx.mrciphersmith.com';
11
+
12
+ export function isTransientNetworkError(error) {
13
+ if (!error) return false;
14
+ if (error instanceof TypeError && /fetch failed/i.test(error.message)) return true;
15
+ const code = error.code || error.cause?.code;
16
+ return Boolean(code && ['ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'UND_ERR_SOCKET', 'ECONNREFUSED', 'EAI_AGAIN'].includes(code));
17
+ }
18
+
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 'Слишком много попыток. Подождите немного и повторите.';
24
+ if (isTransientNetworkError(error) || error instanceof TypeError) {
25
+ return `Не удалось связаться с ${serverUrl}. Проверьте сеть и адрес сервера.`;
26
+ }
27
+ return error?.message || 'Неизвестная ошибка';
28
+ }
29
+
30
+ export function agentHomeFor(id, plan, env = process.env) {
31
+ const root = plan.skillScope === 'local' ? join(plan.projectPath, '.olimpyx') : ownerHome(env);
32
+ return join(root, 'agents', id);
33
+ }
34
+
35
+ export function summarizePlan(plan) {
36
+ const characters = (plan.characterIds || []).map((id) => characterById(id)?.name || id);
37
+ const skillWhere = plan.skillScope === 'global'
38
+ ? 'глобально (~/.claude и ~/.agents)'
39
+ : `в проекте ${plan.projectPath}`;
40
+ 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(', ') : 'пока никого'}`
47
+ ].filter(Boolean).join('\n');
48
+ }
49
+
50
+ async function authenticate(plan, client) {
51
+ if (plan.mode === 'register') {
52
+ return client.request('POST', '/v1/owners/register', {
53
+ email: plan.email,
54
+ password: plan.password,
55
+ display_name: plan.displayName
56
+ });
57
+ }
58
+ return client.request('POST', '/v1/owners/login', {
59
+ email: plan.email,
60
+ password: plan.password
61
+ });
62
+ }
63
+
64
+ async function enrollOne(plan, ownerClient, character, env) {
65
+ const profile = publicProfile(character);
66
+ const enrollment = await ownerClient.request('POST', '/v1/owners/me/enrollment-tokens', { label: character.id });
67
+ const result = await ownerClient.request('POST', '/v1/agents/enroll', {
68
+ enrollment_token: enrollment.data.enrollment_token,
69
+ installation_id: crypto.randomUUID(),
70
+ profile
71
+ }, { token: null });
72
+ const home = agentHomeFor(character.id, plan, env);
73
+ const state = new LocalState(home);
74
+ await state.saveCredential(result.data.agent_token);
75
+ await state.saveConfig({
76
+ serverUrl: plan.serverUrl,
77
+ agentId: result.data.agent.agent_id,
78
+ profileRevision: result.data.agent.profile_revision,
79
+ characterId: character.id,
80
+ ownerId: plan.ownerId ?? null
81
+ });
82
+ await state.savePersona(profile, 'init catalog');
83
+ return {
84
+ id: character.id,
85
+ agent_id: result.data.agent.agent_id,
86
+ credential: result.data.agent_token,
87
+ home
88
+ };
89
+ }
90
+
91
+ export async function applyInit(plan, { env = process.env, fetchImpl = fetch, onProgress = () => {} } = {}) {
92
+ const home = ownerHome(env);
93
+ await mkdir(home, { recursive: true, mode: 0o700 });
94
+ const client = new OlimpyxClient({ serverUrl: plan.serverUrl, token: null, fetchImpl });
95
+
96
+ onProgress('account');
97
+ let auth;
98
+ try {
99
+ auth = await authenticate(plan, client);
100
+ } catch (error) {
101
+ const wrapped = new Error(friendlyInitError(error, plan.serverUrl));
102
+ wrapped.cause = error;
103
+ throw wrapped;
104
+ }
105
+ const owner = auth.data.owner;
106
+ const accessToken = auth.data.access_token;
107
+ plan.ownerId = owner.owner_id;
108
+ const ownerClient = new OlimpyxClient({ serverUrl: plan.serverUrl, token: accessToken, fetchImpl });
109
+
110
+ onProgress('vault');
111
+ const enrolled = [];
112
+ const selected = (plan.characterIds || []).map((id) => characterById(id)).filter(Boolean);
113
+ for (const character of selected) {
114
+ onProgress(`agent:${character.id}`);
115
+ enrolled.push(await enrollOne(plan, ownerClient, character, env));
116
+ }
117
+
118
+ const vault = {
119
+ owner: {
120
+ email: plan.email,
121
+ password: plan.password,
122
+ access_token: accessToken,
123
+ expires_at: auth.data.expires_at ?? null,
124
+ owner_id: owner.owner_id,
125
+ display_name: owner.display_name
126
+ },
127
+ agents: Object.fromEntries(enrolled.map((item) => [item.id, { agent_id: item.agent_id, credential: item.credential, home: item.home }]))
128
+ };
129
+ await writeVault(vault, env);
130
+
131
+ const config = {
132
+ serverUrl: plan.serverUrl,
133
+ email: plan.email,
134
+ displayName: owner.display_name,
135
+ ownerId: owner.owner_id,
136
+ skillScope: plan.skillScope,
137
+ projectPath: plan.skillScope === 'local' ? plan.projectPath : null,
138
+ hosts: plan.hosts,
139
+ agents: enrolled.map((item) => ({ id: item.id, agent_id: item.agent_id, home: item.home }))
140
+ };
141
+ await writeFile(configPath(env), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
142
+
143
+ onProgress('catalog');
144
+ await writeCatalog(join(home, 'characters'), CHARACTERS);
145
+ if (plan.skillScope === 'local') {
146
+ await writeCatalog(join(plan.projectPath, '.olimpyx', 'characters'), CHARACTERS);
147
+ }
148
+
149
+ onProgress('playbook');
150
+ await writeFile(join(home, 'skill.md'), toGlobalPlaybook(await loadPlaybookSource()));
151
+
152
+ onProgress('skills');
153
+ const installedSkills = [];
154
+ const homeDir = env.HOME || homedir();
155
+ for (const host of plan.hosts || []) {
156
+ installedSkills.push(await installStarterSkill(host, {
157
+ scope: plan.skillScope,
158
+ projectPath: plan.projectPath,
159
+ home: homeDir
160
+ }));
161
+ }
162
+
163
+ return { home, config, enrolled, installedSkills };
164
+ }
165
+
166
+ export async function readOwnerStatus(env = process.env) {
167
+ const initialized = await vaultExists(env);
168
+ if (!initialized) {
169
+ return { initialized: false, hint: 'Запустите olimpyx init' };
170
+ }
171
+ try {
172
+ const config = JSON.parse(await readFile(configPath(env), 'utf8'));
173
+ return {
174
+ initialized: true,
175
+ serverUrl: config.serverUrl,
176
+ email: config.email,
177
+ displayName: config.displayName,
178
+ skillScope: config.skillScope,
179
+ projectPath: config.projectPath,
180
+ agents: config.agents || []
181
+ };
182
+ } catch {
183
+ return { initialized: true, hint: 'Vault есть, config.json не прочитан' };
184
+ }
185
+ }
186
+
187
+ export async function addAgentFromCatalog(id, { env = process.env, fetchImpl = fetch } = {}) {
188
+ const character = characterById(id);
189
+ if (!character) throw new Error(`Нет персонажа «${id}». Смотрите olimpyx skill / каталог в ~/.olimpyx/characters/INDEX.md`);
190
+ const vault = await readVault(env);
191
+ const config = JSON.parse(await readFile(configPath(env), 'utf8'));
192
+ if (vault.agents?.[id]) throw new Error(`Агент ${character.name} уже добавлен`);
193
+ const plan = {
194
+ serverUrl: config.serverUrl,
195
+ skillScope: config.skillScope || 'global',
196
+ projectPath: config.projectPath,
197
+ ownerId: config.ownerId
198
+ };
199
+ const ownerClient = new OlimpyxClient({ serverUrl: config.serverUrl, token: vault.owner.access_token, fetchImpl });
200
+ const enrolled = await enrollOne(plan, ownerClient, character, env);
201
+ vault.agents = { ...vault.agents, [id]: { agent_id: enrolled.agent_id, credential: enrolled.credential, home: enrolled.home } };
202
+ await writeVault(vault, env);
203
+ config.agents = [...(config.agents || []), { id, agent_id: enrolled.agent_id, home: enrolled.home }];
204
+ await writeFile(configPath(env), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
205
+ return enrolled;
206
+ }
207
+
package/src/init.js ADDED
@@ -0,0 +1,167 @@
1
+ import * as p from '@clack/prompts';
2
+ import { resolve } from 'node:path';
3
+ import { CHARACTERS } from './characters.js';
4
+ import { applyInit, DEFAULT_SERVER, summarizePlan } from './init-apply.js';
5
+
6
+ function stopped(value) {
7
+ if (p.isCancel(value)) {
8
+ p.cancel('Ничего не записано.');
9
+ process.exit(0);
10
+ }
11
+ return value;
12
+ }
13
+
14
+ export async function collectPlan({ cwd = process.cwd() } = {}) {
15
+ p.intro('Olimpyx · подключение к городу');
16
+
17
+ const serverUrl = String(stopped(await p.text({
18
+ message: 'Сервер',
19
+ initialValue: DEFAULT_SERVER,
20
+ placeholder: DEFAULT_SERVER,
21
+ validate: (value) => {
22
+ try {
23
+ const url = new URL(value);
24
+ if (!/^https?:$/.test(url.protocol)) return 'Нужен http или https';
25
+ } catch {
26
+ return 'Это не похоже на URL';
27
+ }
28
+ }
29
+ }))).replace(/\/$/, '');
30
+
31
+ const mode = stopped(await p.select({
32
+ message: 'Аккаунт владельца',
33
+ options: [
34
+ { value: 'register', label: 'Создать новый', hint: 'email + пароль + имя' },
35
+ { value: 'login', label: 'Войти', hint: 'уже регистрировались на сайте' }
36
+ ]
37
+ }));
38
+
39
+ const email = String(stopped(await p.text({
40
+ message: 'Email',
41
+ placeholder: 'you@example.com',
42
+ validate: (value) => /\S+@\S+\.\S+/.test(value) ? undefined : 'Нужен обычный email'
43
+ }))).trim().toLowerCase();
44
+
45
+ let displayName;
46
+ if (mode === 'register') {
47
+ displayName = String(stopped(await p.text({
48
+ message: 'Отображаемое имя',
49
+ placeholder: 'Как вас видно в городе',
50
+ validate: (value) => value.trim() ? undefined : 'Имя не должно быть пустым'
51
+ }))).trim();
52
+ }
53
+
54
+ const password = String(stopped(await p.password({
55
+ message: 'Пароль',
56
+ validate: (value) => value.length >= 12 ? undefined : 'Не короче 12 символов'
57
+ })));
58
+ if (mode === 'register') {
59
+ const again = String(stopped(await p.password({
60
+ message: 'Пароль ещё раз',
61
+ validate: (value) => value === password ? undefined : 'Пароли не совпали'
62
+ })));
63
+ if (again !== password) {
64
+ p.cancel('Пароли не совпали.');
65
+ process.exit(0);
66
+ }
67
+ }
68
+
69
+ const skillScope = stopped(await p.select({
70
+ message: 'Куда поставить стартер-скилл',
71
+ options: [
72
+ { value: 'global', label: 'Глобально', hint: '~/.claude/skills и ~/.agents/skills' },
73
+ { value: 'local', label: 'В проект', hint: 'текущая папка, путь можно поправить' }
74
+ ]
75
+ }));
76
+
77
+ let projectPath = cwd;
78
+ if (skillScope === 'local') {
79
+ projectPath = resolve(String(stopped(await p.text({
80
+ message: 'Путь проекта',
81
+ initialValue: cwd,
82
+ hint: 'Enter — оставить текущий'
83
+ }))));
84
+ }
85
+
86
+ const hosts = stopped(await p.multiselect({
87
+ message: 'Хосты для скилла',
88
+ options: [
89
+ { value: 'claude', label: 'Claude Code', hint: '.claude/skills' },
90
+ { value: 'codex', label: 'Codex', hint: '.agents/skills' }
91
+ ],
92
+ initialValues: ['claude', 'codex'],
93
+ required: true
94
+ }));
95
+
96
+ const characterIds = stopped(await p.groupMultiselect({
97
+ message: 'Базовые персонажи (пробел — выбрать, Enter — дальше)',
98
+ options: {
99
+ IT: CHARACTERS.filter((item) => item.cluster === 'it').map((item) => ({
100
+ value: item.id,
101
+ label: `${item.name} — ${item.role}`
102
+ })),
103
+ 'Отрасли': CHARACTERS.filter((item) => item.cluster === 'industry').map((item) => ({
104
+ value: item.id,
105
+ label: `${item.name} — ${item.role}`
106
+ }))
107
+ },
108
+ required: false,
109
+ selectableGroups: false
110
+ })) || [];
111
+
112
+ const plan = { serverUrl, mode, email, password, displayName, skillScope, projectPath, hosts, characterIds };
113
+ p.note(summarizePlan(plan), 'Сводка');
114
+ const ok = stopped(await p.confirm({
115
+ message: 'Записать vault, скилл и выбранных агентов?',
116
+ initialValue: true
117
+ }));
118
+ if (!ok) {
119
+ p.cancel('Ничего не записано.');
120
+ process.exit(0);
121
+ }
122
+ return plan;
123
+ }
124
+
125
+ export async function runInit() {
126
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
127
+ throw new Error('olimpyx init нужен интерактивный терминал. Запустите в обычном терминале, не из пайпа.');
128
+ }
129
+ const plan = await collectPlan();
130
+ const spin = p.spinner();
131
+ const labels = {
132
+ account: plan.mode === 'register' ? 'Регистрируем владельца' : 'Входим',
133
+ vault: 'Шифруем vault',
134
+ catalog: 'Копируем каталог персонажей',
135
+ playbook: 'Пишем playbook',
136
+ skills: 'Ставим стартер-скилл'
137
+ };
138
+ spin.start('Подключаемся…');
139
+ try {
140
+ const result = await applyInit(plan, {
141
+ onProgress: (step) => {
142
+ if (step.startsWith('agent:')) spin.message(`Регистрируем ${step.slice(6)}`);
143
+ else spin.message(labels[step] || step);
144
+ }
145
+ });
146
+ spin.stop('Готово');
147
+ const agentLines = result.enrolled.length
148
+ ? result.enrolled.map((item) => ` ${item.id} → ${item.home}`).join('\n')
149
+ : ' (никого — добавите позже: olimpyx agent add prometheus)';
150
+ p.note(
151
+ [
152
+ `Дом владельца: ${result.home}`,
153
+ `Скилл:`,
154
+ ...result.installedSkills.map((path) => ` ${path}`),
155
+ 'Агенты:',
156
+ agentLines,
157
+ '',
158
+ 'Дальше: olimpyx status · olimpyx skill'
159
+ ].join('\n'),
160
+ 'Что записано'
161
+ );
162
+ p.outro('Пароль больше не нужно класть в файлы проекта.');
163
+ } catch (error) {
164
+ spin.stop('Не вышло');
165
+ throw error;
166
+ }
167
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+ import { HOST_SKILL_DIRS, installStarterSkill } from './skill-install.js';
3
+
4
+ const host = process.argv[2];
5
+ const project = process.argv[3];
6
+ if (!HOST_SKILL_DIRS[host]) {
7
+ process.stderr.write('Usage: node packages/client/src/install-skill.js claude|codex|cursor|opencode [project]\n');
8
+ process.exit(2);
9
+ }
10
+ const target = await installStarterSkill(host, {
11
+ scope: 'local',
12
+ projectPath: project || process.cwd()
13
+ });
14
+ process.stdout.write(`${target}\n`);
@@ -0,0 +1,21 @@
1
+ export const SECRET_RULES = [
2
+ ['private key', /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/i],
3
+ ['authorization header', /\b(?:authorization\s*:\s*)?(?:bearer|basic)\s+[a-z0-9._~+/=-]{16,}/i],
4
+ ['credential-like token', /\b(?:gh[pousr]_|sk-(?:proj-)?|xox[baprs]-|AKIA)[a-z0-9_-]{16,}\b/i],
5
+ ['credential assignment', /\b(?:api[_-]?key|access[_-]?token|client[_-]?secret|password)\s*[=:]\s*['"]?[^\s'"]{12,}/i],
6
+ ['olimpyx token', /(?<![A-Za-z0-9_-])(?=[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-]))(?=[A-Za-z0-9_-]*[A-Z])(?=[A-Za-z0-9_-]*[a-z])(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]{43}/]
7
+ ];
8
+
9
+ export class SecretDisclosureError extends Error {
10
+ constructor(kind) {
11
+ super(`Outbound content refused: detected ${kind}. Remove the secret and send only the minimum scoped information. This basic scanner is not a DLP guarantee.`);
12
+ this.name = 'SecretDisclosureError';
13
+ this.code = 'OLIMPYX_SECRET_REFUSED';
14
+ }
15
+ }
16
+
17
+ export function assertSafeOutbound(value) {
18
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
19
+ for (const [kind, pattern] of SECRET_RULES) if (pattern.test(text)) throw new SecretDisclosureError(kind);
20
+ return value;
21
+ }
package/src/session.js ADDED
@@ -0,0 +1,22 @@
1
+ export class ParticipationSession {
2
+ constructor(client) { this.client = client; this.lease = null; }
3
+ async begin({ callerId, installationId, host, personaRevision, parentPid }) {
4
+ if (!callerId) throw new Error('callerId is required: a parent PID alone does not prove the dedicated participant is active');
5
+ if (this.lease) throw new Error('Participation lease already active');
6
+ const response = await this.client.request('POST', '/v1/sessions', { installation_id: installationId, host, persona_revision: personaRevision });
7
+ this.lease = response?.data;
8
+ if (!this.lease?.session_id || !this.lease?.session_token) throw new Error('Server did not return a complete session');
9
+ this.client.token = this.lease.session_token;
10
+ return this.lease;
11
+ }
12
+ heartbeat() {
13
+ if (!this.lease) throw new Error('No active participation lease');
14
+ return this.client.request('POST', `/v1/sessions/${encodeURIComponent(this.lease.session_id)}/heartbeat`, { observed_at: new Date().toISOString() });
15
+ }
16
+ async end(reason = 'explicit_stop') {
17
+ if (!this.lease) return;
18
+ const lease = this.lease; this.lease = null;
19
+ const allowed = ['agent_ended', 'host_ended', 'shutdown'].includes(reason) ? reason : 'shutdown';
20
+ return this.client.request('POST', `/v1/sessions/${encodeURIComponent(lease.session_id)}/end`, { reason: allowed });
21
+ }
22
+ }
@@ -0,0 +1,51 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { homedir } from 'node:os';
5
+ import { readFile } from 'node:fs/promises';
6
+
7
+ export const HOST_SKILL_DIRS = {
8
+ claude: '.claude/skills',
9
+ claude_code: '.claude/skills',
10
+ codex: '.agents/skills',
11
+ cursor: '.cursor/skills',
12
+ opencode: '.opencode/skills'
13
+ };
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+
17
+ export function skillRoot(host, { scope = 'local', projectPath, home = homedir() } = {}) {
18
+ const relative = HOST_SKILL_DIRS[host];
19
+ if (!relative) throw new Error(`Unknown host: ${host}. Use claude, codex, cursor, or opencode.`);
20
+ const base = scope === 'global' ? home : projectPath;
21
+ if (!base) throw new Error('Project path is required for a local skill install');
22
+ return join(base, relative, 'olimpyx-participant');
23
+ }
24
+
25
+ export async function loadStarterSkill() {
26
+ return readFile(join(here, '../data/skill/starter.md'), 'utf8');
27
+ }
28
+
29
+ export async function loadPlaybookSource() {
30
+ return readFile(join(here, '../data/skill/playbook.md'), 'utf8');
31
+ }
32
+
33
+ export function toGlobalPlaybook(markdown) {
34
+ return markdown
35
+ .replaceAll('node scripts/client/cli.js', 'olimpyx')
36
+ .replaceAll('node packages/client/src/cli.js', 'olimpyx')
37
+ .replaceAll('npm exec -w @olimpyx/client olimpyx --', 'olimpyx')
38
+ .replaceAll('npm exec -w @mrciphersmith/olimpyx olimpyx --', 'olimpyx')
39
+ .replaceAll('npm exec -w @goodea/olimpyx olimpyx --', 'olimpyx')
40
+ .replace(
41
+ /The project-local installer bundles the dependency-free client inside this skill\.[\s\S]*?are also valid\.\n\n/,
42
+ 'Use the `olimpyx` command on your PATH (`npm i -g @goodea/olimpyx`).\n\n'
43
+ );
44
+ }
45
+
46
+ export async function installStarterSkill(host, options) {
47
+ const target = skillRoot(host, options);
48
+ await mkdir(target, { recursive: true });
49
+ await writeFile(join(target, 'SKILL.md'), await loadStarterSkill());
50
+ return target;
51
+ }
package/src/state.js ADDED
@@ -0,0 +1,151 @@
1
+ import { chmod, mkdir, readFile, writeFile, rename, readdir, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+
5
+ async function readJson(path, fallback) {
6
+ try { return JSON.parse(await readFile(path, 'utf8')); }
7
+ catch (error) { if (error.code === 'ENOENT') return fallback; throw error; }
8
+ }
9
+
10
+ async function atomicJson(path, value, mode = 0o600) {
11
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
12
+ await writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode });
13
+ await rename(temp, path);
14
+ }
15
+
16
+ export class LocalState {
17
+ constructor(root) { this.root = root; }
18
+ async init() {
19
+ await mkdir(this.root, { recursive: true, mode: 0o700 });
20
+ await mkdir(join(this.root, 'persona-revisions'), { recursive: true, mode: 0o700 });
21
+ }
22
+ async loadConfig() { return readJson(join(this.root, 'config.json'), {}); }
23
+ async saveConfig(config) { await this.init(); await atomicJson(join(this.root, 'config.json'), config); return config; }
24
+ async loadCredential() { return (await readFile(join(this.root, 'credential'), 'utf8')).trim(); }
25
+ async loadOwnerCredential() { return (await readFile(join(this.root, 'owner-credential'), 'utf8')).trim(); }
26
+ async saveCredential(token) {
27
+ if (!token || /\s/.test(token)) throw new Error('Credential must be a non-empty single-line value');
28
+ await this.init();
29
+ const path = join(this.root, 'credential');
30
+ await writeFile(path, token, { mode: 0o600 }); await chmod(path, 0o600);
31
+ }
32
+ async saveOwnerCredential(token) {
33
+ if (!token || /\s/.test(token)) throw new Error('Credential must be a non-empty single-line value');
34
+ await this.init(); const path = join(this.root, 'owner-credential');
35
+ await writeFile(path, token, { mode: 0o600 }); await chmod(path, 0o600);
36
+ }
37
+ async loadSession() {
38
+ const metadata = await readJson(join(this.root, 'session.json'), null);
39
+ if (!metadata) return null;
40
+ return { ...metadata, token: (await readFile(join(this.root, 'session-credential'), 'utf8')).trim() };
41
+ }
42
+ async saveSession(session, callerId, callerLeaseMs = 75_000) {
43
+ if (!session?.session_id || !session?.session_token || !callerId) throw new Error('Complete session and callerId are required');
44
+ await this.init();
45
+ const credentialPath = join(this.root, 'session-credential');
46
+ await writeFile(credentialPath, session.session_token, { mode: 0o600 }); await chmod(credentialPath, 0o600);
47
+ const metadata = { session_id: session.session_id, expires_at: session.expires_at, caller_id: callerId, caller_deadline: new Date(Date.now() + Math.min(callerLeaseMs, 85_000)).toISOString(), inbox_cursor: session.inbox_cursor ?? null };
48
+ await atomicJson(join(this.root, 'session.json'), metadata); return metadata;
49
+ }
50
+ async renewSession(callerId, updates = {}, callerLeaseMs = 75_000) {
51
+ const session = await this.loadSession();
52
+ if (!session) throw new Error('No local session. Run session begin first.');
53
+ if (session.caller_id !== callerId) throw new Error('callerId does not own this participant session');
54
+ if (Date.parse(session.caller_deadline) <= Date.now()) throw new Error('Local caller lease expired; begin a new session');
55
+ const { token: _token, ...metadata } = session;
56
+ const next = { ...metadata, ...updates, caller_deadline: new Date(Date.now() + Math.min(callerLeaseMs, 85_000)).toISOString() };
57
+ await atomicJson(join(this.root, 'session.json'), next); return next;
58
+ }
59
+ async clearSession() { await Promise.all([rm(join(this.root, 'session.json'), { force: true }), rm(join(this.root, 'session-credential'), { force: true })]); }
60
+ async beginMutation(method, path, body, explicitKey) {
61
+ if (explicitKey !== undefined) {
62
+ if (typeof explicitKey !== 'string' || !explicitKey.trim()) throw new Error('--idempotency-key must be a non-empty value');
63
+ return { fingerprint: null, key: explicitKey };
64
+ }
65
+ await this.init();
66
+ const fingerprint = createHash('sha256').update(JSON.stringify({ method: method.toUpperCase(), path, body: body ?? null })).digest('hex');
67
+ const pending = await readJson(join(this.root, 'pending-mutations.json'), {});
68
+ if (!pending[fingerprint]) {
69
+ pending[fingerprint] = { key: randomUUID(), method: method.toUpperCase(), path, created_at: new Date().toISOString() };
70
+ await atomicJson(join(this.root, 'pending-mutations.json'), pending);
71
+ }
72
+ return { fingerprint, key: pending[fingerprint].key };
73
+ }
74
+ async completeMutation(fingerprint) {
75
+ if (!fingerprint) return;
76
+ const path = join(this.root, 'pending-mutations.json');
77
+ const pending = await readJson(path, {});
78
+ delete pending[fingerprint];
79
+ if (Object.keys(pending).length === 0) await rm(path, { force: true });
80
+ else await atomicJson(path, pending);
81
+ }
82
+ async currentPersona() { return readJson(join(this.root, 'persona.json'), null); }
83
+ async savePersona(persona, reason = 'owner edit') {
84
+ await this.init();
85
+ const current = await this.currentPersona();
86
+ const revision = `${Date.now()}-${crypto.randomUUID()}`;
87
+ const record = { revision, ordinal: Number(current?.ordinal ?? 0) + 1, parent_revision: current?.revision ?? null, created_at: new Date().toISOString(), reason, persona };
88
+ await atomicJson(join(this.root, 'persona-revisions', `${revision}.json`), record);
89
+ await atomicJson(join(this.root, 'persona.json'), record);
90
+ return record;
91
+ }
92
+ async listPersonaRevisions() {
93
+ await this.init();
94
+ const names = (await readdir(join(this.root, 'persona-revisions'))).filter((name) => name.endsWith('.json')).sort();
95
+ const records = await Promise.all(names.map((name) => readJson(join(this.root, 'persona-revisions', name), null)));
96
+ return records.sort((a, b) => Number(a.ordinal ?? 0) - Number(b.ordinal ?? 0) || a.created_at.localeCompare(b.created_at));
97
+ }
98
+ async rollbackPersona(revision) {
99
+ if (!/^\d{13}-[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(revision)) throw new Error('Invalid persona revision identifier');
100
+ const record = await readJson(join(this.root, 'persona-revisions', `${revision}.json`), null);
101
+ if (!record) throw new Error(`Persona revision not found: ${revision}`);
102
+ const revisions = await this.listPersonaRevisions();
103
+ const targetIndex = revisions.findIndex((item) => item.revision === revision);
104
+ const laterRevisions = new Set(revisions.slice(targetIndex + 1).map((item) => item.revision));
105
+ const list = await this.influences();
106
+ let changed = false;
107
+ for (let index = 0; index < list.length; index += 1) {
108
+ const belongsToLaterPersona = list[index].persona_revision ? laterRevisions.has(list[index].persona_revision) : list[index].created_at > record.created_at;
109
+ if (list[index].active !== false && belongsToLaterPersona) {
110
+ list[index] = { ...list[index], active: false, archived_at: new Date().toISOString(), archive_reason: `persona rollback to ${revision}` };
111
+ changed = true;
112
+ }
113
+ }
114
+ if (changed) await atomicJson(join(this.root, 'influences.json'), list);
115
+ const restored = await this.savePersona(record.persona, `rollback to ${revision}`);
116
+ return { ...restored, reverted_persona_revisions: [...laterRevisions], target_created_at: record.created_at };
117
+ }
118
+ async pendingMemoryRollbacks() { return readJson(join(this.root, 'pending-memory-rollbacks.json'), []); }
119
+ async savePendingMemoryRollback(entry) {
120
+ if (!entry?.agentId) throw new Error('Cannot save a pending memory rollback without an agentId');
121
+ await this.init();
122
+ const list = await this.pendingMemoryRollbacks();
123
+ const filtered = list.filter((item) => item.idempotencyKey !== entry.idempotencyKey);
124
+ filtered.push({ created_at: new Date().toISOString(), ...entry });
125
+ await atomicJson(join(this.root, 'pending-memory-rollbacks.json'), filtered);
126
+ return entry;
127
+ }
128
+ async clearPendingMemoryRollback(idempotencyKey) {
129
+ const list = await this.pendingMemoryRollbacks();
130
+ const remaining = list.filter((item) => item.idempotencyKey !== idempotencyKey);
131
+ const path = join(this.root, 'pending-memory-rollbacks.json');
132
+ if (remaining.length === 0) await rm(path, { force: true });
133
+ else await atomicJson(path, remaining);
134
+ }
135
+ async influences() { return readJson(join(this.root, 'influences.json'), []); }
136
+ async saveInfluence(influence) {
137
+ await this.init();
138
+ const list = await this.influences();
139
+ const persona = await this.currentPersona();
140
+ const record = { id: influence.id ?? crypto.randomUUID(), created_at: new Date().toISOString(), persona_revision: persona?.revision ?? null, ...influence };
141
+ list.push(record); await atomicJson(join(this.root, 'influences.json'), list); return record;
142
+ }
143
+ async archiveInfluence(source) {
144
+ const list = await this.influences();
145
+ const index = list.findLastIndex((item) => item.source === source && item.active !== false);
146
+ if (index < 0) throw new Error(`Active influence not found: ${source}`);
147
+ list[index] = { ...list[index], active: false, archived_at: new Date().toISOString() };
148
+ await atomicJson(join(this.root, 'influences.json'), list);
149
+ return list[index];
150
+ }
151
+ }