From a2a56a006292a2c5e809ad6e4a3ca368fdfabaeb Mon Sep 17 00:00:00 2001 From: oratis Date: Sat, 8 Aug 2026 18:18:29 +0800 Subject: [PATCH] feat(cli): add /combo to distil a thread into a skill draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skill system was already complete — loader, frontmatter schema, three source layers, overrides. What was missing was the generating end: every SKILL.md had to be hand-written up front, before anyone knew what the work would involve. Floatboat's insight is that automation should be extracted after the work, not configured before it. The moment someone finishes a task is the moment they understand it best. The security angle Floatboat does not advertise: deriving `allowed-tools` from what the thread actually called yields least privilege for free. Hand-written skills are almost always broader than needed, because guessing generously is easier than auditing. `/combo` previews and writes nothing; `--write` commits. A skill assembled from a transcript is a shareable artifact, so it gets read before it exists on disk. Writing one lands on the governance timeline — creating a skill changes what future runs may do. Two filters on the way out. Credential-shaped values become [REDACTED], and paths the file contract denies reading are dropped entirely: a rule that stops at the tool call but not at the export is not much of a rule, since the filename alone leaks. Both report what was withheld rather than leaving the user to guess. Distillation is pure and offline. Model prose is optional; without it the deterministic body is a real draft rather than a placeholder, because the step sequence and touched files are exactly recoverable and are what a reader needs. Explicitly not built: Floatboat's Tacit Engine passively observes files, browser tabs and system apps to model habits. /combo reads the current thread, only when typed, and never aggregates across threads. The useful half of the idea needs no passive collection. Skill names are sanitized to a single path segment — the name becomes a directory under .deepcode/skills/, so `../escape` must not survive. Co-Authored-By: Claude Opus 5 --- apps/cli/src/commands.test.ts | 84 ++++++++- apps/cli/src/commands.ts | 85 +++++++++ docs/combo.md | 71 ++++++++ packages/core/src/index.ts | 8 + packages/core/src/skills/distill.test.ts | 199 +++++++++++++++++++++ packages/core/src/skills/distill.ts | 209 +++++++++++++++++++++++ packages/core/src/skills/index.ts | 7 + 7 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 docs/combo.md create mode 100644 packages/core/src/skills/distill.test.ts create mode 100644 packages/core/src/skills/distill.ts diff --git a/apps/cli/src/commands.test.ts b/apps/cli/src/commands.test.ts index 33acbce..8653052 100644 --- a/apps/cli/src/commands.test.ts +++ b/apps/cli/src/commands.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { tmpdir } from 'node:os'; @@ -539,3 +539,85 @@ describe('inspector + export commands', () => { expect(out.join('\n')).toMatch(/Nothing to compact/); }); }); + +describe('/combo', () => { + let cwd: string; + let home: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), 'dc-combo-cwd-')); + home = await mkdtemp(join(tmpdir(), 'dc-combo-home-')); + }); + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + await rm(home, { recursive: true, force: true }); + }); + + const reg = new CommandRegistry(); + + function threadContext() { + return makeContext({ + cwd, + home, + history: [ + { role: 'user', content: [{ type: 'text', text: 'fix the auth bug' }], timestamp: 'now' }, + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 't1', name: 'Read', input: { file_path: 'src/auth.ts' } }, + { type: 'tool_use', id: 't2', name: 'Edit', input: { file_path: 'src/auth.ts' } }, + ], + timestamp: 'now', + }, + ], + }); + } + + async function run(args: string[], ctx = threadContext()) { + const match = reg.match('/combo'); + if (!match) throw new Error('/combo not registered'); + return { lines: await match.cmd.run(args, ctx), ctx }; + } + + it('previews without writing anything', async () => { + // A skill is a shareable artifact built from a transcript. The user reads it + // before it exists on disk. + const { lines } = await run([]); + expect(lines.join('\n')).toContain('Draft skill'); + expect(lines.join('\n')).toContain('--write'); + await expect( + readFile(join(cwd, '.deepcode', 'skills', 'fix-the-auth-bug', 'SKILL.md'), 'utf8'), + ).rejects.toThrow(); + }); + + it('shows the derived allowed-tools in the preview', async () => { + const { lines } = await run([]); + expect(lines.join('\n')).toContain('Edit, Read'); + }); + + it('writes the skill on --write and logs it as governance', async () => { + const { lines } = await run(['--write']); + expect(lines.join('\n')).toContain('Wrote'); + const written = await readFile( + join(cwd, '.deepcode', 'skills', 'fix-the-auth-bug', 'SKILL.md'), + 'utf8', + ); + expect(written).toContain('allowed-tools'); + expect(written).toContain('TODO: review before use'); + + const { readProjectLedger } = await import('@deepcode/core'); + const gov = await readProjectLedger(cwd, 'governance', home); + expect(gov[0]?.summary).toContain('created skill'); + }); + + it('refuses to overwrite an existing skill', async () => { + await run(['--write']); + const { lines } = await run(['--write']); + expect(lines.join('\n')).toContain('already exists'); + }); + + it('says there is nothing to distil on an empty thread', async () => { + const { lines } = await run([], makeContext({ cwd, home, history: [] })); + expect(lines.join('\n')).toContain('Nothing to distil'); + }); +}); diff --git a/apps/cli/src/commands.ts b/apps/cli/src/commands.ts index a4c962a..cd5b8b8 100644 --- a/apps/cli/src/commands.ts +++ b/apps/cli/src/commands.ts @@ -1310,6 +1310,90 @@ export const BackgroundCommand: SlashCommand = { }, }; +/** + * `/combo [name]` — distil the current thread into a SKILL.md draft. + * + * Two properties this deliberately keeps: + * + * - `allowed-tools` comes from what the thread actually called, not from a + * guess. Hand-written skills are almost always broader than needed. + * - Nothing is written without the user seeing the draft first, and a second + * `/combo --write` is what commits it. `/combo` alone previews. + */ +export const ComboCommand: SlashCommand = { + name: '/combo', + description: 'Distil this thread into a reusable skill draft (/combo [name] [--write]).', + async run(args, ctx) { + const history = ctx.history ?? []; + if (history.length === 0) return ['Nothing to distil yet — do some work first.']; + + const { distillSkill, loadFileContract, FileLedger } = await import('@deepcode/core'); + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + + const write = args.includes('--write'); + const name = args.find((a) => !a.startsWith('--')); + const contract = await loadFileContract({ cwd: ctx.cwd, home: ctx.home }); + + const skill = distillSkill({ + history, + ...(name ? { name } : {}), + cwd: ctx.cwd, + ...(contract.contract ? { contract: contract.contract } : {}), + model: ctx.model, + effort: ctx.effort, + }); + + const target = path.join(ctx.cwd, '.deepcode', 'skills', skill.name, 'SKILL.md'); + const lines: string[] = []; + + if (!write) { + // Preview first. A skill is a shareable artifact assembled from a + // transcript, so the user reads it before it exists on disk. + lines.push(`Draft skill "${skill.name}" → ${target}`, ''); + lines.push(`allowed-tools (from actual use): ${skill.allowedTools.join(', ') || 'none'}`); + if (skill.paths.length > 0) lines.push(`files: ${skill.paths.join(', ')}`); + for (const note of skill.redactions) lines.push(`withheld: ${note}`); + lines.push('', '---8<---', skill.content.trimEnd(), '---8<---', ''); + lines.push(`Write it with: /combo ${skill.name} --write`); + return lines; + } + + try { + await fs.access(target); + return [`${target} already exists — rename with /combo --write.`]; + } catch { + /* absent, which is the normal path */ + } + + try { + await fs.mkdir(path.dirname(target), { recursive: true }); + await fs.writeFile(target, skill.content, 'utf8'); + } catch (err) { + return [`(Could not write the skill: ${(err as Error).message})`]; + } + + // Creating a skill changes what future runs may do, which puts it on the + // governance timeline rather than the ordinary change stream. + await new FileLedger({ cwd: ctx.cwd, ...(ctx.home ? { home: ctx.home } : {}) }).append( + 'governance', + { + actor: 'user', + intent: `distil thread ${ctx.sessionId} into a skill`, + paths: [path.relative(ctx.cwd, target)], + summary: `created skill "${skill.name}" from a thread`, + rollbackHint: { kind: 'manual', ref: `delete ${target}` }, + }, + ); + + lines.push(`✓ Wrote ${target}`); + lines.push(` allowed-tools: ${skill.allowedTools.join(', ') || 'none'}`); + for (const note of skill.redactions) lines.push(` withheld: ${note}`); + lines.push(' Review it before relying on it — it was generated from a transcript.'); + return lines; + }, +}; + export const BUILTIN_COMMANDS: SlashCommand[] = [ HelpCommand, ClearCommand, @@ -1334,6 +1418,7 @@ export const BUILTIN_COMMANDS: SlashCommand[] = [ PermissionsCommand, AgentsCommand, SkillsCommand, + ComboCommand, ExportCommand, CompactCommand, DiffCommand, diff --git a/docs/combo.md b/docs/combo.md new file mode 100644 index 0000000..f5af6b3 --- /dev/null +++ b/docs/combo.md @@ -0,0 +1,71 @@ +# Combo — turning a thread into a skill + +`/combo` distils the work you just finished into a reusable `SKILL.md` draft. + +``` +/combo # preview the draft +/combo my-name --write # write it to .deepcode/skills//SKILL.md +``` + +## Why after, not before + +DeepCode's skill system was already complete — the loader, the frontmatter +schema, three source layers, overrides. What was missing was the _generating_ +end: every `SKILL.md` had to be written by hand, up front, before anyone knew +what the work would involve. + +That's backwards. The moment you finish a task is the moment you understand it +best. `/combo` extracts the automation _after_ the work rather than asking you to +configure it before. + +## `allowed-tools` is derived, not guessed + +The draft's `allowed-tools` lists exactly the tools the thread actually called. + +This matters more than it looks. Hand-written skills are almost always broader +than they need to be, because guessing generously is easier than auditing — a +skill that only reads files ends up with `Bash` in its list "just in case". +Deriving the set from a real run gives you least privilege for free. + +## Nothing is written until you've seen it + +`/combo` alone **prints the draft and writes nothing.** A second invocation with +`--write` commits it. A skill assembled from a transcript is a shareable +artifact, so you read it before it exists on disk. + +Writing one is recorded on the [governance ledger](change-ledger.md) — creating a +skill changes what future runs may do, which is not an ordinary file edit. + +`--write` refuses to overwrite an existing skill. Pick another name. + +## What never makes it into the draft + +**Credential-shaped values** are replaced with `[REDACTED]`: API keys, GitHub +tokens, AWS key ids, JWTs, PEM private keys, and `password:`/`token:`-style +assignments. + +**Paths your [file contract](file-contract.md) denies reading** are dropped +entirely. A rule that stops at the tool call but not at the export isn't much of +a rule — the filename alone leaks. + +Either way the draft tells you what was withheld, so you're not guessing: + +``` +withheld: redacted API key +withheld: path excluded by file contract: .env +``` + +## The draft is a draft + +It carries a `# TODO: review before use` marker and is not auto-enabled. It was +generated from a transcript; read it before you rely on it. + +## What this is not + +Floatboat's Combo sits on a "Tacit Engine" that passively observes activity +across files, browser tabs, and system apps to build a habit model. **DeepCode +does not do that.** `/combo` reads the current thread, only when you type it, +and never aggregates across threads or runs in the background. + +The useful part of the idea — extract the automation after the work — needs no +passive collection at all. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c4292b3..7221af7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -562,3 +562,11 @@ export { type BuildRuntimeCapabilitiesInput, type RuntimeCapabilities, } from './runtime/capabilities.js'; + +// Combo — distil a finished thread into a SKILL.md draft (plan §2.D) +export { + distillSkill, + sanitizeSkillName, + type DistilledSkill, + type DistillOpts, +} from './skills/index.js'; diff --git a/packages/core/src/skills/distill.test.ts b/packages/core/src/skills/distill.test.ts new file mode 100644 index 0000000..4eaee39 --- /dev/null +++ b/packages/core/src/skills/distill.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; +import { parseFileContract } from '../config/file-contract.js'; +import { parseFrontmatter } from './frontmatter.js'; +import { distillSkill, sanitizeName } from './distill.js'; +import type { StoredMessage } from '../types.js'; + +const CWD = '/work/repo'; + +function user(text: string): StoredMessage { + return { role: 'user', content: [{ type: 'text', text }], timestamp: '2026-08-08T00:00:00.000Z' }; +} + +function assistantToolUse( + calls: Array<{ name: string; input: Record }>, +): StoredMessage { + return { + role: 'assistant', + content: calls.map((c, i) => ({ + type: 'tool_use' as const, + id: `t-${i}`, + name: c.name, + input: c.input, + })), + timestamp: '2026-08-08T00:00:01.000Z', + }; +} + +describe('distillSkill', () => { + it('derives allowed-tools from what the thread actually called', () => { + // The least-privilege win: a hand-written skill would almost certainly list + // more than this, because guessing generously is easier than auditing. + const skill = distillSkill({ + cwd: CWD, + history: [ + user('fix the auth bug'), + assistantToolUse([ + { name: 'Read', input: { file_path: 'src/auth.ts' } }, + { name: 'Edit', input: { file_path: 'src/auth.ts' } }, + { name: 'Read', input: { file_path: 'src/auth.ts' } }, + ]), + ], + }); + expect(skill.allowedTools).toEqual(['Edit', 'Read']); + expect(skill.allowedTools).not.toContain('Bash'); + }); + + it('produces frontmatter the existing skills loader can parse', () => { + // A draft the loader rejects is worthless, so this asserts against the real + // parser rather than a regex. + const skill = distillSkill({ + cwd: CWD, + history: [user('fix the auth bug'), assistantToolUse([{ name: 'Read', input: {} }])], + model: 'deepseek-chat', + effort: 'high', + }); + const { fields } = parseFrontmatter(skill.content); + expect(fields.name).toBe(skill.name); + expect(fields.description).toBeTruthy(); + expect(fields['allowed-tools']).toEqual(['Read']); + expect(fields.model).toBe('deepseek-chat'); + expect(fields.effort).toBe('high'); + }); + + it('marks the draft as needing review', () => { + const skill = distillSkill({ cwd: CWD, history: [user('do a thing')] }); + expect(skill.content).toContain('TODO: review before use'); + }); + + it('names the skill from the request when none is given', () => { + const skill = distillSkill({ cwd: CWD, history: [user('Fix the auth bug in prod!')] }); + expect(skill.name).toBe('fix-the-auth-bug'); + }); + + it('honours an explicit name, sanitized', () => { + const skill = distillSkill({ cwd: CWD, history: [user('x')], name: 'My Cool Skill!' }); + expect(skill.name).toBe('my-cool-skill'); + }); + + it('records the files involved, workspace-relative', () => { + const skill = distillSkill({ + cwd: CWD, + history: [ + user('go'), + assistantToolUse([{ name: 'Edit', input: { file_path: '/work/repo/src/a.ts' } }]), + ], + }); + expect(skill.paths).toEqual(['src/a.ts']); + }); + + describe('what it refuses to carry into a shareable file', () => { + it('excludes paths the contract denies reading', () => { + // A rule that stops at the tool call but not at the export is not much of + // a rule — the filename alone leaks. + const contract = parseFileContract( + 'version: 1\nrules:\n - glob: "**/.env*"\n read: deny\n', + ); + const skill = distillSkill({ + cwd: CWD, + contract, + history: [ + user('go'), + assistantToolUse([ + { name: 'Read', input: { file_path: '.env' } }, + { name: 'Read', input: { file_path: 'src/a.ts' } }, + ]), + ], + }); + expect(skill.paths).toEqual(['src/a.ts']); + expect(skill.content).not.toContain('.env'); + expect(skill.redactions.join(' ')).toContain('file contract'); + }); + + it.each([ + ['API key', 'here is sk-abcdefghijklmnopqrstuvwxyz012345'], + ['GitHub token', 'use ghp_abcdefghijklmnopqrstuvwxyz0123456789'], + ['AWS access key id', 'AKIAIOSFODNN7EXAMPLE is the id'], + ['credential assignment', 'password: hunter2correcthorse'], + ['private key', '-----BEGIN RSA PRIVATE KEY-----'], + ])('redacts a %s from the request text', (_label, secret) => { + const skill = distillSkill({ cwd: CWD, history: [user(secret)] }); + expect(skill.content).toContain('[REDACTED]'); + expect(skill.redactions.length).toBeGreaterThan(0); + }); + + it('names what it withheld, so the user is not guessing', () => { + const skill = distillSkill({ + cwd: CWD, + history: [user('key sk-abcdefghijklmnopqrstuvwxyz012345')], + }); + expect(skill.redactions.join(' ')).toContain('API key'); + }); + + it('ignores paths outside the workspace', () => { + const skill = distillSkill({ + cwd: CWD, + history: [ + user('go'), + assistantToolUse([{ name: 'Read', input: { file_path: '/etc/passwd' } }]), + ], + }); + expect(skill.paths).toEqual([]); + }); + }); + + it('uses model prose when supplied, and still redacts it', () => { + const skill = distillSkill({ + cwd: CWD, + history: [user('go')], + prose: { description: 'Nice summary', body: 'token: ghp_abcdefghijklmnopqrstuvwxyz01' }, + }); + expect(skill.content).toContain('Nice summary'); + expect(skill.content).toContain('[REDACTED]'); + }); + + it('falls back to a useful deterministic body without a model', () => { + // The fallback is a real draft, not a placeholder: the step sequence and + // touched files are recoverable exactly, and they are what a reader needs. + const skill = distillSkill({ + cwd: CWD, + history: [ + user('fix the auth bug'), + assistantToolUse([ + { name: 'Read', input: { file_path: 'src/auth.ts' } }, + { name: 'Edit', input: { file_path: 'src/auth.ts' } }, + ]), + ], + }); + expect(skill.content).toContain('Steps taken'); + expect(skill.content).toContain('Read'); + expect(skill.content).toContain('`src/auth.ts`'); + }); + + it('handles an empty thread without throwing', () => { + const skill = distillSkill({ cwd: CWD, history: [] }); + expect(skill.name).toBe('untitled-combo'); + expect(skill.allowedTools).toEqual([]); + }); +}); + +describe('sanitizeName', () => { + it.each([ + ['My Skill', 'my-skill'], + ['../../etc/passwd', 'etc-passwd'], + ['a/b/c', 'a-b-c'], + ['---', 'untitled-combo'], + ['', 'untitled-combo'], + ])('%s → %s', (input, expected) => { + expect(sanitizeName(input)).toBe(expected); + }); + + it('never produces a path separator or traversal', () => { + // The name becomes a directory under .deepcode/skills/. + for (const hostile of ['../escape', 'a/../../b', './x']) { + const out = sanitizeName(hostile); + expect(out).not.toContain('/'); + expect(out).not.toContain('..'); + } + }); +}); diff --git a/packages/core/src/skills/distill.ts b/packages/core/src/skills/distill.ts new file mode 100644 index 0000000..0e99d62 --- /dev/null +++ b/packages/core/src/skills/distill.ts @@ -0,0 +1,209 @@ +// Combo — turn a finished thread into a reusable SKILL.md draft. +// Plan: docs/FLOATBOAT_ADOPTION_PLAN.md §2.D +// +// The skill loader, its frontmatter schema, and the three source layers all +// already exist. What was missing is the generating end: every SKILL.md had to +// be written by hand, up front, before anyone knew what the work involved. +// +// Floatboat's insight is that the automation should be extracted *after* the +// work, not configured before it — the moment someone finishes a task is the +// moment they understand it best. +// +// The security angle, which Floatboat does not advertise: deriving +// `allowed-tools` from what the thread actually used yields a least-privilege +// set for free. Hand-written skills are almost always broader than needed, +// because guessing generously is easier than auditing. + +import { normalizeContractPath, type FileContract } from '../config/file-contract.js'; +import { evaluatePath } from '../config/file-contract.js'; +import type { StoredMessage, ToolUseBlock } from '../types.js'; + +export interface DistilledSkill { + /** Directory-safe skill name. */ + name: string; + /** Full SKILL.md contents, frontmatter included. */ + content: string; + /** Tools the thread actually called — the derived least-privilege set. */ + allowedTools: string[]; + /** Paths mentioned, after contract and secret filtering. */ + paths: string[]; + /** Anything removed, so the user learns what was withheld. */ + redactions: string[]; +} + +export interface DistillOpts { + history: StoredMessage[]; + /** Skill name; derived from the first request when absent. */ + name?: string; + cwd: string; + /** Contract used to exclude paths the agent may not read. */ + contract?: FileContract; + model?: string; + effort?: string; + /** Optional prose from a model; a deterministic fallback is used without it. */ + prose?: { description?: string; body?: string }; +} + +/** Values that look like credentials and must never reach a shareable file. */ +const SECRET_PATTERNS: Array<{ re: RegExp; label: string }> = [ + { re: /\bsk-[A-Za-z0-9_-]{16,}/g, label: 'API key' }, + { re: /\bgh[pousr]_[A-Za-z0-9]{20,}/g, label: 'GitHub token' }, + { re: /\bAKIA[0-9A-Z]{16}\b/g, label: 'AWS access key id' }, + { re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, label: 'JWT' }, + { re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g, label: 'private key' }, + { + re: /\b(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*\S+/gi, + label: 'credential assignment', + }, +]; + +/** + * Distil a thread into a SKILL.md draft. + * + * Pure: no filesystem, no network. The caller writes the file, and only after + * the user has seen it. + */ +export function distillSkill(opts: DistillOpts): DistilledSkill { + const redactions: string[] = []; + const firstRequest = firstUserText(opts.history); + const name = sanitizeName(opts.name ?? deriveName(firstRequest)); + + const toolUses = collectToolUses(opts.history); + const allowedTools = [...new Set(toolUses.map((t) => t.name))].sort(); + const paths = collectPaths(toolUses, opts, redactions); + + const description = redact( + opts.prose?.description ?? deriveDescription(firstRequest), + redactions, + ); + const body = redact(opts.prose?.body ?? deriveBody(firstRequest, toolUses, paths), redactions); + + const frontmatter = [ + '---', + `name: ${name}`, + `description: ${JSON.stringify(description)}`, + // Derived from actual use, which is narrower than anyone writes by hand. + `allowed-tools: [${allowedTools.map((t) => JSON.stringify(t)).join(', ')}]`, + ...(opts.model ? [`model: ${opts.model}`] : []), + ...(opts.effort ? [`effort: ${opts.effort}`] : []), + '---', + ].join('\n'); + + const content = `${frontmatter}\n\n# TODO: review before use — generated from a thread by \`/combo\`.\n\n${body}\n`; + + return { name, content, allowedTools, paths, redactions }; +} + +function firstUserText(history: StoredMessage[]): string { + for (const message of history) { + if (message.role !== 'user') continue; + for (const block of message.content) { + if (block.type === 'text' && block.text.trim()) return block.text.trim(); + } + } + return ''; +} + +function collectToolUses(history: StoredMessage[]): ToolUseBlock[] { + const out: ToolUseBlock[] = []; + for (const message of history) { + for (const block of message.content) { + if (block.type === 'tool_use') out.push(block); + } + } + return out; +} + +/** + * Paths the thread touched, minus anything the contract forbids reading. + * + * A skill is a shareable artifact. Naming a file the agent was not allowed to + * read would leak through the filename alone, so the contract's `deny` has to + * apply here too — a rule that stops at the tool call but not at the export is + * not much of a rule. + */ +function collectPaths(toolUses: ToolUseBlock[], opts: DistillOpts, redactions: string[]): string[] { + const seen = new Set(); + for (const use of toolUses) { + for (const field of ['file_path', 'notebook_path', 'path']) { + const raw = (use.input as Record)[field]; + if (typeof raw !== 'string' || !raw) continue; + const rel = normalizeContractPath(opts.cwd, raw); + if (rel === null) continue; + if (opts.contract) { + const verdict = evaluatePath(opts.contract, { path: rel, action: 'read' }).verdict; + if (verdict === 'deny') { + redactions.push(`path excluded by file contract: ${rel}`); + continue; + } + } + seen.add(rel); + } + } + return [...seen].sort(); +} + +/** Replace anything credential-shaped, recording what was removed. */ +export function redact(text: string, redactions: string[]): string { + let out = text; + for (const { re, label } of SECRET_PATTERNS) { + out = out.replace(re, () => { + redactions.push(`redacted ${label}`); + return '[REDACTED]'; + }); + } + return out; +} + +function deriveName(request: string): string { + const words = request + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, ' ') + .split(/\s+/) + .filter(Boolean) + .slice(0, 4); + return words.length > 0 ? words.join('-') : 'untitled-combo'; +} + +export function sanitizeName(name: string): string { + const cleaned = name + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + return cleaned || 'untitled-combo'; +} + +function deriveDescription(request: string): string { + if (!request) return 'Distilled from a DeepCode thread.'; + const firstLine = request.split('\n')[0]!.trim(); + return firstLine.length > 180 ? `${firstLine.slice(0, 179)}…` : firstLine; +} + +/** + * The deterministic draft body. + * + * Used verbatim when no model prose is supplied, and it is a real fallback + * rather than a placeholder: the tool sequence and touched files are the parts + * a reader most needs, and they are recoverable exactly. Only the prose + * benefits from a model. + */ +function deriveBody(request: string, toolUses: ToolUseBlock[], paths: string[]): string { + const lines: string[] = []; + if (request) { + lines.push('## What this does', '', request, ''); + } + if (toolUses.length > 0) { + lines.push('## Steps taken', ''); + const counts = new Map(); + for (const use of toolUses) counts.set(use.name, (counts.get(use.name) ?? 0) + 1); + for (const [tool, count] of counts) { + lines.push(`- ${tool}${count > 1 ? ` ×${count}` : ''}`); + } + lines.push(''); + } + if (paths.length > 0) { + lines.push('## Files involved', '', ...paths.map((p) => `- \`${p}\``), ''); + } + return lines.join('\n').trim() || 'No recorded activity to distil.'; +} diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts index 5ce717e..eda4a83 100644 --- a/packages/core/src/skills/index.ts +++ b/packages/core/src/skills/index.ts @@ -13,3 +13,10 @@ export { export { parseFrontmatter, parseSimpleYaml, type Frontmatter } from './frontmatter.js'; export { makeSkillTool } from './tool.js'; +export { + distillSkill, + redact as redactSkillText, + sanitizeName as sanitizeSkillName, + type DistilledSkill, + type DistillOpts, +} from './distill.js';