diff --git a/apps/memos-local-plugin/core/config/defaults.ts b/apps/memos-local-plugin/core/config/defaults.ts index 5c9dff305..b00f71dde 100644 --- a/apps/memos-local-plugin/core/config/defaults.ts +++ b/apps/memos-local-plugin/core/config/defaults.ts @@ -240,6 +240,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = { etaDelta: 0.1, archiveEta: 0.1, minEtaForRetrieval: 0.1, + idleArchiveMs: 30 * 24 * 60 * 60 * 1000, }, feedback: { failureThreshold: 3, diff --git a/apps/memos-local-plugin/core/config/schema.ts b/apps/memos-local-plugin/core/config/schema.ts index 8566f90f3..0ac5ade8c 100644 --- a/apps/memos-local-plugin/core/config/schema.ts +++ b/apps/memos-local-plugin/core/config/schema.ts @@ -346,6 +346,12 @@ const AlgorithmSchema = Type.Object({ archiveEta: NumberInRange(0.1, 0, 1), /** Hide Tier-1 skills whose η is below this. Mirrors retrieval.minSkillEta. */ minEtaForRetrieval: NumberInRange(0.1, 0, 1), + /** Archive low-η active skills after this much retrieval inactivity (minimum 1 hour). */ + idleArchiveMs: NumberInRange( + 30 * 24 * 60 * 60 * 1000, + 60 * 60 * 1000, + 365 * 24 * 60 * 60 * 1000, + ), }, { default: {} }), feedback: Type.Object({ /** Raise a burst after this many failures of the same tool in-window. */ diff --git a/apps/memos-local-plugin/core/skill/ALGORITHMS.md b/apps/memos-local-plugin/core/skill/ALGORITHMS.md index 45cabf6ae..205c3e7b2 100644 --- a/apps/memos-local-plugin/core/skill/ALGORITHMS.md +++ b/apps/memos-local-plugin/core/skill/ALGORITHMS.md @@ -236,6 +236,27 @@ can't take down a well-trialled skill. If the blend drives η under `retireEta` we still retire; the skill can rehab later via positive signals. +### Idle archive scan + +The existing lifecycle tick also archives an active skill when both +conditions hold: + +``` +η < minEtaForRetrieval +now - (lastUsedAt ?? createdAt) >= idleArchiveMs +``` + +Configuration validation enforces a one-hour minimum for `idleArchiveMs` to +prevent an accidental zero value from archiving every low-η active Skill on +the next lifecycle tick. + +`lastUsedAt` is updated by the existing recorded-use path. A never-used +skill falls back to `createdAt`; unrelated metadata updates therefore do +not reset its idle clock. The scan runs through the orchestrator's normal +flush lifecycle and does not introduce a separate timer. Each tick processes +at most ten 500-row batches; any remaining backlog is deferred to a later tick +so a large archive queue cannot monopolize the event loop. + --- ## 7. Retrieval surface diff --git a/apps/memos-local-plugin/core/skill/README.md b/apps/memos-local-plugin/core/skill/README.md index 51ed11e25..a0e573b8c 100644 --- a/apps/memos-local-plugin/core/skill/README.md +++ b/apps/memos-local-plugin/core/skill/README.md @@ -208,6 +208,7 @@ See `algorithm.skill` in | `etaDelta` | `0.1` | η step per `user.positive`/`user.negative`. | | `retireEta` | `0.25` | η floor; crossing retires. | | `minEtaForRetrieval` | `0.5` | η gate for Tier-1 retrieval + auto-promotion. | +| `idleArchiveMs` | `2592000000` | Archive low-η active skills after 30 days without use (minimum 1 hour). | ## Logging @@ -232,7 +233,7 @@ log (`logs/audit.jsonl`, never deleted) via the `skill` channel. * `tests/unit/skill/crystallize.test.ts` — LLM draft normalization + failures. * `tests/unit/skill/verifier.test.ts` — coverage + resonance checks. * `tests/unit/skill/packager.test.ts` — row shape, invocation guide, embedder failure. -* `tests/unit/skill/lifecycle.test.ts` — trial counter, thumbs, retire on drift. +* `tests/unit/skill/lifecycle.test.ts` — trial counter, thumbs, reward drift, and idle archive decisions. * `tests/unit/skill/events.test.ts` — bus contract. * `tests/unit/skill/skill.integration.test.ts` — end-to-end against real SQLite. * `tests/unit/skill/subscriber.test.ts` — event-driven trigger + runOnce + flush. diff --git a/apps/memos-local-plugin/core/skill/lifecycle.ts b/apps/memos-local-plugin/core/skill/lifecycle.ts index a4eaee04a..db2ed0a56 100644 --- a/apps/memos-local-plugin/core/skill/lifecycle.ts +++ b/apps/memos-local-plugin/core/skill/lifecycle.ts @@ -196,7 +196,8 @@ export function shouldArchiveIdle( now: number, ): boolean { if (skill.status !== "active") return false; - const age = now - skill.updatedAt; + const idleSince = skill.lastUsedAt ?? skill.createdAt; + const age = now - idleSince; if (age < idleMs) return false; return skill.eta < cfg.minEtaForRetrieval; } diff --git a/apps/memos-local-plugin/core/skill/subscriber.ts b/apps/memos-local-plugin/core/skill/subscriber.ts index 67754c311..9dd861a73 100644 --- a/apps/memos-local-plugin/core/skill/subscriber.ts +++ b/apps/memos-local-plugin/core/skill/subscriber.ts @@ -25,7 +25,7 @@ import { runSkill, type RunSkillDeps, } from "./skill.js"; -import { shouldPromoteCandidate } from "./lifecycle.js"; +import { shouldArchiveIdle, shouldPromoteCandidate } from "./lifecycle.js"; import type { RunSkillInput, RunSkillResult, @@ -35,6 +35,9 @@ import type { } from "./types.js"; import type { SkillId } from "../types.js"; import { now as nowMs } from "../time.js"; +import { IDLE_ARCHIVE_BATCH_LIMIT } from "../storage/repos/skills.js"; + +const IDLE_ARCHIVE_MAX_BATCHES_PER_TICK = 10; export interface SkillSubscriberDeps extends Omit { @@ -210,12 +213,12 @@ export function attachSkillSubscriber( } } - /** Periodic lifecycle pass: promote eligible candidate skills to active. */ + /** Promote eligible candidates and archive stale low-η active skills. */ async function lifecycleTick(): Promise { + const at = nowMs(); const candidates = deps.repos.skills.list({ status: "candidate", limit: 500 }); for (const s of candidates) { if (!shouldPromoteCandidate(s, deps.config)) continue; - const at = nowMs(); deps.repos.skills.setStatus(s.id, "active", at); log.info("skill.auto_promoted", { skillId: s.id, name: s.name, eta: s.eta }); deps.bus.emit({ @@ -227,6 +230,56 @@ export function attachSkillSubscriber( transition: "promoted", }); } + + const cutoff = at - deps.config.idleArchiveMs; + let batchesProcessed = 0; + let archivedTotal = 0; + while (batchesProcessed < IDLE_ARCHIVE_MAX_BATCHES_PER_TICK) { + const archiveCandidates = deps.repos.skills.listIdleArchiveCandidates({ + minEtaForRetrieval: deps.config.minEtaForRetrieval, + cutoff, + limit: IDLE_ARCHIVE_BATCH_LIMIT, + }); + batchesProcessed += 1; + let archivedThisBatch = 0; + for (const s of archiveCandidates) { + if (!shouldArchiveIdle(s, deps.config.idleArchiveMs, deps.config, at)) continue; + deps.repos.skills.setStatus(s.id, "archived", at); + archivedThisBatch += 1; + archivedTotal += 1; + log.info("skill.idle_archived", { + skillId: s.id, + name: s.name, + eta: s.eta, + lastUsedAt: s.lastUsedAt ?? null, + idleArchiveMs: deps.config.idleArchiveMs, + }); + deps.bus.emit({ + kind: "skill.status.changed", + at, + skillId: s.id, + previous: "active", + next: "archived", + transition: "archived", + }); + } + if (archiveCandidates.length > 0 && archivedThisBatch === 0) { + log.warn("skill.idle_archive_stalled", { + candidateCount: archiveCandidates.length, + cutoff, + minEtaForRetrieval: deps.config.minEtaForRetrieval, + }); + break; + } + if (archiveCandidates.length < IDLE_ARCHIVE_BATCH_LIMIT) break; + if (batchesProcessed === IDLE_ARCHIVE_MAX_BATCHES_PER_TICK) { + log.warn("skill.idle_archive_batch_limit_reached", { + batchCount: batchesProcessed, + archivedCount: archivedTotal, + batchSize: IDLE_ARCHIVE_BATCH_LIMIT, + }); + } + } } return { dispose, runOnce, applyFeedback, flush, lifecycleTick }; diff --git a/apps/memos-local-plugin/core/skill/types.ts b/apps/memos-local-plugin/core/skill/types.ts index a2799b061..b04e3bc35 100644 --- a/apps/memos-local-plugin/core/skill/types.ts +++ b/apps/memos-local-plugin/core/skill/types.ts @@ -118,6 +118,8 @@ export interface SkillConfig { archiveEta: number; /** Below this η, skills never surface in Tier-1 — matches retrieval config. */ minEtaForRetrieval: number; + /** Archive a low-η active skill after it has not been retrieved for this long. */ + idleArchiveMs: number; } /** diff --git a/apps/memos-local-plugin/core/storage/repos/skills.ts b/apps/memos-local-plugin/core/storage/repos/skills.ts index 2fef2d71e..01b91eba2 100644 --- a/apps/memos-local-plugin/core/storage/repos/skills.ts +++ b/apps/memos-local-plugin/core/storage/repos/skills.ts @@ -14,6 +14,8 @@ import { toJsonText, } from "./_helpers.js"; +export const IDLE_ARCHIVE_BATCH_LIMIT = 500; + const COLUMNS = [ "id", "owner_agent_kind", @@ -140,6 +142,38 @@ export function makeSkillsRepo(db: StorageDb) { return db.prepare(sql).all(params).map(mapRow); }, + /** + * Return one oldest-first batch of active skills that already satisfy + * the idle-archive predicate. Filtering in SQLite prevents unrelated + * recently-updated skills from starving older candidates. + */ + listIdleArchiveCandidates(input: { + minEtaForRetrieval: number; + cutoff: number; + limit?: number; + }): SkillRow[] { + const params = { + min_eta: input.minEtaForRetrieval, + cutoff: input.cutoff, + limit: Math.max( + 1, + Math.min( + IDLE_ARCHIVE_BATCH_LIMIT, + Math.floor(input.limit ?? IDLE_ARCHIVE_BATCH_LIMIT), + ), + ), + }; + const sql = ` + SELECT ${COLUMNS.join(", ")} + FROM skills + WHERE status = 'active' + AND eta < @min_eta + AND COALESCE(last_used_at, created_at) <= @cutoff + ORDER BY COALESCE(last_used_at, created_at) ASC + LIMIT @limit`; + return db.prepare(sql).all(params).map(mapRow); + }, + count(filter: Omit = {}): number { const fragments: string[] = []; const params: Record = {}; diff --git a/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md b/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md index 8cfe175e9..82e03230d 100644 --- a/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md +++ b/apps/memos-local-plugin/docs/CONFIG-ADVANCED.md @@ -156,6 +156,7 @@ algorithm: etaDelta: 0.1 # η step per user.positive/user.negative thumbs archiveEta: 0.25 # η floor; crossing archives minEtaForRetrieval: 0.5 # η gate for Tier-1 retrieval + auto-promotion + idleArchiveMs: 2592000000 # archive low-η skills after 30d without retrieval (minimum 1h) feedback: failureThreshold: 3 # failures in `failureWindow` that trigger a burst (V7 §6.3) failureWindow: 5 # rolling tool-call window per (toolId, context) diff --git a/apps/memos-local-plugin/templates/config.demo.yaml b/apps/memos-local-plugin/templates/config.demo.yaml index ccaca7012..7359f38bc 100644 --- a/apps/memos-local-plugin/templates/config.demo.yaml +++ b/apps/memos-local-plugin/templates/config.demo.yaml @@ -62,3 +62,4 @@ algorithm: minGain: 0.0 candidateTrials: 1 cooldownMs: 0 + idleArchiveMs: 2592000000 # 30 days; minimum 1 hour diff --git a/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts b/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts index 6ec654173..fa12b9189 100644 --- a/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts +++ b/apps/memos-local-plugin/tests/integration/adapters/openclaw-full-chain.test.ts @@ -55,7 +55,7 @@ import { makeTmpDb, type TmpDbHandle } from "../../helpers/tmp-db.js"; import { fakeLlm, type FakeLlmScript } from "../../helpers/fake-llm.js"; import type { LlmClient } from "../../../core/llm/types.js"; import type { EmbedInput, EmbedStats, Embedder } from "../../../core/embedding/types.js"; -import type { EmbeddingVector } from "../../../core/types.js"; +import type { EmbeddingVector, SkillId, SkillRow } from "../../../core/types.js"; import type { AgentKind } from "../../../agent-contract/dto.js"; // ─── Helpers ───────────────────────────────────────────────────────────── @@ -641,4 +641,50 @@ describe("OpenClaw adapter integration — multi-session full V7 chain", () => { JSON.stringify(snapshot, null, 2), ); }); + + it("archives a stale low-η skill when OpenClaw closes its session", async () => { + const thirtyOneDaysMs = 31 * 24 * 60 * 60 * 1_000; + const stale: SkillRow = { + id: "sk_openclaw_idle_archive" as SkillId, + name: "openclaw_idle_archive", + status: "active", + invocationGuide: "# OpenClaw idle archive integration fixture", + procedureJson: null, + eta: 0.05, + support: 3, + gain: 0.05, + trialsAttempted: 0, + trialsPassed: 0, + sourcePolicyIds: [], + sourceWorldModelIds: [], + evidenceAnchors: [], + vec: unitFromSeed("skill:openclaw_idle_archive") as unknown as EmbeddingVector, + createdAt: (NOW - thirtyOneDaysMs) as SkillRow["createdAt"], + updatedAt: NOW as SkillRow["updatedAt"], + lastUsedAt: (NOW - thirtyOneDaysMs) as SkillRow["lastUsedAt"], + version: 1, + }; + db!.repos.skills.upsert(stale); + const bridge = createOpenClawBridge({ + agent: AGENT, + core: core!, + log: { + trace: (_m: string, _c?: unknown) => undefined, + info: (_m: string, _c?: unknown) => undefined, + warn: (_m: string, _c?: unknown) => undefined, + error: (_m: string, _c?: unknown) => undefined, + debug: (_m: string, _c?: unknown) => undefined, + }, + now: () => NOW, + }); + const session = new OpenClawSimulator({ bridge, sessionKey: "s-idle-archive" }); + + await session.turn( + "用 Python 返回字符串 hello", + '```python\ndef hello() -> str:\n return "hello"\n```', + ); + await session.close(); + + expect(db!.repos.skills.getById(stale.id)?.status).toBe("archived"); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/config/load.test.ts b/apps/memos-local-plugin/tests/unit/config/load.test.ts index 5d85fd6e0..d7f394772 100644 --- a/apps/memos-local-plugin/tests/unit/config/load.test.ts +++ b/apps/memos-local-plugin/tests/unit/config/load.test.ts @@ -28,6 +28,32 @@ describe("config/loadConfig", () => { expect(cfg.logging.timezone).toBe("America/Los_Angeles"); }); + it("defaults skill idle archival to 30 days and accepts an override", () => { + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + const sixHoursMs = 6 * 60 * 60 * 1000; + expect(resolveConfig({}).algorithm.skill.idleArchiveMs).toBe(thirtyDaysMs); + expect(resolveConfig({ + algorithm: { skill: { idleArchiveMs: sixHoursMs } }, + }).algorithm.skill.idleArchiveMs).toBe(sixHoursMs); + }); + + it("rejects skill idle archival outside the supported one-hour-to-365-day range", () => { + const oneHourMs = 60 * 60 * 1000; + const overOneYearMs = 365 * 24 * 60 * 60 * 1000 + 1; + expect(resolveConfig({ + algorithm: { skill: { idleArchiveMs: oneHourMs } }, + }).algorithm.skill.idleArchiveMs).toBe(oneHourMs); + expect(() => resolveConfig({ + algorithm: { skill: { idleArchiveMs: 0 } }, + })).toThrow(/schema validation/); + expect(() => resolveConfig({ + algorithm: { skill: { idleArchiveMs: oneHourMs - 1 } }, + })).toThrow(/schema validation/); + expect(() => resolveConfig({ + algorithm: { skill: { idleArchiveMs: overOneYearMs } }, + })).toThrow(/schema validation/); + }); + it("rejects invalid logging.timezone with config_invalid", () => { expect(() => resolveConfig({ logging: { timezone: "Not/AZone" } })).toThrow(MemosError); try { diff --git a/apps/memos-local-plugin/tests/unit/skill/_helpers.ts b/apps/memos-local-plugin/tests/unit/skill/_helpers.ts index 549a9f477..9cef40506 100644 --- a/apps/memos-local-plugin/tests/unit/skill/_helpers.ts +++ b/apps/memos-local-plugin/tests/unit/skill/_helpers.ts @@ -40,6 +40,7 @@ export function makeSkillConfig(partial: Partial = {}): SkillConfig etaDelta: 0.1, archiveEta: 0.1, minEtaForRetrieval: 0.1, + idleArchiveMs: 30 * 24 * 60 * 60 * 1000, ...partial, }; } @@ -145,7 +146,9 @@ export interface SeedSkillArgs { trialsPassed?: number; sourcePolicyIds?: readonly PolicyId[]; invocationGuide?: string; + createdAt?: EpochMs; updatedAt?: EpochMs; + lastUsedAt?: EpochMs | null; vec?: EmbeddingVector | null; } @@ -165,8 +168,9 @@ export function seedSkill(handle: TmpDbHandle, args: SeedSkillArgs = {}): SkillR sourceWorldModelIds: [], evidenceAnchors: [], vec: args.vec ?? vec([1, 0, 0]), - createdAt: (args.updatedAt ?? NOW) as SkillRow["createdAt"], + createdAt: (args.createdAt ?? args.updatedAt ?? NOW) as SkillRow["createdAt"], updatedAt: (args.updatedAt ?? NOW) as SkillRow["updatedAt"], + lastUsedAt: args.lastUsedAt ?? null, version: 1, }; handle.repos.skills.upsert(row); diff --git a/apps/memos-local-plugin/tests/unit/skill/lifecycle.test.ts b/apps/memos-local-plugin/tests/unit/skill/lifecycle.test.ts index 86101908e..946cfb663 100644 --- a/apps/memos-local-plugin/tests/unit/skill/lifecycle.test.ts +++ b/apps/memos-local-plugin/tests/unit/skill/lifecycle.test.ts @@ -22,6 +22,7 @@ function mkSkill(partial: Partial = {}): SkillRow { vec: null, createdAt: partial.createdAt ?? NOW, updatedAt: partial.updatedAt ?? NOW, + lastUsedAt: partial.lastUsedAt ?? null, version: partial.version ?? 1, }; } @@ -104,9 +105,51 @@ describe("skill/lifecycle", () => { expect(recomputeEta(s, policy, cfg)).toBeCloseTo(0.7, 5); }); - it("shouldArchiveIdle picks up stale active skills with low η", () => { + it("archives a low-η active skill after its last use exceeds idleArchiveMs", () => { + const cfg = makeSkillConfig({ minEtaForRetrieval: 0.6, idleArchiveMs: 1_000 }); + const s = mkSkill({ + status: "active", + eta: 0.4, + lastUsedAt: 1_000 as SkillRow["lastUsedAt"], + }); + expect(shouldArchiveIdle(s, 1_000, cfg, 10_000)).toBe(true); + }); + + it("uses createdAt as the idle baseline for a skill that has never been used", () => { + const cfg = makeSkillConfig({ minEtaForRetrieval: 0.6, idleArchiveMs: 1_000 }); + const s = mkSkill({ + status: "active", + eta: 0.4, + createdAt: 1_000 as SkillRow["createdAt"], + updatedAt: 9_500 as SkillRow["updatedAt"], + lastUsedAt: null, + }); + expect(shouldArchiveIdle(s, 1_000, cfg, 10_000)).toBe(true); + }); + + it("keeps recently used or retrievable active skills", () => { + const cfg = makeSkillConfig({ minEtaForRetrieval: 0.6, idleArchiveMs: 1_000 }); + const recent = mkSkill({ + status: "active", + eta: 0.4, + lastUsedAt: 9_500 as SkillRow["lastUsedAt"], + }); + const retrievable = mkSkill({ + status: "active", + eta: 0.6, + lastUsedAt: 1_000 as SkillRow["lastUsedAt"], + }); + expect(shouldArchiveIdle(recent, 1_000, cfg, 10_000)).toBe(false); + expect(shouldArchiveIdle(retrievable, 1_000, cfg, 10_000)).toBe(false); + }); + + it("archives exactly at the configured idle boundary", () => { const cfg = makeSkillConfig({ minEtaForRetrieval: 0.6 }); - const s = mkSkill({ status: "active", eta: 0.4, updatedAt: 0 as SkillRow["updatedAt"] }); - expect(shouldArchiveIdle(s, 1000, cfg, 10_000)).toBe(true); + const skill = mkSkill({ + status: "active", + eta: 0.4, + lastUsedAt: 9_000 as SkillRow["lastUsedAt"], + }); + expect(shouldArchiveIdle(skill, 1_000, cfg, 10_000)).toBe(true); }); }); diff --git a/apps/memos-local-plugin/tests/unit/skill/subscriber.test.ts b/apps/memos-local-plugin/tests/unit/skill/subscriber.test.ts index dbda7394c..a8c35468a 100644 --- a/apps/memos-local-plugin/tests/unit/skill/subscriber.test.ts +++ b/apps/memos-local-plugin/tests/unit/skill/subscriber.test.ts @@ -16,6 +16,7 @@ import { makeSkillConfig, seedPolicy, seedSessionOnly, + seedSkill, seedTrace, } from "./_helpers.js"; @@ -154,4 +155,146 @@ describe("skill/subscriber", () => { expect(r.crystallized).toBe(1); sub.dispose(); }); + + it("archives each stale low-η active skill once without regressing candidate promotion", async () => { + handle = makeTmpDb(); + const h = handle; + const l2Bus = createL2EventBus(); + const rewardBus = createRewardEventBus(); + const bus = createSkillEventBus(); + const events: Array<{ + skillId: string; + previous: string; + next: string; + transition: string; + }> = []; + bus.on("skill.status.changed", (event) => { + if (event.kind !== "skill.status.changed") return; + events.push({ + skillId: event.skillId, + previous: event.previous, + next: event.next, + transition: event.transition, + }); + }); + + const stale = seedSkill(h, { + id: "sk_stale" as never, + name: "stale_skill", + status: "active", + eta: 0.05, + createdAt: 1 as never, + updatedAt: 9_000 as never, + lastUsedAt: 1_000 as never, + }); + const candidate = seedSkill(h, { + id: "sk_candidate" as never, + name: "candidate_skill", + status: "candidate", + eta: 0.7, + createdAt: 1 as never, + updatedAt: 1 as never, + }); + + const sub = attachSkillSubscriber({ + l2Bus, + rewardBus, + bus, + repos: h.repos, + embedder: null, + llm: null, + log: rootLogger.child({ channel: "core.skill.subscriber" }), + config: makeSkillConfig({ minEtaForRetrieval: 0.1, idleArchiveMs: 1_000 }), + }); + + await sub.lifecycleTick(); + await sub.lifecycleTick(); + + expect(h.repos.skills.getById(stale.id)?.status).toBe("archived"); + expect(h.repos.skills.getById(candidate.id)?.status).toBe("active"); + expect(events.filter((event) => event.skillId === stale.id)).toEqual([ + { skillId: stale.id, previous: "active", next: "archived", transition: "archived" }, + ]); + expect(events.filter((event) => event.skillId === candidate.id)).toHaveLength(1); + sub.dispose(); + }); + + it("drains more than one 500-skill idle archive batch in one lifecycle tick", async () => { + handle = makeTmpDb(); + const h = handle; + for (let i = 0; i < 501; i++) { + seedSkill(h, { + id: `sk_stale_${i}` as never, + name: `stale_skill_${i}`, + status: "active", + eta: 0.05, + createdAt: 1 as never, + updatedAt: (i + 1) as never, + lastUsedAt: 1 as never, + }); + } + const sub = attachSkillSubscriber({ + l2Bus: createL2EventBus(), + rewardBus: createRewardEventBus(), + bus: createSkillEventBus(), + repos: h.repos, + embedder: null, + llm: null, + log: rootLogger.child({ channel: "core.skill.subscriber" }), + config: makeSkillConfig({ minEtaForRetrieval: 0.1, idleArchiveMs: 1_000 }), + }); + + await sub.lifecycleTick(); + + expect(h.repos.skills.count({ status: "archived" })).toBe(501); + expect(h.repos.skills.count({ status: "active" })).toBe(0); + sub.dispose(); + }); + + it("caps idle archival at ten batches per lifecycle tick", async () => { + handle = makeTmpDb(); + const h = handle; + for (let i = 0; i < 5_001; i++) { + seedSkill(h, { + id: `sk_backlog_${i}` as never, + name: `backlog_skill_${i}`, + status: "active", + eta: 0.05, + createdAt: 1 as never, + updatedAt: (i + 1) as never, + lastUsedAt: 1 as never, + }); + } + const log = rootLogger.child({ channel: "core.skill.subscriber" }); + const infoSpy = vi.spyOn(log, "info").mockImplementation(() => undefined); + const warnSpy = vi.spyOn(log, "warn").mockImplementation(() => undefined); + const sub = attachSkillSubscriber({ + l2Bus: createL2EventBus(), + rewardBus: createRewardEventBus(), + bus: createSkillEventBus(), + repos: h.repos, + embedder: null, + llm: null, + log, + config: makeSkillConfig({ minEtaForRetrieval: 0.1, idleArchiveMs: 1_000 }), + }); + + await sub.lifecycleTick(); + + expect(h.repos.skills.count({ status: "archived" })).toBe(5_000); + expect(h.repos.skills.count({ status: "active" })).toBe(1); + expect(warnSpy).toHaveBeenCalledWith("skill.idle_archive_batch_limit_reached", { + batchCount: 10, + archivedCount: 5_000, + batchSize: 500, + }); + + await sub.lifecycleTick(); + + expect(h.repos.skills.count({ status: "archived" })).toBe(5_001); + expect(h.repos.skills.count({ status: "active" })).toBe(0); + sub.dispose(); + infoSpy.mockRestore(); + warnSpy.mockRestore(); + }); }); diff --git a/apps/memos-local-plugin/tests/unit/storage/repos.test.ts b/apps/memos-local-plugin/tests/unit/storage/repos.test.ts index 3c859fc19..aef629696 100644 --- a/apps/memos-local-plugin/tests/unit/storage/repos.test.ts +++ b/apps/memos-local-plugin/tests/unit/storage/repos.test.ts @@ -314,6 +314,67 @@ describe("storage/repos — happy paths", () => { } }); + it("skills: selects idle archive candidates and excludes a skill after recorded use", () => { + const { repos, cleanup } = makeTmpDb(); + try { + const insertSkill = ( + id: string, + status: "active" | "archived", + eta: number, + createdAt: number, + lastUsedAt: number | null, + ) => { + repos.skills.insert({ + id, + name: id, + status, + invocationGuide: "fixture", + procedureJson: null, + eta, + support: 1, + gain: 0, + trialsAttempted: 0, + trialsPassed: 0, + sourcePolicyIds: [], + sourceWorldModelIds: [], + evidenceAnchors: [], + vec: null, + createdAt, + updatedAt: 10_000, + lastUsedAt, + version: 1, + }); + }; + insertSkill("never_used", "active", 0.05, 50, null); + insertSkill("old_used", "active", 0.05, 1, 100); + insertSkill("recent", "active", 0.05, 1, 9_500); + insertSkill("retrievable", "active", 0.1, 1, 100); + insertSkill("already_archived", "archived", 0.05, 1, 100); + + const candidates = repos.skills.listIdleArchiveCandidates({ + minEtaForRetrieval: 0.1, + cutoff: 9_000, + limit: 500, + }); + expect(candidates.map((skill) => skill.id)).toEqual(["never_used", "old_used"]); + expect(repos.skills.listIdleArchiveCandidates({ + minEtaForRetrieval: 0.1, + cutoff: 9_000, + limit: 1, + }).map((skill) => skill.id)).toEqual(["never_used"]); + + expect(repos.skills.recordUse("old_used", 9_500)).toBe(true); + expect(repos.skills.getById("old_used")?.lastUsedAt).toBe(9_500); + expect(repos.skills.listIdleArchiveCandidates({ + minEtaForRetrieval: 0.1, + cutoff: 9_000, + limit: 500, + }).map((skill) => skill.id)).toEqual(["never_used"]); + } finally { + cleanup(); + } + }); + it("feedback: insert, scoped list, polarity filter", () => { const { repos, cleanup } = makeTmpDb(); try {