Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,27 @@ uses `flicknote recall QUERY`; Codex command hooks use
`flicknote recall --hook`, which reads one `UserPromptSubmit` event JSON object
from stdin and emits the same bounded hook JSON contract. The CLI uses the
`--project` argument or `FLICKNOTE_PROJECT`; host event metadata never selects
the project. Empty or unavailable recall supplies no context.
the project. Human recall has a five-second complete daemon-call budget;
command-hook and MCP `note_recall` recall have three seconds. Empty recall,
daemon-unavailable recall, and timed-out recall supply no context. Treat a
timeout as a slow response; recommend daemon status/start only for an actually
unavailable daemon.

Install the command hook with `flicknote hook install codex [--local|--global]`.
Installation does not require an MCP registration, daemon access, or trust
changes. It writes a static shell-quoted absolute CLI command with a one-second
changes. It writes a static shell-quoted absolute CLI command with a three-second
synchronous timeout, preserves unrelated hook configuration, and replaces or
coalesces only recognizable `command` handlers for `flicknote recall --hook` in
the selected hooks file. Old MCP `mcp_tool` hooks are ignored and preserved
unchanged; they never block command-hook installation. If an old MCP hook is
still enabled, remove it manually to avoid duplicate recall. An active command
recall entry in another scope or inline source is reported instead of
duplicated. Review and trust the result in Codex with `/hooks` (and trust the
project for a local hook). Hook failures are nonblocking and must not fabricate
context; use `note_get` to inspect a candidate and verify it before any
separately authorized edit.
project for a local hook). Reinstall an existing command hook explicitly after
upgrading to receive the three-second generated timeout. Hook failures are
nonblocking and must not fabricate context; use `note_get` to inspect a
candidate and verify it before any separately authorized edit. The recall query
optimization is daemon-side and requires the updated daemon to be running.


## Build & Test
Expand Down
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,9 @@ never opens SQLite. The server does not start the daemon automatically.
the daemon and prints up to five matching active-note candidates with their
numeric short IDs, titles, available summaries, and modification times. An
empty or unmatched query prints an empty-result message; it never lists every
note. Use `--project NAME` or `FLICKNOTE_PROJECT` with the same precedence as
the other note queries.
note. Human recall gives the complete daemon call five seconds, including IPC
connection and response work. Use `--project NAME` or `FLICKNOTE_PROJECT` with
the same precedence as the other note queries.

The Codex entrance is a synchronous command hook. Install it without an MCP
registration or a running daemon:
Expand All @@ -207,10 +208,22 @@ The installed handler runs `flicknote recall --hook`. Codex supplies one
string `prompt`, then emits the existing `hookSpecificOutput` JSON contract.
The command is static: prompt text is delivered through stdin and is never
interpolated into shell code. It uses the same five-candidate and 6000-byte
context bounds as the MCP `note_recall` tool. The hook needs the FlickNote
daemon when a prompt arrives; malformed input, an unavailable daemon, or a
timeout emits diagnostics on stderr and no context on stdout, with a
non-blocking failure.
context bounds as the MCP `note_recall` tool. Hook and MCP recall allow three
seconds for the complete daemon call, and the installed command hook has a
three-second synchronous host timeout. That host timeout also bounds an input
stream that never reaches EOF. The hook needs the FlickNote daemon when a
prompt arrives; malformed input, an unavailable daemon, or a response timeout
emits diagnostics on stderr and no context on stdout, with a non-blocking
failure. A response timeout is distinct from an unavailable daemon: only the
latter calls for `flicknote daemon status` and `flicknote daemon start`.

Existing installed hooks keep their generated host timeout until explicitly
reinstalled. After upgrading, run `flicknote hook install codex --local` or
`flicknote hook install codex --global` for the selected scope; reinstalling
updates only the recognizable command-hook entry. The recall query improvement
is in the daemon, so an updated daemon must be running for it to take effect.
Synthetic measurements compare query variants and do not establish a universal
200–300 ms SLA.

In Codex, open `/hooks` to review and trust the installed hook. Project-local
hooks also require a trusted project.
Expand Down
1 change: 1 addition & 0 deletions flicknote-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ async-trait = { workspace = true }
powersync = { workspace = true }
rusqlite = { workspace = true }
uuid = { workspace = true }
tokio = { workspace = true, features = ["process", "test-util"] }

[lints]
workspace = true
7 changes: 4 additions & 3 deletions flicknote-cli/src/commands/hook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ use std::io::{self, BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;

use crate::recall::RECALL_HOOK_TIMEOUT;

const HOOK_EVENT: &str = "UserPromptSubmit";
const COMMAND_HOOK_TYPE: &str = "command";
const RECALL_TIMEOUT_SECONDS: u64 = 1;
const RECALL_COMMAND_SUFFIX: &str = " recall --hook";

#[derive(Args)]
Expand Down Expand Up @@ -587,7 +588,7 @@ fn desired_handler(executable: &Path) -> Result<Value, CliError> {
Ok(json!({
"type": COMMAND_HOOK_TYPE,
"command": format!("{}{}", shell_quote(executable)?, RECALL_COMMAND_SUFFIX),
"timeout": RECALL_TIMEOUT_SECONDS,
"timeout": RECALL_HOOK_TIMEOUT.as_secs(),
}))
}

Expand Down Expand Up @@ -748,7 +749,7 @@ mod tests {
);
assert_eq!(
installed_handler(&installed)["timeout"],
RECALL_TIMEOUT_SECONDS
RECALL_HOOK_TIMEOUT.as_secs()
);
assert!(installed_handler(&installed).get("input").is_none());
assert!(installed_handler(&installed).get("server").is_none());
Expand Down
147 changes: 141 additions & 6 deletions flicknote-cli/src/commands/recall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ use flicknote_core::services::dto::RecallCandidate;
use flicknote_sync::ipc::{AppRequest, DaemonClient};
use serde::Deserialize;
use std::io::{self, Read, Write};
use std::time::Duration;

use super::util::resolve_project_arg;
use crate::recall::{
McpRecallResult, RECALL_HOOK_EVENT, RECALL_QUERY_TIMEOUT, current_time, normalize_timestamp,
McpRecallResult, RECALL_HOOK_EVENT, RECALL_HOOK_TIMEOUT, RECALL_HUMAN_TIMEOUT, current_time,
normalize_timestamp, recall_call_with_timeout,
};

const HOOK_INPUT_MAX_BYTES: usize = 1024 * 1024;
Expand Down Expand Up @@ -50,14 +52,14 @@ pub(crate) async fn run(config: &Config, args: &RecallArgs) -> Result<(), CliErr
{
eprintln!("Filtering by project \"{name}\" from $FLICKNOTE_PROJECT.");
}
let candidates = recall_candidates(config, query, project).await?;
let candidates = recall_candidates(config, query, project, RECALL_HUMAN_TIMEOUT).await?;
println!("{}", render_human_candidates(query, &candidates));
Ok(())
}

async fn run_hook(config: &Config, project: Option<String>) -> Result<(), CliError> {
let event = read_hook_event(&mut io::stdin().lock())?;
let candidates = recall_candidates(config, &event.prompt, project).await?;
let candidates = recall_candidates(config, &event.prompt, project, RECALL_HOOK_TIMEOUT).await?;
let output = serde_json::to_string(&McpRecallResult::from_candidates(
&candidates,
current_time(),
Expand All @@ -72,16 +74,16 @@ async fn recall_candidates(
config: &Config,
prompt: &str,
project: Option<String>,
timeout: Duration,
) -> Result<Vec<RecallCandidate>, CliError> {
tokio::time::timeout(
RECALL_QUERY_TIMEOUT,
recall_call_with_timeout(
timeout,
DaemonClient::new(config).call(AppRequest::NoteRecall {
prompt: prompt.to_string(),
project,
}),
)
.await
.map_err(|_| CliError::Other("FlickNote recall timed out".to_string()))?
.map_err(CliError::from)
}

Expand Down Expand Up @@ -146,8 +148,54 @@ fn single_line(value: &str) -> String {

#[cfg(test)]
mod tests {
use std::path::Path;

use flicknote_core::config::{Config, ConfigPaths};
use flicknote_sync::ipc::{
AppResponse, DaemonResponse, read_request, socket_path, write_response,
};
use tokio::net::UnixListener;
use tokio::sync::oneshot;

use super::*;

fn test_config(directory: &Path) -> Config {
Config {
supabase_url: String::new(),
supabase_anon_key: String::new(),
powersync_url: String::new(),
api_url: String::new(),
gateway_url: String::new(),
web_url: None,
paths: ConfigPaths {
config_dir: directory.to_path_buf(),
data_dir: directory.to_path_buf(),
config_file: directory.join("config.json"),
session_file: directory.join("session.json"),
db_file: directory.join("flicknote.db"),
log_file: directory.join("daemon.log"),
},
}
}

fn delayed_recall_daemon(
config: &Config,
delay: Duration,
) -> (tokio::task::JoinHandle<()>, oneshot::Receiver<()>) {
let path = socket_path(config);
let listener = UnixListener::bind(path).unwrap();
let (ready_sender, ready_receiver) = oneshot::channel();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
read_request(&mut stream).await.unwrap();
ready_sender.send(()).unwrap();
tokio::time::sleep(delay).await;
let response = DaemonResponse::App(Box::new(AppResponse::NoteRecall(Vec::new())));
drop(write_response(&mut stream, &response).await);
});
(server, ready_receiver)
}

fn candidate(
id: i64,
title: Option<&str>,
Expand Down Expand Up @@ -220,4 +268,91 @@ mod tests {
"No recall candidates found."
);
}

#[tokio::test(start_paused = true)]
async fn recall_entrypoints_keep_their_independent_daemon_budgets() {
let human_directory = tempfile::tempdir().unwrap();
let human_config = test_config(human_directory.path());
let (human_server, human_ready) =
delayed_recall_daemon(&human_config, Duration::from_secs(4));
let mut human_call = Box::pin(recall_candidates(
&human_config,
"human query",
None,
RECALL_HUMAN_TIMEOUT,
));
tokio::select! {
result = &mut human_call => panic!("human recall completed before fixture was ready: {result:?}"),
ready = human_ready => ready.unwrap(),
}
tokio::time::advance(Duration::from_secs(4)).await;
tokio::task::yield_now().await;
tokio::task::yield_now().await;
let human_result = human_call.await;
assert!(
human_result.is_ok(),
"human recall failed: {human_result:?}"
);
human_server.await.unwrap();

let hook_directory = tempfile::tempdir().unwrap();
let hook_config = test_config(hook_directory.path());
let (hook_server, hook_ready) = delayed_recall_daemon(&hook_config, Duration::from_secs(2));
let mut hook_call = Box::pin(recall_candidates(
&hook_config,
"hook prompt",
None,
RECALL_HOOK_TIMEOUT,
));
tokio::select! {
result = &mut hook_call => panic!("hook recall completed before fixture was ready: {result:?}"),
ready = hook_ready => ready.unwrap(),
}
tokio::time::advance(Duration::from_secs(2)).await;
tokio::task::yield_now().await;
tokio::task::yield_now().await;
let hook_result = hook_call.await;
assert!(hook_result.is_ok(), "hook recall failed: {hook_result:?}");
hook_server.await.unwrap();

let hook_timeout_directory = tempfile::tempdir().unwrap();
let hook_timeout_config = test_config(hook_timeout_directory.path());
let (hook_timeout_server, hook_timeout_ready) =
delayed_recall_daemon(&hook_timeout_config, Duration::from_secs(4));
let mut hook_timeout_call = Box::pin(recall_candidates(
&hook_timeout_config,
"slow hook prompt",
None,
RECALL_HOOK_TIMEOUT,
));
tokio::select! {
result = &mut hook_timeout_call => panic!("hook timeout completed before fixture was ready: {result:?}"),
ready = hook_timeout_ready => ready.unwrap(),
}
tokio::time::advance(RECALL_HOOK_TIMEOUT).await;
let hook_error = hook_timeout_call.await.unwrap_err();
assert!(hook_error.to_string().contains("timed out"));
hook_timeout_server.abort();
hook_timeout_server.await.unwrap_err();

let human_timeout_directory = tempfile::tempdir().unwrap();
let human_timeout_config = test_config(human_timeout_directory.path());
let (human_timeout_server, human_timeout_ready) =
delayed_recall_daemon(&human_timeout_config, Duration::from_secs(6));
let mut human_timeout_call = Box::pin(recall_candidates(
&human_timeout_config,
"slow human query",
None,
RECALL_HUMAN_TIMEOUT,
));
tokio::select! {
result = &mut human_timeout_call => panic!("human timeout completed before fixture was ready: {result:?}"),
ready = human_timeout_ready => ready.unwrap(),
}
tokio::time::advance(RECALL_HUMAN_TIMEOUT).await;
let human_error = human_timeout_call.await.unwrap_err();
assert!(human_error.to_string().contains("timed out"));
human_timeout_server.abort();
human_timeout_server.await.unwrap_err();
}
}
15 changes: 15 additions & 0 deletions flicknote-cli/src/help/recall.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Human mode takes one query argument:
The query is text supplied by a person or the current conversation. An explicit
empty query is valid and returns no candidates. The command prints at most the
daemon's bounded recall candidates; it does not read note bodies.
Human recall allows five seconds for the complete daemon call, including the
IPC connection and response.

Codex command-hook mode reads one UserPromptSubmit event from stdin and writes
the hook JSON response to stdout:
Expand All @@ -14,3 +16,16 @@ the hook JSON response to stdout:

The event must have `hook_event_name: "UserPromptSubmit"` and a string
`prompt`. Prompt text stays on stdin and is not inserted into shell commands.
Hook recall and MCP `note_recall` allow three seconds for their complete daemon
call. The installed Codex command hook also has a three-second host timeout,
which bounds an input stream that never reaches EOF. Data commands require the
FlickNote daemon; start it with `flicknote daemon start` when it is unavailable.
A response timeout is reported as a timeout rather than daemon unavailability:
human recall exits nonzero with a diagnostic, while hook failures write no
context to stdout so the host can continue without recalled notes.

After upgrading, explicitly reinstall an existing command hook with
`flicknote hook install codex --local` or `--global` to write the new
three-second host timeout. The extraction-first query is daemon-side, so an
updated daemon must be running before the database improvement is used. The
synthetic measurements are comparative evidence, not a universal latency SLA.
Loading