codehere 0.6.0-alpha.1 → 0.6.1

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.
@@ -0,0 +1,256 @@
1
+ /**
2
+ * graphify-context — shared structural memory layer (P1).
3
+ *
4
+ * Wraps `graphify-ts` CLI to compile compact context packs for agent prompts.
5
+ * Every spawn can optionally get a graphify context pack that replaces
6
+ * raw file reads + unstructured JSONL scans.
7
+ *
8
+ * All functions are synchronous (execFileSync) to slot into the sync
9
+ * spawn() call chain without breaking the API.
10
+ *
11
+ * P1 integration points:
12
+ * - Session spawn: inject context pack into prompt prefix
13
+ * - Failover handoff (P2): replacement agent inherits the same pack
14
+ * - S7 compaction (future): use graph to prune stale session logs
15
+ * - PM-brain routing (future): community_overview for agent selection
16
+ */
17
+ import { execFileSync } from 'child_process';
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
19
+ import { join } from 'path';
20
+ import { randomUUID } from 'crypto';
21
+ // ── Constants ──────────────────────────────────────────────────────────────────
22
+ const GRAPHIFY_OUT = 'graphify-out';
23
+ const GRAPH_JSON = 'graph.json';
24
+ const CONTEXT_PACKS_DIR = '.codehere';
25
+ const CONTEXT_PACKS_FILE = 'context-packs.jsonl';
26
+ // ── Helpers ────────────────────────────────────────────────────────────────────
27
+ function getGraphPath(cwd) {
28
+ return join(cwd, GRAPHIFY_OUT, GRAPH_JSON);
29
+ }
30
+ function getGraphStats(cwd) {
31
+ const graphPath = getGraphPath(cwd);
32
+ try {
33
+ if (!existsSync(graphPath)) {
34
+ return { nodeCount: 0, edgeCount: 0 };
35
+ }
36
+ const graphJson = JSON.parse(readFileSync(graphPath, 'utf-8'));
37
+ return {
38
+ nodeCount: graphJson.nodes?.length ?? 0,
39
+ edgeCount: graphJson.edges?.length ?? 0,
40
+ };
41
+ }
42
+ catch {
43
+ return { nodeCount: 0, edgeCount: 0 };
44
+ }
45
+ }
46
+ // ── Graph management (sync) ────────────────────────────────────────────────────
47
+ /**
48
+ * Build or update the knowledge graph for a workspace directory.
49
+ * Writes to <cwd>/graphify-out/graph.json (graphify-ts default).
50
+ * Synchronous — runs synchronously so it can be called from spawn().
51
+ */
52
+ export function buildGraphSync(cwd, { incremental = true } = {}) {
53
+ const startTime = Date.now();
54
+ const graphPath = getGraphPath(cwd);
55
+ const args = ['generate', cwd];
56
+ if (incremental && existsSync(graphPath)) {
57
+ args.push('--update');
58
+ }
59
+ try {
60
+ execFileSync('npx', ['graphify-ts', ...args], {
61
+ cwd,
62
+ stdio: 'ignore',
63
+ timeout: 120_000,
64
+ });
65
+ }
66
+ catch {
67
+ // Non-zero exit is fine — graphify-ts returns 1 when no files changed.
68
+ // Also swallow ENOENT (graphify-ts not installed) — degrade gracefully.
69
+ }
70
+ return { elapsedMs: Date.now() - startTime };
71
+ }
72
+ // ── Context pack compilation (sync) ────────────────────────────────────────────
73
+ /**
74
+ * Compile a compact context pack for a prompt + workspace.
75
+ * Synchronous — calls graphify-ts pack and returns the payload.
76
+ */
77
+ export function compileContextPackSync(prompt, cwd, { task = 'explain', budget = 3000, } = {}) {
78
+ const startTime = Date.now();
79
+ const graphPath = getGraphPath(cwd);
80
+ // Build graph if it doesn't exist yet
81
+ if (!existsSync(graphPath)) {
82
+ buildGraphSync(cwd, { incremental: false });
83
+ }
84
+ // Degrade gracefully if graph still doesn't exist
85
+ if (!existsSync(graphPath)) {
86
+ return null;
87
+ }
88
+ try {
89
+ const output = execFileSync('npx', [
90
+ 'graphify-ts', 'pack', prompt,
91
+ '--graph', graphPath,
92
+ '--task', task,
93
+ '--budget', String(budget),
94
+ ], {
95
+ cwd,
96
+ encoding: 'utf-8',
97
+ maxBuffer: 512 * 1024,
98
+ timeout: 30_000,
99
+ });
100
+ const { nodeCount, edgeCount } = getGraphStats(cwd);
101
+ return {
102
+ graphPath,
103
+ payload: output.trim(),
104
+ budget,
105
+ task,
106
+ nodeCount,
107
+ edgeCount,
108
+ elapsedMs: Date.now() - startTime,
109
+ packId: randomUUID(),
110
+ };
111
+ }
112
+ catch {
113
+ // graphify-ts may return non-zero or hit timeout — degrade gracefully.
114
+ return null;
115
+ }
116
+ }
117
+ /**
118
+ * Compile a context pack from a session's final state (prompt + artifacts + steps).
119
+ * Used for P1 session-exit persistence and P2 handoff enrichment.
120
+ */
121
+ export function compileSessionPackSync(cwd, { prompt, artifacts, steps, agent }) {
122
+ // Combine prompt + artifacts + steps into a rich prompt for the pack
123
+ const sessionContext = [
124
+ prompt,
125
+ agent ? `Agent: ${agent}` : undefined,
126
+ artifacts ? `Artifacts:\n${artifacts}` : undefined,
127
+ steps ? `Steps taken:\n${steps}` : undefined,
128
+ ].filter(Boolean).join('\n\n');
129
+ return compileContextPackSync(sessionContext, cwd, { task: 'impact', budget: 2500 });
130
+ }
131
+ // ── Context pack persistence ───────────────────────────────────────────────────
132
+ /**
133
+ * Persist a context pack to disk for cross-session handoff (P2).
134
+ * Returns the path to the packs file.
135
+ */
136
+ export function persistContextPack(pack, cwd) {
137
+ const packsDir = join(cwd, CONTEXT_PACKS_DIR);
138
+ if (!existsSync(packsDir)) {
139
+ mkdirSync(packsDir, { recursive: true });
140
+ }
141
+ const packsFile = join(packsDir, CONTEXT_PACKS_FILE);
142
+ const entry = {
143
+ packId: pack.packId,
144
+ timestamp: Date.now(),
145
+ task: pack.task,
146
+ nodeCount: pack.nodeCount,
147
+ edgeCount: pack.edgeCount,
148
+ graphPath: pack.graphPath,
149
+ payloadPreview: pack.payload.slice(0, 500), // truncate for log
150
+ payloadLength: pack.payload.length,
151
+ };
152
+ writeFileSync(packsFile, JSON.stringify(entry) + '\n', { flag: 'a' });
153
+ return packsFile;
154
+ }
155
+ /**
156
+ * Load the most recent context pack from disk (for P2 handoff).
157
+ */
158
+ export function loadRecentContextPack(cwd) {
159
+ const packsFile = join(cwd, CONTEXT_PACKS_DIR, CONTEXT_PACKS_FILE);
160
+ if (!existsSync(packsFile)) {
161
+ return null;
162
+ }
163
+ try {
164
+ const lines = readFileSync(packsFile, 'utf-8').split('\n').filter(Boolean);
165
+ if (lines.length === 0)
166
+ return null;
167
+ const lastEntry = JSON.parse(lines[lines.length - 1]);
168
+ return {
169
+ packId: lastEntry.packId,
170
+ graphPath: lastEntry.graphPath,
171
+ payload: '', // payload is truncated in disk storage
172
+ budget: 3000,
173
+ task: lastEntry.task,
174
+ nodeCount: lastEntry.nodeCount,
175
+ edgeCount: lastEntry.edgeCount,
176
+ elapsedMs: 0,
177
+ };
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
183
+ // ── Prompt enrichment (sync) ───────────────────────────────────────────────────
184
+ /**
185
+ * Prepend a context pack to a prompt if graphify-ts is available.
186
+ *
187
+ * Synchronous — can be called directly from spawn().
188
+ * Returns the original prompt if graphify-ts fails or is unavailable.
189
+ *
190
+ * Respects CODEHERE_GRAPHIFY env var:
191
+ * - unset or "auto" (default): compile on spawn if graph doesn't exist
192
+ * - "force": always compile, even if pack exists
193
+ * - "off": skip entirely
194
+ */
195
+ export function enrichPromptWithPackSync(prompt, cwd, { force = false } = {}) {
196
+ const mode = process.env.CODEHERE_GRAPHIFY ?? 'auto';
197
+ if (mode === 'off')
198
+ return prompt;
199
+ // Check for existing pack to avoid recompiling on every spawn
200
+ const existingPack = loadRecentContextPack(cwd);
201
+ const graphPath = getGraphPath(cwd);
202
+ const graphExists = existsSync(graphPath);
203
+ if (!force && existingPack && graphExists) {
204
+ // Reuse existing pack — just enrich the prompt with a compact reference
205
+ const reference = `[graphify-ts context pack: ${existingPack.nodeCount} nodes, ${existingPack.edgeCount} edges, pack=${existingPack.packId}]`;
206
+ return `[graphify context: ${reference} — agent should call retrieve() for full pack]\n\n${prompt}`;
207
+ }
208
+ // Compile a fresh pack
209
+ const pack = compileContextPackSync(prompt, cwd, { task: 'explain', budget: 2500 });
210
+ if (!pack) {
211
+ return prompt;
212
+ }
213
+ // Persist for P2 handoff
214
+ persistContextPack(pack, cwd);
215
+ // Prepend context pack as a system hint so the agent sees it first
216
+ return [
217
+ `[graphify-ts context pack — ${pack.nodeCount} nodes, ${pack.edgeCount} edges, ${pack.elapsedMs}ms]`,
218
+ pack.payload,
219
+ '---',
220
+ prompt,
221
+ ].join('\n\n');
222
+ }
223
+ // ── Graph querying (sync) ──────────────────────────────────────────────────────
224
+ /**
225
+ * Query the knowledge graph for a question.
226
+ * Useful for PM-brain routing and structured context handoff (P2).
227
+ */
228
+ export function queryGraphSync(question, cwd, { maxTokens = 2000 } = {}) {
229
+ const startTime = Date.now();
230
+ const graphPath = getGraphPath(cwd);
231
+ if (!existsSync(graphPath)) {
232
+ return null;
233
+ }
234
+ try {
235
+ const output = execFileSync('npx', [
236
+ 'graphify-ts', 'query', question,
237
+ '--graph', graphPath,
238
+ '--budget', String(maxTokens),
239
+ ], {
240
+ cwd,
241
+ encoding: 'utf-8',
242
+ maxBuffer: 256 * 1024,
243
+ timeout: 15_000,
244
+ });
245
+ return {
246
+ question,
247
+ answer: output.trim(),
248
+ elapsedMs: Date.now() - startTime,
249
+ graphPath,
250
+ };
251
+ }
252
+ catch {
253
+ return null;
254
+ }
255
+ }
256
+ //# sourceMappingURL=graphify-context.js.map
@@ -19,6 +19,7 @@ import { join } from 'path';
19
19
  import { promisify } from 'util';
20
20
  import { ProgressAggregator } from './progress-aggregator.js';
21
21
  import { applyMemoryPrefix } from './memory-injector.js';
22
+ import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
22
23
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
23
24
  const execFileAsync = promisify(execFile);
24
25
  const HEADLESS_FLAGS = ['run', '--format', 'json'];
@@ -142,6 +143,46 @@ export function spawnOpenCode(prompt, options = {}) {
142
143
  if (txt)
143
144
  textAccum += txt + '\n';
144
145
  }
146
+ // #114 — cross-vendor audit mirror for opencode (closes the gap left by
147
+ // #110 slice 4, which wired Codex/Aider/Gemini but deferred opencode).
148
+ // OpenCode's `run --format json` emits `tool_use` events with shape
149
+ // { type: 'tool_use', part: { tool: 'bash'|'read'|'write'|..., state:
150
+ // { input: {...}, output, status, ... } } } (verified via deepwiki +
151
+ // takopi cheatsheet). Same post-hoc visibility surface as Codex/Aider/
152
+ // Gemini — no synchronous block until upstream pre-tool hooks land.
153
+ if (event.type === 'tool_use') {
154
+ const part = event.data?.part;
155
+ const rawTool = String(part?.tool ?? 'tool').toLowerCase();
156
+ // Normalize to Claude-cased tool names so /history filter chips group
157
+ // cross-vendor (a bash call from claude-code and opencode show under
158
+ // the same "Bash" filter chip).
159
+ const toolNameMap = {
160
+ bash: 'Bash',
161
+ read: 'Read',
162
+ write: 'Write',
163
+ edit: 'Edit',
164
+ grep: 'Grep',
165
+ glob: 'Glob',
166
+ webfetch: 'WebFetch',
167
+ websearch: 'WebSearch',
168
+ task: 'Task',
169
+ };
170
+ const toolName = toolNameMap[rawTool] ?? rawTool;
171
+ const input = part?.state?.input ?? {};
172
+ const inputStr = (typeof input.command === 'string' && input.command) ||
173
+ (typeof input.file_path === 'string' && input.file_path) ||
174
+ (typeof input.path === 'string' && input.path) ||
175
+ (typeof input.pattern === 'string' && input.pattern) ||
176
+ (typeof input.query === 'string' && input.query) ||
177
+ JSON.stringify(input).slice(0, 500);
178
+ mirrorToolCallToAudit({
179
+ sessionId,
180
+ agent: 'opencode',
181
+ toolName,
182
+ toolInput: String(inputStr),
183
+ cwd,
184
+ });
185
+ }
145
186
  if (!errorEmitted) {
146
187
  const data = event.data;
147
188
  let text = '';