@causyne/agent 1.10.21 → 1.11.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 (55) hide show
  1. package/native-skills/media/professional-document-publishing/SKILL.md +44 -0
  2. package/native-skills/media/screenplay-and-shotlist-forge/SKILL.md +42 -0
  3. package/package.json +1 -1
  4. package/src/acp/client.js +235 -0
  5. package/src/acp/client.ts +282 -0
  6. package/src/acp/peerProfiles.js +122 -0
  7. package/src/acp/peerProfiles.ts +128 -0
  8. package/src/acp/protocol.js +54 -0
  9. package/src/acp/protocol.ts +434 -0
  10. package/src/acp/server.js +609 -0
  11. package/src/acp/server.ts +676 -0
  12. package/src/agentLoop.d.ts +43 -0
  13. package/src/agentLoop.js +142 -8
  14. package/src/commands.js +203 -0
  15. package/src/compiledSkills.js +10 -0
  16. package/src/documentEngine/antiSlopAuditor.js +156 -0
  17. package/src/documentEngine/antiSlopAuditor.ts +221 -0
  18. package/src/documentEngine/documentForge.js +443 -0
  19. package/src/documentEngine/documentForge.ts +519 -0
  20. package/src/documentEngine/screenplayFormatter.js +336 -0
  21. package/src/documentEngine/screenplayFormatter.ts +407 -0
  22. package/src/entrypoint.js +13 -0
  23. package/src/execSafe.js +10 -13
  24. package/src/execSafe.ts +25 -0
  25. package/src/modelRouter.js +56 -123
  26. package/src/modelRouter.ts +134 -0
  27. package/src/observer/advisory.js +164 -0
  28. package/src/observer/advisory.ts +171 -0
  29. package/src/observer/coldObserver.js +462 -0
  30. package/src/observer/coldObserver.ts +540 -0
  31. package/src/observer/evidence.js +435 -0
  32. package/src/observer/evidence.ts +499 -0
  33. package/src/perception.js +303 -118
  34. package/src/perception.ts +387 -0
  35. package/src/procSignal.js +15 -16
  36. package/src/procSignal.ts +31 -0
  37. package/src/promptMentions.js +170 -0
  38. package/src/promptMentions.ts +212 -0
  39. package/src/sandbox.js +1 -1
  40. package/src/textDiff.js +117 -109
  41. package/src/textDiff.ts +169 -0
  42. package/src/toolPacks.js +5 -2
  43. package/src/tools/documentForge.js +215 -0
  44. package/src/tools/documentForge.ts +265 -0
  45. package/src/tools/peerAgent.js +102 -0
  46. package/src/tools/peerAgent.ts +116 -0
  47. package/src/tools/registry.js +3 -1
  48. package/src/tui/completion.js +97 -41
  49. package/src/tui/prompt.js +17 -17
  50. package/src/uncertainty.js +93 -205
  51. package/src/uncertainty.ts +255 -0
  52. package/src/utils/paths.js +15 -18
  53. package/src/utils/paths.ts +36 -0
  54. package/src/vfeTracker.js +122 -181
  55. package/src/vfeTracker.ts +242 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Causyne Peer Agent Tool
3
+ *
4
+ * Delegates a bounded task to an external ACP agent that the user configured
5
+ * in `~/.causyne/peers.json`. The model chooses a profile and writes a prompt;
6
+ * it never chooses the executable, arguments, working directory, or
7
+ * environment. Those come from the user's file and nowhere else.
8
+ */
9
+
10
+ import { AcpClient } from '../acp/client.js';
11
+ import type { RequestPermissionRequest, RequestPermissionResponse } from '../acp/protocol.js';
12
+ import { getPeerProfile, loadPeerProfiles, peerEnvironment } from '../acp/peerProfiles.js';
13
+ import type { PeerProfile } from '../acp/peerProfiles.js';
14
+
15
+ export interface PeerAgentArgs {
16
+ peer?: string;
17
+ prompt: string;
18
+ timeoutSeconds?: number;
19
+ }
20
+
21
+ const MAX_PROMPT_CHARS = 100_000;
22
+ const READ_ONLY_KINDS = new Set(['read', 'search', 'fetch', 'think']);
23
+
24
+ function selectPermission(profile: PeerProfile, request: RequestPermissionRequest): RequestPermissionResponse['outcome'] {
25
+ const kind = request.toolCall.kind;
26
+ const readLike = typeof kind === 'string' && READ_ONLY_KINDS.has(kind);
27
+ if (profile.policy === 'read-only' && !readLike) {
28
+ const reject = request.options.find((option) => option.kind === 'reject_once' || option.kind === 'reject_always');
29
+ return reject ? { outcome: 'selected', optionId: reject.optionId } : { outcome: 'cancelled' };
30
+ }
31
+ const allow = request.options.find((option) => option.kind === 'allow_once' || option.kind === 'allow_always');
32
+ return allow ? { outcome: 'selected', optionId: allow.optionId } : { outcome: 'cancelled' };
33
+ }
34
+
35
+ export const peerAgentTool = {
36
+ schema: {
37
+ name: 'peer_agent_delegate',
38
+ description: 'Delegate a bounded task to a peer ACP agent that the user configured in the peers file. The model supplies the peer id and the prompt only; the executable, arguments, working directory, environment, and permission policy come from the user configuration. Peers default to rejecting mutating permission requests; the external agent decides how to honor that policy.',
39
+ parameters: {
40
+ type: 'object',
41
+ properties: {
42
+ peer: {
43
+ type: 'string',
44
+ description: 'Id of a configured peer profile from the peers file.'
45
+ },
46
+ prompt: {
47
+ type: 'string',
48
+ description: 'Task instructions and context for the peer agent.'
49
+ },
50
+ timeoutSeconds: {
51
+ type: 'number',
52
+ description: 'Optional shorter timeout in seconds, bounded by the profile timeout.'
53
+ }
54
+ },
55
+ required: ['peer', 'prompt']
56
+ }
57
+ },
58
+ async execute(args: PeerAgentArgs): Promise<{ success: boolean; output?: string; error?: string }> {
59
+ const peerId = String(args.peer || '').trim();
60
+ const prompt = String(args.prompt || '').trim().slice(0, MAX_PROMPT_CHARS);
61
+ if (!peerId) {
62
+ const configured = loadPeerProfiles().map((profile) => profile.id);
63
+ return {
64
+ success: false,
65
+ error: configured.length > 0
66
+ ? `A peer id is required. Configured peers: ${configured.join(', ')}.`
67
+ : 'No peer profiles are configured. Add one to the peers file first.'
68
+ };
69
+ }
70
+ if (!prompt) {
71
+ return { success: false, error: 'A prompt is required for peer delegation.' };
72
+ }
73
+
74
+ const profile = getPeerProfile(peerId);
75
+ if (!profile) {
76
+ return { success: false, error: `Unknown peer profile: ${peerId}.` };
77
+ }
78
+
79
+ const requested = typeof args.timeoutSeconds === 'number' && Number.isFinite(args.timeoutSeconds)
80
+ ? Math.round(args.timeoutSeconds)
81
+ : profile.timeoutSeconds;
82
+ const timeoutSeconds = Math.max(5, Math.min(Math.min(profile.timeoutSeconds, requested), 1800));
83
+
84
+ const client = new AcpClient({
85
+ command: profile.command,
86
+ args: profile.args,
87
+ cwd: profile.cwd ?? process.cwd(),
88
+ env: peerEnvironment(profile),
89
+ promptTimeoutMs: timeoutSeconds * 1000,
90
+ permissionHandler: async (request) => selectPermission(profile, request)
91
+ });
92
+
93
+ try {
94
+ await client.start();
95
+ const sessionId = await client.newSession(profile.cwd ?? process.cwd());
96
+ const result = await client.prompt(sessionId, prompt);
97
+ if (result.stopReason === 'cancelled') {
98
+ return { success: false, error: 'The peer agent cancelled the task.' };
99
+ }
100
+ if (result.stopReason === 'refusal') {
101
+ return { success: false, error: 'The peer agent refused the task.' };
102
+ }
103
+ return {
104
+ success: true,
105
+ output: result.text.trim() || 'Peer agent completed the task without a written summary.'
106
+ };
107
+ } catch (error) {
108
+ return {
109
+ success: false,
110
+ error: error instanceof Error ? error.message : 'Peer agent delegation failed.'
111
+ };
112
+ } finally {
113
+ await client.close();
114
+ }
115
+ }
116
+ };
@@ -1,6 +1,8 @@
1
1
  import { creativeApps } from './creativeApps.js';
2
2
  import { computerUse } from './computerUse.js';
3
3
  import { uiForge } from './uiForge.js';
4
+ import { documentForgeTool } from './documentForge.js';
5
+ import { peerAgentTool } from './peerAgent.js';
4
6
  import { sysRun, sysRunBg } from './sysRun.js';
5
7
  import { fsRead } from './fsRead.js';
6
8
  import { fsWrite } from './fsWrite.js';
@@ -123,7 +125,7 @@ const TOOL_MODULES = [
123
125
  gameShaderForge, gameSceneBuilder, gameEntitySpawner,
124
126
  gameTilemapGenerator, gameUiHudBuilder, gameEconomyBalancer,
125
127
  gameDataForge, gameWebRuntimeInspector, gameCanvasAudit, readNativeSkill, createSkill, creativeApps,
126
- computerUse, uiForge
128
+ computerUse, uiForge, documentForgeTool, peerAgentTool
127
129
  ];
128
130
 
129
131
  // Base static definitions (backwards compatible). Order is preserved from the
@@ -59,6 +59,7 @@ export function score(candidate, query) {
59
59
 
60
60
  const base = haystack.slice(haystack.lastIndexOf('/') + 1);
61
61
  if (base.startsWith(needle)) return 800 - candidate.length;
62
+ if (haystack.includes(needle)) return 700 - candidate.length;
62
63
  if (base.includes(needle)) return 600 - candidate.length;
63
64
 
64
65
  // Subsequence: every character of the query in order, gaps allowed. This is
@@ -79,17 +80,25 @@ export function score(candidate, query) {
79
80
  /**
80
81
  * Score, sort and cap — and report how many matched BEFORE the cap.
81
82
  *
82
- * The cap exists for the file index, which can hold tens of thousands of paths.
83
- * But the picker's "… N more" line was computed from the capped array, so with
84
- * 115 skill entries it announced "42 more" when 107 were reachable by scrolling.
85
- * A count that is wrong is worse than no count: it tells the user to stop
86
- * looking. The total travels with the items so the caller can say the truth.
83
+ * If focusDir is supplied (e.g. active subproject like 'pixel2d games'),
84
+ * items belonging to that directory receive a priority boost so the user's
85
+ * active workspace files are never buried by unrelated paths.
87
86
  */
88
- function rank(items, query, limit = 50) {
87
+ function rank(items, query, limit = 50, focusDir = null) {
89
88
  const scored = [];
89
+ const focusPrefix = focusDir
90
+ ? (focusDir.endsWith('/') ? focusDir.toLowerCase() : focusDir.toLowerCase() + '/')
91
+ : null;
92
+
90
93
  for (const item of items) {
91
- const value = score(item.match !== undefined ? item.match : item.value, query);
92
- if (value >= 0) scored.push({ item, value });
94
+ const val = item.match !== undefined ? item.match : item.value;
95
+ let value = score(val, query);
96
+ if (value >= 0) {
97
+ if (focusPrefix && val.toLowerCase().startsWith(focusPrefix)) {
98
+ value += 3000;
99
+ }
100
+ scored.push({ item, value });
101
+ }
93
102
  }
94
103
  scored.sort((a, b) => b.value - a.value);
95
104
  const ranked = scored.slice(0, limit).map((entry) => entry.item);
@@ -227,6 +236,8 @@ export class FileIndex {
227
236
  if (entry.isDirectory()) {
228
237
  if (SKIP_DIRECTORIES.has(entry.name) || depth >= MAX_INDEX_DEPTH) continue;
229
238
  queue.push({ dir: full, depth: depth + 1 });
239
+ const relDir = path.relative(this.root, full) + '/';
240
+ if (isTerminalSafe(relDir)) found.push(relDir);
230
241
  continue;
231
242
  }
232
243
  if (!entry.isFile()) continue;
@@ -321,10 +332,26 @@ export function detectTrigger(text, cursor) {
321
332
  };
322
333
  }
323
334
 
324
- const mention = before.match(/(?:^|\s)@([^\s@]*)$/);
335
+ // 1. Quoted @ file mention: @"pixel2d games/ or @'pixel2d games/
336
+ // Supports spaces in directory or file names
337
+ const quotedMention = before.match(/(?:^|\s)@(["'])([^"']*)$/);
338
+ if (quotedMention) {
339
+ const quote = quotedMention[1];
340
+ const query = quotedMention[2];
341
+ return {
342
+ kind: 'file',
343
+ quote,
344
+ start: cursor - query.length - 2, // accounts for @ and quote char
345
+ query
346
+ };
347
+ }
348
+
349
+ // 2. Unquoted @ file mention: @alpha or @src/
350
+ const mention = before.match(/(?:^|\s)@([^\s@"']*)$/);
325
351
  if (mention) {
326
352
  return {
327
353
  kind: 'file',
354
+ quote: null,
328
355
  start: cursor - mention[1].length - 1,
329
356
  query: mention[1]
330
357
  };
@@ -356,15 +383,41 @@ export function suggest(text, cursor, options = {}) {
356
383
 
357
384
  const root = options.root || process.cwd();
358
385
  const index = options.index;
386
+ const focusDir = options.focusDir || null;
359
387
  if (index && !index.isReady) index.ensure();
360
388
 
361
- const pool = index && index.isReady && trigger.query.includes('/') === false && trigger.query
362
- ? index.entries.map((relative) => ({
363
- value: relative, match: relative, label: relative, description: ''
364
- }))
365
- : directoryItems(root, trigger.query);
389
+ let pool;
390
+ if (index && index.isReady && trigger.query) {
391
+ pool = index.entries.map((relative) => ({
392
+ value: relative,
393
+ match: relative,
394
+ label: relative,
395
+ description: relative.endsWith('/') ? 'directory' : ''
396
+ }));
397
+ } else {
398
+ pool = directoryItems(root, trigger.query);
399
+ if (focusDir) {
400
+ const focusTarget = path.resolve(root, focusDir);
401
+ try {
402
+ if (fs.existsSync(focusTarget) && fs.statSync(focusTarget).isDirectory()) {
403
+ const focusedItems = directoryItems(focusTarget, trigger.query).map((item) => ({
404
+ ...item,
405
+ value: `${focusDir}/${item.value}`,
406
+ match: `${focusDir}/${item.value}`,
407
+ label: `${focusDir}/${item.label}`
408
+ }));
409
+ pool = pool.concat(focusedItems);
410
+ }
411
+ } catch (e) {}
412
+ }
413
+ }
414
+
415
+ // If index had no ready matches for a path with slashes, fall back to directory items
416
+ if (pool.length === 0 && trigger.query.includes('/')) {
417
+ pool = directoryItems(root, trigger.query);
418
+ }
366
419
 
367
- const items = rank(pool, trigger.query.slice(trigger.query.lastIndexOf('/') + 1));
420
+ const items = rank(pool, trigger.query, 50, focusDir);
368
421
  if (items.length === 0) return null;
369
422
  return { ...trigger, items, total: items.total };
370
423
  }
@@ -374,39 +427,42 @@ export function applyCompletion(text, cursor, trigger, item) {
374
427
  if (trigger.kind === 'command-argument') {
375
428
  const head = text.slice(0, trigger.start);
376
429
  const tail = text.slice(cursor);
377
-
378
- // A COMPLETE argument gets a trailing space; a partial one does not.
379
- //
380
- // This was written the other way round, on the theory that a space would
381
- // reopen the picker on a value still being edited. It does not — a
382
- // completed argument followed by a space matches no trigger at all. What it
383
- // actually did was run the user's next words straight into the argument:
384
- // accepting `python/data-processing-pipelines` and typing a question gave
385
- // `/skills python/data-processing-pipelineswhy my data is like this ?` and
386
- // a "skill not found" error.
387
- //
388
- // The distinction is the same one file completion already makes between a
389
- // directory and a file: a domain (`devops`) is a place you may want to
390
- // descend into with `/`, so it gets no space; a full `domain/skill` id is
391
- // finished, so it gets one and the next thing typed is the instruction.
392
430
  const complete = item.value.includes('/');
393
431
  const replaced = head + item.value + (complete ? ' ' : '');
394
432
  return { text: replaced + tail, cursor: replaced.length };
395
433
  }
396
434
 
397
- const value = trigger.kind === 'command' ? item.value : '@' + item.value;
435
+ if (trigger.kind === 'command') {
436
+ const head = text.slice(0, trigger.start);
437
+ const tail = text.slice(cursor);
438
+ const replaced = head + item.value + ' ';
439
+ return { text: replaced + tail, cursor: replaced.length };
440
+ }
441
+
442
+ // trigger.kind === 'file'
398
443
  const head = text.slice(0, trigger.start);
399
444
  const tail = text.slice(cursor);
400
- // A directory keeps the picker useful: no trailing space, so the next
401
- // keystroke drills in rather than starting a new word.
402
- //
403
- // Everything else — including a skill picked from the slash menu, whose value
404
- // is a whole `/skills domain/id` command — gets one, so the next thing typed
405
- // is a new word rather than more of the last one. The exclusion that used to
406
- // sit here for values containing a space was the same mistake as above: it
407
- // made `/docker` complete to `/skills devops/docker-multi-stage-builds` with
408
- // nothing after it, and the user's instruction fused onto the id.
409
- const suffix = (trigger.kind === 'file' && item.value.endsWith('/')) ? '' : ' ';
445
+ const isDir = item.value.endsWith('/');
446
+ const hasSpace = item.value.includes(' ');
447
+ const quote = trigger.quote || (hasSpace ? '"' : '');
448
+
449
+ let value;
450
+ let suffix;
451
+ if (quote) {
452
+ if (isDir) {
453
+ // Drilling into directory: open quote without closing quote, no trailing space
454
+ value = `@${quote}${item.value}`;
455
+ suffix = '';
456
+ } else {
457
+ // Complete file: close quote and add trailing space
458
+ value = `@${quote}${item.value}${quote}`;
459
+ suffix = ' ';
460
+ }
461
+ } else {
462
+ value = '@' + item.value;
463
+ suffix = isDir ? '' : ' ';
464
+ }
465
+
410
466
  const replaced = head + value + suffix;
411
467
  return { text: replaced + tail, cursor: replaced.length };
412
468
  }
package/src/tui/prompt.js CHANGED
@@ -20,7 +20,7 @@
20
20
  import { KeyDecoder, bracketedPaste } from './keys.js';
21
21
  import { LineEditor } from './lineEditor.js';
22
22
  import { PromptHistory } from './history.js';
23
- import { FileIndex, suggest, applyCompletion, renderPicker, MAX_VISIBLE } from './completion.js';
23
+ import { FileIndex, suggest, applyCompletion, detectTrigger, renderPicker, MAX_VISIBLE } from './completion.js';
24
24
  import { paint, dim, bold, GLYPHS, terminalWidth, HAS_COLOR } from './theme.js';
25
25
  import { barBlock, GUTTER } from './block.js';
26
26
  import { visibleWidth, truncate, wrapBlock, shortenHome, safeDisplay } from './text.js';
@@ -84,6 +84,7 @@ export class ChatPrompt {
84
84
  this.out = options.out || process.stdout;
85
85
  this.input = options.input || process.stdin;
86
86
  this.root = options.root || process.cwd();
87
+ this.focusDir = options.focusDir || null;
87
88
  this.onInterrupt = options.onInterrupt || (() => {});
88
89
 
89
90
  // The Stage, when one is running. Null means the scrollback renderer, which
@@ -920,6 +921,14 @@ export class ChatPrompt {
920
921
  // Completion
921
922
  // -------------------------------------------------------------------------
922
923
 
924
+ /** Dynamically re-target the prompt root and optional focused subproject. */
925
+ setWorkingRoot(newRoot, focusDir = null) {
926
+ this.root = newRoot;
927
+ this.focusDir = focusDir;
928
+ this.index = new FileIndex(newRoot);
929
+ this.index.ensure();
930
+ }
931
+
923
932
  cursorOffset() {
924
933
  const before = this.editor.lines
925
934
  .slice(0, this.editor.line)
@@ -929,13 +938,14 @@ export class ChatPrompt {
929
938
 
930
939
  openCompletion(force = false) {
931
940
  const text = this.editor.getText();
932
- const result = suggest(text, this.cursorOffset(), { root: this.root, index: this.index });
941
+ const result = suggest(text, this.cursorOffset(), {
942
+ root: this.root,
943
+ index: this.index,
944
+ focusDir: this.focusDir
945
+ });
933
946
  if (!result) {
934
947
  if (force) this.notice = 'nothing to complete here';
935
- // Close a picker whose trigger has gone. refreshCompletion() documents
936
- // this ("close it when the trigger is gone") but this early return never
937
- // did it — so typing past the end of an argument left the suggestions on
938
- // screen, still offering matches for text that was no longer there.
948
+ // Close a picker whose trigger has gone.
939
949
  this.completion = null;
940
950
  return;
941
951
  }
@@ -946,19 +956,9 @@ export class ChatPrompt {
946
956
  /** Re-run completion after an edit; close it when the trigger is gone. */
947
957
  refreshCompletion() {
948
958
  if (!this.completion) {
949
- // `/` at the start and `@` anywhere open the picker without a keystroke,
950
- // which is the behaviour both openclaw and opencode settled on.
951
959
  const text = this.editor.getText();
952
960
  const offset = this.cursorOffset();
953
- const tail = text.slice(0, offset);
954
- // `/` at the start, `@` anywhere, and the argument of a slash command all
955
- // open the picker without a keystroke. The third is what makes `/skills d`
956
- // suggest as you type instead of only after Tab — the behaviour both
957
- // references settled on, and the reason a hundred-skill library is usable
958
- // by someone who already knows the name they want.
959
- const opens = /^\/[a-z0-9_?-]*$/i.test(tail)
960
- || /(?:^|\s)@[^\s@]*$/.test(tail)
961
- || /^\/[a-z0-9_?-]+[ \t]+\S*$/i.test(tail);
961
+ const opens = Boolean(detectTrigger(text, offset));
962
962
  if (opens) this.openCompletion(false);
963
963
  return;
964
964
  }