harness: auto-skill general + memory spesifik + lossless compaction
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s

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:
asepharyana
2026-09-08 22:52:31 +07:00
parent 7db69f19da
commit 4848db308a
7 changed files with 203 additions and 103 deletions
+2 -2
View File
@@ -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,
+11 -95
View File
@@ -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<MemoryKind, string> = {
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<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[] {
return [...this.entries];
}
allWithGlobal(): MemoryEntry[] {
return [...this.globalEntries, ...this.entries];
}
private async persist(): Promise<void> {
this.entries = this.entries.slice(-MAX_ENTRIES);
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> {
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<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> {
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<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 {
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;
+3 -4
View File
@@ -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];
}
+56 -2
View File
@@ -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<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 {
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 {} }
}
}
}
}
File diff suppressed because one or more lines are too long