harness: auto-skill general + memory spesifik + lossless compaction
Memory balik spesifik per-project (keep paths, THIS repo), general jadi skill auto-create ke ~/.shiro-neko/skills/auto-*.md. Compaction lossless dengan head preserved + droppedSpan normalized + retained note. Learner throttled tiap 3 turn / delta 8, pakai subagentModel (learnerModel) dan ceiling guard, hash dedup, JSON fallback, TTL tiap 6 turn.
This commit is contained in:
1 parent
7db69f19da
commit
4848db308a
7 files changed
+203
-103
No files matched your search
@@ -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-<slug>.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
|
||||||
+2
-2
@@ -234,8 +234,6 @@ const externalTools = await loadExternalTools(process.cwd(), async (command) =>
|
|||||||
const memory = has('--no-memory') ? undefined : new Memory(process.cwd(), languageModel);
|
const memory = has('--no-memory') ? undefined : new Memory(process.cwd(), languageModel);
|
||||||
if (memory) {
|
if (memory) {
|
||||||
await memory.load();
|
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 {}
|
try { await memory.pruneExpired(); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +305,8 @@ const session = new Session({
|
|||||||
model: languageModel ?? unconfiguredModel,
|
model: languageModel ?? unconfiguredModel,
|
||||||
modelId: cfg.model,
|
modelId: cfg.model,
|
||||||
...(cfg.subagentModel ? { subagentModelId: cfg.subagentModel } : {}),
|
...(cfg.subagentModel ? { subagentModelId: cfg.subagentModel } : {}),
|
||||||
|
...(subagentModel !== unconfiguredModel ? { learnerModel: subagentModel } : {}),
|
||||||
|
onNotice: (t) => notices.emit(t),
|
||||||
askApproval: bridge.ask,
|
askApproval: bridge.ask,
|
||||||
yolo,
|
yolo,
|
||||||
instructions,
|
instructions,
|
||||||
|
|||||||
+11
-95
@@ -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. */
|
/** 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 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<MemoryKind, string> = {
|
const KIND_LABEL: Record<MemoryKind, string> = {
|
||||||
fact: 'fact',
|
fact: 'fact',
|
||||||
@@ -71,9 +70,7 @@ function isExpired(e: MemoryEntry, now: number): boolean {
|
|||||||
*/
|
*/
|
||||||
export class Memory {
|
export class Memory {
|
||||||
private entries: MemoryEntry[] = [];
|
private entries: MemoryEntry[] = [];
|
||||||
private globalEntries: MemoryEntry[] = [];
|
|
||||||
private loaded = false;
|
private loaded = false;
|
||||||
private globalLoaded = false;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly cwd = process.cwd(),
|
private readonly cwd = process.cwd(),
|
||||||
@@ -100,39 +97,16 @@ export class Memory {
|
|||||||
return this.entries;
|
return this.entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadGlobal(): Promise<MemoryEntry[]> {
|
|
||||||
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[] {
|
all(): MemoryEntry[] {
|
||||||
return [...this.entries];
|
return [...this.entries];
|
||||||
}
|
}
|
||||||
|
|
||||||
allWithGlobal(): MemoryEntry[] {
|
|
||||||
return [...this.globalEntries, ...this.entries];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persist(): Promise<void> {
|
private async persist(): Promise<void> {
|
||||||
this.entries = this.entries.slice(-MAX_ENTRIES);
|
this.entries = this.entries.slice(-MAX_ENTRIES);
|
||||||
await Bun.write(fileFor(this.cwd), JSON.stringify(this.entries, null, 2));
|
await Bun.write(fileFor(this.cwd), JSON.stringify(this.entries, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persistGlobal(): Promise<void> {
|
|
||||||
this.globalEntries = this.globalEntries.slice(-MAX_ENTRIES);
|
|
||||||
await Bun.write(globalFile(), JSON.stringify(this.globalEntries, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
async add(kind: MemoryKind, text: string): Promise<MemoryEntry | undefined> {
|
async add(kind: MemoryKind, text: string): Promise<MemoryEntry | undefined> {
|
||||||
await this.load();
|
await this.load();
|
||||||
const clean = text.trim().slice(0, MAX_TEXT);
|
const clean = text.trim().slice(0, MAX_TEXT);
|
||||||
@@ -152,25 +126,6 @@ export class Memory {
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add to global layer (cross-project pattern). */
|
|
||||||
async addGlobal(kind: MemoryKind, text: string): Promise<MemoryEntry | undefined> {
|
|
||||||
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<number> {
|
async forget(idOrPrefix: string): Promise<number> {
|
||||||
await this.load();
|
await this.load();
|
||||||
const before = this.entries.length;
|
const before = this.entries.length;
|
||||||
@@ -238,7 +193,6 @@ export class Memory {
|
|||||||
*/
|
*/
|
||||||
render(limit = BOOT_ENTRIES): string {
|
render(limit = BOOT_ENTRIES): string {
|
||||||
if (this.entries.length === 0) return '';
|
if (this.entries.length === 0) return '';
|
||||||
// ensure global is loaded synchronously if already loaded; otherwise project-only
|
|
||||||
const pool = this.entries;
|
const pool = this.entries;
|
||||||
const byHits = [...pool].sort((a, b) => b.hits - a.hits || b.createdAt.localeCompare(a.createdAt));
|
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));
|
const byRecent = [...pool].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
@@ -282,42 +236,6 @@ export class Memory {
|
|||||||
].join('\n');
|
].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<string>();
|
|
||||||
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 {
|
needsSummary(): boolean {
|
||||||
return this.entries.length >= SUMMARISE_AT;
|
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
|
* 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(
|
async suggestFromTranscript(
|
||||||
messages: { role: string; content: unknown }[],
|
messages: { role: string; content: unknown }[],
|
||||||
|
modelOverride?: LanguageModel,
|
||||||
): Promise<{ kind: MemoryKind; text: string }[]> {
|
): 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 [];
|
if (messages.length < 4) return [];
|
||||||
try {
|
try {
|
||||||
const slice = messages.slice(-20);
|
const slice = messages.slice(-20);
|
||||||
@@ -386,10 +307,11 @@ export class Memory {
|
|||||||
.join('\n')
|
.join('\n')
|
||||||
.slice(0, 8000);
|
.slice(0, 8000);
|
||||||
const { text } = await generateText({
|
const { text } = await generateText({
|
||||||
model: this.model,
|
model,
|
||||||
system:
|
system:
|
||||||
'Extract 0-3 durable learnings from this coding session that will still be true next session. ' +
|
'Extract 0-3 durable learnings from this coding session that will still be true next session in THIS repo. ' +
|
||||||
'Only decisions with reason, traps, or working commands — not narration. ' +
|
'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.',
|
'Output one per line as [fact|decision|gotcha|command] text, or empty if nothing durable.',
|
||||||
prompt: transcript,
|
prompt: transcript,
|
||||||
maxRetries: 1,
|
maxRetries: 1,
|
||||||
@@ -417,14 +339,8 @@ export class Memory {
|
|||||||
.enum(['fact', 'decision', 'gotcha', 'command'])
|
.enum(['fact', 'decision', 'gotcha', 'command'])
|
||||||
.describe('fact: how it is. decision: what was chosen and why. gotcha: a trap. command: an invocation that works'),
|
.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'),
|
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 }) => {
|
execute: async ({ kind, text }) => {
|
||||||
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}`;
|
|
||||||
}
|
|
||||||
const entry = await this.add(kind, text);
|
const entry = await this.add(kind, text);
|
||||||
if (!entry) return `Already recorded: ${text.trim()}`;
|
if (!entry) return `Already recorded: ${text.trim()}`;
|
||||||
return `Remembered as ${entry.kind} (${this.entries.length} stored): ${entry.text}`;
|
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 {
|
function isEntry(value: unknown): value is MemoryEntry {
|
||||||
if (!value || typeof value !== 'object') return false;
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
|||||||
+3
-4
@@ -246,10 +246,9 @@ function headOf(messages: ModelMessage[]): ModelMessage[] {
|
|||||||
function withHeadPreserved(all: ModelMessage[], pruned: ModelMessage[]): ModelMessage[] {
|
function withHeadPreserved(all: ModelMessage[], pruned: ModelMessage[]): ModelMessage[] {
|
||||||
const head = headOf(all);
|
const head = headOf(all);
|
||||||
if (head.length === 0) return pruned;
|
if (head.length === 0) return pruned;
|
||||||
// If head already in pruned (by identity of content's first 80 chars), leave it.
|
// identity + full content equality (not 80-char slice which collides)
|
||||||
const headText = JSON.stringify(head[0]!.content).slice(0, 80);
|
const headNorm = JSON.stringify(head[0]!.content);
|
||||||
if (pruned.some((m) => JSON.stringify(m.content).slice(0, 80) === headText)) return pruned;
|
if (pruned.some((m) => m === head[0]! || JSON.stringify(m.content) === headNorm)) return pruned;
|
||||||
// Prepend head; dedupe if head was partially kept.
|
|
||||||
return [...head, ...pruned];
|
return [...head, ...pruned];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+56
-2
@@ -19,6 +19,7 @@ import { costOf, formatUsd } from './pricing';
|
|||||||
import { systemPrompt } from './prompt';
|
import { systemPrompt } from './prompt';
|
||||||
import { detachProviderItems, droppedSpan, estimateTokens as pruneEstimateTokens, pruneToFit } from './prune';
|
import { detachProviderItems, droppedSpan, estimateTokens as pruneEstimateTokens, pruneToFit } from './prune';
|
||||||
import { createSkillTool, renderSkills, type Skill } from './skills';
|
import { createSkillTool, renderSkills, type Skill } from './skills';
|
||||||
|
import { suggestSkillsFromTranscript, writeAutoSkill } from './skill-learner';
|
||||||
import { disabledToolNames, onBashOutput, tools as builtinTools, type ToolSetName } from './tools';
|
import { disabledToolNames, onBashOutput, tools as builtinTools, type ToolSetName } from './tools';
|
||||||
|
|
||||||
export type ApprovalRequest = {
|
export type ApprovalRequest = {
|
||||||
@@ -92,6 +93,12 @@ export type SessionOptions = {
|
|||||||
/** Live stdout/stderr from bash, for a UI that wants progress. */
|
/** Live stdout/stderr from bash, for a UI that wants progress. */
|
||||||
onToolOutput?: (id: string, chunk: string) => void;
|
onToolOutput?: (id: string, chunk: string) => void;
|
||||||
onNotebookChange?: (state: NotebookState) => 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;
|
const estimateTokens = pruneEstimateTokens;
|
||||||
@@ -141,6 +148,39 @@ async function summarizeDiscarded(span: ModelMessage[], model: LanguageModel): P
|
|||||||
|
|
||||||
type ApprovalContext = Pick<ApprovalRequest, 'matchedPattern' | 'suggestedPattern' | 'repeated'>;
|
type ApprovalContext = Pick<ApprovalRequest, 'matchedPattern' | 'suggestedPattern' | 'repeated'>;
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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 {
|
export class Session {
|
||||||
readonly messages: ModelMessage[];
|
readonly messages: ModelMessage[];
|
||||||
readonly tools: ToolSet;
|
readonly tools: ToolSet;
|
||||||
@@ -160,6 +200,8 @@ export class Session {
|
|||||||
/** The 80% spend warning is shown once, not on every turn past the line. */
|
/** The 80% spend warning is shown once, not on every turn past the line. */
|
||||||
private warnedSpend = false;
|
private warnedSpend = false;
|
||||||
private controller: AbortController | undefined;
|
private controller: AbortController | undefined;
|
||||||
|
private learnTurns = 0;
|
||||||
|
private lastLearnLen = 0;
|
||||||
|
|
||||||
constructor(private readonly opts: SessionOptions) {
|
constructor(private readonly opts: SessionOptions) {
|
||||||
this.messages = opts.messages ?? [];
|
this.messages = opts.messages ?? [];
|
||||||
@@ -304,8 +346,7 @@ export class Session {
|
|||||||
|
|
||||||
private systemFor(): string {
|
private systemFor(): string {
|
||||||
const mem = this.opts.memory;
|
const mem = this.opts.memory;
|
||||||
// Render project+global when available; fall back to project-only.
|
const memoryBlock = mem ? mem.render() : '';
|
||||||
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()) : '';
|
|
||||||
return systemPrompt({
|
return systemPrompt({
|
||||||
cwd: this.opts.cwd ?? process.cwd(),
|
cwd: this.opts.cwd ?? process.cwd(),
|
||||||
instructions: this.opts.instructions ?? [],
|
instructions: this.opts.instructions ?? [],
|
||||||
@@ -442,6 +483,19 @@ export class Session {
|
|||||||
} finally {
|
} finally {
|
||||||
onBashOutput(undefined);
|
onBashOutput(undefined);
|
||||||
await this.opts.plugins?.afterTurn();
|
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 {} }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<SkillCandidate[]> {
|
||||||
|
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 Line truncated
|
||||||
|
prompt: transcript,
|
||||||
|
maxRetries: 1,
|
||||||
|
});
|
||||||
|
return parseCandidates(text);
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAutoSkill(candidate: SkillCandidate): Promise<string | undefined> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -365,6 +365,7 @@ test('lossless compaction appends a retained note when tool content was dropped'
|
|||||||
const session = new Session({
|
const session = new Session({
|
||||||
messages: [...messages],
|
messages: [...messages],
|
||||||
compactThreshold: 1500,
|
compactThreshold: 1500,
|
||||||
|
disableAutoLearn: true,
|
||||||
model: new MockLanguageModelV4({
|
model: new MockLanguageModelV4({
|
||||||
doStream: async () => stream(text('ok')),
|
doStream: async () => stream(text('ok')),
|
||||||
doGenerate: async () => {
|
doGenerate: async () => {
|
||||||
@@ -386,6 +387,7 @@ test('no retained note when history fits', async () => {
|
|||||||
const session = new Session({
|
const session = new Session({
|
||||||
messages: [...bulkyExchange(0)],
|
messages: [...bulkyExchange(0)],
|
||||||
compactThreshold: 1_000_000,
|
compactThreshold: 1_000_000,
|
||||||
|
disableAutoLearn: true,
|
||||||
model: new MockLanguageModelV4({
|
model: new MockLanguageModelV4({
|
||||||
doStream: async () => stream(text('ok')),
|
doStream: async () => stream(text('ok')),
|
||||||
doGenerate: async () => {
|
doGenerate: async () => {
|
||||||
@@ -405,6 +407,7 @@ test('a failing retained-note model does not break the turn', async () => {
|
|||||||
const session = new Session({
|
const session = new Session({
|
||||||
messages: [...messages],
|
messages: [...messages],
|
||||||
compactThreshold: 1000,
|
compactThreshold: 1000,
|
||||||
|
disableAutoLearn: true,
|
||||||
model: new MockLanguageModelV4({
|
model: new MockLanguageModelV4({
|
||||||
doStream: async () => stream(text('ok')),
|
doStream: async () => stream(text('ok')),
|
||||||
doGenerate: async () => { throw new Error('down'); },
|
doGenerate: async () => { throw new Error('down'); },
|
||||||
|
|||||||
Reference in new issue
Block a user