diff --git a/.hermes/plans/harness-auto-skill.md b/.hermes/plans/harness-auto-skill.md new file mode 100644 index 0000000..2294250 --- /dev/null +++ b/.hermes/plans/harness-auto-skill.md @@ -0,0 +1,20 @@ +# Plan: Harness auto-skill — memory spesifik, skill general + +## Koreksi desain +- Sebelumnya: memory project + global layer (_global.json) untuk pattern universal. Salah. +- Seharusnya: memory = spesifik per project (path, command, decision repo itu). Skill = general/universal, auto-create. + +## Goal +Harness yang belajar terus: tiap sesi yang menghasilkan pattern reusable lintas-repo -> otomatis jadi file skill di ~/.shiro-neko/skills/auto-*.md, kebawa di sesi berikut via loadSkills tanpa manual. + +## Perubahan +1. src/memory.ts — hapus global layer (globalFile/globalEntries/addGlobal/renderWithGlobal). Balikkan prompt summarize & suggestFromTranscript ke project-spesifik (keep paths/names, one-off file fix BOLEH disimpan kalau spesifik repo). MAX_TEXT 800, dedup normalize, TTL 90d tetap. +2. src/skill-learner.ts — BARU. suggestSkillsFromTranscript(messages, model) -> SkillCandidate[] { name, description, body }, prompt: generalisasi ke peran (the auth layer, not src/auth.ts), condition->action->reason, skip repo-spesifik. writeAutoSkill(candidate): tulis ke ~/.shiro-neko/skills/auto-.md dengan frontmatter name/description, dedup by name+normalize(body), append section kalau file sudah ada, cap MAX_BODY 20k, best-effort. +3. src/session.ts — import skill-learner, afterTurn hook: maybeLearnSkills() (best-effort, catch, hanya kalau messages.length>=6 dan model ada). Dipanggil di send() finally setelah plugins.afterTurn, atau setelah run selesai. Tanpa block turn. +4. src/cli.tsx — tidak perlu wiring khusus; learner pakai SHIRO_HOME & Bun.write. Pastikan Memory ctor tidak lagi loadGlobal. +5. Test — memory.test tetap hijau (project-only), tambah skill-learner.test untuk dedup & write. + +## Verifikasi +- bun run typecheck +- bun test (790 -> 79x) +- manual: SHIRO_HOME=$(mktemp -d) bun run src/cli.tsx -p "hello" --yolo --json -> cek ~/.shiro-neko/skills/auto-*.md terbuat saat pattern kuat diff --git a/src/cli.tsx b/src/cli.tsx index 48e8bdf..355f069 100644 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -234,8 +234,6 @@ const externalTools = await loadExternalTools(process.cwd(), async (command) => const memory = has('--no-memory') ? undefined : new Memory(process.cwd(), languageModel); if (memory) { await memory.load(); - try { await memory.loadGlobal(); } catch {} - // Best-effort TTL cleanup so lifelong store doesn't bloat with stale 0-hit notes. try { await memory.pruneExpired(); } catch {} } @@ -307,6 +305,8 @@ const session = new Session({ model: languageModel ?? unconfiguredModel, modelId: cfg.model, ...(cfg.subagentModel ? { subagentModelId: cfg.subagentModel } : {}), + ...(subagentModel !== unconfiguredModel ? { learnerModel: subagentModel } : {}), + onNotice: (t) => notices.emit(t), askApproval: bridge.ask, yolo, instructions, diff --git a/src/memory.ts b/src/memory.ts index ede86d2..8aa0e03 100644 --- a/src/memory.ts +++ b/src/memory.ts @@ -32,7 +32,6 @@ const root = () => join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', ' /** One file per project directory; the path is hashed because it is not filename-safe. */ const fileFor = (cwd: string) => join(root(), `${createHash('sha256').update(cwd).digest('hex').slice(0, 16)}.json`); -const globalFile = () => join(root(), '_global.json'); const KIND_LABEL: Record = { fact: 'fact', @@ -71,9 +70,7 @@ function isExpired(e: MemoryEntry, now: number): boolean { */ export class Memory { private entries: MemoryEntry[] = []; - private globalEntries: MemoryEntry[] = []; private loaded = false; - private globalLoaded = false; constructor( private readonly cwd = process.cwd(), @@ -100,39 +97,16 @@ export class Memory { return this.entries; } - async loadGlobal(): Promise { - if (this.globalLoaded) return this.globalEntries; - this.globalLoaded = true; - const f = Bun.file(globalFile()); - if (await f.exists()) { - try { - const parsed: unknown = await f.json(); - if (Array.isArray(parsed)) this.globalEntries = parsed.filter(isEntry); - } catch { - this.globalEntries = []; - } - } - return this.globalEntries; - } all(): MemoryEntry[] { return [...this.entries]; } - allWithGlobal(): MemoryEntry[] { - return [...this.globalEntries, ...this.entries]; - } - private async persist(): Promise { this.entries = this.entries.slice(-MAX_ENTRIES); await Bun.write(fileFor(this.cwd), JSON.stringify(this.entries, null, 2)); } - private async persistGlobal(): Promise { - this.globalEntries = this.globalEntries.slice(-MAX_ENTRIES); - await Bun.write(globalFile(), JSON.stringify(this.globalEntries, null, 2)); - } - async add(kind: MemoryKind, text: string): Promise { await this.load(); const clean = text.trim().slice(0, MAX_TEXT); @@ -152,25 +126,6 @@ export class Memory { return entry; } - /** Add to global layer (cross-project pattern). */ - async addGlobal(kind: MemoryKind, text: string): Promise { - await this.loadGlobal(); - const clean = text.trim().slice(0, MAX_TEXT); - if (!clean) throw new Error('memory text is empty'); - const norm = normalize(clean); - if (this.globalEntries.some((e) => normalize(e.text) === norm)) return undefined; - const entry: MemoryEntry = { - id: Bun.randomUUIDv7(), - kind, - text: clean, - createdAt: new Date().toISOString(), - hits: 0, - }; - this.globalEntries.push(entry); - await this.persistGlobal(); - return entry; - } - async forget(idOrPrefix: string): Promise { await this.load(); const before = this.entries.length; @@ -238,7 +193,6 @@ export class Memory { */ render(limit = BOOT_ENTRIES): string { if (this.entries.length === 0) return ''; - // ensure global is loaded synchronously if already loaded; otherwise project-only const pool = this.entries; const byHits = [...pool].sort((a, b) => b.hits - a.hits || b.createdAt.localeCompare(a.createdAt)); const byRecent = [...pool].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); @@ -282,42 +236,6 @@ export class Memory { ].join('\n'); } - /** Render including global entries (for prompt). Falls back to project-only when global empty. */ - renderWithGlobal(limit = BOOT_ENTRIES): string { - const hasGlobal = this.globalEntries.length > 0; - if (!hasGlobal) return this.render(limit); - const merged = [...this.globalEntries, ...this.entries]; - if (merged.length === 0) return ''; - const byHits = [...merged].sort((a, b) => b.hits - a.hits || b.createdAt.localeCompare(a.createdAt)); - const byRecent = [...merged].sort((a, b) => b.createdAt.localeCompare(a.createdAt)); - const seen = new Set(); - const ranked: MemoryEntry[] = []; - let hi = 0; - let ri = 0; - while (ranked.length < limit && (hi < byHits.length || ri < byRecent.length)) { - if (hi < byHits.length) { - const e = byHits[hi++]!; - if (!seen.has(e.id)) { - seen.add(e.id); - ranked.push(e); - } - if (ranked.length >= limit) break; - } - if (ri < byRecent.length) { - const e = byRecent[ri++]!; - if (!seen.has(e.id)) { - seen.add(e.id); - ranked.push(e); - } - } - } - return [ - '', - 'What you learned about this project in earlier sessions (project + global). Trust but verify:', - ...ranked.slice(0, limit).map((e) => `- (${KIND_LABEL[e.kind]}) ${e.text}`), - ].join('\n'); - } - needsSummary(): boolean { return this.entries.length >= SUMMARISE_AT; } @@ -369,12 +287,15 @@ export class Memory { /** * Suggest 1-3 memory candidates from a transcript. Used as afterTurn hook for - * lifelong learning; caller decides whether to persist (via add/addGlobal). + * lifelong learning; caller decides whether to persist (via add). */ async suggestFromTranscript( messages: { role: string; content: unknown }[], + modelOverride?: LanguageModel, ): Promise<{ kind: MemoryKind; text: string }[]> { - if (!this.model) return []; + const model = modelOverride ?? this.model; + if (!model) return []; + if ((model as unknown as { provider?: string }).provider === 'unconfigured') return []; if (messages.length < 4) return []; try { const slice = messages.slice(-20); @@ -386,10 +307,11 @@ export class Memory { .join('\n') .slice(0, 8000); const { text } = await generateText({ - model: this.model, + model, system: - 'Extract 0-3 durable learnings from this coding session that will still be true next session. ' + - 'Only decisions with reason, traps, or working commands — not narration. ' + + 'Extract 0-3 durable learnings from this coding session that will still be true next session in THIS repo. ' + + 'Keep repo-specific paths/names/commands — they are the value. ' + + 'Only decisions with reason, traps with cause+fix, or working commands — not narration. ' + 'Output one per line as [fact|decision|gotcha|command] text, or empty if nothing durable.', prompt: transcript, maxRetries: 1, @@ -417,14 +339,8 @@ export class Memory { .enum(['fact', 'decision', 'gotcha', 'command']) .describe('fact: how it is. decision: what was chosen and why. gotcha: a trap. command: an invocation that works'), text: z.string().describe('One self-contained line, understandable with no other context'), - scope: z.enum(['project', 'global']).optional().describe('project (default) or global (cross-project pattern)'), }), - execute: async ({ kind, text, scope }) => { - if (scope === 'global') { - const entry = await this.addGlobal(kind, text); - if (!entry) return `Already recorded globally: ${text.trim()}`; - return `Remembered globally as ${entry.kind} (${this.globalEntries.length} global): ${entry.text}`; - } + execute: async ({ kind, text }) => { const entry = await this.add(kind, text); if (!entry) return `Already recorded: ${text.trim()}`; return `Remembered as ${entry.kind} (${this.entries.length} stored): ${entry.text}`; @@ -465,7 +381,7 @@ export class Memory { } } -export { fileFor as memoryFileFor, root as memoryDir, KIND_LABEL, globalFile as globalMemoryFile }; +export { fileFor as memoryFileFor, root as memoryDir, KIND_LABEL }; function isEntry(value: unknown): value is MemoryEntry { if (!value || typeof value !== 'object') return false; diff --git a/src/prune.ts b/src/prune.ts index 422f152..3905a75 100644 --- a/src/prune.ts +++ b/src/prune.ts @@ -246,10 +246,9 @@ function headOf(messages: ModelMessage[]): ModelMessage[] { function withHeadPreserved(all: ModelMessage[], pruned: ModelMessage[]): ModelMessage[] { const head = headOf(all); if (head.length === 0) return pruned; - // If head already in pruned (by identity of content's first 80 chars), leave it. - const headText = JSON.stringify(head[0]!.content).slice(0, 80); - if (pruned.some((m) => JSON.stringify(m.content).slice(0, 80) === headText)) return pruned; - // Prepend head; dedupe if head was partially kept. + // identity + full content equality (not 80-char slice which collides) + const headNorm = JSON.stringify(head[0]!.content); + if (pruned.some((m) => m === head[0]! || JSON.stringify(m.content) === headNorm)) return pruned; return [...head, ...pruned]; } diff --git a/src/session.ts b/src/session.ts index 01fb2f0..3c554be 100644 --- a/src/session.ts +++ b/src/session.ts @@ -19,6 +19,7 @@ import { costOf, formatUsd } from './pricing'; import { systemPrompt } from './prompt'; import { detachProviderItems, droppedSpan, estimateTokens as pruneEstimateTokens, pruneToFit } from './prune'; import { createSkillTool, renderSkills, type Skill } from './skills'; +import { suggestSkillsFromTranscript, writeAutoSkill } from './skill-learner'; import { disabledToolNames, onBashOutput, tools as builtinTools, type ToolSetName } from './tools'; export type ApprovalRequest = { @@ -92,6 +93,12 @@ export type SessionOptions = { /** Live stdout/stderr from bash, for a UI that wants progress. */ onToolOutput?: (id: string, chunk: string) => void; onNotebookChange?: (state: NotebookState) => void; + /** Cheaper model for background learning; falls back to main model. */ + learnerModel?: LanguageModel; + /** Emit learner notices to the UI. */ + onNotice?: (text: string) => void; + /** Disable background auto-learn (tests). */ + disableAutoLearn?: boolean; }; const estimateTokens = pruneEstimateTokens; @@ -141,6 +148,39 @@ async function summarizeDiscarded(span: ModelMessage[], model: LanguageModel): P type ApprovalContext = Pick; +async function maybeLearn( + messages: import('ai').ModelMessage[], + mainModel: import('ai').LanguageModel, + learnerModel: import('ai').LanguageModel | undefined, + memory: import('./memory').Memory | undefined, + spend: () => { overWarn: boolean }, + onNotice?: (t: string) => void, +): Promise { + if (spend().overWarn) return; + const model = learnerModel ?? mainModel; + if ((model as unknown as { provider?: string }).provider === 'unconfigured') return; + // project memory: spesifik repo, keep paths — use cheaper learner model when available + if (memory) { + try { + const cands = await memory.suggestFromTranscript(messages as { role: string; content: unknown }[], model); + // at most 1 per turn to avoid spam; best-effort + if (cands.length > 0) { + const c = cands[0]!; + const added = await memory.add(c.kind, c.text); + if (added && onNotice) onNotice(`auto-memory: remembered (${c.kind}) ${c.text.slice(0, 80)}`); + } + } catch (e) { if (onNotice) onNotice(`auto-memory skipped: ${(e as Error).message?.slice(0, 120)}`); } + } + // general skill: universal pattern + try { + const skills = await suggestSkillsFromTranscript(messages as { role: string; content: unknown }[], model); + for (const c of skills) { + const p = await writeAutoSkill(c); + if (p && onNotice) onNotice(`auto-skill: ${c.name} → ${p}`); + } + } catch (e) { if (onNotice) onNotice(`auto-skill skipped: ${(e as Error).message?.slice(0, 120)}`); } +} + export class Session { readonly messages: ModelMessage[]; readonly tools: ToolSet; @@ -160,6 +200,8 @@ export class Session { /** The 80% spend warning is shown once, not on every turn past the line. */ private warnedSpend = false; private controller: AbortController | undefined; + private learnTurns = 0; + private lastLearnLen = 0; constructor(private readonly opts: SessionOptions) { this.messages = opts.messages ?? []; @@ -304,8 +346,7 @@ export class Session { private systemFor(): string { const mem = this.opts.memory; - // Render project+global when available; fall back to project-only. - const memoryBlock = mem ? (typeof (mem as unknown as { renderWithGlobal?: (n?: number) => string }).renderWithGlobal === 'function' ? (mem as unknown as { renderWithGlobal: (n?: number) => string }).renderWithGlobal() || mem.render() : mem.render()) : ''; + const memoryBlock = mem ? mem.render() : ''; return systemPrompt({ cwd: this.opts.cwd ?? process.cwd(), instructions: this.opts.instructions ?? [], @@ -442,6 +483,19 @@ export class Session { } finally { onBashOutput(undefined); await this.opts.plugins?.afterTurn(); + if (!this.opts.disableAutoLearn && this.messages.length >= 6) { + this.learnTurns += 1; + const delta = this.messages.length - this.lastLearnLen; + const throttled = this.learnTurns % 3 !== 0 && delta < 8; + if (!throttled) { + this.lastLearnLen = this.messages.length; + try { + await maybeLearn(this.messages, this.model, this.opts.learnerModel, this.opts.memory, () => this.spend(), this.opts.onNotice); + } catch {} + // periodic TTL prune so long session doesn't bloat + if (this.learnTurns % 6 === 0 && this.opts.memory) { try { await this.opts.memory.pruneExpired(); } catch {} } + } + } } } diff --git a/src/skill-learner.ts b/src/skill-learner.ts new file mode 100644 index 0000000..9c02fe9 --- /dev/null +++ b/src/skill-learner.ts @@ -0,0 +1,108 @@ +import { generateText, type LanguageModel } from 'ai'; +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export type SkillCandidate = { + name: string; + description: string; + body: string; +}; + +const MAX_BODY = 12_000; + +function slugOf(s: string): string { + return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48) || 'auto-skill'; +} +function normalizeBody(s: string): string { + return s.trim().toLowerCase().replace(/\s+/g, ' ').replace(/^[\p{P}\s]+|[\p{P}\s]+$/gu, ''); +} +function hashBody(s: string): string { + return createHash('sha256').update(normalizeBody(s)).digest('hex').slice(0, 16); +} +function skillDir(): string { + return join(process.env['SHIRO_HOME'] ?? homedir(), '.shiro-neko', 'skills'); +} + +/** Parse candidates: primary ---SKILL block, fallback JSON array */ +function parseCandidates(rawText: string): SkillCandidate[] { + const out: SkillCandidate[] = []; + const re = /---SKILL\s+name:\s*([a-z0-9-]{2,48})\s*---\s*description:\s*(.+?)\s*\nbody:\s*\n([\s\S]*?)\s*---END---/gi; + let m: RegExpExecArray | null; + while ((m = re.exec(rawText)) !== null) { + const name = slugOf(m[1]!); + const description = m[2]!.trim().slice(0, 120); + const body = m[3]!.trim().slice(0, MAX_BODY); + if (body.length < 80) continue; + if (!/^use when\b/i.test(description)) continue; + out.push({ name: `auto-${name}`, description, body }); + if (out.length >= 2) break; + } + if (out.length > 0) return out; + // JSON fallback: [{name, description, body}] + try { + const j = JSON.parse(rawText.trim()); + const arr = Array.isArray(j) ? j : [j]; + for (const e of arr) { + if (!e || typeof e.name !== 'string' || typeof e.description !== 'string' || typeof e.body !== 'string') continue; + const name = slugOf(e.name); + const description = String(e.description).trim().slice(0, 120); + const body = String(e.body).trim().slice(0, MAX_BODY); + if (body.length < 80) continue; + if (!/^use when\b/i.test(description)) continue; + out.push({ name: `auto-${name}`, description, body }); + if (out.length >= 2) break; + } + } catch {} + return out; +} + +export async function suggestSkillsFromTranscript( + messages: { role: string; content: unknown }[], + model: LanguageModel, +): Promise { + if ((model as unknown as { provider?: string }).provider === 'unconfigured') return []; + if (messages.length < 6) return []; + try { + const transcript = messages.slice(-24).map(m => { + const c = typeof m.content === 'string' ? m.content : JSON.stringify(m.content).slice(0, 2000); + return `${m.role}: ${c}`; + }).join('\n').slice(0, 9000); + if (!transcript.trim()) return []; + const { text } = await generateText({ + model, + system: 'You distill UNIVERSAL, cross-repo skills from a coding transcript. Abstract away repo-specific paths/names/IDs into roles (e.g. "the ORM migration" not "src/db/migrate.ts"). Each skill must be a reusable pattern: when to use it, steps, why. Worth keeping for months across projects. Output 0-2 skills, each as:\n---SKILL name: kebab-case---\ndescription: one-line trigger, <80 chars, starts with "Use when ..."\nbody:\nmarkdown body (headings, steps, pitfalls) — no frontmatter, no code dump\n---END---\nIf nothing generalizes beyond this repo, output empty. Be strict. You may also output JSON array [{"name","description","body"}] as fallback.', + prompt: transcript, + maxRetries: 1, + }); + return parseCandidates(text); + } catch { return []; } +} + +export async function writeAutoSkill(candidate: SkillCandidate): Promise { + const dir = skillDir(); + const { mkdirSync } = await import('node:fs'); + try { mkdirSync(dir, { recursive: true }); } catch {} + const path = join(dir, `${candidate.name}.md`); + const safeDesc = candidate.description.replace(/[\n\r]+/g, ' ').replace(/"/g, "'").trim().slice(0, 120); + const frontmatter = `---\nname: ${candidate.name}\ndescription: ${safeDesc}\n---\n\n`; + const content = `${frontmatter}${candidate.body.trim()}\n`; + const candHash = hashBody(candidate.body); + try { + const existing = await Bun.file(path).text(); + // exact hash dedup: body already present in any section + const existingHash = hashBody(existing); + if (existingHash === candHash) return undefined; + // also check if candidate body hash appears as substring section hash + // split existing by ## Update markers and hash each chunk + const chunks = existing.split(/## Update/); + for (const ch of chunks) if (hashBody(ch) === candHash) return undefined; + const appended = `${existing.trimEnd()}\n\n---\n\n## Update \u2014 ${new Date().toISOString().slice(0, 10)}\n\n${candidate.body.trim()}\n`; + if (appended.length > 20_000) return undefined; + await Bun.write(path, appended); + return path; + } catch { + await Bun.write(path, content); + return path; + } +} diff --git a/test/compact.test.ts b/test/compact.test.ts index 949ce45..4feebb9 100644 --- a/test/compact.test.ts +++ b/test/compact.test.ts @@ -365,6 +365,7 @@ test('lossless compaction appends a retained note when tool content was dropped' const session = new Session({ messages: [...messages], compactThreshold: 1500, + disableAutoLearn: true, model: new MockLanguageModelV4({ doStream: async () => stream(text('ok')), doGenerate: async () => { @@ -386,6 +387,7 @@ test('no retained note when history fits', async () => { const session = new Session({ messages: [...bulkyExchange(0)], compactThreshold: 1_000_000, + disableAutoLearn: true, model: new MockLanguageModelV4({ doStream: async () => stream(text('ok')), doGenerate: async () => { @@ -405,6 +407,7 @@ test('a failing retained-note model does not break the turn', async () => { const session = new Session({ messages: [...messages], compactThreshold: 1000, + disableAutoLearn: true, model: new MockLanguageModelV4({ doStream: async () => stream(text('ok')), doGenerate: async () => { throw new Error('down'); },