ai-memory 0.7.1

AI-agnostic persistent memory system — MCP server, HTTP API, and CLI for any AI platform
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! MCP `memory_skill_promote_from_reflection` handler (v0.7.0 L2-6,
//! issue #671) — the **closing loop** of the recursive-learning
//! substrate.
//!
//! Reflections become skills become reusable knowledge. This handler
//! is the structural keystone that promotes a Reflection-kind memory
//! (synthesised via `memory_reflect` — depth ≥ configurable threshold)
//! into a SKILL.md-format Agent Skill stored in the `skills` table.
//!
//! # Promotion contract
//!
//! 1. **Fetch + validate.** The source memory MUST be Reflection-kind
//!    (`memory_kind = 'reflection'`) and its `reflection_depth` MUST be
//!    `>= skill_promotion_min_depth` (per-namespace
//!    `governance.skill_promotion_min_depth`, default `1`). Depth-0
//!    rows are refused — a reflection at depth 0 carries no synthesised
//!    insight to promote.
//! 2. **Construct SKILL.md.** The handler builds an in-memory SKILL.md
//!    with:
//!    - frontmatter: `name`, `description`, `license=Apache-2.0`,
//!      `metadata.derived_from_reflection_id`,
//!      `metadata.original_reflection_depth`
//!    - body: the reflection's `content` plus structured `## Applies
//!      when` / `## Outputs` sections inferred from the reflection
//!      pattern.
//! 3. **Walk `reflects_on` edges.** Each source memory the reflection
//!    pointed at becomes a `references/source_{i}.md` resource attached
//!    to the new skill. The source's title + body are embedded so the
//!    skill remains reusable even if the source memory is later GC'd.
//! 4. **Compute Ed25519 digest.** Routed through
//!    [`super::skill_register::register_core`], which is the same
//!    function the public `memory_skill_register` tool uses — so the
//!    promoted skill is byte-indistinguishable from a hand-authored
//!    one. The signing-surface digest covers the canonical frontmatter
//!    JSON, the body bytes, and the sorted per-resource digests.
//! 5. **Register.** The constructed skill lands in the `skills` table
//!    with full Bucket-1 attestation when an `active_keypair` is
//!    provided.
//! 6. **Provenance edge.** The new skill's metadata carries
//!    `derived_from_reflection_id` and `original_reflection_depth` so
//!    a downstream auditor can re-derive the promotion lineage. Skills
//!    do not live in the `memory_links` graph (the `skills` table has
//!    its own id space), so the lineage is recorded in metadata rather
//!    than as a `derived_from` row in `memory_links`.
//!
//! # Round-trip guarantee
//!
//! The keystone acceptance for L2-6 is the digest round-trip:
//! `promote → export → re-register → IDENTICAL digest`. The handler
//! achieves this by routing the construction through `register_core`
//! (so the digest is computed exactly once over the canonical
//! frontmatter + body + sorted resource digests) and by serializing the
//! constructed SKILL.md in the same shape `memory_skill_export`
//! produces it on disk. The accompanying integration test pins the
//! contract.

use crate::mcp::param_names;
use crate::models::field_names;
use rusqlite::Connection;
use serde_json::{Value, json};

use crate::identity::keypair::AgentKeypair;
use crate::models::{MemoryKind, MemoryLinkRelation};

use super::skill_register::{RegisterResult, register_core, resource_digest};

/// Compiled default for `governance.skill_promotion_min_depth` when the
/// namespace governance blob does not override it. A reflection at
/// depth 1 represents one level of synthesised insight on top of raw
/// observations — the minimum surface that carries any reusable
/// signal. Depth 0 is the kill-switch refusal (no insight to promote).
const DEFAULT_SKILL_PROMOTION_MIN_DEPTH: u32 = 1;

/// Result of the handler call — broken out as a struct mostly so the
/// hex-encoding step below has a stable target shape.
struct PromoteOutcome {
    skill_id: String,
    digest_hex: String,
    namespace: String,
    name: String,
    reflection_id: String,
    reflection_depth: i32,
    sources_attached: usize,
    superseded: Option<String>,
}

/// MCP handler for `memory_skill_promote_from_reflection`.
///
/// Args:
/// - `reflection_id` (required): the UUID of a Reflection-kind memory.
/// - `skill_name` (required): the agentskills.io-compliant skill name.
/// - `skill_description` (required): 1–1024 char description.
/// - `parameters_schema` (optional): JSON object spliced into the
///   SKILL.md body's `## Parameters` section verbatim.
///
/// Returns a JSON envelope describing the promotion outcome. Errors
/// are plain strings, matching the convention every other MCP handler
/// in this directory follows.
#[allow(clippy::too_many_lines)]
pub fn handle_skill_promote_from_reflection(
    conn: &Connection,
    params: &Value,
    active_keypair: Option<&AgentKeypair>,
) -> Result<Value, String> {
    // ─── 1. Argument parsing ────────────────────────────────────────────
    let reflection_id = params[field_names::REFLECTION_ID]
        .as_str()
        .filter(|s| !s.is_empty())
        .ok_or("memory_skill_promote_from_reflection requires 'reflection_id'")?;
    let skill_name = params[param_names::SKILL_NAME]
        .as_str()
        .filter(|s| !s.is_empty())
        .ok_or("memory_skill_promote_from_reflection requires 'skill_name'")?;
    let skill_description = params[field_names::SKILL_DESCRIPTION]
        .as_str()
        .filter(|s| !s.is_empty())
        .ok_or("memory_skill_promote_from_reflection requires 'skill_description'")?;
    let parameters_schema: Option<&Value> = params
        .get(field_names::PARAMETERS_SCHEMA)
        .filter(|v| !v.is_null() && v.is_object());

    // Validate skill name against agentskills.io §3.1 BEFORE any DB work
    // so the caller sees the parse error at the boundary.
    crate::parsing::skill_md::validate_skill_name(skill_name)?;

    // #913 (security-medium / SOC2, 2026-05-19) — admin/state-change
    // audit. Skill promotion mints a new signed capability bundle from
    // a reflection; emit the forensic-chain row BEFORE the storage write
    // so the audit trail captures the caller + source reflection_id
    // regardless of downstream outcome.
    let caller = crate::identity::resolve_agent_id(params["agent_id"].as_str(), None)
        .unwrap_or_else(|_| crate::identity::sentinels::ANONYMOUS_INVALID.to_string());
    crate::governance::audit::record_decision(
        &caller,
        "allow",
        "skill_promote_from_reflection",
        "",
        serde_json::json!({
            (field_names::REFLECTION_ID): reflection_id,
            (field_names::SKILL_NAME): skill_name,
        }),
    );
    if skill_description.len() > 1024 {
        return Err(format!(
            "skill 'description' must be ≤ 1024 characters \
             (agentskills.io spec §3.2): got {} characters",
            skill_description.len()
        ));
    }

    // ─── 2. Fetch + validate the source reflection ─────────────────────
    let reflection = crate::db::get(conn, reflection_id)
        .map_err(|e| format!("loading reflection '{reflection_id}': {e}"))?
        .ok_or_else(|| format!("reflection not found: {reflection_id}"))?;

    if reflection.memory_kind != MemoryKind::Reflection {
        return Err(format!(
            "memory '{reflection_id}' is memory_kind='{}', expected 'reflection' \
             (memory_skill_promote_from_reflection is reflection-only)",
            reflection.memory_kind
        ));
    }

    // Resolve the per-namespace threshold; compiled default is 1.
    let min_depth = crate::db::resolve_skill_promotion_min_depth(conn, &reflection.namespace)
        .unwrap_or(DEFAULT_SKILL_PROMOTION_MIN_DEPTH);

    // `reflection_depth` is stored as i32; clamp negative values to 0
    // for the comparison so a corrupt row can't slip past the threshold
    // via signed-underflow.
    #[allow(clippy::cast_sign_loss)]
    let actual_depth_u32: u32 = reflection.reflection_depth.max(0) as u32;
    if actual_depth_u32 < min_depth {
        return Err(format!(
            "reflection '{reflection_id}' has reflection_depth={} but \
             namespace '{}' requires skill_promotion_min_depth={}\
             a depth-0 reflection carries no synthesised insight to promote",
            reflection.reflection_depth, reflection.namespace, min_depth,
        ));
    }

    // ─── 3. Walk reflects_on edges → source resources ──────────────────
    // get_links returns edges in both directions; we want the OUTBOUND
    // `reflects_on` edges (source_id == reflection_id, relation ==
    // ReflectsOn). The substrate `reflect` writer is the only producer
    // of these edges, so the order matches the original source_ids
    // input order at the wire level.
    let links = crate::db::get_links(conn, reflection_id)
        .map_err(|e| format!("loading reflects_on edges: {e}"))?;
    let mut source_ids: Vec<String> = links
        .into_iter()
        .filter(|l| l.source_id == reflection_id && l.relation == MemoryLinkRelation::ReflectsOn)
        .map(|l| l.target_id)
        .collect();
    // Stable ordering — `references/source_{i}.md` must be deterministic
    // for the round-trip digest to land identically. SQLite's
    // `query_map` order isn't a documented guarantee, so we sort by id.
    source_ids.sort();

    // Materialise each source memory into a reference resource.
    let mut resources: Vec<(String, String, Vec<u8>)> = Vec::with_capacity(source_ids.len());
    for (i, src_id) in source_ids.iter().enumerate() {
        let src = crate::db::get(conn, src_id)
            .map_err(|e| format!("loading source memory '{src_id}': {e}"))?;
        // Build the reference body. If the source memory is gone (GC'd
        // between reflect and promote), fall back to an id-only stub so
        // the promotion still lands — provenance edge is preserved by id.
        let body = match src {
            Some(m) => format!(
                "# Source memory: {title}\n\n\
                 - memory id: `{id}`\n\
                 - namespace: `{ns}`\n\
                 - reflection_depth: {depth}\n\
                 - created_at: {created}\n\n\
                 ## Content\n\n{content}\n",
                title = m.title,
                id = m.id,
                ns = m.namespace,
                depth = m.reflection_depth,
                created = m.created_at,
                content = m.content,
            ),
            None => format!(
                "# Source memory: (deleted)\n\n\
                 - memory id: `{src_id}`\n\
                 - note: source memory was deleted between reflection and promotion; \
                 only the id provenance edge is preserved.\n",
            ),
        };
        let res_path = format!("references/source_{i}.md");
        resources.push((res_path, "reference".to_string(), body.into_bytes()));
    }

    // ─── 4. Construct the SKILL.md body ────────────────────────────────
    let mut body = String::new();
    body.push_str(&format!("# {skill_name}\n\n"));
    body.push_str(&format!("{skill_description}\n\n"));
    body.push_str("## Reflection content\n\n");
    body.push_str(&reflection.content);
    if !reflection.content.ends_with('\n') {
        body.push('\n');
    }
    body.push('\n');
    body.push_str("## Applies when\n\n");
    body.push_str(
        "This skill was promoted from a reflection memory. It applies in contexts \
         that resemble the situations described above — the source memories listed \
         under `references/` capture the originating evidence.\n\n",
    );
    body.push_str("## Outputs\n\n");
    body.push_str(
        "Apply the reflection content as a reusable pattern. Reference the \
         per-source resources in `references/` for the underlying evidence \
         when the agent needs to re-derive the conclusion.\n",
    );

    if let Some(schema) = parameters_schema {
        let pretty = serde_json::to_string_pretty(schema)
            .map_err(|e| format!("parameters_schema serialize: {e}"))?;
        body.push_str("\n## Parameters\n\n```json\n");
        body.push_str(&pretty);
        body.push_str("\n```\n");
    }

    // ─── 5. Metadata: provenance edge to the source reflection ─────────
    let metadata = json!({
        "derived_from_reflection_id": reflection_id,
        "original_reflection_depth": reflection.reflection_depth,
    });

    // ─── 6. Compute per-resource digests for the signing surface ───────
    let res_digests: Vec<Vec<u8>> = resources
        .iter()
        .map(|(_, _, content)| resource_digest(content))
        .collect();
    let sources_attached = resources.len();

    // ─── 7. Register via the shared core ───────────────────────────────
    // license is hard-coded to "Apache-2.0" per the L2-6 contract — a
    // promoted reflection is a derivative work of the source memories,
    // which themselves carry no per-row license; the project's Apache-2.0
    // umbrella applies. Callers who need a different license must
    // re-register the exported folder with an explicit value.
    let license = Some("Apache-2.0");
    let compatibility: Option<&str> = None;
    let allowed_tools: Vec<String> = Vec::new();

    let RegisterResult {
        id: skill_id,
        digest,
        superseded,
    } = register_core(
        conn,
        &reflection.namespace,
        skill_name,
        skill_description,
        license,
        compatibility,
        &allowed_tools,
        &metadata,
        body.as_bytes(),
        res_digests,
        &resources,
        active_keypair,
    )?;

    let digest_hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();

    let outcome = PromoteOutcome {
        skill_id,
        digest_hex,
        namespace: reflection.namespace.clone(),
        name: skill_name.to_string(),
        reflection_id: reflection_id.to_string(),
        reflection_depth: reflection.reflection_depth,
        sources_attached,
        superseded,
    };

    let mut response = json!({
        "promoted": true,
        "skill_id": outcome.skill_id,
        "namespace": outcome.namespace,
        "name": outcome.name,
        "digest": outcome.digest_hex,
        "derived_from_reflection_id": outcome.reflection_id,
        "original_reflection_depth": outcome.reflection_depth,
        "sources_attached": outcome.sources_attached,
        "signed": active_keypair.is_some(),
    });
    if let Some(prev) = outcome.superseded {
        response[field_names::SUPERSEDED_ID] = json!(prev);
    }
    Ok(response)
}

// --- D1.5 (#986): per-tool McpTool impl for memory_skill_promote_from_reflection ---

use crate::mcp::registry::McpTool;
use schemars::JsonSchema;
use serde::Deserialize;

/// v0.7.0 #972 D1.5 (#986) — request body for
/// `memory_skill_promote_from_reflection`. The `parameters_schema`
/// property is an opaque pass-through JSON object spliced into the
/// generated SKILL.md `Parameters` section.
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
#[allow(dead_code)]
pub struct SkillPromoteFromReflectionRequest {
    /// Reflection-kind memory UUID.
    pub reflection_id: String,

    /// agentskills.io §3.1 name: ^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$, 1-64.
    pub skill_name: String,

    /// 1-1024 char description.
    pub skill_description: String,

    /// Optional JSON schema spliced as Parameters section.
    #[serde(default)]
    pub parameters_schema: Option<serde_json::Value>,
}

/// v0.7.0 #972 D1.5 (#986) — `McpTool` impl for
/// `memory_skill_promote_from_reflection`.
#[allow(dead_code)]
pub struct SkillPromoteFromReflectionTool;

impl McpTool for SkillPromoteFromReflectionTool {
    fn name() -> &'static str {
        crate::mcp::registry::tool_names::MEMORY_SKILL_PROMOTE_FROM_REFLECTION
    }
    fn description() -> &'static str {
        "Promote a Reflection into a reusable Agent Skill."
    }
    fn docs() -> &'static str {
        "L2-6 (#671): reflection (depth>=namespace.governance.skill_promotion_min_depth, default 1) -> SKILL.md. Each reflects_on source -> references/source_{i}.md. Frontmatter preserves derived_from_reflection_id + original_reflection_depth. Promote->export->register => identical SHA-256. Refuses depth-0."
    }
    fn input_schema() -> Value {
        crate::mcp::registry::input_schema_for::<SkillPromoteFromReflectionRequest>()
    }
    fn family() -> &'static str {
        crate::profile::Family::Other.name()
    }
}

#[cfg(test)]
mod d1_5_986_tests {
    //! D1.5 (#986) — schema parity for `memory_skill_promote_from_reflection`.
    //! Shared helpers live at [`crate::mcp::parity_test_helpers`].
    use super::*;
    use crate::mcp::parity_test_helpers::{
        assert_descriptions_match, assert_property_set_parity, derived_props_for,
    };

    #[test]
    fn skill_promote_from_reflection_parity_986() {
        let derived = derived_props_for::<SkillPromoteFromReflectionRequest>();
        assert_property_set_parity("memory_skill_promote_from_reflection", &derived);
        assert_descriptions_match("memory_skill_promote_from_reflection", &derived);
    }

    #[test]
    fn skill_promote_from_reflection_tool_metadata_986() {
        assert_eq!(
            SkillPromoteFromReflectionTool::name(),
            "memory_skill_promote_from_reflection"
        );
        assert_eq!(SkillPromoteFromReflectionTool::family(), "other");
    }
}

// ---------------------------------------------------------------------------
// Lib-level unit tests — exercise the depth-threshold gate at the
// handler boundary so the failure mode is pinned without spinning up
// the full integration harness. Round-trip and end-to-end scenarios
// land in `tests/skill_promote_test.rs`.
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::db;
    use crate::models::{Memory, MemoryKind, Tier};
    use serde_json::json as sjson;

    fn open_db() -> (rusqlite::Connection, tempfile::TempDir) {
        let dir = tempfile::tempdir().expect("tempdir");
        let path = dir.path().join("promote.db");
        let conn = db::open(&path).expect("db open");
        (conn, dir)
    }

    fn insert_observation(conn: &rusqlite::Connection, title: &str, ns: &str) -> String {
        let now = chrono::Utc::now().to_rfc3339();
        let m = Memory {
            id: uuid::Uuid::new_v4().to_string(),
            tier: Tier::Mid,
            namespace: ns.to_string(),
            title: title.to_string(),
            content: format!("body of {title}"),
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "cli".to_string(),
            access_count: 0,
            created_at: now.clone(),
            updated_at: now,
            last_accessed_at: None,
            expires_at: None,
            metadata: sjson!({}),
            reflection_depth: 0,
            memory_kind: MemoryKind::Observation,
            entity_id: None,
            persona_version: None,
            citations: Vec::new(),
            source_uri: None,
            source_span: None,
            confidence_source: crate::models::ConfidenceSource::CallerProvided,
            confidence_signals: None,
            confidence_decayed_at: None,
            version: 1,
        };
        db::insert(conn, &m).expect("insert observation")
    }

    fn make_reflection(conn: &rusqlite::Connection, sources: &[String], ns: &str) -> String {
        let input = db::ReflectInput {
            source_ids: sources.to_vec(),
            title: format!("reflection over {} sources", sources.len()),
            content: "Synthesised insight: pattern X implies action Y.".to_string(),
            namespace: Some(ns.to_string()),
            tier: Tier::Mid,
            tags: vec![],
            priority: 5,
            confidence: 1.0,
            source: "cli".to_string(),
            agent_id: "test-agent".to_string(),
            metadata: sjson!({}),
        };
        db::reflect(conn, &input).expect("reflect").id
    }

    #[test]
    fn refuses_non_reflection_memory() {
        let (conn, _dir) = open_db();
        let obs_id = insert_observation(&conn, "raw note", "ns");
        let params = sjson!({
            "reflection_id": obs_id,
            "skill_name": "test-skill",
            "skill_description": "Test skill from observation (should fail).",
        });
        let err = handle_skill_promote_from_reflection(&conn, &params, None).unwrap_err();
        assert!(
            err.contains("memory_kind='observation'"),
            "must surface kind mismatch: {err}",
        );
    }

    #[test]
    fn refuses_unknown_reflection_id() {
        let (conn, _dir) = open_db();
        let params = sjson!({
            "reflection_id": "nonexistent-id",
            "skill_name": "x",
            "skill_description": "desc",
        });
        let err = handle_skill_promote_from_reflection(&conn, &params, None).unwrap_err();
        assert!(err.contains("not found"), "expected not found: {err}");
    }

    #[test]
    fn refuses_invalid_skill_name() {
        let (conn, _dir) = open_db();
        let obs_id = insert_observation(&conn, "source", "ns");
        let refl_id = make_reflection(&conn, &[obs_id], "ns");
        let params = sjson!({
            "reflection_id": refl_id,
            "skill_name": "BadName",
            "skill_description": "desc",
        });
        let err = handle_skill_promote_from_reflection(&conn, &params, None).unwrap_err();
        assert!(err.contains("spec §3.1"), "must cite spec: {err}");
    }

    #[test]
    fn rejects_missing_required_params() {
        let (conn, _dir) = open_db();
        let err = handle_skill_promote_from_reflection(&conn, &sjson!({}), None).unwrap_err();
        assert!(err.contains("reflection_id"), "{err}");

        let err =
            handle_skill_promote_from_reflection(&conn, &sjson!({"reflection_id": "x"}), None)
                .unwrap_err();
        assert!(err.contains("skill_name"), "{err}");

        let err = handle_skill_promote_from_reflection(
            &conn,
            &sjson!({"reflection_id": "x", "skill_name": "n"}),
            None,
        )
        .unwrap_err();
        assert!(err.contains("skill_description"), "{err}");
    }
}