diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index f3a8ebd4..d5aa2664 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -1134,6 +1134,7 @@ class CreateMentalModelRequest(BaseModel): model_config = ConfigDict( json_schema_extra={ "example": { + "id": "team-communication", "name": "Team Communication Preferences", "source_query": "How does the team prefer to communicate?", "tags": ["team"], @@ -1143,6 +1144,9 @@ class CreateMentalModelRequest(BaseModel): } ) + id: str | None = Field( + None, description="Optional custom ID for the mental model (alphanumeric lowercase with hyphens)" + ) name: str = Field(description="Human-readable name for the mental model") source_query: str = Field(description="The query to run to generate content") tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility") @@ -2386,6 +2390,7 @@ def _register_routes(app: FastAPI): name=body.name, source_query=body.source_query, content="Generating content...", + mental_model_id=body.id if body.id else None, tags=body.tags if body.tags else None, max_tokens=body.max_tokens, trigger=body.trigger.model_dump() if body.trigger else None, diff --git a/hindsight-api/tests/test_reflections.py b/hindsight-api/tests/test_reflections.py index 4e95c4ce..3ff2f4fe 100644 --- a/hindsight-api/tests/test_reflections.py +++ b/hindsight-api/tests/test_reflections.py @@ -175,6 +175,45 @@ class TestMentalModelsCRUD: # Cleanup await memory.delete_bank(bank_id, request_context=request_context) + @pytest.mark.asyncio + async def test_create_mental_model_with_custom_id(self, memory: MemoryEngine, request_context): + """Test creating a mental model with a custom ID.""" + bank_id = f"test-mental-model-custom-id-{uuid.uuid4().hex[:8]}" + + # Create the bank first + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + # Create a mental model with a custom ID + custom_id = "team-communication-preferences" + mental_model = await memory.create_mental_model( + bank_id=bank_id, + mental_model_id=custom_id, + name="Team Communication Preferences", + source_query="How does the team prefer to communicate?", + content="The team prefers async communication via Slack", + tags=["team", "communication"], + request_context=request_context, + ) + + # Verify the custom ID was used + assert mental_model["id"] == custom_id + assert mental_model["name"] == "Team Communication Preferences" + assert mental_model["tags"] == ["team", "communication"] + + # Verify we can retrieve it with the custom ID + fetched = await memory.get_mental_model( + bank_id=bank_id, + mental_model_id=custom_id, + request_context=request_context, + ) + + assert fetched is not None + assert fetched["id"] == custom_id + assert fetched["name"] == "Team Communication Preferences" + + # Cleanup + await memory.delete_bank(bank_id, request_context=request_context) + class TestObservationsAPI: """Test observations API endpoints. diff --git a/hindsight-cli/src/commands/mental_model.rs b/hindsight-cli/src/commands/mental_model.rs index 3fd2a770..27a373fd 100644 --- a/hindsight-cli/src/commands/mental_model.rs +++ b/hindsight-cli/src/commands/mental_model.rs @@ -98,6 +98,7 @@ pub fn create( bank_id: &str, name: &str, source_query: &str, + id: Option<&str>, verbose: bool, output_format: OutputFormat, ) -> Result<()> { @@ -108,6 +109,7 @@ pub fn create( }; let request = types::CreateMentalModelRequest { + id: id.map(|s| s.to_string()), name: name.to_string(), source_query: source_query.to_string(), max_tokens: 2048, diff --git a/hindsight-cli/src/main.rs b/hindsight-cli/src/main.rs index 4f8c32af..2b652068 100644 --- a/hindsight-cli/src/main.rs +++ b/hindsight-cli/src/main.rs @@ -596,6 +596,10 @@ enum MentalModelCommands { /// Source query to generate the mental model from source_query: String, + + /// Optional custom ID for the mental model (alphanumeric lowercase with hyphens) + #[arg(long)] + id: Option, }, /// Update a mental model @@ -863,8 +867,8 @@ fn run() -> Result<()> { MentalModelCommands::Get { bank_id, mental_model_id } => { commands::mental_model::get(&client, &bank_id, &mental_model_id, verbose, output_format) } - MentalModelCommands::Create { bank_id, name, source_query } => { - commands::mental_model::create(&client, &bank_id, &name, &source_query, verbose, output_format) + MentalModelCommands::Create { bank_id, name, source_query, id } => { + commands::mental_model::create(&client, &bank_id, &name, &source_query, id.as_deref(), verbose, output_format) } MentalModelCommands::Update { bank_id, mental_model_id, name } => { commands::mental_model::update(&client, &bank_id, &mental_model_id, name, verbose, output_format) diff --git a/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py b/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py index 6eb15493..beb35f33 100644 --- a/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py @@ -28,12 +28,13 @@ class CreateMentalModelRequest(BaseModel): """ Request model for creating a mental model. """ # noqa: E501 + id: Optional[StrictStr] = None name: StrictStr = Field(description="Human-readable name for the mental model") source_query: StrictStr = Field(description="The query to run to generate content") tags: Optional[List[StrictStr]] = Field(default=None, description="Tags for scoped visibility") max_tokens: Optional[Annotated[int, Field(le=8192, strict=True, ge=256)]] = Field(default=2048, description="Maximum tokens for generated content") trigger: Optional[MentalModelTrigger] = Field(default=None, description="Trigger settings") - __properties: ClassVar[List[str]] = ["name", "source_query", "tags", "max_tokens", "trigger"] + __properties: ClassVar[List[str]] = ["id", "name", "source_query", "tags", "max_tokens", "trigger"] model_config = ConfigDict( populate_by_name=True, @@ -77,6 +78,11 @@ class CreateMentalModelRequest(BaseModel): # override the default output from pydantic by calling `to_dict()` of trigger if self.trigger: _dict['trigger'] = self.trigger.to_dict() + # set to None if id (nullable) is None + # and model_fields_set contains the field + if self.id is None and "id" in self.model_fields_set: + _dict['id'] = None + return _dict @classmethod @@ -89,6 +95,7 @@ class CreateMentalModelRequest(BaseModel): return cls.model_validate(obj) _obj = cls.model_validate({ + "id": obj.get("id"), "name": obj.get("name"), "source_query": obj.get("source_query"), "tags": obj.get("tags"), diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index 9d7a5d7f..493b1622 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -393,6 +393,12 @@ export type CreateDirectiveRequest = { * Request model for creating a mental model. */ export type CreateMentalModelRequest = { + /** + * Id + * + * Optional custom ID for the mental model (alphanumeric lowercase with hyphens) + */ + id?: string | null; /** * Name * diff --git a/hindsight-control-plane/src/components/mental-models-view.tsx b/hindsight-control-plane/src/components/mental-models-view.tsx index c12f26cd..c6fb7f68 100644 --- a/hindsight-control-plane/src/components/mental-models-view.tsx +++ b/hindsight-control-plane/src/components/mental-models-view.tsx @@ -6,6 +6,7 @@ import { client } from "@/lib/api"; import { useBank } from "@/lib/bank-context"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; import { Card, CardContent } from "@/components/ui/card"; import { Dialog, @@ -45,6 +46,8 @@ import { ChevronsLeft, ChevronsRight, Pencil, + LayoutGrid, + List, } from "lucide-react"; import { MemoryDetailModal } from "./memory-detail-modal"; @@ -66,21 +69,30 @@ interface MentalModel { source_query: string; content: string; tags: string[]; + max_tokens: number; + trigger: { + refresh_after_consolidation: boolean; + }; last_refreshed_at: string; created_at: string; reflect_response?: ReflectResponse; } +type ViewMode = "dashboard" | "table"; + export function MentalModelsView() { const { currentBank } = useBank(); const [mentalModels, setMentalModels] = useState([]); const [loading, setLoading] = useState(false); + const [viewMode, setViewMode] = useState("dashboard"); const [searchQuery, setSearchQuery] = useState(""); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 100; const [showCreateMentalModel, setShowCreateMentalModel] = useState(false); const [selectedMentalModel, setSelectedMentalModel] = useState(null); + const [showUpdateDialog, setShowUpdateDialog] = useState(false); + const [mentalModelToUpdate, setMentalModelToUpdate] = useState(null); const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string; @@ -193,69 +205,91 @@ export function MentalModelsView() { ? `${filteredMentalModels.length} of ${mentalModels.length} mental models` : `${mentalModels.length} mental model${mentalModels.length !== 1 ? "s" : ""}`} - +
+ +
+ + +
+
{filteredMentalModels.length > 0 ? ( <> -
- - - - ID - Name - Source Query - Last Refreshed - - - - - {paginatedMentalModels.map((m) => { - const refreshedDate = new Date(m.last_refreshed_at); - const dateDisplay = refreshedDate.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); - const timeDisplay = refreshedDate.toLocaleTimeString("en-US", { - hour: "2-digit", - minute: "2-digit", - hour12: false, - }); + {/* Dashboard View - Cards */} + {viewMode === "dashboard" && ( +
+ {paginatedMentalModels.map((m) => { + const refreshedDate = new Date(m.last_refreshed_at); + const dateDisplay = refreshedDate.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + const timeDisplay = refreshedDate.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); - return ( - setSelectedMentalModel(m)} - > - - - {m.id} - - - -
{m.name}
-
- -
- {m.source_query} + return ( + setSelectedMentalModel(m)} + > + +
+
+

+ {m.name} +

+
+ + {m.id} + + + {m.trigger?.refresh_after_consolidation + ? "Auto Refresh" + : "Manual"} + +
- - -
{dateDisplay}
-
{timeDisplay}
-
- - - - ); - })} - -
-
+ +

+ {m.source_query} +

+
+ {m.content} +
+
+
+ {m.tags.length > 0 && ( +
+ {m.tags.slice(0, 2).map((tag) => ( + + {tag} + + ))} + {m.tags.length > 2 && ( + + +{m.tags.length - 2} + + )} +
+ )} +
+
+ {dateDisplay} {timeDisplay} +
+
+ + + ); + })} + + )} + + {/* Table View */} + {viewMode === "table" && ( +
+ + + + ID + Name + Source Query + Last Refreshed + + + + + {paginatedMentalModels.map((m) => { + const refreshedDate = new Date(m.last_refreshed_at); + const dateDisplay = refreshedDate.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + const timeDisplay = refreshedDate.toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + + return ( + setSelectedMentalModel(m)} + > + + + {m.id} + + + +
{m.name}
+
+ +
+ {m.source_query} +
+
+ +
{dateDisplay}
+
{timeDisplay}
+
+ + + +
+ ); + })} +
+
+
+ )} {/* Pagination Controls */} {totalPages > 1 && ( @@ -378,12 +515,33 @@ export function MentalModelsView() { onDelete={() => setDeleteTarget({ id: selectedMentalModel.id, name: selectedMentalModel.name }) } + onEdit={() => { + setMentalModelToUpdate(selectedMentalModel); + setShowUpdateDialog(true); + }} onRefreshed={(updated) => { setMentalModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m))); setSelectedMentalModel(updated); }} /> )} + + {mentalModelToUpdate && ( + { + setShowUpdateDialog(false); + setMentalModelToUpdate(null); + }} + onUpdated={(updated) => { + setMentalModels((prev) => prev.map((m) => (m.id === updated.id ? updated : m))); + setSelectedMentalModel(updated); + setShowUpdateDialog(false); + setMentalModelToUpdate(null); + }} + /> + )} ); } @@ -399,7 +557,14 @@ function CreateMentalModelDialog({ }) { const { currentBank } = useBank(); const [creating, setCreating] = useState(false); - const [form, setForm] = useState({ name: "", sourceQuery: "", maxTokens: "2048", tags: "" }); + const [form, setForm] = useState({ + id: "", + name: "", + sourceQuery: "", + maxTokens: "2048", + tags: "", + autoRefresh: false, + }); const handleCreate = async () => { if (!currentBank || !form.name.trim() || !form.sourceQuery.trim()) return; @@ -415,13 +580,22 @@ function CreateMentalModelDialog({ // Submit mental model creation - content will be generated in background await client.createMentalModel(currentBank, { + id: form.id.trim() || undefined, name: form.name.trim(), source_query: form.sourceQuery.trim(), tags: tags.length > 0 ? tags : undefined, max_tokens: maxTokens, + trigger: { refresh_after_consolidation: form.autoRefresh }, }); - setForm({ name: "", sourceQuery: "", maxTokens: "2048", tags: "" }); + setForm({ + id: "", + name: "", + sourceQuery: "", + maxTokens: "2048", + tags: "", + autoRefresh: false, + }); onCreated(); } catch (error) { console.error("Error creating mental model:", error); @@ -436,7 +610,14 @@ function CreateMentalModelDialog({ open={open} onOpenChange={(o) => { if (!o) { - setForm({ name: "", sourceQuery: "", maxTokens: "2048", tags: "" }); + setForm({ + id: "", + name: "", + sourceQuery: "", + maxTokens: "2048", + tags: "", + autoRefresh: false, + }); onClose(); } }} @@ -451,6 +632,19 @@ function CreateMentalModelDialog({
+
+ + setForm({ ...form, id: e.target.value })} + placeholder="e.g., team-communication" + /> +

+ Custom ID for the mental model. If not provided, a UUID will be generated. +

+
+
+ setForm({ ...form, autoRefresh: checked === true })} + /> + +
+

+ Automatically refresh this mental model when memories are consolidated. +

@@ -519,29 +729,186 @@ function CreateMentalModelDialog({ ); } +function UpdateMentalModelDialog({ + open, + mentalModel, + onClose, + onUpdated, +}: { + open: boolean; + mentalModel: MentalModel; + onClose: () => void; + onUpdated: (updated: MentalModel) => void; +}) { + const { currentBank } = useBank(); + const [updating, setUpdating] = useState(false); + const [form, setForm] = useState({ + name: mentalModel.name, + sourceQuery: mentalModel.source_query, + maxTokens: String(mentalModel.max_tokens || 2048), + tags: mentalModel.tags.join(", "), + autoRefresh: mentalModel.trigger?.refresh_after_consolidation || false, + }); + + // Reset form when mental model changes or dialog opens + useEffect(() => { + if (open) { + setForm({ + name: mentalModel.name, + sourceQuery: mentalModel.source_query, + maxTokens: String(mentalModel.max_tokens || 2048), + tags: mentalModel.tags.join(", "), + autoRefresh: mentalModel.trigger?.refresh_after_consolidation || false, + }); + } + }, [open, mentalModel]); + + const handleUpdate = async () => { + if (!currentBank || !form.name.trim() || !form.sourceQuery.trim()) return; + + setUpdating(true); + try { + const tags = form.tags + .split(",") + .map((t) => t.trim()) + .filter((t) => t.length > 0); + + const maxTokens = parseInt(form.maxTokens) || 2048; + + const updated = await client.updateMentalModel(currentBank, mentalModel.id, { + name: form.name.trim(), + source_query: form.sourceQuery.trim(), + tags: tags.length > 0 ? tags : undefined, + max_tokens: maxTokens, + trigger: { refresh_after_consolidation: form.autoRefresh }, + }); + + onUpdated(updated); + onClose(); + } catch (error) { + console.error("Error updating mental model:", error); + alert("Error updating mental model: " + (error as Error).message); + } finally { + setUpdating(false); + } + }; + + return ( + !o && onClose()}> + + + Update Mental Model + + Update the mental model configuration. Changes will take effect immediately. + + + +
+
+ + +

ID cannot be changed after creation.

+
+
+ + setForm({ ...form, name: e.target.value })} + placeholder="e.g., Team Communication Preferences" + /> +
+
+ + setForm({ ...form, sourceQuery: e.target.value })} + placeholder="e.g., How does the team prefer to communicate?" + /> +

+ This query will be run to generate the initial content, and re-run when you refresh. +

+
+
+ + setForm({ ...form, maxTokens: e.target.value })} + placeholder="2048" + min="256" + max="8192" + /> +

+ Maximum tokens for the generated response (256-8192). +

+
+
+ + setForm({ ...form, tags: e.target.value })} + placeholder="e.g., project-x, team-alpha (comma-separated)" + /> +
+
+ setForm({ ...form, autoRefresh: checked === true })} + /> + +
+

+ Automatically refresh this mental model when memories are consolidated. +

+
+ + + + + +
+
+ ); +} + function MentalModelDetailPanel({ mentalModel, onClose, onDelete, + onEdit, onRefreshed, }: { mentalModel: MentalModel; onClose: () => void; onDelete: () => void; + onEdit: () => void; onRefreshed: (m: MentalModel) => void; }) { const { currentBank } = useBank(); const [refreshing, setRefreshing] = useState(false); const [viewMemoryId, setViewMemoryId] = useState(null); - const [isEditing, setIsEditing] = useState(false); - const [editName, setEditName] = useState(mentalModel.name); - const [saving, setSaving] = useState(false); - - // Reset edit form when mental model changes - useEffect(() => { - setEditName(mentalModel.name); - setIsEditing(false); - }, [mentalModel.id, mentalModel.name]); const handleRefresh = async () => { if (!currentBank) return; @@ -591,24 +958,6 @@ function MentalModelDetailPanel({ } }; - const handleSave = async () => { - if (!currentBank || !editName.trim()) return; - - setSaving(true); - try { - const updated = await client.updateMentalModel(currentBank, mentalModel.id, { - name: editName.trim(), - }); - onRefreshed(updated); - setIsEditing(false); - } catch (error) { - console.error("Error updating mental model:", error); - alert("Error updating: " + (error as Error).message); - } finally { - setSaving(false); - } - }; - const formatDateTime = (dateStr: string) => { const date = new Date(dateStr); return `${date.toLocaleDateString("en-US", { @@ -637,47 +986,13 @@ function MentalModelDetailPanel({
- {isEditing ? ( -
- setEditName(e.target.value)} - className="text-lg font-bold" - autoFocus - /> -
- - -
-
- ) : ( - <> -
-

{mentalModel.name}

- -
-

{mentalModel.source_query}

- - )} +
+

{mentalModel.name}

+ +
+

{mentalModel.source_query}