Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/memos-local-plugin/core/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions apps/memos-local-plugin/core/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
21 changes: 21 additions & 0 deletions apps/memos-local-plugin/core/skill/ALGORITHMS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion apps/memos-local-plugin/core/skill/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion apps/memos-local-plugin/core/skill/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
59 changes: 56 additions & 3 deletions apps/memos-local-plugin/core/skill/subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<RunSkillDeps, "log" | "bus"> {
Expand Down Expand Up @@ -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<void> {
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({
Expand All @@ -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 };
Expand Down
2 changes: 2 additions & 0 deletions apps/memos-local-plugin/core/skill/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
34 changes: 34 additions & 0 deletions apps/memos-local-plugin/core/storage/repos/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
toJsonText,
} from "./_helpers.js";

export const IDLE_ARCHIVE_BATCH_LIMIT = 500;

const COLUMNS = [
"id",
"owner_agent_kind",
Expand Down Expand Up @@ -140,6 +142,38 @@ export function makeSkillsRepo(db: StorageDb) {
return db.prepare<typeof params, RawSkillRow>(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<typeof params, RawSkillRow>(sql).all(params).map(mapRow);
},

count(filter: Omit<SkillListFilter, "limit" | "offset"> = {}): number {
const fragments: string[] = [];
const params: Record<string, unknown> = {};
Expand Down
1 change: 1 addition & 0 deletions apps/memos-local-plugin/docs/CONFIG-ADVANCED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions apps/memos-local-plugin/templates/config.demo.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,4 @@ algorithm:
minGain: 0.0
candidateTrials: 1
cooldownMs: 0
idleArchiveMs: 2592000000 # 30 days; minimum 1 hour
Original file line number Diff line number Diff line change
Expand Up @@ -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 ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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");
});
});
26 changes: 26 additions & 0 deletions apps/memos-local-plugin/tests/unit/config/load.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 5 additions & 1 deletion apps/memos-local-plugin/tests/unit/skill/_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function makeSkillConfig(partial: Partial<SkillConfig> = {}): SkillConfig
etaDelta: 0.1,
archiveEta: 0.1,
minEtaForRetrieval: 0.1,
idleArchiveMs: 30 * 24 * 60 * 60 * 1000,
...partial,
};
}
Expand Down Expand Up @@ -145,7 +146,9 @@ export interface SeedSkillArgs {
trialsPassed?: number;
sourcePolicyIds?: readonly PolicyId[];
invocationGuide?: string;
createdAt?: EpochMs;
updatedAt?: EpochMs;
lastUsedAt?: EpochMs | null;
vec?: EmbeddingVector | null;
}

Expand All @@ -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);
Expand Down
Loading