@moxxy/sdk 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.
Files changed (211) hide show
  1. package/dist/agent.d.ts +39 -0
  2. package/dist/agent.d.ts.map +1 -0
  3. package/dist/agent.js +2 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/cache-strategy.d.ts +35 -0
  6. package/dist/cache-strategy.d.ts.map +1 -0
  7. package/dist/cache-strategy.js +2 -0
  8. package/dist/cache-strategy.js.map +1 -0
  9. package/dist/channel.d.ts +146 -0
  10. package/dist/channel.d.ts.map +1 -0
  11. package/dist/channel.js +2 -0
  12. package/dist/channel.js.map +1 -0
  13. package/dist/client-session.d.ts +83 -0
  14. package/dist/client-session.d.ts.map +1 -0
  15. package/dist/client-session.js +2 -0
  16. package/dist/client-session.js.map +1 -0
  17. package/dist/command.d.ts +90 -0
  18. package/dist/command.d.ts.map +1 -0
  19. package/dist/command.js +15 -0
  20. package/dist/command.js.map +1 -0
  21. package/dist/compactor-helpers.d.ts +34 -0
  22. package/dist/compactor-helpers.d.ts.map +1 -0
  23. package/dist/compactor-helpers.js +156 -0
  24. package/dist/compactor-helpers.js.map +1 -0
  25. package/dist/compactor.d.ts +20 -0
  26. package/dist/compactor.d.ts.map +1 -0
  27. package/dist/compactor.js +2 -0
  28. package/dist/compactor.js.map +1 -0
  29. package/dist/define.d.ts +51 -0
  30. package/dist/define.d.ts.map +1 -0
  31. package/dist/define.js +71 -0
  32. package/dist/define.js.map +1 -0
  33. package/dist/elision-helpers.d.ts +27 -0
  34. package/dist/elision-helpers.d.ts.map +1 -0
  35. package/dist/elision-helpers.js +109 -0
  36. package/dist/elision-helpers.js.map +1 -0
  37. package/dist/elision-state.d.ts +49 -0
  38. package/dist/elision-state.d.ts.map +1 -0
  39. package/dist/elision-state.js +144 -0
  40. package/dist/elision-state.js.map +1 -0
  41. package/dist/embedding-cache.d.ts +32 -0
  42. package/dist/embedding-cache.d.ts.map +1 -0
  43. package/dist/embedding-cache.js +0 -0
  44. package/dist/embedding-cache.js.map +1 -0
  45. package/dist/embedding.d.ts +21 -0
  46. package/dist/embedding.d.ts.map +1 -0
  47. package/dist/embedding.js +2 -0
  48. package/dist/embedding.js.map +1 -0
  49. package/dist/errors.d.ts +81 -0
  50. package/dist/errors.d.ts.map +1 -0
  51. package/dist/errors.js +241 -0
  52. package/dist/errors.js.map +1 -0
  53. package/dist/events.d.ts +193 -0
  54. package/dist/events.d.ts.map +1 -0
  55. package/dist/events.js +2 -0
  56. package/dist/events.js.map +1 -0
  57. package/dist/hooks.d.ts +52 -0
  58. package/dist/hooks.d.ts.map +1 -0
  59. package/dist/hooks.js +2 -0
  60. package/dist/hooks.js.map +1 -0
  61. package/dist/ids.d.ts +18 -0
  62. package/dist/ids.d.ts.map +1 -0
  63. package/dist/ids.js +7 -0
  64. package/dist/ids.js.map +1 -0
  65. package/dist/index.d.ts +45 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +18 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/install-hints.d.ts +26 -0
  70. package/dist/install-hints.d.ts.map +1 -0
  71. package/dist/install-hints.js +15 -0
  72. package/dist/install-hints.js.map +1 -0
  73. package/dist/isolation.d.ts +125 -0
  74. package/dist/isolation.d.ts.map +1 -0
  75. package/dist/isolation.js +11 -0
  76. package/dist/isolation.js.map +1 -0
  77. package/dist/log.d.ts +11 -0
  78. package/dist/log.d.ts.map +1 -0
  79. package/dist/log.js +2 -0
  80. package/dist/log.js.map +1 -0
  81. package/dist/mode-helpers.d.ts +99 -0
  82. package/dist/mode-helpers.d.ts.map +1 -0
  83. package/dist/mode-helpers.js +368 -0
  84. package/dist/mode-helpers.js.map +1 -0
  85. package/dist/mode.d.ts +133 -0
  86. package/dist/mode.d.ts.map +1 -0
  87. package/dist/mode.js +2 -0
  88. package/dist/mode.js.map +1 -0
  89. package/dist/permission.d.ts +35 -0
  90. package/dist/permission.d.ts.map +1 -0
  91. package/dist/permission.js +2 -0
  92. package/dist/permission.js.map +1 -0
  93. package/dist/plugin.d.ts +88 -0
  94. package/dist/plugin.d.ts.map +1 -0
  95. package/dist/plugin.js +2 -0
  96. package/dist/plugin.js.map +1 -0
  97. package/dist/provider-utils.d.ts +40 -0
  98. package/dist/provider-utils.d.ts.map +1 -0
  99. package/dist/provider-utils.js +163 -0
  100. package/dist/provider-utils.js.map +1 -0
  101. package/dist/provider.d.ts +219 -0
  102. package/dist/provider.d.ts.map +1 -0
  103. package/dist/provider.js +2 -0
  104. package/dist/provider.js.map +1 -0
  105. package/dist/requirements.d.ts +22 -0
  106. package/dist/requirements.d.ts.map +1 -0
  107. package/dist/requirements.js +2 -0
  108. package/dist/requirements.js.map +1 -0
  109. package/dist/schemas.d.ts +228 -0
  110. package/dist/schemas.d.ts.map +1 -0
  111. package/dist/schemas.js +88 -0
  112. package/dist/schemas.js.map +1 -0
  113. package/dist/session-like.d.ts +111 -0
  114. package/dist/session-like.d.ts.map +1 -0
  115. package/dist/session-like.js +2 -0
  116. package/dist/session-like.js.map +1 -0
  117. package/dist/skill.d.ts +30 -0
  118. package/dist/skill.d.ts.map +1 -0
  119. package/dist/skill.js +2 -0
  120. package/dist/skill.js.map +1 -0
  121. package/dist/subagent.d.ts +45 -0
  122. package/dist/subagent.d.ts.map +1 -0
  123. package/dist/subagent.js +11 -0
  124. package/dist/subagent.js.map +1 -0
  125. package/dist/token-accounting.d.ts +73 -0
  126. package/dist/token-accounting.d.ts.map +1 -0
  127. package/dist/token-accounting.js +129 -0
  128. package/dist/token-accounting.js.map +1 -0
  129. package/dist/tool-dispatch.d.ts +19 -0
  130. package/dist/tool-dispatch.d.ts.map +1 -0
  131. package/dist/tool-dispatch.js +118 -0
  132. package/dist/tool-dispatch.js.map +1 -0
  133. package/dist/tool-gating.d.ts +32 -0
  134. package/dist/tool-gating.d.ts.map +1 -0
  135. package/dist/tool-gating.js +83 -0
  136. package/dist/tool-gating.js.map +1 -0
  137. package/dist/tool.d.ts +166 -0
  138. package/dist/tool.d.ts.map +1 -0
  139. package/dist/tool.js +2 -0
  140. package/dist/tool.js.map +1 -0
  141. package/dist/transcriber.d.ts +64 -0
  142. package/dist/transcriber.d.ts.map +1 -0
  143. package/dist/transcriber.js +21 -0
  144. package/dist/transcriber.js.map +1 -0
  145. package/dist/tunnel.d.ts +25 -0
  146. package/dist/tunnel.d.ts.map +1 -0
  147. package/dist/tunnel.js +2 -0
  148. package/dist/tunnel.js.map +1 -0
  149. package/dist/type-contracts.d.ts +2 -0
  150. package/dist/type-contracts.d.ts.map +1 -0
  151. package/dist/type-contracts.js +4 -0
  152. package/dist/type-contracts.js.map +1 -0
  153. package/dist/types.test-d.d.ts +2 -0
  154. package/dist/types.test-d.d.ts.map +1 -0
  155. package/dist/types.test-d.js +22 -0
  156. package/dist/types.test-d.js.map +1 -0
  157. package/dist/view-renderer.d.ts +97 -0
  158. package/dist/view-renderer.d.ts.map +1 -0
  159. package/dist/view-renderer.js +72 -0
  160. package/dist/view-renderer.js.map +1 -0
  161. package/package.json +38 -0
  162. package/src/agent.ts +38 -0
  163. package/src/cache-strategy.ts +39 -0
  164. package/src/channel.ts +158 -0
  165. package/src/client-session.ts +88 -0
  166. package/src/command.ts +86 -0
  167. package/src/compactor-helpers.test.ts +206 -0
  168. package/src/compactor-helpers.ts +163 -0
  169. package/src/compactor.ts +20 -0
  170. package/src/define.test.ts +76 -0
  171. package/src/define.ts +113 -0
  172. package/src/elision-helpers.ts +143 -0
  173. package/src/elision-state.ts +170 -0
  174. package/src/embedding-cache.ts +0 -0
  175. package/src/embedding.ts +22 -0
  176. package/src/errors.test.ts +176 -0
  177. package/src/errors.ts +308 -0
  178. package/src/events.ts +250 -0
  179. package/src/hooks.ts +54 -0
  180. package/src/ids.ts +16 -0
  181. package/src/index.ts +304 -0
  182. package/src/install-hints.ts +43 -0
  183. package/src/isolation.ts +148 -0
  184. package/src/log.ts +11 -0
  185. package/src/loop-helpers.test.ts +78 -0
  186. package/src/mode-helpers.ts +483 -0
  187. package/src/mode.ts +139 -0
  188. package/src/package-root.test.ts +40 -0
  189. package/src/permission.ts +37 -0
  190. package/src/plugin.ts +92 -0
  191. package/src/provider-utils.test.ts +84 -0
  192. package/src/provider-utils.ts +171 -0
  193. package/src/provider.ts +195 -0
  194. package/src/requirements.ts +35 -0
  195. package/src/schemas.test.ts +106 -0
  196. package/src/schemas.ts +97 -0
  197. package/src/session-like.ts +118 -0
  198. package/src/skill.ts +34 -0
  199. package/src/subagent.ts +46 -0
  200. package/src/token-accounting.test.ts +93 -0
  201. package/src/token-accounting.ts +202 -0
  202. package/src/token-efficiency.test.ts +459 -0
  203. package/src/tool-dispatch.ts +137 -0
  204. package/src/tool-gating.ts +107 -0
  205. package/src/tool.ts +175 -0
  206. package/src/transcriber.ts +71 -0
  207. package/src/tunnel.ts +26 -0
  208. package/src/type-contracts.ts +4 -0
  209. package/src/types.test-d.ts +29 -0
  210. package/src/view-renderer.test.ts +90 -0
  211. package/src/view-renderer.ts +156 -0
@@ -0,0 +1,20 @@
1
+ import type { CompactionEvent, MoxxyEvent } from './events.js';
2
+ import type { EventLogReader } from './log.js';
3
+
4
+ export interface TokenBudget {
5
+ readonly contextWindow: number;
6
+ readonly estimatedTokens: number;
7
+ readonly reserveForOutput: number;
8
+ }
9
+
10
+ export interface CompactContext {
11
+ readonly log: EventLogReader;
12
+ readonly budget: TokenBudget;
13
+ readonly signal: AbortSignal;
14
+ }
15
+
16
+ export interface CompactorDef {
17
+ readonly name: string;
18
+ shouldCompact(log: EventLogReader, budget: TokenBudget): boolean;
19
+ compact(events: ReadonlyArray<MoxxyEvent>, ctx: CompactContext): Promise<Omit<CompactionEvent, keyof import('./events.js').EventBase> & { ts?: number }>;
20
+ }
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { z } from 'zod';
3
+ import {
4
+ defineCompactor,
5
+ defineMode,
6
+ definePermission,
7
+ definePlugin,
8
+ defineProvider,
9
+ defineSkill,
10
+ defineTool,
11
+ defineTranscriber,
12
+ } from './define.js';
13
+
14
+ describe('define* factories', () => {
15
+ it('definePlugin freezes and stamps __moxxy + version', () => {
16
+ const p = definePlugin({ name: 'p', tools: [] });
17
+ expect(p.__moxxy).toBe('plugin');
18
+ expect(p.version).toBe('0.0.0');
19
+ expect(Object.isFrozen(p)).toBe(true);
20
+ });
21
+
22
+ it('definePlugin preserves explicit version', () => {
23
+ const p = definePlugin({ name: 'p', version: '1.2.3' });
24
+ expect(p.version).toBe('1.2.3');
25
+ });
26
+
27
+ it('definePlugin defaults version when spec.version is explicitly undefined', () => {
28
+ // Reproduces the spread-order bug: a spec with `version: undefined`
29
+ // (e.g. produced by destructuring) must not erase the default.
30
+ const spec = { name: 'p', version: undefined as string | undefined };
31
+ const p = definePlugin(spec);
32
+ expect(p.version).toBe('0.0.0');
33
+ });
34
+
35
+ it('defineTool round-trips typed input', async () => {
36
+ const tool = defineTool({
37
+ name: 'echo',
38
+ description: 'echoes',
39
+ inputSchema: z.object({ msg: z.string() }),
40
+ handler: (input) => input.msg.toUpperCase(),
41
+ });
42
+ expect(tool.name).toBe('echo');
43
+ const out = await tool.handler({ msg: 'hi' }, {} as never);
44
+ expect(out).toBe('HI');
45
+ });
46
+
47
+ it('defineProvider, defineMode, defineCompactor, definePermission, defineSkill, defineTranscriber all freeze', () => {
48
+ const items = [
49
+ defineProvider({ name: 'p', models: [], createClient: () => ({}) as never }),
50
+ defineMode({ name: 'l', run: async function* () {} }),
51
+ defineCompactor({ name: 'c', shouldCompact: () => false, compact: async () => ({}) as never }),
52
+ definePermission({ action: 'allow' }),
53
+ defineSkill({ frontmatter: { name: 'foo', description: 'd' }, body: '' }),
54
+ defineTranscriber({
55
+ name: 't',
56
+ createClient: () => ({ name: 't', transcribe: async () => ({ text: '' }) }),
57
+ }),
58
+ ];
59
+ for (const item of items) expect(Object.isFrozen(item)).toBe(true);
60
+ });
61
+
62
+ it('defineTranscriber preserves displayName + createClient identity', () => {
63
+ const t = defineTranscriber({
64
+ name: 'whisper-fake',
65
+ displayName: 'Whisper (fake)',
66
+ createClient: (cfg) => ({
67
+ name: 'whisper-fake',
68
+ transcribe: async () => ({ text: String(cfg.echo ?? '') }),
69
+ }),
70
+ });
71
+ expect(t.name).toBe('whisper-fake');
72
+ expect(t.displayName).toBe('Whisper (fake)');
73
+ const client = t.createClient({ echo: 'hi' });
74
+ expect(client.name).toBe('whisper-fake');
75
+ });
76
+ });
package/src/define.ts ADDED
@@ -0,0 +1,113 @@
1
+ import type { AgentDef } from './agent.js';
2
+ import type { CacheStrategyDef } from './cache-strategy.js';
3
+ import type { ChannelDef } from './channel.js';
4
+ import type { CommandDef } from './command.js';
5
+ import type { CompactorDef } from './compactor.js';
6
+ import type { ModeDef } from './mode.js';
7
+ import type { PermissionRule } from './permission.js';
8
+ import type { Plugin, PluginSpec } from './plugin.js';
9
+ import type { ProviderDef } from './provider.js';
10
+ import type { SkillDef, SkillFrontmatter } from './skill.js';
11
+ import type { ToolCompactPresentation, ToolContext, ToolDef } from './tool.js';
12
+ import type { ToolIsolationSpec } from './isolation.js';
13
+ import type { TranscriberDef } from './transcriber.js';
14
+ import type { ViewRendererDef } from './view-renderer.js';
15
+ import type { TunnelProviderDef } from './tunnel.js';
16
+ import type { z } from 'zod';
17
+
18
+ export function definePlugin(spec: PluginSpec): Plugin {
19
+ // Spread spec first so the defaults below can't be clobbered by an
20
+ // explicit `version: undefined` in the spec (which violates Plugin.version
21
+ // typed as `string`).
22
+ return Object.freeze({
23
+ ...spec,
24
+ __moxxy: 'plugin' as const,
25
+ version: spec.version ?? '0.0.0',
26
+ });
27
+ }
28
+
29
+ /**
30
+ * `defineTool` carries an extra generic `<S, O>` so the `handler`'s `input`
31
+ * parameter is typed as `z.output<S>` rather than `unknown` — that's the
32
+ * authoring-ergonomics benefit. The returned `ToolDef` widens the handler
33
+ * to the runtime contract `(unknown, ToolContext) => unknown` because the
34
+ * registry parses input via `inputSchema` before calling.
35
+ */
36
+ export function defineTool<S extends z.ZodTypeAny, O = unknown>(spec: {
37
+ name: string;
38
+ description: string;
39
+ inputSchema: S;
40
+ inputJsonSchema?: unknown;
41
+ outputSchema?: z.ZodType<O>;
42
+ permission?: PermissionRule;
43
+ handler: (input: z.output<S>, ctx: ToolContext) => Promise<O> | O;
44
+ compact?: ToolCompactPresentation;
45
+ isolation?: ToolIsolationSpec;
46
+ }): ToolDef {
47
+ return Object.freeze({
48
+ name: spec.name,
49
+ description: spec.description,
50
+ inputSchema: spec.inputSchema,
51
+ inputJsonSchema: spec.inputJsonSchema,
52
+ outputSchema: spec.outputSchema,
53
+ permission: spec.permission,
54
+ handler: spec.handler as (input: unknown, ctx: ToolContext) => Promise<unknown> | unknown,
55
+ compact: spec.compact,
56
+ isolation: spec.isolation,
57
+ });
58
+ }
59
+
60
+ // Every other `defineX` follows the same `(spec: XDef): XDef` shape: it
61
+ // freezes the spec and hands it back. The compile-time win is a clean
62
+ // "this is how you author one" signature; the runtime win is
63
+ // Object.freeze so plugin authors can't mutate published defs.
64
+
65
+ export function defineProvider(spec: ProviderDef): ProviderDef {
66
+ return Object.freeze(spec);
67
+ }
68
+
69
+ export function defineMode(spec: ModeDef): ModeDef {
70
+ return Object.freeze(spec);
71
+ }
72
+
73
+ export function defineCompactor(spec: CompactorDef): CompactorDef {
74
+ return Object.freeze(spec);
75
+ }
76
+
77
+ export function defineCacheStrategy(spec: CacheStrategyDef): CacheStrategyDef {
78
+ return Object.freeze(spec);
79
+ }
80
+
81
+ export function defineViewRenderer(spec: ViewRendererDef): ViewRendererDef {
82
+ return Object.freeze(spec);
83
+ }
84
+
85
+ export function defineTunnelProvider(spec: TunnelProviderDef): TunnelProviderDef {
86
+ return Object.freeze(spec);
87
+ }
88
+
89
+ export function defineChannel<TStartOpts = unknown>(
90
+ spec: ChannelDef<TStartOpts>,
91
+ ): ChannelDef<TStartOpts> {
92
+ return Object.freeze(spec);
93
+ }
94
+
95
+ export function definePermission(spec: PermissionRule): PermissionRule {
96
+ return Object.freeze(spec);
97
+ }
98
+
99
+ export function defineSkill(spec: { frontmatter: SkillFrontmatter; body: string }): SkillDef {
100
+ return Object.freeze(spec);
101
+ }
102
+
103
+ export function defineTranscriber(spec: TranscriberDef): TranscriberDef {
104
+ return Object.freeze(spec);
105
+ }
106
+
107
+ export function defineCommand(spec: CommandDef): CommandDef {
108
+ return Object.freeze(spec);
109
+ }
110
+
111
+ export function defineAgent(spec: AgentDef): AgentDef {
112
+ return Object.freeze(spec);
113
+ }
@@ -0,0 +1,143 @@
1
+ import { estimateContextTokens } from './compactor-helpers.js';
2
+ import { toolResultBytes } from './elision-state.js';
3
+ import type { EmittedEvent, MoxxyEvent } from './events.js';
4
+ import type { ElisionSettings, ModeContext } from './mode.js';
5
+
6
+ /**
7
+ * Turn-boundary elision (context-on-demand). Called once per loop iteration by
8
+ * every mode, right next to `runCompactionIfNeeded`. When the recent window
9
+ * exceeds `keepRecentTurns` AND the context is at least `minContextRatioToElide`
10
+ * full, it advances a high-water mark (in whole-turn steps) by appending an
11
+ * `ElisionEvent`. `projectMessagesFromLog` then renders events at or below that
12
+ * mark as compact stubs the model can expand via the `recall` tool.
13
+ *
14
+ * Hard invariants (independent of config): never elide the in-progress turn,
15
+ * never elide the first user_prompt (task anchor — enforced in projection),
16
+ * and degrade to a no-op on any internal error so an elision bug can't kill a
17
+ * turn. The mark only advances on completed-turn boundaries, which keeps the
18
+ * elided prefix byte-stable across a turn's iterations so caching still hits.
19
+ */
20
+ export interface ResolvedElisionSettings {
21
+ readonly enabled: boolean;
22
+ readonly keepRecentTurns: number;
23
+ readonly minContextRatioToElide: number;
24
+ readonly elideConversational: boolean;
25
+ readonly conversationalRecallThreshold: number;
26
+ readonly maxRecallBytes: number;
27
+ readonly neverElideTools: ReadonlyArray<string>;
28
+ }
29
+
30
+ const DEFAULTS: ResolvedElisionSettings = {
31
+ enabled: true,
32
+ keepRecentTurns: 4,
33
+ minContextRatioToElide: 0.3,
34
+ // User-selected default: aggressive context-on-demand (collapse old text
35
+ // turns too), but with adaptive auto-disable below.
36
+ elideConversational: true,
37
+ // After this many seq-based recalls, conversational elision switches off for
38
+ // the session (the model is clearly leaning on elided text turns).
39
+ conversationalRecallThreshold: 4,
40
+ maxRecallBytes: 32_768,
41
+ neverElideTools: [],
42
+ };
43
+
44
+ export function resolveElisionSettings(s?: ElisionSettings): ResolvedElisionSettings {
45
+ return {
46
+ enabled: s?.enabled ?? DEFAULTS.enabled,
47
+ // Hard floor: never keep fewer than 2 recent turns verbatim.
48
+ keepRecentTurns: Math.max(2, s?.keepRecentTurns ?? DEFAULTS.keepRecentTurns),
49
+ minContextRatioToElide: s?.minContextRatioToElide ?? DEFAULTS.minContextRatioToElide,
50
+ elideConversational: s?.elideConversational ?? DEFAULTS.elideConversational,
51
+ conversationalRecallThreshold:
52
+ s?.conversationalRecallThreshold ?? DEFAULTS.conversationalRecallThreshold,
53
+ maxRecallBytes: s?.maxRecallBytes ?? DEFAULTS.maxRecallBytes,
54
+ neverElideTools: s?.neverElideTools ?? DEFAULTS.neverElideTools,
55
+ };
56
+ }
57
+
58
+ export async function runElisionIfNeeded(ctx: ModeContext): Promise<void> {
59
+ try {
60
+ const s = resolveElisionSettings(ctx.elision);
61
+ if (!s.enabled) return;
62
+
63
+ const descriptor = ctx.provider.models.find((m) => m.id === ctx.model);
64
+ const contextWindow = descriptor?.contextWindow;
65
+ if (!contextWindow || contextWindow <= 0) return;
66
+
67
+ // Gate: below this fill the whole history fits comfortably — eliding would
68
+ // only add missed-context risk for no token benefit.
69
+ if (estimateContextTokens(ctx.log) < s.minContextRatioToElide * contextWindow) return;
70
+
71
+ const events = ctx.log.slice();
72
+ if (events.length === 0) return;
73
+
74
+ const priorHWM = events.reduce(
75
+ (max, e) => (e.type === 'elision' ? Math.max(max, e.elidedThrough) : max),
76
+ -1,
77
+ );
78
+
79
+ // Distinct COMPLETED turns in order (exclude the in-progress turn).
80
+ const turnOrder: string[] = [];
81
+ const seen = new Set<string>();
82
+ for (const e of events) {
83
+ if (e.turnId === ctx.turnId) continue;
84
+ if (!seen.has(e.turnId)) {
85
+ seen.add(e.turnId);
86
+ turnOrder.push(e.turnId);
87
+ }
88
+ }
89
+ if (turnOrder.length <= s.keepRecentTurns) return;
90
+
91
+ // Elide every completed turn before the recent window.
92
+ const elideTurns = new Set(turnOrder.slice(0, turnOrder.length - s.keepRecentTurns));
93
+ let to = -1;
94
+ for (const e of events) {
95
+ if (elideTurns.has(e.turnId)) to = Math.max(to, e.seq);
96
+ }
97
+ if (to <= priorHWM) return; // nothing new to elide
98
+
99
+ const from = priorHWM + 1;
100
+ const tokensSaved = estimateElisionSavings(events, from, to, s.neverElideTools);
101
+
102
+ const emittable: EmittedEvent = {
103
+ type: 'elision',
104
+ sessionId: ctx.sessionId,
105
+ turnId: ctx.turnId,
106
+ source: 'system',
107
+ elidedThrough: to,
108
+ stubbedRanges: [[from, to]],
109
+ elideConversational: s.elideConversational,
110
+ conversationalRecallThreshold: s.conversationalRecallThreshold,
111
+ maxRecallBytes: s.maxRecallBytes,
112
+ neverElideTools: s.neverElideTools,
113
+ tokensSaved,
114
+ };
115
+ await ctx.emit(emittable);
116
+ } catch {
117
+ // Elision is best-effort; a bug here must never kill the turn.
118
+ }
119
+ }
120
+
121
+ function estimateElisionSavings(
122
+ events: ReadonlyArray<MoxxyEvent>,
123
+ from: number,
124
+ to: number,
125
+ neverElide: ReadonlyArray<string>,
126
+ ): number {
127
+ const neverSet = new Set(neverElide);
128
+ const toolNameByCall = new Map<string, string>();
129
+ for (const e of events) {
130
+ if (e.type === 'tool_call_requested') toolNameByCall.set(e.callId, e.name);
131
+ }
132
+ let savedChars = 0;
133
+ for (const e of events) {
134
+ if (e.seq < from || e.seq > to) continue;
135
+ if (e.type === 'tool_result' && !e.error) {
136
+ const name = toolNameByCall.get(e.callId);
137
+ if (name && neverSet.has(name)) continue;
138
+ const bytes = toolResultBytes(e.output);
139
+ savedChars += Math.max(0, bytes - 60); // stub is ~60 chars
140
+ }
141
+ }
142
+ return Math.ceil(savedChars / 4);
143
+ }
@@ -0,0 +1,170 @@
1
+ import type { MoxxyEvent } from './events.js';
2
+
3
+ /**
4
+ * Shared elision decision logic — the single source of truth for "is this event
5
+ * stubbed, and to what size", consumed by BOTH `projectMessagesFromLog` (what we
6
+ * send) and `estimateContextTokens` (what we think we send). Keeping them in one
7
+ * leaf module guarantees the estimate matches reality (no overflow from
8
+ * undercounting pinned recalls) and avoids a circular import between the
9
+ * projection and the estimate.
10
+ */
11
+
12
+ /** Below this size, eliding a turn/result saves nothing — keep it verbatim. */
13
+ export const TINY_TURN_CHARS = 200;
14
+
15
+ /** Bytes of a tool_result payload (for stub labels + the recall cap). */
16
+ export function toolResultBytes(output: unknown, errorMessage?: string): number {
17
+ if (errorMessage !== undefined) return errorMessage.length;
18
+ if (typeof output === 'string') return output.length;
19
+ try {
20
+ return JSON.stringify(output ?? '').length;
21
+ } catch {
22
+ return 0;
23
+ }
24
+ }
25
+
26
+ /** Deterministic stub for an elided tool result. Stable bytes → cache-safe. */
27
+ export function toolResultStub(callId: string, bytes: number, recalled: boolean): string {
28
+ if (recalled) return `[output elided — already recalled below · call "${callId}"]`;
29
+ const label = bytes >= 1024 ? `${(Math.round(bytes / 102.4) / 10).toFixed(1)} KB` : `${bytes} B`;
30
+ return `[output elided — ${label} · recall("${callId}") to view]`;
31
+ }
32
+
33
+ /** Deterministic stub for an elided conversational (user/assistant) turn. */
34
+ export function conversationalStub(role: 'user' | 'assistant', seq: number): string {
35
+ return `[elided ${role} turn · recall({ seq: ${seq} }) to view]`;
36
+ }
37
+
38
+ export interface ElisionState {
39
+ /** Inclusive seq high-water mark; -1 when no elision is active. */
40
+ readonly hwm: number;
41
+ /** Conversational elision after the adaptive auto-disable check. */
42
+ readonly effectiveElideConversational: boolean;
43
+ readonly neverElide: ReadonlySet<string>;
44
+ readonly toolNameByCall: ReadonlyMap<string, string>;
45
+ /** callIds an earlier `recall` referenced (their stub says "already recalled"). */
46
+ readonly recalledCallIds: ReadonlySet<string>;
47
+ readonly recalledSeqs: ReadonlySet<number>;
48
+ /** callIds whose tool_result IS a recall's output — pinned verbatim... */
49
+ readonly recallResultCallIds: ReadonlySet<string>;
50
+ /** ...except these, which exceeded `maxRecallBytes` and get stubbed. */
51
+ readonly unpinnedRecallCallIds: ReadonlySet<string>;
52
+ /** Seq of the first user_prompt (task anchor) — never elided. */
53
+ readonly firstUserPromptSeq: number;
54
+ }
55
+
56
+ const EMPTY_STATE: ElisionState = {
57
+ hwm: -1,
58
+ effectiveElideConversational: false,
59
+ neverElide: new Set(),
60
+ toolNameByCall: new Map(),
61
+ recalledCallIds: new Set(),
62
+ recalledSeqs: new Set(),
63
+ recallResultCallIds: new Set(),
64
+ unpinnedRecallCallIds: new Set(),
65
+ firstUserPromptSeq: -1,
66
+ };
67
+
68
+ /**
69
+ * Derive elision state purely from the log: the active high-water mark + flags
70
+ * (from the latest ElisionEvent), the callId→tool map, recall bookkeeping, the
71
+ * adaptive conversational auto-disable, and which pinned recalls exceed the cap.
72
+ */
73
+ export function computeElisionState(events: ReadonlyArray<MoxxyEvent>): ElisionState {
74
+ let hwm = -1;
75
+ let elideConversational = false;
76
+ let conversationalRecallThreshold = Number.POSITIVE_INFINITY;
77
+ let maxRecallBytes = Number.POSITIVE_INFINITY;
78
+ let neverElide: ReadonlyArray<string> = [];
79
+ for (const e of events) {
80
+ if (e.type === 'elision' && e.elidedThrough > hwm) {
81
+ hwm = e.elidedThrough;
82
+ elideConversational = e.elideConversational;
83
+ conversationalRecallThreshold = e.conversationalRecallThreshold;
84
+ maxRecallBytes = e.maxRecallBytes;
85
+ neverElide = e.neverElideTools;
86
+ }
87
+ }
88
+ if (hwm < 0) return EMPTY_STATE;
89
+
90
+ const toolNameByCall = new Map<string, string>();
91
+ const recalledCallIds = new Set<string>();
92
+ const recalledSeqs = new Set<number>();
93
+ const recallResultCallIds = new Set<string>();
94
+ let seqRecalls = 0;
95
+ let firstUserPromptSeq = -1;
96
+
97
+ for (const e of events) {
98
+ if (e.type === 'tool_call_requested') {
99
+ toolNameByCall.set(e.callId, e.name);
100
+ if (e.name === 'recall') {
101
+ recallResultCallIds.add(e.callId);
102
+ const input = e.input as { callId?: unknown; seq?: unknown } | null | undefined;
103
+ if (input && typeof input === 'object') {
104
+ if (typeof input.callId === 'string') recalledCallIds.add(input.callId);
105
+ if (typeof input.seq === 'number') {
106
+ recalledSeqs.add(input.seq);
107
+ seqRecalls += 1; // seq-recalls = signal that TEXT elision is hurting
108
+ }
109
+ }
110
+ }
111
+ } else if (e.type === 'user_prompt' && firstUserPromptSeq < 0) {
112
+ firstUserPromptSeq = e.seq;
113
+ }
114
+ }
115
+
116
+ // Cap pinned recalls: keep the newest recall outputs within maxRecallBytes
117
+ // verbatim, stub the rest. Only matters once a recall result ages below HWM.
118
+ const unpinnedRecallCallIds = new Set<string>();
119
+ const agedRecalls = events
120
+ .filter(
121
+ (e): e is Extract<MoxxyEvent, { type: 'tool_result' }> =>
122
+ e.type === 'tool_result' && recallResultCallIds.has(e.callId) && e.seq <= hwm,
123
+ )
124
+ .sort((a, b) => b.seq - a.seq); // newest first
125
+ let pinned = 0;
126
+ for (const e of agedRecalls) {
127
+ pinned += toolResultBytes(e.output, e.error?.message);
128
+ if (pinned > maxRecallBytes) unpinnedRecallCallIds.add(e.callId);
129
+ }
130
+
131
+ return {
132
+ hwm,
133
+ effectiveElideConversational: elideConversational && seqRecalls < conversationalRecallThreshold,
134
+ neverElide: new Set(neverElide),
135
+ toolNameByCall,
136
+ recalledCallIds,
137
+ recalledSeqs,
138
+ recallResultCallIds,
139
+ unpinnedRecallCallIds,
140
+ firstUserPromptSeq,
141
+ };
142
+ }
143
+
144
+ /** Is this tool_result sent as a stub? (Shared by projection + estimate.) */
145
+ export function toolResultStubbed(
146
+ e: Extract<MoxxyEvent, { type: 'tool_result' }>,
147
+ state: ElisionState,
148
+ ): boolean {
149
+ if (e.seq > state.hwm || e.error) return false;
150
+ const name = state.toolNameByCall.get(e.callId);
151
+ if (name !== undefined && state.neverElide.has(name)) return false;
152
+ if (state.recallResultCallIds.has(e.callId)) {
153
+ // Recall outputs are pinned verbatim unless they blew the cap.
154
+ return state.unpinnedRecallCallIds.has(e.callId);
155
+ }
156
+ if (toolResultBytes(e.output) <= TINY_TURN_CHARS) return false; // tiny: keep full
157
+ return true;
158
+ }
159
+
160
+ /** Is this user/assistant turn collapsed to a conversational stub? */
161
+ export function conversationalStubbed(
162
+ e: Extract<MoxxyEvent, { type: 'user_prompt' | 'assistant_message' }>,
163
+ state: ElisionState,
164
+ ): boolean {
165
+ if (e.seq > state.hwm || !state.effectiveElideConversational) return false;
166
+ if (e.type === 'user_prompt' && e.seq === state.firstUserPromptSeq) return false; // anchor
167
+ const len = e.type === 'user_prompt' ? e.text.length : e.content.length;
168
+ if (len <= TINY_TURN_CHARS) return false;
169
+ return true;
170
+ }
Binary file
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Provider of dense vector embeddings for text. Used by @moxxy/plugin-memory
3
+ * for semantic recall. Implementations might call OpenAI, Voyage, Anthropic
4
+ * (when available), or run a local model via transformers.js.
5
+ *
6
+ * @moxxy/plugin-memory ships a built-in TF-IDF embedder for zero-dep operation
7
+ * — set `mode: 'auto'` (default) and it picks the registered provider when
8
+ * available, else TF-IDF.
9
+ */
10
+ export interface EmbeddingProvider {
11
+ /** Short stable name, e.g. 'openai-text-embedding-3-small'. */
12
+ readonly name: string;
13
+
14
+ /**
15
+ * Vector dimensionality. Consumers may verify this matches their stored
16
+ * index. For TF-IDF this is the vocab size (dynamic).
17
+ */
18
+ readonly dim: number | 'dynamic';
19
+
20
+ /** Embed a batch of strings. Returns vectors in input order. */
21
+ embed(texts: ReadonlyArray<string>): Promise<ReadonlyArray<ReadonlyArray<number>>>;
22
+ }