@@ -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]
321513pub 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