@moxxy/sdk 0.41.3 → 0.42.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 (172) hide show
  1. package/dist/auto-approve.d.ts +14 -0
  2. package/dist/auto-approve.d.ts.map +1 -0
  3. package/dist/auto-approve.js +29 -0
  4. package/dist/auto-approve.js.map +1 -0
  5. package/dist/channel.d.ts +9 -0
  6. package/dist/channel.d.ts.map +1 -1
  7. package/dist/channel.js +14 -0
  8. package/dist/channel.js.map +1 -1
  9. package/dist/client-session.d.ts +3 -0
  10. package/dist/client-session.d.ts.map +1 -1
  11. package/dist/compactor-helpers.d.ts +1 -1
  12. package/dist/compactor-helpers.d.ts.map +1 -1
  13. package/dist/compactor-helpers.js +14 -4
  14. package/dist/compactor-helpers.js.map +1 -1
  15. package/dist/computer-control.d.ts +77 -3
  16. package/dist/computer-control.d.ts.map +1 -1
  17. package/dist/computer-control.js +12 -1
  18. package/dist/computer-control.js.map +1 -1
  19. package/dist/conduct.d.ts +16 -0
  20. package/dist/conduct.d.ts.map +1 -0
  21. package/dist/conduct.js +33 -0
  22. package/dist/conduct.js.map +1 -0
  23. package/dist/define.d.ts +3 -0
  24. package/dist/define.d.ts.map +1 -1
  25. package/dist/define.js +3 -0
  26. package/dist/define.js.map +1 -1
  27. package/dist/elision-state.d.ts +9 -0
  28. package/dist/elision-state.d.ts.map +1 -1
  29. package/dist/elision-state.js +16 -3
  30. package/dist/elision-state.js.map +1 -1
  31. package/dist/events.d.ts +13 -1
  32. package/dist/events.d.ts.map +1 -1
  33. package/dist/fs-utils.d.ts.map +1 -1
  34. package/dist/fs-utils.js +43 -2
  35. package/dist/fs-utils.js.map +1 -1
  36. package/dist/index.d.ts +14 -7
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +12 -5
  39. package/dist/index.js.map +1 -1
  40. package/dist/locale.d.ts +9 -0
  41. package/dist/locale.d.ts.map +1 -0
  42. package/dist/locale.js +16 -0
  43. package/dist/locale.js.map +1 -0
  44. package/dist/mode/checkpoint.d.ts +7 -0
  45. package/dist/mode/checkpoint.d.ts.map +1 -1
  46. package/dist/mode/collect-stream.d.ts.map +1 -1
  47. package/dist/mode/collect-stream.js +3 -2
  48. package/dist/mode/collect-stream.js.map +1 -1
  49. package/dist/mode/project-messages.d.ts +7 -0
  50. package/dist/mode/project-messages.d.ts.map +1 -1
  51. package/dist/mode/project-messages.js +26 -4
  52. package/dist/mode/project-messages.js.map +1 -1
  53. package/dist/mode/react-loop.d.ts.map +1 -1
  54. package/dist/mode/react-loop.js +5 -3
  55. package/dist/mode/react-loop.js.map +1 -1
  56. package/dist/mode/stuck-loop.d.ts +8 -2
  57. package/dist/mode/stuck-loop.d.ts.map +1 -1
  58. package/dist/mode/stuck-loop.js +9 -1
  59. package/dist/mode/stuck-loop.js.map +1 -1
  60. package/dist/mode-helpers.d.ts +1 -1
  61. package/dist/mode-helpers.d.ts.map +1 -1
  62. package/dist/mode-helpers.js +1 -1
  63. package/dist/mode-helpers.js.map +1 -1
  64. package/dist/mode.d.ts +10 -3
  65. package/dist/mode.d.ts.map +1 -1
  66. package/dist/mode.js.map +1 -1
  67. package/dist/permission.d.ts +6 -0
  68. package/dist/permission.d.ts.map +1 -1
  69. package/dist/progress.d.ts +24 -0
  70. package/dist/progress.d.ts.map +1 -0
  71. package/dist/progress.js +59 -0
  72. package/dist/progress.js.map +1 -0
  73. package/dist/provider.d.ts +11 -1
  74. package/dist/provider.d.ts.map +1 -1
  75. package/dist/resolvers.js +2 -2
  76. package/dist/resolvers.js.map +1 -1
  77. package/dist/schemas.d.ts +9 -0
  78. package/dist/schemas.d.ts.map +1 -1
  79. package/dist/schemas.js +3 -0
  80. package/dist/schemas.js.map +1 -1
  81. package/dist/server.d.ts +1 -0
  82. package/dist/server.d.ts.map +1 -1
  83. package/dist/server.js +2 -0
  84. package/dist/server.js.map +1 -1
  85. package/dist/session-like.d.ts +21 -1
  86. package/dist/session-like.d.ts.map +1 -1
  87. package/dist/skill-mentions.d.ts +38 -0
  88. package/dist/skill-mentions.d.ts.map +1 -0
  89. package/dist/skill-mentions.js +84 -0
  90. package/dist/skill-mentions.js.map +1 -0
  91. package/dist/skill.d.ts +6 -0
  92. package/dist/skill.d.ts.map +1 -1
  93. package/dist/supersede.d.ts +31 -0
  94. package/dist/supersede.d.ts.map +1 -0
  95. package/dist/supersede.js +34 -0
  96. package/dist/supersede.js.map +1 -0
  97. package/dist/tool-dispatch.d.ts.map +1 -1
  98. package/dist/tool-dispatch.js +3 -2
  99. package/dist/tool-dispatch.js.map +1 -1
  100. package/dist/tool-gating.d.ts +9 -1
  101. package/dist/tool-gating.d.ts.map +1 -1
  102. package/dist/tool-gating.js +57 -7
  103. package/dist/tool-gating.js.map +1 -1
  104. package/dist/tool-modes.d.ts +8 -0
  105. package/dist/tool-modes.d.ts.map +1 -0
  106. package/dist/tool-modes.js +21 -0
  107. package/dist/tool-modes.js.map +1 -0
  108. package/dist/tool.d.ts +18 -0
  109. package/dist/tool.d.ts.map +1 -1
  110. package/dist/wait-for.d.ts +34 -0
  111. package/dist/wait-for.d.ts.map +1 -0
  112. package/dist/wait-for.js +67 -0
  113. package/dist/wait-for.js.map +1 -0
  114. package/package.json +9 -5
  115. package/src/auto-approve.test.ts +29 -0
  116. package/src/auto-approve.ts +29 -0
  117. package/src/channel-auth.test.ts +2 -1
  118. package/src/channel-control.test.ts +2 -1
  119. package/src/channel.ts +18 -0
  120. package/src/client-session.ts +3 -0
  121. package/src/compactor-helpers.test.ts +26 -0
  122. package/src/compactor-helpers.ts +16 -4
  123. package/src/computer-control.test.ts +11 -0
  124. package/src/computer-control.ts +16 -1
  125. package/src/conduct.test.ts +62 -0
  126. package/src/conduct.ts +37 -0
  127. package/src/cross-process-lock.test.ts +3 -2
  128. package/src/define.ts +6 -0
  129. package/src/elision-state.ts +24 -3
  130. package/src/events.ts +13 -1
  131. package/src/finish-pairing.test.ts +45 -0
  132. package/src/fs-utils.test.ts +40 -6
  133. package/src/fs-utils.ts +42 -2
  134. package/src/index.ts +38 -1
  135. package/src/json-file-store.test.ts +5 -3
  136. package/src/locale.test.ts +31 -0
  137. package/src/locale.ts +16 -0
  138. package/src/mode/checkpoint.ts +7 -0
  139. package/src/mode/collect-stream.ts +3 -2
  140. package/src/mode/project-messages.test.ts +72 -1
  141. package/src/mode/project-messages.ts +26 -5
  142. package/src/mode/react-loop.ts +6 -3
  143. package/src/mode/stuck-loop.ts +14 -3
  144. package/src/mode-helpers.ts +1 -0
  145. package/src/mode.ts +10 -3
  146. package/src/permission.ts +6 -0
  147. package/src/process-invocation.test.ts +3 -9
  148. package/src/progress.test.ts +81 -0
  149. package/src/progress.ts +73 -0
  150. package/src/provider.ts +12 -1
  151. package/src/resolvers.test.ts +14 -1
  152. package/src/resolvers.ts +2 -2
  153. package/src/schemas.ts +3 -0
  154. package/src/server.ts +2 -0
  155. package/src/session-like.ts +21 -1
  156. package/src/skill-mentions.test.ts +113 -0
  157. package/src/skill-mentions.ts +108 -0
  158. package/src/skill.ts +6 -0
  159. package/src/stuck-loop.test.ts +12 -0
  160. package/src/supersede.test.ts +115 -0
  161. package/src/supersede.ts +55 -0
  162. package/src/test-isolation.test.ts +26 -0
  163. package/src/token-efficiency.test.ts +7 -1
  164. package/src/tool-dispatch.test.ts +13 -0
  165. package/src/tool-dispatch.ts +3 -2
  166. package/src/tool-gating.test.ts +81 -0
  167. package/src/tool-gating.ts +53 -7
  168. package/src/tool-modes.test.ts +45 -0
  169. package/src/tool-modes.ts +23 -0
  170. package/src/tool.ts +18 -0
  171. package/src/wait-for.test.ts +132 -0
  172. package/src/wait-for.ts +79 -0
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { createDeferredPermissionResolver, evaluateToolRule } from './resolvers.js';
2
+ import { createAllowListResolver, createDeferredPermissionResolver, evaluateToolRule } from './resolvers.js';
3
3
  import type { PendingToolCall, PermissionRule } from './permission.js';
4
4
 
5
5
  function call(name: string, input: unknown = {}): PendingToolCall {
@@ -70,3 +70,16 @@ describe('createDeferredPermissionResolver scoped session grants', () => {
70
70
  expect(prompts).toBe(2);
71
71
  });
72
72
  });
73
+
74
+ describe('a decision made now', () => {
75
+ it('is marked when the prompt was answered for this call, not when an earlier answer is reused', async () => {
76
+ const resolver = createDeferredPermissionResolver({ prompt: async () => ({ mode: 'allow_session' }) });
77
+ const ctx = { sessionId: 's' };
78
+ expect((await resolver.check(call('computer_run', { app: 'Notes' }), ctx)).decidedNow).toBe(true);
79
+ expect((await resolver.check(call('computer_run', { app: 'Mail' }), ctx)).decidedNow).toBeUndefined();
80
+ });
81
+
82
+ it('is marked for a tool the run was started with on its allow-list', async () => {
83
+ expect((await createAllowListResolver(['computer_run']).check(call('computer_run', {}), { sessionId: 's' })).decidedNow).toBe(true);
84
+ });
85
+ });
package/src/resolvers.ts CHANGED
@@ -37,7 +37,7 @@ export function createAllowListResolver(toolNames: ReadonlyArray<string>): Permi
37
37
  return {
38
38
  name: 'allow-list',
39
39
  async check(call) {
40
- if (allowed.has(call.name)) return { mode: 'allow_session', reason: 'allow-list' };
40
+ if (allowed.has(call.name)) return { mode: 'allow_session', reason: 'allow-list', decidedNow: true };
41
41
  return { mode: 'deny', reason: `Tool '${call.name}' not in allow-list` };
42
42
  },
43
43
  };
@@ -123,7 +123,7 @@ export function createDeferredPermissionResolver(
123
123
  } else if (decision.mode === 'allow_session' || decision.mode === 'allow_always') {
124
124
  sessionAllows.add(call.name);
125
125
  }
126
- return decision;
126
+ return decision.mode === 'deny' ? decision : { ...decision, decidedNow: true };
127
127
  },
128
128
  abortAll(reason = 'channel closed') {
129
129
  for (const r of pending) r({ mode: 'deny', reason });
package/src/schemas.ts CHANGED
@@ -66,6 +66,9 @@ export const skillFrontmatterSchema = z.object({
66
66
  description: z.string().min(1).max(240),
67
67
  triggers: z.array(z.string().min(1)).optional(),
68
68
  'allowed-tools': z.array(z.string().min(1)).optional(),
69
+ aliases: z.array(z.string().regex(/^[a-z0-9][a-z0-9_-]*$/, 'alias must be slug-like')).optional(),
70
+ 'disallowed-tools': z.array(z.string().min(1)).optional(),
71
+ label: z.string().min(1).max(40).optional(),
69
72
  version: z.string().optional(),
70
73
  tags: z.array(z.string().min(1)).optional(),
71
74
  /** Opt the skill into automatic recurring/one-shot execution. */
package/src/server.ts CHANGED
@@ -41,6 +41,8 @@ export {
41
41
  redactProxyUrl,
42
42
  hasProxy,
43
43
  } from './egress.js';
44
+ // The UTF-8 character type a GUI-launched process lacks (read from process.env).
45
+ export { utf8Locale } from './locale.js';
44
46
  // Cross-process "fire exactly once" lock (node:fs). Value lives here; its
45
47
  // options type is re-exported from the main barrel like other erased types.
46
48
  export { CrossProcessFireLock } from './cross-process-lock.js';
@@ -4,7 +4,7 @@ import type { CapabilitySpec } from './isolation.js';
4
4
  import type { EventLogReader } from './log.js';
5
5
  import type { ApprovalResolver, ModeBadge } from './mode.js';
6
6
  import type { PermissionResolver } from './permission.js';
7
- import type { ModelDescriptor, ProviderKeyValidation } from './provider.js';
7
+ import type { ModelDescriptor, ProviderKeyValidation, ReasoningEffort } from './provider.js';
8
8
  import type { PluginSetupSpec } from './schemas.js';
9
9
  import type { ToolIcon } from './tool-icon.js';
10
10
  import type { ToolCompactPresentation } from './tool.js';
@@ -18,6 +18,8 @@ import type { ComputerControlService } from './computer-control.js';
18
18
  */
19
19
  export interface RunTurnOptions {
20
20
  readonly model?: string;
21
+ /** Optional context-window estimate for a custom model that is not in the provider catalog. */
22
+ readonly contextWindow?: number;
21
23
  readonly systemPrompt?: string;
22
24
  readonly maxIterations?: number;
23
25
  /**
@@ -91,6 +93,11 @@ export interface ToolInfo {
91
93
  export interface SkillInfo {
92
94
  readonly id: string;
93
95
  readonly name: string;
96
+ readonly description?: string;
97
+ /** Other names an @ mention can use; the first is what the @ menu inserts. */
98
+ readonly aliases?: ReadonlyArray<string>;
99
+ /** How the @ menu shows the skill ("Computer Use"); a skill with one is offered first. */
100
+ readonly label?: string;
94
101
  }
95
102
 
96
103
  /** Serializable slash-command metadata for the picker / `/help`. */
@@ -150,6 +157,19 @@ export interface SessionInfo {
150
157
  /** Name of the active synthesizer, or null. Lets a thin client proxy TTS
151
158
  * (the desktop routes "Read aloud" through it; null → OS voice fallback). */
152
159
  readonly activeSynthesizer: string | null;
160
+ /** Tool calls run without asking in this conversation (see `auto-approve.ts`).
161
+ * Absent from runners that predate the shared switch. */
162
+ readonly autoApprove?: boolean;
163
+ /**
164
+ * How deeply the model thinks in this conversation: a set effort, `default` when reasoning is on at the
165
+ * provider's own default effort, null when it is off. Absent from older runners.
166
+ */
167
+ readonly reasoningEffort?: ReasoningEffort | 'default' | null;
168
+ /** Whether turns ask for the provider's faster tier. Absent from older runners. */
169
+ readonly fast?: boolean;
170
+ /** Turns running now, whichever client (or in-process channel) started them.
171
+ * Reported by a runner to its clients; absent from runners that predate it. */
172
+ readonly runningTurns?: ReadonlyArray<string>;
153
173
  }
154
174
 
155
175
  /**
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { asSkillId } from './ids.js';
3
+ import type { ToolRegistry } from './mode.js';
4
+ import type { Skill, SkillFrontmatter } from './skill.js';
5
+ import { insertMention, mentionedSkills, mentionOptions, mentionQueryAt, skillAttachment, withoutTools } from './skill-mentions.js';
6
+ import type { SkillInfo } from './session-like.js';
7
+ import { defineTool } from './define.js';
8
+ import { z } from 'zod';
9
+
10
+ const skill = (frontmatter: Omit<SkillFrontmatter, 'description'>, body = 'Do it on the desktop.'): Skill => ({
11
+ id: asSkillId(`plugin/${frontmatter.name}`),
12
+ path: `/skills/${frontmatter.name}.md`,
13
+ scope: 'plugin',
14
+ frontmatter: { description: 'test skill', ...frontmatter },
15
+ body,
16
+ });
17
+
18
+ const computer = skill({ name: 'computer-control', aliases: ['computer-use'], 'disallowed-tools': ['browser_*'] });
19
+ const browser = skill({ name: 'browser' });
20
+
21
+ describe('mentionedSkills', () => {
22
+ it('finds a skill the prompt names with @, by an alias written with underscores', () => {
23
+ expect(mentionedSkills('@computer_use otwórz Canvę w Arc', [browser, computer])).toEqual([computer]);
24
+ });
25
+
26
+ it('finds a skill by its own name, in any letter case, anywhere in the prompt', () => {
27
+ expect(mentionedSkills('zrób to przez @Computer-Control, proszę', [computer])).toEqual([computer]);
28
+ });
29
+
30
+ it('names each skill once however often it is mentioned', () => {
31
+ expect(mentionedSkills('@computer_use i jeszcze raz @computer-control', [computer])).toEqual([computer]);
32
+ });
33
+
34
+ it('ignores an e-mail address and a mention of no skill', () => {
35
+ expect(mentionedSkills('napisz do kamil@computer-use.pl o @nobody', [computer])).toEqual([]);
36
+ });
37
+ });
38
+
39
+ describe('skillAttachment', () => {
40
+ it('carries the skill to the model as a file, saying the user asked for it and which tools are off', () => {
41
+ const attachment = skillAttachment(computer);
42
+
43
+ expect(attachment).toMatchObject({ kind: 'file', name: 'computer-control skill' });
44
+ expect(attachment.content).toContain('The user called this skill with an @ mention');
45
+ expect(attachment.content).toContain('browser_*');
46
+ expect(attachment.content.endsWith('Do it on the desktop.')).toBe(true);
47
+ });
48
+ });
49
+
50
+ describe('withoutTools', () => {
51
+ const tool = (name: string) => defineTool({ name, description: name, inputSchema: z.object({}), handler: async () => name });
52
+ const registry: ToolRegistry = {
53
+ list: () => [tool('browser_click'), tool('computer_click'), tool('web_fetch')],
54
+ get: (name) => registry.list().find((t) => t.name === name),
55
+ execute: async (name) => name,
56
+ };
57
+
58
+ it('leaves the withheld tools out of the list and out of reach by name', async () => {
59
+ const narrowed = withoutTools(registry, ['browser_*']);
60
+
61
+ expect(narrowed.list().map((t) => t.name)).toEqual(['computer_click', 'web_fetch']);
62
+ expect(narrowed.get('browser_click')).toBeUndefined();
63
+ await expect(narrowed.execute('computer_click', {}, new AbortController().signal)).resolves.toBe('computer_click');
64
+ await expect(narrowed.execute('browser_click', {}, new AbortController().signal)).rejects.toThrow(/browser_click is off for this request/);
65
+ });
66
+ });
67
+
68
+ describe('mentionQueryAt', () => {
69
+ it('reads the @ word the caret is in, from its @ to its end', () => {
70
+ expect(mentionQueryAt('zrób to @com', 12)).toEqual({ start: 8, end: 12, query: 'com' });
71
+ expect(mentionQueryAt('@comp reszta', 3)).toEqual({ start: 0, end: 5, query: 'comp' });
72
+ expect(mentionQueryAt('@', 1)).toEqual({ start: 0, end: 1, query: '' });
73
+ });
74
+
75
+ it('is nothing outside an @ word, in an e-mail address, or after a space', () => {
76
+ expect(mentionQueryAt('zwykły tekst', 5)).toBeNull();
77
+ expect(mentionQueryAt('kamil@gmail', 11)).toBeNull();
78
+ expect(mentionQueryAt('@computer_use ', 14)).toBeNull();
79
+ });
80
+ });
81
+
82
+ describe('mentionOptions', () => {
83
+ const skills: SkillInfo[] = [
84
+ { id: 'builtin/web-research', name: 'web-research', description: 'Research a topic on the web' },
85
+ { id: 'builtin/browser', name: 'browser', label: 'Moxxy Browser', aliases: ['moxxy_browser', 'przegladarka'], description: 'Drive the in-window browser' },
86
+ { id: 'plugin/computer-control', name: 'computer-control', label: 'Computer Use', aliases: ['computer_use', 'komputer'], description: 'Operate desktop apps' },
87
+ ];
88
+
89
+ it('offers the labelled skills first, each to be inserted by its first alias', () => {
90
+ expect(mentionOptions(skills, '').map((o) => o.token)).toEqual(['computer_use', 'moxxy_browser', 'web-research']);
91
+ expect(mentionOptions(skills, '')[0]).toEqual({ name: 'computer-control', token: 'computer_use', label: 'Computer Use', description: 'Operate desktop apps' });
92
+ });
93
+
94
+ it('matches what was typed against names, aliases and labels, diacritics and _ aside', () => {
95
+ expect(mentionOptions(skills, 'com').map((o) => o.name)).toEqual(['computer-control']);
96
+ expect(mentionOptions(skills, 'przeglą').map((o) => o.name)).toEqual(['browser']);
97
+ expect(mentionOptions(skills, 'computer-u').map((o) => o.name)).toEqual(['computer-control']);
98
+ expect(mentionOptions(skills, 'brow').map((o) => o.name)).toEqual(['browser']);
99
+ expect(mentionOptions(skills, 'zzz')).toEqual([]);
100
+ });
101
+
102
+ it('puts a word that starts with the query before one that only holds it', () => {
103
+ expect(mentionOptions(skills, 'res').map((o) => o.name)).toEqual(['web-research']);
104
+ expect(mentionOptions(skills, 'r').map((o) => o.name)).toEqual(['web-research', 'computer-control', 'browser']);
105
+ });
106
+ });
107
+
108
+ describe('insertMention', () => {
109
+ it('replaces the @ word with the token and a space, the caret after it', () => {
110
+ expect(insertMention('zrób @com', { start: 5, end: 9, query: 'com' }, 'computer_use')).toEqual({ text: 'zrób @computer_use ', caret: 19 });
111
+ expect(insertMention('@co reszta', { start: 0, end: 3, query: 'co' }, 'computer_use')).toEqual({ text: '@computer_use reszta', caret: 14 });
112
+ });
113
+ });
@@ -0,0 +1,108 @@
1
+ import type { UserPromptAttachment } from './events.js';
2
+ import type { ToolRegistry } from './mode.js';
3
+ import type { SkillInfo } from './session-like.js';
4
+ import type { Skill } from './skill.js';
5
+
6
+ /** `@computer_use`, but not the `@` inside an e-mail address. */
7
+ const MENTION = /(?<![\p{L}\p{N}_@.])@(\p{L}[\p{L}\p{N}_-]*)/gu;
8
+
9
+ /** `computer_use`, `Computer-Use` and `computer-use` name the same skill; so do `przeglądarka` and `przegladarka`. */
10
+ const key = (name: string): string =>
11
+ name.toLowerCase().normalize('NFD').replace(/\p{M}/gu, '').replace(/ł/gu, 'l').replace(/[_\s]+/gu, '-');
12
+
13
+ /** The skills a prompt calls by an @ mention of their name or an alias, each once, in mention order. */
14
+ export function mentionedSkills(text: string, skills: ReadonlyArray<Skill>): Skill[] {
15
+ const byKey = new Map<string, Skill>();
16
+ for (const skill of skills) {
17
+ for (const name of [skill.frontmatter.name, ...(skill.frontmatter.aliases ?? [])]) byKey.set(key(name), skill);
18
+ }
19
+ const found = new Set<Skill>();
20
+ for (const [, name] of text.matchAll(MENTION)) {
21
+ const skill = byKey.get(key(name ?? ''));
22
+ if (skill) found.add(skill);
23
+ }
24
+ return [...found];
25
+ }
26
+
27
+ /** The mentioned skill, recorded on the prompt so every surface and every replay sees the same request. */
28
+ export function skillAttachment(skill: Skill): UserPromptAttachment {
29
+ const withheld = skill.frontmatter['disallowed-tools'] ?? [];
30
+ const off = withheld.length > 0 ? ` The tools ${withheld.join(', ')} are off for this request.` : '';
31
+ return {
32
+ kind: 'file',
33
+ name: `${skill.frontmatter.name} skill`,
34
+ content: `The user called this skill with an @ mention: follow it for this request.${off}\n\n${skill.body}`,
35
+ };
36
+ }
37
+
38
+ /** Whether a tool name fits an `allowed-tools`-style pattern (`browser_*` or an exact name). */
39
+ export function toolPatternMatches(pattern: string, name: string): boolean {
40
+ return pattern.endsWith('*') ? name.startsWith(pattern.slice(0, -1)) : name === pattern;
41
+ }
42
+
43
+ /** The registry without the tools the patterns name: not offered to the model, and refused if called anyway. */
44
+ export function withoutTools(tools: ToolRegistry, patterns: ReadonlyArray<string>): ToolRegistry {
45
+ const withheld = (name: string) => patterns.some((pattern) => toolPatternMatches(pattern, name));
46
+ return {
47
+ list: () => tools.list().filter((tool) => !withheld(tool.name)),
48
+ get: (name) => (withheld(name) ? undefined : tools.get(name)),
49
+ execute: async (name, input, signal, opts) => {
50
+ if (withheld(name)) throw new Error(`${name} is off for this request: the user called a skill that rules it out`);
51
+ return tools.execute(name, input, signal, opts);
52
+ },
53
+ };
54
+ }
55
+
56
+ /** The @ word being typed: where it starts (its `@`), where it ends, and what follows the `@`. */
57
+ export interface MentionQuery {
58
+ readonly start: number;
59
+ readonly end: number;
60
+ readonly query: string;
61
+ }
62
+
63
+ const TYPED = /(?:^|[\s(])@([\p{L}\p{N}_-]*)$/u;
64
+ const REST = /^[\p{L}\p{N}_-]*/u;
65
+
66
+ /** The @ word the caret is in, or null when it is in none (an e-mail address is none). */
67
+ export function mentionQueryAt(text: string, caret: number): MentionQuery | null {
68
+ const typed = TYPED.exec(text.slice(0, caret));
69
+ if (!typed) return null;
70
+ const query = typed[1] ?? '';
71
+ const rest = REST.exec(text.slice(caret))?.[0] ?? '';
72
+ return { start: caret - query.length - 1, end: caret + rest.length, query: query + rest };
73
+ }
74
+
75
+ /** One skill the @ menu offers: shown by `label`, inserted as `@token`. */
76
+ export interface MentionOption {
77
+ readonly name: string;
78
+ readonly token: string;
79
+ readonly label: string;
80
+ readonly description: string;
81
+ }
82
+
83
+ /**
84
+ * The skills that answer an @ query: a word of a name, alias or label that starts with the query
85
+ * before one that only holds it, skills with a label (the tool-like ones) first, then by label.
86
+ */
87
+ export function mentionOptions(skills: ReadonlyArray<SkillInfo>, query: string, limit = 8): MentionOption[] {
88
+ const wanted = key(query);
89
+ const ranked = skills.flatMap((skill) => {
90
+ const keys = [skill.name, ...(skill.aliases ?? []), ...(skill.label ? [skill.label] : [])].map(key);
91
+ const rank = keys.some((k) => k.split('-').some((word) => word.startsWith(wanted)) || k.startsWith(wanted))
92
+ ? 0
93
+ : keys.some((k) => k.includes(wanted)) ? 1 : undefined;
94
+ if (rank === undefined) return [];
95
+ const option = { name: skill.name, token: skill.aliases?.[0] ?? skill.name, label: skill.label ?? skill.name, description: skill.description ?? '' };
96
+ return [{ option, rank, labelled: skill.label ? 0 : 1 }];
97
+ });
98
+ ranked.sort((a, b) => a.rank - b.rank || a.labelled - b.labelled || a.option.label.localeCompare(b.option.label));
99
+ return ranked.slice(0, limit).map((entry) => entry.option);
100
+ }
101
+
102
+ /** The text with the @ word replaced by `@token` and a space, and the caret after that space. */
103
+ export function insertMention(text: string, at: MentionQuery, token: string): { text: string; caret: number } {
104
+ const after = text.slice(at.end);
105
+ const spaced = /^\s/u.test(after) ? after : ` ${after}`;
106
+ const next = `${text.slice(0, at.start)}@${token}${spaced}`;
107
+ return { text: next, caret: at.start + token.length + 2 };
108
+ }
package/src/skill.ts CHANGED
@@ -13,6 +13,12 @@ export interface SkillFrontmatter {
13
13
  readonly description: string;
14
14
  readonly triggers?: ReadonlyArray<string>;
15
15
  readonly 'allowed-tools'?: ReadonlyArray<string>;
16
+ /** Other names a prompt can call the skill by with an @ mention (`computer_use`; `_` and `-` read the same). */
17
+ readonly aliases?: ReadonlyArray<string>;
18
+ /** How the chat's @ menu shows the skill ("Computer Use"); a skill with one is offered first. */
19
+ readonly label?: string;
20
+ /** Tools withheld from a turn whose prompt calls the skill by an @ mention (`browser_*` or exact names). */
21
+ readonly 'disallowed-tools'?: ReadonlyArray<string>;
16
22
  readonly version?: string;
17
23
  readonly tags?: ReadonlyArray<string>;
18
24
  readonly schedule?: SkillSchedule;
@@ -18,6 +18,18 @@ describe('createStuckLoopDetector', () => {
18
18
  expect(d.record('Read', input).stuck).toBe(true); // 8th identical call trips
19
19
  });
20
20
 
21
+ it('lets a live-state tool repeat between other calls, and still trips on a back-to-back run', () => {
22
+ const d = createStuckLoopDetector({ repeatThreshold: 3 });
23
+ const look = { app: 'Finder' };
24
+ for (let i = 0; i < 10; i++) {
25
+ expect(d.record('computer_get_app_state', look, { liveState: true }).stuck).toBe(false);
26
+ expect(d.record('computer_click', { element_index: i }).stuck).toBe(false);
27
+ }
28
+ expect(d.record('computer_get_app_state', look, { liveState: true }).stuck).toBe(false);
29
+ expect(d.record('computer_get_app_state', look, { liveState: true }).stuck).toBe(false);
30
+ expect(d.record('computer_get_app_state', look, { liveState: true })).toMatchObject({ stuck: true, count: 3, kind: 'exact' });
31
+ });
32
+
21
33
  it('never trips when disabled (relies on maxIterations alone)', () => {
22
34
  const d = createStuckLoopDetector({ enabled: false, repeatThreshold: 2 });
23
35
  const input = { x: 1 };
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { assertDefined } from './assert.js';
3
+ import { asEventId, asSessionId, asTurnId } from './ids.js';
4
+ import type { EventLogReader } from './log.js';
5
+ import type { MoxxyEvent, MoxxyEventOfType, MoxxyEventType } from './events.js';
6
+ import type { TurnId } from './ids.js';
7
+ import { computeElisionState, toolResultStubbed } from './elision-state.js';
8
+ import { estimateContextTokens } from './compactor-helpers.js';
9
+ import { projectMessagesFromLog } from './mode/project-messages.js';
10
+
11
+ /**
12
+ * A read of a page stops meaning anything once the same page has been read
13
+ * whole again. In the Coolify task the first turn's reads (one of them 178,073
14
+ * characters) were sent again on each of the 44 calls of the second turn, long
15
+ * after the agent had moved on to other pages. A result that names what it
16
+ * reads (`supersede.key`) is sent as a short marker once a later result reads
17
+ * the same thing whole; `recall` still brings it back.
18
+ */
19
+ const sid = asSessionId('s1');
20
+ const t1 = asTurnId('t1');
21
+
22
+ function event(seq: number, partial: Omit<MoxxyEvent, 'id' | 'seq' | 'ts' | 'sessionId'>): MoxxyEvent {
23
+ return { id: asEventId(`e${seq}`), seq, ts: seq, sessionId: sid, ...partial } as MoxxyEvent;
24
+ }
25
+
26
+ function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
27
+ return {
28
+ length: events.length,
29
+ at: (seq) => events[seq],
30
+ slice: (from = 0, to = events.length) => events.slice(from, to),
31
+ ofType: <T extends MoxxyEventType>(type: T): ReadonlyArray<MoxxyEventOfType<T>> =>
32
+ events.filter((e): e is MoxxyEventOfType<T> => e.type === type),
33
+ byTurn: (turnId: TurnId) => events.filter((e) => e.turnId === turnId),
34
+ toJSON: () => events,
35
+ };
36
+ }
37
+
38
+ /** A tool call and its result, at seq and seq + 1. */
39
+ function read(seq: number, callId: string, key: string, whole: boolean, text = 'x'.repeat(5_000)): MoxxyEvent[] {
40
+ return [
41
+ event(seq, { type: 'tool_call_requested', turnId: t1, source: 'model', callId, name: 'browser_snapshot', input: {} }),
42
+ event(seq + 1, {
43
+ type: 'tool_result',
44
+ turnId: t1,
45
+ source: 'tool',
46
+ callId,
47
+ ok: true,
48
+ output: { text, supersede: { key, whole } },
49
+ }),
50
+ ];
51
+ }
52
+
53
+ const log: MoxxyEvent[] = [
54
+ event(0, { type: 'user_prompt', turnId: t1, source: 'user', text: 'go' }),
55
+ ...read(1, 'page-1', 'browser:t1', true),
56
+ ...read(3, 'diff-1', 'browser:t1', false),
57
+ ...read(5, 'other-tab', 'browser:t2', true),
58
+ ...read(7, 'page-2', 'browser:t1', true, 'the page now'),
59
+ ];
60
+
61
+ function resultOf(callId: string): Extract<MoxxyEvent, { type: 'tool_result' }> {
62
+ const found = log.find(
63
+ (e): e is Extract<MoxxyEvent, { type: 'tool_result' }> => e.type === 'tool_result' && e.callId === callId,
64
+ );
65
+ assertDefined(found, `result ${callId}`);
66
+ return found;
67
+ }
68
+
69
+ function sentFor(events: MoxxyEvent[], callId: string): string {
70
+ for (const m of projectMessagesFromLog({ log: reader(events) })) {
71
+ for (const block of m.content) {
72
+ if (block.type === 'tool_result' && block.toolUseId === callId) return String(block.content);
73
+ }
74
+ }
75
+ return '';
76
+ }
77
+
78
+ describe('a result read again whole is superseded', () => {
79
+ it('marks every earlier result of the same key once a later one is whole', () => {
80
+ const state = computeElisionState(log);
81
+
82
+ expect(toolResultStubbed(resultOf('page-1'), state)).toBe(true);
83
+ expect(toolResultStubbed(resultOf('diff-1'), state)).toBe(true);
84
+ });
85
+
86
+ it('keeps the newest whole read, and what another key read', () => {
87
+ const state = computeElisionState(log);
88
+
89
+ expect(toolResultStubbed(resultOf('page-2'), state)).toBe(false);
90
+ expect(toolResultStubbed(resultOf('other-tab'), state)).toBe(false);
91
+ });
92
+
93
+ it('does not let a partial read stand in for the whole one before it', () => {
94
+ // The changes alone mean something only against the read they change.
95
+ const partialLast = [...log.slice(0, 5)];
96
+ const state = computeElisionState(partialLast);
97
+
98
+ expect(toolResultStubbed(resultOf('page-1'), state)).toBe(false);
99
+ });
100
+
101
+ it('sends a superseded result as a marker that says how to get it back', () => {
102
+ const sent = sentFor(log, 'page-1');
103
+
104
+ expect(sent).toContain('recall("page-1")');
105
+ expect(sent.length).toBeLessThan(200);
106
+ expect(sentFor(log, 'page-2')).toContain('the page now');
107
+ });
108
+
109
+ it('counts the marker, not the read, when estimating the context', () => {
110
+ const withoutLater = log.slice(0, 7);
111
+
112
+ // Adding a short whole read takes the earlier reads out of the estimate.
113
+ expect(estimateContextTokens(reader(log))).toBeLessThan(estimateContextTokens(reader(withoutLater)));
114
+ });
115
+ });
@@ -0,0 +1,55 @@
1
+ import type { MoxxyEvent } from './events.js';
2
+
3
+ /**
4
+ * Results that a later result made stale.
5
+ *
6
+ * A tool that reads something which changes — a page in a browser tab — can
7
+ * name what it read in its result (`supersede.key`) and say whether the read is
8
+ * whole or only what changed since the last one. Once a later result reads the
9
+ * same key whole, every earlier result of that key says nothing the model still
10
+ * needs, and is sent as a short marker instead (`recall` brings it back).
11
+ *
12
+ * In the Coolify task the first turn's page reads, one of them 178,073
13
+ * characters, were sent again on each of the 44 calls of the second turn, long
14
+ * after the agent had moved on to other pages. A partial read never stands in
15
+ * for a whole one: what changed means something only against what it changed.
16
+ *
17
+ * Decided from the log alone, so projection and the context estimate agree, and
18
+ * deterministic, so a prefix only changes when a new whole read arrives.
19
+ */
20
+ export interface Supersede {
21
+ /** What the result read, e.g. `browser:t1`. Results of different keys never touch. */
22
+ readonly key: string;
23
+ /** The whole of it, rather than only what changed since the last read. */
24
+ readonly whole: boolean;
25
+ }
26
+
27
+ /** The `supersede` a tool output carries, if it carries a well-formed one. */
28
+ export function supersedeOf(output: unknown): Supersede | null {
29
+ if (typeof output !== 'object' || output === null) return null;
30
+ const s = (output as { supersede?: unknown }).supersede;
31
+ if (typeof s !== 'object' || s === null) return null;
32
+ const { key, whole } = s as { key?: unknown; whole?: unknown };
33
+ return typeof key === 'string' && key !== '' && typeof whole === 'boolean' ? { key, whole } : null;
34
+ }
35
+
36
+ /** callIds of results a later whole result of the same key replaced. */
37
+ export function supersededCallIds(events: ReadonlyArray<MoxxyEvent>): ReadonlySet<string> {
38
+ const superseded = new Set<string>();
39
+ const readWholeLater = new Set<string>();
40
+ for (let i = events.length - 1; i >= 0; i--) {
41
+ const e = events[i];
42
+ if (e === undefined || e.type !== 'tool_result' || e.error) continue;
43
+ const s = supersedeOf(e.output);
44
+ if (!s) continue;
45
+ if (readWholeLater.has(s.key)) superseded.add(e.callId);
46
+ else if (s.whole) readWholeLater.add(s.key);
47
+ }
48
+ return superseded;
49
+ }
50
+
51
+ /** Deterministic marker for a superseded result. Stable bytes → cache-safe. */
52
+ export function supersededStub(callId: string, bytes: number): string {
53
+ const label = bytes >= 1024 ? `${(Math.round(bytes / 102.4) / 10).toFixed(1)} KB` : `${bytes} B`;
54
+ return `[superseded — a later result read the same thing whole · ${label} · recall("${callId}") to view]`;
55
+ }
@@ -0,0 +1,26 @@
1
+ import { homedir, tmpdir } from 'node:os';
2
+ import { isAbsolute, relative } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { moxxyHome } from './fs-utils.js';
5
+
6
+ function isInside(parent: string, child: string): boolean {
7
+ const rel = relative(parent, child);
8
+ return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
9
+ }
10
+
11
+ // The shared vitest preset points every test process at a throwaway home, so a
12
+ // test can never read or write the developer's real ~/.moxxy or OS keychain.
13
+ describe('test isolation', () => {
14
+ it('resolves the home directory inside the temp dir', () => {
15
+ expect(isInside(tmpdir(), homedir())).toBe(true);
16
+ });
17
+
18
+ it('resolves the moxxy home inside the temp dir, with no inherited override', () => {
19
+ expect(process.env.MOXXY_HOME).toBeUndefined();
20
+ expect(isInside(tmpdir(), moxxyHome())).toBe(true);
21
+ });
22
+
23
+ it('keeps the OS keychain out of reach', () => {
24
+ expect(process.env.MOXXY_NO_KEYCHAIN).toBe('1');
25
+ });
26
+ });
@@ -547,7 +547,11 @@ describe('projectMessages compaction-range lookup (golden: binary cursor == line
547
547
  const emitted = new Set<Range>();
548
548
  const msgs: ProviderMessage[] = [];
549
549
  const resolved = new Set<string>();
550
- for (const e of events) if (e.type === 'tool_result' || e.type === 'tool_call_denied') resolved.add(e.callId);
550
+ for (const e of events) {
551
+ if (linearLookup(e.seq, ranges)) continue;
552
+ if (e.type === 'tool_result' || e.type === 'tool_call_denied') resolved.add(e.callId);
553
+ }
554
+ const projected = new Set<string>();
551
555
  let pending: ProviderMessage | null = null;
552
556
  const flush = () => {
553
557
  if (!pending) return;
@@ -586,9 +590,11 @@ describe('projectMessages compaction-range lookup (golden: binary cursor == line
586
590
  break;
587
591
  case 'tool_call_requested':
588
592
  if (!pending) pending = { role: 'assistant', content: [] };
593
+ projected.add(e.callId);
589
594
  (pending.content as Array<ProviderMessage['content'][number]>).push({ type: 'tool_use', id: e.callId, name: e.name, input: e.input });
590
595
  break;
591
596
  case 'tool_result': {
597
+ if (!projected.has(e.callId)) break;
592
598
  flush();
593
599
  const text = e.error ? `[error:${e.error.kind}] ${e.error.message}` : typeof e.output === 'string' ? e.output : JSON.stringify(e.output ?? '');
594
600
  msgs.push({ role: 'tool_result', content: [{ type: 'tool_result', toolUseId: e.callId, content: text, isError: !e.ok }] });
@@ -290,3 +290,16 @@ describe('emitRequestsAndNudgeOnStuck — steer, never stop', () => {
290
290
  expect(events.filter((e) => e.type !== 'tool_call_requested')).toHaveLength(0);
291
291
  });
292
292
  });
293
+
294
+ describe('how an approval was reached', () => {
295
+ const approvedBy = async (decision: PermissionDecision): Promise<MoxxyEvent | undefined> => {
296
+ const { ctx, events } = makeCtx({ decision });
297
+ await drain(dispatchToolCall(ctx, { id: 'c1', name: 'computer_run', input: {} } as CollectedToolUse, 0));
298
+ return events.find((event) => event.type === 'tool_call_approved');
299
+ };
300
+
301
+ it('records that the call was decided now, so a tool can tell it from a standing rule', async () => {
302
+ expect(await approvedBy({ mode: 'allow', decidedNow: true })).toMatchObject({ mode: 'allow', decidedNow: true });
303
+ expect(await approvedBy({ mode: 'allow' })).not.toHaveProperty('decidedNow');
304
+ });
305
+ });
@@ -94,6 +94,7 @@ export async function* dispatchToolCall(
94
94
  callId: asToolCallId(t.id),
95
95
  decidedBy: 'resolver',
96
96
  mode: decision.mode,
97
+ ...(decision.decidedNow ? { decidedNow: true } : {}),
97
98
  });
98
99
 
99
100
  try {
@@ -279,7 +280,7 @@ export async function* emitRequestsAndDetectStuck(
279
280
  input: t.input,
280
281
  });
281
282
  emitted.push(t);
282
- const sig = detector.record(t.name, t.input);
283
+ const sig = detector.record(t.name, t.input, { liveState: ctx.tools.get(t.name)?.liveState === true });
283
284
  if (!sig.stuck) continue;
284
285
  for (const r of emitted) {
285
286
  yield await ctx.emit({
@@ -362,7 +363,7 @@ export async function* emitRequestsAndNudgeOnStuck(
362
363
  name: t.name,
363
364
  input: t.input,
364
365
  });
365
- const sig = detector.record(t.name, t.input);
366
+ const sig = detector.record(t.name, t.input, { liveState: ctx.tools.get(t.name)?.liveState === true });
366
367
  if (!sig.stuck || trip) continue;
367
368
  trip = {
368
369
  toolName: t.name,