@moxxy/core 0.6.3 → 0.21.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/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/host-options.d.ts +2 -0
- package/dist/plugins/host-options.d.ts.map +1 -1
- package/dist/plugins/host.d.ts +7 -0
- package/dist/plugins/host.d.ts.map +1 -1
- package/dist/plugins/host.js +1 -0
- package/dist/plugins/host.js.map +1 -1
- package/dist/plugins/registry-kinds.d.ts +1 -0
- package/dist/plugins/registry-kinds.d.ts.map +1 -1
- package/dist/plugins/registry-kinds.js +12 -0
- package/dist/plugins/registry-kinds.js.map +1 -1
- package/dist/registries/active-def-registry.d.ts +46 -9
- package/dist/registries/active-def-registry.d.ts.map +1 -1
- package/dist/registries/active-def-registry.js +39 -10
- package/dist/registries/active-def-registry.js.map +1 -1
- package/dist/registries/event-stores.d.ts +13 -0
- package/dist/registries/event-stores.d.ts.map +1 -0
- package/dist/registries/event-stores.js +14 -0
- package/dist/registries/event-stores.js.map +1 -0
- package/dist/registries/modes.d.ts +6 -0
- package/dist/registries/modes.d.ts.map +1 -1
- package/dist/registries/modes.js +10 -0
- package/dist/registries/modes.js.map +1 -1
- package/dist/registries/services.d.ts +15 -0
- package/dist/registries/services.d.ts.map +1 -0
- package/dist/registries/services.js +26 -0
- package/dist/registries/services.js.map +1 -0
- package/dist/run-turn.d.ts.map +1 -1
- package/dist/run-turn.js +2 -0
- package/dist/run-turn.js.map +1 -1
- package/dist/session-runtime.d.ts +3 -1
- package/dist/session-runtime.d.ts.map +1 -1
- package/dist/session.d.ts +25 -3
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +80 -10
- package/dist/session.js.map +1 -1
- package/dist/sessions/jsonl-event-store.d.ts +16 -0
- package/dist/sessions/jsonl-event-store.d.ts.map +1 -0
- package/dist/sessions/jsonl-event-store.js +30 -0
- package/dist/sessions/jsonl-event-store.js.map +1 -0
- package/dist/sessions/persistence.d.ts +3 -58
- package/dist/sessions/persistence.d.ts.map +1 -1
- package/dist/sessions/persistence.js +2 -0
- package/dist/sessions/persistence.js.map +1 -1
- package/dist/subagents/run-child.d.ts.map +1 -1
- package/dist/subagents/run-child.js +2 -0
- package/dist/subagents/run-child.js.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +11 -6
- package/src/plugins/host-options.ts +2 -0
- package/src/plugins/host.test.ts +7 -1
- package/src/plugins/host.ts +8 -0
- package/src/plugins/registry-kinds.test.ts +17 -0
- package/src/plugins/registry-kinds.ts +14 -0
- package/src/registries/active-def-registry.test.ts +42 -0
- package/src/registries/active-def-registry.ts +70 -13
- package/src/registries/event-stores.test.ts +77 -0
- package/src/registries/event-stores.ts +15 -0
- package/src/registries/modes.ts +12 -0
- package/src/registries/services.test.ts +29 -0
- package/src/registries/services.ts +33 -0
- package/src/run-turn.ts +2 -0
- package/src/session-runtime.ts +3 -0
- package/src/session.ts +91 -10
- package/src/sessions/jsonl-event-store.ts +31 -0
- package/src/sessions/persistence.ts +11 -58
- package/src/subagents/run-child.ts +2 -0
- package/dist/preferences.d.ts +0 -40
- package/dist/preferences.d.ts.map +0 -1
- package/dist/preferences.js +0 -59
- package/dist/preferences.js.map +0 -1
- package/src/preferences.test.ts +0 -156
- package/src/preferences.ts +0 -85
package/src/preferences.test.ts
DELETED
|
@@ -1,156 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
import { promises as fs } from 'node:fs';
|
|
3
|
-
import * as os from 'node:os';
|
|
4
|
-
import * as path from 'node:path';
|
|
5
|
-
import { loadPreferences, preferencesPath, savePreferences } from './preferences.js';
|
|
6
|
-
|
|
7
|
-
// preferencesPath() resolves ~/.moxxy/preferences.json via os.homedir(), which
|
|
8
|
-
// derives from HOME (POSIX) / USERPROFILE (Windows). Point both at a tmpdir per
|
|
9
|
-
// test so the suite never touches the developer's real preferences file.
|
|
10
|
-
let tmpHome: string;
|
|
11
|
-
let savedHome: string | undefined;
|
|
12
|
-
let savedUserProfile: string | undefined;
|
|
13
|
-
let savedMoxxyHome: string | undefined;
|
|
14
|
-
|
|
15
|
-
beforeEach(async () => {
|
|
16
|
-
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-prefs-'));
|
|
17
|
-
savedHome = process.env.HOME;
|
|
18
|
-
savedUserProfile = process.env.USERPROFILE;
|
|
19
|
-
savedMoxxyHome = process.env.MOXXY_HOME;
|
|
20
|
-
process.env.HOME = tmpHome;
|
|
21
|
-
process.env.USERPROFILE = tmpHome;
|
|
22
|
-
// The default-home tests below assert the `~/.moxxy` fallback, so MOXXY_HOME
|
|
23
|
-
// must be unset for them (it would otherwise win).
|
|
24
|
-
delete process.env.MOXXY_HOME;
|
|
25
|
-
await fs.mkdir(path.join(tmpHome, '.moxxy'), { recursive: true });
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
afterEach(async () => {
|
|
29
|
-
if (savedHome === undefined) delete process.env.HOME;
|
|
30
|
-
else process.env.HOME = savedHome;
|
|
31
|
-
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
|
32
|
-
else process.env.USERPROFILE = savedUserProfile;
|
|
33
|
-
if (savedMoxxyHome === undefined) delete process.env.MOXXY_HOME;
|
|
34
|
-
else process.env.MOXXY_HOME = savedMoxxyHome;
|
|
35
|
-
await fs.rm(tmpHome, { recursive: true, force: true });
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
const readRaw = async (): Promise<unknown> =>
|
|
39
|
-
JSON.parse(await fs.readFile(preferencesPath(), 'utf8'));
|
|
40
|
-
|
|
41
|
-
describe('preferences store', () => {
|
|
42
|
-
it('resolves the path under the (overridden) home dir', () => {
|
|
43
|
-
expect(preferencesPath()).toBe(path.join(tmpHome, '.moxxy', 'preferences.json'));
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('honors $MOXXY_HOME so preferences follow the relocated data dir', async () => {
|
|
47
|
-
const altHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-prefs-alt-'));
|
|
48
|
-
const prevMoxxyHome = process.env.MOXXY_HOME;
|
|
49
|
-
process.env.MOXXY_HOME = altHome;
|
|
50
|
-
try {
|
|
51
|
-
// Path now lives directly under MOXXY_HOME, NOT under ~/.moxxy.
|
|
52
|
-
expect(preferencesPath()).toBe(path.join(altHome, 'preferences.json'));
|
|
53
|
-
await savePreferences({ model: 'relocated' });
|
|
54
|
-
// Written to the relocated dir, not the homedir fallback.
|
|
55
|
-
const raw = await fs.readFile(path.join(altHome, 'preferences.json'), 'utf8');
|
|
56
|
-
expect(JSON.parse(raw).model).toBe('relocated');
|
|
57
|
-
// The homedir fallback location must remain empty.
|
|
58
|
-
await expect(
|
|
59
|
-
fs.readFile(path.join(tmpHome, '.moxxy', 'preferences.json'), 'utf8'),
|
|
60
|
-
).rejects.toMatchObject({ code: 'ENOENT' });
|
|
61
|
-
} finally {
|
|
62
|
-
if (prevMoxxyHome === undefined) delete process.env.MOXXY_HOME;
|
|
63
|
-
else process.env.MOXXY_HOME = prevMoxxyHome;
|
|
64
|
-
await fs.rm(altHome, { recursive: true, force: true });
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('returns an empty object when the file is missing', async () => {
|
|
69
|
-
expect(await loadPreferences()).toEqual({});
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('returns an empty object when the file is corrupt', async () => {
|
|
73
|
-
await fs.writeFile(preferencesPath(), '{ not json', 'utf8');
|
|
74
|
-
expect(await loadPreferences()).toEqual({});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('round-trips a patch through disk', async () => {
|
|
78
|
-
await savePreferences({ model: 'gpt-5.4-mini', providerName: 'openai' });
|
|
79
|
-
const loaded = await loadPreferences();
|
|
80
|
-
expect(loaded.model).toBe('gpt-5.4-mini');
|
|
81
|
-
expect(loaded.providerName).toBe('openai');
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('merges a second patch without clobbering unrelated fields', async () => {
|
|
85
|
-
await savePreferences({ providerName: 'openai' });
|
|
86
|
-
await savePreferences({ model: 'gpt-5.4-mini' });
|
|
87
|
-
const loaded = await loadPreferences();
|
|
88
|
-
expect(loaded.providerName).toBe('openai');
|
|
89
|
-
expect(loaded.model).toBe('gpt-5.4-mini');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('migrates legacy mode names on load', async () => {
|
|
93
|
-
// "tool-use" is the legacy id that migrateModeName maps to "default".
|
|
94
|
-
await fs.writeFile(
|
|
95
|
-
preferencesPath(),
|
|
96
|
-
JSON.stringify({ mode: 'tool-use' }) + '\n',
|
|
97
|
-
'utf8',
|
|
98
|
-
);
|
|
99
|
-
const loaded = await loadPreferences();
|
|
100
|
-
expect(loaded.mode).toBe('default');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('writes a trailing newline atomically (no partial file)', async () => {
|
|
104
|
-
await savePreferences({ model: 'm1' });
|
|
105
|
-
const raw = await fs.readFile(preferencesPath(), 'utf8');
|
|
106
|
-
expect(raw.endsWith('\n')).toBe(true);
|
|
107
|
-
expect(JSON.parse(raw)).toEqual({ model: 'm1' });
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it('serializes overlapping saves so no update is lost (invariant #5)', async () => {
|
|
111
|
-
// Two patches firing concurrently each read-merge-write the same file.
|
|
112
|
-
// Without the mutex the later writer clobbers the earlier writer's field.
|
|
113
|
-
await Promise.all([
|
|
114
|
-
savePreferences({ model: 'a' }),
|
|
115
|
-
savePreferences({ mode: 'goal' }),
|
|
116
|
-
]);
|
|
117
|
-
const loaded = await loadPreferences();
|
|
118
|
-
expect(loaded.model).toBe('a');
|
|
119
|
-
expect(loaded.mode).toBe('goal');
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
it('swallows a write failure: logs to stderr but never throws (best-effort contract)', async () => {
|
|
123
|
-
// savePreferences is documented best-effort — a persist failure must NOT
|
|
124
|
-
// bubble out and break the slash-command / shutdown that triggered it. Force
|
|
125
|
-
// an unwritable target by pointing MOXXY_HOME *under a regular file*, so the
|
|
126
|
-
// atomic writer's `mkdir(dirname)` fails with ENOTDIR.
|
|
127
|
-
const blocker = path.join(tmpHome, 'not-a-dir');
|
|
128
|
-
await fs.writeFile(blocker, 'x', 'utf8');
|
|
129
|
-
const prevMoxxyHome = process.env.MOXXY_HOME;
|
|
130
|
-
process.env.MOXXY_HOME = path.join(blocker, 'nested'); // dirname is a file → mkdir fails
|
|
131
|
-
const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
|
|
132
|
-
try {
|
|
133
|
-
// Must resolve, not reject — the pick still took effect in-session.
|
|
134
|
-
await expect(savePreferences({ model: 'm' })).resolves.toBeUndefined();
|
|
135
|
-
// The failure is surfaced (not silently swallowed) on stderr.
|
|
136
|
-
expect(stderr).toHaveBeenCalled();
|
|
137
|
-
} finally {
|
|
138
|
-
stderr.mockRestore();
|
|
139
|
-
if (prevMoxxyHome === undefined) delete process.env.MOXXY_HOME;
|
|
140
|
-
else process.env.MOXXY_HOME = prevMoxxyHome;
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('keeps ALL distinct keys present under many overlapping writers', async () => {
|
|
145
|
-
const patches: Array<Partial<Record<`k${number}`, string>>> = [];
|
|
146
|
-
for (let i = 0; i < 25; i++) patches.push({ [`k${i}`]: `v${i}` });
|
|
147
|
-
// Fire all saves at once; each merges a distinct key.
|
|
148
|
-
await Promise.all(
|
|
149
|
-
patches.map((p) => savePreferences(p as Record<string, string>)),
|
|
150
|
-
);
|
|
151
|
-
const loaded = (await readRaw()) as Record<string, string>;
|
|
152
|
-
for (let i = 0; i < 25; i++) {
|
|
153
|
-
expect(loaded[`k${i}`]).toBe(`v${i}`);
|
|
154
|
-
}
|
|
155
|
-
});
|
|
156
|
-
});
|
package/src/preferences.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs';
|
|
2
|
-
import { createMutex, migrateModeName } from '@moxxy/sdk';
|
|
3
|
-
import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* User-level runtime preferences persisted at ~/.moxxy/preferences.json.
|
|
7
|
-
* Distinct from `~/.moxxy/config.yaml` (user-edited, source of truth for
|
|
8
|
-
* provider/plugin wiring) — this file is mutated by the TUI as the user
|
|
9
|
-
* picks a model / loop strategy via slash commands, so it survives across
|
|
10
|
-
* CLI invocations. A missing file or unreadable contents is a no-op:
|
|
11
|
-
* preferences are best-effort, never load-blocking.
|
|
12
|
-
*/
|
|
13
|
-
export interface MoxxyPreferences {
|
|
14
|
-
/** Active provider name (e.g. "openai", "anthropic"). */
|
|
15
|
-
readonly providerName?: string;
|
|
16
|
-
/** Active model id under that provider (e.g. "gpt-5.4-mini"). */
|
|
17
|
-
readonly model?: string;
|
|
18
|
-
/** Active loop strategy name (e.g. "default", "research", "goal"). */
|
|
19
|
-
readonly mode?: string;
|
|
20
|
-
/**
|
|
21
|
-
* Providers the user disabled (desktop Settings → Providers toggle).
|
|
22
|
-
* Seeded into the ProviderRegistry at boot; a listed provider stays
|
|
23
|
-
* registered but can't be activated until re-enabled.
|
|
24
|
-
*/
|
|
25
|
-
readonly disabledProviders?: ReadonlyArray<string>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function preferencesPath(): string {
|
|
29
|
-
// Route through `moxxyPath` so a `$MOXXY_HOME` override relocates preferences
|
|
30
|
-
// alongside the rest of the data dir (config.yaml, vault, providers.json, …).
|
|
31
|
-
// Identical to `~/.moxxy/preferences.json` when MOXXY_HOME is unset.
|
|
32
|
-
return moxxyPath('preferences.json');
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Read preferences from disk. Returns an empty object when the file
|
|
37
|
-
* doesn't exist or fails to parse — preferences are an optional layer,
|
|
38
|
-
* never a hard dependency of session bootstrap.
|
|
39
|
-
*/
|
|
40
|
-
export async function loadPreferences(): Promise<MoxxyPreferences> {
|
|
41
|
-
try {
|
|
42
|
-
const raw = await fs.readFile(preferencesPath(), 'utf8');
|
|
43
|
-
const parsed = JSON.parse(raw) as unknown;
|
|
44
|
-
if (parsed && typeof parsed === 'object') {
|
|
45
|
-
const prefs = parsed as MoxxyPreferences;
|
|
46
|
-
return prefs.mode ? { ...prefs, mode: migrateModeName(prefs.mode) } : prefs;
|
|
47
|
-
}
|
|
48
|
-
return {};
|
|
49
|
-
} catch {
|
|
50
|
-
return {};
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Serializes the read-modify-write in `savePreferences` so two concurrent
|
|
55
|
-
// saves (e.g. a /model pick and a provider-disable toggle firing close
|
|
56
|
-
// together) can't both read the same snapshot and have the second write
|
|
57
|
-
// clobber the first's field. This is the single write path for the file —
|
|
58
|
-
// other packages (CLI, runner, channels) route their fire-and-forget saves
|
|
59
|
-
// through it, and any load-modify-save must go through it too.
|
|
60
|
-
const writeMutex = createMutex();
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Merge-and-write preferences. Reads the current file (if any), merges
|
|
64
|
-
* the patch on top so we don't blow away unrelated fields, and writes
|
|
65
|
-
* the result back atomically. The whole read-merge-write runs inside a
|
|
66
|
-
* module-level mutex so overlapping saves serialize (invariant #5) and
|
|
67
|
-
* never lose an update. Best-effort: a write failure logs to stderr but
|
|
68
|
-
* does not throw — the user's pick still takes effect in this session,
|
|
69
|
-
* just won't persist across invocations.
|
|
70
|
-
*/
|
|
71
|
-
export async function savePreferences(patch: Partial<MoxxyPreferences>): Promise<void> {
|
|
72
|
-
return writeMutex.run(async () => {
|
|
73
|
-
const current = await loadPreferences();
|
|
74
|
-
const next: MoxxyPreferences = { ...current, ...patch };
|
|
75
|
-
const target = preferencesPath();
|
|
76
|
-
try {
|
|
77
|
-
await writeFileAtomic(target, JSON.stringify(next, null, 2) + '\n');
|
|
78
|
-
} catch (err) {
|
|
79
|
-
process.stderr.write(
|
|
80
|
-
`moxxy: failed to persist preferences to ${target}: ` +
|
|
81
|
-
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
});
|
|
85
|
-
}
|