@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,134 @@
1
+ import { UTILITY_MODEL_BY_PROVIDER } from './modelCatalogue.js';
2
+
3
+ // Hybrid model routing.
4
+ //
5
+ // Not every call an agent makes is reasoning. Condensing a dropped transcript,
6
+ // classifying an intent, filtering a log, extracting keywords — these are
7
+ // utility work, and paying a flagship rate for them is pure waste. The research
8
+ // spec puts the saving at the difference between the two rate cards, which for
9
+ // OpenAI is $5/$30 against $0.75/$4.50: roughly 6x on work that never needed
10
+ // the better model.
11
+ //
12
+ // THREE RULES THIS MODULE EXISTS TO ENFORCE, all of them things a naive router
13
+ // gets wrong:
14
+ //
15
+ // 1. NEVER CHANGE PROVIDER. Causyne is BYOK. The user holds one key. A router
16
+ // that sends utility work to "whatever is cheapest on the market" calls an
17
+ // endpoint there is no credential for and the task fails — having spent
18
+ // the tokens that got it there.
19
+ //
20
+ // 2. NEVER CHANGE THE PRIVACY POSTURE. Muse's cheap tier is `-contributor`,
21
+ // discounted because the vendor trains on the content. Routing into it to
22
+ // save money would opt the user into training silently. Cost is not a
23
+ // reason to make that choice for someone.
24
+ //
25
+ // 3. NEVER GUESS. A provider with no published second tier routes to nothing
26
+ // and pays full rate. An invented model id becomes a 404 mid-task, or
27
+ // worse, a real model at a price nobody predicted.
28
+ //
29
+ // The router returns a DESCRIPTION of where a call should go. It never
30
+ // constructs a provider and never performs a call, so it stays trivially
31
+ // testable and cannot become a second place where credentials are handled.
32
+
33
+ export type RouteClass = 'reasoning' | 'utility';
34
+
35
+ export interface Route {
36
+ provider: string;
37
+ model: string;
38
+ routed: boolean;
39
+ reason: string;
40
+ }
41
+
42
+ export interface RouterConfig {
43
+ provider?: string | undefined;
44
+ model?: string | undefined;
45
+ modelRouting?: Record<string, string> | undefined;
46
+ disableModelRouting?: boolean | undefined;
47
+ [key: string]: unknown;
48
+ }
49
+
50
+ /**
51
+ * Utility work that is cheap to get wrong and expensive to run on a flagship.
52
+ * Anything not listed is treated as reasoning — the safe default is to spend
53
+ * more, never to silently downgrade work the user is judging.
54
+ */
55
+ export const UTILITY_KINDS: ReadonlySet<string> = new Set([
56
+ 'condense', // cortex/autoCondenser semantic pass
57
+ 'keywords', // plan-cache intent extraction
58
+ 'classify', // intent / archetype disambiguation
59
+ 'filter', // log and output filtering
60
+ 'summarise' // non-decision summaries
61
+ ]);
62
+
63
+ /**
64
+ * Decide where a call of a given kind should go.
65
+ *
66
+ * @param config The live agent config, as loaded from `~/.causyne/config.json`.
67
+ * @param kind Either a RouteClass or one of UTILITY_KINDS.
68
+ * @returns The resolved route description.
69
+ */
70
+ export function resolveRoute(config: RouterConfig | undefined | null, kind: RouteClass | string): Route {
71
+ const provider = String(config?.provider || '').toLowerCase();
72
+ const configured = String(config?.model || '');
73
+
74
+ const stay: Route = {
75
+ provider,
76
+ model: configured,
77
+ routed: false,
78
+ reason: 'reasoning work runs on the configured model'
79
+ };
80
+
81
+ if (!provider || !configured) {
82
+ return { ...stay, reason: 'no provider or model configured' };
83
+ }
84
+
85
+ const isUtility = kind === 'utility' || UTILITY_KINDS.has(String(kind));
86
+ if (!isUtility) return stay;
87
+
88
+ if (config?.disableModelRouting === true) {
89
+ return { ...stay, reason: 'model routing disabled by config' };
90
+ }
91
+
92
+ // An explicit choice by the user outranks the table, including a deliberate
93
+ // choice of a training tier — they made it, we did not make it for them.
94
+ const override = config?.modelRouting?.['utility'];
95
+ if (override) {
96
+ return {
97
+ provider,
98
+ model: String(override),
99
+ routed: String(override) !== configured,
100
+ reason: 'utility route set explicitly in config'
101
+ };
102
+ }
103
+
104
+ const cheap = UTILITY_MODEL_BY_PROVIDER[provider];
105
+ if (!cheap) {
106
+ return { ...stay, reason: `no published utility tier for ${provider}; paying full rate` };
107
+ }
108
+ if (cheap === configured) {
109
+ return { ...stay, reason: 'already on this provider\'s utility tier' };
110
+ }
111
+
112
+ // Rule 2. The configured model already carries a privacy posture; a route may
113
+ // not alter it in either direction.
114
+ if (/-contributor$/.test(configured) !== /-contributor$/.test(cheap)) {
115
+ return { ...stay, reason: 'a route may not change the training-data posture' };
116
+ }
117
+
118
+ return {
119
+ provider,
120
+ model: cheap,
121
+ routed: true,
122
+ reason: `utility work routed to ${provider}'s cheaper tier`
123
+ };
124
+ }
125
+
126
+ /**
127
+ * A config object for the routed call, leaving everything except the model
128
+ * untouched — same provider, same key, same base URL.
129
+ */
130
+ export function routedConfig<T extends RouterConfig>(config: T, kind: RouteClass | string): { config: T; route: Route } {
131
+ const route = resolveRoute(config, kind);
132
+ if (!route.routed) return { config, route };
133
+ return { config: { ...config, model: route.model }, route };
134
+ }
@@ -0,0 +1,164 @@
1
+ const MAX_PAYLOAD_CHARS = 5000;
2
+ export function isLoopbackEndpoint(value) {
3
+ try {
4
+ const url = new URL(value);
5
+ if (url.username || url.password)
6
+ return false;
7
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
8
+ return false;
9
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
10
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ function completionUrl(endpoint) {
17
+ const trimmed = endpoint.replace(/\/+$/, '');
18
+ if (/\/chat\/completions$/.test(trimmed))
19
+ return trimmed;
20
+ return `${trimmed}/chat/completions`;
21
+ }
22
+ function redact(value) {
23
+ return value
24
+ .replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [redacted]')
25
+ .replace(/\b(api[-_]?key|token|secret|password|passwd)\b\s*[:=]\s*["']?[^"'\s,}]+/gi, '$1=[redacted]')
26
+ .slice(0, MAX_PAYLOAD_CHARS);
27
+ }
28
+ function compactArguments(args) {
29
+ const allowed = {};
30
+ for (const key of ['command', 'cmd', 'path', 'file_path', 'source', 'destination', 'action', 'url']) {
31
+ const value = args[key];
32
+ if (typeof value === 'string')
33
+ allowed[key] = value;
34
+ }
35
+ if (typeof args.content === 'string') {
36
+ allowed.contentExcerpt = args.content.slice(0, 800);
37
+ allowed.contentBytes = Buffer.byteLength(args.content, 'utf8');
38
+ }
39
+ if (Array.isArray(args.edits)) {
40
+ allowed.edits = args.edits.slice(0, 8).map((edit) => {
41
+ if (edit === null || typeof edit !== 'object' || Array.isArray(edit))
42
+ return {};
43
+ const record = edit;
44
+ return {
45
+ before: typeof record.old_string === 'string'
46
+ ? record.old_string.slice(0, 200)
47
+ : typeof record.searchContent === 'string' ? record.searchContent.slice(0, 200) : '',
48
+ after: typeof record.new_string === 'string'
49
+ ? record.new_string.slice(0, 200)
50
+ : typeof record.replaceContent === 'string' ? record.replaceContent.slice(0, 200) : ''
51
+ };
52
+ });
53
+ }
54
+ return redact(JSON.stringify(allowed));
55
+ }
56
+ function parseVerdict(content) {
57
+ const match = content.match(/\{[\s\S]*\}/);
58
+ if (!match)
59
+ return null;
60
+ try {
61
+ const parsed = JSON.parse(match[0]);
62
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
63
+ return null;
64
+ const record = parsed;
65
+ const risk = record.risk;
66
+ const confidence = record.confidence;
67
+ if (typeof risk !== 'number' || !Number.isFinite(risk))
68
+ return null;
69
+ if (typeof confidence !== 'number' || !Number.isFinite(confidence))
70
+ return null;
71
+ return {
72
+ risk: Math.max(0, Math.min(1, risk)),
73
+ confidence: Math.max(0, Math.min(1, confidence)),
74
+ observation: typeof record.observation === 'string' ? record.observation.slice(0, 400) : ''
75
+ };
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ export async function queryAdvisory(input, options) {
82
+ if (!isLoopbackEndpoint(options.endpoint))
83
+ return null;
84
+ if (!options.model.trim())
85
+ return null;
86
+ if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 200)
87
+ return null;
88
+ const payload = {
89
+ model: options.model,
90
+ temperature: 0,
91
+ max_tokens: 160,
92
+ messages: [
93
+ {
94
+ role: 'system',
95
+ content: 'You are an evidence reviewer for a software agent. Judge only the supplied action and deterministic findings. Do not invent facts. Return one JSON object with keys risk (0..1), confidence (0..1), and observation (one sentence). High risk means the action could destroy work, escape the workspace, expose credentials, or publish state. Low risk means the action is reversible and scoped.'
96
+ },
97
+ {
98
+ role: 'user',
99
+ content: `Tool: ${input.tool}\nWorkspace: ${input.workspaceRoot}\nFindings: ${input.findings.slice(0, 6).join(' | ') || 'none'}\nArguments: ${compactArguments(input.args)}`
100
+ }
101
+ ]
102
+ };
103
+ const controller = new AbortController();
104
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs);
105
+ try {
106
+ const response = await fetch(completionUrl(options.endpoint), {
107
+ method: 'POST',
108
+ headers: { 'content-type': 'application/json' },
109
+ body: JSON.stringify(payload),
110
+ signal: controller.signal
111
+ });
112
+ if (!response.ok)
113
+ return null;
114
+ const body = await response.json();
115
+ if (body === null || typeof body !== 'object' || Array.isArray(body))
116
+ return null;
117
+ const choices = body.choices;
118
+ if (!Array.isArray(choices) || choices.length === 0)
119
+ return null;
120
+ const first = choices[0];
121
+ if (first === null || typeof first !== 'object' || Array.isArray(first))
122
+ return null;
123
+ const message = first.message;
124
+ if (message === null || typeof message !== 'object' || Array.isArray(message))
125
+ return null;
126
+ const content = message.content;
127
+ if (typeof content !== 'string')
128
+ return null;
129
+ return parseVerdict(content);
130
+ }
131
+ catch {
132
+ return null;
133
+ }
134
+ finally {
135
+ clearTimeout(timer);
136
+ }
137
+ }
138
+ export async function probeAdvisory(options) {
139
+ if (!isLoopbackEndpoint(options.endpoint)) {
140
+ return { healthy: false, latencyMs: 0, detail: 'endpoint must be a loopback HTTP URL' };
141
+ }
142
+ const start = Date.now();
143
+ const controller = new AbortController();
144
+ const timer = setTimeout(() => controller.abort(), Math.max(500, options.timeoutMs));
145
+ try {
146
+ const base = options.endpoint.replace(/\/+$/, '').replace(/\/chat\/completions$/, '');
147
+ const response = await fetch(`${base}/models`, { signal: controller.signal });
148
+ return {
149
+ healthy: response.ok,
150
+ latencyMs: Date.now() - start,
151
+ detail: response.ok ? 'endpoint responded' : `endpoint returned HTTP ${response.status}`
152
+ };
153
+ }
154
+ catch (error) {
155
+ return {
156
+ healthy: false,
157
+ latencyMs: Date.now() - start,
158
+ detail: error instanceof Error ? error.message : 'endpoint did not respond'
159
+ };
160
+ }
161
+ finally {
162
+ clearTimeout(timer);
163
+ }
164
+ }
@@ -0,0 +1,171 @@
1
+ export interface AdvisoryInput {
2
+ tool: string;
3
+ args: Record<string, unknown>;
4
+ findings: string[];
5
+ workspaceRoot: string;
6
+ }
7
+
8
+ export interface AdvisoryVerdict {
9
+ risk: number;
10
+ confidence: number;
11
+ observation: string;
12
+ }
13
+
14
+ export interface AdvisoryOptions {
15
+ endpoint: string;
16
+ model: string;
17
+ timeoutMs: number;
18
+ }
19
+
20
+ const MAX_PAYLOAD_CHARS = 5000;
21
+
22
+ export function isLoopbackEndpoint(value: string): boolean {
23
+ try {
24
+ const url = new URL(value);
25
+ if (url.username || url.password) return false;
26
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
27
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
28
+ return host === 'localhost' || host === '127.0.0.1' || host === '::1';
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ function completionUrl(endpoint: string): string {
35
+ const trimmed = endpoint.replace(/\/+$/, '');
36
+ if (/\/chat\/completions$/.test(trimmed)) return trimmed;
37
+ return `${trimmed}/chat/completions`;
38
+ }
39
+
40
+ function redact(value: string): string {
41
+ return value
42
+ .replace(/\b(Bearer)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [redacted]')
43
+ .replace(/\b(api[-_]?key|token|secret|password|passwd)\b\s*[:=]\s*["']?[^"'\s,}]+/gi, '$1=[redacted]')
44
+ .slice(0, MAX_PAYLOAD_CHARS);
45
+ }
46
+
47
+ function compactArguments(args: Record<string, unknown>): string {
48
+ const allowed: Record<string, unknown> = {};
49
+ for (const key of ['command', 'cmd', 'path', 'file_path', 'source', 'destination', 'action', 'url']) {
50
+ const value = args[key];
51
+ if (typeof value === 'string') allowed[key] = value;
52
+ }
53
+ if (typeof args.content === 'string') {
54
+ allowed.contentExcerpt = args.content.slice(0, 800);
55
+ allowed.contentBytes = Buffer.byteLength(args.content, 'utf8');
56
+ }
57
+ if (Array.isArray(args.edits)) {
58
+ allowed.edits = args.edits.slice(0, 8).map((edit) => {
59
+ if (edit === null || typeof edit !== 'object' || Array.isArray(edit)) return {};
60
+ const record = edit as Record<string, unknown>;
61
+ return {
62
+ before: typeof record.old_string === 'string'
63
+ ? record.old_string.slice(0, 200)
64
+ : typeof record.searchContent === 'string' ? record.searchContent.slice(0, 200) : '',
65
+ after: typeof record.new_string === 'string'
66
+ ? record.new_string.slice(0, 200)
67
+ : typeof record.replaceContent === 'string' ? record.replaceContent.slice(0, 200) : ''
68
+ };
69
+ });
70
+ }
71
+ return redact(JSON.stringify(allowed));
72
+ }
73
+
74
+ function parseVerdict(content: string): AdvisoryVerdict | null {
75
+ const match = content.match(/\{[\s\S]*\}/);
76
+ if (!match) return null;
77
+ try {
78
+ const parsed: unknown = JSON.parse(match[0]);
79
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
80
+ const record = parsed as Record<string, unknown>;
81
+ const risk = record.risk;
82
+ const confidence = record.confidence;
83
+ if (typeof risk !== 'number' || !Number.isFinite(risk)) return null;
84
+ if (typeof confidence !== 'number' || !Number.isFinite(confidence)) return null;
85
+ return {
86
+ risk: Math.max(0, Math.min(1, risk)),
87
+ confidence: Math.max(0, Math.min(1, confidence)),
88
+ observation: typeof record.observation === 'string' ? record.observation.slice(0, 400) : ''
89
+ };
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ export async function queryAdvisory(
96
+ input: AdvisoryInput,
97
+ options: AdvisoryOptions
98
+ ): Promise<AdvisoryVerdict | null> {
99
+ if (!isLoopbackEndpoint(options.endpoint)) return null;
100
+ if (!options.model.trim()) return null;
101
+ if (!Number.isFinite(options.timeoutMs) || options.timeoutMs < 200) return null;
102
+
103
+ const payload = {
104
+ model: options.model,
105
+ temperature: 0,
106
+ max_tokens: 160,
107
+ messages: [
108
+ {
109
+ role: 'system',
110
+ content: 'You are an evidence reviewer for a software agent. Judge only the supplied action and deterministic findings. Do not invent facts. Return one JSON object with keys risk (0..1), confidence (0..1), and observation (one sentence). High risk means the action could destroy work, escape the workspace, expose credentials, or publish state. Low risk means the action is reversible and scoped.'
111
+ },
112
+ {
113
+ role: 'user',
114
+ content: `Tool: ${input.tool}\nWorkspace: ${input.workspaceRoot}\nFindings: ${input.findings.slice(0, 6).join(' | ') || 'none'}\nArguments: ${compactArguments(input.args)}`
115
+ }
116
+ ]
117
+ };
118
+
119
+ const controller = new AbortController();
120
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs);
121
+ try {
122
+ const response = await fetch(completionUrl(options.endpoint), {
123
+ method: 'POST',
124
+ headers: { 'content-type': 'application/json' },
125
+ body: JSON.stringify(payload),
126
+ signal: controller.signal
127
+ });
128
+ if (!response.ok) return null;
129
+ const body: unknown = await response.json();
130
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) return null;
131
+ const choices = (body as Record<string, unknown>).choices;
132
+ if (!Array.isArray(choices) || choices.length === 0) return null;
133
+ const first = choices[0];
134
+ if (first === null || typeof first !== 'object' || Array.isArray(first)) return null;
135
+ const message = (first as Record<string, unknown>).message;
136
+ if (message === null || typeof message !== 'object' || Array.isArray(message)) return null;
137
+ const content = (message as Record<string, unknown>).content;
138
+ if (typeof content !== 'string') return null;
139
+ return parseVerdict(content);
140
+ } catch {
141
+ return null;
142
+ } finally {
143
+ clearTimeout(timer);
144
+ }
145
+ }
146
+
147
+ export async function probeAdvisory(options: AdvisoryOptions): Promise<{ healthy: boolean; latencyMs: number; detail: string }> {
148
+ if (!isLoopbackEndpoint(options.endpoint)) {
149
+ return { healthy: false, latencyMs: 0, detail: 'endpoint must be a loopback HTTP URL' };
150
+ }
151
+ const start = Date.now();
152
+ const controller = new AbortController();
153
+ const timer = setTimeout(() => controller.abort(), Math.max(500, options.timeoutMs));
154
+ try {
155
+ const base = options.endpoint.replace(/\/+$/, '').replace(/\/chat\/completions$/, '');
156
+ const response = await fetch(`${base}/models`, { signal: controller.signal });
157
+ return {
158
+ healthy: response.ok,
159
+ latencyMs: Date.now() - start,
160
+ detail: response.ok ? 'endpoint responded' : `endpoint returned HTTP ${response.status}`
161
+ };
162
+ } catch (error) {
163
+ return {
164
+ healthy: false,
165
+ latencyMs: Date.now() - start,
166
+ detail: error instanceof Error ? error.message : 'endpoint did not respond'
167
+ };
168
+ } finally {
169
+ clearTimeout(timer);
170
+ }
171
+ }