@cogineai/dearharness 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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +141 -0
  3. package/dist/cli.js +259 -0
  4. package/dist/config.js +21 -0
  5. package/dist/daemon/server.js +225 -0
  6. package/dist/extensions/builtin/policy-instructions.js +19 -0
  7. package/dist/extensions/loader.js +58 -0
  8. package/dist/extensions/types.js +1 -0
  9. package/dist/harness/install-applicator.js +126 -0
  10. package/dist/harness/install-apply.js +156 -0
  11. package/dist/harness/install-plan.js +154 -0
  12. package/dist/harness/install-runner.js +178 -0
  13. package/dist/harness/install-verification.js +117 -0
  14. package/dist/harness/lockfile.js +83 -0
  15. package/dist/harness/manifest.js +491 -0
  16. package/dist/harness/source.js +224 -0
  17. package/dist/harness/transaction.js +77 -0
  18. package/dist/harness/workspace.js +61 -0
  19. package/dist/index.js +9 -0
  20. package/dist/instructions/builder.js +33 -0
  21. package/dist/instructions/types.js +1 -0
  22. package/dist/model/config.js +100 -0
  23. package/dist/model/http.js +128 -0
  24. package/dist/model/index.js +22 -0
  25. package/dist/model/openrouter.js +9 -0
  26. package/dist/model/providers/anthropic.js +104 -0
  27. package/dist/model/providers/ollama-discovery.js +32 -0
  28. package/dist/model/providers/ollama.js +70 -0
  29. package/dist/model/providers/openai-compatible.js +118 -0
  30. package/dist/model/providers/openai.js +4 -0
  31. package/dist/model/providers/openrouter.js +79 -0
  32. package/dist/model/registry.js +108 -0
  33. package/dist/model/types.js +1 -0
  34. package/dist/policy/engine.js +30 -0
  35. package/dist/policy/types.js +1 -0
  36. package/dist/prompt/system.js +30 -0
  37. package/dist/protocol/actions.js +88 -0
  38. package/dist/runtime/assembly.js +54 -0
  39. package/dist/runtime/events.js +1 -0
  40. package/dist/runtime/hooks.js +13 -0
  41. package/dist/runtime/runner.js +193 -0
  42. package/dist/session/store.js +198 -0
  43. package/dist/session/types.js +1 -0
  44. package/dist/skills/loader.js +51 -0
  45. package/dist/skills/types.js +1 -0
  46. package/dist/tools/bash.js +71 -0
  47. package/dist/tools/edit.js +61 -0
  48. package/dist/tools/find.js +67 -0
  49. package/dist/tools/grep.js +88 -0
  50. package/dist/tools/ls.js +37 -0
  51. package/dist/tools/path.js +35 -0
  52. package/dist/tools/read.js +40 -0
  53. package/dist/tools/registry.js +18 -0
  54. package/dist/tools/types.js +1 -0
  55. package/dist/workspace/config.js +72 -0
  56. package/package.json +52 -0
@@ -0,0 +1,104 @@
1
+ import { fetchWithTimeout, joinUrl, readJsonResponse, readSseDeltas } from '../http.js';
2
+ function splitMessages(messages) {
3
+ const system = messages
4
+ .filter((message) => message.role === 'system')
5
+ .map((message) => message.content)
6
+ .join('\n\n');
7
+ const rest = messages
8
+ .filter((message) => message.role !== 'system')
9
+ .map((message) => ({ role: message.role, content: message.content }));
10
+ return { system, messages: rest };
11
+ }
12
+ function messagesUrl(baseUrl) {
13
+ return baseUrl.replace(/\/+$/, '').endsWith('/v1')
14
+ ? joinUrl(baseUrl, '/messages')
15
+ : joinUrl(baseUrl, '/v1/messages');
16
+ }
17
+ export function createAnthropicClient(config) {
18
+ return {
19
+ async complete(messages, options) {
20
+ const body = splitMessages(messages);
21
+ await options?.onEvent?.({
22
+ type: 'start',
23
+ provider: config.provider,
24
+ model: config.model,
25
+ streaming: config.streaming !== 'off'
26
+ });
27
+ try {
28
+ if (!config.apiKey) {
29
+ throw new Error('ANTHROPIC_API_KEY is required');
30
+ }
31
+ if (config.streaming !== 'off') {
32
+ const response = await fetchWithTimeout(messagesUrl(config.baseUrl), {
33
+ method: 'POST',
34
+ headers: {
35
+ 'content-type': 'application/json',
36
+ 'x-api-key': config.apiKey,
37
+ 'anthropic-version': '2023-06-01'
38
+ },
39
+ body: JSON.stringify({
40
+ model: config.model,
41
+ max_tokens: config.maxOutputTokens ?? 4096,
42
+ ...(body.system ? { system: body.system } : {}),
43
+ messages: body.messages,
44
+ stream: true
45
+ })
46
+ });
47
+ const content = (await readSseDeltas(response, (json) => {
48
+ const event = json;
49
+ return event.type === 'content_block_delta' && event.delta?.type === 'text_delta'
50
+ ? (event.delta.text ?? null)
51
+ : null;
52
+ }, async (text) => options?.onEvent?.({ type: 'text-delta', text }))).trim();
53
+ if (!content) {
54
+ throw new Error('Anthropic stream missing text content');
55
+ }
56
+ await options?.onEvent?.({ type: 'end' });
57
+ return {
58
+ content,
59
+ provider: config.provider,
60
+ model: config.model
61
+ };
62
+ }
63
+ const response = await fetchWithTimeout(messagesUrl(config.baseUrl), {
64
+ method: 'POST',
65
+ headers: {
66
+ 'content-type': 'application/json',
67
+ 'x-api-key': config.apiKey,
68
+ 'anthropic-version': '2023-06-01'
69
+ },
70
+ body: JSON.stringify({
71
+ model: config.model,
72
+ max_tokens: config.maxOutputTokens ?? 4096,
73
+ ...(body.system ? { system: body.system } : {}),
74
+ messages: body.messages,
75
+ stream: false
76
+ })
77
+ });
78
+ const json = await readJsonResponse(response, 'Anthropic');
79
+ if (!Array.isArray(json.content)) {
80
+ throw new Error(`Anthropic response missing or invalid content array: ${JSON.stringify(json)}`);
81
+ }
82
+ const content = json.content
83
+ .find((block) => block.type === 'text' && typeof block.text === 'string')
84
+ ?.text?.trim();
85
+ if (!content) {
86
+ throw new Error(`Anthropic response missing text content: ${JSON.stringify(json)}`);
87
+ }
88
+ await options?.onEvent?.({ type: 'end' });
89
+ return {
90
+ content,
91
+ provider: config.provider,
92
+ model: config.model
93
+ };
94
+ }
95
+ catch (error) {
96
+ await options?.onEvent?.({
97
+ type: 'error',
98
+ message: error instanceof Error ? error.message : String(error)
99
+ });
100
+ throw error;
101
+ }
102
+ }
103
+ };
104
+ }
@@ -0,0 +1,32 @@
1
+ import { OLLAMA_DEFAULT_BASE_URL, OLLAMA_DISCOVERY_TIMEOUT_MS } from '../../config.js';
2
+ import { fetchWithTimeout, joinUrl, readJsonResponse } from '../http.js';
3
+ function isRecord(value) {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+ function normalizeOllamaModel(value) {
7
+ if (!isRecord(value) || typeof value.name !== 'string' || value.name.trim() === '') {
8
+ return null;
9
+ }
10
+ return {
11
+ name: value.name,
12
+ ...(typeof value.modified_at === 'string' ? { modified_at: value.modified_at } : {}),
13
+ ...(typeof value.size === 'number' ? { size: value.size } : {}),
14
+ ...(typeof value.digest === 'string' ? { digest: value.digest } : {})
15
+ };
16
+ }
17
+ export function selectDefaultOllamaModel(models) {
18
+ return models.find((model) => model.name.toLowerCase().includes('qwen'))?.name ?? models[0]?.name ?? null;
19
+ }
20
+ export async function discoverOllamaModels(baseUrl = OLLAMA_DEFAULT_BASE_URL) {
21
+ const response = await fetchWithTimeout(joinUrl(baseUrl, '/api/tags'), {
22
+ method: 'GET'
23
+ }, OLLAMA_DISCOVERY_TIMEOUT_MS);
24
+ const json = await readJsonResponse(response, 'Ollama discovery');
25
+ if (!isRecord(json) || !Array.isArray(json.models)) {
26
+ throw new Error(`Ollama discovery response missing models array: ${JSON.stringify(json)}`);
27
+ }
28
+ return json.models.flatMap((model) => {
29
+ const normalized = normalizeOllamaModel(model);
30
+ return normalized ? [normalized] : [];
31
+ });
32
+ }
@@ -0,0 +1,70 @@
1
+ import { fetchWithTimeout, joinUrl, readJsonResponse, readNdjsonDeltas } from '../http.js';
2
+ export function createOllamaClient(config) {
3
+ return {
4
+ async complete(messages, options) {
5
+ await options?.onEvent?.({
6
+ type: 'start',
7
+ provider: config.provider,
8
+ model: config.model,
9
+ streaming: config.streaming !== 'off'
10
+ });
11
+ try {
12
+ if (config.streaming !== 'off') {
13
+ const response = await fetchWithTimeout(joinUrl(config.baseUrl, '/api/chat'), {
14
+ method: 'POST',
15
+ headers: {
16
+ 'content-type': 'application/json'
17
+ },
18
+ body: JSON.stringify({
19
+ model: config.model,
20
+ messages,
21
+ stream: true
22
+ })
23
+ });
24
+ const content = (await readNdjsonDeltas(response, (json) => {
25
+ const event = json;
26
+ return event.message?.content ?? null;
27
+ }, async (text) => options?.onEvent?.({ type: 'text-delta', text }))).trim();
28
+ if (!content) {
29
+ throw new Error('Ollama stream missing message/content');
30
+ }
31
+ await options?.onEvent?.({ type: 'end' });
32
+ return {
33
+ content,
34
+ provider: config.provider,
35
+ model: config.model
36
+ };
37
+ }
38
+ const response = await fetchWithTimeout(joinUrl(config.baseUrl, '/api/chat'), {
39
+ method: 'POST',
40
+ headers: {
41
+ 'content-type': 'application/json'
42
+ },
43
+ body: JSON.stringify({
44
+ model: config.model,
45
+ messages,
46
+ stream: false
47
+ })
48
+ });
49
+ const json = await readJsonResponse(response, 'Ollama');
50
+ const content = json.message?.content?.trim();
51
+ if (!content) {
52
+ throw new Error(`Ollama response missing message/content: ${JSON.stringify(json)}`);
53
+ }
54
+ await options?.onEvent?.({ type: 'end' });
55
+ return {
56
+ content,
57
+ provider: config.provider,
58
+ model: config.model
59
+ };
60
+ }
61
+ catch (error) {
62
+ await options?.onEvent?.({
63
+ type: 'error',
64
+ message: error instanceof Error ? error.message : String(error)
65
+ });
66
+ throw error;
67
+ }
68
+ }
69
+ };
70
+ }
@@ -0,0 +1,118 @@
1
+ import { fetchWithTimeout, joinUrl, readJsonResponse, readSseDeltas } from '../http.js';
2
+ const AUTO_STREAM_FALLBACK_STATUSES = new Set([400, 404, 405, 415, 422]);
3
+ function headers(config) {
4
+ return {
5
+ 'content-type': 'application/json',
6
+ ...(config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {})
7
+ };
8
+ }
9
+ function shouldFallbackFromStreamingResponse(response) {
10
+ return AUTO_STREAM_FALLBACK_STATUSES.has(response.status);
11
+ }
12
+ async function streamHttpError(response) {
13
+ const body = (await response.text()).trim();
14
+ const detail = body ? `: ${body}` : '';
15
+ return new Error(`Model stream error ${response.status}${detail}. If this endpoint does not support streaming, retry with --streaming off.`);
16
+ }
17
+ async function emitErrorEvent(options, error) {
18
+ try {
19
+ await options?.onEvent?.({
20
+ type: 'error',
21
+ message: error instanceof Error ? error.message : String(error)
22
+ });
23
+ }
24
+ catch {
25
+ // Preserve the original provider failure even if the event sink fails.
26
+ }
27
+ }
28
+ async function emitStartEvent(config, options, streaming) {
29
+ await options?.onEvent?.({
30
+ type: 'start',
31
+ provider: config.provider,
32
+ model: config.model,
33
+ streaming
34
+ });
35
+ }
36
+ function parseContent(json, provider) {
37
+ if (!Array.isArray(json.choices) || json.choices.length === 0) {
38
+ throw new Error(`${provider} response missing choices/content: ${JSON.stringify(json)}`);
39
+ }
40
+ const content = json.choices[0]?.message?.content?.trim();
41
+ if (!content) {
42
+ throw new Error(`${provider} response missing choices/content: ${JSON.stringify(json)}`);
43
+ }
44
+ return content;
45
+ }
46
+ async function completeWithoutStreaming(config, messages, options) {
47
+ await emitStartEvent(config, options, false);
48
+ const response = await fetchWithTimeout(joinUrl(config.baseUrl, '/chat/completions'), {
49
+ method: 'POST',
50
+ headers: headers(config),
51
+ body: JSON.stringify({
52
+ model: config.model,
53
+ messages,
54
+ stream: false
55
+ })
56
+ });
57
+ const json = await readJsonResponse(response, config.provider);
58
+ const content = parseContent(json, config.provider);
59
+ await options?.onEvent?.({ type: 'end' });
60
+ return {
61
+ content,
62
+ provider: config.provider,
63
+ model: config.model
64
+ };
65
+ }
66
+ async function completeWithStreaming(config, messages, options) {
67
+ if (config.streaming === 'on') {
68
+ await emitStartEvent(config, options, true);
69
+ }
70
+ const response = await fetchWithTimeout(joinUrl(config.baseUrl, '/chat/completions'), {
71
+ method: 'POST',
72
+ headers: headers(config),
73
+ body: JSON.stringify({
74
+ model: config.model,
75
+ messages,
76
+ stream: true
77
+ })
78
+ });
79
+ if (!response.ok) {
80
+ if (config.streaming === 'auto' && shouldFallbackFromStreamingResponse(response)) {
81
+ await response.body?.cancel();
82
+ return completeWithoutStreaming(config, messages, options);
83
+ }
84
+ throw await streamHttpError(response);
85
+ }
86
+ if (config.streaming === 'auto') {
87
+ await emitStartEvent(config, options, true);
88
+ }
89
+ const content = (await readSseDeltas(response, (json) => {
90
+ const choice = json.choices?.[0];
91
+ return choice?.delta?.content ?? null;
92
+ }, async (text) => options?.onEvent?.({ type: 'text-delta', text }))).trim();
93
+ if (!content) {
94
+ throw new Error(`${config.provider} stream missing text content`);
95
+ }
96
+ await options?.onEvent?.({ type: 'end' });
97
+ return {
98
+ content,
99
+ provider: config.provider,
100
+ model: config.model
101
+ };
102
+ }
103
+ export function createOpenAICompatibleClient(config) {
104
+ return {
105
+ async complete(messages, options) {
106
+ try {
107
+ if (config.streaming !== 'off') {
108
+ return await completeWithStreaming(config, messages, options);
109
+ }
110
+ return await completeWithoutStreaming(config, messages, options);
111
+ }
112
+ catch (error) {
113
+ await emitErrorEvent(options, error);
114
+ throw error;
115
+ }
116
+ }
117
+ };
118
+ }
@@ -0,0 +1,4 @@
1
+ import { createOpenAICompatibleClient } from './openai-compatible.js';
2
+ export function createOpenAIClient(config) {
3
+ return createOpenAICompatibleClient(config);
4
+ }
@@ -0,0 +1,79 @@
1
+ import { fetchWithTimeout, joinUrl, readJsonResponse, readSseDeltas } from '../http.js';
2
+ export function createOpenRouterClient(config) {
3
+ return {
4
+ async complete(messages, options) {
5
+ if (!config.apiKey) {
6
+ throw new Error('OPENROUTER_API_KEY is required');
7
+ }
8
+ await options?.onEvent?.({
9
+ type: 'start',
10
+ provider: config.provider,
11
+ model: config.model,
12
+ streaming: config.streaming !== 'off'
13
+ });
14
+ try {
15
+ if (config.streaming !== 'off') {
16
+ const response = await fetchWithTimeout(joinUrl(config.baseUrl, '/chat/completions'), {
17
+ method: 'POST',
18
+ headers: {
19
+ 'content-type': 'application/json',
20
+ authorization: `Bearer ${config.apiKey}`,
21
+ 'HTTP-Referer': 'https://local.cliq',
22
+ 'X-Title': 'cliq-agent'
23
+ },
24
+ body: JSON.stringify({
25
+ model: config.model,
26
+ messages,
27
+ stream: true
28
+ })
29
+ });
30
+ const content = (await readSseDeltas(response, (json) => {
31
+ const choice = json.choices?.[0];
32
+ return choice?.delta?.content ?? null;
33
+ }, async (text) => options?.onEvent?.({ type: 'text-delta', text }))).trim();
34
+ if (!content) {
35
+ throw new Error('OpenRouter stream missing text content');
36
+ }
37
+ await options?.onEvent?.({ type: 'end' });
38
+ return {
39
+ content,
40
+ provider: config.provider,
41
+ model: config.model
42
+ };
43
+ }
44
+ const response = await fetchWithTimeout(joinUrl(config.baseUrl, '/chat/completions'), {
45
+ method: 'POST',
46
+ headers: {
47
+ 'content-type': 'application/json',
48
+ authorization: `Bearer ${config.apiKey}`,
49
+ 'HTTP-Referer': 'https://local.cliq',
50
+ 'X-Title': 'cliq-agent'
51
+ },
52
+ body: JSON.stringify({
53
+ model: config.model,
54
+ messages,
55
+ stream: false
56
+ })
57
+ });
58
+ const json = await readJsonResponse(response, 'OpenRouter');
59
+ const content = json.choices[0]?.message?.content?.trim();
60
+ if (!content) {
61
+ throw new Error(`OpenRouter response missing choices/content: ${JSON.stringify(json)}`);
62
+ }
63
+ await options?.onEvent?.({ type: 'end' });
64
+ return {
65
+ content,
66
+ provider: config.provider,
67
+ model: config.model
68
+ };
69
+ }
70
+ catch (error) {
71
+ await options?.onEvent?.({
72
+ type: 'error',
73
+ message: error instanceof Error ? error.message : String(error)
74
+ });
75
+ throw error;
76
+ }
77
+ }
78
+ };
79
+ }
@@ -0,0 +1,108 @@
1
+ import { DEFAULT_MODEL_BASE_URL, DEFAULT_MODEL_PROVIDER, MODEL, OLLAMA_DEFAULT_BASE_URL } from '../config.js';
2
+ const TEXT_TO_TEXT = {
3
+ input: ['text'],
4
+ output: ['text'],
5
+ streaming: true,
6
+ reasoning: false,
7
+ toolCalling: false
8
+ };
9
+ const TEXT_TO_TEXT_REASONING = {
10
+ ...TEXT_TO_TEXT,
11
+ reasoning: true
12
+ };
13
+ export const DEFAULT_MODEL_CONFIG = {
14
+ provider: DEFAULT_MODEL_PROVIDER,
15
+ model: MODEL,
16
+ baseUrl: DEFAULT_MODEL_BASE_URL,
17
+ streaming: 'auto'
18
+ };
19
+ const PROVIDERS = {
20
+ openrouter: {
21
+ name: 'openrouter',
22
+ displayName: 'OpenRouter',
23
+ defaultBaseUrl: 'https://openrouter.ai/api/v1',
24
+ apiKeyEnv: 'OPENROUTER_API_KEY',
25
+ requiresApiKey: true,
26
+ getDefaultModel: () => MODEL,
27
+ getKnownModels: () => [
28
+ {
29
+ provider: 'openrouter',
30
+ model: MODEL,
31
+ displayName: 'Claude Sonnet 4.6 via OpenRouter',
32
+ capabilities: TEXT_TO_TEXT_REASONING
33
+ }
34
+ ]
35
+ },
36
+ anthropic: {
37
+ name: 'anthropic',
38
+ displayName: 'Anthropic',
39
+ defaultBaseUrl: 'https://api.anthropic.com',
40
+ apiKeyEnv: 'ANTHROPIC_API_KEY',
41
+ requiresApiKey: true,
42
+ getDefaultModel: () => 'claude-sonnet-4-20250514',
43
+ getKnownModels: () => [
44
+ {
45
+ provider: 'anthropic',
46
+ model: 'claude-sonnet-4-20250514',
47
+ displayName: 'Claude Sonnet 4',
48
+ capabilities: TEXT_TO_TEXT_REASONING
49
+ }
50
+ ]
51
+ },
52
+ openai: {
53
+ name: 'openai',
54
+ displayName: 'OpenAI',
55
+ defaultBaseUrl: 'https://api.openai.com/v1',
56
+ apiKeyEnv: 'OPENAI_API_KEY',
57
+ requiresApiKey: true,
58
+ getDefaultModel: () => 'gpt-5.2',
59
+ getKnownModels: () => [
60
+ {
61
+ provider: 'openai',
62
+ model: 'gpt-5.2',
63
+ displayName: 'GPT-5.2',
64
+ capabilities: TEXT_TO_TEXT_REASONING
65
+ }
66
+ ]
67
+ },
68
+ 'openai-compatible': {
69
+ name: 'openai-compatible',
70
+ displayName: 'OpenAI-compatible',
71
+ defaultBaseUrl: '',
72
+ apiKeyEnv: 'OPENAI_COMPATIBLE_API_KEY',
73
+ requiresApiKey: false,
74
+ getDefaultModel: () => null,
75
+ getKnownModels: () => []
76
+ },
77
+ ollama: {
78
+ name: 'ollama',
79
+ displayName: 'Ollama',
80
+ defaultBaseUrl: OLLAMA_DEFAULT_BASE_URL,
81
+ requiresApiKey: false,
82
+ getDefaultModel: () => null,
83
+ getKnownModels: () => []
84
+ }
85
+ };
86
+ const factories = new Map();
87
+ export function registerModelClientFactory(provider, factory) {
88
+ factories.set(provider, factory);
89
+ }
90
+ export function isProviderName(value) {
91
+ return Object.hasOwn(PROVIDERS, value);
92
+ }
93
+ export function getModelProvider(provider) {
94
+ const definition = PROVIDERS[provider];
95
+ return {
96
+ ...definition,
97
+ createClient(config) {
98
+ const factory = factories.get(provider);
99
+ if (!factory) {
100
+ throw new Error(`Model provider ${provider} is not registered`);
101
+ }
102
+ return factory(config);
103
+ }
104
+ };
105
+ }
106
+ export function listModelProviders() {
107
+ return Object.keys(PROVIDERS).map((provider) => getModelProvider(provider));
108
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ export function createPolicyEngine({ mode, confirm }) {
2
+ async function authorize(definition) {
3
+ if (mode === 'auto') {
4
+ return { allowed: true };
5
+ }
6
+ if (mode === 'read-only' && definition.access !== 'read') {
7
+ return {
8
+ allowed: false,
9
+ reason: `policy mode read-only blocks ${definition.access} tools`
10
+ };
11
+ }
12
+ // "confirm-bash" is the current user-facing label, but policy should apply
13
+ // to any exec-capable tool rather than only the built-in bash tool name.
14
+ const requiresConfirmation = mode === 'confirm-all' ||
15
+ (mode === 'confirm-write' && definition.access === 'write') ||
16
+ (mode === 'confirm-bash' && definition.access === 'exec');
17
+ if (!requiresConfirmation) {
18
+ return { allowed: true };
19
+ }
20
+ if (!confirm) {
21
+ return {
22
+ allowed: false,
23
+ reason: 'confirmation required but no confirmer is available'
24
+ };
25
+ }
26
+ const approved = await confirm(`Allow ${definition.name} (${definition.access})?`);
27
+ return approved ? { allowed: true } : { allowed: false, reason: 'user declined confirmation' };
28
+ }
29
+ return { mode, authorize };
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,30 @@
1
+ export const BASE_SYSTEM_PROMPT = `You are a tiny coding agent inside a local CLI runtime.
2
+ Return exactly one JSON object and nothing else.
3
+
4
+ Allowed response shapes:
5
+ - {"bash":"<shell command>"}
6
+ - {"edit":{"path":"<workspace-relative-path>","old_text":"<exact old text>","new_text":"<replacement text>"}}
7
+ - {"read":{"path":"<workspace-relative-path>","start_line":1,"end_line":120}}
8
+ - {"ls":{"path":"<workspace-relative-path>"}}
9
+ - {"find":{"path":"<workspace-relative-path>","name":"<substring>"}}
10
+ - {"grep":{"path":"<workspace-relative-path>","pattern":"<substring>"}}
11
+ - {"message":"<final user-facing response>"}
12
+
13
+ Rules:
14
+ - The workspace root is the current working directory. Commands run there.
15
+ - Prefer {"edit":...} for precise single-file text replacements when it is simpler and safer than shell editing.
16
+ - Prefer {"read":...}, {"ls":...}, {"find":...}, and {"grep":...} for repo inspection before using {"bash":...}.
17
+ - Use {"bash":...} for tests, formatting, file creation, multi-step shell work, or anything not covered by the structured tools.
18
+ - Paths should normally be relative to the workspace root.
19
+ - old_text must match exactly once. If it does not, inspect first and recover.
20
+ - Keep going until the task is complete or you are blocked.
21
+ - When finished, respond with {"message":"..."} summarizing what changed and any verification.
22
+ - Do not wrap JSON in markdown fences.
23
+ - Do not emit explanatory text before or after the JSON.
24
+
25
+ Examples:
26
+ - {"ls":{"path":"src"}}
27
+ - {"read":{"path":"src/runtime/runner.ts","start_line":1,"end_line":80}}
28
+ - {"find":{"path":"src","name":"runner"}}
29
+ - {"grep":{"path":"src","pattern":"runTurn"}}`;
30
+ export const SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
@@ -0,0 +1,88 @@
1
+ const TOP_LEVEL_ACTIONS = ['bash', 'edit', 'read', 'ls', 'find', 'grep', 'message'];
2
+ export function parseModelAction(content) {
3
+ let parsed;
4
+ try {
5
+ parsed = JSON.parse(content);
6
+ }
7
+ catch (error) {
8
+ const message = error instanceof Error ? error.message : String(error);
9
+ throw new Error(`Invalid JSON from model: ${message}\n${content}`);
10
+ }
11
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
12
+ throw new Error(`Model returned invalid action object:\n${content}`);
13
+ }
14
+ const record = parsed;
15
+ const keys = Object.keys(record);
16
+ if (keys.length !== 1) {
17
+ throw new Error(`Model action must contain exactly one top-level key:\n${content}`);
18
+ }
19
+ const topLevelKey = keys[0];
20
+ if (typeof record.bash === 'string') {
21
+ return { bash: record.bash };
22
+ }
23
+ if (typeof record.message === 'string') {
24
+ return { message: record.message };
25
+ }
26
+ if (record.edit && typeof record.edit === 'object' && !Array.isArray(record.edit)) {
27
+ const edit = record.edit;
28
+ if (typeof edit.path === 'string' && typeof edit.old_text === 'string' && typeof edit.new_text === 'string') {
29
+ return {
30
+ edit: {
31
+ path: edit.path,
32
+ old_text: edit.old_text,
33
+ new_text: edit.new_text
34
+ }
35
+ };
36
+ }
37
+ }
38
+ if (record.read && typeof record.read === 'object' && !Array.isArray(record.read)) {
39
+ const read = record.read;
40
+ if (typeof read.path === 'string' &&
41
+ (read.start_line === undefined || typeof read.start_line === 'number') &&
42
+ (read.end_line === undefined || typeof read.end_line === 'number')) {
43
+ return {
44
+ read: {
45
+ path: read.path,
46
+ start_line: read.start_line,
47
+ end_line: read.end_line
48
+ }
49
+ };
50
+ }
51
+ }
52
+ if (record.ls && typeof record.ls === 'object' && !Array.isArray(record.ls)) {
53
+ const ls = record.ls;
54
+ if (ls.path === undefined || typeof ls.path === 'string') {
55
+ return {
56
+ ls: {
57
+ path: ls.path
58
+ }
59
+ };
60
+ }
61
+ }
62
+ if (record.find && typeof record.find === 'object' && !Array.isArray(record.find)) {
63
+ const find = record.find;
64
+ if ((find.path === undefined || typeof find.path === 'string') && typeof find.name === 'string') {
65
+ return {
66
+ find: {
67
+ path: find.path,
68
+ name: find.name
69
+ }
70
+ };
71
+ }
72
+ }
73
+ if (record.grep && typeof record.grep === 'object' && !Array.isArray(record.grep)) {
74
+ const grep = record.grep;
75
+ if ((grep.path === undefined || typeof grep.path === 'string') && typeof grep.pattern === 'string') {
76
+ return {
77
+ grep: {
78
+ path: grep.path,
79
+ pattern: grep.pattern
80
+ }
81
+ };
82
+ }
83
+ }
84
+ if (!TOP_LEVEL_ACTIONS.includes(topLevelKey)) {
85
+ throw new Error(`Unknown top-level key in model action: ${topLevelKey}\n${content}`);
86
+ }
87
+ throw new Error(`Model returned unsupported action:\n${content}`);
88
+ }