Skip to content

Commit 8536134

Browse files
feat(companion): tools, live session & deterministic crisis guardrails
Turn the read-only Companion into a peer-support sitter that can act, in a calm live-session workspace, backed by a model-independent safety net. Crisis guardrails (load-bearing, deterministic): - new crisis.rs scans each user message for medical / psychiatric / peer signals and returns graded, localized resources — independent of the LLM. A dangerous interaction flagged in the active session escalates to medical. - always-available "Get help now" panic screen; persistent crisis banner. Tool-enabled Companion: - ollama::chat_tools + a bounded tool loop: log_dose, add_note, session_status, lookup_dose, check_interactions. The model can drive the journal at the user's request ("log 100 mg MDMA", "how am I doing?"). - companion_chat now returns { reply, actions, journal_changed }; the UI shows what it did and refreshes. - system prompt rewritten to the Zendo four principles + Fireside stance, support-style intake, and crisis-escalation reinforcement. Live session UI: - calm full-screen mode for an ongoing experience: elapsed time, running doses/timeline, one-tap quick-log & note, inline companion, panic button. - support-style chips ("just listen", "keep me grounded", …) honored by the model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f1bdf55 commit 8536134

6 files changed

Lines changed: 868 additions & 37 deletions

File tree

src-tauri/src/commands.rs

Lines changed: 279 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -317,26 +317,295 @@ pub fn import_experience(db: State<'_, Db>, parsed: ollama::ParsedExperience) ->
317317
Ok(exp)
318318
}
319319

320+
#[derive(Serialize)]
321+
pub struct CompanionReply {
322+
pub reply: String,
323+
/// Human-readable descriptions of any journal actions the model took.
324+
pub actions: Vec<String>,
325+
/// True if the model changed the journal (so the UI should refresh).
326+
pub journal_changed: bool,
327+
}
328+
329+
fn sys(content: impl Into<String>) -> serde_json::Value {
330+
serde_json::json!({ "role": "system", "content": content.into() })
331+
}
332+
333+
/// Tool definitions offered to the Companion during an active session.
334+
fn companion_tools() -> serde_json::Value {
335+
serde_json::json!([
336+
{ "type": "function", "function": {
337+
"name": "log_dose",
338+
"description": "Record a dose the person reports having just taken, in the current session. Only call this when they clearly state they took something. Never suggest or initiate dosing.",
339+
"parameters": { "type": "object", "properties": {
340+
"substance": { "type": "string" },
341+
"amount": { "type": "number", "description": "amount taken; omit if unknown" },
342+
"unit": { "type": "string", "description": "e.g. mg, g, ug, ml" },
343+
"route": { "type": "string", "description": "e.g. oral, insufflated, sublingual" },
344+
"note": { "type": "string" }
345+
}, "required": ["substance"] }
346+
}},
347+
{ "type": "function", "function": {
348+
"name": "add_note",
349+
"description": "Add a note/feeling to the session timeline at the current time.",
350+
"parameters": { "type": "object", "properties": {
351+
"note": { "type": "string" },
352+
"mood": { "type": "string" },
353+
"intensity": { "type": "integer", "description": "1-10 subjective intensity, if given" }
354+
}, "required": ["note"] }
355+
}},
356+
{ "type": "function", "function": {
357+
"name": "session_status",
358+
"description": "Get a summary of the current session: doses logged so far and any known interaction flags. Use for 'how am I doing?'.",
359+
"parameters": { "type": "object", "properties": {} }
360+
}},
361+
{ "type": "function", "function": {
362+
"name": "lookup_dose",
363+
"description": "Look up the bundled dose reference (ranges, duration) for a substance. Facts only — never a prescription.",
364+
"parameters": { "type": "object", "properties": {
365+
"substance": { "type": "string" }
366+
}, "required": ["substance"] }
367+
}},
368+
{ "type": "function", "function": {
369+
"name": "check_interactions",
370+
"description": "Check known interaction risks between two or more substances using the deterministic safety checker.",
371+
"parameters": { "type": "object", "properties": {
372+
"substances": { "type": "array", "items": { "type": "string" } }
373+
}, "required": ["substances"] }
374+
}}
375+
])
376+
}
377+
378+
fn arg_obj(call: &serde_json::Value) -> serde_json::Value {
379+
match call.pointer("/function/arguments") {
380+
Some(serde_json::Value::String(s)) => {
381+
serde_json::from_str(s).unwrap_or_else(|_| serde_json::json!({}))
382+
}
383+
Some(v) => v.clone(),
384+
None => serde_json::json!({}),
385+
}
386+
}
387+
388+
fn now_iso(conn: &rusqlite::Connection) -> rusqlite::Result<String> {
389+
conn.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%SZ','now')", [], |r| r.get(0))
390+
}
391+
392+
/// Execute one Companion tool call against the journal. Returns (result text for
393+
/// the model, optional human-readable action description, whether the journal changed).
394+
fn run_companion_tool(
395+
db: &Db,
396+
experience_id: Option<i64>,
397+
name: &str,
398+
args: &serde_json::Value,
399+
) -> Result<(String, Option<String>, bool), String> {
400+
let s = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
401+
match name {
402+
"log_dose" => {
403+
let Some(id) = experience_id else {
404+
return Ok(("No active session to log into.".into(), None, false));
405+
};
406+
let substance = s("substance");
407+
if substance.trim().is_empty() {
408+
return Ok(("Missing substance name; nothing logged.".into(), None, false));
409+
}
410+
let amount = args.get("amount").and_then(|v| v.as_f64());
411+
let unit = { let u = s("unit"); if u.is_empty() { "mg".into() } else { u } };
412+
let route = s("route");
413+
let note = s("note");
414+
let (dose, warns) = db.with(|c| {
415+
let now = now_iso(c)?;
416+
db::log_dose(c, &DoseInput {
417+
experience_id: id,
418+
substance_name: substance.clone(),
419+
amount,
420+
unit: unit.clone(),
421+
route: route.clone(),
422+
taken_at: now,
423+
note: note.clone(),
424+
})
425+
})?;
426+
let amt = dose.amount.map(|a| format!("{a} {}", dose.unit)).unwrap_or_else(|| dose.unit.clone());
427+
let desc = format!("Logged {amt} {}{}", dose.substance_name, if dose.route.is_empty() { String::new() } else { format!(" ({})", dose.route) });
428+
let mut result = format!("Logged: {desc}.");
429+
if !warns.is_empty() {
430+
result.push_str(" Interaction flags: ");
431+
result.push_str(&warns.iter().map(|w| format!("[{}] {} + {}: {}", w.severity, w.a, w.b, w.message)).collect::<Vec<_>>().join("; "));
432+
}
433+
Ok((result, Some(desc), true))
434+
}
435+
"add_note" => {
436+
let Some(id) = experience_id else {
437+
return Ok(("No active session to note into.".into(), None, false));
438+
};
439+
let note = s("note");
440+
if note.trim().is_empty() {
441+
return Ok(("Empty note; nothing added.".into(), None, false));
442+
}
443+
let mood = s("mood");
444+
let intensity = args.get("intensity").and_then(|v| v.as_i64());
445+
db.with(|c| {
446+
let now = now_iso(c)?;
447+
db::add_timeline_event(c, &TimelineInput {
448+
experience_id: id,
449+
at: now,
450+
note: note.clone(),
451+
mood: mood.clone(),
452+
intensity,
453+
})
454+
})?;
455+
Ok(("Note added to the timeline.".into(), Some("Added a timeline note".into()), true))
456+
}
457+
"session_status" => {
458+
let Some(id) = experience_id else {
459+
return Ok(("No active session.".into(), None, false));
460+
};
461+
let ctx = db.with(|c| Ok(session_context(c, id)))?;
462+
Ok((ctx.unwrap_or_else(|| "No doses logged in this session yet.".into()), None, false))
463+
}
464+
"lookup_dose" => {
465+
let substance = s("substance");
466+
let info = db.with(|c| db::pw_lookup(c, &substance))?;
467+
match info {
468+
Some(pi) => {
469+
let mut out = format!("Dose reference for {}:", pi.name);
470+
for roa in &pi.roas {
471+
let rng = |r: &pw::Range| match (r.min, r.max) {
472+
(Some(a), Some(b)) => format!("{a}-{b}"),
473+
(Some(a), None) => format!("{a}+"),
474+
_ => "?".into(),
475+
};
476+
out.push_str(&format!(
477+
" [{}] {} light {}, common {}, strong {}.",
478+
roa.name, roa.units.clone().unwrap_or_default(), rng(&roa.light), rng(&roa.common), rng(&roa.strong)
479+
));
480+
}
481+
out.push_str(" Reference only, not a prescription.");
482+
Ok((out, None, false))
483+
}
484+
None => Ok((format!("No dose reference found for '{substance}'."), None, false)),
485+
}
486+
}
487+
"check_interactions" => {
488+
let names: Vec<String> = args
489+
.get("substances")
490+
.and_then(|v| v.as_array())
491+
.map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
492+
.unwrap_or_default();
493+
if names.len() < 2 {
494+
return Ok(("Need at least two substances to check.".into(), None, false));
495+
}
496+
let subs: Vec<(String, Vec<String>)> =
497+
names.iter().map(|n| (n.clone(), interactions::builtin_classes(n))).collect();
498+
let mut warns = interactions::check(&subs);
499+
warns.extend(db.with(|c| Ok(db::pw_interaction_warnings(c, &names)))?);
500+
let warns = interactions::dedup_pairs(warns);
501+
if warns.is_empty() {
502+
Ok(("No known interaction flags for that combination. Absence of a flag does not mean it's safe.".into(), None, false))
503+
} else {
504+
let text = warns.iter().map(|w| format!("[{}] {} + {}: {}", w.severity, w.a, w.b, w.message)).collect::<Vec<_>>().join("; ");
505+
Ok((format!("Interaction flags: {text}"), None, false))
506+
}
507+
}
508+
other => Ok((format!("Unknown tool '{other}'."), None, false)),
509+
}
510+
}
511+
320512
#[tauri::command]
321513
pub fn companion_chat(
322514
db: State<'_, Db>,
323515
model: String,
324516
history: Vec<ChatMsg>,
325517
experience_id: Option<i64>,
326-
) -> Result<String, String> {
327-
let mut messages = vec![ChatMsg { role: "system".into(), content: ollama::SYSTEM_PROMPT.into() }];
518+
support_style: Option<String>,
519+
) -> Result<CompanionReply, String> {
520+
let mut messages: Vec<serde_json::Value> = vec![sys(ollama::SYSTEM_PROMPT)];
521+
if let Some(style) = support_style.as_deref().filter(|s| !s.is_empty()) {
522+
messages.push(sys(format!(
523+
"The person has chosen this kind of support for now: \"{style}\". Honor it, and gently re-offer to adjust if it seems to change."
524+
)));
525+
}
328526
if let Some(id) = experience_id {
329-
let ctx = {
330-
let guard = db.conn.lock().unwrap();
331-
let conn = guard.as_ref().ok_or_else(Db::locked_err)?;
332-
session_context(conn, id)
333-
};
527+
let ctx = db.with(|c| Ok(session_context(c, id)))?;
334528
if let Some(ctx) = ctx {
335-
messages.push(ChatMsg { role: "system".into(), content: ctx });
529+
messages.push(sys(ctx));
336530
}
337531
}
338-
messages.extend(history);
339-
ollama::chat(&model, &messages)
532+
for m in &history {
533+
messages.push(serde_json::json!({ "role": m.role, "content": m.content }));
534+
}
535+
536+
// Tools are only offered when there's a session to act on.
537+
let tools = if experience_id.is_some() { companion_tools() } else { serde_json::json!([]) };
538+
let mut actions: Vec<String> = Vec::new();
539+
let mut changed = false;
540+
let mut last_content = String::new();
541+
542+
// Bounded tool loop: the model may call tools, we run them, feed results back.
543+
for _ in 0..5 {
544+
let msg = ollama::chat_tools(&model, &messages, &tools)?;
545+
last_content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("").to_string();
546+
let calls = msg.get("tool_calls").and_then(|t| t.as_array()).cloned().unwrap_or_default();
547+
if calls.is_empty() {
548+
return Ok(CompanionReply { reply: last_content, actions, journal_changed: changed });
549+
}
550+
// Record the assistant's tool-call turn, then answer each call.
551+
messages.push(msg.clone());
552+
for call in &calls {
553+
let name = call.pointer("/function/name").and_then(|n| n.as_str()).unwrap_or("").to_string();
554+
let args = arg_obj(call);
555+
let (result, desc, did_change) = run_companion_tool(db.inner(), experience_id, &name, &args)?;
556+
if let Some(d) = desc {
557+
actions.push(d);
558+
}
559+
changed |= did_change;
560+
messages.push(serde_json::json!({ "role": "tool", "tool_name": name, "content": result }));
561+
}
562+
}
563+
564+
// Ran the loop out — return whatever text we have (or a gentle fallback).
565+
let reply = if last_content.is_empty() {
566+
"I've done what I can with that — how are you feeling now?".to_string()
567+
} else {
568+
last_content
569+
};
570+
Ok(CompanionReply { reply, actions, journal_changed: changed })
571+
}
572+
573+
// ---------- crisis escalation (deterministic) ----------
574+
575+
/// Scan a message for crisis signals, independent of the language model. If a
576+
/// session is active and its combination is flagged dangerous, elevate to medical.
577+
#[tauri::command]
578+
pub fn crisis_scan(db: State<'_, Db>, text: String, experience_id: Option<i64>) -> crate::crisis::CrisisResult {
579+
let mut result = crate::crisis::scan(&text);
580+
if let Some(id) = experience_id {
581+
let has_danger = db
582+
.with(|c| {
583+
let detail = db::get_experience(c, id)?;
584+
let names: Vec<String> = detail
585+
.doses
586+
.iter()
587+
.map(|d| d.substance_name.clone())
588+
.collect::<BTreeSet<String>>()
589+
.into_iter()
590+
.collect();
591+
let subs: Vec<(String, Vec<String>)> =
592+
names.iter().map(|n| (n.clone(), interactions::builtin_classes(n))).collect();
593+
let mut warns = interactions::check(&subs);
594+
warns.extend(db::pw_interaction_warnings(c, &names));
595+
Ok(warns.iter().any(|w| w.severity == "danger"))
596+
})
597+
.unwrap_or(false);
598+
if has_danger {
599+
result = crate::crisis::escalate(result, crate::crisis::Level::Medical, "a dangerous interaction is flagged in this session");
600+
}
601+
}
602+
result
603+
}
604+
605+
/// The full list of emergency/support resources — for the always-available panic screen.
606+
#[tauri::command]
607+
pub fn emergency_resources() -> Vec<crate::crisis::Resource> {
608+
crate::crisis::all_resources()
340609
}
341610

342611
// ---------- encryption at rest & backups ----------

0 commit comments

Comments
 (0)