Skip to content

Commit 814e617

Browse files
shreyas-lyzrclaude
andcommitted
feat: add buildTool factory, cost tracker, context compaction, expanded hooks
Applies Claude Code architecture patterns to GitClaw: - buildTool() factory with fail-closed defaults (isConcurrencySafe, isReadOnly, isDestructive) - CostTracker class for per-model token usage and cost tracking across sessions - Context compaction utilities (token estimation, tool result truncation, summarization prompts) - 3 new hook events: pre_query, post_tool_failure, file_changed - Query.costs() method returns session cost breakdown - All new modules exported from SDK Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ac8d648 commit 814e617

8 files changed

Lines changed: 305 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "gitclaw",
3-
"version": "1.1.9",
3+
"version": "1.2.0",
44
"description": "A universal git-native multimodal always learning AI Agent (TinyHuman)",
55
"author": "shreyaskapale",
66
"license": "MIT",

src/compact.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { GCMessage } from "./sdk-types.js";
2+
3+
// ── Token estimation ──────────────────────────────────────────────────
4+
5+
/** Rough token estimate: 1 token ≈ 4 chars */
6+
export function estimateTokens(text: string): number {
7+
return Math.ceil(text.length / 4);
8+
}
9+
10+
/** Estimate total tokens across a message array */
11+
export function estimateMessageTokens(messages: GCMessage[]): number {
12+
let total = 0;
13+
for (const msg of messages) {
14+
switch (msg.type) {
15+
case "assistant":
16+
total += estimateTokens(msg.content) + estimateTokens(msg.thinking ?? "");
17+
break;
18+
case "user":
19+
total += estimateTokens(msg.content);
20+
break;
21+
case "tool_use":
22+
total += estimateTokens(JSON.stringify(msg.args)) + 50;
23+
break;
24+
case "tool_result":
25+
total += estimateTokens(msg.content);
26+
break;
27+
case "delta":
28+
total += estimateTokens(msg.content);
29+
break;
30+
case "system":
31+
total += estimateTokens(msg.content);
32+
break;
33+
}
34+
}
35+
return total;
36+
}
37+
38+
// ── Compaction checks ─────────────────────────────────────────────────
39+
40+
/** Check if messages are approaching context limit and need compaction */
41+
export function needsCompaction(
42+
messages: GCMessage[],
43+
contextWindow: number = 200000,
44+
): { needed: boolean; tokenEstimate: number; ratio: number } {
45+
const tokenEstimate = estimateMessageTokens(messages);
46+
const ratio = tokenEstimate / contextWindow;
47+
return { needed: ratio > 0.75, tokenEstimate, ratio };
48+
}
49+
50+
// ── Tool result truncation ────────────────────────────────────────────
51+
52+
/** Truncate oversized tool results, keeping first and last portions */
53+
export function truncateToolResults(
54+
messages: GCMessage[],
55+
maxCharsPerResult: number = 10000,
56+
): GCMessage[] {
57+
return messages.map((msg) => {
58+
if (msg.type === "tool_result" && msg.content.length > maxCharsPerResult) {
59+
const half = Math.floor(maxCharsPerResult / 2);
60+
const truncated =
61+
msg.content.slice(0, half) +
62+
`\n\n... [${msg.content.length - maxCharsPerResult} chars truncated] ...\n\n` +
63+
msg.content.slice(-half);
64+
return { ...msg, content: truncated };
65+
}
66+
return msg;
67+
});
68+
}
69+
70+
// ── Conversation summarization ────────────────────────────────────────
71+
72+
/**
73+
* Build a text representation of messages for summarization.
74+
* Strips deltas and system messages, keeps the substantive conversation.
75+
*/
76+
export function messagesToText(messages: GCMessage[]): string {
77+
const parts: string[] = [];
78+
for (const msg of messages) {
79+
switch (msg.type) {
80+
case "assistant":
81+
parts.push(`Assistant: ${msg.content}`);
82+
break;
83+
case "user":
84+
parts.push(`User: ${msg.content}`);
85+
break;
86+
case "tool_use":
87+
parts.push(`Tool call: ${msg.toolName}(${JSON.stringify(msg.args).slice(0, 200)})`);
88+
break;
89+
case "tool_result":
90+
parts.push(`Tool result [${msg.toolName}]: ${msg.content.slice(0, 500)}`);
91+
break;
92+
}
93+
}
94+
return parts.join("\n");
95+
}
96+
97+
/**
98+
* Generate a compaction prompt that can be sent to the model to summarize
99+
* the conversation so far. The caller runs the actual query.
100+
*/
101+
export function buildCompactPrompt(messages: GCMessage[]): string {
102+
const text = messagesToText(messages);
103+
if (!text) return "";
104+
return (
105+
"Summarize this conversation concisely. Preserve key decisions, " +
106+
"file paths, code changes, and outcomes. Omit tool call details " +
107+
"unless they failed.\n\n" +
108+
text
109+
);
110+
}

src/cost-tracker.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// ── Per-model cost and token tracking ──────────────────────────────────
2+
3+
export interface ModelUsage {
4+
inputTokens: number;
5+
outputTokens: number;
6+
cacheReadTokens: number;
7+
cacheWriteTokens: number;
8+
totalTokens: number;
9+
costUsd: number;
10+
requests: number;
11+
}
12+
13+
export interface SessionCosts {
14+
totalCostUsd: number;
15+
totalInputTokens: number;
16+
totalOutputTokens: number;
17+
totalRequests: number;
18+
startTime: number;
19+
modelUsage: Record<string, ModelUsage>;
20+
}
21+
22+
/**
23+
* Tracks token usage and cost per model across a session.
24+
* Mirrors Claude Code's cost-tracker pattern.
25+
*/
26+
export class CostTracker {
27+
private costs: SessionCosts;
28+
29+
constructor() {
30+
this.costs = {
31+
totalCostUsd: 0,
32+
totalInputTokens: 0,
33+
totalOutputTokens: 0,
34+
totalRequests: 0,
35+
startTime: Date.now(),
36+
modelUsage: {},
37+
};
38+
}
39+
40+
add(
41+
model: string,
42+
usage: {
43+
inputTokens: number;
44+
outputTokens: number;
45+
cacheReadTokens?: number;
46+
cacheWriteTokens?: number;
47+
totalTokens?: number;
48+
costUsd?: number;
49+
},
50+
): void {
51+
this.costs.totalInputTokens += usage.inputTokens;
52+
this.costs.totalOutputTokens += usage.outputTokens;
53+
this.costs.totalCostUsd += usage.costUsd ?? 0;
54+
this.costs.totalRequests++;
55+
56+
if (!this.costs.modelUsage[model]) {
57+
this.costs.modelUsage[model] = {
58+
inputTokens: 0,
59+
outputTokens: 0,
60+
cacheReadTokens: 0,
61+
cacheWriteTokens: 0,
62+
totalTokens: 0,
63+
costUsd: 0,
64+
requests: 0,
65+
};
66+
}
67+
const mu = this.costs.modelUsage[model];
68+
mu.inputTokens += usage.inputTokens;
69+
mu.outputTokens += usage.outputTokens;
70+
mu.cacheReadTokens += usage.cacheReadTokens ?? 0;
71+
mu.cacheWriteTokens += usage.cacheWriteTokens ?? 0;
72+
mu.totalTokens += usage.totalTokens ?? (usage.inputTokens + usage.outputTokens);
73+
mu.costUsd += usage.costUsd ?? 0;
74+
mu.requests++;
75+
}
76+
77+
get(): SessionCosts {
78+
return {
79+
...this.costs,
80+
modelUsage: { ...this.costs.modelUsage },
81+
};
82+
}
83+
84+
reset(): void {
85+
this.costs = {
86+
totalCostUsd: 0,
87+
totalInputTokens: 0,
88+
totalOutputTokens: 0,
89+
totalRequests: 0,
90+
startTime: Date.now(),
91+
modelUsage: {},
92+
};
93+
}
94+
}

src/exports.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,5 +46,16 @@ export type { PluginManifest, PluginConfig, LoadedPlugin } from "./plugin-types.
4646
export type { GitclawPluginApi } from "./plugin-sdk.js";
4747
export { createPluginApi } from "./plugin-sdk.js";
4848

49+
// Tool factory (Claude Code buildTool pattern)
50+
export { buildTool, getToolMetadata } from "./tool-factory.js";
51+
export type { ToolDefinition, ToolMetadata } from "./tool-factory.js";
52+
53+
// Cost tracking
54+
export { CostTracker } from "./cost-tracker.js";
55+
export type { SessionCosts, ModelUsage } from "./cost-tracker.js";
56+
57+
// Context compaction
58+
export { estimateTokens, estimateMessageTokens, needsCompaction, truncateToolResults, messagesToText, buildCompactPrompt } from "./compact.js";
59+
4960
// Loader (escape hatch)
5061
export { loadAgent } from "./loader.js";

src/hooks.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ export interface HooksConfig {
1515
hooks: {
1616
on_session_start?: HookDefinition[];
1717
pre_tool_use?: HookDefinition[];
18+
post_tool_failure?: HookDefinition[];
1819
post_response?: HookDefinition[];
20+
pre_query?: HookDefinition[];
21+
file_changed?: HookDefinition[];
1922
on_error?: HookDefinition[];
2023
};
2124
}

src/sdk-types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AgentManifest } from "./loader.js";
2+
import type { SessionCosts } from "./cost-tracker.js";
23

34
// ── Message types ──────────────────────────────────────────────────────
45

@@ -64,7 +65,7 @@ export interface GCStreamDelta {
6465

6566
// ── Hook types ─────────────────────────────────────────────────────────
6667

67-
export type GCHookEvent = "SessionStart" | "PreToolUse" | "PostResponse" | "OnError";
68+
export type GCHookEvent = "SessionStart" | "PreToolUse" | "PostToolFailure" | "PreQuery" | "PostResponse" | "FileChanged" | "OnError";
6869

6970
export interface GCHookContext {
7071
sessionId: string;
@@ -87,7 +88,10 @@ export interface GCHookResult {
8788
export interface GCHooks {
8889
onSessionStart?: (ctx: GCHookContext) => Promise<GCHookResult> | GCHookResult;
8990
preToolUse?: (ctx: GCPreToolUseContext) => Promise<GCHookResult> | GCHookResult;
91+
postToolFailure?: (ctx: GCHookContext & { toolName: string; error: string }) => Promise<void> | void;
92+
preQuery?: (ctx: GCHookContext) => Promise<GCHookResult> | GCHookResult;
9093
postResponse?: (ctx: GCHookContext) => Promise<void> | void;
94+
fileChanged?: (ctx: GCHookContext & { path: string }) => Promise<void> | void;
9195
onError?: (ctx: GCHookContext & { error: string }) => Promise<void> | void;
9296
}
9397

@@ -157,4 +161,5 @@ export interface Query extends AsyncGenerator<GCMessage, void, undefined> {
157161
sessionId(): string;
158162
manifest(): AgentManifest;
159163
messages(): GCMessage[];
164+
costs(): SessionCosts;
160165
}

src/sdk.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
QueryOptions,
2323
SandboxOptions,
2424
} from "./sdk-types.js";
25+
import { CostTracker } from "./cost-tracker.js";
2526

2627
// ── Event channel ──────────────────────────────────────────────────────
2728

@@ -82,6 +83,7 @@ export function query(options: QueryOptions): Query {
8283
const channel = createChannel<GCMessage>();
8384
const collectedMessages: GCMessage[] = [];
8485
const ac = options.abortController ?? new AbortController();
86+
const costTracker = new CostTracker();
8587

8688
// These are set once the agent is loaded (async init below)
8789
let _sessionId = options.sessionId ?? "";
@@ -358,6 +360,14 @@ export function query(options: QueryOptions): Query {
358360
};
359361
pushMsg(assistantMsg);
360362

363+
// Track costs per model
364+
if (assistantMsg.usage) {
365+
costTracker.add(
366+
`${assistantMsg.provider}:${assistantMsg.model}`,
367+
assistantMsg.usage,
368+
);
369+
}
370+
361371
// Reset accumulators
362372
accText = "";
363373
accThinking = "";
@@ -487,6 +497,10 @@ export function query(options: QueryOptions): Query {
487497
return [...collectedMessages];
488498
},
489499

500+
costs() {
501+
return costTracker.get();
502+
},
503+
490504
// AsyncGenerator protocol
491505
next() {
492506
return channel.pull();

src/tool-factory.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { AgentTool } from "@mariozechner/pi-agent-core";
2+
import { buildTypeboxSchema } from "./tool-loader.js";
3+
4+
// ── Tool metadata for concurrency, safety, and budget ─────────────────
5+
6+
export interface ToolMetadata {
7+
/** Can run in parallel with other concurrent-safe tools. Default: false (fail-closed) */
8+
isConcurrencySafe?: boolean;
9+
/** Only reads, never writes. Default: false (fail-closed) */
10+
isReadOnly?: boolean;
11+
/** Irreversible action (delete, send). Default: false */
12+
isDestructive?: boolean;
13+
/** Truncate result if larger than this. Default: 50000 chars */
14+
maxResultSizeChars?: number;
15+
}
16+
17+
export interface ToolDefinition<T = any> {
18+
name: string;
19+
description: string;
20+
parameters: Record<string, any>;
21+
execute: (args: T, signal?: AbortSignal) => Promise<string>;
22+
metadata?: ToolMetadata;
23+
}
24+
25+
const TOOL_DEFAULTS: Required<ToolMetadata> = {
26+
isConcurrencySafe: false,
27+
isReadOnly: false,
28+
isDestructive: false,
29+
maxResultSizeChars: 50000,
30+
};
31+
32+
/**
33+
* Build a tool with fail-closed defaults and result truncation.
34+
* Mirrors Claude Code's buildTool() pattern.
35+
*/
36+
export function buildTool<T = any>(def: ToolDefinition<T>): AgentTool<any> & { metadata: Required<ToolMetadata> } {
37+
const metadata: Required<ToolMetadata> = { ...TOOL_DEFAULTS, ...def.metadata };
38+
const schema = buildTypeboxSchema(def.parameters);
39+
40+
return {
41+
name: def.name,
42+
label: def.name,
43+
description: def.description,
44+
parameters: schema,
45+
metadata,
46+
async execute(
47+
_toolCallId: string,
48+
params: T,
49+
signal?: AbortSignal,
50+
) {
51+
let result = await def.execute(params, signal);
52+
if (result.length > metadata.maxResultSizeChars) {
53+
result = result.slice(0, metadata.maxResultSizeChars) +
54+
`\n\n[Truncated: ${result.length} chars total, showing first ${metadata.maxResultSizeChars}]`;
55+
}
56+
return { content: [{ type: "text" as const, text: result }], details: undefined };
57+
},
58+
};
59+
}
60+
61+
/**
62+
* Get metadata for a tool, returning fail-closed defaults if not set.
63+
*/
64+
export function getToolMetadata(tool: AgentTool<any>): Required<ToolMetadata> {
65+
return (tool as any).metadata ?? { ...TOOL_DEFAULTS };
66+
}

0 commit comments

Comments
 (0)