Files
zesdex/crates/zesdex-backend/src/app/review/mod.rs
T
asepharyana be0a9582bb refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo:

- Root Cargo.toml as [workspace] manager with resolver = "2"
- zesdex-entities: Domain entity types (session, settings, store, message, etc.)
- zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard)
- zesdex-dto: Data Transfer Objects for LLM provider API communication
- zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol)
- zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure)
- zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure)
- zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting)
- zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2)
- zesdex-backend: Main binary entry point + seed/migrate binaries
- DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates
- Remove dead root src/ and src-misc/ directories

All crate re-exports maintain backward compatibility with original
crate::model::*, crate::dto::*, crate::ipc::* module paths.
Feature crates enforce strict layer separation: domain -> application
-> infrastructure with generic trait-based dependency injection.
2026-07-17 09:08:41 +07:00

651 lines
26 KiB
Rust

#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)]
//! Adaptive quality-review triggering, build/test probing, staleness
//! sweeps for stored lessons, and the pending-lesson approval workflow.
use std::process::Command;
use crate::app::state::rest::AppStateRest;
use crate::app::state::runtime::TurnEvent;
use crate::app::state::types::{Origin, Toast, ToastKind};
use crate::app::subagent::context::build_subagent_context;
use crate::app::subagent::engine::run_subagent;
use crate::app::subagent::spawn::AgentDefinition;
use serde::{Deserialize, Serialize};
/// How much trust a lesson's origin/verification warrants.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Confidence {
Human,
Verified,
Unverified,
Auto,
}
/// Where a lesson sits in its life cycle, from freshly written to superseded.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LessonLifecycle {
New,
Active,
Stale,
Contradicted,
Superseded,
}
/// Whether a lesson applies to the current project only or globally.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum LessonScope {
Project,
Global,
}
/// Records who/what produced a lesson and in which session/turn.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Provenance {
pub session_turn: String,
pub session_id: String,
pub reviewer: Origin,
}
/// A single learned fact/pattern surfaced by a review, prior to being
/// written to persistent memory.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lesson {
pub name: String,
pub content: String,
pub confidence: Confidence,
pub outcome: Option<String>,
pub lifecycle: LessonLifecycle,
pub scope: LessonScope,
pub contradiction_with: Option<String>,
pub provenance: Provenance,
}
/// Decide whether an adaptive quality review should fire for this turn.
///
/// Flow: only `Origin::Main` turns are eligible → require review enabled
/// in settings → fire every 5th edit unconditionally → otherwise, once
/// `consecutive_empty_reviews` reaches `adaptive_review_max_skip` (min 2),
/// fire on an exponentially growing skip interval (2^n, capped at 2^10)
/// to avoid reviewing every single edit once reviews keep coming back empty.
///
/// Why: balances review usefulness against wasted subagent calls when
/// reviews consistently find nothing.
///
/// Return: `true` if a review should be triggered this turn.
pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
if origin != Origin::Main {
return false;
}
let Some(runtime) = &state.session_runtime else { return false };
if !state.settings.flags.review_enabled {
return false;
}
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
return true;
}
let base: u32 = state.settings.adaptive_review_max_skip.max(2);
let consecutive = runtime.consecutive_empty_reviews;
if consecutive >= base {
let skip = 1u32 << (consecutive - base).min(10);
if runtime.edit_count > 0 && (runtime.edit_count % skip == 0) {
return true;
}
return false;
}
false
}
/// Outcome of running a build/test probe command against a workspace.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProbeResult {
pub command: String,
pub passed: bool,
pub output: String,
pub timed_out: bool,
}
/// Run a build/test verification command in the first workspace root and
/// capture its outcome, to back a review with a real pass/fail signal.
///
/// Flow: pick the first workspace → resolve the verify command (explicit
/// override or auto-detected via `resolve_verify_command`) → spawn it →
/// poll `try_wait` in a loop, killing the child if `timeout_ms` elapses →
/// capture combined stdout+stderr (truncated) on completion.
///
/// Why: polling instead of a blocking wait lets the timeout be enforced
/// without spawning a watcher thread.
///
/// Return: `None` if no workspace exists, no command could be resolved,
/// or the process failed to spawn/poll; otherwise `Some(ProbeResult)`
/// describing pass/fail/timeout and truncated output.
pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option<ProbeResult> {
let probe_dir = workspaces.first()?;
let cmd = resolve_verify_command(probe_dir, verify_command)?;
let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else(|| (cmd.clone(), String::new()), |(p, a)| (p.to_string(), a.to_string()));
let Ok(mut child) = Command::new(&cmd_prog)
.args(cmd_args.split_whitespace())
.current_dir(probe_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn() else { return None };
let start = std::time::Instant::now();
let timed_out = loop {
if start.elapsed().as_millis() as u64 >= timeout_ms {
let _ = child.kill();
break true;
}
match child.try_wait() {
Ok(Some(status)) => {
let output = child.wait_with_output().ok();
let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default();
let combined = if stderr.is_empty() { stdout } else { format!("{stdout}\n{stderr}") };
return Some(ProbeResult {
command: cmd.clone(),
passed: status.success(),
output: truncate_output(&combined, 2048),
timed_out: false,
});
}
Ok(None) => { std::thread::sleep(std::time::Duration::from_millis(50)); }
Err(_) => return None,
}
};
if timed_out {
Some(ProbeResult {
command: cmd.clone(),
passed: false,
output: "timed out".to_string(),
timed_out: true,
})
} else {
None
}
}
/// Determine the shell command to build/test a workspace, auto-detecting
/// the project type from marker files when no override is given.
///
/// Flow: use `override_cmd` verbatim if non-empty → otherwise probe for
/// language/tool marker files (Cargo.toml, go.mod, package.json, etc.)
/// in priority order and return that ecosystem's conventional test/build
/// command.
///
/// Why: covers a broad set of ecosystems so review probing works without
/// per-project configuration in the common case.
///
/// Return: `Some(command)` if a command could be determined, `None` if
/// no marker files matched (e.g. plain Python project with no test dir).
fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option<String> {
if let Some(cmd) = override_cmd {
if !cmd.trim().is_empty() {
return Some(cmd.trim().to_string());
}
}
let has_file = |name: &str| probe_dir.join(name).exists();
let has_dir = |name: &str| probe_dir.join(name).is_dir();
if has_file("Cargo.toml") {
if has_dir("src") || has_dir("tests") {
return Some("cargo build 2>&1 && cargo test 2>&1".to_string());
}
return Some("cargo build 2>&1".to_string());
}
if has_file("go.mod") {
return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string());
}
if has_file("package.json") {
let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?;
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&pkg) {
let scripts = v.get("scripts")?;
if scripts.get("test").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
return Some("npm test 2>&1".to_string());
}
if scripts.get("build").and_then(|s| s.as_str()).as_ref().is_some_and(|s| !s.is_empty()) {
return Some("npm run build 2>&1".to_string());
}
}
return Some("npm test 2>&1".to_string());
}
if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") {
if has_file("pyproject.toml") {
let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default();
if content.contains("[tool.pytest") {
return Some("python -m pytest --tb=short -q 2>&1".to_string());
}
}
if has_dir("tests") || has_dir("test") {
return Some("python -m pytest --tb=short -q 2>&1".to_string());
}
return None;
}
if has_file("Cargo.lock") {
return Some("cargo build 2>&1".to_string());
}
if has_file("Gemfile") || has_file("Rakefile") || has_file("*.gemspec") {
return Some("bundle exec rake 2>&1".to_string());
}
if has_file("Makefile") || has_file("makefile") || has_file("GNUmakefile") {
return Some("make test 2>&1 || make build 2>&1".to_string());
}
if has_file("justfile") || has_file("justfile") {
return Some("just test 2>&1 || just build 2>&1".to_string());
}
if has_file("deno.json") || has_file("deno.jsonc") {
return Some("deno test 2>&1".to_string());
}
if has_file("bun.lock") || has_file("bun.lockb") {
return Some("bun test 2>&1".to_string());
}
if has_file("pnpm-lock.yaml") {
return Some("pnpm test 2>&1 || pnpm build 2>&1".to_string());
}
if has_file("yarn.lock") {
return Some("yarn test 2>&1 || yarn build 2>&1".to_string());
}
if has_file("composer.json") {
return Some("composer test 2>&1 || composer run build 2>&1".to_string());
}
if has_file("build.gradle") || has_file("build.gradle.kts") || has_file("gradlew") {
return Some("gradle build 2>&1 && gradle test 2>&1".to_string());
}
if has_file("pom.xml") || has_file("mvnw") {
return Some("mvn test 2>&1".to_string());
}
if has_file("stack.yaml") || has_file("package.yaml") || has_file("cabal.project") {
return Some("cabal test all 2>&1 || stack test 2>&1".to_string());
}
if has_file("mix.exs") {
return Some("mix test 2>&1".to_string());
}
if has_file("rebar.config") || has_file("rebar.lock") {
return Some("rebar3 ct 2>&1 || rebar3 eunit 2>&1".to_string());
}
if has_file("dune-project") || has_file("jbuild") || has_file("Makefile") {
return Some("dune runtest 2>&1".to_string());
}
if has_file("shard.yml") {
return Some("crystal spec 2>&1".to_string());
}
if has_file("Project.toml") || has_file("JuliaProject.toml") {
return Some("julia --project=. -e 'using Pkg; Pkg.test()' 2>&1".to_string());
}
None
}
/// Truncate a string to at most `max` characters, appending a marker if cut.
///
/// Return: the original string if short enough, otherwise the first `max`
/// characters plus `"... (truncated)"`.
fn truncate_output(s: &str, max: usize) -> String {
if s.len() <= max {
s.to_string()
} else {
let mut t: String = s.chars().take(max).collect();
t.push_str("... (truncated)");
t
}
}
/// Spawn a background quality-review subagent for the current session.
///
/// Flow: build a "quality-reviewer" subagent context → probe build/test
/// status via `probe_build_test` to give the reviewer a real pass/fail
/// signal → compose a system prompt embedding the probe result and lesson
/// tagging instructions → spawn a thread running `run_subagent` → on
/// completion, push a `TurnEvent::SystemNote` with the verdict's first
/// line (or error) → push an "in progress" toast immediately.
///
/// Why: runs on a plain OS thread (not tokio) so it doesn't block the
/// async event loop; communicates its result back via `turn_events`
/// rather than a channel receiver (the `_rx` half is intentionally unused).
///
/// Return: `Ok(())` once the review has been kicked off; errors only
/// propagate from constructing the subagent context, not from the review
/// itself (that failure is reported via a `SystemNote` instead).
/// Compose the system prompt for the quality-review subagent.
fn compose_review_prompt(
state: &AppStateRest,
probe_note: &str,
) -> String {
let diff_output = if let Some(workspace) = state.workspace_roots.first() {
std::process::Command::new("git")
.arg("diff")
.arg("HEAD")
.current_dir(workspace)
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default()
} else {
String::new()
};
let history_output = if let Some(rt) = &state.session_runtime {
let msgs: Vec<String> = rt.messages.iter()
.filter(|m| m.role == crate::dto::chat::message::Role::Assistant || m.role == crate::dto::chat::message::Role::User)
.rev()
.take(10)
.map(|m| format!("{:?}: {}", m.role, m.content.as_deref().unwrap_or("")))
.collect();
let mut rev_msgs = msgs;
rev_msgs.reverse();
rev_msgs.join("\n\n")
} else {
String::new()
};
let session_dir_disp = state.session_dir.display();
format!(
"You are a code quality reviewer and lesson generator. Your goal is to review recent code changes.\n\n\
Session directory: {session_dir_disp}\n\n\
--- Build/Test Probe ---\n{probe_note}\n\n\
--- Recent Chat History (Last 10 messages) ---\n{history_output}\n\n\
--- Recent Code Diffs (git diff HEAD) ---\n{diff_output}\n\n\
INSTRUCTIONS:\n\
1. Compare the 'Recent Chat History' (what the AI promised or discussed) with the 'Recent Code Diffs' (what was actually changed).\n\
2. Ensure that the AI's promises match the actual code changes.\n\
3. Evaluate the code quality in the diff (check for best practices, clean code).\n\
4. Write your findings and learning points as a lesson to a file in `docs/lesson/` (e.g., docs/lesson/lesson_01.md).\n\
5. Use the `write` tool to save this markdown file.\n\
6. Your verdict should briefly summarize what lesson was created.",
)
}
/// Spawn a background quality-review subagent for the current session.
///
/// Flow: build a "quality-reviewer" subagent context → probe build/test
/// status via `probe_build_test` to give the reviewer a real pass/fail
/// signal → compose a system prompt embedding the probe result and lesson
/// tagging instructions → spawn a thread running `run_subagent` → on
/// completion, push a `TurnEvent::SystemNote` with the verdict's first
/// line (or error) → push an "in progress" toast immediately.
///
/// Why: runs on a plain OS thread (not tokio) so it doesn't block the
/// async event loop; communicates its result back via `turn_events`
/// rather than a channel receiver (the `_rx` half is intentionally unused).
///
/// Return: `Ok(())` once the review has been kicked off; errors only
/// propagate from constructing the subagent context, not from the review
/// itself (that failure is reported via a `SystemNote` instead).
#[allow(clippy::unnecessary_debug_formatting)]
pub fn trigger_review(state: &mut AppStateRest) {
state.misc.lesson_running = true;
if let Some(workspace) = state.workspace_roots.first() {
let gitignore_path = workspace.join(".gitignore");
let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
if !content.contains("docs/lesson") {
use std::io::Write;
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&gitignore_path) {
let prefix = if content.is_empty() || content.ends_with('\n') { "" } else { "\n" };
let _ = writeln!(file, "{prefix}docs/lesson/");
}
}
}
let mut def = AgentDefinition::new(
"lesson-generator".to_string(),
"reviewer".to_string(),
);
// Explicitly allow write_file for docs/lesson
def.allowed_tools = Some(vec![
"read".to_string(),
"write".to_string(),
"grep".to_string(),
"glob".to_string(),
]);
let mut ctx = build_subagent_context(&def);
ctx.session_dir.clone_from(&state.session_dir);
ctx.workspaces.clone_from(&state.workspace_roots);
let probe_result = probe_build_test(
&state.workspace_roots,
state.settings.verify_command.as_deref(),
state.settings.verify_timeout_ms,
);
let probe_note = match &probe_result {
Some(r) => {
if r.passed {
format!("Build/test verification passed ({}).", r.command)
} else if r.timed_out {
format!("Build/test verification timed out ({}).", r.command)
} else {
format!("Build/test verification failed ({}). Output: {}", r.command, r.output)
}
}
None => "No build/test probe matched.".to_string(),
};
ctx.system_prompt = compose_review_prompt(state, &probe_note);
let turn_events_for_drain = state.turn_events.clone();
// Use a drain thread for subagent events
let (tx, rx) = tokio::sync::mpsc::channel(32);
let _drain_thread = std::thread::spawn(move || {
use crate::app::subagent::event::SubagentEvent;
let mut rx = rx;
while let Some(event) = rx.blocking_recv() {
match &event {
SubagentEvent::ToolCall { tool, .. } => tracing::debug!("[review] tool call: {}", tool),
SubagentEvent::ToolResult { tool, .. } => tracing::debug!("[review] tool result: {}", tool),
SubagentEvent::StepCompleted { .. } => tracing::trace!("[review] step completed"),
SubagentEvent::StepFailed { step, error } => tracing::warn!("[review] step {} failed: {}", step, error),
SubagentEvent::Progress(_) => {}
SubagentEvent::Completed { .. } => tracing::debug!("[review] completed"),
SubagentEvent::Usage { tokens_in, tokens_out } => {
if let Ok(mut q) = turn_events_for_drain.lock() {
q.push_back(TurnEvent::ReviewUsage {
tokens_in: *tokens_in,
tokens_out: *tokens_out,
});
}
}
}
}
});
let turn_events = state.turn_events.clone();
std::thread::spawn(move || {
let result = run_subagent(&ctx, &tx);
let message = match result {
Ok(verdict) => {
let first_line = verdict.lines().next().unwrap_or(&verdict);
format!("Lesson created: {first_line}")
}
Err(e) => format!("Lesson generation failed: {e}"),
};
if let Ok(mut q) = turn_events.lock() {
q.push_back(TurnEvent::SystemNote {
kind: "review".to_string(),
message,
});
}
});
state.push_toast(Toast::new(
ToastKind::Info,
"Generating lesson...".to_string(),
));
}
const STALE_AFTER_DAYS: i64 = 60;
/// Flag memory entries as stale if they haven't been updated recently.
///
/// Flow: list all memory files → for each, read it → if `updated_at` is
/// older than `STALE_AFTER_DAYS` and it isn't already flagged, set
/// `lifecycle = "stale"` and write it back → collect flagged names.
///
/// Return: names of newly-flagged memories, or an I/O error from
/// `mem.write`.
pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<String>> {
let mut flagged = Vec::new();
let names = crate::model::memory::Memory::list(memory_dir);
let now = chrono::Utc::now().timestamp_millis();
let cutoff = now - STALE_AFTER_DAYS * 24 * 3600 * 1000;
for name in names {
if let Ok(mut mem) = crate::model::memory::Memory::read(memory_dir, &name) {
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
mem.lifecycle = "stale".to_string();
mem.write(memory_dir)?;
flagged.push(name);
}
}
}
Ok(flagged)
}
/// Run the staleness sweep at most once every 10 minutes, notifying via toast.
///
/// Flow: skip if less than 600,000ms since `last_staleness_sweep_ms` →
/// otherwise update the timestamp and run `run_staleness_sweep`, pushing
/// an info toast listing flagged lessons if any were found.
///
/// Why: rate-limited so the sweep (a file read/write per memory) doesn't
/// run on every event-loop tick.
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
let now = chrono::Utc::now().timestamp_millis();
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
return;
}
state.misc.last_staleness_sweep_ms = now;
if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) {
if !flagged.is_empty() {
state.push_toast(Toast::new(
ToastKind::Info,
format!("Staleness sweep: {} lesson(s) flagged as stale: {}", flagged.len(), flagged.join(", ")),
));
}
}
}
/// A lesson awaiting confirmation before being committed to memory,
/// optionally auto-resolving after a grace period.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingLesson {
pub lesson: Lesson,
pub created_at: i64,
pub auto_resolve: bool,
}
/// Load the session's pending-lessons queue from disk.
///
/// Return: the parsed list, or an empty `Vec` if the file is missing or
/// fails to parse.
pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec<PendingLesson> {
let path = session_dir.join("pending_lessons.json");
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
/// Write the session's pending-lessons queue to disk as pretty JSON.
///
/// Return: `Ok(())`, or an I/O error from writing the file.
pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLesson]) -> std::io::Result<()> {
let path = session_dir.join("pending_lessons.json");
let data = serde_json::to_string_pretty(pending)?;
std::fs::write(&path, data)
}
/// Commit any auto-resolvable pending lessons whose grace period has
/// elapsed, and persist the remaining queue.
///
/// Flow: load pending lessons → partition into those eligible to commit
/// (`auto_resolve` and older than the 5s grace window) vs. still pending
/// → write eligible lessons as new `Memory` entries with `lifecycle:
/// "active"` → save the remaining (unresolved) queue back to disk.
///
/// Why: the grace window gives the user a brief window to reject an
/// auto-resolving lesson via `resolve_pending_lesson` before it commits.
///
/// Return: the still-pending lessons (post-commit), or an I/O error from
/// writing memory files or the queue.
pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result<Vec<PendingLesson>> {
let pending = load_pending_lessons(session_dir);
let now = chrono::Utc::now().timestamp_millis();
let grace_window = 5_000;
let mut remaining = Vec::new();
let mut to_keep = Vec::new();
for p in &pending {
if p.auto_resolve && now.saturating_sub(p.created_at) >= grace_window {
to_keep.push(p.lesson.clone());
} else {
remaining.push(p.clone());
}
}
for lesson in &to_keep {
let mem = crate::model::memory::Memory {
name: lesson.name.clone(),
description: lesson.content.chars().take(80).collect(),
content: lesson.content.clone(),
kind: "lesson".to_string(),
created_at: now,
updated_at: now,
outcome: None,
lifecycle: "active".to_string(),
scope: Some("project".to_string()),
before_snippet: None,
after_snippet: None,
provenances: vec![],
};
mem.write(memory_dir)?;
}
save_pending_lessons(session_dir, &remaining)?;
Ok(remaining)
}
/// Manually resolve a single pending lesson by name: commit it to memory
/// or discard it.
///
/// Flow: load the queue → find the lesson matching `lesson_name` →
/// if `keep` is true, write it as an active `Memory` entry; either way
/// remove it from the queue → save the remaining queue.
///
/// Why: lets the user (or UI action) override a pending lesson's fate
/// before/without waiting for the auto-resolve grace window.
///
/// Return: `Ok(())`, or an I/O error from writing the memory file or queue.
pub fn resolve_pending_lesson(
session_dir: &std::path::Path,
memory_dir: &std::path::Path,
lesson_name: &str,
keep: bool,
) -> std::io::Result<()> {
let pending = load_pending_lessons(session_dir);
let mut remaining = Vec::new();
let now = chrono::Utc::now().timestamp_millis();
for p in pending {
if p.lesson.name == lesson_name {
if keep {
let mem = crate::model::memory::Memory {
name: p.lesson.name.clone(),
description: p.lesson.content.chars().take(80).collect(),
content: p.lesson.content.clone(),
kind: "lesson".to_string(),
created_at: now,
updated_at: now,
outcome: None,
lifecycle: "active".to_string(),
scope: Some("project".to_string()),
before_snippet: None,
after_snippet: None,
provenances: vec![],
};
mem.write(memory_dir)?;
}
} else {
remaining.push(p);
}
}
save_pending_lessons(session_dir, &remaining)
}