@kal-elsam/kairo-runtime 0.1.5 → 0.2.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.
- package/README.md +23 -0
- package/package.json +1 -1
- package/src/cli.js +54 -1
- package/src/global/action-planner.js +55 -3
- package/src/global/ink/orchestrator-app.js +20 -5
- package/src/global/ink/orchestrator-state.js +31 -2
- package/src/global/intelligence/backends/custom-http.js +175 -0
- package/src/global/intelligence/backends/ollama.js +164 -0
- package/src/global/intelligence/backends/openrouter.js +198 -0
- package/src/global/intelligence/context-compiler.js +338 -0
- package/src/global/intelligence/custom-url.js +82 -0
- package/src/global/intelligence/http.js +63 -0
- package/src/global/intelligence/index.js +38 -0
- package/src/global/intelligence/orchestrate.js +189 -0
- package/src/global/intelligence/registry.js +77 -0
- package/src/global/intelligence/router.js +191 -0
- package/src/global/intelligence/types.js +99 -0
- package/src/global/intelligence-cli.js +323 -0
- package/src/global/orchestrator.js +11 -0
- package/src/global/profile.js +153 -2
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BACKEND_IDS,
|
|
3
|
+
COST_CLASSES,
|
|
4
|
+
DEFAULT_OLLAMA_HOST,
|
|
5
|
+
PRIVACY_CLASSES,
|
|
6
|
+
createModelDescriptor
|
|
7
|
+
} from "../types.js";
|
|
8
|
+
import { fetchJson } from "../http.js";
|
|
9
|
+
import { CAPABILITY_STATES } from "../../capability-states.js";
|
|
10
|
+
|
|
11
|
+
export function createOllamaBackend({
|
|
12
|
+
host = null,
|
|
13
|
+
fetchImpl = globalThis.fetch,
|
|
14
|
+
env = process.env
|
|
15
|
+
} = {}) {
|
|
16
|
+
const baseUrl = normalizeOllamaHost(host ?? env.OLLAMA_HOST ?? DEFAULT_OLLAMA_HOST);
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
id: BACKEND_IDS.OLLAMA,
|
|
20
|
+
label: "Ollama",
|
|
21
|
+
local: true,
|
|
22
|
+
|
|
23
|
+
async detect() {
|
|
24
|
+
const result = await fetchJson(`${baseUrl}/api/tags`, {
|
|
25
|
+
timeoutMs: 2000,
|
|
26
|
+
fetchImpl
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!result.ok) {
|
|
30
|
+
return {
|
|
31
|
+
id: BACKEND_IDS.OLLAMA,
|
|
32
|
+
label: "Ollama",
|
|
33
|
+
state: CAPABILITY_STATES.UNKNOWN,
|
|
34
|
+
detected: false,
|
|
35
|
+
available: false,
|
|
36
|
+
host: baseUrl,
|
|
37
|
+
error: result.error,
|
|
38
|
+
recommendation: `Start Ollama locally (${DEFAULT_OLLAMA_HOST}) or set OLLAMA_HOST.`
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const models = parseOllamaModels(result.data);
|
|
43
|
+
return {
|
|
44
|
+
id: BACKEND_IDS.OLLAMA,
|
|
45
|
+
label: "Ollama",
|
|
46
|
+
state: models.length > 0 ? CAPABILITY_STATES.AVAILABLE : CAPABILITY_STATES.DETECTED,
|
|
47
|
+
detected: true,
|
|
48
|
+
available: models.length > 0,
|
|
49
|
+
host: baseUrl,
|
|
50
|
+
modelCount: models.length,
|
|
51
|
+
error: null,
|
|
52
|
+
recommendation: models.length > 0
|
|
53
|
+
? `Ollama ready with ${models.length} local model(s).`
|
|
54
|
+
: "Ollama is running but no models are pulled yet."
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async listModels() {
|
|
59
|
+
const result = await fetchJson(`${baseUrl}/api/tags`, { fetchImpl });
|
|
60
|
+
if (!result.ok) return [];
|
|
61
|
+
return parseOllamaModels(result.data);
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async capabilities() {
|
|
65
|
+
const detection = await this.detect();
|
|
66
|
+
return {
|
|
67
|
+
id: BACKEND_IDS.OLLAMA,
|
|
68
|
+
local: true,
|
|
69
|
+
cloud: false,
|
|
70
|
+
requiresApiKey: false,
|
|
71
|
+
requiresConsent: false,
|
|
72
|
+
streaming: true,
|
|
73
|
+
tools: false,
|
|
74
|
+
state: detection.state,
|
|
75
|
+
host: baseUrl
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
async invoke(contextPack, request = {}) {
|
|
80
|
+
const modelId = request.modelId;
|
|
81
|
+
if (!modelId) {
|
|
82
|
+
return invokeError("Ollama invoke requires modelId.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const messages = buildChatMessages(contextPack, request);
|
|
86
|
+
const result = await fetchJson(`${baseUrl}/api/chat`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
body: {
|
|
90
|
+
model: modelId,
|
|
91
|
+
messages,
|
|
92
|
+
stream: false,
|
|
93
|
+
options: request.options ?? undefined
|
|
94
|
+
},
|
|
95
|
+
timeoutMs: request.timeoutMs ?? 120000,
|
|
96
|
+
fetchImpl
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (!result.ok) {
|
|
100
|
+
return invokeError(result.error ?? "Ollama chat failed.");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const content = result.data?.message?.content ?? "";
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
backendId: BACKEND_IDS.OLLAMA,
|
|
107
|
+
model: modelId,
|
|
108
|
+
content,
|
|
109
|
+
usage: {
|
|
110
|
+
inputTokens: result.data?.prompt_eval_count ?? null,
|
|
111
|
+
outputTokens: result.data?.eval_count ?? null,
|
|
112
|
+
cachedTokens: null,
|
|
113
|
+
estimatedCost: 0,
|
|
114
|
+
model: modelId,
|
|
115
|
+
backendId: BACKEND_IDS.OLLAMA,
|
|
116
|
+
fallbackUsed: false
|
|
117
|
+
},
|
|
118
|
+
raw: result.data
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parseOllamaModels(data) {
|
|
125
|
+
const models = Array.isArray(data?.models) ? data.models : [];
|
|
126
|
+
return models.map((entry) => createModelDescriptor({
|
|
127
|
+
provider: BACKEND_IDS.OLLAMA,
|
|
128
|
+
modelId: entry.name ?? entry.model,
|
|
129
|
+
local: true,
|
|
130
|
+
costClass: COST_CLASSES.LOCAL,
|
|
131
|
+
privacyClass: PRIVACY_CLASSES.LOCAL,
|
|
132
|
+
contextLimit: null,
|
|
133
|
+
tools: false,
|
|
134
|
+
reasoning: false
|
|
135
|
+
})).filter((entry) => Boolean(entry.modelId));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeOllamaHost(host) {
|
|
139
|
+
return String(host).replace(/\/+$/, "");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function buildChatMessages(contextPack, request) {
|
|
143
|
+
const messages = [];
|
|
144
|
+
if (contextPack?.systemPrompt) {
|
|
145
|
+
messages.push({ role: "system", content: contextPack.systemPrompt });
|
|
146
|
+
}
|
|
147
|
+
if (Array.isArray(request.messages) && request.messages.length > 0) {
|
|
148
|
+
messages.push(...request.messages);
|
|
149
|
+
} else if (request.prompt) {
|
|
150
|
+
messages.push({ role: "user", content: request.prompt });
|
|
151
|
+
}
|
|
152
|
+
return messages;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function invokeError(message) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
backendId: BACKEND_IDS.OLLAMA,
|
|
159
|
+
model: null,
|
|
160
|
+
content: null,
|
|
161
|
+
error: message,
|
|
162
|
+
usage: null
|
|
163
|
+
};
|
|
164
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BACKEND_IDS,
|
|
3
|
+
COST_CLASSES,
|
|
4
|
+
OPENROUTER_FREE_MODEL,
|
|
5
|
+
PRIVACY_CLASSES,
|
|
6
|
+
createModelDescriptor
|
|
7
|
+
} from "../types.js";
|
|
8
|
+
import { fetchJson } from "../http.js";
|
|
9
|
+
import { CAPABILITY_STATES } from "../../capability-states.js";
|
|
10
|
+
|
|
11
|
+
const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
|
|
12
|
+
|
|
13
|
+
export function createOpenRouterBackend({
|
|
14
|
+
fetchImpl = globalThis.fetch,
|
|
15
|
+
env = process.env,
|
|
16
|
+
apiKey = null
|
|
17
|
+
} = {}) {
|
|
18
|
+
const resolvedKey = apiKey ?? env.OPENROUTER_API_KEY ?? null;
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
id: BACKEND_IDS.OPENROUTER,
|
|
22
|
+
label: "OpenRouter",
|
|
23
|
+
local: false,
|
|
24
|
+
|
|
25
|
+
async detect() {
|
|
26
|
+
if (!resolvedKey) {
|
|
27
|
+
return {
|
|
28
|
+
id: BACKEND_IDS.OPENROUTER,
|
|
29
|
+
label: "OpenRouter",
|
|
30
|
+
state: CAPABILITY_STATES.UNKNOWN,
|
|
31
|
+
detected: false,
|
|
32
|
+
available: false,
|
|
33
|
+
hasApiKey: false,
|
|
34
|
+
error: null,
|
|
35
|
+
recommendation: "Set OPENROUTER_API_KEY in the environment to enable OpenRouter (never stored by Kairo)."
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
id: BACKEND_IDS.OPENROUTER,
|
|
41
|
+
label: "OpenRouter",
|
|
42
|
+
state: CAPABILITY_STATES.AUTHENTICATED,
|
|
43
|
+
detected: true,
|
|
44
|
+
available: true,
|
|
45
|
+
hasApiKey: true,
|
|
46
|
+
error: null,
|
|
47
|
+
recommendation: "OpenRouter API key detected in environment. Cloud use still requires explicit consent."
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
async listModels({ freeOnly = true } = {}) {
|
|
52
|
+
if (!resolvedKey) return [];
|
|
53
|
+
|
|
54
|
+
const result = await fetchJson(`${OPENROUTER_BASE}/models`, {
|
|
55
|
+
headers: {
|
|
56
|
+
Authorization: `Bearer ${resolvedKey}`,
|
|
57
|
+
"Content-Type": "application/json"
|
|
58
|
+
},
|
|
59
|
+
fetchImpl
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!result.ok) {
|
|
63
|
+
return [
|
|
64
|
+
createModelDescriptor({
|
|
65
|
+
provider: BACKEND_IDS.OPENROUTER,
|
|
66
|
+
modelId: OPENROUTER_FREE_MODEL,
|
|
67
|
+
local: false,
|
|
68
|
+
costClass: COST_CLASSES.FREE,
|
|
69
|
+
privacyClass: PRIVACY_CLASSES.CLOUD,
|
|
70
|
+
opaque: true
|
|
71
|
+
})
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const models = Array.isArray(result.data?.data) ? result.data.data : [];
|
|
76
|
+
const mapped = models
|
|
77
|
+
.filter((entry) => {
|
|
78
|
+
if (!freeOnly) return true;
|
|
79
|
+
const id = entry.id ?? "";
|
|
80
|
+
return id.endsWith(":free") || id === OPENROUTER_FREE_MODEL;
|
|
81
|
+
})
|
|
82
|
+
.map((entry) => createModelDescriptor({
|
|
83
|
+
provider: BACKEND_IDS.OPENROUTER,
|
|
84
|
+
modelId: entry.id,
|
|
85
|
+
local: false,
|
|
86
|
+
costClass: COST_CLASSES.FREE,
|
|
87
|
+
privacyClass: PRIVACY_CLASSES.CLOUD,
|
|
88
|
+
contextLimit: entry.context_length ?? null,
|
|
89
|
+
tools: Boolean(entry.supported_parameters?.includes?.("tools")),
|
|
90
|
+
reasoning: false
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
if (!mapped.some((entry) => entry.modelId === OPENROUTER_FREE_MODEL)) {
|
|
94
|
+
mapped.unshift(createModelDescriptor({
|
|
95
|
+
provider: BACKEND_IDS.OPENROUTER,
|
|
96
|
+
modelId: OPENROUTER_FREE_MODEL,
|
|
97
|
+
local: false,
|
|
98
|
+
costClass: COST_CLASSES.FREE,
|
|
99
|
+
privacyClass: PRIVACY_CLASSES.CLOUD,
|
|
100
|
+
opaque: true
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return mapped;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async capabilities() {
|
|
108
|
+
const detection = await this.detect();
|
|
109
|
+
return {
|
|
110
|
+
id: BACKEND_IDS.OPENROUTER,
|
|
111
|
+
local: false,
|
|
112
|
+
cloud: true,
|
|
113
|
+
requiresApiKey: true,
|
|
114
|
+
requiresConsent: true,
|
|
115
|
+
streaming: true,
|
|
116
|
+
tools: true,
|
|
117
|
+
freeRouterModel: OPENROUTER_FREE_MODEL,
|
|
118
|
+
state: detection.state,
|
|
119
|
+
hasApiKey: Boolean(resolvedKey)
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async invoke(contextPack, request = {}) {
|
|
124
|
+
if (!resolvedKey) {
|
|
125
|
+
return invokeError("OPENROUTER_API_KEY is not set. Kairo never stores credentials.");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const modelId = request.modelId ?? OPENROUTER_FREE_MODEL;
|
|
129
|
+
const messages = buildChatMessages(contextPack, request);
|
|
130
|
+
|
|
131
|
+
const result = await fetchJson(`${OPENROUTER_BASE}/chat/completions`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: {
|
|
134
|
+
Authorization: `Bearer ${resolvedKey}`,
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
"HTTP-Referer": "https://github.com/Kal-elSam/harness",
|
|
137
|
+
"X-OpenRouter-Title": "Kairo Runtime"
|
|
138
|
+
},
|
|
139
|
+
body: {
|
|
140
|
+
model: modelId,
|
|
141
|
+
messages,
|
|
142
|
+
stream: false
|
|
143
|
+
},
|
|
144
|
+
timeoutMs: request.timeoutMs ?? 120000,
|
|
145
|
+
fetchImpl
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!result.ok) {
|
|
149
|
+
return invokeError(result.error ?? "OpenRouter chat failed.");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const content = result.data?.choices?.[0]?.message?.content ?? "";
|
|
153
|
+
const usedModel = result.data?.model ?? modelId;
|
|
154
|
+
const usage = result.data?.usage ?? {};
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
backendId: BACKEND_IDS.OPENROUTER,
|
|
159
|
+
model: usedModel,
|
|
160
|
+
content,
|
|
161
|
+
usage: {
|
|
162
|
+
inputTokens: usage.prompt_tokens ?? null,
|
|
163
|
+
outputTokens: usage.completion_tokens ?? null,
|
|
164
|
+
cachedTokens: usage.prompt_tokens_details?.cached_tokens ?? null,
|
|
165
|
+
estimatedCost: null,
|
|
166
|
+
model: usedModel,
|
|
167
|
+
backendId: BACKEND_IDS.OPENROUTER,
|
|
168
|
+
fallbackUsed: usedModel !== modelId
|
|
169
|
+
},
|
|
170
|
+
raw: result.data
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildChatMessages(contextPack, request) {
|
|
177
|
+
const messages = [];
|
|
178
|
+
if (contextPack?.systemPrompt) {
|
|
179
|
+
messages.push({ role: "system", content: contextPack.systemPrompt });
|
|
180
|
+
}
|
|
181
|
+
if (Array.isArray(request.messages) && request.messages.length > 0) {
|
|
182
|
+
messages.push(...request.messages);
|
|
183
|
+
} else if (request.prompt) {
|
|
184
|
+
messages.push({ role: "user", content: request.prompt });
|
|
185
|
+
}
|
|
186
|
+
return messages;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function invokeError(message) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
backendId: BACKEND_IDS.OPENROUTER,
|
|
193
|
+
model: null,
|
|
194
|
+
content: null,
|
|
195
|
+
error: message,
|
|
196
|
+
usage: null
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readdir, readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import { join, relative, resolve } from "node:path";
|
|
4
|
+
import { detectProject } from "../../project-detection.js";
|
|
5
|
+
|
|
6
|
+
const PRIVATE_PATH_PATTERNS = [
|
|
7
|
+
/^\.env(\.|$)/i,
|
|
8
|
+
/(^|\/)\.env(\.|$)/i,
|
|
9
|
+
/(^|\/)secrets?\//i,
|
|
10
|
+
/(^|\/)credentials?\./i,
|
|
11
|
+
/\.pem$/i,
|
|
12
|
+
/\.key$/i,
|
|
13
|
+
/(^|\/)id_rsa/i,
|
|
14
|
+
/(^|\/)\.npmrc$/i,
|
|
15
|
+
/(^|\/)\.netrc$/i
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const STABLE_DOC_CANDIDATES = [
|
|
19
|
+
"AGENTS.md",
|
|
20
|
+
"docs/ai/harness.md",
|
|
21
|
+
"docs/ai/architecture.md",
|
|
22
|
+
"docs/ai/conventions.md",
|
|
23
|
+
"docs/ai/testing.md",
|
|
24
|
+
"docs/ai/spec-driven-development.md",
|
|
25
|
+
"docs/ai/test-driven-development.md",
|
|
26
|
+
"docs/ai/provider-routing.md",
|
|
27
|
+
"docs/ai/model-policy.md",
|
|
28
|
+
"docs/ai/context-budget.md"
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const DEFAULT_STABLE_BUDGET = 6000;
|
|
32
|
+
const DEFAULT_REQUEST_BUDGET = 4000;
|
|
33
|
+
|
|
34
|
+
export async function compileContextPack({
|
|
35
|
+
workspaceRoot,
|
|
36
|
+
task = null,
|
|
37
|
+
relevantPaths = [],
|
|
38
|
+
includePrivate = false,
|
|
39
|
+
stableBudgetTokens = DEFAULT_STABLE_BUDGET,
|
|
40
|
+
requestBudgetTokens = DEFAULT_REQUEST_BUDGET
|
|
41
|
+
} = {}) {
|
|
42
|
+
const root = resolve(workspaceRoot ?? process.cwd());
|
|
43
|
+
const workspaceRealRoot = await resolveWorkspaceRoot(root);
|
|
44
|
+
const project = await detectProject(root);
|
|
45
|
+
const evidence = [];
|
|
46
|
+
|
|
47
|
+
const agentsMd = await readEvidenceFile(
|
|
48
|
+
root,
|
|
49
|
+
workspaceRealRoot,
|
|
50
|
+
"AGENTS.md",
|
|
51
|
+
evidence,
|
|
52
|
+
2000,
|
|
53
|
+
{ includePrivate }
|
|
54
|
+
);
|
|
55
|
+
const stableDocs = await collectStableDocs(root, workspaceRealRoot, evidence, stableBudgetTokens, includePrivate);
|
|
56
|
+
const skills = await listSkillIds(root, evidence);
|
|
57
|
+
const graphify = await detectGraphify(root, evidence);
|
|
58
|
+
const engram = await detectEngramHints(root, evidence);
|
|
59
|
+
|
|
60
|
+
const requestFiles = [];
|
|
61
|
+
for (const filePath of relevantPaths) {
|
|
62
|
+
const rel = relative(root, resolve(root, filePath));
|
|
63
|
+
const content = await readEvidenceFile(
|
|
64
|
+
root,
|
|
65
|
+
workspaceRealRoot,
|
|
66
|
+
rel,
|
|
67
|
+
evidence,
|
|
68
|
+
requestBudgetTokens,
|
|
69
|
+
{ includePrivate }
|
|
70
|
+
);
|
|
71
|
+
if (content) {
|
|
72
|
+
requestFiles.push({ path: rel, content: content.text, truncated: content.truncated });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const stable = {
|
|
77
|
+
project: {
|
|
78
|
+
name: project.name,
|
|
79
|
+
purpose: project.purpose,
|
|
80
|
+
stack: project.stack,
|
|
81
|
+
architecturePattern: project.architecturePattern,
|
|
82
|
+
packageManager: project.packageManager,
|
|
83
|
+
commands: project.commands,
|
|
84
|
+
detectedAdapters: project.detectedAdapters
|
|
85
|
+
},
|
|
86
|
+
agentsMd: agentsMd?.text ?? null,
|
|
87
|
+
docs: stableDocs,
|
|
88
|
+
skills,
|
|
89
|
+
sdd: Boolean(stableDocs.find((doc) => doc.path.includes("spec-driven"))),
|
|
90
|
+
tdd: Boolean(stableDocs.find((doc) => doc.path.includes("test-driven"))),
|
|
91
|
+
graphify,
|
|
92
|
+
engram
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const perRequest = {
|
|
96
|
+
task,
|
|
97
|
+
files: requestFiles
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const systemPrompt = buildSystemPrompt(stable, perRequest);
|
|
101
|
+
const estimatedTokens = estimateTokens(systemPrompt);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
version: 1,
|
|
105
|
+
workspaceRoot: root,
|
|
106
|
+
stable,
|
|
107
|
+
perRequest,
|
|
108
|
+
evidence,
|
|
109
|
+
systemPrompt,
|
|
110
|
+
estimatedTokens,
|
|
111
|
+
budgets: {
|
|
112
|
+
stableBudgetTokens,
|
|
113
|
+
requestBudgetTokens
|
|
114
|
+
},
|
|
115
|
+
privacy: {
|
|
116
|
+
includePrivate,
|
|
117
|
+
excludedPrivate: evidence
|
|
118
|
+
.filter((entry) => entry.kind === "excluded_private")
|
|
119
|
+
.map((entry) => entry.path)
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function isPrivatePath(relativePath) {
|
|
125
|
+
const normalized = relativePath.replace(/\\/g, "/");
|
|
126
|
+
return PRIVATE_PATH_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function estimateTokens(text) {
|
|
130
|
+
if (!text) return 0;
|
|
131
|
+
return Math.ceil(text.length / 4);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function collectStableDocs(root, workspaceRealRoot, evidence, budgetTokens, includePrivate) {
|
|
135
|
+
const docs = [];
|
|
136
|
+
let used = 0;
|
|
137
|
+
|
|
138
|
+
for (const candidate of STABLE_DOC_CANDIDATES) {
|
|
139
|
+
if (candidate === "AGENTS.md") continue;
|
|
140
|
+
const remaining = Math.max(500, budgetTokens - used);
|
|
141
|
+
const content = await readEvidenceFile(
|
|
142
|
+
root,
|
|
143
|
+
workspaceRealRoot,
|
|
144
|
+
candidate,
|
|
145
|
+
evidence,
|
|
146
|
+
remaining,
|
|
147
|
+
{ includePrivate }
|
|
148
|
+
);
|
|
149
|
+
if (!content) continue;
|
|
150
|
+
docs.push({ path: candidate, content: content.text, truncated: content.truncated });
|
|
151
|
+
used += estimateTokens(content.text);
|
|
152
|
+
if (used >= budgetTokens) break;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return docs;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function listSkillIds(root, evidence) {
|
|
159
|
+
const skillRoots = [
|
|
160
|
+
join(root, "docs", "skills"),
|
|
161
|
+
join(root, ".cursor", "skills"),
|
|
162
|
+
join(root, ".codex", "skills"),
|
|
163
|
+
join(root, ".claude", "skills")
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const ids = new Set();
|
|
167
|
+
for (const skillRoot of skillRoots) {
|
|
168
|
+
if (!existsSync(skillRoot)) continue;
|
|
169
|
+
evidence.push({ kind: "skills_root", path: relative(root, skillRoot) });
|
|
170
|
+
try {
|
|
171
|
+
const entries = await readdir(skillRoot, { withFileTypes: true });
|
|
172
|
+
for (const entry of entries) {
|
|
173
|
+
if (entry.isDirectory()) ids.add(entry.name);
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// ignore unreadable skill roots
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return [...ids].sort();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function detectGraphify(root, evidence) {
|
|
184
|
+
const reportPath = join(root, "graphify-out", "GRAPH_REPORT.md");
|
|
185
|
+
const graphPath = join(root, "graphify-out", "graph.json");
|
|
186
|
+
const present = existsSync(reportPath) || existsSync(graphPath);
|
|
187
|
+
if (present) {
|
|
188
|
+
evidence.push({ kind: "graphify", path: "graphify-out" });
|
|
189
|
+
}
|
|
190
|
+
return { present, report: existsSync(reportPath), graph: existsSync(graphPath) };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function detectEngramHints(root, evidence) {
|
|
194
|
+
const memoryDoc = join(root, "docs", "ai", "memory.md");
|
|
195
|
+
const present = existsSync(memoryDoc);
|
|
196
|
+
if (present) {
|
|
197
|
+
evidence.push({ kind: "engram_doc", path: "docs/ai/memory.md" });
|
|
198
|
+
}
|
|
199
|
+
return { documented: present };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function readEvidenceFile(
|
|
203
|
+
root,
|
|
204
|
+
workspaceRealRoot,
|
|
205
|
+
relativePath,
|
|
206
|
+
evidence,
|
|
207
|
+
maxTokens = 2000,
|
|
208
|
+
{ includePrivate = false } = {}
|
|
209
|
+
) {
|
|
210
|
+
const requested = resolve(root, relativePath);
|
|
211
|
+
const requestedRelative = relative(root, requested) || ".";
|
|
212
|
+
if (!isPathInside(root, requested)) {
|
|
213
|
+
evidence.push({
|
|
214
|
+
kind: "rejected_outside_workspace",
|
|
215
|
+
path: requestedRelative,
|
|
216
|
+
reason: "Path is outside workspaceRoot"
|
|
217
|
+
});
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!existsSync(requested)) return null;
|
|
222
|
+
|
|
223
|
+
let target;
|
|
224
|
+
try {
|
|
225
|
+
target = await realpath(requested);
|
|
226
|
+
} catch {
|
|
227
|
+
evidence.push({ kind: "unreadable", path: requestedRelative });
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!isPathInside(workspaceRealRoot, target)) {
|
|
232
|
+
evidence.push({
|
|
233
|
+
kind: "rejected_outside_workspace",
|
|
234
|
+
path: requestedRelative,
|
|
235
|
+
reason: "Symlink target is outside workspaceRoot"
|
|
236
|
+
});
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const targetRelative = relative(workspaceRealRoot, target) || ".";
|
|
241
|
+
if ((isPrivatePath(requestedRelative) || isPrivatePath(targetRelative)) && !includePrivate) {
|
|
242
|
+
evidence.push({
|
|
243
|
+
kind: "excluded_private",
|
|
244
|
+
path: requestedRelative,
|
|
245
|
+
reason: "Private path excluded without consent"
|
|
246
|
+
});
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const raw = await readFile(target, "utf8");
|
|
252
|
+
const maxChars = maxTokens * 4;
|
|
253
|
+
const truncated = raw.length > maxChars;
|
|
254
|
+
const text = truncated ? `${raw.slice(0, maxChars)}\n…[truncated]` : raw;
|
|
255
|
+
evidence.push({
|
|
256
|
+
kind: "file",
|
|
257
|
+
path: requestedRelative,
|
|
258
|
+
truncated,
|
|
259
|
+
chars: text.length
|
|
260
|
+
});
|
|
261
|
+
return { text, truncated };
|
|
262
|
+
} catch {
|
|
263
|
+
evidence.push({ kind: "unreadable", path: requestedRelative });
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function resolveWorkspaceRoot(root) {
|
|
269
|
+
try {
|
|
270
|
+
return await realpath(root);
|
|
271
|
+
} catch {
|
|
272
|
+
return root;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function isPathInside(root, candidate) {
|
|
277
|
+
const rel = relative(root, candidate);
|
|
278
|
+
return rel === "" || (rel !== ".." && !rel.startsWith(`..${requirePathSeparator()}`));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function requirePathSeparator() {
|
|
282
|
+
return process.platform === "win32" ? "\\" : "/";
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function buildSystemPrompt(stable, perRequest) {
|
|
286
|
+
const lines = [
|
|
287
|
+
"You are assisting inside Kairo Runtime harness governance.",
|
|
288
|
+
"Prefer architecture before implementation, existing patterns, minimal diffs, tests/specs.",
|
|
289
|
+
"Do not invent speculative code, unnecessary dependencies, or duplicate abstractions.",
|
|
290
|
+
"Never request or echo secrets. Private files require explicit human consent.",
|
|
291
|
+
"",
|
|
292
|
+
"## Project",
|
|
293
|
+
`Name: ${stable.project.name}`,
|
|
294
|
+
`Purpose: ${stable.project.purpose}`,
|
|
295
|
+
`Stack: ${stable.project.stack}`,
|
|
296
|
+
`Architecture: ${stable.project.architecturePattern}`,
|
|
297
|
+
`Package manager: ${stable.project.packageManager}`,
|
|
298
|
+
`Commands: ${JSON.stringify(stable.project.commands)}`,
|
|
299
|
+
`Adapters: ${(stable.project.detectedAdapters ?? []).join(", ") || "none"}`,
|
|
300
|
+
""
|
|
301
|
+
];
|
|
302
|
+
|
|
303
|
+
if (stable.agentsMd) {
|
|
304
|
+
lines.push("## AGENTS.md", stable.agentsMd, "");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (stable.docs.length > 0) {
|
|
308
|
+
lines.push("## Stable docs");
|
|
309
|
+
for (const doc of stable.docs) {
|
|
310
|
+
lines.push(`### ${doc.path}`, doc.content, "");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (stable.skills.length > 0) {
|
|
315
|
+
lines.push(`## Skills available: ${stable.skills.join(", ")}`, "");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
lines.push(
|
|
319
|
+
`## SDD: ${stable.sdd ? "documented" : "not detected"}`,
|
|
320
|
+
`## TDD: ${stable.tdd ? "documented" : "not detected"}`,
|
|
321
|
+
`## Graphify: ${stable.graphify.present ? "present" : "absent"}`,
|
|
322
|
+
`## Engram docs: ${stable.engram.documented ? "present" : "absent"}`,
|
|
323
|
+
""
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
if (perRequest.task) {
|
|
327
|
+
lines.push("## Task", perRequest.task, "");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (perRequest.files.length > 0) {
|
|
331
|
+
lines.push("## Relevant files");
|
|
332
|
+
for (const file of perRequest.files) {
|
|
333
|
+
lines.push(`### ${file.path}`, file.content, "");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return lines.join("\n");
|
|
338
|
+
}
|