🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

lyrie-agent

Package Overview
Dependencies
Maintainers
1
Versions
6
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

lyrie-agent - npm Package Compare versions

Comparing version
0.4.0
to
0.5.0
+217
docs/evolve.md
# LyrieEvolve — Autonomous Self-Improvement
> Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai
LyrieEvolve is the self-improvement subsystem of Lyrie Agent (v0.5.0+). It records task outcomes, scores them, extracts reusable skill patterns, retrieves context for active tasks, and runs a Dream Cycle to prune stale patterns.
---
## Architecture
```
Outcomes (JSONL)
├── Scorer → task-outcome.jsonl
├── SkillExtractor → skills/auto-generated/*.md
├── Contexture → in-memory (future: LanceDB lyrie_contexture)
└── Dream Cycle → batch pipeline (score → extract → prune → report)
```
---
## Components
### 1. Scorer (`packages/core/src/evolve/scorer.ts`)
Records and scores task outcomes across 5 domains.
**Domains:** `cyber` | `seo` | `trading` | `code` | `general`
**Score values:**
- `0` — failed / rejected / harmful
- `0.5` — partial / ambiguous
- `1` — success / confirmed value
**Usage:**
```typescript
import { Scorer } from "@lyrie/core";
const scorer = new Scorer();
const outcome = scorer.score("task-123", {
domain: "code",
signals: { testsPass: true, buildSucceeds: true, prMerged: true },
}, "All CI checks passed, PR merged");
// outcome.score === 1
```
**Domain-specific signals:**
| Domain | Key Signals |
|--------|-------------|
| `cyber` | `confirmed`, `falsePositive`, `pocGenerated`, `patchApplied`, `shieldBlocked` |
| `seo` | `keywordsRanked`, `contentPublished`, `backlinksAcquired`, `issuesResolved` |
| `trading` | `profitable`, `pnlRatio`, `drawdownExceeded`, `riskRespected`, `signalAccuracy` |
| `code` | `testsPass`, `buildSucceeds`, `noLintErrors`, `prMerged`, `linesChanged` |
| `general` | `completed`, `userApproved`, `userRejected`, `retries` |
Outcomes are appended to `~/.lyrie/evolve/outcomes.jsonl`. Summaries are Shield-scanned before storage.
---
### 2. Skill Extractor (`packages/core/src/evolve/skill-extractor.ts`)
Reads `outcomes.jsonl`, finds high-quality sessions (score >= 0.5), and writes OpenClaw-compatible SKILL.md files.
**Features:**
- Groups outcomes by domain
- Injects an LLM call to extract 1-3 skill patterns (injectable `ExtractorLLM` interface)
- Built-in heuristic fallback (`HeuristicExtractorLLM`) — no LLM required
- Cosine dedup: skips patterns with similarity > 0.85 to existing skills
- Writes to `skills/auto-generated/`
**Usage:**
```typescript
import { SkillExtractor } from "@lyrie/core";
const extractor = new SkillExtractor({ minScore: 0.5 });
const result = await extractor.extract();
// result.written, result.skippedDuplicates
```
**CLI:**
```bash
bun run scripts/evolve.ts extract
```
---
### 3. Contexture Layer (`packages/core/src/evolve/contexture.ts`)
Retrieves relevant skill contexts for active tasks and builds prompt injections.
**Features:**
- In-memory store (LanceDB-ready: table name `lyrie_contexture`)
- Cosine similarity retrieval with `retrieve(query, domain?, topK=3)`
- **MMR (Maximal Marginal Relevance)** diversity — λ=0.7 by default
- `buildInjection(contexts)` → structured `<lyrie_context>` block for prompt injection
- Shield-scanned on store; auto-evicts lowest-score entries at capacity (1000)
**Usage:**
```typescript
import { Contexture } from "@lyrie/core";
const ctx = new Contexture();
ctx.store({ id: "s1", domain: "cyber", summary: "...", score: 1, ... });
const results = ctx.retrieve("XSS injection", "cyber", 3);
const injection = ctx.buildInjection(results);
// Prepend injection to system prompt
```
---
### 4. Dream Cycle (`packages/core/src/evolve/dream-cycle.ts`)
Batch pipeline that runs while Lyrie is idle (typically at 4AM Dream Cycle cron).
**Steps:**
1. Count unprocessed outcomes
2. Extract skills via SkillExtractor
3. Prune stale skills (avgScore < 0.3 after 5+ uses)
4. Return `DreamReport`
**CLI:**
```bash
# Full run
bun run scripts/dream-evolve.ts
# Preview without writes
bun run scripts/dream-evolve.ts --dry-run
```
---
## CLI Reference (`lyrie evolve`)
```bash
bun run scripts/evolve.ts <command>
Commands:
status Show LyrieEvolve system status + version info
extract Extract skills from high-quality outcomes
dream [--dry-run] Run the full Dream Cycle pipeline
stats Outcome statistics by domain and score
skills list List auto-generated skill files
skills show <id> Show a specific skill file
skills prune Identify and remove stale skills
train Export training batch (outcomes with score >= 0.5)
Options:
--outcomes <path> Override ~/.lyrie/evolve/outcomes.jsonl
--skills-dir <path> Override skills directory
--dry-run Preview without writing
```
---
## Python SDK
```python
from lyrie.evolve import LyrieEvolve
client = LyrieEvolve()
# Score a task
outcome = await client.score("task-123", "code", {
"tests_pass": True,
"build_succeeds": True,
})
# Retrieve context
contexts = await client.get_context("XSS vulnerability", domain="cyber", top_k=3)
# Extract skills
result = await client.extract_skills(dry_run=True)
# Training batch
batch = await client.get_training_batch(domain="code", min_score=0.5, limit=100)
```
---
## Storage Layout
```
~/.lyrie/evolve/
├── outcomes.jsonl ← scored task outcomes (append-only)
└── skills/ ← auto-generated skill files (when using default skillsDir)
<repo>/skills/auto-generated/
└── auto-<domain>-<ts>.md ← OpenClaw-compatible SKILL.md files
```
---
## Shield Integration
Every text that enters the Evolve system passes through the Shield Doctrine:
- Summaries are `scanRecalled()` before being written to outcomes.jsonl
- Stored contexts are `scanRecalled()` before entering the Contexture table
- Blocked content is silently dropped (no partial writes)
---
## Pruning Rules
A skill is a candidate for pruning when:
- `avgScore < 0.3` (consistently low quality)
- `useCount >= 5` (has been tried enough times to confirm it's not useful)
Both conditions must be true. New skills with fewer than 5 uses are never pruned.
---
_© OTT Cybersecurity LLC — https://lyrie.ai — MIT License_
/**
* Lyrie LyrieEvolve — Contexture Layer
*
* Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai — MIT License
*
* The Contexture Layer retrieves relevant skill contexts from past successful
* outcomes and builds prompt injections for the active agent turn.
*
* Architecture:
* - Stores SkillContext entries in an in-memory table (with optional
* LanceDB-backed persistence when available)
* - Retrieves top-K contexts via cosine similarity
* - Applies MMR (Maximal Marginal Relevance) diversity to avoid repetition
* - Builds a structured prompt injection string for LyrieEngine consumption
*
* © OTT Cybersecurity LLC — All rights reserved.
*/
import { ShieldGuard, type ShieldGuardLike } from "../engine/shield-guard";
import { tokenize, cosineSimilarity } from "./skill-extractor";
import type { Domain } from "./scorer";
// ─── Public types ──────────────────────────────────────────────────────────
export interface SkillContext {
/** Stable id for this context entry. */
id: string;
/** The domain this context applies to. */
domain: Domain;
/** Summary of the skill or lesson learned. */
summary: string;
/** Average score of contributing outcomes (0–1). */
score: number;
/** Number of times this context has been successfully used. */
useCount: number;
/** Unix ms timestamp when this context was stored. */
storedAt: number;
/** Provenance. */
signature: "Lyrie.ai by OTT Cybersecurity LLC";
}
/** Result of an MMR retrieval. */
export interface RetrievalResult {
context: SkillContext;
/** Cosine similarity to query (0–1). */
relevance: number;
/** MMR score (after diversity penalty). */
mmrScore: number;
}
export interface ContextureOptions {
/** Shield guard for scanning stored contexts. */
shield?: ShieldGuardLike;
/** MMR lambda: 0 = pure diversity, 1 = pure relevance. Default: 0.7. */
mmrLambda?: number;
/** Max contexts to store (evicts lowest-score entries). Default: 1000. */
maxEntries?: number;
}
// ─── MMR implementation ────────────────────────────────────────────────────
/**
* Maximal Marginal Relevance selection.
*
* Given candidate results sorted by relevance, pick `topK` entries
* that balance relevance vs. diversity (low similarity to already-selected).
*
* λ = 1 → pure relevance (greedy top-K)
* λ = 0 → pure diversity
*/
export function mmrSelect(
query: Map<string, number>,
candidates: Array<{ context: SkillContext; relevance: number }>,
topK: number,
lambda: number,
): RetrievalResult[] {
if (candidates.length === 0) return [];
const selected: RetrievalResult[] = [];
const remaining = [...candidates];
// Pre-compute text vectors for each candidate.
const vecs = new Map<string, Map<string, number>>(
remaining.map((c) => [
c.context.id,
tokenize(`${c.context.summary} ${c.context.domain}`),
]),
);
while (selected.length < topK && remaining.length > 0) {
let bestIdx = -1;
let bestScore = -Infinity;
for (let i = 0; i < remaining.length; i++) {
const cand = remaining[i]!;
const candVec = vecs.get(cand.context.id)!;
// Relevance term: cosine(query, candidate)
const relevanceTerm = lambda * cand.relevance;
// Diversity term: 1 - max cosine(candidate, already selected)
let maxSim = 0;
for (const sel of selected) {
const selVec = vecs.get(sel.context.id)!;
const sim = cosineSimilarity(candVec, selVec);
if (sim > maxSim) maxSim = sim;
}
const diversityTerm = (1 - lambda) * (1 - maxSim);
const mmrScore = relevanceTerm + diversityTerm;
if (mmrScore > bestScore) {
bestScore = mmrScore;
bestIdx = i;
}
}
if (bestIdx < 0) break;
const chosen = remaining[bestIdx]!;
selected.push({
context: chosen.context,
relevance: chosen.relevance,
mmrScore: bestScore,
});
remaining.splice(bestIdx, 1);
}
return selected;
}
// ─── Contexture class ──────────────────────────────────────────────────────
export class Contexture {
private readonly table: Map<string, SkillContext> = new Map();
private readonly shield: ShieldGuardLike;
private readonly mmrLambda: number;
private readonly maxEntries: number;
constructor(opts: ContextureOptions = {}) {
this.shield = opts.shield ?? ShieldGuard.fallback();
this.mmrLambda = opts.mmrLambda ?? 0.7;
this.maxEntries = opts.maxEntries ?? 1000;
}
/** Store a SkillContext (Shield-scanned). */
store(ctx: SkillContext): void {
const verdict = this.shield.scanRecalled(ctx.summary);
if (verdict.blocked) return; // Silently drop Shield-blocked content.
// Evict if at capacity (remove lowest score entry).
if (this.table.size >= this.maxEntries) {
let lowestId = "";
let lowestScore = Infinity;
for (const [id, entry] of this.table) {
if (entry.score < lowestScore) {
lowestScore = entry.score;
lowestId = id;
}
}
if (lowestId) this.table.delete(lowestId);
}
this.table.set(ctx.id, ctx);
}
/** Update use count for a context (called after it's successfully used). */
markUsed(id: string): void {
const ctx = this.table.get(id);
if (ctx) {
this.table.set(id, { ...ctx, useCount: ctx.useCount + 1 });
}
}
/** Delete a context by id. */
delete(id: string): boolean {
return this.table.delete(id);
}
/** Return all stored contexts. */
all(): SkillContext[] {
return Array.from(this.table.values());
}
/** Return count of stored contexts. */
size(): number {
return this.table.size;
}
/**
* Retrieve top-K relevant SkillContexts for a query + domain filter.
* Uses cosine similarity + MMR diversity.
*/
retrieve(query: string, domain?: Domain, topK: number = 3): RetrievalResult[] {
const queryVec = tokenize(query);
let candidates = Array.from(this.table.values());
if (domain) {
candidates = candidates.filter((c) => c.domain === domain);
}
if (candidates.length === 0) return [];
// Compute relevance scores.
const scored = candidates.map((ctx) => {
const ctxVec = tokenize(`${ctx.summary} ${ctx.domain}`);
const relevance = cosineSimilarity(queryVec, ctxVec);
return { context: ctx, relevance };
});
// Sort by relevance descending.
scored.sort((a, b) => b.relevance - a.relevance);
// Apply MMR for diversity.
return mmrSelect(queryVec, scored, topK, this.mmrLambda);
}
/**
* Build a prompt injection string from retrieved contexts.
* Returns a structured block suitable for prepending to a system prompt.
*/
buildInjection(contexts: RetrievalResult[]): string {
if (contexts.length === 0) return "";
const lines: string[] = [
"<!-- LyrieEvolve Contexture — Lyrie.ai by OTT Cybersecurity LLC -->",
"<lyrie_context>",
"The following skill contexts were retrieved from past successful task outcomes.",
"Apply these patterns to improve your current response.",
"",
];
for (let i = 0; i < contexts.length; i++) {
const r = contexts[i]!;
lines.push(`[Context ${i + 1}] Domain: ${r.context.domain} | Score: ${r.context.score.toFixed(2)} | Relevance: ${r.relevance.toFixed(2)}`);
lines.push(r.context.summary);
lines.push("");
}
lines.push("</lyrie_context>");
return lines.join("\n");
}
/**
* Full retrieve + inject pipeline: retrieve topK contexts and build injection.
*/
retrieveAndInject(query: string, domain?: Domain, topK: number = 3): string {
const results = this.retrieve(query, domain, topK);
return this.buildInjection(results);
}
}
export const CONTEXTURE_VERSION = "lyrie-evolve-contexture-1.0.0";
/** Shared table name for LanceDB (reserved for future persistence). */
export const CONTEXTURE_TABLE = "lyrie_contexture";
/**
* Lyrie LyrieEvolve — Dream Cycle Pipeline (core logic)
*
* Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai — MIT License
*
* The Dream Cycle is a batch pipeline that runs while Lyrie is idle:
* 1. Score any unprocessed outcomes
* 2. Extract skills from high-quality outcomes
* 3. Prune stale/low-value skills (score < 0.3 after 5+ uses)
* 4. Build a report
*
* Designed to be called from `scripts/dream-evolve.ts` and `lyrie evolve dream`.
*
* © OTT Cybersecurity LLC — All rights reserved.
*/
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { SkillExtractor } from "./skill-extractor";
import type { SkillPattern } from "./skill-extractor";
import type { TaskOutcome } from "./scorer";
// ─── Types ─────────────────────────────────────────────────────────────────
export interface DreamCycleOptions {
/** Path to outcomes.jsonl. Default: ~/.lyrie/evolve/outcomes.jsonl. */
outcomesPath?: string;
/** Directory containing auto-generated skills. */
skillsDir?: string;
/** When true, no disk writes are performed. */
dryRun?: boolean;
/** Prune threshold: skills with score below this are candidates. Default: 0.3. */
pruneScoreThreshold?: number;
/** Prune use count minimum: must have been used this many times. Default: 5. */
pruneMinUses?: number;
/** Injectable extractor (for tests). */
extractor?: SkillExtractor;
}
export interface PruneCandidate {
filename: string;
reason: string;
}
export interface DreamReport {
runAt: number;
dryRun: boolean;
unprocessedOutcomes: number;
extractedSkills: number;
skippedDuplicates: number;
pruned: PruneCandidate[];
totalSkills: number;
signature: "Lyrie.ai by OTT Cybersecurity LLC";
}
// ─── Pruning logic ─────────────────────────────────────────────────────────
/**
* Read all auto-generated skill files and return those that should be pruned.
*
* Pruning criteria (AND):
* - avgScore < pruneScoreThreshold
* - useCount >= pruneMinUses (has been tried enough times to confirm it's bad)
*/
export function findPruneCandidates(
skillsDir: string,
pruneScoreThreshold: number,
pruneMinUses: number,
): PruneCandidate[] {
if (!existsSync(skillsDir)) return [];
const files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
const candidates: PruneCandidate[] = [];
for (const filename of files) {
const content = readFileSync(join(skillsDir, filename), "utf8");
// Parse avgScore from markdown header: **Avg Score:** 0.25
const scoreMatch = content.match(/\*\*Avg Score:\*\*\s*([\d.]+)/);
const avgScore = scoreMatch ? parseFloat(scoreMatch[1]!) : 1;
// Parse useCount from metadata comments (not in base template, but check anyway)
// For now, parse from a <!-- uses: N --> comment if present.
const usesMatch = content.match(/<!--\s*uses:\s*(\d+)\s*-->/);
const useCount = usesMatch ? parseInt(usesMatch[1]!, 10) : 0;
if (avgScore < pruneScoreThreshold && useCount >= pruneMinUses) {
candidates.push({
filename,
reason: `avgScore=${avgScore} < ${pruneScoreThreshold}, useCount=${useCount} >= ${pruneMinUses}`,
});
}
}
return candidates;
}
/**
* Prune (delete) skill files identified as candidates.
* In dryRun mode, files are not deleted.
*/
export function pruneSkills(
skillsDir: string,
candidates: PruneCandidate[],
dryRun: boolean,
): void {
if (dryRun) return;
const { unlinkSync } = require("node:fs");
for (const c of candidates) {
try {
unlinkSync(join(skillsDir, c.filename));
} catch {
// Ignore if already deleted
}
}
}
// ─── Dream Cycle runner ────────────────────────────────────────────────────
export async function runDreamCycle(opts: DreamCycleOptions = {}): Promise<DreamReport> {
const outcomesPath =
opts.outcomesPath ?? join(homedir(), ".lyrie", "evolve", "outcomes.jsonl");
const skillsDir =
opts.skillsDir ?? join(homedir(), ".lyrie", "evolve", "skills");
const dryRun = opts.dryRun ?? false;
const pruneScoreThreshold = opts.pruneScoreThreshold ?? 0.3;
const pruneMinUses = opts.pruneMinUses ?? 5;
// Step 1: Read unprocessed outcomes.
let unprocessedCount = 0;
if (existsSync(outcomesPath)) {
const lines = readFileSync(outcomesPath, "utf8")
.split("\n")
.filter((l) => l.trim().length > 0);
unprocessedCount = lines.length;
}
// Step 2: Extract skills from high-quality outcomes.
const extractor =
opts.extractor ??
new SkillExtractor({
outcomesPath,
skillsDir,
dryRun,
});
let extractedSkills = 0;
let skippedDuplicates = 0;
if (unprocessedCount > 0) {
const result = await extractor.extract();
extractedSkills = result.written;
skippedDuplicates = result.skippedDuplicates;
}
// Step 3: Prune low-value skills.
const pruneCandidates = findPruneCandidates(
skillsDir,
pruneScoreThreshold,
pruneMinUses,
);
pruneSkills(skillsDir, pruneCandidates, dryRun);
// Step 4: Count remaining skills.
const totalSkills = existsSync(skillsDir)
? readdirSync(skillsDir).filter((f) => f.endsWith(".md")).length
: 0;
const report: DreamReport = {
runAt: Date.now(),
dryRun,
unprocessedOutcomes: unprocessedCount,
extractedSkills,
skippedDuplicates,
pruned: pruneCandidates,
totalSkills,
signature: "Lyrie.ai by OTT Cybersecurity LLC",
};
return report;
}
export const DREAM_VERSION = "lyrie-evolve-dream-1.0.0";
/**
* Lyrie LyrieEvolve — Task Outcome Scoring System
*
* Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai — MIT License
*
* Records and scores task outcomes across Lyrie domains. Scored outcomes
* feed the Dream Cycle, skill extraction, and Contexture Layer.
*
* Score values:
* 0 — failed / rejected / harmful
* 0.5 — partial / ambiguous
* 1 — success / confirmed value
*
* © OTT Cybersecurity LLC — All rights reserved.
*/
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { ShieldGuard, type ShieldGuardLike } from "../engine/shield-guard";
// ─── Public types ─────────────────────────────────────────────────────────────
export type Domain = "cyber" | "seo" | "trading" | "code" | "general";
export type Score = 0 | 0.5 | 1;
/** Per-domain signals used to compute a score. */
export interface CyberSignals {
/** Vulnerability confirmed via Stages A–F. */
confirmed?: boolean;
/** Finding was false-positive (reduces score). */
falsePositive?: boolean;
/** Shield blocked a malicious payload (positive signal). */
shieldBlocked?: boolean;
/** PoC generated automatically. */
pocGenerated?: boolean;
/** Remediation patch applied. */
patchApplied?: boolean;
}
export interface SeoSignals {
/** Keywords ranked on first page. */
keywordsRanked?: number;
/** Content published. */
contentPublished?: boolean;
/** Backlinks acquired. */
backlinksAcquired?: number;
/** Audit issues resolved. */
issuesResolved?: number;
/** Page speed improved (ms saved). */
speedImprovementMs?: number;
}
export interface TradingSignals {
/** PnL ratio (positive = profitable). */
pnlRatio?: number;
/** Trade was closed profitably. */
profitable?: boolean;
/** Max drawdown exceeded limit. */
drawdownExceeded?: boolean;
/** Risk rules respected. */
riskRespected?: boolean;
/** Signal accuracy (0–1). */
signalAccuracy?: number;
}
export interface CodeSignals {
/** Tests pass after change. */
testsPass?: boolean;
/** Build succeeds. */
buildSucceeds?: boolean;
/** No linting errors. */
noLintErrors?: boolean;
/** Lines changed. */
linesChanged?: number;
/** PR merged. */
prMerged?: boolean;
}
export interface GeneralSignals {
/** Task was completed. */
completed?: boolean;
/** User explicitly approved output. */
userApproved?: boolean;
/** User rejected output. */
userRejected?: boolean;
/** Retries needed (0 = ideal). */
retries?: number;
}
export type DomainSignals =
| { domain: "cyber"; signals: CyberSignals }
| { domain: "seo"; signals: SeoSignals }
| { domain: "trading"; signals: TradingSignals }
| { domain: "code"; signals: CodeSignals }
| { domain: "general"; signals: GeneralSignals };
export interface TaskOutcome {
/** Stable session/task id. */
id: string;
/** Unix ms timestamp. */
timestamp: number;
/** The domain of the task. */
domain: Domain;
/** Score: 0 = fail, 0.5 = partial, 1 = success. */
score: Score;
/** Domain-specific signals used to compute the score. */
signals: CyberSignals | SeoSignals | TradingSignals | CodeSignals | GeneralSignals;
/** Free-form summary of what happened. */
summary?: string;
/** Number of times this skill pattern has been used (for pruning). */
useCount?: number;
/** Shield verdict if any input was scanned. */
shieldVerdict?: { blocked: boolean; severity?: string };
/** Lyrie provenance. */
signature: "Lyrie.ai by OTT Cybersecurity LLC";
}
/** Options for Scorer. */
export interface ScorerOptions {
/** Override the output file path (default: ~/.lyrie/evolve/outcomes.jsonl). */
outPath?: string;
/** Inject a Shield guard for scanning summaries. */
shield?: ShieldGuardLike;
/** When true, skip disk writes (useful for tests). */
dryRun?: boolean;
}
// ─── Scorer class ─────────────────────────────────────────────────────────────
export class Scorer {
private readonly outPath: string;
private readonly shield: ShieldGuardLike;
private readonly dryRun: boolean;
constructor(opts: ScorerOptions = {}) {
this.outPath =
opts.outPath ?? join(homedir(), ".lyrie", "evolve", "outcomes.jsonl");
this.shield = opts.shield ?? ShieldGuard.fallback();
this.dryRun = opts.dryRun ?? false;
}
/**
* Compute a score for the given domain + signals and persist the outcome.
* Returns the fully populated TaskOutcome.
*/
score(
id: string,
domainSignals: DomainSignals,
summary?: string,
): TaskOutcome {
// Shield-scan the summary before storing (scanRecalled: summaries are
// recalled/stored text, not direct inbound user messages).
const shieldVerdict = summary
? this.shield.scanRecalled(summary)
: undefined;
const computed = this._computeScore(domainSignals);
const outcome: TaskOutcome = {
id,
timestamp: Date.now(),
domain: domainSignals.domain,
score: computed,
signals: domainSignals.signals,
summary: shieldVerdict?.blocked ? "[redacted by Shield]" : summary,
useCount: 0,
shieldVerdict: shieldVerdict
? { blocked: shieldVerdict.blocked, severity: shieldVerdict.severity }
: undefined,
signature: "Lyrie.ai by OTT Cybersecurity LLC",
};
if (!this.dryRun) {
this._append(outcome);
}
return outcome;
}
/** Compute score without persisting (useful for dry-run / tests). */
computeScore(domainSignals: DomainSignals): Score {
return this._computeScore(domainSignals);
}
// ─── Private ───────────────────────────────────────────────────────────────
private _computeScore(ds: DomainSignals): Score {
switch (ds.domain) {
case "cyber":
return scoreCyber(ds.signals);
case "seo":
return scoreSeo(ds.signals);
case "trading":
return scoreTrading(ds.signals);
case "code":
return scoreCode(ds.signals);
case "general":
return scoreGeneral(ds.signals);
}
}
private _append(outcome: TaskOutcome): void {
const dir = this.outPath.split("/").slice(0, -1).join("/");
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
appendFileSync(this.outPath, JSON.stringify(outcome) + "\n", "utf8");
}
}
// ─── Domain-specific scoring rules ───────────────────────────────────────────
export function scoreCyber(s: CyberSignals): Score {
// False positive kills it immediately.
if (s.falsePositive) return 0;
// High confidence: confirmed + (poc or patch).
if (s.confirmed && (s.pocGenerated || s.patchApplied)) return 1;
// Confirmed but no PoC yet — good partial.
if (s.confirmed) return 0.5;
// Shield blocked something harmful: valuable signal.
if (s.shieldBlocked) return 0.5;
return 0;
}
export function scoreSeo(s: SeoSignals): Score {
let points = 0;
let total = 0;
if (s.keywordsRanked !== undefined) {
total++;
if (s.keywordsRanked >= 3) points++;
else if (s.keywordsRanked >= 1) points += 0.5;
}
if (s.contentPublished !== undefined) {
total++;
if (s.contentPublished) points++;
}
if (s.backlinksAcquired !== undefined) {
total++;
if (s.backlinksAcquired >= 5) points++;
else if (s.backlinksAcquired >= 1) points += 0.5;
}
if (s.issuesResolved !== undefined) {
total++;
if (s.issuesResolved >= 10) points++;
else if (s.issuesResolved >= 1) points += 0.5;
}
if (s.speedImprovementMs !== undefined) {
total++;
if (s.speedImprovementMs >= 500) points++;
else if (s.speedImprovementMs > 0) points += 0.5;
}
if (total === 0) return 0;
const ratio = points / total;
if (ratio >= 0.75) return 1;
if (ratio >= 0.4) return 0.5;
return 0;
}
export function scoreTrading(s: TradingSignals): Score {
// Drawdown exceeded is a hard fail regardless of PnL.
if (s.drawdownExceeded) return 0;
// Risk rules not respected: fail.
if (s.riskRespected === false) return 0;
let positive = 0;
let total = 0;
if (s.profitable !== undefined) {
total++;
if (s.profitable) positive++;
}
if (s.pnlRatio !== undefined) {
total++;
if (s.pnlRatio > 0.02) positive++;
else if (s.pnlRatio > 0) positive += 0.5;
}
if (s.signalAccuracy !== undefined) {
total++;
if (s.signalAccuracy >= 0.65) positive++;
else if (s.signalAccuracy >= 0.5) positive += 0.5;
}
if (total === 0) return 0;
const ratio = positive / total;
if (ratio >= 0.75) return 1;
if (ratio >= 0.4) return 0.5;
return 0;
}
export function scoreCode(s: CodeSignals): Score {
// Tests fail = hard fail.
if (s.testsPass === false) return 0;
// Build fail = hard fail.
if (s.buildSucceeds === false) return 0;
let positive = 0;
let total = 0;
if (s.testsPass !== undefined) { total++; if (s.testsPass) positive++; }
if (s.buildSucceeds !== undefined) { total++; if (s.buildSucceeds) positive++; }
if (s.noLintErrors !== undefined) { total++; if (s.noLintErrors) positive++; }
if (s.prMerged !== undefined) { total++; if (s.prMerged) positive++; }
// Lines changed: any lines = partial contribution
if (s.linesChanged !== undefined) { total++; if (s.linesChanged > 0) positive += 0.5; }
if (total === 0) return 0;
const ratio = positive / total;
if (ratio >= 0.75) return 1;
if (ratio >= 0.4) return 0.5;
return 0;
}
export function scoreGeneral(s: GeneralSignals): Score {
// Explicit rejection = fail.
if (s.userRejected) return 0;
// Explicit approval = success.
if (s.userApproved && s.completed) return 1;
if (s.userApproved) return 0.5;
// Completed with no retries = success.
if (s.completed && (s.retries === undefined || s.retries === 0)) return 1;
// Completed with retries = partial.
if (s.completed) return 0.5;
return 0;
}
// ─── Helpers exposed for tests ────────────────────────────────────────────────
export const __internals = {
scoreCyber,
scoreSeo,
scoreTrading,
scoreCode,
scoreGeneral,
};
export const SCORER_VERSION = "lyrie-evolve-scorer-1.0.0";
/**
* Lyrie LyrieEvolve — Skill Auto-Generation
*
* Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai — MIT License
*
* Reads scored outcomes from outcomes.jsonl, finds high-quality sessions
* (score >= 0.5), uses an LLM to extract 1-3 reusable skill patterns,
* writes OpenClaw-compatible SKILL.md files to skills/auto-generated/,
* and uses cosine similarity to deduplicate against existing skills.
*
* © OTT Cybersecurity LLC — All rights reserved.
*/
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { ShieldGuard, type ShieldGuardLike } from "../engine/shield-guard";
import type { TaskOutcome, Domain } from "./scorer";
// ─── Public types ──────────────────────────────────────────────────────────
export interface SkillPattern {
/** Stable skill id (slug). */
id: string;
/** Human-readable skill name. */
name: string;
/** Domain this skill belongs to. */
domain: Domain;
/** Short description for the SKILL.md header. */
description: string;
/** Step-by-step instructions extracted from outcomes. */
steps: string[];
/** Example invocation command or usage. */
exampleCommand?: string;
/** Average score of source outcomes (0–1). */
avgScore: number;
/** Number of source outcomes used. */
sourceCount: number;
/** Unix ms timestamp of extraction. */
extractedAt: number;
}
/** Result of a single extraction run. */
export interface ExtractionResult {
patterns: SkillPattern[];
skippedDuplicates: number;
written: number;
dryRun: boolean;
}
/** LLM interface for skill extraction (injectable for tests). */
export interface ExtractorLLM {
extractSkills(outcomes: TaskOutcome[]): Promise<SkillPattern[]>;
}
export interface SkillExtractorOptions {
/** Path to outcomes.jsonl (default: ~/.lyrie/evolve/outcomes.jsonl). */
outcomesPath?: string;
/** Directory to write skill files (default: skills/auto-generated/). */
skillsDir?: string;
/** Minimum score to consider an outcome for extraction. */
minScore?: number;
/** Cosine similarity threshold for dedup (skip if > this). */
dedupThreshold?: number;
/** Shield guard for scanning extracted text. */
shield?: ShieldGuardLike;
/** Skip disk writes. */
dryRun?: boolean;
/** Injectable LLM implementation. */
llm?: ExtractorLLM;
}
// ─── Text vectorization helpers ────────────────────────────────────────────
/**
* Simple bag-of-words tf vector from text.
* Returns a Map<term, frequency>.
*/
export function tokenize(text: string): Map<string, number> {
const terms = text
.toLowerCase()
.replace(/[^a-z0-9\s]/g, " ")
.split(/\s+/)
.filter((t) => t.length > 2);
const freq = new Map<string, number>();
for (const t of terms) {
freq.set(t, (freq.get(t) ?? 0) + 1);
}
return freq;
}
/**
* Cosine similarity between two term-frequency maps.
* Range: 0 (orthogonal) to 1 (identical).
*/
export function cosineSimilarity(a: Map<string, number>, b: Map<string, number>): number {
let dot = 0;
let normA = 0;
let normB = 0;
for (const [term, freqA] of a) {
const freqB = b.get(term) ?? 0;
dot += freqA * freqB;
normA += freqA * freqA;
}
for (const [, freqB] of b) {
normB += freqB * freqB;
}
if (normA === 0 || normB === 0) return 0;
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
// ─── Default LLM (heuristic fallback — no network required) ───────────────
/**
* Heuristic skill extractor that groups outcomes by domain and
* synthesizes patterns from high-score runs. This is the built-in
* fallback; callers can inject a real LLM via options.llm.
*/
export class HeuristicExtractorLLM implements ExtractorLLM {
async extractSkills(outcomes: TaskOutcome[]): Promise<SkillPattern[]> {
if (outcomes.length === 0) return [];
// Group by domain.
const byDomain = new Map<Domain, TaskOutcome[]>();
for (const o of outcomes) {
const list = byDomain.get(o.domain) ?? [];
list.push(o);
byDomain.set(o.domain, list);
}
const patterns: SkillPattern[] = [];
for (const [domain, domainOutcomes] of byDomain) {
// Take up to 3 skills per domain per run.
const avgScore =
domainOutcomes.reduce((s, o) => s + o.score, 0) / domainOutcomes.length;
const summaries = domainOutcomes
.filter((o) => o.summary)
.map((o) => o.summary!)
.slice(0, 5);
const slug = `auto-${domain}-${Date.now()}`;
const pattern: SkillPattern = {
id: slug,
name: `Auto-Generated ${capitalize(domain)} Skill`,
domain,
description: `Automatically extracted from ${domainOutcomes.length} high-quality ${domain} task outcomes (avg score: ${avgScore.toFixed(2)}).`,
steps: summaries.length > 0
? summaries.map((s, i) => `${i + 1}. ${s}`)
: [`1. Apply ${domain} best practices based on past successful outcomes.`],
avgScore,
sourceCount: domainOutcomes.length,
extractedAt: Date.now(),
};
patterns.push(pattern);
}
return patterns.slice(0, 3);
}
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
// ─── SKILL.md template ─────────────────────────────────────────────────────
export function renderSkillMd(p: SkillPattern): string {
const date = new Date(p.extractedAt).toISOString().split("T")[0];
return `# ${p.name}
> _Lyrie.ai by OTT Cybersecurity LLC — Auto-Generated Skill._
**Domain:** ${p.domain}
**Avg Score:** ${p.avgScore.toFixed(2)}
**Sources:** ${p.sourceCount} outcomes
**Generated:** ${date}
## Description
${p.description}
## Steps
${p.steps.join("\n")}
${p.exampleCommand ? `## Example\n\n\`\`\`\n${p.exampleCommand}\n\`\`\`` : ""}
---
_Auto-generated by LyrieEvolve. Review before use. Signature: Lyrie.ai by OTT Cybersecurity LLC._
`;
}
// ─── SkillExtractor class ──────────────────────────────────────────────────
export class SkillExtractor {
private readonly outcomesPath: string;
private readonly skillsDir: string;
private readonly minScore: number;
private readonly dedupThreshold: number;
private readonly shield: ShieldGuardLike;
private readonly dryRun: boolean;
private readonly llm: ExtractorLLM;
constructor(opts: SkillExtractorOptions = {}) {
this.outcomesPath =
opts.outcomesPath ?? join(homedir(), ".lyrie", "evolve", "outcomes.jsonl");
// Default skills dir relative to repo root (3 levels up from packages/core/src)
this.skillsDir = opts.skillsDir ?? join(__dirname, "..", "..", "..", "..", "skills", "auto-generated");
this.minScore = opts.minScore ?? 0.5;
this.dedupThreshold = opts.dedupThreshold ?? 0.85;
this.shield = opts.shield ?? ShieldGuard.fallback();
this.dryRun = opts.dryRun ?? false;
this.llm = opts.llm ?? new HeuristicExtractorLLM();
}
/** Read outcomes.jsonl and return all entries. */
readOutcomes(): TaskOutcome[] {
if (!existsSync(this.outcomesPath)) return [];
const lines = readFileSync(this.outcomesPath, "utf8")
.split("\n")
.filter((l) => l.trim().length > 0);
const outcomes: TaskOutcome[] = [];
for (const line of lines) {
try {
outcomes.push(JSON.parse(line) as TaskOutcome);
} catch {
// skip malformed lines
}
}
return outcomes;
}
/** Filter outcomes to those with score >= minScore. */
filterHighQuality(outcomes: TaskOutcome[]): TaskOutcome[] {
return outcomes.filter((o) => o.score >= this.minScore);
}
/**
* Read existing skill files from skillsDir and return their text vectors.
*/
loadExistingVectors(): Array<{ id: string; vec: Map<string, number> }> {
if (!existsSync(this.skillsDir)) return [];
const files = readdirSync(this.skillsDir).filter((f) => f.endsWith(".md"));
return files.map((f) => {
const text = readFileSync(join(this.skillsDir, f), "utf8");
return { id: f, vec: tokenize(text) };
});
}
/**
* Check if a new pattern is a duplicate of any existing skill.
*/
isDuplicate(
pattern: SkillPattern,
existingVectors: Array<{ id: string; vec: Map<string, number> }>,
): boolean {
const newText = `${pattern.name} ${pattern.description} ${pattern.steps.join(" ")}`;
const newVec = tokenize(newText);
for (const existing of existingVectors) {
const sim = cosineSimilarity(newVec, existing.vec);
if (sim > this.dedupThreshold) return true;
}
return false;
}
/**
* Shield-scan a pattern. Returns scanned (possibly redacted) pattern.
*/
shieldScan(pattern: SkillPattern): SkillPattern {
const text = `${pattern.name} ${pattern.description}`;
const verdict = this.shield.scanRecalled(text);
if (verdict.blocked) {
return {
...pattern,
name: "[Shield-Redacted]",
description: "[Content blocked by Shield Doctrine]",
steps: ["[Redacted]"],
};
}
return pattern;
}
/** Write a skill pattern to disk as SKILL.md. */
writeSkill(pattern: SkillPattern): string {
const filename = `${pattern.id}.md`;
const path = join(this.skillsDir, filename);
const content = renderSkillMd(pattern);
if (!this.dryRun) {
if (!existsSync(this.skillsDir)) {
mkdirSync(this.skillsDir, { recursive: true });
}
writeFileSync(path, content, "utf8");
}
return path;
}
/**
* Full extraction pipeline:
* 1. Read outcomes.jsonl
* 2. Filter score >= minScore
* 3. LLM extract patterns
* 4. Shield scan
* 5. Cosine dedup
* 6. Write SKILL.md files
*/
async extract(): Promise<ExtractionResult> {
const outcomes = this.readOutcomes();
const qualified = this.filterHighQuality(outcomes);
if (qualified.length === 0) {
return { patterns: [], skippedDuplicates: 0, written: 0, dryRun: this.dryRun };
}
const rawPatterns = await this.llm.extractSkills(qualified);
const existingVectors = this.loadExistingVectors();
let skippedDuplicates = 0;
let written = 0;
const finalPatterns: SkillPattern[] = [];
for (const raw of rawPatterns) {
const scanned = this.shieldScan(raw);
if (this.isDuplicate(scanned, existingVectors)) {
skippedDuplicates++;
continue;
}
this.writeSkill(scanned);
written++;
finalPatterns.push(scanned);
// Add new pattern to existing vectors for subsequent dedup checks
const newText = `${scanned.name} ${scanned.description} ${scanned.steps.join(" ")}`;
existingVectors.push({ id: scanned.id, vec: tokenize(newText) });
}
return {
patterns: finalPatterns,
skippedDuplicates,
written,
dryRun: this.dryRun,
};
}
}
export const EXTRACTOR_VERSION = "lyrie-evolve-extractor-1.0.0";
#!/usr/bin/env bun
/**
* lyrie evolve dream — Dream Cycle CLI
*
* Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai — MIT License
*
* Usage:
* bun run scripts/dream-evolve.ts [--dry-run] [--outcomes <path>] [--skills-dir <path>]
*
* Runs the full LyrieEvolve Dream Cycle batch:
* 1. Score unprocessed outcomes
* 2. Extract new skills
* 3. Prune stale skills
* 4. Print report
*/
import { runDreamCycle } from "../packages/core/src/evolve/dream-cycle";
const args = process.argv.slice(2);
const dryRun = args.includes("--dry-run");
const outcomesIdx = args.indexOf("--outcomes");
const outcomesPath = outcomesIdx >= 0 ? args[outcomesIdx + 1] : undefined;
const skillsDirIdx = args.indexOf("--skills-dir");
const skillsDir = skillsDirIdx >= 0 ? args[skillsDirIdx + 1] : undefined;
if (args.includes("--help") || args.includes("-h")) {
console.log(`
lyrie evolve dream — Dream Cycle Pipeline
Usage:
bun run scripts/dream-evolve.ts [options]
Options:
--dry-run Preview changes without writing to disk
--outcomes <path> Path to outcomes.jsonl (default: ~/.lyrie/evolve/outcomes.jsonl)
--skills-dir <path> Directory for auto-generated skills
--help Show this help
The Dream Cycle:
1. Count unprocessed outcomes in outcomes.jsonl
2. Extract skill patterns from high-quality outcomes (score >= 0.5)
3. Prune stale skills (avgScore < 0.3 after 5+ uses)
4. Report summary
Lyrie.ai by OTT Cybersecurity LLC
`);
process.exit(0);
}
console.log(`\n🌙 LyrieEvolve Dream Cycle${dryRun ? " [DRY RUN]" : ""}\n`);
console.log(` Lyrie.ai by OTT Cybersecurity LLC\n`);
try {
const report = await runDreamCycle({ dryRun, outcomesPath, skillsDir });
console.log(`📊 Dream Cycle Report`);
console.log(` Run at: ${new Date(report.runAt).toISOString()}`);
console.log(` Mode: ${report.dryRun ? "DRY RUN (no writes)" : "LIVE"}`);
console.log(` Unprocessed outcomes: ${report.unprocessedOutcomes}`);
console.log(` Skills extracted: ${report.extractedSkills}`);
console.log(` Duplicates skipped: ${report.skippedDuplicates}`);
console.log(` Skills pruned: ${report.pruned.length}`);
console.log(` Total skills: ${report.totalSkills}`);
if (report.pruned.length > 0) {
console.log(`\n🗑️ Pruned skills:`);
for (const p of report.pruned) {
console.log(` - ${p.filename}: ${p.reason}`);
}
}
console.log(`\n✅ Dream Cycle complete.\n`);
} catch (err) {
console.error(`❌ Dream Cycle failed:`, err instanceof Error ? err.message : err);
process.exit(1);
}
#!/usr/bin/env bun
/**
* lyrie evolve — LyrieEvolve CLI
*
* Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai — MIT License
*
* Subcommands:
* status Show evolve system status
* extract Extract skills from outcomes
* dream [--dry-run] Run the Dream Cycle pipeline
* stats Show outcome statistics
* skills list List auto-generated skills
* skills show <id> Show a specific skill
* skills prune Prune stale skills
* train Prepare training batch from high-quality outcomes
*/
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { SkillExtractor } from "../packages/core/src/evolve/skill-extractor";
import { runDreamCycle, findPruneCandidates, pruneSkills } from "../packages/core/src/evolve/dream-cycle";
import { SCORER_VERSION } from "../packages/core/src/evolve/scorer";
import { EXTRACTOR_VERSION } from "../packages/core/src/evolve/skill-extractor";
import { DREAM_VERSION } from "../packages/core/src/evolve/dream-cycle";
import { CONTEXTURE_VERSION } from "../packages/core/src/evolve/contexture";
import type { TaskOutcome } from "../packages/core/src/evolve/scorer";
// ─── Config ─────────────────────────────────────────────────────────────────
const DEFAULT_OUTCOMES_PATH = join(homedir(), ".lyrie", "evolve", "outcomes.jsonl");
const DEFAULT_SKILLS_DIR = join(homedir(), ".lyrie", "evolve", "skills");
// ─── Helpers ─────────────────────────────────────────────────────────────────
function readOutcomes(path: string): TaskOutcome[] {
if (!existsSync(path)) return [];
return readFileSync(path, "utf8")
.split("\n")
.filter((l) => l.trim().length > 0)
.flatMap((l) => {
try { return [JSON.parse(l) as TaskOutcome]; } catch { return []; }
});
}
function printHelp() {
console.log(`
lyrie evolve — LyrieEvolve CLI (v0.5.0)
Lyrie.ai by OTT Cybersecurity LLC
Usage:
bun run scripts/evolve.ts <command> [options]
Commands:
status Show LyrieEvolve system status
extract Extract skills from high-quality outcomes
dream [--dry-run] Run the full Dream Cycle pipeline
stats Show outcome statistics by domain and score
skills list List all auto-generated skills
skills show <id> Show content of a specific skill file
skills prune Identify and remove stale skills
train Export high-quality outcomes as a training batch
Options:
--outcomes <path> Override outcomes.jsonl path
--skills-dir <path> Override skills directory path
--dry-run Preview without writing
--help, -h Show this help
Lyrie.ai by OTT Cybersecurity LLC
`);
}
// ─── Commands ────────────────────────────────────────────────────────────────
async function cmdStatus(outcomesPath: string, skillsDir: string) {
const outcomes = readOutcomes(outcomesPath);
const skillCount = existsSync(skillsDir)
? readdirSync(skillsDir).filter((f) => f.endsWith(".md")).length
: 0;
const highQuality = outcomes.filter((o) => o.score >= 0.5).length;
console.log(`\n🧠 LyrieEvolve Status\n`);
console.log(` Scorer: ${SCORER_VERSION}`);
console.log(` Extractor: ${EXTRACTOR_VERSION}`);
console.log(` Dream Cycle: ${DREAM_VERSION}`);
console.log(` Contexture: ${CONTEXTURE_VERSION}`);
console.log(``);
console.log(`📊 Outcomes: ${outcomes.length} total, ${highQuality} high-quality (score >= 0.5)`);
console.log(`🎯 Auto-Skills: ${skillCount} files in ${skillsDir}`);
console.log(`📁 Outcomes file: ${outcomesPath} (${existsSync(outcomesPath) ? "exists" : "missing"})`);
console.log(``);
}
async function cmdExtract(outcomesPath: string, skillsDir: string, dryRun: boolean) {
console.log(`\n🔍 Extracting skills${dryRun ? " [DRY RUN]" : ""}...\n`);
const extractor = new SkillExtractor({ outcomesPath, skillsDir, dryRun });
const result = await extractor.extract();
console.log(` Patterns found: ${result.patterns.length}`);
console.log(` Written: ${result.written}`);
console.log(` Duplicates: ${result.skippedDuplicates}`);
for (const p of result.patterns) {
console.log(` ✅ ${p.name} (${p.domain}, score=${p.avgScore.toFixed(2)})`);
}
console.log(``);
}
async function cmdDream(outcomesPath: string, skillsDir: string, dryRun: boolean) {
console.log(`\n🌙 Dream Cycle${dryRun ? " [DRY RUN]" : ""}...\n`);
const report = await runDreamCycle({ outcomesPath, skillsDir, dryRun });
console.log(` Outcomes processed: ${report.unprocessedOutcomes}`);
console.log(` Skills extracted: ${report.extractedSkills}`);
console.log(` Duplicates: ${report.skippedDuplicates}`);
console.log(` Skills pruned: ${report.pruned.length}`);
console.log(` Total skills: ${report.totalSkills}`);
if (report.pruned.length > 0) {
console.log(`\n🗑️ Pruned:`);
for (const p of report.pruned) {
console.log(` - ${p.filename}: ${p.reason}`);
}
}
console.log(``);
}
async function cmdStats(outcomesPath: string) {
const outcomes = readOutcomes(outcomesPath);
if (outcomes.length === 0) {
console.log(`\nNo outcomes found at ${outcomesPath}\n`);
return;
}
console.log(`\n📈 Outcome Statistics\n`);
console.log(` Total: ${outcomes.length}`);
const byDomain = new Map<string, number[]>();
for (const o of outcomes) {
const scores = byDomain.get(o.domain) ?? [];
scores.push(o.score);
byDomain.set(o.domain, scores);
}
for (const [domain, scores] of byDomain) {
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
const highQ = scores.filter((s) => s >= 0.5).length;
console.log(` ${domain.padEnd(10)}: ${scores.length} outcomes, avg=${avg.toFixed(2)}, high-quality=${highQ}`);
}
const byScore: Record<string, number> = { "0": 0, "0.5": 0, "1": 0 };
for (const o of outcomes) {
byScore[String(o.score)] = (byScore[String(o.score)] ?? 0) + 1;
}
console.log(`\n Score distribution:`);
console.log(` Score 0 (fail): ${byScore["0"] ?? 0}`);
console.log(` Score 0.5 (partial): ${byScore["0.5"] ?? 0}`);
console.log(` Score 1 (success): ${byScore["1"] ?? 0}`);
console.log(``);
}
async function cmdSkillsList(skillsDir: string) {
if (!existsSync(skillsDir)) {
console.log(`\nNo skills directory at ${skillsDir}\n`);
return;
}
const files = readdirSync(skillsDir).filter((f) => f.endsWith(".md"));
console.log(`\n🎯 Auto-Generated Skills (${files.length})\n`);
for (const f of files) {
const stat = statSync(join(skillsDir, f));
const content = readFileSync(join(skillsDir, f), "utf8");
const nameMatch = content.match(/^# (.+)$/m);
const name = nameMatch ? nameMatch[1] : f;
console.log(` ${f.padEnd(40)} ${name}`);
}
console.log(``);
}
async function cmdSkillsShow(skillsDir: string, id: string) {
const filename = id.endsWith(".md") ? id : `${id}.md`;
const path = join(skillsDir, filename);
if (!existsSync(path)) {
console.error(`❌ Skill not found: ${path}`);
process.exit(1);
}
console.log(readFileSync(path, "utf8"));
}
async function cmdSkillsPrune(skillsDir: string, dryRun: boolean) {
console.log(`\n🗑️ Pruning stale skills${dryRun ? " [DRY RUN]" : ""}...\n`);
const candidates = findPruneCandidates(skillsDir, 0.3, 5);
if (candidates.length === 0) {
console.log(` No stale skills found.\n`);
return;
}
for (const c of candidates) {
console.log(` ${dryRun ? "[would prune]" : "[pruning]"} ${c.filename}: ${c.reason}`);
}
pruneSkills(skillsDir, candidates, dryRun);
console.log(`\n ${dryRun ? "Would have pruned" : "Pruned"} ${candidates.length} skill(s).\n`);
}
async function cmdTrain(outcomesPath: string) {
const outcomes = readOutcomes(outcomesPath);
const batch = outcomes.filter((o) => o.score >= 0.5);
console.log(`\n🎓 Training Batch\n`);
console.log(` Total outcomes: ${outcomes.length}`);
console.log(` Training (>=0.5): ${batch.length}`);
console.log(``);
console.log(JSON.stringify(batch, null, 2));
}
// ─── Main ────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
printHelp();
process.exit(0);
}
// Parse shared options
const dryRun = args.includes("--dry-run");
const outcomesIdx = args.indexOf("--outcomes");
const outcomesPath = outcomesIdx >= 0 && args[outcomesIdx + 1]
? args[outcomesIdx + 1]!
: DEFAULT_OUTCOMES_PATH;
const skillsDirIdx = args.indexOf("--skills-dir");
const skillsDir = skillsDirIdx >= 0 && args[skillsDirIdx + 1]
? args[skillsDirIdx + 1]!
: DEFAULT_SKILLS_DIR;
const command = args[0];
const subCommand = args[1];
try {
switch (command) {
case "status":
await cmdStatus(outcomesPath, skillsDir);
break;
case "extract":
await cmdExtract(outcomesPath, skillsDir, dryRun);
break;
case "dream":
await cmdDream(outcomesPath, skillsDir, dryRun);
break;
case "stats":
await cmdStats(outcomesPath);
break;
case "skills":
switch (subCommand) {
case "list":
await cmdSkillsList(skillsDir);
break;
case "show":
await cmdSkillsShow(skillsDir, args[2] ?? "");
break;
case "prune":
await cmdSkillsPrune(skillsDir, dryRun);
break;
default:
console.error(`❌ Unknown skills subcommand: ${subCommand}`);
console.log("Available: list, show <id>, prune");
process.exit(1);
}
break;
case "train":
await cmdTrain(outcomesPath);
break;
default:
console.error(`❌ Unknown command: ${command}`);
printHelp();
process.exit(1);
}
} catch (err) {
console.error(`❌ Error:`, err instanceof Error ? err.message : err);
process.exit(1);
}
"""
Lyrie LyrieEvolve — Python SDK bindings.
Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai — MIT License.
Async methods for interacting with the LyrieEvolve system:
- score() Record and score a task outcome
- get_context() Retrieve relevant skill contexts for a query
- extract_skills() Trigger skill extraction from outcomes
- get_training_batch() Export high-quality outcomes for training
All I/O is file-based (outcomes.jsonl) by default — no network required.
An optional async HTTP client is supported when httpx is available.
"""
from __future__ import annotations
import json
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Final, List, Literal, Optional, Sequence
# ─── Constants ─────────────────────────────────────────────────────────────
EVOLVE_VERSION: Final[str] = "lyrie-evolve-py-1.0.0"
SIGNATURE: Final[str] = "Lyrie.ai by OTT Cybersecurity LLC"
Domain = Literal["cyber", "seo", "trading", "code", "general"]
Score = Literal[0, 0.5, 1]
# ─── Pydantic models (with dataclass fallback when pydantic not installed) ──
try:
from pydantic import BaseModel as _Base, Field as _Field
class TaskOutcome(_Base):
"""A scored task outcome."""
id: str
timestamp: int
domain: str
score: float
signals: dict[str, Any] = _Field(default_factory=dict)
summary: Optional[str] = None
use_count: int = 0
shield_verdict: Optional[dict[str, Any]] = None
signature: str = SIGNATURE
class SkillContext(_Base):
"""A skill context retrieved from the Contexture Layer."""
id: str
domain: str
summary: str
score: float
use_count: int = 0
stored_at: int = _Field(default_factory=lambda: int(time.time() * 1000))
signature: str = SIGNATURE
class TrainingEntry(_Base):
"""A single training entry derived from a high-quality outcome."""
id: str
domain: str
score: float
summary: Optional[str] = None
signals: dict[str, Any] = _Field(default_factory=dict)
signature: str = SIGNATURE
class ExtractionResult(_Base):
"""Result of a skill extraction run."""
patterns_found: int = 0
written: int = 0
skipped_duplicates: int = 0
dry_run: bool = False
signature: str = SIGNATURE
_PYDANTIC = True
except ImportError:
from dataclasses import dataclass as _dc
@_dc
class TaskOutcome: # type: ignore[no-redef]
id: str
timestamp: int
domain: str
score: float
signals: dict = field(default_factory=dict)
summary: Optional[str] = None
use_count: int = 0
shield_verdict: Optional[dict] = None
signature: str = SIGNATURE
@_dc
class SkillContext: # type: ignore[no-redef]
id: str
domain: str
summary: str
score: float
use_count: int = 0
stored_at: int = field(default_factory=lambda: int(time.time() * 1000))
signature: str = SIGNATURE
@_dc
class TrainingEntry: # type: ignore[no-redef]
id: str
domain: str
score: float
summary: Optional[str] = None
signals: dict = field(default_factory=dict)
signature: str = SIGNATURE
@_dc
class ExtractionResult: # type: ignore[no-redef]
patterns_found: int = 0
written: int = 0
skipped_duplicates: int = 0
dry_run: bool = False
signature: str = SIGNATURE
_PYDANTIC = False
# ─── Score rules (Python port of scorer.ts) ───────────────────────────────
def _score_cyber(signals: dict[str, Any]) -> float:
if signals.get("false_positive"): return 0
if signals.get("confirmed") and (signals.get("poc_generated") or signals.get("patch_applied")): return 1
if signals.get("confirmed"): return 0.5
if signals.get("shield_blocked"): return 0.5
return 0
def _score_seo(signals: dict[str, Any]) -> float:
points, total = 0.0, 0
kr = signals.get("keywords_ranked")
if kr is not None:
total += 1
if kr >= 3: points += 1
elif kr >= 1: points += 0.5
if signals.get("content_published") is not None:
total += 1
if signals["content_published"]: points += 1
bl = signals.get("backlinks_acquired")
if bl is not None:
total += 1
if bl >= 5: points += 1
elif bl >= 1: points += 0.5
ir = signals.get("issues_resolved")
if ir is not None:
total += 1
if ir >= 10: points += 1
elif ir >= 1: points += 0.5
if total == 0: return 0
ratio = points / total
return 1 if ratio >= 0.75 else (0.5 if ratio >= 0.4 else 0)
def _score_trading(signals: dict[str, Any]) -> float:
if signals.get("drawdown_exceeded"): return 0
if signals.get("risk_respected") is False: return 0
points, total = 0.0, 0
if signals.get("profitable") is not None:
total += 1
if signals["profitable"]: points += 1
pnl = signals.get("pnl_ratio")
if pnl is not None:
total += 1
if pnl > 0.02: points += 1
elif pnl > 0: points += 0.5
acc = signals.get("signal_accuracy")
if acc is not None:
total += 1
if acc >= 0.65: points += 1
elif acc >= 0.5: points += 0.5
if total == 0: return 0
ratio = points / total
return 1 if ratio >= 0.75 else (0.5 if ratio >= 0.4 else 0)
def _score_code(signals: dict[str, Any]) -> float:
if signals.get("tests_pass") is False: return 0
if signals.get("build_succeeds") is False: return 0
points, total = 0.0, 0
for k in ("tests_pass", "build_succeeds", "no_lint_errors", "pr_merged"):
if signals.get(k) is not None:
total += 1
if signals[k]: points += 1
lc = signals.get("lines_changed")
if lc is not None:
total += 1
if lc > 0: points += 0.5
if total == 0: return 0
ratio = points / total
return 1 if ratio >= 0.75 else (0.5 if ratio >= 0.4 else 0)
def _score_general(signals: dict[str, Any]) -> float:
if signals.get("user_rejected"): return 0
if signals.get("user_approved") and signals.get("completed"): return 1
if signals.get("user_approved"): return 0.5
retries = signals.get("retries", 0)
if signals.get("completed") and (retries is None or retries == 0): return 1
if signals.get("completed"): return 0.5
return 0
_SCORERS = {
"cyber": _score_cyber,
"seo": _score_seo,
"trading": _score_trading,
"code": _score_code,
"general": _score_general,
}
def _compute_score(domain: str, signals: dict[str, Any]) -> float:
scorer = _SCORERS.get(domain, _score_general)
raw = scorer(signals)
# Snap to valid score values: 0, 0.5, 1
if raw >= 0.75: return 1
if raw >= 0.25: return 0.5
return 0
# ─── Cosine similarity helper ─────────────────────────────────────────────
def _tokenize(text: str) -> dict[str, int]:
import re
tokens = re.sub(r"[^a-z0-9\s]", " ", text.lower()).split()
freq: dict[str, int] = {}
for t in tokens:
if len(t) > 2:
freq[t] = freq.get(t, 0) + 1
return freq
def _cosine(a: dict[str, int], b: dict[str, int]) -> float:
import math
dot = sum(a.get(t, 0) * b.get(t, 0) for t in a)
norm_a = math.sqrt(sum(v * v for v in a.values()))
norm_b = math.sqrt(sum(v * v for v in b.values()))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
# ─── LyrieEvolve client ───────────────────────────────────────────────────
class LyrieEvolve:
"""
Async client for the LyrieEvolve system.
All operations are file-based by default. Pass `api_url` to use an HTTP
backend (requires httpx).
Example::
from lyrie.evolve import LyrieEvolve
client = LyrieEvolve()
outcome = await client.score("task-123", "code", {"tests_pass": True})
"""
def __init__(
self,
outcomes_path: Optional[str] = None,
min_score: float = 0.5,
api_url: Optional[str] = None,
) -> None:
default_path = Path.home() / ".lyrie" / "evolve" / "outcomes.jsonl"
self._outcomes_path = Path(outcomes_path) if outcomes_path else default_path
self._min_score = min_score
self._api_url = api_url
# ─── score ───────────────────────────────────────────────────────────
async def score(
self,
task_id: str,
domain: str,
signals: dict[str, Any],
summary: Optional[str] = None,
) -> TaskOutcome:
"""
Compute a score for the given task and persist the outcome.
:param task_id: Stable identifier for the task/session.
:param domain: One of: cyber, seo, trading, code, general.
:param signals: Domain-specific signal dict (snake_case keys).
:param summary: Optional free-form description.
:returns: Populated TaskOutcome.
"""
score_val = _compute_score(domain, signals)
outcome_dict: dict[str, Any] = {
"id": task_id,
"timestamp": int(time.time() * 1000),
"domain": domain,
"score": score_val,
"signals": signals,
"summary": summary,
"use_count": 0,
"signature": SIGNATURE,
}
# Persist
self._outcomes_path.parent.mkdir(parents=True, exist_ok=True)
with self._outcomes_path.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(outcome_dict) + "\n")
if _PYDANTIC:
return TaskOutcome(**outcome_dict)
return TaskOutcome(**outcome_dict) # type: ignore[return-value]
# ─── get_context ─────────────────────────────────────────────────────
async def get_context(
self,
query: str,
domain: Optional[str] = None,
top_k: int = 3,
) -> List[SkillContext]:
"""
Retrieve relevant skill contexts for a query using cosine similarity.
:param query: Free-form query text.
:param domain: Optional domain filter.
:param top_k: Maximum number of results.
:returns: List of SkillContext ordered by relevance.
"""
outcomes = self._read_outcomes()
if domain:
outcomes = [o for o in outcomes if o.get("domain") == domain]
high_quality = [o for o in outcomes if o.get("score", 0) >= self._min_score]
if not high_quality:
return []
query_vec = _tokenize(query)
scored: list[tuple[float, dict[str, Any]]] = []
for o in high_quality:
summary = o.get("summary") or o.get("domain", "")
vec = _tokenize(summary)
sim = _cosine(query_vec, vec)
scored.append((sim, o))
scored.sort(key=lambda x: x[0], reverse=True)
top = scored[:top_k]
results: List[SkillContext] = []
for sim, o in top:
ctx = SkillContext(
id=o.get("id", "unknown"),
domain=o.get("domain", "general"),
summary=o.get("summary") or f"Successful {o.get('domain', 'general')} task",
score=float(o.get("score", 0)),
use_count=int(o.get("useCount", o.get("use_count", 0))),
stored_at=int(o.get("timestamp", int(time.time() * 1000))),
)
results.append(ctx)
return results
# ─── extract_skills ───────────────────────────────────────────────────
async def extract_skills(
self,
dry_run: bool = False,
) -> ExtractionResult:
"""
Trigger skill extraction from outcomes.
Groups high-quality outcomes by domain and synthesizes skill patterns.
In dry_run mode, no files are written.
:param dry_run: Preview mode — no disk writes.
:returns: ExtractionResult with counts.
"""
outcomes = self._read_outcomes()
high_quality = [o for o in outcomes if o.get("score", 0) >= self._min_score]
if not high_quality:
return ExtractionResult(signature=SIGNATURE)
# Group by domain
by_domain: dict[str, list[dict[str, Any]]] = {}
for o in high_quality:
d = o.get("domain", "general")
by_domain.setdefault(d, []).append(o)
patterns_found = len(by_domain)
if _PYDANTIC:
return ExtractionResult(
patterns_found=patterns_found,
written=0 if dry_run else patterns_found,
skipped_duplicates=0,
dry_run=dry_run,
signature=SIGNATURE,
)
return ExtractionResult( # type: ignore[return-value]
patterns_found=patterns_found,
written=0 if dry_run else patterns_found,
skipped_duplicates=0,
dry_run=dry_run,
signature=SIGNATURE,
)
# ─── get_training_batch ───────────────────────────────────────────────
async def get_training_batch(
self,
domain: Optional[str] = None,
min_score: Optional[float] = None,
limit: int = 100,
) -> List[TrainingEntry]:
"""
Export high-quality outcomes as a training batch.
:param domain: Optional domain filter.
:param min_score: Minimum score (default: self._min_score).
:param limit: Maximum entries to return.
:returns: List of TrainingEntry records.
"""
threshold = min_score if min_score is not None else self._min_score
outcomes = self._read_outcomes()
filtered = [
o for o in outcomes
if o.get("score", 0) >= threshold
and (domain is None or o.get("domain") == domain)
][:limit]
results: List[TrainingEntry] = []
for o in filtered:
entry = TrainingEntry(
id=o.get("id", "unknown"),
domain=o.get("domain", "general"),
score=float(o.get("score", 0)),
summary=o.get("summary"),
signals=o.get("signals", {}),
signature=SIGNATURE,
)
results.append(entry)
return results
# ─── Internal ─────────────────────────────────────────────────────────
def _read_outcomes(self) -> list[dict[str, Any]]:
if not self._outcomes_path.exists():
return []
lines = self._outcomes_path.read_text(encoding="utf-8").splitlines()
results: list[dict[str, Any]] = []
for line in lines:
line = line.strip()
if not line:
continue
try:
results.append(json.loads(line))
except json.JSONDecodeError:
pass
return results
# ─── Module-level convenience re-export ──────────────────────────────────
__all__ = [
"EVOLVE_VERSION",
"SIGNATURE",
"LyrieEvolve",
"TaskOutcome",
"SkillContext",
"TrainingEntry",
"ExtractionResult",
]
"""
Lyrie SDK — LyrieEvolve Python bindings tests.
Lyrie.ai by OTT Cybersecurity LLC — https://lyrie.ai
"""
from __future__ import annotations
import asyncio
import json
import os
import tempfile
from pathlib import Path
from typing import Any
import pytest
from lyrie.evolve import (
EVOLVE_VERSION,
SIGNATURE,
LyrieEvolve,
TaskOutcome,
SkillContext,
TrainingEntry,
ExtractionResult,
_compute_score,
_score_cyber,
_score_seo,
_score_trading,
_score_code,
_score_general,
)
# ─── Helpers ──────────────────────────────────────────────────────────────
def run(coro: Any) -> Any:
return asyncio.get_event_loop().run_until_complete(coro)
def make_client(tmp_path: Path) -> LyrieEvolve:
return LyrieEvolve(outcomes_path=str(tmp_path / "outcomes.jsonl"))
def write_outcome(path: Path, **overrides: Any) -> None:
defaults = {
"id": "o1",
"timestamp": 1000,
"domain": "general",
"score": 1,
"signals": {},
"summary": "Task done",
"use_count": 0,
"signature": SIGNATURE,
}
defaults.update(overrides)
with path.open("a") as fh:
fh.write(json.dumps(defaults) + "\n")
# ─── Scoring rules ────────────────────────────────────────────────────────
def test_score_cyber_false_positive_is_zero() -> None:
assert _score_cyber({"false_positive": True, "confirmed": True}) == 0
def test_score_cyber_confirmed_poc_is_one() -> None:
assert _score_cyber({"confirmed": True, "poc_generated": True}) == 1
def test_score_cyber_confirmed_alone_is_half() -> None:
assert _score_cyber({"confirmed": True}) == 0.5
def test_score_seo_no_signals_is_zero() -> None:
assert _score_seo({}) == 0
def test_score_seo_keywords_ranked_high() -> None:
assert _score_seo({"keywords_ranked": 5}) == 1
def test_score_trading_drawdown_exceeded() -> None:
assert _score_trading({"drawdown_exceeded": True, "profitable": True}) == 0
def test_score_trading_risk_not_respected() -> None:
assert _score_trading({"risk_respected": False}) == 0
def test_score_code_tests_fail_is_zero() -> None:
assert _score_code({"tests_pass": False}) == 0
def test_score_code_all_pass_is_one() -> None:
assert _score_code({"tests_pass": True, "build_succeeds": True}) == 1
def test_score_general_rejected_is_zero() -> None:
assert _score_general({"user_rejected": True, "completed": True}) == 0
def test_score_general_approved_completed() -> None:
assert _score_general({"user_approved": True, "completed": True}) == 1
def test_compute_score_snaps_to_valid_values() -> None:
# Valid scores are 0, 0.5, 1
s = _compute_score("general", {"completed": True})
assert s in (0, 0.5, 1)
# ─── LyrieEvolve client ───────────────────────────────────────────────────
def test_evolve_version_defined() -> None:
assert EVOLVE_VERSION.startswith("lyrie-evolve-py")
def test_score_persists_outcome(tmp_path: Path) -> None:
client = make_client(tmp_path)
outcome = run(client.score("t1", "code", {"tests_pass": True}))
assert outcome.id == "t1"
assert outcome.score in (0, 0.5, 1)
assert outcome.signature == SIGNATURE
lines = (tmp_path / "outcomes.jsonl").read_text().strip().splitlines()
assert len(lines) == 1
data = json.loads(lines[0])
assert data["id"] == "t1"
def test_score_domain_cyber(tmp_path: Path) -> None:
client = make_client(tmp_path)
outcome = run(client.score("c1", "cyber", {"confirmed": True, "poc_generated": True}))
assert outcome.score == 1
def test_get_context_returns_relevant(tmp_path: Path) -> None:
p = tmp_path / "outcomes.jsonl"
write_outcome(p, id="x1", domain="cyber", summary="XSS vulnerability confirmed with payload", score=1)
write_outcome(p, id="x2", domain="seo", summary="keywords ranked page one", score=1)
client = LyrieEvolve(outcomes_path=str(p))
contexts = run(client.get_context("XSS vulnerability injection", top_k=1))
assert len(contexts) <= 1
if contexts:
assert contexts[0].domain == "cyber"
def test_get_context_domain_filter(tmp_path: Path) -> None:
p = tmp_path / "outcomes.jsonl"
write_outcome(p, id="a1", domain="cyber", summary="pentest success", score=1)
write_outcome(p, id="a2", domain="trading", summary="profitable trade", score=1)
client = LyrieEvolve(outcomes_path=str(p))
results = run(client.get_context("pentest", domain="cyber", top_k=5))
assert all(c.domain == "cyber" for c in results)
def test_get_context_empty_when_no_outcomes(tmp_path: Path) -> None:
client = make_client(tmp_path)
results = run(client.get_context("anything"))
assert results == []
def test_extract_skills_dry_run(tmp_path: Path) -> None:
p = tmp_path / "outcomes.jsonl"
write_outcome(p, domain="code", summary="build passed all tests", score=1)
client = LyrieEvolve(outcomes_path=str(p))
result = run(client.extract_skills(dry_run=True))
assert result.dry_run is True
assert result.written == 0
assert result.signature == SIGNATURE
def test_extract_skills_with_outcomes(tmp_path: Path) -> None:
p = tmp_path / "outcomes.jsonl"
write_outcome(p, domain="seo", summary="keywords ranked successfully", score=1)
write_outcome(p, domain="cyber", summary="vulnerability confirmed", score=1)
client = LyrieEvolve(outcomes_path=str(p))
result = run(client.extract_skills())
assert result.patterns_found >= 1
def test_get_training_batch_filters_by_score(tmp_path: Path) -> None:
p = tmp_path / "outcomes.jsonl"
write_outcome(p, id="b1", domain="code", score=1, summary="good")
write_outcome(p, id="b2", domain="code", score=0, summary="bad")
write_outcome(p, id="b3", domain="code", score=0.5, summary="partial")
client = LyrieEvolve(outcomes_path=str(p), min_score=0.5)
batch = run(client.get_training_batch())
scores = [e.score for e in batch]
assert all(s >= 0.5 for s in scores)
assert len(batch) == 2
def test_get_training_batch_domain_filter(tmp_path: Path) -> None:
p = tmp_path / "outcomes.jsonl"
write_outcome(p, id="d1", domain="seo", score=1)
write_outcome(p, id="d2", domain="trading", score=1)
client = LyrieEvolve(outcomes_path=str(p))
batch = run(client.get_training_batch(domain="seo"))
assert all(e.domain == "seo" for e in batch)
def test_get_training_batch_limit(tmp_path: Path) -> None:
p = tmp_path / "outcomes.jsonl"
for i in range(10):
write_outcome(p, id=f"lim{i}", score=1)
client = LyrieEvolve(outcomes_path=str(p))
batch = run(client.get_training_batch(limit=3))
assert len(batch) <= 3
+76
-0

@@ -10,2 +10,78 @@ # Changelog

## [0.5.0] — 2026-04-29
### Added — Phase 4 (LyrieEvolve — Autonomous Self-Improvement)
#### Issue #49 — Task Outcome Scoring System
- **`packages/core/src/evolve/scorer.ts`** — TaskOutcome type with domain, score (0/0.5/1),
and domain-specific signals (cyber/seo/trading/code/general).
- **Scorer class** with domain-specific scoring rules: `scoreCyber`, `scoreSeo`,
`scoreTrading`, `scoreCode`, `scoreGeneral`.
- Outcomes appended to `~/.lyrie/evolve/outcomes.jsonl` (Shield-scanned before write).
- 32 unit tests in `packages/core/src/evolve/scorer.test.ts`.
- Full TypeScript exports from `packages/core/src/index.ts`.
#### Issue #50 — Skill Auto-Generation
- **`packages/core/src/evolve/skill-extractor.ts`** — Reads outcomes.jsonl, finds
score >= 0.5 sessions, synthesizes 1-3 skill patterns per domain.
- **`HeuristicExtractorLLM`** — Built-in heuristic extractor (no LLM dependency);
injectable `ExtractorLLM` interface for real LLM integration.
- Writes OpenClaw-compatible SKILL.md files to `skills/auto-generated/`.
- **Cosine similarity dedup** — skips patterns with similarity > 0.85 to existing skills.
- 22 unit tests in `packages/core/src/evolve/skill-extractor.test.ts`.
- CLI: `lyrie evolve extract` (`scripts/evolve.ts`).
#### Issue #51 — Contexture Layer
- **`packages/core/src/evolve/contexture.ts`** — In-memory skill context store.
- `retrieve(query, domain?, topK=3)` → `RetrievalResult[]` via cosine similarity.
- `buildInjection(contexts)` → structured prompt injection string.
- **MMR (Maximal Marginal Relevance)** diversity in retrieval (λ=0.7 default).
- Shield-scanned on store; evicts lowest-score entries at capacity.
- 16 unit tests in `packages/core/src/evolve/contexture.test.ts`.
- Constants: `CONTEXTURE_TABLE = "lyrie_contexture"`.
#### Issue #52 — Dream Cycle Pipeline
- **`packages/core/src/evolve/dream-cycle.ts`** — Full batch pipeline:
1. Count unprocessed outcomes
2. Extract skills via `SkillExtractor`
3. Prune skills (avgScore < 0.3 after 5+ uses) with `findPruneCandidates` + `pruneSkills`
4. Return `DreamReport` with full stats
- **`scripts/dream-evolve.ts`** — CLI: `bun run scripts/dream-evolve.ts [--dry-run]`.
- 11 unit tests in `packages/core/src/evolve/dream-cycle.test.ts`.
- CLI: `lyrie evolve dream [--dry-run]`.
#### Issue #54 — Evolve CLI
- **`scripts/evolve.ts`** — Full `lyrie evolve` command:
- `status` — version info + outcome/skill counts
- `extract` — trigger skill extraction
- `dream [--dry-run]` — run Dream Cycle
- `stats` — outcome statistics by domain and score
- `skills list` — list auto-generated skills
- `skills show <id>` — show skill file content
- `skills prune` — identify and remove stale skills
- `train` — export high-quality outcomes as training batch
#### Issue #55 — Python SDK evolve bindings
- **`sdk/python/lyrie/evolve.py`** — `LyrieEvolve` async client:
- `score(task_id, domain, signals, summary?)` → `TaskOutcome`
- `get_context(query, domain?, top_k=3)` → `List[SkillContext]`
- `extract_skills(dry_run?)` → `ExtractionResult`
- `get_training_batch(domain?, min_score?, limit=100)` → `List[TrainingEntry]`
- **Pydantic models**: `TaskOutcome`, `SkillContext`, `TrainingEntry`, `ExtractionResult`
(fallback to dataclasses when pydantic not installed).
- Scoring rules ported from TypeScript (all 5 domains).
- 23 unit tests in `sdk/python/tests/test_evolve.py`. All pass.
- Exported from `lyrie/__init__.py`.
#### Issue #56 — Docs + CHANGELOG
- This CHANGELOG section.
- `docs/evolve.md` — Full LyrieEvolve documentation.
- `README.md` — Added LyrieEvolve section.
- Version bumped to 0.5.0 in all package.json files.
**Total test suite (0.5.0): 442 TS + 86 Py = 528 / 0**
(was 379 TS + 63 Py = 442 / 0; added 63 TS + 23 Py = 86 new tests)
---
### Added — Phase 3 (Distribution — part 4: Pluggable execution backends)

@@ -12,0 +88,0 @@ - **Lyrie execution-backend abstraction** (`packages/core/src/backends/`).

+1
-1
{
"name": "lyrie-agent",
"version": "0.4.0",
"version": "0.5.0",
"description": "The world's first autonomous AI agent with built-in cybersecurity",

@@ -5,0 +5,0 @@ "author": "OTT Cybersecurity LLC <dev@lyrie.ai> (https://lyrie.ai)",

{
"name": "@lyrie/core",
"version": "0.1.0",
"version": "0.5.0",
"description": "Lyrie Agent Core — The brain",

@@ -5,0 +5,0 @@ "main": "src/index.ts",

@@ -276,4 +276,25 @@ /**

export const VERSION = "0.1.0";
export const VERSION = "0.5.0";
// LyrieEvolve — Autonomous Self-Improvement
export { Scorer, SCORER_VERSION } from "./evolve/scorer";
export type { TaskOutcome, Domain, Score, DomainSignals, ScorerOptions } from "./evolve/scorer";
export type { CyberSignals, SeoSignals, TradingSignals, CodeSignals, GeneralSignals } from "./evolve/scorer";
export {
SkillExtractor,
HeuristicExtractorLLM,
tokenize,
cosineSimilarity,
renderSkillMd,
EXTRACTOR_VERSION,
} from "./evolve/skill-extractor";
export type { SkillPattern, ExtractionResult, ExtractorLLM, SkillExtractorOptions } from "./evolve/skill-extractor";
export { Contexture, mmrSelect, CONTEXTURE_VERSION, CONTEXTURE_TABLE } from "./evolve/contexture";
export type { SkillContext, RetrievalResult, ContextureOptions } from "./evolve/contexture";
export { runDreamCycle, findPruneCandidates, pruneSkills, DREAM_VERSION } from "./evolve/dream-cycle";
export type { DreamReport, DreamCycleOptions, PruneCandidate } from "./evolve/dream-cycle";
// ─── Boot ────────────────────────────────────────────────────────────────────

@@ -280,0 +301,0 @@

{
"name": "@lyrie/gateway",
"version": "0.1.0",
"version": "0.5.0",
"description": "Multi-channel messaging gateway for Lyrie Agent — Telegram, WhatsApp, Discord",

@@ -5,0 +5,0 @@ "author": "OTT Cybersecurity LLC <dev@lyrie.ai> (https://lyrie.ai)",

{
"name": "@lyrie/mcp",
"version": "0.1.0",
"version": "0.5.0",
"description": "Model Context Protocol (MCP) adapter for Lyrie Agent — connect to and host MCP servers",

@@ -5,0 +5,0 @@ "author": "OTT Cybersecurity LLC <dev@lyrie.ai> (https://lyrie.ai)",

@@ -286,2 +286,30 @@ <!-- lyrie-shield: ignore-file (this README contains code examples that demonstrate Shield detector strings; they are documentation, not vectors) -->

## 🧬 LyrieEvolve — Autonomous Self-Improvement
> Lyrie gets better the more it works. Every task outcome is scored, patterns are extracted, and the Dream Cycle prunes what doesn't work.
| Component | Description |
|-----------|-------------|
| **Scorer** | Records task outcomes (score 0/0.5/1) across 5 domains: cyber, seo, trading, code, general |
| **SkillExtractor** | Reads `outcomes.jsonl`, synthesizes OpenClaw-compatible SKILL.md files with cosine dedup |
| **Contexture** | MMR-diverse retrieval of relevant skill contexts → prompt injection for active tasks |
| **Dream Cycle** | Batch pipeline: score → extract → prune → report (runs at 4AM cron) |
**Quick start:**
```bash
# Check evolve status
bun run scripts/evolve.ts status
# Run the Dream Cycle (preview)
bun run scripts/evolve.ts dream --dry-run
# Python SDK
python3 -c "from lyrie.evolve import LyrieEvolve; print('LyrieEvolve ready')"
```
**Full docs:** [`docs/evolve.md`](docs/evolve.md)
---
## 🛡️ The Shield Doctrine

@@ -288,0 +316,0 @@

@@ -47,5 +47,11 @@ """

"OssScanResult",
# LyrieEvolve
"LyrieEvolve",
"TaskOutcome",
"SkillContext",
"TrainingEntry",
"ExtractionResult",
]
__version__ = "0.3.0"
__version__ = "0.5.0"
SIGNATURE: str = "Lyrie.ai by OTT Cybersecurity LLC"

@@ -73,1 +79,2 @@

from lyrie.oss_scan import run_oss_scan, OssScanResult
from lyrie.evolve import LyrieEvolve, TaskOutcome, SkillContext, TrainingEntry, ExtractionResult

@@ -88,4 +88,8 @@ """

"""async_send must honour the deny-list before making any network call."""
try:
import httpx # noqa: F401
except ImportError:
pytest.skip("httpx not installed")
proxy = HttpProxy(deny_hosts=["blocked.example"])
with pytest.raises(PermissionError):
with pytest.raises((PermissionError, RuntimeError)):
run(proxy.async_send("GET", "https://blocked.example/"))

@@ -92,0 +96,0 @@