use anyhow::Result;
use rusqlite::Connection;
use serde_json::{json, Value};
use crate::config::PalaceConfig;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HookClient {
Cursor,
Claude,
Codex,
Gemini,
Vscode,
OpenCode,
Cline,
Goose,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum HookEvent {
SessionStart,
UserPromptSubmit,
PostToolUse,
}
impl HookClient {
pub fn parse(value: &str) -> Self {
match value.trim().to_lowercase().as_str() {
"claude" | "claude-code" => Self::Claude,
"codex" => Self::Codex,
"gemini" | "gemini-cli" => Self::Gemini,
"vscode" | "copilot" | "vscode-copilot" => Self::Vscode,
"opencode" => Self::OpenCode,
"cline" => Self::Cline,
"goose" => Self::Goose,
_ => Self::Cursor,
}
}
fn claude_style(self) -> bool {
matches!(
self,
Self::Claude | Self::Codex | Self::Gemini | Self::Vscode
)
}
fn injects_on_prompt(self) -> bool {
!matches!(self, Self::Cursor | Self::Vscode | Self::Goose)
}
fn tracks_palace_use(self) -> bool {
self == Self::Claude
}
fn event_name(self, event: HookEvent) -> &'static str {
match (self, event) {
(_, HookEvent::SessionStart) => "SessionStart",
(Self::Gemini, HookEvent::UserPromptSubmit) => "BeforeAgent",
(Self::Gemini, HookEvent::PostToolUse) => "AfterTool",
(_, HookEvent::UserPromptSubmit) => "UserPromptSubmit",
(_, HookEvent::PostToolUse) => "PostToolUse",
}
}
fn context_output(self, event: HookEvent, context: String) -> Value {
match self {
_ if self.claude_style() => json!({
"hookSpecificOutput": {
"hookEventName": self.event_name(event),
"additionalContext": context,
}
}),
Self::Cline => json!({ "contextModification": context }),
Self::Goose => json!({}),
_ => json!({ "additional_context": context }),
}
}
fn continue_output(self, message: &str) -> Value {
match self {
Self::Claude | Self::Codex => json!({ "decision": "block", "reason": message }),
Self::Gemini => json!({ "decision": "deny", "reason": message }),
Self::Vscode => json!({
"hookSpecificOutput": {
"hookEventName": "Stop",
"decision": "block",
"reason": message,
}
}),
Self::OpenCode => json!({ "followup_message": message }),
Self::Goose => json!({ "decision": "block", "reason": message }),
Self::Cline | Self::Cursor => json!({}),
}
}
}
pub fn normalize_input(input: Value, client: HookClient) -> Value {
let mut input = input;
let set = |input: &mut Value, key: &str, value: Option<Value>| {
if let (Some(value), Some(object)) = (value, input.as_object_mut()) {
object.entry(key).or_insert(value);
}
};
match client {
HookClient::Cline => {
let cwd = input.pointer("/workspaceRoots/0").cloned();
let session = input.get("taskId").cloned();
let prompt = input.pointer("/userPromptSubmit/prompt").cloned();
let tool_name = input
.pointer("/postToolUse/toolName")
.or_else(|| input.pointer("/tool_call/name"))
.cloned();
let tool_input = input
.pointer("/postToolUse/parameters")
.or_else(|| input.pointer("/tool_call/input"))
.cloned();
set(&mut input, "cwd", cwd);
set(&mut input, "session_id", session);
set(&mut input, "prompt", prompt);
set(&mut input, "tool_name", tool_name);
set(&mut input, "tool_input", tool_input);
}
HookClient::Goose => {
let cwd = input.get("working_dir").cloned();
let prompt = input.get("message").cloned();
set(&mut input, "cwd", cwd);
set(&mut input, "prompt", prompt);
}
_ => {}
}
input
}
const RECALL_RELEVANCE_THRESHOLD: f64 = 0.4;
const RECALL_LIMIT: usize = 3;
const RECALL_CANDIDATES: usize = 12;
const SAVE_WINDOW_MINUTES: i64 = 360;
const SAVE_NUDGE: &str = "Before finishing: you investigated using Palace memory this session but \
recorded nothing. Save it so the next agent benefits — call palace_diary_write with what you \
investigated and decided, and palace_kg_add for any durable facts or decisions (use \
palace_kg_invalidate first if a fact changed). Then stop.";
const UNUSED_NUDGE: &str = "Before finishing: this session did real work but did not use Palace \
memory at all. Record it so the next agent benefits — call palace_diary_write with what you \
did and decided, and palace_kg_add for any durable facts. Then stop.";
const BUSY_SESSION_PROMPTS: i64 = 2;
const BUSY_SESSION_TOOL_CALLS: i64 = 3;
pub fn session_start_response(
conn: Option<&Connection>,
input: &Value,
client: HookClient,
) -> Result<String> {
let mut context = format!(
"# Palace Memory Protocol — MANDATORY\n\n{}",
crate::install::RULE_BODY
);
if let Some(conn) = conn {
if let Some(preferences) = standing_preferences(conn) {
context.push_str("\n\n");
context.push_str(&preferences);
}
let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or("");
if let Some(recall) = session_start_recall(conn, cwd) {
context.push_str("\n\n");
context.push_str(&recall);
}
}
let mut response = client.context_output(HookEvent::SessionStart, context);
if client == HookClient::Cursor {
if let Some(session_id) = input.get("session_id").and_then(Value::as_str) {
if !session_id.is_empty() {
response["env"] = json!({ "PALACE_SESSION_ID": session_id });
}
}
}
Ok(serde_json::to_string(&response)?)
}
pub fn post_tool_use_response(conn: &Connection, input: &Value, client: HookClient) -> Value {
let tool = input.get("tool_name").and_then(Value::as_str).unwrap_or("");
if let Some(session_id) = session_id(input) {
let _ = crate::hook_sessions::record_tool(conn, session_id, cwd(input), tool);
}
if crate::hook_sessions::palace_tool(tool).is_some() {
return json!({});
}
let Some(context) = recall_context(conn, input) else {
return json!({});
};
client.context_output(HookEvent::PostToolUse, context)
}
const PROMPT_MIN_WORDS: usize = 3;
const MEMORY_INTENT_TERMS: &[&str] = &[
"last time",
"previous",
"remember",
"why did",
"why do we",
"decided",
"decision",
"we chose",
"convention",
"prefer",
"history",
"what happened",
];
const PROMPT_SEARCH_NUDGE: &str = "Palace memory: this question looks like it depends on prior \
decisions or history, and auto-recall found nothing close. Call palace_search (and \
palace_kg_query for stable facts) before answering from the code or from training data.";
const PALACE_REMINDER: &str = "Palace not consulted yet this session. Load the palace tools if \
they are deferred, run the SESSION START calls (palace_status, palace_session_context), and \
call palace_search before answering questions about past work.";
const PROMPT_SAVE_REMINDER: &str = "Palace: this session has done real work but saved nothing \
yet. Before you finish this task, call palace_diary_write with what you did and decided, and \
palace_kg_add for any durable facts.";
pub fn user_prompt_submit_response(conn: &Connection, input: &Value, client: HookClient) -> Value {
if let Some(session_id) = session_id(input) {
let _ = crate::hook_sessions::record_prompt(conn, session_id, cwd(input));
}
if !client.injects_on_prompt() {
return json!({});
}
let Some(prompt) = substantive_prompt(input) else {
return json!({});
};
let parts: Vec<String> = [
prompt_context(conn, input, prompt),
palace_reminder(conn, input, client),
]
.into_iter()
.flatten()
.collect();
if parts.is_empty() {
return json!({});
}
client.context_output(HookEvent::UserPromptSubmit, parts.join("\n\n"))
}
fn substantive_prompt(input: &Value) -> Option<&str> {
let prompt = input.get("prompt").and_then(Value::as_str)?.trim();
if prompt.starts_with('/') || prompt.split_whitespace().count() < PROMPT_MIN_WORDS {
return None;
}
Some(prompt)
}
fn palace_reminder(conn: &Connection, input: &Value, client: HookClient) -> Option<String> {
if !client.tracks_palace_use() {
return None;
}
let activity = crate::hook_sessions::activity(conn, session_id(input)?).ok()??;
if !activity.palace_used {
return Some(PALACE_REMINDER.to_string());
}
(!activity.saved && activity.tool_calls >= BUSY_SESSION_TOOL_CALLS)
.then(|| PROMPT_SAVE_REMINDER.to_string())
}
fn prompt_context(conn: &Connection, input: &Value, prompt: &str) -> Option<String> {
let lines = recall_lines(conn, prompt, cwd(input));
if !lines.is_empty() {
return Some(format!(
"Palace memory relevant to this request (check it before answering; cite the drawer \
id if you rely on it):\n{}",
lines.join("\n")
));
}
let lower = prompt.to_lowercase();
MEMORY_INTENT_TERMS
.iter()
.any(|term| lower.contains(term))
.then(|| PROMPT_SEARCH_NUDGE.to_string())
}
fn recall_context(conn: &Connection, input: &Value) -> Option<String> {
let query = investigation_query(input)?;
let cwd = input.get("cwd").and_then(Value::as_str).unwrap_or("");
let lines = recall_lines(conn, &query, cwd);
if lines.is_empty() {
return None;
}
Some(format!(
"Palace memory (recall before re-investigating or re-deciding — a prior agent may have \
already done this):\n{}",
lines.join("\n")
))
}
fn recall_lines(conn: &Connection, query: &str, cwd: &str) -> Vec<String> {
let options = crate::searcher::SearchOptions {
rerank: false,
context: false,
};
let results =
crate::searcher::search_memories_with(conn, query, None, None, RECALL_CANDIDATES, options);
let Some(hits) = results.get("results").and_then(Value::as_array) else {
return Vec::new();
};
let scope = ProjectScope::new(conn, cwd);
let mut lines: Vec<String> = Vec::new();
for hit in hits {
if lines.len() >= RECALL_LIMIT {
break;
}
let similarity = hit
.get("similarity")
.and_then(Value::as_f64)
.or_else(|| hit.get("combined").and_then(Value::as_f64))
.unwrap_or(0.0);
if similarity < RECALL_RELEVANCE_THRESHOLD {
continue;
}
let text = hit.get("text").and_then(Value::as_str).unwrap_or_default();
if text.is_empty() {
continue;
}
let id = hit.get("id").and_then(Value::as_str).unwrap_or("");
let wing = hit.get("wing").and_then(Value::as_str).unwrap_or("");
let room = hit.get("room").and_then(Value::as_str).unwrap_or("");
if !scope.allows(conn, id, wing, room, text) {
continue;
}
let source = hit.get("source_file").and_then(Value::as_str).unwrap_or("");
if names_protocol_tools(text) && is_mined_file(conn, source) {
continue;
}
lines.push(format!("- [{wing}/{room}] {} ({id})", compact(text, 240)));
}
lines
}
const PROTOCOL_TOOLS: &[&str] = &[
"palace_status",
"palace_session_context",
"palace_diary_search",
"palace_search",
"palace_kg_query",
"palace_preference_search",
"palace_diary_write",
"palace_kg_add",
];
const PROTOCOL_TOOLS_NAMED: usize = 3;
fn names_protocol_tools(text: &str) -> bool {
PROTOCOL_TOOLS
.iter()
.filter(|tool| text.contains(*tool))
.count()
>= PROTOCOL_TOOLS_NAMED
}
fn is_mined_file(conn: &Connection, source_file: &str) -> bool {
let mined = crate::store::mined_sources(conn).unwrap_or_default();
crate::ranker::is_mined_file(&mined, source_file)
}
struct ProjectScope {
wing: Option<String>,
foreign: std::collections::HashSet<String>,
}
impl ProjectScope {
fn new(conn: &Connection, cwd: &str) -> Self {
let wing = project_wing(conn, cwd);
let foreign = match &wing {
Some(current) => crate::store::list_wings_registry(conn)
.unwrap_or_default()
.into_iter()
.filter(|w| w.kind == "project" && &w.name != current)
.map(|w| w.name)
.collect(),
None => Default::default(),
};
Self { wing, foreign }
}
fn allows(&self, conn: &Connection, id: &str, wing: &str, room: &str, text: &str) -> bool {
let Some(current) = &self.wing else {
return true;
};
if self.foreign.contains(wing) {
return false;
}
if room != "diary" {
return true;
}
let project = crate::store::get_drawer(conn, id)
.ok()
.flatten()
.and_then(|d| {
d.metadata
.get("project_path")
.and_then(Value::as_str)
.filter(|p| !p.trim().is_empty())
.map(|p| crate::miner::wing_slug_from_dir(std::path::Path::new(p)))
})
.or_else(|| diary_project_tag(text));
project.is_none_or(|slug| &slug == current)
}
}
fn diary_project_tag(text: &str) -> Option<String> {
let start = text.find("PROJ:")? + "PROJ:".len();
let name: String = text[start..]
.trim_start()
.chars()
.take_while(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
.collect();
(!name.is_empty()).then(|| name.to_lowercase().replace([' ', '-'], "_"))
}
fn project_wing(conn: &Connection, cwd: &str) -> Option<String> {
if cwd.trim().is_empty() {
return None;
}
use crate::miner::ProjectWingStatus;
match crate::miner::project_wing_status(conn, std::path::Path::new(cwd)).ok()? {
ProjectWingStatus::Mined { wing, .. }
| ProjectWingStatus::RegisteredNotMined { wing, .. } => Some(wing),
ProjectWingStatus::Unknown { suggested_wing, .. } => Some(suggested_wing),
}
}
const PREFERENCES_WING: &str = "general";
const PREFERENCES_ROOM: &str = "preferences";
const STANDING_PREFERENCES_LIMIT: usize = 20;
const STANDING_PREFERENCES_SCAN: usize = 500;
fn standing_preferences(conn: &Connection) -> Option<String> {
let mut drawers = crate::store::list_drawers(
conn,
&crate::store::DrawerFilter {
wing: Some(PREFERENCES_WING.to_string()),
room: Some(PREFERENCES_ROOM.to_string()),
},
STANDING_PREFERENCES_SCAN,
)
.unwrap_or_default();
if drawers.is_empty() {
return None;
}
drawers.sort_by(|a, b| b.importance.total_cmp(&a.importance));
let mut kept = 0;
drawers.retain(|d| {
kept += 1;
kept <= STANDING_PREFERENCES_LIMIT || d.importance >= 5.0
});
let mut lines = vec![format!(
"# Standing instructions (override defaults) — from Palace `{PREFERENCES_WING}/{PREFERENCES_ROOM}`\n\
These are the user's standing rules. They take priority over harness or tool defaults \
(for example built-in file-memory or commit-attribution instructions)."
)];
for d in &drawers {
lines.push(format!("- ({}) {}", d.id, d.content.trim()));
}
Some(lines.join("\n"))
}
const SESSION_START_RECALL_LIMIT: usize = 3;
const SESSION_START_DIARY_HOURS: i64 = 24 * 30;
fn session_start_recall(conn: &Connection, cwd: &str) -> Option<String> {
if cwd.trim().is_empty() {
return None;
}
let path = std::path::Path::new(cwd);
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let project = canonical.to_string_lossy().to_string();
let wing = crate::miner::wing_slug_from_dir(&canonical);
let mut sections: Vec<String> = Vec::new();
let diary = crate::mcp_server::recent_diary_drawers(
conn,
None,
Some(&project),
SESSION_START_DIARY_HOURS,
SESSION_START_RECALL_LIMIT,
);
if !diary.is_empty() {
let mut lines =
vec!["Recent diary for this project (what prior agents did here):".to_string()];
for d in &diary {
let topic = d
.metadata
.get("topic")
.and_then(Value::as_str)
.unwrap_or("general");
let date = d.filed_at.get(..10).unwrap_or(&d.filed_at);
lines.push(format!("- [{date}] ({topic}) {}", compact(&d.content, 220)));
}
sections.push(lines.join("\n"));
}
let project_drawers = crate::store::list_drawers(
conn,
&crate::store::DrawerFilter {
wing: Some(wing.clone()),
room: None,
},
SESSION_START_RECALL_LIMIT,
)
.unwrap_or_default();
let project_drawers: Vec<_> = project_drawers
.into_iter()
.filter(|d| d.room != "diary")
.collect();
if !project_drawers.is_empty() {
let mut lines = vec![format!("Project knowledge (wing `{wing}`):")];
for d in &project_drawers {
lines.push(format!("- [{}] {}", d.room, compact(&d.content, 200)));
}
sections.push(lines.join("\n"));
}
if sections.is_empty() {
return None;
}
Some(format!(
"# Recalled from Palace memory (this project — consult before re-investigating or \
re-deciding; a prior agent may have already done this)\n{}",
sections.join("\n\n")
))
}
fn session_id(input: &Value) -> Option<&str> {
input
.get("session_id")
.and_then(Value::as_str)
.filter(|id| !id.is_empty())
}
fn cwd(input: &Value) -> &str {
input.get("cwd").and_then(Value::as_str).unwrap_or("")
}
pub fn stop_response(conn: &Connection, input: &Value, client: HookClient) -> Value {
if stop_should_skip(input, client) {
return json!({});
}
if let Some(activity) = tracked_activity(conn, input, client) {
return match session_stop_nudge(&activity) {
Some(message) => client.continue_output(message),
None => json!({}),
};
}
match crate::usage::recent_save_status(conn, SAVE_WINDOW_MINUTES) {
Ok((engaged, saved)) if engaged && !saved => client.continue_output(SAVE_NUDGE),
_ => json!({}),
}
}
fn tracked_activity(
conn: &Connection,
input: &Value,
client: HookClient,
) -> Option<crate::hook_sessions::SessionActivity> {
if !client.tracks_palace_use() {
return None;
}
crate::hook_sessions::activity(conn, session_id(input)?)
.ok()
.flatten()
}
fn session_stop_nudge(activity: &crate::hook_sessions::SessionActivity) -> Option<&'static str> {
let busy =
activity.prompts >= BUSY_SESSION_PROMPTS || activity.tool_calls >= BUSY_SESSION_TOOL_CALLS;
if !busy || activity.saved {
return None;
}
Some(if activity.palace_used {
SAVE_NUDGE
} else {
UNUSED_NUDGE
})
}
fn stop_should_skip(input: &Value, client: HookClient) -> bool {
if input
.get("stop_hook_active")
.and_then(Value::as_bool)
.unwrap_or(false)
{
return true;
}
if input.get("loop_count").and_then(Value::as_i64).unwrap_or(0) != 0 {
return true;
}
client == HookClient::Cursor || input.get("cursor_version").is_some()
}
pub fn run(event: &str, client: HookClient) -> Result<()> {
use std::io::Read;
let mut raw = String::new();
std::io::stdin().read_to_string(&mut raw).ok();
let input: Value = serde_json::from_str(raw.trim()).unwrap_or_else(|_| json!({}));
let input = normalize_input(input, client);
let response = match event {
"session-start" | "sessionStart" | "SessionStart" => {
let conn = open_palace();
session_start_response(conn.as_ref(), &input, client)?
}
"post-tool-use" | "postToolUse" | "PostToolUse" => {
serde_json::to_string(&with_db(|conn| {
post_tool_use_response(conn, &input, client)
}))?
}
"user-prompt-submit" | "userPromptSubmit" | "UserPromptSubmit" => {
serde_json::to_string(&with_db(|conn| {
user_prompt_submit_response(conn, &input, client)
}))?
}
"stop" | "Stop" => serde_json::to_string(&with_db(|conn| {
if client == HookClient::Goose && goose_already_nudged(&input) {
return json!({});
}
let response = stop_response(conn, &input, client);
if client == HookClient::Goose && response != json!({}) {
mark_goose_nudged(&input);
}
response
}))?,
other => {
tracing::warn!(event = other, "palace hook: unknown event, ignoring");
"{}".to_string()
}
};
println!("{response}");
Ok(())
}
fn goose_nudge_marker(input: &Value) -> Option<std::path::PathBuf> {
let session = input.get("session_id").and_then(Value::as_str)?;
let safe: String = session
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect();
let db_path = PalaceConfig::new().palace_db_path();
Some(
db_path
.parent()?
.join("hook_state")
.join(format!("goose-stop-{safe}")),
)
}
fn goose_already_nudged(input: &Value) -> bool {
goose_nudge_marker(input).is_some_and(|path| path.exists())
}
fn mark_goose_nudged(input: &Value) {
if let Some(path) = goose_nudge_marker(input) {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(path, "");
}
}
fn with_db<F>(f: F) -> Value
where
F: FnOnce(&Connection) -> Value,
{
match open_palace() {
Some(conn) => f(&conn),
None => json!({}),
}
}
fn open_palace() -> Option<Connection> {
let config = PalaceConfig::new();
match crate::db::open(&config.palace_db_path()) {
Ok(conn) => Some(conn),
Err(err) => {
tracing::warn!(error = %err, "palace hook: could not open palace, skipping");
None
}
}
}
const MUTATING_TOOL_TERMS: &[&str] = &[
"edit", "write", "replace", "patch", "create", "delete", "todo",
];
fn investigation_query(input: &Value) -> Option<String> {
let tool_name = input
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or("")
.to_lowercase();
if MUTATING_TOOL_TERMS
.iter()
.any(|term| tool_name.contains(term))
{
return None;
}
let tool_input = input.get("tool_input");
let candidate = tool_input
.and_then(|ti| ti.get("pattern").and_then(Value::as_str)) .or_else(|| tool_input.and_then(|ti| ti.get("query").and_then(Value::as_str)))
.or_else(|| tool_input.and_then(|ti| ti.get("command").and_then(Value::as_str))) .or_else(|| tool_input.and_then(|ti| ti.get("commands").and_then(Value::as_str))) .or_else(|| tool_input.and_then(|ti| ti.get("file_path").and_then(Value::as_str))) .or_else(|| tool_input.and_then(|ti| ti.get("filePath").and_then(Value::as_str))) .or_else(|| tool_input.and_then(|ti| ti.get("absolute_path").and_then(Value::as_str))) .or_else(|| tool_input.and_then(|ti| ti.get("path").and_then(Value::as_str))) .or_else(|| tool_input.and_then(|ti| ti.get("file").and_then(Value::as_str)))
.or_else(|| tool_input.and_then(|ti| ti.get("paths").and_then(Value::as_str))); let text = candidate?.trim();
if text.len() < 3 {
return None;
}
Some(text.to_string())
}
fn compact(text: &str, max: usize) -> String {
let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() <= max {
return collapsed;
}
let truncated: String = collapsed.chars().take(max).collect();
format!("{truncated}…")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn q(tool_input: Value) -> Option<String> {
investigation_query(&json!({ "tool_input": tool_input }))
}
#[test]
fn investigation_query_extracts_each_investigation_tool_field() {
assert_eq!(
q(json!({"pattern": "retry loop"})).as_deref(),
Some("retry loop")
); assert_eq!(q(json!({"command": "rg dlq"})).as_deref(), Some("rg dlq")); assert_eq!(
q(json!({"file_path": "/proj/src/dlq_consumer.rs"})).as_deref(),
Some("/proj/src/dlq_consumer.rs")
);
}
#[test]
fn diary_project_tag_reads_aaak_proj_marker() {
assert_eq!(
diary_project_tag("2026-08-05 | PROJ:palace-server-license | User rejected…")
.as_deref(),
Some("palace_server_license")
);
assert_eq!(
diary_project_tag("PROJ:palace_rs | fixed").as_deref(),
Some("palace_rs")
);
assert_eq!(diary_project_tag("no project named here"), None);
}
#[test]
fn investigation_query_silent_for_non_investigation_tools() {
assert_eq!(q(json!({"content": "hello"})), None);
assert_eq!(q(json!({"pattern": "x"})), None);
assert_eq!(investigation_query(&json!({})), None);
}
}