add folgezettel usage trace report
94 unmodified lines959697989994 unmodified lines{"id":"int-24fd625d","kind":"field_change","created_at":"2026-07-01T09:18:07.32698818Z","actor":"dawn","issue_id":"klbr-q53","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Moved external crate versions into root workspace.dependencies and switched member manifests to workspace=true; verified cargo metadata, workspace check, and workspace tests."}}{"id":"int-04183019","kind":"field_change","created_at":"2026-07-01T09:25:30.275787695Z","actor":"dawn","issue_id":"klbr-so2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Split agent run_turn into stream_iteration and execute_tool_iteration phase helpers, reducing run_turn to a smaller dispatcher while preserving tool/preemption behavior; verified core and workspace tests."}}{"id":"int-5dcd101a","kind":"field_change","created_at":"2026-07-01T09:26:28.189416219Z","actor":"dawn","issue_id":"klbr-ds9","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Satisfied by e61b0e5 and 453b014: deduplicated run-loop failure handling and split run_turn into stream/tool phase helpers while preserving behavior; workspace tests passed."}}{"id":"int-706d5ee3","kind":"field_change","created_at":"2026-07-01T09:35:51.422527514Z","actor":"dawn","issue_id":"klbr-6m2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Split App.svelte into ConnectionRail, ChatHeader, Composer, AddDaemonModal, ReflinkPopover, ResolutionTraces, and shared UI types; verified svelte-check, build, diff check, and local Vite smoke."}}{"id":"int-6a5bb5cc","kind":"field_change","created_at":"2026-07-01T09:48:37.465114937Z","actor":"dawn","issue_id":"klbr-wj2","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Added real folgezettel usage trace report command with reuse, revision, and review flags."}}85 unmodified lines868788899090919293942 unmodified lines85 unmodified lines{"_type":"issue","id":"klbr-wmz.9","title":"Clean up memory architecture docs and dead references","description":"AGENTS.md still links docs/memory_architecture.md, but the current docs are docs/memory-arch.md, docs/memory-benches.md, and docs/memory-implementation-status.md. The architecture doc also reads like a target-state essay while the status doc says several items have since landed. This makes agents start from stale paths and over-file already-completed work.","design":"Do not rewrite the architecture voice into officecore sludge. Make the docs more navigable while preserving the current direct style.","acceptance_criteria":"All repo docs and agent instructions point at the existing memory docs; dead references to docs/memory_architecture.md are removed or redirected; docs/memory-arch.md clearly separates target architecture from already-implemented pieces; docs/memory-implementation-status.md lists open gaps by bead id; rg memory_architecture shows no stale repo references unless intentionally documented as legacy.","status":"closed","priority":2,"issue_type":"chore","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-26T17:54:16Z","created_by":"dawn","updated_at":"2026-06-26T23:29:50Z","started_at":"2026-06-26T23:20:28Z","closed_at":"2026-06-26T23:29:50Z","close_reason":"Updated AGENTS and memory docs to current paths/status, separated architecture target-state from landed work, listed open bead ids, and verified stale docs/memory_architecture references are gone.","labels":["architecture","docs","memory"],"dependencies":[{"issue_id":"klbr-wmz.9","depends_on_id":"klbr-wmz","type":"parent-child","created_at":"2026-06-26T20:54:16Z","created_by":"dawn","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-wmz.8","title":"Validate the LoCoMo adapter and comparison protocol","description":"docs/memory-benches.md recommends LoCoMo as the secondary public suite for memweaver-style comparison, and docs/memory-implementation-status.md says a flexible locomo adapter exists. Current source parses locomo-ish shapes inside klbr-bench/src/longmemeval.rs, but there is no verified dataset fixture, loader test, scoring protocol, or documented run result proving the adapter matches the intended benchmark semantics.","design":"Keep this as a comparison harness task, not a claim about beating another system. The output should make dataset version, reader model, token budget, and scoring method explicit.","acceptance_criteria":"A small LoCoMo-shaped fixture or documented local dataset path exercises the adapter; loader tests cover supported input shapes and session ordering; the benchmark manifest/report clearly labels LoCoMo runs and avoids claiming comparability without matched reader/budget/scoring; docs include the exact command and current verified result or blocker.","status":"closed","priority":2,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-26T17:54:09Z","created_by":"dawn","updated_at":"2026-06-26T23:44:57Z","started_at":"2026-06-26T23:42:41Z","closed_at":"2026-06-26T23:44:57Z","close_reason":"Added LoCoMo smoke fixture, loader tests for session/haystack/conversation shapes, protocol notes in report/manifest, docs with exact command/result, and verified locomo retrieval-only smoke.","labels":["architecture","benchmarks","locomo","memory"],"dependencies":[{"issue_id":"klbr-wmz.8","depends_on_id":"klbr-wmz","type":"parent-child","created_at":"2026-06-26T20:54:09Z","created_by":"dawn","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-wmz.5","title":"Upgrade episodic notes from transcript cards to source-grounded event cards","description":"klbr-core/src/pipeline.rs render_episode_card creates an episode note from timestamp, source refs, and truncated role lines. docs/memory-arch.md asks for source-grounded scene memory that preserves who, when, where, what changed, what was said, available attachments, and supporting raw refs. The current implementation is addressable, but still too transcript-shaped for temporal/update reasoning.","design":"Do not synthesize unsupported vivid details. The event card should summarize only what source turns or attachments support, with raw refs retained as the escape hatch.","acceptance_criteria":"Episode artifacts have a stable structured representation for time anchors, participants/entities, changes/decisions, salient quotes or snippets, attachments when present, and source refs; the markdown rendering remains human-editable; retrieval/context packets can expose the structured gist without losing provenance; tests cover an episode with multiple turns and verify source refs, session id resolution, and useful promptable chunks.","status":"closed","priority":2,"issue_type":"feature","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-26T17:53:44Z","created_by":"dawn","updated_at":"2026-06-26T23:36:56Z","started_at":"2026-06-26T23:33:59Z","closed_at":"2026-06-26T23:36:56Z","close_reason":"Episode notes now render as structured episode_event_card artifacts with event metadata, source-grounded timelines, stated facts/decisions, attachment markers, source refs, and promptable retrieval coverage; added core regression.","labels":["architecture","episodic","memory","provenance"],"dependencies":[{"issue_id":"klbr-wmz.5","depends_on_id":"klbr-wmz","type":"parent-child","created_at":"2026-06-26T20:53:44Z","created_by":"dawn","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-wmz.4","title":"Materialize profile and procedural lanes as first-class notes","description":"The schema and MemoryGarden know about profile and procedural lanes, but the production pipeline observe_session path currently writes raw turns plus episodic notes/memories. Stable preferences, standing instructions, and workflows still depend mostly on tags or model-authored remember calls instead of a first-class markdown-note flow with source refs and update policy.","design":"Build on MemoryGarden and upsert_markdown_note rather than adding a new store. Treat legacy memory tags as routing hints, not the durable source of truth for profile/procedural knowledge.","acceptance_criteria":"There is an explicit writer/reflection path for profile_note and procedural_note artifacts; new notes include source refs, frontmatter, stable paths under profile/ or procedural/, and canonical refs/chunks; updates use supersession or versioning instead of silent overwrite; user-confirmation or policy gates are documented for stable profile changes; tests cover creating and updating one profile note and one procedural note from source turns.","status":"closed","priority":2,"issue_type":"feature","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-26T17:53:36Z","created_by":"dawn","updated_at":"2026-06-26T23:42:13Z","started_at":"2026-06-26T23:37:20Z","closed_at":"2026-06-26T23:42:13Z","close_reason":"Added write_memory_note reflection tool for source-grounded profile/procedural markdown notes, ref supersession updates, source/policy frontmatter, stable lane paths, and tests for profile/procedural create/update paths.","labels":["architecture","markdown","memory","profile"],"dependencies":[{"issue_id":"klbr-wmz.4","depends_on_id":"klbr-wmz","type":"parent-child","created_at":"2026-06-26T20:53:36Z","created_by":"dawn","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-wj2","title":"Instrument real-world folgezettel usage review","description":"Replace the proposed synthetic folgezettel eval with real usage trace review. Capture or summarize mk_* tool use, whether later answers cite/follow notes, whether revisions and supersedes behavior prevent stale facts, and whether titles/links remain useful in later workflows. Use real failures before designing any benchmark.","status":"open","priority":3,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-30T17:49:10Z","created_by":"dawn","updated_at":"2026-06-30T17:49:10Z","dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-wj2","title":"Instrument real-world folgezettel usage review","description":"Replace the proposed synthetic folgezettel eval with real usage trace review. Capture or summarize mk_* tool use, whether later answers cite/follow notes, whether revisions and supersedes behavior prevent stale facts, and whether titles/links remain useful in later workflows. Use real failures before designing any benchmark.","status":"closed","priority":3,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-30T17:49:10Z","created_by":"dawn","updated_at":"2026-07-01T09:48:37Z","started_at":"2026-07-01T09:36:39Z","closed_at":"2026-07-01T09:48:37Z","close_reason":"Added real folgezettel usage trace report command with reuse, revision, and review flags.","dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-d76","title":"Design a separate folgezettel agentic memory eval","description":"LongMemEval passive QA is useful as best-effort passive recall, but it does not test the mk tool surface, titles-as-apis, sourced note writing, revisions, links, or trail-aware recall. Design a small agentic memory eval where the model must use mk_index, mk_search, mk_follow, mk_recall, mk_remember, mk_revise, mk_link, and mk_fleeting over a folgezettel tree, then measure sourced note quality, title usefulness, revision behavior, and later retrieval through trail_support.","acceptance_criteria":"A design doc or bench skeleton defines tasks, scoring, and traces for agentic folgezettel memory separately from LongMemEval passive QA; it includes at least note creation, revision/supersession, linking, search/follow/recall, and later answer-from-zettel scenarios; docs make clear this is not a replacement for passive LongMemEval recall.","status":"closed","priority":3,"issue_type":"feature","owner":"90008@klbr.net","created_at":"2026-06-30T11:14:04Z","created_by":"dawn","updated_at":"2026-06-30T17:48:56Z","closed_at":"2026-06-30T17:48:56Z","close_reason":"Not planned for now: user wants folgezettel usefulness evaluated from real-world usage traces rather than a synthetic agentic eval. Revisit only after real usage exposes concrete failures or measurable trace-review needs.","labels":["agentic","bench","folgezettel","memory"],"dependency_count":0,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-u3t.4","title":"Integrate BGE-M3 sparse retrieval integration path","description":"Research and implement a second sparse retrieval channel from BGE-M3 sparse embeddings (if the embedder service supports exposing sparse weights) as a multilingual fallback to FTS BM25.","status":"closed","priority":3,"issue_type":"task","owner":"90008@klbr.net","created_at":"2026-06-27T17:26:43Z","created_by":"dawn","updated_at":"2026-06-27T17:37:28Z","closed_at":"2026-06-27T17:37:28Z","close_reason":"Implemented BGE-M3 sparse embeddings retrieval path via API fallback, customized SQLite LIKE-based sparse match scoring in memory.rs, and integrated into pipeline.rs. Verified all tests pass.","dependencies":[{"issue_id":"klbr-u3t.4","depends_on_id":"klbr-u3t","type":"parent-child","created_at":"2026-06-27T20:26:43Z","created_by":"dawn","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}{"_type":"issue","id":"klbr-mpy","title":"Improve tool-required router separability","description":"Fresh router artifact benchmarks/models/router/linear/out-router-linear-iter-15 reports test tool-required false-memory rate 0.6913 and tool-required recall 0.3087 after adding the metric/calibration surface. Improve training data, features, or model shape so tool-required queries stop looking like memory queries without regressing memory false-abstain.","status":"closed","priority":3,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-27T15:08:07Z","created_by":"dawn","updated_at":"2026-06-27T15:17:20Z","started_at":"2026-06-27T15:12:36Z","closed_at":"2026-06-27T15:17:20Z","close_reason":"Added tool-required training weighting for the linear router and regenerated out-router-linear-iter-16. Test tool→memory improved 0.6913→0.0940, tool-required recall 0.3087→0.9060, and memory false-abstain remained 0.0000 on test/holdout.","dependencies":[{"issue_id":"klbr-mpy","depends_on_id":"klbr-b4z","type":"blocks","created_at":"2026-06-27T18:08:16Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}{"_type":"issue","id":"klbr-b4z","title":"Regenerate router calibration artifacts","description":"After klbr-9yo, rerun the router bench with the calibrated tool-required metrics and commit fresh benchmarks/models/router model/report outputs so future sweeps track tool→memory and tool-required recall from generated artifacts.","status":"closed","priority":3,"issue_type":"task","assignee":"dawn","owner":"90008@klbr.net","created_at":"2026-06-27T14:56:42Z","created_by":"dawn","updated_at":"2026-06-27T15:08:17Z","started_at":"2026-06-27T15:01:12Z","closed_at":"2026-06-27T15:08:17Z","close_reason":"Regenerated router linear artifact out-router-linear-iter-15 with tool-required metrics, updated benchmark helper default to the fresh model, and filed klbr-mpy for residual tool→memory quality work.","dependencies":[{"issue_id":"klbr-b4z","depends_on_id":"klbr-9yo","type":"blocks","created_at":"2026-06-27T17:56:51Z","created_by":"dawn","metadata":"{}"}],"dependency_count":1,"dependent_count":1,"comment_count":0}2 unmodified lines85 unmodified lines868788899091929394959697989910010110210310410510610785 unmodified linesthese entries are tagged as `trail_support` in packet signals. the rendered`<memory_packets>` still include refs, lanes, packet kind, facts/timeline rows,and raw bodies, but the zettel trail gives the reader enough local structure tounderstand why a title matters.
## real usage review
do not add a synthetic folgezettel eval until real traces show the failureshape. inspect actual `mk_*` tool use from the sqlite turn log instead:
```bashrtk cargo run -p klbr-bench -- folgezettel-usage \ --db path/to/klbr.sqlite \ --out benchmarks/runs/folgezettel-usage/report.json```
the report summarizes `mk_index`, `mk_search`, `mk_follow`, `mk_recall`,`mk_remember`, `mk_revise`, `mk_link`, and `mk_fleeting` calls. it flags toolfailures, writes that are not cited or reused in later turns, revisions missingthe `refs` supersession plus `supersedes` edge, later mentions of stale revisedrefs, vague titles, and fleeting notes that never get promoted through later`mk_remember` or `mk_revise` calls.25 unmodified lines2627282930313233343528 unmodified lines6465666768697071727374757677787980818283282 unmodified linesExpand all25 unmodified lines #[command(name = "dump-tools")] DumpTools(OutputPathArg), #[command(name = "continuous-loop")] ContinuousLoop(OutputDirArg), #[command(name = "folgezettel-usage")] FolgezettelUsage(FolgezettelUsageArgs), Longmemeval { #[command(subcommand)] command: LongMemEvalCommand, },28 unmodified lines pub(super) output_dir: String,}
#[derive(Debug, Args)]pub(super) struct FolgezettelUsageArgs { #[arg(long)] pub(super) db: PathBuf, #[arg(long)] pub(super) out: Option<PathBuf>, #[arg(long = "lookahead-turns", default_value_t = 40)] pub(super) lookahead_turns: usize, #[arg(long = "excerpt-chars", default_value_t = 240)] pub(super) excerpt_chars: usize,}
#[derive(Debug, Args)]pub(super) struct TraceDiffArgs { pub(super) old_run_dir: String, pub(super) new_run_dir: String, pub(super) question_id: Vec<String>,282 unmodified linesExpand all1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192use std::{ collections::{BTreeMap, BTreeSet}, fs, path::{Path, PathBuf},};
use anyhow::{bail, Context, Result};use chrono::Utc;use klbr_core::models::ToolCall;use rusqlite::{params, Connection, OptionalExtension};use serde::Serialize;use serde_json::Value;
#[derive(Debug)]pub(crate) struct FolgezettelUsageOptions { pub(crate) db: PathBuf, pub(crate) out: Option<PathBuf>, pub(crate) lookahead_turns: usize, pub(crate) excerpt_chars: usize,}
#[derive(Debug, Serialize)]struct FolgezettelUsageReport { schema_version: u8, generated_at: String, db_path: String, options: ReportOptions, note_inventory: BTreeMap<String, usize>, totals: FolgezettelUsageTotals, review: ReviewSummary, calls: Vec<MkToolCallReport>,}
#[derive(Debug, Serialize)]struct ReportOptions { lookahead_turns: usize, excerpt_chars: usize,}
#[derive(Debug, Default, Serialize)]struct FolgezettelUsageTotals { total_calls: usize, failures: usize, writes: usize, searches: usize, follows: usize, recalls: usize, links: usize, revisions: usize, notes_written: usize, later_answer_citation_hits: usize, later_tool_reuse_hits: usize, later_fleeting_promotion_hits: usize, revisions_with_supersedes: usize, revisions_without_supersedes: usize, stale_old_ref_mentions_after_revision: usize, calls_by_tool: BTreeMap<String, usize>,}
#[derive(Debug, Default, Serialize)]struct ReviewSummary { failures: Vec<ReviewItem>, writes_without_later_reuse: Vec<ReviewItem>, revisions_without_supersedes: Vec<ReviewItem>, stale_old_ref_mentions_after_revision: Vec<ReviewItem>, vague_titles: Vec<ReviewItem>, fleeting_without_promotion: Vec<ReviewItem>,}
#[derive(Debug, Clone, Serialize)]struct ReviewItem { assistant_turn_id: i64, tool: String, note_id: Option<String>, title: Option<String>, reason: String,}
#[derive(Debug, Clone, Serialize)]struct MkToolCallReport { assistant_turn_id: i64, tool_result_turn_id: Option<i64>, timestamp: i64, tool_call_id: String, tool: String, arguments: Value, output: ToolOutputSummary, reuse: ReuseSummary, revision: Option<RevisionCheck>, review_flags: Vec<String>,}
#[derive(Debug, Clone, Serialize)]struct ToolOutputSummary { status: String, error: Option<String>, note: Option<NoteOutput>, link: Option<LinkOutput>, notes: Vec<NoteHandle>, raw_excerpt: Option<String>,}
#[derive(Debug, Clone, Serialize)]struct NoteOutput { id: String, ref_id: String, title: String, lane: String, kind: String, path: String, status: String, parent: Option<String>, source_refs: Vec<String>, child_count: usize, body_excerpt: String,}
#[derive(Debug, Clone, Serialize)]struct NoteHandle { id: String, ref_id: String, title: String, lane: String, kind: String, status: String, parent: Option<String>,}
#[derive(Debug, Clone, Serialize)]struct LinkOutput { edge_id: Option<i64>, from: String, to: String, relation: String,}
#[derive(Debug, Clone, Default, Serialize)]struct ReuseSummary { later_answer_citations: Vec<LaterAnswerHit>, later_tool_uses: Vec<LaterToolUseHit>, later_fleeting_promotions: Vec<LaterToolUseHit>,}
#[derive(Debug, Clone, Serialize)]struct LaterAnswerHit { turn_id: i64, matched: String, match_kind: String, excerpt: String,}
#[derive(Debug, Clone, Serialize)]struct LaterToolUseHit { assistant_turn_id: i64, tool_call_id: String, tool: String, matched: String, match_kind: String, arguments: Value,}
#[derive(Debug, Clone, Serialize)]struct RevisionCheck { old_id: String, new_id: Option<String>, old_canonical_refs: Vec<String>, new_canonical_refs: Vec<String>, has_ref_status_link: bool, has_supersedes_edge: bool, stale_old_mentions_after_revision: Vec<LaterAnswerHit>,}
#[derive(Debug, Clone)]struct ReuseKey { kind: &'static str, value: String,}
struct ToolResultRow { id: i64, content: String,}
pub(crate) async fn run_folgezettel_usage_command(options: FolgezettelUsageOptions) -> Result<()> { if options.lookahead_turns == 0 { bail!("--lookahead-turns must be greater than 0"); }
let conn = Connection::open(&options.db) .with_context(|| format!("failed to open sqlite db {}", options.db.display()))?; let report = build_folgezettel_usage_report(&conn, &options.db, &options)?; let output_json = serde_json::to_vec_pretty(&report)?;
if let Some(out) = &options.out { if let Some(parent) = out.parent().filter(|path| !path.as_os_str().is_empty()) { fs::create_dir_all(parent)?; } fs::write(out, output_json)?; println!( "folgezettel usage: {} mk calls, {} writes, {} failures, {} review flags", report.totals.total_calls, report.totals.writes, report.totals.failures, report.review_flag_count() ); println!("wrote folgezettel usage report to {}", out.display()); } else { println!("{}", String::from_utf8(output_json)?); }
Ok(())}
fn build_folgezettel_usage_report( conn: &Connection, db_path: &Path, options: &FolgezettelUsageOptions,) -> Result<FolgezettelUsageReport> { let turn_ranks = load_turn_ranks(conn)?; let mut calls = load_mk_tool_calls(conn, options.excerpt_chars)?;
let reuses = calls .iter() .enumerate() .map(|(idx, _)| { find_later_reuse( conn, &turn_ranks, &calls, idx, options.lookahead_turns, options.excerpt_chars, ) }) .collect::<Result<Vec<_>>>()?; for (call, reuse) in calls.iter_mut().zip(reuses) { call.reuse = reuse; }
let revisions = calls .iter() .map(|call| { if call.tool == "mk_revise" { check_revision( conn, &turn_ranks, call, options.lookahead_turns, options.excerpt_chars, ) .map(Some) } else { Ok(None) } }) .collect::<Result<Vec<_>>>()?; for (call, revision) in calls.iter_mut().zip(revisions) { call.revision = revision; }
for call in &mut calls { call.review_flags = review_flags(call); }
let totals = compute_totals(&calls); let review = collect_review(&calls);
Ok(FolgezettelUsageReport { schema_version: 1, generated_at: Utc::now().to_rfc3339(), db_path: db_path.display().to_string(), options: ReportOptions { lookahead_turns: options.lookahead_turns, excerpt_chars: options.excerpt_chars, }, note_inventory: load_note_inventory(conn)?, totals, review, calls, })}
fn load_mk_tool_calls(conn: &Connection, excerpt_chars: usize) -> Result<Vec<MkToolCallReport>> { let mut stmt = conn.prepare( "SELECT id, ts, tool_calls FROM turns WHERE role = 'assistant' AND tool_calls IS NOT NULL AND tool_calls <> '' ORDER BY id ASC", )?; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, i64>(0)?, row.get::<_, i64>(1)?, row.get::<_, String>(2)?, )) })?;
let mut calls = Vec::new(); for row in rows { let (assistant_turn_id, timestamp, tool_calls_json) = row?; let tool_calls: Vec<ToolCall> = serde_json::from_str(&tool_calls_json) .with_context(|| format!("failed to parse tool_calls for turn {assistant_turn_id}"))?;
for tool_call in tool_calls .into_iter() .filter(|call| call.function.name.starts_with("mk_")) { let arguments = parse_arguments(&tool_call.function.arguments); let result = load_tool_result(conn, &tool_call.id)?; let output = summarize_tool_output( result.as_ref().map(|row| row.content.as_str()), excerpt_chars, ); calls.push(MkToolCallReport { assistant_turn_id, tool_result_turn_id: result.as_ref().map(|row| row.id), timestamp, tool_call_id: tool_call.id, tool: tool_call.function.name, arguments, output, reuse: ReuseSummary::default(), revision: None, review_flags: Vec::new(), }); } }
Ok(calls)}
fn load_tool_result(conn: &Connection, tool_call_id: &str) -> Result<Option<ToolResultRow>> { conn.query_row( "SELECT id, content FROM turns WHERE role = 'tool' AND tool_call_id = ?1 ORDER BY id ASC LIMIT 1", params![tool_call_id], |row| { Ok(ToolResultRow { id: row.get(0)?, content: row.get(1)?, }) }, ) .optional() .map_err(Into::into)}
fn load_turn_ranks(conn: &Connection) -> Result<BTreeMap<i64, usize>> { let mut stmt = conn.prepare("SELECT id FROM turns ORDER BY id ASC")?; let rows = stmt.query_map([], |row| row.get::<_, i64>(0))?; let mut ranks = BTreeMap::new(); for (idx, row) in rows.enumerate() { ranks.insert(row?, idx); } Ok(ranks)}
fn load_note_inventory(conn: &Connection) -> Result<BTreeMap<String, usize>> { let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM markdown_notes GROUP BY kind ORDER BY kind")?; let rows = stmt.query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) })?; let mut inventory = BTreeMap::new(); for row in rows { let (kind, count) = row?; inventory.insert(kind, count as usize); } Ok(inventory)}
fn summarize_tool_output(content: Option<&str>, excerpt_chars: usize) -> ToolOutputSummary { let Some(content) = content else { return ToolOutputSummary { status: "missing_result".to_string(), error: Some("missing tool result row".to_string()), note: None, link: None, notes: Vec::new(), raw_excerpt: None, }; };
let trimmed = content.trim(); if trimmed.starts_with("error:") { return ToolOutputSummary { status: "error".to_string(), error: Some(trimmed.to_string()), note: None, link: None, notes: Vec::new(), raw_excerpt: Some(truncate_chars(trimmed, excerpt_chars)), }; }
let Ok(value) = serde_json::from_str::<Value>(trimmed) else { return ToolOutputSummary { status: "raw".to_string(), error: None, note: None, link: None, notes: Vec::new(), raw_excerpt: Some(truncate_chars(trimmed, excerpt_chars)), }; };
let note = note_output_from_value(&value, excerpt_chars); let link = link_output_from_value(&value); let mut notes = Vec::new(); collect_note_handles(&value, &mut notes); dedup_note_handles(&mut notes);
ToolOutputSummary { status: "ok".to_string(), error: None, note, link, notes, raw_excerpt: None, }}
fn parse_arguments(arguments: &str) -> Value { serde_json::from_str(arguments).unwrap_or_else(|_| Value::String(arguments.to_string()))}
fn note_output_from_value(value: &Value, excerpt_chars: usize) -> Option<NoteOutput> { let handle = note_handle_from_value(value)?; let body = value.get("body").and_then(Value::as_str)?; Some(NoteOutput { id: handle.id, ref_id: handle.ref_id, title: handle.title, lane: handle.lane, kind: handle.kind, path: string_field(value, "path").unwrap_or_default(), status: handle.status, parent: handle.parent, source_refs: string_array_field(value, "source_refs"), child_count: value .get("child_count") .and_then(Value::as_u64) .unwrap_or(0) as usize, body_excerpt: truncate_chars(body, excerpt_chars), })}
fn note_handle_from_value(value: &Value) -> Option<NoteHandle> { let id = string_field(value, "id")?; let ref_id = string_field(value, "ref_id").unwrap_or_else(|| id.clone()); Some(NoteHandle { id, ref_id, title: string_field(value, "title").unwrap_or_default(), lane: string_field(value, "lane").unwrap_or_default(), kind: string_field(value, "kind").unwrap_or_default(), status: string_field(value, "status").unwrap_or_default(), parent: string_field(value, "parent"), })}
fn link_output_from_value(value: &Value) -> Option<LinkOutput> { Some(LinkOutput { edge_id: value.get("edge_id").and_then(Value::as_i64), from: string_field(value, "from")?, to: string_field(value, "to")?, relation: string_field(value, "relation")?, })}
fn collect_note_handles(value: &Value, out: &mut Vec<NoteHandle>) { if let Some(note) = note_handle_from_value(value) { out.push(note); }
match value { Value::Array(items) => { for item in items { collect_note_handles(item, out); } } Value::Object(map) => { for nested in map.values() { collect_note_handles(nested, out); } } _ => {} }}
fn dedup_note_handles(notes: &mut Vec<NoteHandle>) { let mut seen = BTreeSet::new(); notes.retain(|note| seen.insert(note.ref_id.clone()));}
fn find_later_reuse( conn: &Connection, turn_ranks: &BTreeMap<i64, usize>, calls: &[MkToolCallReport], idx: usize, lookahead_turns: usize, excerpt_chars: usize,) -> Result<ReuseSummary> { let call = &calls[idx]; let keys = reuse_keys(call); if keys.is_empty() { return Ok(ReuseSummary::default()); }
let later_answer_citations = later_answer_hits( conn, turn_ranks, call.tool_result_turn_id.unwrap_or(call.assistant_turn_id), lookahead_turns, excerpt_chars, &keys, )?;
let later_tool_uses = calls .iter() .skip(idx + 1) .filter(|later| { within_lookahead( turn_ranks, call.tool_result_turn_id.unwrap_or(call.assistant_turn_id), later.assistant_turn_id, lookahead_turns, ) }) .filter_map(|later| { let arg_text = serde_json::to_string(&later.arguments).ok()?; key_match(&arg_text, &keys, MatchMode::ToolArgument).map(|matched| LaterToolUseHit { assistant_turn_id: later.assistant_turn_id, tool_call_id: later.tool_call_id.clone(), tool: later.tool.clone(), matched: matched.value, match_kind: matched.kind, arguments: later.arguments.clone(), }) }) .collect::<Vec<_>>();
let later_fleeting_promotions = if call.tool == "mk_fleeting" { later_tool_uses .iter() .filter(|hit| matches!(hit.tool.as_str(), "mk_remember" | "mk_revise")) .cloned() .collect() } else { Vec::new() };
Ok(ReuseSummary { later_answer_citations, later_tool_uses, later_fleeting_promotions, })}
fn later_answer_hits( conn: &Connection, turn_ranks: &BTreeMap<i64, usize>, after_turn_id: i64, lookahead_turns: usize, excerpt_chars: usize, keys: &[ReuseKey],) -> Result<Vec<LaterAnswerHit>> { let mut stmt = conn.prepare( "SELECT id, content FROM turns WHERE id > ?1 AND role = 'assistant' AND content <> '' ORDER BY id ASC", )?; let rows = stmt.query_map(params![after_turn_id], |row| { Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) })?;
let mut hits = Vec::new(); for row in rows { let (turn_id, content) = row?; if !within_lookahead(turn_ranks, after_turn_id, turn_id, lookahead_turns) { break; } if let Some(matched) = key_match(&content, keys, MatchMode::AnswerText) { hits.push(LaterAnswerHit { turn_id, matched: matched.value, match_kind: matched.kind, excerpt: matched_excerpt(&content, &matched.raw, excerpt_chars), }); } }
Ok(hits)}
fn check_revision( conn: &Connection, turn_ranks: &BTreeMap<i64, usize>, call: &MkToolCallReport, lookahead_turns: usize, excerpt_chars: usize,) -> Result<RevisionCheck> { let old_id = string_field(&call.arguments, "id").unwrap_or_default(); let old_canonical_refs = canonical_refs(conn, &old_id)?; let new_id = call.output.note.as_ref().map(|note| note.id.clone()); let mut new_refs = BTreeSet::new(); if let Some(note) = &call.output.note { for value in [¬e.id, ¬e.ref_id] { for ref_id in canonical_refs(conn, value)? { new_refs.insert(ref_id); } } } let new_canonical_refs = new_refs.into_iter().collect::<Vec<_>>();
let has_ref_status_link = refs_have_superseded_status(conn, &old_canonical_refs, &new_canonical_refs)?; let has_supersedes_edge = refs_have_supersedes_edge(conn, &old_canonical_refs, &new_canonical_refs)?; let old_keys = old_canonical_refs .iter() .map(|value| ReuseKey { kind: "old_ref", value: value.clone(), }) .collect::<Vec<_>>(); let stale_old_mentions_after_revision = later_answer_hits( conn, turn_ranks, call.tool_result_turn_id.unwrap_or(call.assistant_turn_id), lookahead_turns, excerpt_chars, &old_keys, )?;
Ok(RevisionCheck { old_id, new_id, old_canonical_refs, new_canonical_refs, has_ref_status_link, has_supersedes_edge, stale_old_mentions_after_revision, })}
fn canonical_refs(conn: &Connection, value: &str) -> Result<Vec<String>> { let value = value.trim(); if value.is_empty() { return Ok(Vec::new()); }
let mut refs = BTreeSet::from([value.to_string()]); conn.query_row( "SELECT ref_id FROM ref_aliases WHERE alias = ?1", params![value], |row| row.get::<_, String>(0), ) .optional()? .into_iter() .for_each(|ref_id| { refs.insert(ref_id); }); conn.query_row( "SELECT ref_id FROM refs WHERE ref_id = ?1", params![value], |row| row.get::<_, String>(0), ) .optional()? .into_iter() .for_each(|ref_id| { refs.insert(ref_id); });
Ok(refs.into_iter().collect())}
fn refs_have_superseded_status( conn: &Connection, old_refs: &[String], new_refs: &[String],) -> Result<bool> { for old_ref in old_refs { let replacement = conn .query_row( "SELECT replacement_ref_id FROM refs WHERE ref_id = ?1 AND status = 'superseded'", params![old_ref], |row| row.get::<_, Option<String>>(0), ) .optional()? .flatten(); if replacement .as_ref() .is_some_and(|replacement| new_refs.iter().any(|new_ref| new_ref == replacement)) { return Ok(true); } } Ok(false)}
fn refs_have_supersedes_edge( conn: &Connection, old_refs: &[String], new_refs: &[String],) -> Result<bool> { for new_ref in new_refs { for old_ref in old_refs { let count: i64 = conn.query_row( "SELECT COUNT(*) FROM edges WHERE rel_type = 'supersedes' AND edge_state = 'active' AND (src_ref_id = ?1 OR src_ref_id_original = ?1) AND (dst_ref_id = ?2 OR dst_ref_id_original = ?2)", params![new_ref, old_ref], |row| row.get(0), )?; if count > 0 { return Ok(true); } } } Ok(false)}
fn reuse_keys(call: &MkToolCallReport) -> Vec<ReuseKey> { let mut keys = Vec::new(); let mut seen = BTreeSet::new(); if let Some(note) = &call.output.note { push_key(&mut keys, &mut seen, "note_id", ¬e.id); push_key(&mut keys, &mut seen, "ref_id", ¬e.ref_id); push_title_key(&mut keys, &mut seen, ¬e.title); } if let Some(link) = &call.output.link { push_key(&mut keys, &mut seen, "link_from", &link.from); push_key(&mut keys, &mut seen, "link_to", &link.to); }
for field in ["id", "note_ref", "parent", "from", "to"] { if let Some(value) = string_field(&call.arguments, field) { push_key(&mut keys, &mut seen, field, &value); } } for source_ref in string_array_field(&call.arguments, "source_refs") { push_key(&mut keys, &mut seen, "source_ref", &source_ref); }
keys}
fn push_key( keys: &mut Vec<ReuseKey>, seen: &mut BTreeSet<(String, String)>, kind: &'static str, value: &str,) { let value = value.trim(); if value.len() < 2 { return; } if seen.insert((kind.to_string(), value.to_string())) { keys.push(ReuseKey { kind, value: value.to_string(), }); }}
fn push_title_key(keys: &mut Vec<ReuseKey>, seen: &mut BTreeSet<(String, String)>, title: &str) { if title.chars().count() >= 8 { push_key(keys, seen, "title", title); }}
#[derive(Clone, Copy)]enum MatchMode { AnswerText, ToolArgument,}
struct KeyMatch { kind: String, value: String, raw: String,}
fn key_match(text: &str, keys: &[ReuseKey], mode: MatchMode) -> Option<KeyMatch> { for key in keys { let Some(raw) = key_match_raw(text, key, mode) else { continue; }; return Some(KeyMatch { kind: key.kind.to_string(), value: key.value.clone(), raw, }); } None}
fn key_match_raw(text: &str, key: &ReuseKey, mode: MatchMode) -> Option<String> { match (mode, key.kind) { (MatchMode::AnswerText, "title") => { contains_case_insensitive(text, &key.value).then(|| key.value.clone()) } (MatchMode::AnswerText, _) => { let bracketed = format!("[{}]", key.value); if text.contains(&bracketed) { Some(bracketed) } else if key.value.len() >= 6 && text.contains(&key.value) { Some(key.value.clone()) } else { None } } (MatchMode::ToolArgument, "title") => { contains_case_insensitive(text, &key.value).then(|| key.value.clone()) } (MatchMode::ToolArgument, _) => text.contains(&key.value).then(|| key.value.clone()), }}
fn contains_case_insensitive(haystack: &str, needle: &str) -> bool { haystack .to_ascii_lowercase() .contains(&needle.to_ascii_lowercase())}
fn within_lookahead( turn_ranks: &BTreeMap<i64, usize>, after_turn_id: i64, target_turn_id: i64, lookahead_turns: usize,) -> bool { match ( turn_ranks.get(&after_turn_id), turn_ranks.get(&target_turn_id), ) { (Some(after), Some(target)) => target > after && target - after <= lookahead_turns, _ => target_turn_id > after_turn_id, }}
fn review_flags(call: &MkToolCallReport) -> Vec<String> { let mut flags = Vec::new(); if call.output.error.is_some() { flags.push("failure".to_string()); } if is_write_tool(&call.tool) && call.output.note.is_some() && call.reuse.later_answer_citations.is_empty() && call.reuse.later_tool_uses.is_empty() { flags.push("write_without_later_reuse".to_string()); } if let Some(revision) = &call.revision { if !(revision.has_ref_status_link && revision.has_supersedes_edge) { flags.push("revision_without_supersedes_evidence".to_string()); } if !revision.stale_old_mentions_after_revision.is_empty() { flags.push("stale_old_ref_mentioned_after_revision".to_string()); } } if let Some(note) = &call.output.note { if title_is_vague(¬e.title) { flags.push("vague_title".to_string()); } } if call.tool == "mk_fleeting" && call.output.note.is_some() && call.reuse.later_fleeting_promotions.is_empty() { flags.push("fleeting_without_promotion".to_string()); } flags}
fn compute_totals(calls: &[MkToolCallReport]) -> FolgezettelUsageTotals { let mut totals = FolgezettelUsageTotals { total_calls: calls.len(), ..FolgezettelUsageTotals::default() }; for call in calls { *totals.calls_by_tool.entry(call.tool.clone()).or_default() += 1; if call.output.error.is_some() { totals.failures += 1; } match call.tool.as_str() { "mk_search" => totals.searches += 1, "mk_follow" => totals.follows += 1, "mk_recall" => totals.recalls += 1, "mk_link" => totals.links += 1, "mk_revise" => { totals.revisions += 1; totals.writes += 1; } "mk_remember" | "mk_fleeting" => totals.writes += 1, _ => {} } if is_write_tool(&call.tool) && call.output.note.is_some() { totals.notes_written += 1; } totals.later_answer_citation_hits += call.reuse.later_answer_citations.len(); totals.later_tool_reuse_hits += call.reuse.later_tool_uses.len(); totals.later_fleeting_promotion_hits += call.reuse.later_fleeting_promotions.len(); if let Some(revision) = &call.revision { if revision.has_ref_status_link && revision.has_supersedes_edge { totals.revisions_with_supersedes += 1; } else { totals.revisions_without_supersedes += 1; } totals.stale_old_ref_mentions_after_revision += revision.stale_old_mentions_after_revision.len(); } } totals}
fn collect_review(calls: &[MkToolCallReport]) -> ReviewSummary { let mut review = ReviewSummary::default(); for call in calls { for flag in &call.review_flags { let item = review_item(call, flag); match flag.as_str() { "failure" => review.failures.push(item), "write_without_later_reuse" => review.writes_without_later_reuse.push(item), "revision_without_supersedes_evidence" => { review.revisions_without_supersedes.push(item); } "stale_old_ref_mentioned_after_revision" => { review.stale_old_ref_mentions_after_revision.push(item); } "vague_title" => review.vague_titles.push(item), "fleeting_without_promotion" => review.fleeting_without_promotion.push(item), _ => {} } } } review}
fn review_item(call: &MkToolCallReport, reason: &str) -> ReviewItem { ReviewItem { assistant_turn_id: call.assistant_turn_id, tool: call.tool.clone(), note_id: call.output.note.as_ref().map(|note| note.id.clone()), title: call.output.note.as_ref().map(|note| note.title.clone()), reason: reason.to_string(), }}
impl FolgezettelUsageReport { fn review_flag_count(&self) -> usize { self.review.failures.len() + self.review.writes_without_later_reuse.len() + self.review.revisions_without_supersedes.len() + self.review.stale_old_ref_mentions_after_revision.len() + self.review.vague_titles.len() + self.review.fleeting_without_promotion.len() }}
fn is_write_tool(tool: &str) -> bool { matches!(tool, "mk_remember" | "mk_revise" | "mk_fleeting")}
fn title_is_vague(title: &str) -> bool { let normalized = title.trim().to_ascii_lowercase(); if normalized.is_empty() { return true; } if title.chars().count() > 120 { return true; } if normalized.split_whitespace().count() <= 1 { return true; } matches!( normalized.as_str(), "memory" | "note" | "todo" | "preference" | "project" | "context" | "misc" | "scratch" )}
fn string_field(value: &Value, key: &str) -> Option<String> { value .get(key) .and_then(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string)}
fn string_array_field(value: &Value, key: &str) -> Vec<String> { value .get(key) .and_then(Value::as_array) .into_iter() .flatten() .filter_map(Value::as_str) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .collect()}
fn matched_excerpt(text: &str, matched: &str, max_chars: usize) -> String { let Some(byte_idx) = text.find(matched) else { return truncate_chars(text, max_chars); }; let char_idx = text[..byte_idx].chars().count(); let half = max_chars / 2; let start = char_idx.saturating_sub(half); let excerpt = text.chars().skip(start).take(max_chars).collect::<String>(); if start == 0 && excerpt.chars().count() == text.chars().count() { excerpt } else { format!("...{}...", excerpt) }}
fn truncate_chars(text: &str, max_chars: usize) -> String { if text.chars().count() <= max_chars { return text.to_string(); } let mut out = text.chars().take(max_chars).collect::<String>(); out.push_str("..."); out}
#[cfg(test)]mod tests { use super::*; use serde_json::json;
#[test] fn report_detects_later_answer_citation_for_written_note() { let conn = trace_db(); let tool_calls = json!([{ "id": "call_remember", "type": "function", "function": { "name": "mk_remember", "arguments": "{\"title\":\"Durable source policy\",\"content\":\"body\",\"source_refs\":[\"src-turn\"]}" } }]); conn.execute( "INSERT INTO turns(id, role, content, tool_calls, ts) VALUES (1, 'assistant', '', ?1, 10)", params![tool_calls.to_string()], ) .unwrap(); conn.execute( "INSERT INTO turns(id, role, content, tool_call_id, ts) VALUES (2, 'tool', ?1, 'call_remember', 11)", params![note_json("z-durable-source-policy", "Durable source policy").to_string()], ) .unwrap(); conn.execute( "INSERT INTO turns(id, role, content, ts) VALUES (3, 'assistant', 'i will use [z-durable-source-policy] here', 12)", [], ) .unwrap(); conn.execute( "INSERT INTO markdown_notes(kind) VALUES ('zettel_note')", [], ) .unwrap();
let report = build_folgezettel_usage_report(&conn, Path::new("trace.db"), &options()).unwrap();
assert_eq!(report.totals.total_calls, 1); assert_eq!(report.totals.writes, 1); assert_eq!(report.totals.failures, 0); assert_eq!(report.totals.later_answer_citation_hits, 1); assert!(report.review.writes_without_later_reuse.is_empty()); }
#[test] fn report_verifies_revision_supersedes_edges() { let conn = trace_db(); conn.execute_batch( "INSERT INTO refs(ref_id, status, replacement_ref_id) VALUES ('old-note', 'superseded', 'new-note'); INSERT INTO refs(ref_id, status, replacement_ref_id) VALUES ('new-note', 'active', NULL); INSERT INTO edges(src_ref_id, dst_ref_id, src_ref_id_original, dst_ref_id_original, rel_type, edge_state) VALUES ('new-note', 'old-note', 'new-note', 'old-note', 'supersedes', 'active');", ) .unwrap(); let tool_calls = json!([{ "id": "call_revise", "type": "function", "function": { "name": "mk_revise", "arguments": "{\"id\":\"old-note\",\"title\":\"Replacement policy\",\"content\":\"body\",\"source_refs\":[\"src-turn\"]}" } }]); conn.execute( "INSERT INTO turns(id, role, content, tool_calls, ts) VALUES (1, 'assistant', '', ?1, 10)", params![tool_calls.to_string()], ) .unwrap(); conn.execute( "INSERT INTO turns(id, role, content, tool_call_id, ts) VALUES (2, 'tool', ?1, 'call_revise', 11)", params![note_json("new-note", "Replacement policy").to_string()], ) .unwrap(); conn.execute( "INSERT INTO markdown_notes(kind) VALUES ('zettel_note')", [], ) .unwrap();
let report = build_folgezettel_usage_report(&conn, Path::new("trace.db"), &options()).unwrap(); let revision = report.calls[0].revision.as_ref().unwrap();
assert!(revision.has_ref_status_link); assert!(revision.has_supersedes_edge); assert_eq!(report.totals.revisions_with_supersedes, 1); assert!(report.review.revisions_without_supersedes.is_empty()); }
fn trace_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); conn.execute_batch( "CREATE TABLE turns ( id INTEGER PRIMARY KEY, role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', thinking TEXT, tool_calls TEXT, tool_call_id TEXT, status TEXT NOT NULL DEFAULT 'active', ts INTEGER NOT NULL, metadata TEXT NOT NULL DEFAULT '{}' ); CREATE TABLE markdown_notes (kind TEXT NOT NULL); CREATE TABLE refs ( ref_id TEXT PRIMARY KEY, status TEXT NOT NULL, replacement_ref_id TEXT ); CREATE TABLE ref_aliases (alias TEXT PRIMARY KEY, ref_id TEXT NOT NULL); CREATE TABLE edges ( src_ref_id TEXT, dst_ref_id TEXT, src_ref_id_original TEXT NOT NULL, dst_ref_id_original TEXT NOT NULL, rel_type TEXT NOT NULL, edge_state TEXT NOT NULL );", ) .unwrap(); conn }
fn options() -> FolgezettelUsageOptions { FolgezettelUsageOptions { db: PathBuf::from("trace.db"), out: None, lookahead_turns: 40, excerpt_chars: 120, } }
fn note_json(id: &str, title: &str) -> Value { json!({ "id": id, "ref_id": id, "title": title, "lane": "semantic", "kind": "zettel_note", "path": format!("zettel/{id}.md"), "status": "active", "parent": null, "source_refs": ["src-turn"], "child_count": 0, "body": "sourced body" }) }}