@moxxy/sdk 0.26.0 → 0.27.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/channel.d.ts +13 -0
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js +19 -0
- package/dist/channel.js.map +1 -1
- package/dist/event-store.d.ts +5 -1
- package/dist/event-store.d.ts.map +1 -1
- package/dist/event-store.js +24 -1
- package/dist/event-store.js.map +1 -1
- package/dist/events.d.ts +1 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/first-party.d.ts +17 -0
- package/dist/first-party.d.ts.map +1 -0
- package/dist/first-party.js +19 -0
- package/dist/first-party.js.map +1 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -4
- package/dist/index.js.map +1 -1
- package/dist/isolation.d.ts +14 -0
- package/dist/isolation.d.ts.map +1 -1
- package/dist/isolation.js +75 -0
- package/dist/isolation.js.map +1 -1
- package/dist/mode.d.ts +7 -0
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/plugin.d.ts +10 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/reflector.d.ts +58 -0
- package/dist/reflector.d.ts.map +1 -0
- package/dist/reflector.js +2 -0
- package/dist/reflector.js.map +1 -0
- package/dist/schemas.d.ts +215 -8
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +37 -0
- package/dist/schemas.js.map +1 -1
- package/dist/session-like.d.ts +52 -0
- package/dist/session-like.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/channel.ts +25 -0
- package/src/event-store.ts +16 -1
- package/src/events.ts +1 -0
- package/src/exit-after-pair.test.ts +32 -0
- package/src/first-party.test.ts +26 -0
- package/src/first-party.ts +19 -0
- package/src/index.ts +19 -4
- package/src/isolation-aggregate.test.ts +69 -0
- package/src/isolation.ts +67 -0
- package/src/mode.ts +7 -0
- package/src/plugin.ts +10 -1
- package/src/reflector.ts +61 -0
- package/src/schemas.ts +41 -0
- package/src/session-like.ts +49 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { EXIT_AFTER_PAIR_FLAG, exitAfterPairRequested } from './channel.js';
|
|
3
|
+
|
|
4
|
+
function ctx(
|
|
5
|
+
flags: Record<string, string | boolean | undefined>,
|
|
6
|
+
options?: Record<string, unknown>,
|
|
7
|
+
): Parameters<typeof exitAfterPairRequested>[0] {
|
|
8
|
+
return {
|
|
9
|
+
args: { positional: [], flags },
|
|
10
|
+
deps: { cwd: '/tmp', ...(options ? { options } : {}) },
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('exitAfterPairRequested', () => {
|
|
15
|
+
it('defaults to false (standalone pair keeps the channel running)', () => {
|
|
16
|
+
expect(exitAfterPairRequested(ctx({}))).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('honors the flag via args.flags', () => {
|
|
20
|
+
expect(exitAfterPairRequested(ctx({ [EXIT_AFTER_PAIR_FLAG]: true }))).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('honors the flag via deps.options (programmatic callers)', () => {
|
|
24
|
+
expect(exitAfterPairRequested(ctx({}, { [EXIT_AFTER_PAIR_FLAG]: true }))).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('requires boolean true — a string "true" flag is not an opt-in', () => {
|
|
28
|
+
// argv flags are string|boolean; only an explicit boolean true counts, so
|
|
29
|
+
// `--exit-after-pair=weird` values can't accidentally change lifecycle.
|
|
30
|
+
expect(exitAfterPairRequested(ctx({ [EXIT_AFTER_PAIR_FLAG]: 'true' }))).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { FIRST_PARTY_PLUGIN_SCOPE, isFirstPartyPackage } from './first-party.js';
|
|
3
|
+
|
|
4
|
+
describe('isFirstPartyPackage (third-party detection)', () => {
|
|
5
|
+
it('accepts packages under the @moxxy scope', () => {
|
|
6
|
+
expect(isFirstPartyPackage('@moxxy/plugin-channel-slack')).toBe(true);
|
|
7
|
+
expect(isFirstPartyPackage('@moxxy/sdk')).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('rejects bare (unscoped) packages', () => {
|
|
11
|
+
expect(isFirstPartyPackage('left-pad')).toBe(false);
|
|
12
|
+
expect(isFirstPartyPackage('moxxy-plugin-evil')).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('rejects other scopes, including lookalikes', () => {
|
|
16
|
+
expect(isFirstPartyPackage('@moxxyy/plugin-x')).toBe(false);
|
|
17
|
+
expect(isFirstPartyPackage('@moxxy-plugins/x')).toBe(false);
|
|
18
|
+
expect(isFirstPartyPackage('@evil/moxxy')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('rejects a scope-only prefix trick (@moxxy without the slash)', () => {
|
|
22
|
+
// `@moxxy` alone is not under the scope: the slash is part of the prefix.
|
|
23
|
+
expect(FIRST_PARTY_PLUGIN_SCOPE.endsWith('/')).toBe(true);
|
|
24
|
+
expect(isFirstPartyPackage('@moxxy')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The npm scope every first-party moxxy package lives under. First-party
|
|
3
|
+
* packages are the trusted, co-versioned set (published together via the
|
|
4
|
+
* fixed changeset group); anything outside this scope is third-party code
|
|
5
|
+
* the user pulled in from the registry and gets stricter treatment:
|
|
6
|
+
* post-install capability consent and the `thirdPartyRequireDeclaration`
|
|
7
|
+
* ratchet in `@moxxy/plugin-security`.
|
|
8
|
+
*/
|
|
9
|
+
export const FIRST_PARTY_PLUGIN_SCOPE = '@moxxy/';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Whether a package name belongs to the trusted first-party scope. The input
|
|
13
|
+
* is a bare npm package name (`@moxxy/plugin-x`, `some-plugin`) — pass names,
|
|
14
|
+
* not install specs (`name@1.2.3` would still work, but git/path specs are
|
|
15
|
+
* not names and should be resolved to one first).
|
|
16
|
+
*/
|
|
17
|
+
export function isFirstPartyPackage(packageName: string): boolean {
|
|
18
|
+
return packageName.startsWith(FIRST_PARTY_PLUGIN_SCOPE);
|
|
19
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -117,7 +117,9 @@ export type {
|
|
|
117
117
|
Isolator,
|
|
118
118
|
HandlerModuleRef,
|
|
119
119
|
} from './isolation.js';
|
|
120
|
-
export { ISOLATION_RANK } from './isolation.js';
|
|
120
|
+
export { ISOLATION_RANK, aggregateCapabilitySpecs } from './isolation.js';
|
|
121
|
+
|
|
122
|
+
export { FIRST_PARTY_PLUGIN_SCOPE, isFirstPartyPackage } from './first-party.js';
|
|
121
123
|
|
|
122
124
|
export type {
|
|
123
125
|
SubagentSpec,
|
|
@@ -190,6 +192,12 @@ export type {
|
|
|
190
192
|
SessionMeta,
|
|
191
193
|
SessionSource,
|
|
192
194
|
} from './event-store.js';
|
|
195
|
+
export { SESSION_SOURCES } from './event-store.js';
|
|
196
|
+
export type {
|
|
197
|
+
ReflectorDef,
|
|
198
|
+
ReflectContext,
|
|
199
|
+
ReflectionProposal,
|
|
200
|
+
} from './reflector.js';
|
|
193
201
|
// Node-runtime helpers (writeFileAtomic*, moxxyHome/moxxyPath,
|
|
194
202
|
// readRequestBody/bearerTokenMatches, channel-auth) are exported from the
|
|
195
203
|
// './server' subpath, NOT the main barrel — they statically reach node:*
|
|
@@ -228,6 +236,11 @@ export {
|
|
|
228
236
|
type MoxxyErrorCode,
|
|
229
237
|
type MoxxyErrorInit,
|
|
230
238
|
} from './errors.js';
|
|
239
|
+
// Shared retry primitives: a leak-safe, abort-aware sleep + the exponential
|
|
240
|
+
// back-off schedule. General-purpose — not mode-loop internals: the runner's
|
|
241
|
+
// connect retry and the desktop supervisor's restart wait use them too, so
|
|
242
|
+
// don't reimplement an ad-hoc `new Promise(setTimeout)` back-off elsewhere.
|
|
243
|
+
export { sleepWithAbort, nextBackoffMs } from './mode/abort-backoff.js';
|
|
231
244
|
export {
|
|
232
245
|
collectProviderStream,
|
|
233
246
|
runSingleShotTurn,
|
|
@@ -236,8 +249,6 @@ export {
|
|
|
236
249
|
buildSystemPromptWithSkills,
|
|
237
250
|
createStuckLoopDetector,
|
|
238
251
|
stableHash,
|
|
239
|
-
sleepWithAbort,
|
|
240
|
-
nextBackoffMs,
|
|
241
252
|
type CollectedToolUse,
|
|
242
253
|
type StreamResult,
|
|
243
254
|
type ProjectMessagesOptions,
|
|
@@ -367,7 +378,7 @@ export type {
|
|
|
367
378
|
ResolvedPluginManifest,
|
|
368
379
|
} from './plugin.js';
|
|
369
380
|
|
|
370
|
-
export { startChannelWith } from './channel.js';
|
|
381
|
+
export { startChannelWith, EXIT_AFTER_PAIR_FLAG, exitAfterPairRequested } from './channel.js';
|
|
371
382
|
export type {
|
|
372
383
|
Channel,
|
|
373
384
|
ChannelHandle,
|
|
@@ -458,10 +469,14 @@ export {
|
|
|
458
469
|
skillFrontmatterSchema,
|
|
459
470
|
pluginManifestSchema,
|
|
460
471
|
moxxyPackageSchema,
|
|
472
|
+
pluginSetupFieldSchema,
|
|
473
|
+
pluginSetupSchema,
|
|
461
474
|
requirementSchema,
|
|
462
475
|
type SkillFrontmatterInput,
|
|
463
476
|
type PluginManifestInput,
|
|
464
477
|
type MoxxyPackageInput,
|
|
478
|
+
type PluginSetupField,
|
|
479
|
+
type PluginSetupSpec,
|
|
465
480
|
} from './schemas.js';
|
|
466
481
|
|
|
467
482
|
export {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { aggregateCapabilitySpecs, type CapabilitySpec } from './isolation.js';
|
|
3
|
+
|
|
4
|
+
describe('aggregateCapabilitySpecs', () => {
|
|
5
|
+
it('empty / all-undefined input aggregates to an empty surface', () => {
|
|
6
|
+
expect(aggregateCapabilitySpecs([])).toEqual({});
|
|
7
|
+
expect(aggregateCapabilitySpecs([undefined, undefined])).toEqual({});
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('unions fs globs, env, and commands (sorted, deduped)', () => {
|
|
11
|
+
const a: CapabilitySpec = {
|
|
12
|
+
fs: { read: ['$cwd/**', '/tmp/**'], write: ['/tmp/**'] },
|
|
13
|
+
env: ['PATH', 'HOME'],
|
|
14
|
+
subprocess: true,
|
|
15
|
+
commands: ['npm'],
|
|
16
|
+
};
|
|
17
|
+
const b: CapabilitySpec = {
|
|
18
|
+
fs: { read: ['/tmp/**'] },
|
|
19
|
+
env: ['PATH'],
|
|
20
|
+
subprocess: true,
|
|
21
|
+
commands: ['git', 'npm'],
|
|
22
|
+
};
|
|
23
|
+
expect(aggregateCapabilitySpecs([a, b])).toEqual({
|
|
24
|
+
fs: { read: ['$cwd/**', '/tmp/**'], write: ['/tmp/**'] },
|
|
25
|
+
env: ['HOME', 'PATH'],
|
|
26
|
+
subprocess: true,
|
|
27
|
+
commands: ['git', 'npm'],
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('net takes the strongest mode: any > allowlist > none', () => {
|
|
32
|
+
const none: CapabilitySpec = { net: { mode: 'none' } };
|
|
33
|
+
const list: CapabilitySpec = { net: { mode: 'allowlist', hosts: ['b.com', 'a.com'] } };
|
|
34
|
+
const any: CapabilitySpec = { net: { mode: 'any' } };
|
|
35
|
+
|
|
36
|
+
expect(aggregateCapabilitySpecs([none])).toEqual({ net: { mode: 'none' } });
|
|
37
|
+
expect(aggregateCapabilitySpecs([none, list])).toEqual({
|
|
38
|
+
net: { mode: 'allowlist', hosts: ['a.com', 'b.com'] },
|
|
39
|
+
});
|
|
40
|
+
expect(aggregateCapabilitySpecs([list, any, none])).toEqual({ net: { mode: 'any' } });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('merges allowlist hosts across specs', () => {
|
|
44
|
+
const a: CapabilitySpec = { net: { mode: 'allowlist', hosts: ['registry.npmjs.org'] } };
|
|
45
|
+
const b: CapabilitySpec = { net: { mode: 'allowlist', hosts: ['api.slack.com'] } };
|
|
46
|
+
expect(aggregateCapabilitySpecs([a, b])).toEqual({
|
|
47
|
+
net: { mode: 'allowlist', hosts: ['api.slack.com', 'registry.npmjs.org'] },
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('timeMs and memMb take the max; undeclared entries contribute nothing', () => {
|
|
52
|
+
const a: CapabilitySpec = { timeMs: 30_000, memMb: 128 };
|
|
53
|
+
const b: CapabilitySpec = { timeMs: 600_000 };
|
|
54
|
+
expect(aggregateCapabilitySpecs([a, undefined, b])).toEqual({
|
|
55
|
+
timeMs: 600_000,
|
|
56
|
+
memMb: 128,
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('an "any" net dropped into a wide union does not resurrect allowlist hosts', () => {
|
|
61
|
+
// Regression guard: once mode is 'any', collected hosts must not leak
|
|
62
|
+
// back into the result as a misleading narrower-looking allowlist.
|
|
63
|
+
const spec = aggregateCapabilitySpecs([
|
|
64
|
+
{ net: { mode: 'allowlist', hosts: ['a.com'] } },
|
|
65
|
+
{ net: { mode: 'any' } },
|
|
66
|
+
]);
|
|
67
|
+
expect(spec.net).toEqual({ mode: 'any' });
|
|
68
|
+
});
|
|
69
|
+
});
|
package/src/isolation.ts
CHANGED
|
@@ -146,3 +146,70 @@ export const ISOLATION_RANK: Readonly<Record<IsolationStrength, number>> = Objec
|
|
|
146
146
|
wasm: 5,
|
|
147
147
|
docker: 6,
|
|
148
148
|
});
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Widest-wins union of capability declarations — the combined blast radius
|
|
152
|
+
* if every contributing tool exercised its full declared surface. Used for
|
|
153
|
+
* package-level views: `moxxy security audit --package`, the install-time
|
|
154
|
+
* capability report, and (later) signed-index capability manifests.
|
|
155
|
+
*
|
|
156
|
+
* Union semantics per axis: fs globs / env vars / commands are set-unions
|
|
157
|
+
* (sorted, deduped); net takes the strongest mode (`any` > `allowlist` with
|
|
158
|
+
* merged hosts > `none`); `subprocess` is an OR; `timeMs` / `memMb` take the
|
|
159
|
+
* max. `undefined` entries (undeclared tools) contribute nothing — callers
|
|
160
|
+
* report their count separately so a wide-open undeclared tool is never
|
|
161
|
+
* hidden behind a tidy aggregate.
|
|
162
|
+
*/
|
|
163
|
+
export function aggregateCapabilitySpecs(
|
|
164
|
+
specs: ReadonlyArray<CapabilitySpec | undefined>,
|
|
165
|
+
): CapabilitySpec {
|
|
166
|
+
const read = new Set<string>();
|
|
167
|
+
const write = new Set<string>();
|
|
168
|
+
const env = new Set<string>();
|
|
169
|
+
const commands = new Set<string>();
|
|
170
|
+
const hosts = new Set<string>();
|
|
171
|
+
let netMode: NetCapability['mode'] | undefined;
|
|
172
|
+
let subprocess = false;
|
|
173
|
+
let timeMs: number | undefined;
|
|
174
|
+
let memMb: number | undefined;
|
|
175
|
+
|
|
176
|
+
for (const spec of specs) {
|
|
177
|
+
if (!spec) continue;
|
|
178
|
+
for (const g of spec.fs?.read ?? []) read.add(g);
|
|
179
|
+
for (const g of spec.fs?.write ?? []) write.add(g);
|
|
180
|
+
if (spec.net) {
|
|
181
|
+
if (spec.net.mode === 'any') netMode = 'any';
|
|
182
|
+
else if (spec.net.mode === 'allowlist') {
|
|
183
|
+
if (netMode !== 'any') netMode = 'allowlist';
|
|
184
|
+
for (const h of spec.net.hosts) hosts.add(h);
|
|
185
|
+
} else netMode ??= 'none';
|
|
186
|
+
}
|
|
187
|
+
for (const e of spec.env ?? []) env.add(e);
|
|
188
|
+
if (spec.subprocess) subprocess = true;
|
|
189
|
+
for (const c of spec.commands ?? []) commands.add(c);
|
|
190
|
+
if (spec.timeMs !== undefined) timeMs = Math.max(timeMs ?? 0, spec.timeMs);
|
|
191
|
+
if (spec.memMb !== undefined) memMb = Math.max(memMb ?? 0, spec.memMb);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const sorted = (s: Set<string>): string[] => [...s].sort();
|
|
195
|
+
return {
|
|
196
|
+
...(read.size || write.size
|
|
197
|
+
? {
|
|
198
|
+
fs: {
|
|
199
|
+
...(read.size ? { read: sorted(read) } : {}),
|
|
200
|
+
...(write.size ? { write: sorted(write) } : {}),
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
: {}),
|
|
204
|
+
...(netMode === 'allowlist'
|
|
205
|
+
? { net: { mode: 'allowlist' as const, hosts: sorted(hosts) } }
|
|
206
|
+
: netMode
|
|
207
|
+
? { net: { mode: netMode } }
|
|
208
|
+
: {}),
|
|
209
|
+
...(env.size ? { env: sorted(env) } : {}),
|
|
210
|
+
...(timeMs !== undefined ? { timeMs } : {}),
|
|
211
|
+
...(memMb !== undefined ? { memMb } : {}),
|
|
212
|
+
...(subprocess ? { subprocess: true } : {}),
|
|
213
|
+
...(commands.size ? { commands: sorted(commands) } : {}),
|
|
214
|
+
};
|
|
215
|
+
}
|
package/src/mode.ts
CHANGED
|
@@ -46,6 +46,13 @@ export interface PluginHostHandle {
|
|
|
46
46
|
kinds?: ReadonlyArray<string>;
|
|
47
47
|
}>;
|
|
48
48
|
reload(): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* The plugin (package) that contributed the named tool, or undefined for
|
|
51
|
+
* unknown / dynamically-attached tools. Optional so a thin-client
|
|
52
|
+
* `RemoteSession` can omit it; powers plugin-level security routing
|
|
53
|
+
* (`security.perPlugin`) and package-scoped audit views.
|
|
54
|
+
*/
|
|
55
|
+
ownerOfTool?(toolName: string): string | undefined;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
/**
|
package/src/plugin.ts
CHANGED
|
@@ -17,8 +17,9 @@ import type { ViewRendererDef } from './view-renderer.js';
|
|
|
17
17
|
import type { TunnelProviderDef } from './tunnel.js';
|
|
18
18
|
import type { WorkflowExecutorDef } from './workflow.js';
|
|
19
19
|
import type { EventStoreDef } from './event-store.js';
|
|
20
|
+
import type { ReflectorDef } from './reflector.js';
|
|
20
21
|
|
|
21
|
-
export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'surface' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'synthesizer' | 'embedder' | 'isolator' | 'workflow-executor' | 'event-store';
|
|
22
|
+
export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'surface' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'synthesizer' | 'embedder' | 'isolator' | 'workflow-executor' | 'event-store' | 'reflector';
|
|
22
23
|
|
|
23
24
|
export interface PluginSpec {
|
|
24
25
|
readonly name: string;
|
|
@@ -53,6 +54,14 @@ export interface PluginSpec {
|
|
|
53
54
|
* boundary, since the store sees every event). See {@link EventStoreDef}.
|
|
54
55
|
*/
|
|
55
56
|
readonly eventStores?: ReadonlyArray<EventStoreDef>;
|
|
57
|
+
/**
|
|
58
|
+
* Reflector backends — the learning-loop block that watches a finished turn
|
|
59
|
+
* and *proposes* memory/skill improvements without silently writing. One
|
|
60
|
+
* active at a time, selected via `plugins.reflector.default`. NULLABLE: core
|
|
61
|
+
* seeds no floor, so reflection is opt-in — no registered reflector means the
|
|
62
|
+
* session never reflects. See {@link ReflectorDef}.
|
|
63
|
+
*/
|
|
64
|
+
readonly reflectors?: ReadonlyArray<ReflectorDef>;
|
|
56
65
|
readonly channels?: ReadonlyArray<ChannelDef>;
|
|
57
66
|
/**
|
|
58
67
|
* Interactive surfaces contributed by the plugin — long-lived panes a human
|
package/src/reflector.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { EventLogReader } from './log.js';
|
|
2
|
+
import type { SessionId, TurnId } from './ids.js';
|
|
3
|
+
import type { ServiceRegistry } from './services.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The learning-loop block — a swappable strategy that watches a finished turn
|
|
7
|
+
* and *proposes* (never silently writes) memory/skill improvements. It is the
|
|
8
|
+
* def-only sibling of the other single-active registries (compactor, cache
|
|
9
|
+
* strategy, …) but NULLABLE: core seeds NO floor, so reflection is entirely
|
|
10
|
+
* opt-in — a session with no registered reflector simply never reflects.
|
|
11
|
+
*
|
|
12
|
+
* The trust boundary is the "propose, don't write" contract. A reflector reads
|
|
13
|
+
* the turn's events and returns {@link ReflectionProposal}s; the driver that
|
|
14
|
+
* hosts it delivers those as a one-time nudge on the next provider call, phrased
|
|
15
|
+
* so the *model* may choose to call `memory_save` / `synthesize_skill` — which
|
|
16
|
+
* still go through the existing permission prompts. Nothing a reflector returns
|
|
17
|
+
* mutates memory or skills on its own.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The context handed to {@link ReflectorDef.reflect}. Scoped to one finished
|
|
22
|
+
* turn: `log.byTurn(turnId)` yields exactly that turn's events. `services`
|
|
23
|
+
* exposes the host's inter-plugin registry (e.g. `services.get('providers')`
|
|
24
|
+
* for a side-channel LLM pass); `signal` bounds the whole reflection (a timeout
|
|
25
|
+
* and/or session shutdown) so a slow provider can never wedge it.
|
|
26
|
+
*/
|
|
27
|
+
export interface ReflectContext {
|
|
28
|
+
readonly sessionId: SessionId;
|
|
29
|
+
readonly turnId: TurnId;
|
|
30
|
+
readonly cwd: string;
|
|
31
|
+
readonly log: EventLogReader;
|
|
32
|
+
readonly services: ServiceRegistry;
|
|
33
|
+
readonly signal: AbortSignal;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One improvement a reflector suggests. `kind` picks the target surface
|
|
38
|
+
* (`'memory'` → a fact worth `memory_save`; `'skill'` → a repeated procedure
|
|
39
|
+
* worth `synthesize_skill`); `title` is a short label; `nudge` is a
|
|
40
|
+
* one-paragraph suggestion addressed to the assistant. A proposal is a HINT,
|
|
41
|
+
* not a command — the model decides whether to act, and any resulting write
|
|
42
|
+
* still hits its own permission prompt.
|
|
43
|
+
*/
|
|
44
|
+
export interface ReflectionProposal {
|
|
45
|
+
readonly kind: 'memory' | 'skill';
|
|
46
|
+
readonly title: string;
|
|
47
|
+
/** One-paragraph suggestion addressed to the assistant. */
|
|
48
|
+
readonly nudge: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A registered reflector backend. `reflect` inspects the just-finished turn and
|
|
53
|
+
* returns 0 or more proposals (an empty array = "nothing worth suggesting").
|
|
54
|
+
* It MUST be side-effect-free with respect to memory/skills — it only reads and
|
|
55
|
+
* proposes — and MUST honor `ctx.signal` (abort/timeout).
|
|
56
|
+
*/
|
|
57
|
+
export interface ReflectorDef {
|
|
58
|
+
readonly name: string;
|
|
59
|
+
readonly displayName?: string;
|
|
60
|
+
reflect(ctx: ReflectContext): Promise<ReadonlyArray<ReflectionProposal>>;
|
|
61
|
+
}
|
package/src/schemas.ts
CHANGED
|
@@ -16,6 +16,7 @@ const pluginKindSchema = z.enum([
|
|
|
16
16
|
'command',
|
|
17
17
|
'transcriber',
|
|
18
18
|
'synthesizer',
|
|
19
|
+
'reflector',
|
|
19
20
|
]);
|
|
20
21
|
|
|
21
22
|
export const requirementSchema = z.object({
|
|
@@ -89,11 +90,51 @@ export const pluginManifestSchema = z.object({
|
|
|
89
90
|
* requirements may be authored; per-tool/per-transcriber/per-anything
|
|
90
91
|
* runtime declarations were removed in favor of static analysis.
|
|
91
92
|
*/
|
|
93
|
+
/**
|
|
94
|
+
* One field of a plugin's declarative setup step (`package.json#moxxy.setup`).
|
|
95
|
+
* Declarative-only so EVERY frontend (the init wizard, the TUI, desktop
|
|
96
|
+
* onboarding) can render it without executing plugin code:
|
|
97
|
+
* - `secret` values land in the VAULT (never plaintext config); the plugin's
|
|
98
|
+
* `options.<key>` gets a `${vault:<name>}` ref, resolved at boot.
|
|
99
|
+
* - other kinds land at `plugins.packages.<pkg>.options.<key>` in the user
|
|
100
|
+
* config through the shared schema-validated writer.
|
|
101
|
+
*/
|
|
102
|
+
export const pluginSetupFieldSchema = z.object({
|
|
103
|
+
key: z.string().min(1).regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, 'key must be an identifier'),
|
|
104
|
+
label: z.string().min(1),
|
|
105
|
+
description: z.string().optional(),
|
|
106
|
+
kind: z.enum(['secret', 'string', 'boolean', 'select']),
|
|
107
|
+
/** Vault entry name for `secret` fields. Default: `<PKG>_<KEY>` upper-snake. */
|
|
108
|
+
vaultKey: z.string().min(1).optional(),
|
|
109
|
+
/** Choices for `select` fields. */
|
|
110
|
+
options: z.array(z.string().min(1)).optional(),
|
|
111
|
+
/** Required fields block completion; optional ones may stay unset. */
|
|
112
|
+
required: z.boolean().optional(),
|
|
113
|
+
placeholder: z.string().optional(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A plugin's declarative configuration step, walked by `moxxy init` (and
|
|
118
|
+
* surfaced after an on-demand install). `required: true` means the plugin is
|
|
119
|
+
* left DISABLED until its required fields are provided — the author's way to
|
|
120
|
+
* say "this cannot work unconfigured".
|
|
121
|
+
*/
|
|
122
|
+
export const pluginSetupSchema = z.object({
|
|
123
|
+
title: z.string().min(1),
|
|
124
|
+
description: z.string().optional(),
|
|
125
|
+
required: z.boolean().optional(),
|
|
126
|
+
fields: z.array(pluginSetupFieldSchema).min(1),
|
|
127
|
+
});
|
|
128
|
+
|
|
92
129
|
export const moxxyPackageSchema = z.object({
|
|
93
130
|
plugin: pluginManifestSchema.optional(),
|
|
94
131
|
requirements: z.array(requirementSchema).optional(),
|
|
132
|
+
/** Declarative setup step users walk through in init / post-install. */
|
|
133
|
+
setup: pluginSetupSchema.optional(),
|
|
95
134
|
});
|
|
96
135
|
|
|
136
|
+
export type PluginSetupField = z.infer<typeof pluginSetupFieldSchema>;
|
|
137
|
+
export type PluginSetupSpec = z.infer<typeof pluginSetupSchema>;
|
|
97
138
|
export type SkillFrontmatterInput = z.infer<typeof skillFrontmatterSchema>;
|
|
98
139
|
export type PluginManifestInput = z.infer<typeof pluginManifestSchema>;
|
|
99
140
|
export type MoxxyPackageInput = z.infer<typeof moxxyPackageSchema>;
|
package/src/session-like.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { MoxxyEvent, TriggerOrigin, UserPromptAttachment } from './events.js';
|
|
2
2
|
import type { SessionId, TurnId } from './ids.js';
|
|
3
|
+
import type { CapabilitySpec } from './isolation.js';
|
|
3
4
|
import type { EventLogReader } from './log.js';
|
|
4
5
|
import type { ApprovalResolver, ModeBadge } from './mode.js';
|
|
5
6
|
import type { PermissionResolver } from './permission.js';
|
|
6
7
|
import type { ModelDescriptor, ProviderKeyValidation } from './provider.js';
|
|
8
|
+
import type { PluginSetupSpec } from './schemas.js';
|
|
7
9
|
import type { ToolCompactPresentation } from './tool.js';
|
|
8
10
|
|
|
9
11
|
/**
|
|
@@ -365,7 +367,42 @@ export interface PluginsAdminView {
|
|
|
365
367
|
install?(idOrSpec: string): Promise<{
|
|
366
368
|
readonly installed: string;
|
|
367
369
|
readonly registered: Readonly<Partial<Record<string, ReadonlyArray<string>>>>;
|
|
370
|
+
/**
|
|
371
|
+
* Combined capability surface of the tools this install registered —
|
|
372
|
+
* the package's blast radius, so a channel can render post-install
|
|
373
|
+
* consent (third-party packages) or an info line (first-party). Absent
|
|
374
|
+
* when the install registered no tools or the host cannot introspect
|
|
375
|
+
* isolation specs.
|
|
376
|
+
*/
|
|
377
|
+
readonly capabilities?: {
|
|
378
|
+
/** Tools that declared an isolation spec. */
|
|
379
|
+
readonly declared: number;
|
|
380
|
+
/** Tools the install registered. */
|
|
381
|
+
readonly total: number;
|
|
382
|
+
/** Widest-wins union of the declared specs. */
|
|
383
|
+
readonly surface: CapabilitySpec;
|
|
384
|
+
/** Tools with NO declaration: their surface is unknown, not empty. */
|
|
385
|
+
readonly undeclaredTools?: ReadonlyArray<string>;
|
|
386
|
+
};
|
|
387
|
+
/** Present when the package declares a `moxxy.setup` step to complete. */
|
|
388
|
+
readonly needsSetup?: { readonly title: string; readonly required: boolean };
|
|
368
389
|
}>;
|
|
390
|
+
/**
|
|
391
|
+
* The package's declarative setup step (`moxxy.setup`), read from its
|
|
392
|
+
* installed package.json — plain data, safe to render anywhere. Null when
|
|
393
|
+
* absent. Powers the post-install dialog and `/setup`.
|
|
394
|
+
*/
|
|
395
|
+
setupSpec?(packageName: string): Promise<PluginSetupSpec | null>;
|
|
396
|
+
/**
|
|
397
|
+
* Persist collected setup values: secrets → vault + `${vault:NAME}` option
|
|
398
|
+
* ref; other kinds → `plugins.packages.<pkg>.options.<key>`. A complete
|
|
399
|
+
* required setup re-enables the package; an incomplete one disables it
|
|
400
|
+
* (mirrors the init wizard). Optional capability per the seam convention.
|
|
401
|
+
*/
|
|
402
|
+
applySetup?(
|
|
403
|
+
packageName: string,
|
|
404
|
+
values: Readonly<Record<string, string | boolean>>,
|
|
405
|
+
): Promise<{ readonly complete: boolean; readonly missing: ReadonlyArray<string> }>;
|
|
369
406
|
}
|
|
370
407
|
|
|
371
408
|
/** IO a channel supplies to drive an interactive OAuth flow inside its own
|
|
@@ -470,4 +507,16 @@ export interface SessionLike {
|
|
|
470
507
|
pluginsAdmin?: PluginsAdminView;
|
|
471
508
|
/** Provider onboarding slice backing in-channel connect (key entry / OAuth). */
|
|
472
509
|
providerSetup?: ProviderSetupView;
|
|
510
|
+
/**
|
|
511
|
+
* Re-read the merged config from disk and live-apply the safe subset —
|
|
512
|
+
* backs the TUI /settings panel's write-then-apply. Structurally matches
|
|
513
|
+
* @moxxy/config's ConfigApplyResult (the SDK stays config-free). Absent on
|
|
514
|
+
* a RemoteSession: writes still land, but apply waits for a restart.
|
|
515
|
+
*/
|
|
516
|
+
configAdmin?: {
|
|
517
|
+
apply(): Promise<{
|
|
518
|
+
readonly applied: ReadonlyArray<string>;
|
|
519
|
+
readonly pending: ReadonlyArray<string>;
|
|
520
|
+
}>;
|
|
521
|
+
};
|
|
473
522
|
}
|