codehere 0.4.1 → 0.5.0-alpha.3
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 +64 -7
- package/dist/application/use-cases/generate-final-report.js +144 -0
- package/dist/index.js +47 -4
- package/dist/infrastructure/hooks/claude-sdk-hook.js +18 -3
- package/dist/infrastructure/orchestration/aider-adapter.js +12 -2
- package/dist/infrastructure/orchestration/claude-code-adapter.js +70 -1
- package/dist/infrastructure/orchestration/codex-adapter.js +6 -1
- package/dist/infrastructure/orchestration/conductor-adapter.js +12 -2
- package/dist/infrastructure/orchestration/gemini-adapter.js +41 -5
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +10 -2
- package/dist/infrastructure/orchestration/memory-injector.js +337 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +34 -4
- package/dist/infrastructure/orchestration/progress-aggregator.js +119 -0
- package/dist/infrastructure/orchestration/readiness.js +2 -5
- package/dist/infrastructure/orchestration/session-manager.js +65 -2
- package/dist/infrastructure/orchestration/session-persistence.js +13 -1
- package/dist/infrastructure/protocols/mcp-server-impl.js +86 -14
- package/dist/infrastructure/protocols/ui-page.js +665 -67
- package/dist/presentation/cli/commands/init-command.js +345 -24
- package/dist/presentation/cli/commands/memory-command.js +1017 -0
- package/dist/presentation/cli/commands/protocols-builder.js +254 -0
- package/dist/presentation/cli/commands/protocols-command.js +84 -0
- package/dist/presentation/cli/commands/repo-map-builder.js +247 -0
- package/package.json +4 -3
|
@@ -31,7 +31,11 @@
|
|
|
31
31
|
import { spawn, execFileSync } from 'child_process';
|
|
32
32
|
import { EventEmitter } from 'events';
|
|
33
33
|
import { randomUUID } from 'crypto';
|
|
34
|
+
import { existsSync } from 'fs';
|
|
35
|
+
import { homedir } from 'os';
|
|
36
|
+
import { join } from 'path';
|
|
34
37
|
import { ProgressAggregator } from './progress-aggregator.js';
|
|
38
|
+
import { applyMemoryPrefix } from './memory-injector.js';
|
|
35
39
|
import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
|
|
36
40
|
// `--approval-mode=yolo` auto-accepts every tool action without prompting (the
|
|
37
41
|
// older `--yolo` flag is deprecated). codehere's safety hook is the external
|
|
@@ -91,6 +95,9 @@ export function spawnGemini(prompt, options = {}) {
|
|
|
91
95
|
let statusValue = 'running';
|
|
92
96
|
let killRequested = false;
|
|
93
97
|
const cwd = options.cwd ?? process.cwd();
|
|
98
|
+
// F3 Task 8 — memory injection (same shape as every other adapter).
|
|
99
|
+
const injected = applyMemoryPrefix(prompt, cwd, options);
|
|
100
|
+
const finalPrompt = injected.prompt;
|
|
94
101
|
const snapshot = snapshotCwd(cwd);
|
|
95
102
|
const aggregator = new ProgressAggregator('gemini');
|
|
96
103
|
let stepsSnapshot = [];
|
|
@@ -187,7 +194,7 @@ export function spawnGemini(prompt, options = {}) {
|
|
|
187
194
|
};
|
|
188
195
|
let child;
|
|
189
196
|
try {
|
|
190
|
-
child = spawn('gemini', ['-p',
|
|
197
|
+
child = spawn('gemini', ['-p', finalPrompt, ...HEADLESS_FLAGS, ...(options.extraArgs ?? [])], {
|
|
191
198
|
cwd,
|
|
192
199
|
env: spawnEnv,
|
|
193
200
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -301,6 +308,7 @@ export function spawnGemini(prompt, options = {}) {
|
|
|
301
308
|
startedAt,
|
|
302
309
|
prompt,
|
|
303
310
|
agent: 'gemini',
|
|
311
|
+
memoryInjection: injected.record,
|
|
304
312
|
get status() {
|
|
305
313
|
return statusValue;
|
|
306
314
|
},
|
|
@@ -379,10 +387,38 @@ export const GeminiAdapter = {
|
|
|
379
387
|
},
|
|
380
388
|
};
|
|
381
389
|
}
|
|
382
|
-
// Gemini supports
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
390
|
+
// Gemini supports several auth paths:
|
|
391
|
+
// env: GEMINI_API_KEY (free tier), GOOGLE_API_KEY, GOOGLE_GENAI_USE_VERTEXAI,
|
|
392
|
+
// GOOGLE_GENAI_USE_GCA, GOOGLE_APPLICATION_CREDENTIALS
|
|
393
|
+
// file: ~/.gemini/settings.json (the file gemini's own error msg points at)
|
|
394
|
+
// file: ~/.gemini/oauth_creds.json (cached Google login)
|
|
395
|
+
//
|
|
396
|
+
// Pre-May-1 we returned 'ready' on install alone, deferring to runtime to
|
|
397
|
+
// catch missing-auth via ERROR_PATTERNS.auth_missing. That was dishonest:
|
|
398
|
+
// /api/readiness/agents would say 'ready' → user would spawn → 41s later
|
|
399
|
+
// the task errored with no clear next step. Surfaced May 1 2026 by the
|
|
400
|
+
// multi-agent dogfood test (see SECURITY_AUDIT_2026-05-01.md spirit —
|
|
401
|
+
// honest readiness signals are a UX security property).
|
|
402
|
+
const hasEnvAuth = !!process.env.GEMINI_API_KEY ||
|
|
403
|
+
!!process.env.GOOGLE_API_KEY ||
|
|
404
|
+
!!process.env.GOOGLE_GENAI_USE_VERTEXAI ||
|
|
405
|
+
!!process.env.GOOGLE_GENAI_USE_GCA ||
|
|
406
|
+
!!process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
|
407
|
+
const hasFileAuth = existsSync(join(homedir(), '.gemini', 'settings.json')) ||
|
|
408
|
+
existsSync(join(homedir(), '.gemini', 'oauth_creds.json'));
|
|
409
|
+
if (!hasEnvAuth && !hasFileAuth) {
|
|
410
|
+
return {
|
|
411
|
+
id: this.id,
|
|
412
|
+
label: this.label,
|
|
413
|
+
state: 'needs_auth',
|
|
414
|
+
installed: true,
|
|
415
|
+
authHint: {
|
|
416
|
+
command: 'export GEMINI_API_KEY=... # free tier: https://aistudio.google.com/apikey',
|
|
417
|
+
url: 'https://google-gemini.github.io/gemini-cli/docs/get-started/authentication.html',
|
|
418
|
+
detail: 'Gemini found on PATH but no auth detected (no GEMINI_API_KEY env var, no ~/.gemini/settings.json, no ~/.gemini/oauth_creds.json). Free tier available at aistudio.google.com/apikey.',
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
386
422
|
return { id: this.id, label: this.label, state: 'ready', installed: true };
|
|
387
423
|
},
|
|
388
424
|
};
|
|
@@ -37,6 +37,7 @@ import { join } from 'path';
|
|
|
37
37
|
import { randomUUID } from 'crypto';
|
|
38
38
|
import { ProgressAggregator } from './progress-aggregator.js';
|
|
39
39
|
import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
|
|
40
|
+
import { applyMemoryPrefix } from './memory-injector.js';
|
|
40
41
|
const CONFIG_PATH = join(homedir(), '.codehere', 'agents.yaml');
|
|
41
42
|
const CONFIG_PATH_JSON = join(homedir(), '.codehere', 'agents.json');
|
|
42
43
|
/** Minimal YAML parser for the narrow shape above. Intentionally NOT a full YAML
|
|
@@ -134,6 +135,12 @@ function spawnGeneric(cfg, prompt, options = {}) {
|
|
|
134
135
|
let statusValue = 'running';
|
|
135
136
|
let killRequested = false;
|
|
136
137
|
const cwd = options.cwd ?? process.cwd();
|
|
138
|
+
// F3 Task 8 — memory injection (same shape as every other adapter).
|
|
139
|
+
// Apr 29: caught via the adapter-injection-wiring trip-wire — generic
|
|
140
|
+
// adapters were silently exempt from F3 even though Task 8 said
|
|
141
|
+
// "every spawned agent." Now consistent.
|
|
142
|
+
const injected = applyMemoryPrefix(prompt, cwd, options);
|
|
143
|
+
const finalPrompt = injected.prompt;
|
|
137
144
|
const snapshot = snapshotCwd(cwd);
|
|
138
145
|
const aggregator = new ProgressAggregator(cfg.id);
|
|
139
146
|
let stepsSnapshot = [];
|
|
@@ -155,7 +162,7 @@ function spawnGeneric(cfg, prompt, options = {}) {
|
|
|
155
162
|
const spawnEnv = { ...process.env, ...(cfg.env ?? {}), ...(options.env ?? {}) };
|
|
156
163
|
const usesStdin = cfg.prompt_via === 'stdin';
|
|
157
164
|
const fixedArgs = cfg.args ?? [];
|
|
158
|
-
const argv = usesStdin ? [...fixedArgs] : [...fixedArgs,
|
|
165
|
+
const argv = usesStdin ? [...fixedArgs] : [...fixedArgs, finalPrompt];
|
|
159
166
|
let child;
|
|
160
167
|
try {
|
|
161
168
|
// SAFE · array form (not shell:true) means no shell interpretation.
|
|
@@ -172,7 +179,7 @@ function spawnGeneric(cfg, prompt, options = {}) {
|
|
|
172
179
|
stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
173
180
|
});
|
|
174
181
|
if (usesStdin) {
|
|
175
|
-
child.stdin?.write(
|
|
182
|
+
child.stdin?.write(finalPrompt);
|
|
176
183
|
child.stdin?.end();
|
|
177
184
|
}
|
|
178
185
|
}
|
|
@@ -264,6 +271,7 @@ function spawnGeneric(cfg, prompt, options = {}) {
|
|
|
264
271
|
startedAt,
|
|
265
272
|
prompt,
|
|
266
273
|
agent: cfg.id,
|
|
274
|
+
memoryInjection: injected.record,
|
|
267
275
|
get status() {
|
|
268
276
|
return statusValue;
|
|
269
277
|
},
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F3 Task 8 — memory injection into spawned-agent prompts.
|
|
3
|
+
*
|
|
4
|
+
* Builds a prompt prefix from the project's `.codehere/` memory layers
|
|
5
|
+
* (AGENTS.md briefing + working/session.md + semantic/lessons.jsonl + a
|
|
6
|
+
* slice of episodic/repo_map.json topSymbols) so spawned agents start
|
|
7
|
+
* warm — with the project's conventions, in-flight context, accumulated
|
|
8
|
+
* lessons, and the most-referenced symbols already in their context window.
|
|
9
|
+
*
|
|
10
|
+
* Pure, side-effect-free, fully unit-tested. Each adapter calls
|
|
11
|
+
* `buildMemoryPrefix(projectRoot)` near the top of `spawn()`, prepends
|
|
12
|
+
* the resulting prefix to the user's prompt, and exposes the metadata via
|
|
13
|
+
* `TaskSession.memoryInjection`.
|
|
14
|
+
*
|
|
15
|
+
* Token budget is char-count-approximated (4 chars ≈ 1 token, the
|
|
16
|
+
* Anthropic / OpenAI rule-of-thumb). Default cap is 3000 tokens —
|
|
17
|
+
* generous but well under any realistic context window. When budget is
|
|
18
|
+
* exceeded, sections are dropped in priority order:
|
|
19
|
+
*
|
|
20
|
+
* keep AGENTS.md — never dropped if user-edited (the briefing
|
|
21
|
+
* is the primary value)
|
|
22
|
+
* drop 3 repo_map slice — symbols are nice-to-have; the agent can
|
|
23
|
+
* discover them via grep if missing
|
|
24
|
+
* drop 2 lessons — useful but not load-bearing
|
|
25
|
+
* drop 1 session.md — useful for hand-off but redundant if the
|
|
26
|
+
* user re-states their task
|
|
27
|
+
*
|
|
28
|
+
* Default-seed detection skips placeholder content (the init seeds for
|
|
29
|
+
* AGENTS.md / session.md). Same byte-equality-based detection pattern as
|
|
30
|
+
* memory-command.ts (per feedback_implementation_patterns_v05.md Pattern #1).
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, readFileSync, realpathSync } from 'fs';
|
|
33
|
+
import { join, dirname, resolve } from 'path';
|
|
34
|
+
const DEFAULT_MAX_TOKENS = 3000;
|
|
35
|
+
const DEFAULT_MAX_LESSONS = 10;
|
|
36
|
+
const DEFAULT_MAX_SYMBOLS = 30;
|
|
37
|
+
// 3 chars per token — conservative ratio per K2 Round 1 feedback. Code
|
|
38
|
+
// tokenizes denser than prose under typical BPE, so the safer-defaults 4/1
|
|
39
|
+
// rule-of-thumb underestimates by 1.5-2x for code-heavy prefixes (which
|
|
40
|
+
// repo_map + lessons.jsonl frequently are). 3/1 stays well clear of model
|
|
41
|
+
// per-message caps without dropping useful content from typical repos.
|
|
42
|
+
const CHARS_PER_TOKEN = 3;
|
|
43
|
+
/** Char-count → token estimate. Conservative — actual tokenization may differ. */
|
|
44
|
+
function estimateTokens(s) {
|
|
45
|
+
return Math.ceil(s.length / CHARS_PER_TOKEN);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Detect whether the given file content is still the init-time placeholder.
|
|
49
|
+
* If so, the user hasn't customized it — injecting the placeholder would
|
|
50
|
+
* just confuse the agent with template instructions.
|
|
51
|
+
*
|
|
52
|
+
* Heuristic: presence of bracketed-instruction lines like `[One-paragraph
|
|
53
|
+
* description...]` or the `> Per-machine, gitignored` boilerplate, AND no
|
|
54
|
+
* substantive non-template content after the markers. Conservative — when
|
|
55
|
+
* in doubt, INCLUDE rather than exclude (better to over-share than miss
|
|
56
|
+
* the briefing).
|
|
57
|
+
*/
|
|
58
|
+
function looksLikeInitSeed(content, kind) {
|
|
59
|
+
if (kind === 'agents') {
|
|
60
|
+
// The init AGENTS/CONTEXT seed has these distinctive bracketed placeholders.
|
|
61
|
+
// If at least 2 of them are present, treat as untouched.
|
|
62
|
+
const markers = [
|
|
63
|
+
/\[One-paragraph description of what this repo does/,
|
|
64
|
+
/\[How this codebase is organized/,
|
|
65
|
+
/\[Tone for generated code\./,
|
|
66
|
+
/\[Don't do X\. Always do Y\./,
|
|
67
|
+
];
|
|
68
|
+
const hits = markers.filter(re => re.test(content)).length;
|
|
69
|
+
return hits >= 2;
|
|
70
|
+
}
|
|
71
|
+
if (kind === 'session') {
|
|
72
|
+
// The init session.md seed has this header + structure
|
|
73
|
+
const markers = [
|
|
74
|
+
/^# Working session/m,
|
|
75
|
+
/^> Per-machine, gitignored/m,
|
|
76
|
+
/^## In flight\s*\n+- \[ \]\s*$/m,
|
|
77
|
+
];
|
|
78
|
+
const hits = markers.filter(re => re.test(content)).length;
|
|
79
|
+
return hits >= 2;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read the project briefing. Search order (highest-priority first):
|
|
85
|
+
* 1. <projectRoot>/AGENTS.md — agentic-stack convention; if the user
|
|
86
|
+
* has this file at the repo root with real content, it IS their
|
|
87
|
+
* canonical briefing and codehere must respect it (Bug 2 from the
|
|
88
|
+
* Apr 28 EXP-2 dogfood on `~/code/clipa`).
|
|
89
|
+
* 2. <codehereDir>/AGENTS.md — F3-canonical (codehere's own)
|
|
90
|
+
* 3. <codehereDir>/CONTEXT.md — v0.4.1 back-compat
|
|
91
|
+
*
|
|
92
|
+
* Returns the body string + the path that was read (for the prefix
|
|
93
|
+
* header), or null if no usable briefing exists at any tier (every
|
|
94
|
+
* candidate is missing, empty, or looks like the init placeholder).
|
|
95
|
+
*/
|
|
96
|
+
function readBriefing(projectRoot, codehereDir) {
|
|
97
|
+
const candidates = [
|
|
98
|
+
{ path: join(projectRoot, 'AGENTS.md'), label: 'AGENTS.md (repo root)' },
|
|
99
|
+
{ path: join(codehereDir, 'AGENTS.md'), label: '.codehere/AGENTS.md' },
|
|
100
|
+
{ path: join(codehereDir, 'CONTEXT.md'), label: '.codehere/CONTEXT.md' },
|
|
101
|
+
];
|
|
102
|
+
for (const c of candidates) {
|
|
103
|
+
if (existsSync(c.path)) {
|
|
104
|
+
try {
|
|
105
|
+
const body = readFileSync(c.path, 'utf8').trim();
|
|
106
|
+
if (body.length === 0)
|
|
107
|
+
continue;
|
|
108
|
+
if (looksLikeInitSeed(body, 'agents'))
|
|
109
|
+
continue; // placeholder — try next
|
|
110
|
+
return { body, sourcePath: c.label };
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
/** Read working/session.md. Skip if seed-shaped or empty. */
|
|
120
|
+
function readSession(codehereDir) {
|
|
121
|
+
const p = join(codehereDir, 'memory/working/session.md');
|
|
122
|
+
if (!existsSync(p))
|
|
123
|
+
return null;
|
|
124
|
+
try {
|
|
125
|
+
const body = readFileSync(p, 'utf8').trim();
|
|
126
|
+
if (body.length === 0)
|
|
127
|
+
return null;
|
|
128
|
+
if (looksLikeInitSeed(body, 'session'))
|
|
129
|
+
return null;
|
|
130
|
+
return body;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Read up to N lessons from semantic/lessons.jsonl. */
|
|
137
|
+
function readLessons(codehereDir, n) {
|
|
138
|
+
const p = join(codehereDir, 'memory/semantic/lessons.jsonl');
|
|
139
|
+
if (!existsSync(p))
|
|
140
|
+
return [];
|
|
141
|
+
try {
|
|
142
|
+
const body = readFileSync(p, 'utf8');
|
|
143
|
+
return body
|
|
144
|
+
.split('\n')
|
|
145
|
+
.filter(line => line.trim().length > 0)
|
|
146
|
+
.slice(0, n)
|
|
147
|
+
.map(line => {
|
|
148
|
+
try {
|
|
149
|
+
const obj = JSON.parse(line);
|
|
150
|
+
return typeof obj.text === 'string' ? obj.text : line;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return line;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function readTopSymbols(codehereDir, n) {
|
|
162
|
+
const p = join(codehereDir, 'memory/episodic/repo_map.json');
|
|
163
|
+
if (!existsSync(p))
|
|
164
|
+
return [];
|
|
165
|
+
try {
|
|
166
|
+
const body = JSON.parse(readFileSync(p, 'utf8'));
|
|
167
|
+
if (!Array.isArray(body.topSymbols))
|
|
168
|
+
return [];
|
|
169
|
+
return body.topSymbols.slice(0, n);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Format individual sections. Each returns `null` if empty/skipped.
|
|
177
|
+
*/
|
|
178
|
+
function formatBriefing(briefing, sourcePath) {
|
|
179
|
+
return `# Project briefing (${sourcePath})\n\n${briefing}\n`;
|
|
180
|
+
}
|
|
181
|
+
function formatSession(session) {
|
|
182
|
+
return `# Current session scratch (.codehere/memory/working/session.md)\n\n${session}\n`;
|
|
183
|
+
}
|
|
184
|
+
function formatLessons(lessons) {
|
|
185
|
+
const items = lessons.map(l => `- ${l}`).join('\n');
|
|
186
|
+
return `# Project lessons (.codehere/memory/semantic/lessons.jsonl)\n\n${items}\n`;
|
|
187
|
+
}
|
|
188
|
+
function formatSymbols(symbols) {
|
|
189
|
+
const lines = symbols.map(s => `- \`${s.name}\` — ${s.file}:${s.line} (${s.references} refs)`).join('\n');
|
|
190
|
+
return `# Top symbols in this repo (.codehere/memory/episodic/repo_map.json)\n\n${lines}\n`;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Build the memory-injection prefix for the project at `projectRoot`.
|
|
194
|
+
*
|
|
195
|
+
* If `.codehere/` doesn't exist, returns empty (nothing to inject).
|
|
196
|
+
* Otherwise reads the four sections, formats each, fits to the token
|
|
197
|
+
* budget by dropping low-priority sections first.
|
|
198
|
+
*/
|
|
199
|
+
export function buildMemoryPrefix(projectRoot, options = {}) {
|
|
200
|
+
const codehereDir = join(projectRoot, '.codehere');
|
|
201
|
+
const hasCodehereDir = existsSync(codehereDir);
|
|
202
|
+
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
203
|
+
const maxLessons = options.maxLessons ?? DEFAULT_MAX_LESSONS;
|
|
204
|
+
const maxSymbols = options.maxSymbols ?? DEFAULT_MAX_SYMBOLS;
|
|
205
|
+
// Briefing discovery is INDEPENDENT of .codehere/ presence — a pure
|
|
206
|
+
// agentic-stack repo (root AGENTS.md, no .codehere/) is a valid
|
|
207
|
+
// injection source on its own (codex Round 1 fix on Bug 2). Only
|
|
208
|
+
// session/lessons/repo_map require the .codehere/ tree.
|
|
209
|
+
const briefing = readBriefing(projectRoot, codehereDir);
|
|
210
|
+
const session = hasCodehereDir ? readSession(codehereDir) : null;
|
|
211
|
+
const lessons = hasCodehereDir ? readLessons(codehereDir, maxLessons) : [];
|
|
212
|
+
const symbols = hasCodehereDir ? readTopSymbols(codehereDir, maxSymbols) : [];
|
|
213
|
+
// Build candidate sections in PRIORITY order (most-essential first).
|
|
214
|
+
// Drop direction is REVERSE — least-essential drops first.
|
|
215
|
+
const candidates = [];
|
|
216
|
+
if (briefing)
|
|
217
|
+
candidates.push({ key: 'agents', body: formatBriefing(briefing.body, briefing.sourcePath) });
|
|
218
|
+
if (session)
|
|
219
|
+
candidates.push({ key: 'session', body: formatSession(session) });
|
|
220
|
+
if (lessons.length > 0)
|
|
221
|
+
candidates.push({ key: 'lessons', body: formatLessons(lessons) });
|
|
222
|
+
if (symbols.length > 0)
|
|
223
|
+
candidates.push({ key: 'repo_map', body: formatSymbols(symbols) });
|
|
224
|
+
if (candidates.length === 0) {
|
|
225
|
+
return { prefix: '', included: [], tokens: 0, truncated: false };
|
|
226
|
+
}
|
|
227
|
+
// Closing separator, always added at the end.
|
|
228
|
+
const SEPARATOR = '\n---\n\n';
|
|
229
|
+
const separatorTokens = estimateTokens(SEPARATOR);
|
|
230
|
+
// Drop from least-essential (last) until everything fits in budget.
|
|
231
|
+
let truncated = false;
|
|
232
|
+
let kept = [...candidates];
|
|
233
|
+
while (kept.length > 0) {
|
|
234
|
+
const totalChars = kept.reduce((acc, c) => acc + c.body.length, 0);
|
|
235
|
+
const totalTokens = estimateTokens(kept.map(c => c.body).join('\n')) + separatorTokens;
|
|
236
|
+
if (totalTokens <= maxTokens)
|
|
237
|
+
break;
|
|
238
|
+
// Drop the last (least-essential) section.
|
|
239
|
+
kept.pop();
|
|
240
|
+
truncated = true;
|
|
241
|
+
void totalChars;
|
|
242
|
+
}
|
|
243
|
+
// Edge: even AGENTS alone is over budget — truncate AGENTS body itself.
|
|
244
|
+
if (kept.length === 1 && estimateTokens(kept[0].body) + separatorTokens > maxTokens) {
|
|
245
|
+
const room = (maxTokens - separatorTokens) * CHARS_PER_TOKEN;
|
|
246
|
+
const truncatedBody = kept[0].body.slice(0, Math.max(0, room - 32)) + '\n... [truncated to fit]\n';
|
|
247
|
+
kept = [{ key: kept[0].key, body: truncatedBody }];
|
|
248
|
+
truncated = true;
|
|
249
|
+
}
|
|
250
|
+
if (kept.length === 0) {
|
|
251
|
+
return { prefix: '', included: [], tokens: 0, truncated };
|
|
252
|
+
}
|
|
253
|
+
const prefix = kept.map(c => c.body).join('\n') + SEPARATOR;
|
|
254
|
+
return {
|
|
255
|
+
prefix,
|
|
256
|
+
included: kept.map(c => c.key),
|
|
257
|
+
tokens: estimateTokens(prefix),
|
|
258
|
+
truncated,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Walk up from `startPath` looking for the nearest ancestor (inclusive)
|
|
263
|
+
* that codehere can inject memory FROM — meaning EITHER:
|
|
264
|
+
* - has a `.codehere/` directory (codehere-initialized project), OR
|
|
265
|
+
* - has a root-level `AGENTS.md` (agentic-stack convention)
|
|
266
|
+
*
|
|
267
|
+
* Returns the REAL filesystem ancestor path (symlinks resolved), or
|
|
268
|
+
* `null` if no such ancestor exists.
|
|
269
|
+
*
|
|
270
|
+
* codex Round 1 BLOCKER fix: spawning a task from a subdirectory (e.g.
|
|
271
|
+
* `cd src/auth && codehere task ...`) would miss the project root if we
|
|
272
|
+
* only used the immediate cwd. Hence the upward walk.
|
|
273
|
+
*
|
|
274
|
+
* codex Round 2 BLOCKER fix: the upward walk must walk the REAL
|
|
275
|
+
* filesystem ancestry, not the LEXICAL one. Without `realpathSync`,
|
|
276
|
+
* `cwd` inside a symlinked subtree ascends through the lexical path and
|
|
277
|
+
* misses the real project root. Real scenario for iCloud-symlinked /
|
|
278
|
+
* shared-volume / mounted-drive projects.
|
|
279
|
+
*
|
|
280
|
+
* Bug 2 Round 2 BLOCKER fix: also accept a project root that has a
|
|
281
|
+
* root-level `AGENTS.md` even WITHOUT `.codehere/`. A pure agentic-stack
|
|
282
|
+
* repo (clipa-style) is a valid injection source on its own.
|
|
283
|
+
*
|
|
284
|
+
* Stops at the filesystem root. Symlink-following on read is the right
|
|
285
|
+
* asymmetry per ADR-012 (the symlink defense applies to writes, not reads).
|
|
286
|
+
*/
|
|
287
|
+
function findProjectRoot(startPath) {
|
|
288
|
+
let current;
|
|
289
|
+
try {
|
|
290
|
+
current = realpathSync(resolve(startPath));
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Broken symlink at startPath itself — fall back to lexical resolve.
|
|
294
|
+
// (Better to attempt some lookup than fail outright.)
|
|
295
|
+
current = resolve(startPath);
|
|
296
|
+
}
|
|
297
|
+
// Bound the loop at the filesystem root by checking dirname stability.
|
|
298
|
+
for (;;) {
|
|
299
|
+
if (existsSync(join(current, '.codehere')) ||
|
|
300
|
+
existsSync(join(current, 'AGENTS.md'))) {
|
|
301
|
+
return current;
|
|
302
|
+
}
|
|
303
|
+
const parent = dirname(current);
|
|
304
|
+
if (parent === current)
|
|
305
|
+
return null;
|
|
306
|
+
current = parent;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Adapter-side convenience: turn a raw user prompt into the spawn-ready
|
|
311
|
+
* prompt + the diagnostic record. Each of the 5 built-in adapters calls
|
|
312
|
+
* this near the top of `spawn()` and uses the returned `prompt` going
|
|
313
|
+
* forward, plus exposes `record` via `TaskSession.memoryInjection`.
|
|
314
|
+
*
|
|
315
|
+
* Walks UPWARD from `cwd` to find the nearest ancestor with `.codehere/`,
|
|
316
|
+
* so spawning from a subdirectory still gets injection (codex Round 1 fix).
|
|
317
|
+
*
|
|
318
|
+
* Returns `{ prompt: <unchanged>, record: undefined }` when:
|
|
319
|
+
* - `options.noMemoryPrefix` is true (caller opted out)
|
|
320
|
+
* - no ancestor of `cwd` has `.codehere/` (nothing to inject)
|
|
321
|
+
* - all sections are empty / placeholder seeds
|
|
322
|
+
*/
|
|
323
|
+
export function applyMemoryPrefix(prompt, cwd, options) {
|
|
324
|
+
if (options.noMemoryPrefix)
|
|
325
|
+
return { prompt, record: undefined };
|
|
326
|
+
const projectRoot = findProjectRoot(cwd);
|
|
327
|
+
if (projectRoot === null)
|
|
328
|
+
return { prompt, record: undefined };
|
|
329
|
+
const r = buildMemoryPrefix(projectRoot);
|
|
330
|
+
if (r.prefix === '')
|
|
331
|
+
return { prompt, record: undefined };
|
|
332
|
+
return {
|
|
333
|
+
prompt: r.prefix + prompt,
|
|
334
|
+
record: { included: r.included, tokens: r.tokens, truncated: r.truncated },
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
//# sourceMappingURL=memory-injector.js.map
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
import { spawn, execFileSync } from 'child_process';
|
|
14
14
|
import { EventEmitter } from 'events';
|
|
15
15
|
import { randomUUID } from 'crypto';
|
|
16
|
+
import { existsSync } from 'fs';
|
|
17
|
+
import { homedir } from 'os';
|
|
18
|
+
import { join } from 'path';
|
|
16
19
|
import { ProgressAggregator } from './progress-aggregator.js';
|
|
20
|
+
import { applyMemoryPrefix } from './memory-injector.js';
|
|
17
21
|
import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
|
|
18
22
|
const HEADLESS_FLAGS = ['run', '--format', 'json'];
|
|
19
23
|
const ERROR_PATTERNS = [
|
|
@@ -45,6 +49,9 @@ export function spawnOpenCode(prompt, options = {}) {
|
|
|
45
49
|
let statusValue = 'running';
|
|
46
50
|
let killRequested = false;
|
|
47
51
|
const cwd = options.cwd ?? process.cwd();
|
|
52
|
+
// F3 Task 8 — memory injection (same shape as every other adapter).
|
|
53
|
+
const injected = applyMemoryPrefix(prompt, cwd, options);
|
|
54
|
+
const finalPrompt = injected.prompt;
|
|
48
55
|
const snapshot = snapshotCwd(cwd);
|
|
49
56
|
const aggregator = new ProgressAggregator('opencode');
|
|
50
57
|
let stepsSnapshot = [];
|
|
@@ -115,7 +122,7 @@ export function spawnOpenCode(prompt, options = {}) {
|
|
|
115
122
|
let child;
|
|
116
123
|
try {
|
|
117
124
|
// OpenCode's `run` expects the message as positional args, not stdin.
|
|
118
|
-
child = spawn('opencode', [...HEADLESS_FLAGS,
|
|
125
|
+
child = spawn('opencode', [...HEADLESS_FLAGS, finalPrompt, ...(options.extraArgs ?? [])], {
|
|
119
126
|
cwd,
|
|
120
127
|
env: spawnEnv,
|
|
121
128
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -206,6 +213,7 @@ export function spawnOpenCode(prompt, options = {}) {
|
|
|
206
213
|
startedAt,
|
|
207
214
|
prompt,
|
|
208
215
|
agent: 'opencode',
|
|
216
|
+
memoryInjection: injected.record,
|
|
209
217
|
get status() {
|
|
210
218
|
return statusValue;
|
|
211
219
|
},
|
|
@@ -266,9 +274,31 @@ export const OpenCodeAdapter = {
|
|
|
266
274
|
installHint: { command: 'curl -fsSL https://opencode.ai/install | bash', url: 'https://opencode.ai' },
|
|
267
275
|
};
|
|
268
276
|
}
|
|
269
|
-
// OpenCode manages its own provider credentials via `opencode
|
|
270
|
-
//
|
|
271
|
-
//
|
|
277
|
+
// OpenCode manages its own provider credentials via `opencode auth`
|
|
278
|
+
// (alias `opencode providers`). The auth file lives at
|
|
279
|
+
// $XDG_DATA_HOME/opencode/auth.json (default ~/.local/share/opencode/auth.json
|
|
280
|
+
// on Linux/macOS) — created with mode 600 the first time a provider is added.
|
|
281
|
+
//
|
|
282
|
+
// Pre-May-1 we returned 'ready' on install alone, deferring to runtime to
|
|
283
|
+
// catch missing-auth via ERROR_PATTERNS.auth_missing. That was dishonest:
|
|
284
|
+
// /api/readiness/agents would say 'ready' → user would spawn → ~50s later
|
|
285
|
+
// the task errored with no clear next step. Surfaced May 1 2026 by the
|
|
286
|
+
// multi-agent dogfood test alongside the same-shape gemini fix.
|
|
287
|
+
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
|
|
288
|
+
const opencodeAuthPath = join(xdgData, 'opencode', 'auth.json');
|
|
289
|
+
if (!existsSync(opencodeAuthPath)) {
|
|
290
|
+
return {
|
|
291
|
+
id: this.id,
|
|
292
|
+
label: this.label,
|
|
293
|
+
state: 'needs_auth',
|
|
294
|
+
installed: true,
|
|
295
|
+
authHint: {
|
|
296
|
+
command: 'opencode auth # interactive provider/key wizard',
|
|
297
|
+
url: 'https://opencode.ai/docs/configuration',
|
|
298
|
+
detail: `OpenCode found on PATH but no provider configured (${opencodeAuthPath} missing). Run \`opencode auth\` once to add an AI provider — supports 75+ providers BYOK including Anthropic, OpenAI, Mistral, etc.`,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
272
302
|
return { id: this.id, label: this.label, state: 'ready', installed: true };
|
|
273
303
|
},
|
|
274
304
|
};
|
|
@@ -101,6 +101,8 @@ export class ProgressAggregator {
|
|
|
101
101
|
return this.ingestCodex(type, data);
|
|
102
102
|
if (this.agentId === 'gemini')
|
|
103
103
|
return this.ingestGemini(type, data);
|
|
104
|
+
if (this.agentId === 'aider')
|
|
105
|
+
return this.ingestAider(type, data);
|
|
104
106
|
return false;
|
|
105
107
|
}
|
|
106
108
|
/** Flush: mark every still-active step as failed (called on abnormal exit). */
|
|
@@ -195,6 +197,123 @@ export class ProgressAggregator {
|
|
|
195
197
|
}
|
|
196
198
|
return false;
|
|
197
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Aider observability parity (v0.5 Path B per docs/design/f3_memory_spec.md
|
|
202
|
+
* + ~/.claude/.../memory/learning_aider.md).
|
|
203
|
+
*
|
|
204
|
+
* Aider doesn't expose a structured event stream — we parse stdout lines
|
|
205
|
+
* matching the EXACT patterns aider's `base_coder.py` writes when it
|
|
206
|
+
* applies an edit, creates a new file, or runs a shell command (verified
|
|
207
|
+
* against installed aider 0.86.2 source — line refs below).
|
|
208
|
+
*
|
|
209
|
+
* Patterns we match:
|
|
210
|
+
* "Running <command>" — base_coder.py:2472 — fires AFTER
|
|
211
|
+
* aider's confirm_ask returns yes (which `--yes-always` answers
|
|
212
|
+
* automatically). Aider has already started executing by the time we see
|
|
213
|
+
* this — OBSERVABILITY, not blocking.
|
|
214
|
+
* "Applied edit to <path>" — base_coder.py:2334 — fires after
|
|
215
|
+
* a SEARCH/REPLACE block lands. Used for both edits AND net-new files
|
|
216
|
+
* created via the same flow (aider doesn't distinguish in this message).
|
|
217
|
+
* "Creating empty file <path>" — base_coder.py:461 — fires when
|
|
218
|
+
* aider creates a brand-new empty file as scaffolding before edits.
|
|
219
|
+
*
|
|
220
|
+
* Patterns we DELIBERATELY do NOT match:
|
|
221
|
+
* "Did not apply edit to ..." — explicit non-event; we drop these
|
|
222
|
+
* (would be a false-positive Edit step otherwise).
|
|
223
|
+
* "Create new file?" — that's a CONFIRM_ASK prompt, not a
|
|
224
|
+
* tool_output. With `--yes-always` it never fires interactively, and
|
|
225
|
+
* it's not the action-taken signal anyway.
|
|
226
|
+
*
|
|
227
|
+
* Each matched pattern → one ProgressStep marked done immediately (aider
|
|
228
|
+
* doesn't emit separate completion events; the line itself IS the completion).
|
|
229
|
+
*
|
|
230
|
+
* Label vocabulary is aligned with Claude / Gemini / Codex labels for
|
|
231
|
+
* cross-agent UI consistency: `Run X` for shell, `Edit <path>` for file
|
|
232
|
+
* edits, `Write <path>` for net-new file creation.
|
|
233
|
+
*
|
|
234
|
+
* ANSI handling: HEADLESS_FLAGS in aider-adapter.ts include `--no-pretty`
|
|
235
|
+
* which strips colors. As defense-in-depth (in case extraArgs override that
|
|
236
|
+
* or aider regresses), we strip ANSI escape sequences before matching.
|
|
237
|
+
*
|
|
238
|
+
* Limitation: codehere CANNOT block aider's actions via this path. The
|
|
239
|
+
* upstream-PR Path A (adding `--pre-action-hook` to aider) is what enables
|
|
240
|
+
* blocking parity. v0.5 ships observability; v0.5.x or v0.6 ships blocking
|
|
241
|
+
* once Path A merges.
|
|
242
|
+
*/
|
|
243
|
+
// eslint-disable-next-line no-control-regex
|
|
244
|
+
static ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
245
|
+
/**
|
|
246
|
+
* Per-instance set of file paths aider just created via "Creating empty file ...".
|
|
247
|
+
* Aider emits BOTH `Creating empty file <p>` AND `Applied edit to <p>` for one
|
|
248
|
+
* logical net-new-file creation (file gets touched, then initial content
|
|
249
|
+
* gets applied). Without this set we'd record two steps (`Write <p>` then
|
|
250
|
+
* `Edit <p>`) for one action — codex Round 2 caught this. We suppress the
|
|
251
|
+
* subsequent `Applied edit to` for the same path so the user sees `Write`
|
|
252
|
+
* once, not `Write` followed by `Edit`.
|
|
253
|
+
*/
|
|
254
|
+
aiderJustCreated = new Set();
|
|
255
|
+
ingestAider(type, data) {
|
|
256
|
+
if (type !== 'stdout')
|
|
257
|
+
return false;
|
|
258
|
+
const raw = typeof data.line === 'string' ? data.line : '';
|
|
259
|
+
// Strip ANSI escape codes BEFORE matching so the parser survives a
|
|
260
|
+
// `--no-pretty` regression or override.
|
|
261
|
+
const line = raw.replace(ProgressAggregator.ANSI_RE, '').trim();
|
|
262
|
+
if (!line)
|
|
263
|
+
return false;
|
|
264
|
+
// EXPLICIT NON-EVENT: defense-in-depth guard. The `^Applied edit to` regex
|
|
265
|
+
// below wouldn't match "Did not apply edit to ..." because of the anchor,
|
|
266
|
+
// but if either pattern ever loosens, this short-circuit prevents a false
|
|
267
|
+
// Edit step from creeping in.
|
|
268
|
+
if (/^Did not apply edit to\s+/.test(line))
|
|
269
|
+
return false;
|
|
270
|
+
// "Running <command>" — base_coder.py:2472
|
|
271
|
+
let m = line.match(/^Running\s+(.+)$/);
|
|
272
|
+
if (m) {
|
|
273
|
+
this._steps.push({
|
|
274
|
+
label: truncate('Run ' + m[1]),
|
|
275
|
+
state: 'done',
|
|
276
|
+
startedAt: Date.now(),
|
|
277
|
+
completedAt: Date.now(),
|
|
278
|
+
});
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
// "Applied edit to <path>" — base_coder.py:2334
|
|
282
|
+
m = line.match(/^Applied edit to\s+(.+)$/);
|
|
283
|
+
if (m) {
|
|
284
|
+
const path = m[1];
|
|
285
|
+
// If aider JUST created this file, the Write step already represents the
|
|
286
|
+
// action; skip the Edit step that aider emits for the initial content.
|
|
287
|
+
// Clear from the set after consuming so subsequent edits to the same
|
|
288
|
+
// path (in this same session) DO show as Edit.
|
|
289
|
+
if (this.aiderJustCreated.has(path)) {
|
|
290
|
+
this.aiderJustCreated.delete(path);
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
this._steps.push({
|
|
294
|
+
label: 'Edit ' + shortPath(path),
|
|
295
|
+
state: 'done',
|
|
296
|
+
startedAt: Date.now(),
|
|
297
|
+
completedAt: Date.now(),
|
|
298
|
+
});
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
// "Creating empty file <path>" — base_coder.py:461 — net-new file scaffold
|
|
302
|
+
m = line.match(/^Creating empty file\s+(.+)$/);
|
|
303
|
+
if (m) {
|
|
304
|
+
const path = m[1];
|
|
305
|
+
this.aiderJustCreated.add(path);
|
|
306
|
+
// Use "Write" to align with Claude/Gemini's net-new-file label vocabulary.
|
|
307
|
+
this._steps.push({
|
|
308
|
+
label: 'Write ' + shortPath(path),
|
|
309
|
+
state: 'done',
|
|
310
|
+
startedAt: Date.now(),
|
|
311
|
+
completedAt: Date.now(),
|
|
312
|
+
});
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
198
317
|
ingestCodex(type, data) {
|
|
199
318
|
if (type !== 'item.completed')
|
|
200
319
|
return false;
|