Skip to content

Commit fe79e5a

Browse files
authored
feat: implement enricher (#1715)
## TLDR Adds a file enrichment feature that annotates PostHog-related code with live metadata (event volume, flag rollout/staleness, experiment links) as the agent reads files. Closes #1097 ## Problem When the agent reads source files containing PostHog SDK calls, it has no awareness of the current state of those flags or events in production. This means the agent lacks important product context — such as whether a flag is rolled out, how much volume an event has, or whether an event is verified — that would help it make better recommendations. ## Changes - Introduces a new `@posthog/enricher` package dependency in the agent, with a `FileEnrichmentDeps`/`Enrichment` abstraction in `packages/agent/src/enrichment/file-enricher.ts` that wraps `PostHogEnricher` and exposes an `enrichFileForAgent` function - For the Claude adapter, a `PostToolUse` hook (`createReadEnrichmentHook`) intercepts `Read` tool responses, strips line numbers, runs enrichment, and returns `additionalContext` to the model alongside writing enriched content to an `EnrichedReadCache` keyed by `tool_use_id`; the cache is consumed in `toolUpdateFromToolResult` so the UI displays the annotated version - For the Codex adapter, enrichment is applied directly inside `readTextFile` on the `CodexClient`, replacing the file content before it reaches codex-acp - Adds a `toInlineComments()` method on `EnrichedResult` that appends PostHog annotations as inline comments on the same source line (rather than inserting new lines above), and a corresponding `formatInlineComments` function in the comment formatter - API errors (401, 500, network failures, malformed JSON) in `enrichFromApi` are now tolerated via `Promise.allSettled` rather than rejecting, returning partial/empty enrichment instead - Fixes a HogQL query bug where typed placeholders (`{name:Type}`) and `INTERVAL` with a placeholder were rejected; `daysBack` is now inlined directly into the query string - Enrichment is enabled by default when `posthog` config is present and can be disabled via `enricher: { enabled: false }` in `AgentConfig` - Grammar file resolution in `ParserManager` now checks multiple candidate paths to support packaged Electron app layouts; the `forge.config.ts` asar unpack glob and a new `copyEnricherGrammars` Vite plugin ensure grammars are available at both dev and packaged runtime paths - CI build and test workflows include a build step for `@posthog/enricher` - Adds unit tests for `enrichFileForAgent`, `createReadEnrichmentHook`, and `createCodexClient.readTextFile`
1 parent c211f16 commit fe79e5a

26 files changed

Lines changed: 1024 additions & 63 deletions

.github/workflows/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ jobs:
4242
- name: Build git
4343
run: pnpm --filter @posthog/git build
4444

45+
- name: Build enricher
46+
run: pnpm --filter @posthog/enricher build
47+
4548
- name: Build agent
4649
run: pnpm --filter agent build
4750

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ jobs:
8181
pnpm --filter @posthog/platform build &
8282
pnpm --filter @posthog/shared build
8383
pnpm --filter @posthog/git build
84+
pnpm --filter @posthog/enricher build
8485
pnpm --filter agent build &
8586
wait
8687

apps/code/forge.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ const config: ForgeConfig = {
143143
packagerConfig: {
144144
asar: {
145145
unpack:
146-
"{**/*.node,**/spawn-helper,**/.vite/build/claude-cli/**,**/.vite/build/plugins/posthog/**,**/.vite/build/codex-acp/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**,**/node_modules/better-sqlite3/**,**/node_modules/bindings/**,**/node_modules/file-uri-to-path/**}",
146+
"{**/*.node,**/spawn-helper,**/.vite/build/claude-cli/**,**/.vite/build/plugins/posthog/**,**/.vite/build/codex-acp/**,**/.vite/build/grammars/**,**/node_modules/node-pty/**,**/node_modules/@parcel/**,**/node_modules/file-icon/**,**/node_modules/better-sqlite3/**,**/node_modules/bindings/**,**/node_modules/file-uri-to-path/**}",
147147
},
148148
prune: false,
149149
name: "PostHog Code",

apps/code/vite.main.config.mts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,52 @@ function copyDrizzleMigrations(): Plugin {
424424
};
425425
}
426426

427+
let enricherGrammarsCopied = false;
428+
429+
function copyEnricherGrammars(): Plugin {
430+
return {
431+
name: "copy-enricher-grammars",
432+
writeBundle() {
433+
// `.vite/grammars` is what the bundle resolves at dev-time; electron-forge
434+
// only copies `.vite/build/**` into the packaged app, so we need both.
435+
const destDirs = [
436+
join(__dirname, ".vite/grammars"),
437+
join(__dirname, ".vite/build/grammars"),
438+
];
439+
440+
if (enricherGrammarsCopied && destDirs.every((d) => existsSync(d))) {
441+
return;
442+
}
443+
444+
const candidates = [
445+
join(__dirname, "node_modules/@posthog/enricher/grammars"),
446+
join(__dirname, "../../node_modules/@posthog/enricher/grammars"),
447+
join(__dirname, "../../packages/enricher/grammars"),
448+
];
449+
450+
const sourceDir = candidates.find((p) => existsSync(p));
451+
if (!sourceDir) {
452+
console.warn(
453+
"[copy-enricher-grammars] grammars directory not found. Checked:",
454+
candidates.join(", "),
455+
);
456+
return;
457+
}
458+
459+
for (const destDir of destDirs) {
460+
if (!existsSync(destDir)) {
461+
mkdirSync(destDir, { recursive: true });
462+
}
463+
cpSync(sourceDir, destDir, { recursive: true });
464+
}
465+
enricherGrammarsCopied = true;
466+
console.log(
467+
`Copied enricher grammars from ${sourceDir} to ${destDirs.join(", ")}`,
468+
);
469+
},
470+
};
471+
}
472+
427473
let codexAcpCopied = false;
428474

429475
function copyCodexAcpBinaries(): Plugin {
@@ -492,6 +538,7 @@ export default defineConfig(({ mode }) => {
492538
copyPosthogPlugin(isDev),
493539
copyDrizzleMigrations(),
494540
copyCodexAcpBinaries(),
541+
copyEnricherGrammars(),
495542
createPosthogPlugin(env, "posthog-code-main"),
496543
].filter(Boolean),
497544
define: {

packages/agent/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
"@opentelemetry/resources": "^2.0.0",
110110
"@opentelemetry/sdk-logs": "^0.208.0",
111111
"@opentelemetry/semantic-conventions": "^1.28.0",
112+
"@posthog/enricher": "workspace:*",
112113
"@types/jsonwebtoken": "^9.0.10",
113114
"commander": "^14.0.2",
114115
"hono": "^4.11.7",

packages/agent/src/adapters/acp-connection.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
22
import type { SessionLogWriter } from "../session-log-writer";
3-
import type { ProcessSpawnedCallback } from "../types";
3+
import type { PostHogAPIConfig, ProcessSpawnedCallback } from "../types";
44
import { Logger } from "../utils/logger";
55
import {
66
createBidirectionalStreams,
@@ -26,6 +26,10 @@ export type AcpConnectionConfig = {
2626
allowedModelIds?: Set<string>;
2727
/** Callback invoked when the agent calls the create_output tool for structured output */
2828
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
29+
/** PostHog API config; when set, enables file-read enrichment unless disabled. */
30+
posthogApiConfig?: PostHogAPIConfig;
31+
/** Defaults to true when posthogApiConfig is set. Set to false to disable enrichment. */
32+
enricherEnabled?: boolean;
2933
};
3034

3135
export type AcpConnection = {
@@ -54,6 +58,13 @@ export function createAcpConnection(
5458
return createClaudeConnection(config);
5559
}
5660

61+
function resolveEnricherApiConfig(
62+
config: AcpConnectionConfig,
63+
): PostHogAPIConfig | undefined {
64+
const enabled = !!config.posthogApiConfig && config.enricherEnabled !== false;
65+
return enabled ? config.posthogApiConfig : undefined;
66+
}
67+
5768
function createClaudeConnection(config: AcpConnectionConfig): AcpConnection {
5869
const logger =
5970
config.logger?.child("AcpConnection") ??
@@ -102,6 +113,7 @@ function createClaudeConnection(config: AcpConnectionConfig): AcpConnection {
102113
agent = new ClaudeAcpAgent(client, {
103114
...config.processCallbacks,
104115
onStructuredOutput: config.onStructuredOutput,
116+
posthogApiConfig: resolveEnricherApiConfig(config),
105117
});
106118
return agent;
107119
}, agentStream);
@@ -192,6 +204,7 @@ function createCodexConnection(config: AcpConnectionConfig): AcpConnection {
192204
agent = new CodexAcpAgent(client, {
193205
codexProcessOptions: config.codexOptions ?? {},
194206
processCallbacks: config.processCallbacks,
207+
posthogApiConfig: resolveEnricherApiConfig(config),
195208
});
196209
return agent;
197210
}, agentStream);

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ import {
5151
POSTHOG_METHODS,
5252
POSTHOG_NOTIFICATIONS,
5353
} from "../../acp-extensions";
54+
import {
55+
createEnrichment,
56+
type Enrichment,
57+
type FileEnrichmentDeps,
58+
} from "../../enrichment/file-enricher";
59+
import type { PostHogAPIConfig } from "../../types";
5460
import { unreachable, withTimeout } from "../../utils/common";
5561
import { Logger } from "../../utils/logger";
5662
import { Pushable } from "../../utils/streams";
@@ -62,6 +68,7 @@ import {
6268
handleSystemMessage,
6369
handleUserAssistantMessage,
6470
} from "./conversion/sdk-to-acp";
71+
import type { EnrichedReadCache } from "./hooks";
6572
import {
6673
fetchMcpToolMetadata,
6774
getConnectedMcpServerNames,
@@ -116,6 +123,7 @@ export interface ClaudeAcpAgentOptions {
116123
onProcessExited?: (pid: number) => void;
117124
onMcpServersReady?: (serverNames: string[]) => void;
118125
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
126+
posthogApiConfig?: PostHogAPIConfig;
119127
}
120128

121129
export class ClaudeAcpAgent extends BaseAcpAgent {
@@ -125,12 +133,29 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
125133
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
126134
clientCapabilities?: ClientCapabilities;
127135
private options?: ClaudeAcpAgentOptions;
136+
private enrichment?: Enrichment;
137+
private enrichedReadCache: EnrichedReadCache = new Map();
128138

129139
constructor(client: AgentSideConnection, options?: ClaudeAcpAgentOptions) {
130140
super(client);
131141
this.options = options;
132142
this.toolUseCache = {};
133143
this.logger = new Logger({ debug: true, prefix: "[ClaudeAcpAgent]" });
144+
this.enrichment = createEnrichment(options?.posthogApiConfig, this.logger);
145+
}
146+
147+
protected getEnrichmentDeps(): FileEnrichmentDeps | undefined {
148+
return this.enrichment?.deps;
149+
}
150+
151+
override async closeSession(): Promise<void> {
152+
try {
153+
await super.closeSession();
154+
} finally {
155+
this.enrichment?.dispose();
156+
this.enrichment = undefined;
157+
this.enrichedReadCache.clear();
158+
}
134159
}
135160

136161
async initialize(request: InitializeRequest): Promise<InitializeResponse> {
@@ -355,6 +380,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
355380
client: this.client,
356381
toolUseCache: this.toolUseCache,
357382
fileContentCache: this.fileContentCache,
383+
enrichedReadCache: this.enrichedReadCache,
358384
logger: this.logger,
359385
supportsTerminalOutput,
360386
};
@@ -993,6 +1019,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
9931019
onProcessSpawned: this.options?.onProcessSpawned,
9941020
onProcessExited: this.options?.onProcessExited,
9951021
effort,
1022+
enrichmentDeps: this.enrichment?.deps,
1023+
enrichedReadCache: this.enrichedReadCache,
9961024
});
9971025

9981026
// Use the same abort controller that buildSessionOptions gave to the query
@@ -1354,6 +1382,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
13541382
client: this.client,
13551383
toolUseCache: this.toolUseCache,
13561384
fileContentCache: this.fileContentCache,
1385+
enrichedReadCache: this.enrichedReadCache,
13571386
logger: this.logger,
13581387
registerHooks: false,
13591388
};

packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { POSTHOG_NOTIFICATIONS } from "@/acp-extensions";
2121
import { image, text } from "../../../utils/acp-content";
2222
import { unreachable } from "../../../utils/common";
2323
import type { Logger } from "../../../utils/logger";
24-
import { registerHookCallback } from "../hooks";
24+
import { type EnrichedReadCache, registerHookCallback } from "../hooks";
2525
import type { Session, ToolUpdateMeta, ToolUseCache } from "../types";
2626
import {
2727
type ClaudePlanEntry,
@@ -51,6 +51,7 @@ type ChunkHandlerContext = {
5151
sessionId: string;
5252
toolUseCache: ToolUseCache;
5353
fileContentCache: { [key: string]: string };
54+
enrichedReadCache?: EnrichedReadCache;
5455
client: AgentSideConnection;
5556
logger: Logger;
5657
parentToolCallId?: string;
@@ -67,6 +68,7 @@ export interface MessageHandlerContext {
6768
client: AgentSideConnection;
6869
toolUseCache: ToolUseCache;
6970
fileContentCache: { [key: string]: string };
71+
enrichedReadCache?: EnrichedReadCache;
7072
logger: Logger;
7173
registerHooks?: boolean;
7274
supportsTerminalOutput?: boolean;
@@ -248,7 +250,7 @@ function extractTextFromContent(content: unknown): string | null {
248250
return null;
249251
}
250252

251-
function stripCatLineNumbers(text: string): string {
253+
export function stripCatLineNumbers(text: string): string {
252254
return text.replace(/^ *\d+[\t]/gm, "");
253255
}
254256

@@ -318,6 +320,7 @@ function handleToolResultChunk(
318320
supportsTerminalOutput: ctx.supportsTerminalOutput,
319321
toolUseId: chunk.tool_use_id,
320322
cachedFileContent: ctx.fileContentCache,
323+
enrichedReadCache: ctx.enrichedReadCache,
321324
},
322325
);
323326

@@ -448,6 +451,7 @@ function toAcpNotifications(
448451
supportsTerminalOutput?: boolean,
449452
cwd?: string,
450453
mcpToolUseResult?: Record<string, unknown>,
454+
enrichedReadCache?: EnrichedReadCache,
451455
): SessionNotification[] {
452456
if (typeof content === "string") {
453457
const update: SessionUpdate = {
@@ -468,6 +472,7 @@ function toAcpNotifications(
468472
sessionId,
469473
toolUseCache,
470474
fileContentCache,
475+
enrichedReadCache,
471476
client,
472477
logger,
473478
parentToolCallId,
@@ -498,6 +503,7 @@ function streamEventToAcpNotifications(
498503
registerHooks?: boolean,
499504
supportsTerminalOutput?: boolean,
500505
cwd?: string,
506+
enrichedReadCache?: EnrichedReadCache,
501507
): SessionNotification[] {
502508
const event = message.event;
503509
switch (event.type) {
@@ -514,6 +520,8 @@ function streamEventToAcpNotifications(
514520
registerHooks,
515521
supportsTerminalOutput,
516522
cwd,
523+
undefined,
524+
enrichedReadCache,
517525
);
518526
case "content_block_delta":
519527
return toAcpNotifications(
@@ -528,6 +536,8 @@ function streamEventToAcpNotifications(
528536
registerHooks,
529537
supportsTerminalOutput,
530538
cwd,
539+
undefined,
540+
enrichedReadCache,
531541
);
532542
case "message_start":
533543
case "message_delta":
@@ -717,6 +727,7 @@ export async function handleStreamEvent(
717727
context.registerHooks,
718728
context.supportsTerminalOutput,
719729
context.session.cwd,
730+
context.enrichedReadCache,
720731
)) {
721732
await client.sessionUpdate(notification);
722733
context.session.notificationHistory.push(notification);
@@ -840,6 +851,7 @@ export async function handleUserAssistantMessage(
840851
context.supportsTerminalOutput,
841852
session.cwd,
842853
mcpToolUseResult,
854+
context.enrichedReadCache,
843855
)) {
844856
await client.sessionUpdate(notification);
845857
session.notificationHistory.push(notification);

packages/agent/src/adapters/claude/conversion/tool-use-to-acp.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ function stripSystemReminders(value: string): string {
3131
}
3232

3333
import { resourceLink, text, toolContent } from "../../../utils/acp-content";
34+
import type { EnrichedReadCache } from "../hooks";
3435
import { getMcpToolMetadata } from "../mcp/tool-metadata";
3536

3637
type ToolInfo = Pick<ToolCall, "title" | "kind" | "content" | "locations">;
@@ -526,6 +527,7 @@ export function toolUpdateFromToolResult(
526527
supportsTerminalOutput?: boolean;
527528
toolUseId?: string;
528529
cachedFileContent?: Record<string, string>;
530+
enrichedReadCache?: EnrichedReadCache;
529531
},
530532
): Pick<ToolCallUpdate, "title" | "content" | "locations" | "_meta"> {
531533
if (
@@ -538,7 +540,21 @@ export function toolUpdateFromToolResult(
538540
}
539541

540542
switch (toolUse?.name) {
541-
case "Read":
543+
case "Read": {
544+
const cache = options?.enrichedReadCache;
545+
const enriched =
546+
cache && options?.toolUseId ? cache.get(options.toolUseId) : undefined;
547+
if (enriched !== undefined && cache && options?.toolUseId) {
548+
cache.delete(options.toolUseId);
549+
return {
550+
content: [
551+
{
552+
type: "content" as const,
553+
content: text(markdownEscape(enriched)),
554+
},
555+
],
556+
};
557+
}
542558
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
543559
return {
544560
content: toolResult.content.map((item) => {
@@ -582,6 +598,7 @@ export function toolUpdateFromToolResult(
582598
};
583599
}
584600
return {};
601+
}
585602

586603
case "Bash": {
587604
const result = toolResult.content;

0 commit comments

Comments
 (0)