kronk-cli 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.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "kronk-cli",
3
+ "version": "0.1.0",
4
+ "description": "A Claude-Code-style terminal agent for local models served by Kronk.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "kronk-cli": "./src/index.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=20"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md",
16
+ "LICENSE",
17
+ "NOTICE"
18
+ ],
19
+ "keywords": [
20
+ "kronk",
21
+ "llm",
22
+ "local",
23
+ "agent",
24
+ "cli",
25
+ "qwen",
26
+ "llama.cpp",
27
+ "offline"
28
+ ],
29
+ "scripts": {
30
+ "start": "node src/index.js",
31
+ "test": "node --test test/*.test.js",
32
+ "lint": "eslint .",
33
+ "check": "npm run lint && npm test"
34
+ },
35
+ "devDependencies": {
36
+ "@eslint/js": "^10.0.1",
37
+ "eslint": "^10.8.1"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/BardiaN/kronk-cli.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/BardiaN/kronk-cli/issues"
45
+ },
46
+ "homepage": "https://github.com/BardiaN/kronk-cli#readme",
47
+ "author": "Bardia Navvabian"
48
+ }
package/src/agent.js ADDED
@@ -0,0 +1,190 @@
1
+ import { streamChat } from './client.js';
2
+ import { TOOLS, NEEDS_APPROVAL, runTool, describe, preview, mcpNeedsApproval } from './tools.js';
3
+ import { config } from './config.js';
4
+ import { c, fmtUsage, spinner, liveLine } from './ui.js';
5
+ import { compact, isOverflow, report } from './compact.js';
6
+ import { maybeDistill } from './distill.js';
7
+
8
+ export const SYSTEM = `You are kronk-cli, a terse coding assistant running fully offline on the user's machine.
9
+
10
+ Rules:
11
+ - Inspect before you answer. Use read_file / list_dir / search rather than guessing at code.
12
+ - Prefer one decisive action over narrating options.
13
+ - Keep prose short. Code blocks should be complete and runnable.
14
+ - The working directory is the user's project root. Paths are relative to it.`;
15
+
16
+ export const SYSTEM_AUTO = `${SYSTEM}
17
+
18
+ You are running autonomously on a whole task. Finish it before you stop.
19
+ - Do not ask the user questions. Make a reasonable choice and proceed.
20
+ - After you write code, RUN it with bash and fix whatever breaks.
21
+ - Never claim something works unless you have executed it and seen the output.
22
+ - Work in small steps: one file or one command per tool call.
23
+ - When the task is genuinely done, reply with a short summary of what you changed.`;
24
+
25
+ /**
26
+ * Run one user turn to completion, looping while the model requests tools.
27
+ * `approve(name, args)` returns a boolean; used for mutating tools.
28
+ */
29
+ /** Compact in place, preserving the caller's array identity. */
30
+ async function compactInto(messages, model, signal) {
31
+ const res = await compact(messages, { model, signal });
32
+ if (res.failed || res.skipped) { console.log(report(res)); return false; }
33
+ messages.splice(0, messages.length, ...res.messages);
34
+ console.log(report(res));
35
+ return true;
36
+ }
37
+
38
+ export async function runTurn({ messages, model, signal, approve, mcp, maxSteps = config.maxSteps }) {
39
+ const tools = mcp ? [...TOOLS, ...mcp.toolDefs()] : TOOLS;
40
+ let totalUsage = null;
41
+ let step = 0;
42
+ let compacted = false;
43
+
44
+ for (;;) {
45
+ step += 1;
46
+ if (Number.isFinite(maxSteps) && step > maxSteps) {
47
+ console.log(c.yellow(` ⛔ step cap reached (${maxSteps}). Stopping.`));
48
+ console.log(c.grey(' raise it with --steps N, or /steps N in the REPL'));
49
+ messages.push({ role: 'assistant', content: '(stopped: step cap reached)' });
50
+ return messages;
51
+ }
52
+ let sp = spinner('thinking');
53
+ let text = '';
54
+ let calls = [];
55
+ let wroteAnything = false;
56
+ let inReasoning = false;
57
+
58
+ try {
59
+ for await (const ev of streamChat({
60
+ model, messages, tools, signal, maxTokens: config.maxTokens, noThink: config.noThink,
61
+ })) {
62
+ if (ev.type === 'reasoning') {
63
+ if (!config.showThinking) continue;
64
+ if (sp) { sp.stop(); sp = null; }
65
+ if (!inReasoning) { process.stdout.write(c.grey('\n ┄ thinking ┄\n ')); inReasoning = true; }
66
+ process.stdout.write(c.grey(ev.value.replace(/\n/g, '\n ')));
67
+ wroteAnything = true;
68
+ }
69
+
70
+ else if (ev.type === 'text') {
71
+ if (sp) { sp.stop(); sp = null; }
72
+ if (inReasoning) { process.stdout.write(c.grey('\n ┄─────────┄\n\n')); inReasoning = false; }
73
+ text += ev.value;
74
+ process.stdout.write(ev.value);
75
+ wroteAnything = true;
76
+ }
77
+
78
+ else if (ev.type === 'usage') {
79
+ totalUsage = ev.value;
80
+ }
81
+
82
+ else if (ev.type === 'done') {
83
+ calls = ev.calls;
84
+ }
85
+ }
86
+ } catch (e) {
87
+ if (sp) { sp.stop(); sp = null; }
88
+ if (isOverflow(e) && !compacted) {
89
+ compacted = true;
90
+ console.log(c.yellow('\n context full — compacting and retrying'));
91
+ if (await compactInto(messages, model, signal)) { step -= 1; continue; }
92
+ }
93
+ throw e;
94
+ } finally {
95
+ if (sp) sp.stop();
96
+ }
97
+
98
+ if (inReasoning) process.stdout.write(c.grey('\n ┄─────────┄\n'));
99
+ if (wroteAnything) process.stdout.write('\n');
100
+ if (totalUsage) {
101
+ console.log(fmtUsage(totalUsage, config.contextWindow));
102
+ config.lastUsed = (totalUsage.prompt_tokens ?? 0) + (totalUsage.completion_tokens ?? 0);
103
+ }
104
+
105
+ if (config.autoCompact && config.contextWindow && totalUsage) {
106
+ const used = (totalUsage.prompt_tokens ?? 0) + (totalUsage.completion_tokens ?? 0);
107
+ if (used / config.contextWindow >= config.compactAt) {
108
+ console.log(c.yellow(` context ${Math.round((used / config.contextWindow) * 100)}% full — compacting`));
109
+ if (!await compactInto(messages, model, signal)) config.autoCompact = false;
110
+ }
111
+ }
112
+
113
+ // No tools requested → the turn is finished.
114
+ if (calls.length === 0) {
115
+ if (!text.trim()) {
116
+ // Reasoning models sometimes spend the whole budget thinking and emit no
117
+ // answer. Say so rather than returning silence.
118
+ console.log(c.yellow(' (model produced no answer — raise KRONK_MAX_TOKENS or /thinking off)'));
119
+ messages.push({ role: 'assistant', content: '(no answer produced)' });
120
+ } else {
121
+ messages.push({ role: 'assistant', content: text });
122
+ }
123
+ return messages;
124
+ }
125
+
126
+ messages.push({
127
+ role: 'assistant',
128
+ content: text,
129
+ tool_calls: calls.map((t) => ({
130
+ id: t.id, type: 'function',
131
+ function: { name: t.name, arguments: t.args || '{}' },
132
+ })),
133
+ });
134
+
135
+ for (const call of calls) {
136
+ if (signal?.aborted) {
137
+ messages.push({ role: 'tool', tool_call_id: call.id, content: 'error: interrupted by user' });
138
+ continue;
139
+ }
140
+ let args;
141
+ try { args = JSON.parse(call.args || '{}'); }
142
+ catch {
143
+ messages.push({ role: 'tool', tool_call_id: call.id,
144
+ content: `error: arguments were not valid JSON: ${call.args}` });
145
+ continue;
146
+ }
147
+
148
+ const at = Number.isFinite(maxSteps) ? `${step}/${maxSteps}` : `${step}`;
149
+ const isMcp = Boolean(mcp?.has(call.name));
150
+ const label = isMcp
151
+ ? `${c.magenta('⚙')} ${call.name} ${c.grey(JSON.stringify(args).slice(0, 120))}`
152
+ : `${c.blue(`⚙ ${describe(call.name, args)}`)}`;
153
+ console.log(`${c.grey(` ${at}`)} ${label}`);
154
+
155
+ if (isMcp ? mcpNeedsApproval(call.name) : NEEDS_APPROVAL.has(call.name)) {
156
+ const body = preview(call.name, args);
157
+ if (body) console.log(body.split('\n').map((l) => ` ${l}`).join('\n'));
158
+ const ok = await approve(call.name, args);
159
+ if (!ok) {
160
+ console.log(c.red(' ✗ denied'));
161
+ messages.push({ role: 'tool', tool_call_id: call.id,
162
+ content: 'error: the user denied this action. Ask what to do instead.' });
163
+ continue;
164
+ }
165
+ }
166
+
167
+ const live = liveLine();
168
+ let result;
169
+ try {
170
+ result = isMcp
171
+ ? await mcp.call(call.name, args)
172
+ : await runTool(call.name, args, {
173
+ onProgress: (info) => live.update({ ...info, window: config.contextWindow }),
174
+ });
175
+ } finally {
176
+ live.done();
177
+ }
178
+
179
+ result = await maybeDistill(result, {
180
+ model, signal, command: args.cmd ?? describe(call.name, args),
181
+ });
182
+ const failed = result.startsWith('error:');
183
+ console.log(failed
184
+ ? c.red(` ✗ ${result.split('\n')[0]}`)
185
+ : c.grey(` ✓ ${result.split('\n').length} lines`));
186
+ messages.push({ role: 'tool', tool_call_id: call.id, content: result });
187
+ }
188
+ // loop: the model now sees the tool output
189
+ }
190
+ }
package/src/client.js ADDED
@@ -0,0 +1,115 @@
1
+ import { config, headers } from './config.js';
2
+ import { createSseParser, accumulateToolCalls } from './sse.js';
3
+
4
+ async function req(path, init = {}) {
5
+ const res = await fetch(`${config.baseUrl}${path}`, { headers: headers(), ...init });
6
+ if (!res.ok) {
7
+ const body = await res.text();
8
+ throw new Error(`${res.status} ${path} — ${body.slice(0, 400)}`);
9
+ }
10
+ return res;
11
+ }
12
+
13
+ export async function listModels() {
14
+ const { data } = await (await req('/models')).json();
15
+ return data.map((m) => m.id);
16
+ }
17
+
18
+ /**
19
+ * Effective context window for a model id, plus the model's native maximum.
20
+ * The id contains slashes, so it must be percent-encoded — Kronk's route takes
21
+ * one path segment and 404s on a raw id.
22
+ */
23
+ export async function modelLimits(id) {
24
+ try {
25
+ const d = await (await req(`/kronk/models/${encodeURIComponent(id)}`)).json();
26
+ const configured = d.model_config?.['context-window'] ?? null;
27
+ const nativeKey = Object.keys(d.metadata ?? {}).find((k) => k.endsWith('.context_length'));
28
+ const native = nativeKey ? Number(d.metadata[nativeKey]) : null;
29
+ return { configured, native };
30
+ } catch { return { configured: null, native: null }; }
31
+ }
32
+
33
+ /** Native Kronk model list: size, projector, validation. */
34
+ export async function listModelDetails() {
35
+ try {
36
+ const { data } = await (await req('/kronk/models')).json();
37
+ return data ?? [];
38
+ } catch { return []; }
39
+ }
40
+
41
+ /** Which models are resident in the pool right now, and what they cost. */
42
+ export async function listLoaded() {
43
+ try {
44
+ return await (await req('/kronk/models/ps')).json();
45
+ } catch { return []; }
46
+ }
47
+
48
+ export async function tokenize(model, input) {
49
+ try {
50
+ const r = await (await req('/tokenize', {
51
+ method: 'POST',
52
+ body: JSON.stringify({ model, input }),
53
+ })).json();
54
+ return r.tokens;
55
+ } catch {
56
+ return Math.ceil(input.length / 4);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Stream a chat completion. Yields:
62
+ * {type:'text', value}
63
+ * {type:'reasoning', value}
64
+ * {type:'usage', value}
65
+ * {type:'done', calls, finish}
66
+ */
67
+ export async function* streamChat({ model, messages, tools, signal, maxTokens, noThink }) {
68
+ const res = await req('/chat/completions', {
69
+ method: 'POST',
70
+ signal,
71
+ body: JSON.stringify({
72
+ model,
73
+ messages,
74
+ ...(tools?.length ? { tools, tool_choice: 'auto' } : {}),
75
+ stream: true,
76
+ stream_options: { include_usage: true },
77
+ max_completion_tokens: maxTokens,
78
+ ...(noThink ? { enable_thinking: false } : {}),
79
+ }),
80
+ });
81
+
82
+ const reader = res.body.getReader();
83
+ const dec = new TextDecoder();
84
+ const parser = createSseParser();
85
+ const calls = new Map();
86
+ let finish = null;
87
+
88
+ while (true) {
89
+ const { done, value } = await reader.read();
90
+ if (done) break;
91
+
92
+ for (const payload of parser.push(dec.decode(value, { stream: true }))) {
93
+ if (payload === '[DONE]') continue;
94
+
95
+ let chunk;
96
+ try { chunk = JSON.parse(payload); } catch { continue; }
97
+
98
+ if (chunk.usage) yield { type: 'usage', value: chunk.usage };
99
+
100
+ const choice = chunk.choices?.[0];
101
+ if (!choice) continue;
102
+ if (choice.finish_reason) finish = choice.finish_reason;
103
+
104
+ const d = choice.delta;
105
+ if (!d) continue;
106
+
107
+ if (d.reasoning_content) yield { type: 'reasoning', value: d.reasoning_content };
108
+ if (d.content) yield { type: 'text', value: d.content };
109
+
110
+ accumulateToolCalls(calls, d.tool_calls);
111
+ }
112
+ }
113
+
114
+ yield { type: 'done', calls: [...calls.values()], finish };
115
+ }
package/src/compact.js ADDED
@@ -0,0 +1,108 @@
1
+ import { streamChat, tokenize } from './client.js';
2
+ import { config } from './config.js';
3
+ import { c } from './ui.js';
4
+
5
+ const PROMPT = `Summarize the conversation above so it can be continued in a fresh context.
6
+
7
+ Write it for your own future self, not for a human reader. Include, in this order:
8
+
9
+ 1. What the user is trying to achieve, in their words where possible.
10
+ 2. Decisions already made, and any the user explicitly rejected.
11
+ 3. Files created or modified, with their paths and what each now does.
12
+ 4. Commands that were run and what they showed — especially failures.
13
+ 5. What is still outstanding.
14
+
15
+ Be specific: real paths, real function names, real error text. Omit pleasantries and
16
+ anything already superseded. Facts you drop are lost for good.`;
17
+
18
+ /**
19
+ * Keep a transcript under `budget` tokens by removing the middle.
20
+ *
21
+ * The summarizer runs against the same context window that just overflowed, so
22
+ * feeding it the whole transcript fails the same way. Head and tail are the
23
+ * parts worth keeping: the goal is stated at the start, current state at the
24
+ * end. ~4 chars per token is close enough for a safety margin.
25
+ */
26
+ function fit(text, budgetTokens) {
27
+ const cap = budgetTokens * 4;
28
+ if (text.length <= cap) return { text, elided: 0 };
29
+ const head = Math.floor(cap * 0.35);
30
+ const tail = Math.floor(cap * 0.65);
31
+ const dropped = text.length - head - tail;
32
+ return {
33
+ text: `${text.slice(0, head)}\n\n[…${dropped.toLocaleString()} characters elided…]\n\n${text.slice(-tail)}`,
34
+ elided: dropped,
35
+ };
36
+ }
37
+
38
+ /** Flatten a message list into something the model can read back. */
39
+ function transcript(messages) {
40
+ return messages
41
+ .filter((m) => m.role !== 'system')
42
+ .map((m) => {
43
+ if (m.role === 'tool') return `[tool result]\n${String(m.content).slice(0, 4000)}`;
44
+ const calls = m.tool_calls?.length
45
+ ? `\n[called ${m.tool_calls.map((t) => t.function.name).join(', ')}]`
46
+ : '';
47
+ return `${m.role}: ${m.content ?? ''}${calls}`;
48
+ })
49
+ .join('\n\n');
50
+ }
51
+
52
+ /**
53
+ * Replace the conversation with a summary of itself, keeping the system message.
54
+ *
55
+ * Tool messages are dropped rather than carried over: they are only valid when
56
+ * paired with the assistant tool_calls that produced them, and a partial carry
57
+ * leaves orphaned tool_call_ids that the API rejects.
58
+ */
59
+ export async function compact(messages, { model, signal } = {}) {
60
+ const system = messages[0];
61
+ const raw = transcript(messages);
62
+ if (!raw.trim()) return { messages, before: 0, after: 0 };
63
+
64
+ const before = await tokenize(model, [system.content, raw].join('\n'));
65
+
66
+ // Leave room for the instruction and the summary itself.
67
+ const window = config.contextWindow ?? 32768;
68
+ const { text: body, elided } = fit(raw, Math.floor(window * 0.6));
69
+ if (elided) console.log(c.grey(` transcript too large for one pass — elided ${elided.toLocaleString()} chars from the middle`));
70
+
71
+ let summary = '';
72
+ for await (const ev of streamChat({
73
+ model,
74
+ messages: [
75
+ { role: 'user', content: `${body}\n\n---\n\n${PROMPT}` },
76
+ ],
77
+ signal,
78
+ maxTokens: Math.min(4096, config.maxTokens),
79
+ noThink: true,
80
+ })) {
81
+ if (ev.type === 'text') summary += ev.value;
82
+ }
83
+
84
+ if (!summary.trim()) return { messages, before, after: before, failed: true };
85
+
86
+ const next = [
87
+ system,
88
+ { role: 'user', content: `[context compacted — summary of the work so far]\n\n${summary.trim()}` },
89
+ { role: 'assistant', content: 'Understood. Continuing from there.' },
90
+ ];
91
+ const after = await tokenize(model, next.map((m) => m.content).join('\n'));
92
+
93
+ // A short conversation can summarize to something longer than itself. Keep
94
+ // the original rather than paying tokens to lose detail.
95
+ if (after >= before) return { messages, before, after, skipped: true };
96
+
97
+ return { messages: next, before, after, summary: summary.trim() };
98
+ }
99
+
100
+ /** True when an API error is the context-window rejection. */
101
+ export const isOverflow = (e) =>
102
+ /exceed(s)? context window|context window/i.test(e?.message ?? '');
103
+
104
+ export function report({ before, after, skipped }) {
105
+ if (skipped) return c.grey(` already compact (${before.toLocaleString()} tokens) — left as is`);
106
+ const pct = Math.round((1 - after / before) * 100);
107
+ return c.grey(` compacted ${before.toLocaleString()} → ${after.toLocaleString()} tokens (−${pct}%)`);
108
+ }
package/src/config.js ADDED
@@ -0,0 +1,41 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ const RC = join(homedir(), '.kronk-cli.json');
6
+
7
+ function fileConfig() {
8
+ try { return JSON.parse(readFileSync(RC, 'utf8')); } catch { return {}; }
9
+ }
10
+
11
+ const file = fileConfig();
12
+
13
+ /** Used when nothing is passed on the command line or in the environment. */
14
+ export const DEFAULT_MODEL = 'unsloth/Qwen3.6-35B-A3B-UD-Q4_K_M/AGENT';
15
+
16
+ export const config = {
17
+ baseUrl: process.env.KRONK_URL ?? file.baseUrl ?? 'http://localhost:11435/v1',
18
+ token: process.env.KRONK_TOKEN ?? file.token ?? 'kronk',
19
+ model: process.env.KRONK_MODEL ?? file.model ?? null, // null → DEFAULT_MODEL, then auto-pick
20
+ maxTokens: Number(process.env.KRONK_MAX_TOKENS ?? file.maxTokens ?? 8192),
21
+ // Unlimited by default — a run stops when the model is done or you press Ctrl-C.
22
+ // Set --steps / KRONK_MAX_STEPS to opt into a cap.
23
+ maxSteps: Number(process.env.KRONK_MAX_STEPS ?? file.maxSteps ?? Infinity),
24
+ showThinking: (process.env.KRONK_THINKING ?? String(file.showThinking ?? 'true')) !== 'false',
25
+ noThink: (process.env.KRONK_NO_THINK ?? String(file.noThink ?? '')) === '1',
26
+ autoCompact: (process.env.KRONK_AUTO_COMPACT ?? String(file.autoCompact ?? 'true')) !== 'false',
27
+ compactAt: Number(process.env.KRONK_COMPACT_AT ?? file.compactAt ?? 0.85),
28
+ // Large tool output is summarized in a throwaway context so the raw text
29
+ // never enters the conversation. Set KRONK_DISTILL=false to keep it whole.
30
+ distill: (process.env.KRONK_DISTILL ?? String(file.distill ?? 'true')) !== 'false',
31
+ distillAt: Number(process.env.KRONK_DISTILL_AT ?? file.distillAt ?? 8000),
32
+ lastUsed: 0,
33
+ contextWindow: null, // filled in at boot from Kronk
34
+ nativeContext: null,
35
+ rcPath: RC,
36
+ };
37
+
38
+ export const headers = () => ({
39
+ 'Content-Type': 'application/json',
40
+ Authorization: `Bearer ${config.token}`,
41
+ });
package/src/context.js ADDED
@@ -0,0 +1,95 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
4
+ import { join } from 'node:path';
5
+
6
+ const exec = promisify(execFile);
7
+
8
+ /** Files that, by convention, tell an agent how to work in this repo. */
9
+ const AGENT_FILES = ['AGENTS.md', 'CLAUDE.md', 'KRONK.md', '.cursorrules', 'CONVENTIONS.md'];
10
+ const AGENT_FILE_CAP = 6000;
11
+ const LISTING_CAP = 60;
12
+
13
+ const SKIP = new Set([
14
+ 'node_modules', '.git', 'dist', 'build', 'out', 'target', 'vendor',
15
+ '.next', '.venv', '__pycache__', '.DS_Store', 'coverage', '.turbo',
16
+ ]);
17
+
18
+ async function sh(cmd, args, cwd) {
19
+ try {
20
+ const { stdout } = await exec(cmd, args, { cwd, timeout: 5000 });
21
+ return stdout.trim();
22
+ } catch { return ''; }
23
+ }
24
+
25
+ async function gitContext(cwd) {
26
+ const inside = await sh('git', ['rev-parse', '--is-inside-work-tree'], cwd);
27
+ if (inside !== 'true') return null;
28
+
29
+ const [branch, status, recent] = await Promise.all([
30
+ sh('git', ['rev-parse', '--abbrev-ref', 'HEAD'], cwd),
31
+ sh('git', ['status', '--porcelain'], cwd),
32
+ sh('git', ['log', '-5', '--oneline', '--no-decorate'], cwd),
33
+ ]);
34
+
35
+ const changed = status ? status.split('\n').filter(Boolean) : [];
36
+ return {
37
+ branch,
38
+ dirty: changed.length,
39
+ changed: changed.slice(0, 20),
40
+ recent: recent ? recent.split('\n') : [],
41
+ };
42
+ }
43
+
44
+ async function listing(cwd) {
45
+ let names;
46
+ try { names = await readdir(cwd); } catch { return []; }
47
+ const kept = names.filter((n) => !SKIP.has(n) && !n.startsWith('.git'));
48
+ const rows = await Promise.all(kept.slice(0, LISTING_CAP).map(async (n) => {
49
+ try { return (await stat(join(cwd, n))).isDirectory() ? `${n}/` : n; }
50
+ catch { return n; }
51
+ }));
52
+ rows.sort();
53
+ if (kept.length > LISTING_CAP) rows.push(`…and ${kept.length - LISTING_CAP} more`);
54
+ return rows;
55
+ }
56
+
57
+ async function agentFile(cwd) {
58
+ for (const name of AGENT_FILES) {
59
+ try {
60
+ const body = await readFile(join(cwd, name), 'utf8');
61
+ if (!body.trim()) continue;
62
+ const clipped = body.length > AGENT_FILE_CAP
63
+ ? `${body.slice(0, AGENT_FILE_CAP)}\n…[truncated]`
64
+ : body;
65
+ return { name, body: clipped };
66
+ } catch { /* next */ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * A short primer describing where the agent is standing, gathered once at
73
+ * startup. Saves the model a round trip and stops it guessing about layout.
74
+ */
75
+ export async function projectContext(cwd = process.cwd()) {
76
+ const [files, git, agents] = await Promise.all([listing(cwd), gitContext(cwd), agentFile(cwd)]);
77
+
78
+ const parts = [`Working directory: ${cwd}`, `Platform: ${process.platform}`];
79
+
80
+ if (git) {
81
+ parts.push(`Git: branch ${git.branch}, ${git.dirty} uncommitted change(s)`);
82
+ if (git.changed.length) parts.push(`Modified:\n${git.changed.map((l) => ` ${l}`).join('\n')}`);
83
+ if (git.recent.length) parts.push(`Recent commits:\n${git.recent.map((l) => ` ${l}`).join('\n')}`);
84
+ } else {
85
+ parts.push('Git: not a repository');
86
+ }
87
+
88
+ if (files.length) parts.push(`Top level:\n${files.map((f) => ` ${f}`).join('\n')}`);
89
+
90
+ if (agents) {
91
+ parts.push(`Project instructions from ${agents.name} — follow these:\n\n${agents.body}`);
92
+ }
93
+
94
+ return { text: parts.join('\n\n'), agentFile: agents?.name ?? null, isGit: Boolean(git) };
95
+ }