Skip to content

Commit 0ee0e2a

Browse files
committed
feat(cli): add remember subcommand
Adds `icm remember <content>` as a positional shorthand for `icm store`. Topic defaults to the auto-detected project name; can be overridden with `--topic`. Includes `cmd_remember_tests` covering default topic/importance, topic override, and importance override.
1 parent db70ba2 commit 0ee0e2a

1 file changed

Lines changed: 170 additions & 0 deletions

File tree

crates/icm-cli/src/main.rs

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,25 @@ enum Commands {
7979
raw: Option<String>,
8080
},
8181

82+
/// Shorthand for `store` with positional content. Topic defaults to the
83+
/// auto-detected project name (git remote or cwd).
84+
Remember {
85+
/// Fact to remember
86+
content: String,
87+
88+
/// Topic/category (default: auto-detected project name)
89+
#[arg(short, long)]
90+
topic: Option<String>,
91+
92+
/// Importance level
93+
#[arg(short, long, default_value = "medium")]
94+
importance: CliImportance,
95+
96+
/// Keywords (comma-separated)
97+
#[arg(short, long)]
98+
keywords: Option<String>,
99+
},
100+
82101
/// Search memories
83102
Recall {
84103
/// Search query
@@ -1110,6 +1129,26 @@ fn main() -> Result<()> {
11101129
raw,
11111130
)
11121131
}
1132+
Commands::Remember {
1133+
content,
1134+
topic,
1135+
importance,
1136+
keywords,
1137+
} => {
1138+
#[cfg(feature = "embeddings")]
1139+
let emb_ref = embedder.as_ref().map(|e| e as &dyn icm_core::Embedder);
1140+
#[cfg(not(feature = "embeddings"))]
1141+
let emb_ref: Option<&dyn icm_core::Embedder> = None;
1142+
cmd_remember(
1143+
&store,
1144+
emb_ref,
1145+
&cfg.memory,
1146+
content,
1147+
topic,
1148+
importance.into(),
1149+
keywords,
1150+
)
1151+
}
11131152
Commands::Recall {
11141153
query,
11151154
topic,
@@ -1611,6 +1650,38 @@ fn cmd_store(
16111650
Ok(())
16121651
}
16131652

1653+
/// `remember` is `store` with a positional content arg and an auto-detected
1654+
/// topic when `--topic` is omitted.
1655+
#[allow(clippy::too_many_arguments)]
1656+
fn cmd_remember(
1657+
store: &SqliteStore,
1658+
embedder: Option<&dyn icm_core::Embedder>,
1659+
memory_cfg: &crate::config::MemoryConfig,
1660+
content: String,
1661+
topic: Option<String>,
1662+
importance: Importance,
1663+
keywords: Option<String>,
1664+
) -> Result<()> {
1665+
if content.trim().is_empty() {
1666+
anyhow::bail!("content cannot be empty - provide something to remember");
1667+
}
1668+
let resolved_topic = topic.unwrap_or_else(|| {
1669+
let project = detect_project();
1670+
eprintln!("Project: {project}");
1671+
project
1672+
});
1673+
cmd_store(
1674+
store,
1675+
embedder,
1676+
memory_cfg,
1677+
resolved_topic,
1678+
content,
1679+
importance,
1680+
keywords,
1681+
None,
1682+
)
1683+
}
1684+
16141685
#[allow(clippy::too_many_arguments)]
16151686
fn cmd_recall(
16161687
store: &SqliteStore,
@@ -8394,3 +8465,102 @@ mod hook_payload_tests {
83948465
);
83958466
}
83968467
}
8468+
8469+
#[cfg(test)]
8470+
mod cmd_remember_tests {
8471+
//! Parse `icm remember ...` through clap so a broken variant
8472+
//! (wrong positional, swapped fields, dropped default) fails here
8473+
//! rather than only at runtime.
8474+
use super::*;
8475+
8476+
/// Positional content, default topic None, default importance medium.
8477+
#[test]
8478+
fn parses_positional_content_with_defaults() {
8479+
let cli = Cli::try_parse_from(["icm", "remember", "some fact"]).unwrap();
8480+
let Commands::Remember {
8481+
content,
8482+
topic,
8483+
importance,
8484+
keywords,
8485+
} = cli.command
8486+
else {
8487+
panic!("expected Commands::Remember");
8488+
};
8489+
assert_eq!(content, "some fact");
8490+
assert_eq!(topic, None);
8491+
assert!(matches!(importance, CliImportance::Medium));
8492+
assert_eq!(keywords, None);
8493+
}
8494+
8495+
/// `--topic` and `--importance` overrides land on the Remember variant.
8496+
#[test]
8497+
fn parses_topic_and_importance_overrides() {
8498+
let cli = Cli::try_parse_from([
8499+
"icm",
8500+
"remember",
8501+
"critical deployment constraint",
8502+
"--topic",
8503+
"preferences",
8504+
"--importance",
8505+
"high",
8506+
])
8507+
.unwrap();
8508+
let Commands::Remember {
8509+
content,
8510+
topic,
8511+
importance,
8512+
..
8513+
} = cli.command
8514+
else {
8515+
panic!("expected Commands::Remember");
8516+
};
8517+
assert_eq!(content, "critical deployment constraint");
8518+
assert_eq!(topic.as_deref(), Some("preferences"));
8519+
assert!(matches!(importance, CliImportance::High));
8520+
}
8521+
8522+
/// Missing positional content is a parse error.
8523+
#[test]
8524+
fn missing_content_is_a_parse_error() {
8525+
assert!(Cli::try_parse_from(["icm", "remember"]).is_err());
8526+
}
8527+
8528+
/// `remember` appends; prior memories under the same topic stay intact.
8529+
#[test]
8530+
fn remember_appends_status_update_to_existing_memories() {
8531+
use icm_core::{Importance, MemoryStore};
8532+
use icm_store::SqliteStore;
8533+
let store = SqliteStore::in_memory().unwrap();
8534+
let cfg = crate::config::MemoryConfig::default();
8535+
8536+
cmd_store(
8537+
&store,
8538+
None,
8539+
&cfg,
8540+
"icm".into(),
8541+
"TODO: wire FTS5 trigger for memory updates".into(),
8542+
Importance::Medium,
8543+
None,
8544+
None,
8545+
)
8546+
.unwrap();
8547+
8548+
cmd_remember(
8549+
&store,
8550+
None,
8551+
&cfg,
8552+
"FTS5 trigger now syncs on update; closes the recall gap".into(),
8553+
Some("icm".into()),
8554+
Importance::Medium,
8555+
None,
8556+
)
8557+
.unwrap();
8558+
8559+
let memories = store.get_by_topic("icm").unwrap();
8560+
assert_eq!(memories.len(), 2, "remember appends, never overwrites");
8561+
assert!(memories.iter().any(|m| m.summary.contains("TODO")));
8562+
assert!(memories
8563+
.iter()
8564+
.any(|m| m.summary.contains("closes the recall gap")));
8565+
}
8566+
}

0 commit comments

Comments
 (0)