Skip to content

Commit c979024

Browse files
claudeanandgupta42
authored andcommitted
Polish AI Teammate training UX: auto-lowercase names, update detection, budget visibility
- Fix researcher agent permissions: add training_save/remove (was read-only) - Auto-lowercase + space-to-hyphen name transform in training_save (ARR → arr) - Detect update vs new save, show "Updated" with preserved applied count - Show training budget usage (chars/percent) on save, list, and remove - Improve training_list: group by kind, show most-applied entries, budget % - Improve training_remove: show available entries on not-found, applied count - Show similar entry names in duplicate warnings (not just count) - Raise content limit from 1800 to 2500 chars - Export TRAINING_BUDGET constant, add budgetUsage() to TrainingPrompt - Add 30 new tests: auto-lowercase, update detection, budget overflow, name collision, scale (80 entries), improved messaging - All 118 training tests + 305 memory tests pass https://claude.ai/code/session_01V17Kk3qCZFp9ZJiuNYucoq
1 parent af3bf4c commit c979024

9 files changed

Lines changed: 753 additions & 40 deletions

File tree

packages/opencode/src/agent/agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ export namespace Agent {
252252
altimate_core_check: "allow",
253253
read: "allow", grep: "allow", glob: "allow", bash: "allow",
254254
question: "allow", webfetch: "allow", websearch: "allow",
255-
task: "allow", training_list: "allow",
255+
task: "allow", training_save: "allow", training_list: "allow", training_remove: "allow",
256256
}),
257257
user,
258258
),

packages/opencode/src/altimate/tools/training-list.ts

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export const TrainingListTool = Tool.define("training_list", {
99
"List all learned training entries (patterns, rules, glossary, standards).",
1010
"Shows what your teammate has been taught and how often each entry has been applied.",
1111
"Use this to review training, check what's been learned, or find entries to update/remove.",
12+
"",
13+
"Filter by kind (pattern/rule/glossary/standard) or scope (global/project/all).",
1214
].join("\n"),
1315
parameters: z.object({
1416
kind: TrainingKind.optional().describe("Filter by kind: pattern, rule, glossary, or standard"),
@@ -26,11 +28,14 @@ export const TrainingListTool = Tool.define("training_list", {
2628
const hint = args.kind ? ` of kind "${args.kind}"` : ""
2729
return {
2830
title: "Training: empty",
29-
metadata: { count: 0 },
31+
metadata: { count: 0, budgetPercent: 0 },
3032
output: `No training entries found${hint}. Use /teach to learn from example files, /train to learn from documents, or correct me and I'll offer to save the rule.`,
3133
}
3234
}
3335

36+
// Budget usage
37+
const budget = await TrainingPrompt.budgetUsage()
38+
3439
const counts = await TrainingStore.count()
3540
const summary = [
3641
`## Training Status`,
@@ -43,26 +48,54 @@ export const TrainingListTool = Tool.define("training_list", {
4348
`| Standards | ${counts.standard} |`,
4449
`| **Total** | **${entries.length}** |`,
4550
"",
51+
`**Context budget**: ${budget.used}/${budget.budget} chars (${budget.percent}% full)`,
52+
"",
4653
].join("\n")
4754

48-
const details = entries
49-
.map((e) => {
55+
// Sort by applied count descending for visibility of most-used entries
56+
const sorted = [...entries].sort((a, b) => b.meta.applied - a.meta.applied)
57+
58+
// Find top applied entries for highlight
59+
const topApplied = sorted.filter((e) => e.meta.applied > 0).slice(0, 3)
60+
let highlights = ""
61+
if (topApplied.length > 0) {
62+
highlights =
63+
"**Most applied**: " +
64+
topApplied.map((e) => `\`${e.name}\` (${e.meta.applied}x)`).join(", ") +
65+
"\n\n"
66+
}
67+
68+
// Group by kind for display
69+
const grouped = new Map<string, typeof entries>()
70+
for (const e of entries) {
71+
const list = grouped.get(e.kind) ?? []
72+
list.push(e)
73+
grouped.set(e.kind, list)
74+
}
75+
76+
const sections: string[] = []
77+
for (const kind of ["rule", "pattern", "standard", "glossary"] as const) {
78+
const items = grouped.get(kind)
79+
if (!items || items.length === 0) continue
80+
sections.push(`### ${kind.charAt(0).toUpperCase() + kind.slice(1)}s`)
81+
for (const e of items) {
5082
const applied = e.meta.applied > 0 ? ` (applied ${e.meta.applied}x)` : ""
5183
const source = e.meta.source ? ` — from: ${e.meta.source}` : ""
5284
const scope = e.scope === "global" ? " [global]" : ""
53-
return `- **${e.name}** (${e.kind})${scope}${applied}${source}\n ${e.content.split("\n")[0].slice(0, 100)}`
54-
})
55-
.join("\n")
85+
sections.push(`- **${e.name}**${scope}${applied}${source}\n ${e.content.split("\n")[0].slice(0, 120)}`)
86+
}
87+
sections.push("")
88+
}
5689

5790
return {
5891
title: `Training: ${entries.length} entries`,
59-
metadata: { count: entries.length },
60-
output: summary + details,
92+
metadata: { count: entries.length, budgetPercent: budget.percent },
93+
output: summary + highlights + sections.join("\n"),
6194
}
6295
} catch (e) {
6396
return {
6497
title: "Training List: ERROR",
65-
metadata: { count: 0 },
98+
metadata: { count: 0, budgetPercent: 0 },
6699
output: `Failed to list training: ${e instanceof Error ? e.message : String(e)}`,
67100
}
68101
}

packages/opencode/src/altimate/tools/training-remove.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// altimate_change - Training remove tool for AI Teammate
22
import z from "zod"
33
import { Tool } from "../../tool/tool"
4-
import { TrainingStore } from "../training"
4+
import { TrainingStore, TrainingPrompt } from "../training"
55
import { TrainingKind } from "../training/types"
66

77
export const TrainingRemoveTool = Tool.define("training_remove", {
@@ -17,20 +17,33 @@ export const TrainingRemoveTool = Tool.define("training_remove", {
1717
}),
1818
async execute(args, ctx) {
1919
try {
20+
// Get the entry first so we can show what was removed
21+
const entry = await TrainingStore.get(args.scope, args.kind, args.name)
22+
2023
const removed = await TrainingStore.remove(args.scope, args.kind, args.name)
2124

2225
if (!removed) {
26+
// Help the user find the right name
27+
const available = await TrainingStore.list({ kind: args.kind })
28+
let hint = ""
29+
if (available.length > 0) {
30+
const names = available.map((e) => `\`${e.name}\``).join(", ")
31+
hint = `\n\nAvailable ${args.kind} entries: ${names}`
32+
}
2333
return {
2434
title: "Training: not found",
2535
metadata: { action: "not_found", kind: args.kind, name: args.name },
26-
output: `No training entry found: ${args.kind}/${args.name} in ${args.scope} scope.`,
36+
output: `No training entry found: ${args.kind}/${args.name} in ${args.scope} scope.${hint}`,
2737
}
2838
}
2939

40+
const appliedNote = entry && entry.meta.applied > 0 ? ` It had been applied ${entry.meta.applied} time(s).` : ""
41+
const budget = await TrainingPrompt.budgetUsage()
42+
3043
return {
3144
title: `Training: removed "${args.name}" (${args.kind})`,
3245
metadata: { action: "removed", kind: args.kind, name: args.name },
33-
output: `Removed ${args.kind} "${args.name}" from ${args.scope} training.`,
46+
output: `Removed ${args.kind} "${args.name}" from ${args.scope} training.${appliedNote}\nTraining usage: ${budget.used}/${budget.budget} chars (${budget.percent}% full).`,
3447
}
3548
} catch (e) {
3649
return {

packages/opencode/src/altimate/tools/training-save.ts

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// altimate_change - Training save tool for AI Teammate learning
22
import z from "zod"
33
import { Tool } from "../../tool/tool"
4-
import { TrainingStore } from "../training"
5-
import { TrainingKind, TRAINING_MAX_PATTERNS_PER_KIND } from "../training/types"
4+
import { TrainingStore, TrainingPrompt } from "../training"
5+
import { TrainingKind, TRAINING_MAX_PATTERNS_PER_KIND, TRAINING_BUDGET } from "../training/types"
66
import { CitationSchema } from "../../memory/types"
77

88
export const TrainingSaveTool = Tool.define("training_save", {
@@ -25,15 +25,21 @@ export const TrainingSaveTool = Tool.define("training_save", {
2525
.string()
2626
.min(1)
2727
.max(64)
28-
.regex(/^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/, {
29-
message: "Name must be lowercase alphanumeric with hyphens/underscores",
30-
})
31-
.describe("Short identifier for this training entry (e.g., 'staging-model', 'no-float', 'arr-definition')"),
28+
.transform((s) => s.toLowerCase().replace(/\s+/g, "-"))
29+
.pipe(
30+
z.string().regex(/^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/, {
31+
message:
32+
"Name must be lowercase alphanumeric with hyphens/underscores (e.g., 'staging-model', 'no-float', 'arr')",
33+
}),
34+
)
35+
.describe(
36+
"Short identifier for this training entry (e.g., 'staging-model', 'no-float', 'ARR'). Auto-lowercased.",
37+
),
3238
content: z
3339
.string()
3440
.min(1)
35-
.max(1800)
36-
.describe("The knowledge to save. Be specific and actionable. Use markdown for structure."),
41+
.max(2500)
42+
.describe("The knowledge to save. Be specific and actionable. Use markdown for structure. Max 2500 chars."),
3743
scope: z
3844
.enum(["global", "project"])
3945
.default("project")
@@ -51,12 +57,21 @@ export const TrainingSaveTool = Tool.define("training_save", {
5157
}),
5258
async execute(args, ctx) {
5359
try {
54-
const existing = await TrainingStore.count({ kind: args.kind, scope: args.scope === "global" ? "global" : "project" })
55-
if (existing[args.kind] >= TRAINING_MAX_PATTERNS_PER_KIND) {
56-
return {
57-
title: "Training: limit reached",
58-
metadata: { action: "error" as string, kind: args.kind, name: args.name, scope: args.scope },
59-
output: `Cannot save: already at ${TRAINING_MAX_PATTERNS_PER_KIND} ${args.kind} entries. Remove an existing one first with training_remove.`,
60+
const scopeForCount = args.scope === "global" ? "global" : "project"
61+
62+
// Check if this is an update to an existing entry
63+
const existingEntry = await TrainingStore.get(scopeForCount, args.kind, args.name)
64+
const isUpdate = !!existingEntry
65+
66+
// Only check limit for new entries (not updates)
67+
if (!isUpdate) {
68+
const existing = await TrainingStore.count({ kind: args.kind, scope: scopeForCount })
69+
if (existing[args.kind] >= TRAINING_MAX_PATTERNS_PER_KIND) {
70+
return {
71+
title: "Training: limit reached",
72+
metadata: { action: "error" as string, kind: args.kind, name: args.name, scope: args.scope },
73+
output: `Cannot save: already at ${TRAINING_MAX_PATTERNS_PER_KIND} ${args.kind} entries. Remove an existing one first with training_remove.`,
74+
}
6075
}
6176
}
6277

@@ -69,17 +84,40 @@ export const TrainingSaveTool = Tool.define("training_save", {
6984
citations: args.citations,
7085
})
7186

72-
let output = `Saved ${args.kind} "${args.name}" to ${args.scope} training.`
87+
// Build response with context
88+
let output: string
89+
if (isUpdate) {
90+
const appliedNote = existingEntry.meta.applied > 0 ? ` (preserving ${existingEntry.meta.applied} prior applications)` : ""
91+
output = `Updated ${args.kind} "${args.name}" in ${args.scope} training${appliedNote}.`
92+
} else {
93+
output = `Saved ${args.kind} "${args.name}" to ${args.scope} training.`
94+
}
95+
7396
if (args.scope === "project") {
7497
output += "\nThis will be shared with your team when committed to git."
7598
}
99+
100+
// Show budget usage
101+
const budgetUsed = await TrainingPrompt.budgetUsage()
102+
output += `\nTraining usage: ${budgetUsed.used}/${budgetUsed.budget} chars (${budgetUsed.percent}% full).`
103+
if (budgetUsed.percent >= 80) {
104+
output += "\n⚠ Training is getting full. Oldest entries may not fit in context. Consider consolidating."
105+
}
106+
107+
// Show duplicate details
76108
if (duplicates.length > 0) {
77-
output += `\n\nNote: Found ${duplicates.length} similar training block(s). Consider consolidating.`
109+
const dupNames = duplicates
110+
.map((d) => {
111+
const parts = d.id.split("/")
112+
return `\`${parts.slice(1).join("/")}\``
113+
})
114+
.join(", ")
115+
output += `\n\nSimilar entries found: ${dupNames}. Run training_remove to consolidate if these are duplicates.`
78116
}
79117

80118
return {
81-
title: `Training: saved "${args.name}" (${args.kind})`,
82-
metadata: { action: "saved" as string, kind: args.kind, name: args.name, scope: args.scope },
119+
title: `Training: ${isUpdate ? "updated" : "saved"} "${args.name}" (${args.kind})`,
120+
metadata: { action: isUpdate ? "updated" : "saved", kind: args.kind, name: args.name, scope: args.scope },
83121
output,
84122
}
85123
} catch (e) {

packages/opencode/src/altimate/training/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export {
66
TRAINING_TAG,
77
TRAINING_ID_PREFIX,
88
TRAINING_MAX_PATTERNS_PER_KIND,
9+
TRAINING_BUDGET,
910
trainingId,
1011
trainingTags,
1112
isTrainingBlock,

packages/opencode/src/altimate/training/prompt.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
// altimate_change - Training prompt injection for AI Teammate learned knowledge
22
import { TrainingStore, type TrainingEntry } from "./store"
3-
import type { TrainingKind } from "./types"
4-
5-
const TRAINING_BUDGET = 6000
3+
import { TRAINING_BUDGET, type TrainingKind } from "./types"
64

75
const KIND_HEADERS: Record<TrainingKind, { header: string; instruction: string }> = {
86
pattern: {
@@ -66,4 +64,18 @@ export namespace TrainingPrompt {
6664

6765
return result
6866
}
67+
68+
export async function budgetUsage(budget: number = TRAINING_BUDGET): Promise<{
69+
used: number
70+
budget: number
71+
percent: number
72+
}> {
73+
const injected = await inject(budget)
74+
const used = injected.length
75+
return {
76+
used,
77+
budget,
78+
percent: budget > 0 ? Math.round((used / budget) * 100) : 0,
79+
}
80+
}
6981
}

packages/opencode/src/altimate/training/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import z from "zod"
44
export const TRAINING_TAG = "training"
55
export const TRAINING_ID_PREFIX = "training"
66
export const TRAINING_MAX_PATTERNS_PER_KIND = 20
7+
export const TRAINING_BUDGET = 6000
78

89
export const TrainingKind = z.enum(["pattern", "rule", "glossary", "standard"])
910
export type TrainingKind = z.infer<typeof TrainingKind>

packages/opencode/test/training/tools.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -153,13 +153,13 @@ describe("TRAINING_MAX_PATTERNS_PER_KIND", () => {
153153
})
154154

155155
describe("content length validation", () => {
156-
test("content within 1800 chars is acceptable", () => {
157-
const content = "x".repeat(1800)
158-
expect(content.length).toBeLessThanOrEqual(1800)
156+
test("content within 2500 chars is acceptable", () => {
157+
const content = "x".repeat(2500)
158+
expect(content.length).toBeLessThanOrEqual(2500)
159159
})
160160

161-
test("content over 1800 chars should be rejected by tool", () => {
162-
const content = "x".repeat(1801)
163-
expect(content.length).toBeGreaterThan(1800)
161+
test("content over 2500 chars should be rejected by tool", () => {
162+
const content = "x".repeat(2501)
163+
expect(content.length).toBeGreaterThan(2500)
164164
})
165165
})

0 commit comments

Comments
 (0)