Skip to content

feat(bin): opt-in daemon fast-path for search and vsearch#5

Closed
lukeboyett wants to merge 10 commits intofix/db-transaction-typefrom
feat/bin-qmd-daemon-fastpath
Closed

feat(bin): opt-in daemon fast-path for search and vsearch#5
lukeboyett wants to merge 10 commits intofix/db-transaction-typefrom
feat/bin-qmd-daemon-fastpath

Conversation

@lukeboyett
Copy link
Copy Markdown
Owner

Summary

Wires bin/qmd to the existing POST /search REST endpoint on qmd mcp --http --daemon so qmd search / qmd vsearch can skip the ~500–800 ms per-call Node + better-sqlite3 + sqlite-vec bootstrap on large indexes.

Opt-in via QMD_DAEMON_URL. Default interactive behavior is unchanged.

Why

For scripted / agent workflows on a large index, the cold-start cost dominates wall clock: module loading, SQLite open, extension init. qmd mcp --http --daemon already ships as an always-warm process and already exposes the structured-search REST endpoint. This PR is purely a CLI routing change — no new daemon, no new endpoints, no architectural duplication with MCP.

Behavior

When QMD_DAEMON_URL is set, bin/qmd:

  • Health-checks GET /health with a 1 s timeout.
  • Translates qmd search foo -c alma --limit 5POST /search {"searches":[{"type":"lex","query":"foo"}],"collections":["alma"],"limit":5}.
  • vsearch uses type:"vec".
  • Prints whatever JSON the daemon returns.
  • Silently falls through to cold-start on: missing env var, health-check timeout, non-2xx response, --index <name> (one daemon = one index).

Why opt-in, not auto-detect

The daemon returns structured JSON; the cold-start CLI prints formatted text. Auto-routing would surprise interactive users by changing stdout shape. Opt-in via env var means scripts explicitly choose JSON and interactive users never notice.

Tests

New contract tests in test/mcp.test.ts under "MCP HTTP Transport":

  • POST /search happy path with type="lex" + collections — asserts the {docid, file, title, score} fields the fast-path relies on.
  • POST /search with null collections (unset -c case).
  • POST /search with missing searches returns 400.
  • POST /search with type="vec" (vsearch routing).

These pin the payload/response shape so a future change to that endpoint can't silently break the CLI fast-path.

Docs

  • README: new "Fast CLI via daemon (scripts / agents)" section with usage, example payload, and fall-through rules.
  • CHANGELOG entry under Changes.

Notes

`qmd search foo` on a large index pays ~500–800 ms of Node + better-
sqlite3 + sqlite-vec bootstrap every call — most of the wall clock is
module initialization, not the query itself. `qmd mcp --http --daemon`
already ships as an always-warm server and already exposes a
`POST /search` REST endpoint (used today by MCP-adjacent tooling), so
the cold-start cost is avoidable for callers willing to keep a daemon
running.

This PR wires `bin/qmd` to that existing endpoint. When `QMD_DAEMON_URL`
is set, the shell wrapper:

- Health-checks `GET /health` with a 1 s timeout. On failure it falls
  through to the normal cold-start CLI.
- Translates `qmd search foo -c alma --limit 5` into
  `POST /search {"searches":[{"type":"lex","query":"foo"}],
  "collections":["alma"],"limit":5}`.
- Does the same for `vsearch` with `type:"vec"`.
- Prints whatever JSON the daemon returns (users that want formatted
  text leave `QMD_DAEMON_URL` unset).
- Silently falls through when `--index <name>` is used (one daemon =
  one index) or when any curl error occurs.

No new daemon or new endpoints are introduced; this is purely a CLI
routing change over existing server surface. Stays POSIX sh so it
works under the current #!/bin/sh shebang.

Tests:

- `test/mcp.test.ts` "MCP HTTP Transport" suite gains contract tests
  for the REST path `bin/qmd` depends on: happy path with
  `type="lex"` + collections, happy path with `null` collections
  (unset `-c`), happy path with `type="vec"` (`vsearch`), and a 400
  for missing `searches`. These pin the payload/response shape so a
  future upstream change to that endpoint can't silently break the
  fast-path.

Docs:

- README gains a "Fast CLI via daemon (scripts / agents)" section with
  the opt-in env var, example payload, and fall-through rules.
- CHANGELOG entry under Changes.
@lukeboyett
Copy link
Copy Markdown
Owner Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a45a7cc1b4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/qmd Outdated
_qmd_limit=10
_qmd_collection=""
_qmd_query=""
shift # drop the subcommand
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve CLI args when daemon POST falls through

This shift starts destructive parsing of $@, and the loop below consumes all remaining args before the daemon request. If /health passes but POST /search fails (the path this change says should “silently fall through”), execution reaches the final exec ... "$@" with mutated/empty argv, so the cold-start fallback no longer receives the original search/vsearch command and query.

Useful? React with 👍 / 👎.

Comment thread bin/qmd Outdated
Comment on lines +64 to +65
-l|--limit) _qmd_limit="$2"; shift 2 ;;
--limit=*) _qmd_limit="${1#*=}"; shift ;;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Support -n limit flag in daemon fast-path parser

The fast-path parses -l|--limit, but the CLI’s search limit option is -n (see src/cli/qmd.ts option parsing/help). Under daemon mode, qmd search -n 1 foo hits the generic -* branch, so -n is discarded, 1 is treated as part of the query text, and the request still uses the default limit (10), producing incorrect query and result count.

Useful? React with 👍 / 👎.

Comment thread bin/qmd Outdated
Comment on lines +66 to +67
-c|--collection) _qmd_collection="$2"; shift 2 ;;
--collection=*) _qmd_collection="${1#*=}"; shift ;;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve all collection filters in daemon requests

This parser stores collections in a single scalar, so repeated -c/--collection flags overwrite each other; the main CLI supports multiple collections (collection is declared multiple: true in src/cli/qmd.ts). With daemon routing enabled, a call like qmd search foo -c alpha -c beta will silently search only beta, changing result scope.

Useful? React with 👍 / 👎.

Three linked fixes for the daemon fast-path, addressing Codex round 1
on #5:

- P1 "Preserve CLI args when daemon POST falls through": the previous
  implementation did `shift` in the outer shell and then iterated `$@`
  destructively. A healthy /health followed by a failed POST would
  reach the fallback `exec` with mutated/empty argv, so the cold-start
  CLI would see the wrong command. Moved the whole fast-path into a
  shell function (`_qmd_try_daemon`) so its parsing operates on a
  local copy of the args. The outer shell's `$@` is never touched,
  and on any `return 1` (health fail, POST fail, empty query) control
  falls through to the lockfile-gated `exec` with the exact argv the
  user typed.

- P1 "Support -n limit flag in daemon fast-path parser": upstream's
  search limit flag is `-n <num>` (`src/cli/qmd.ts:2486`, documented
  at `--help` line 2773). The fast-path was parsing `-l|--limit` which
  does not exist on `search` (`-l` is a `get` option). `qmd search
  foo -n 3` would discard `-n`, treat `3` as part of the query, and
  use the default limit of 10. Now parses `-n`, `--n`, `--n=...`.

- P2 "Preserve all collection filters": `collection` is declared
  `multiple: true` in `src/cli/qmd.ts:2496` — `qmd search foo -c a -c
  b` should hit both collections. The parser stored a single scalar
  so only the last `-c` survived. Collections are now accumulated
  into a JSON array (`["a","b"]`) or rendered as `null` when no `-c`
  is passed.

Tests: new `test/bin-qmd-daemon-fastpath.test.ts` spins up a tiny HTTP
mock on a random port, exports QMD_DAEMON_URL, and spawns the shell
wrapper via async spawn (spawnSync would deadlock the mock's event
loop). It covers:

  - payload shape for `search -c -n`,
  - `vsearch` → `type:"vec"`,
  - multiple `-c` flags accumulate,
  - `--collection=` long form mixes with `-c`,
  - unset `-c` yields `collections: null`,
  - `--index` bypasses (zero captures),
  - non-`search` subcommands don't touch the daemon,
  - health-check 500 falls through without attempting POST.
@lukeboyett
Copy link
Copy Markdown
Owner Author

All three addressed in 4a9eb76.

P1 "Preserve CLI args when daemon POST falls through" — fast-path logic moved into a shell function (_qmd_try_daemon) so its argument parsing operates on a local copy. Outer $@ is untouched; on any return 1 (health fail, POST fail, empty query) the cold-start exec gets the exact argv the user typed.

P1 "Support -n limit flag" — upstream's search limit flag is -n <num> (the --limit long-form I was parsing doesn't exist). Fixed to parse -n, --n, --n=<N>.

P2 "Preserve all collection filters"-c is multiple: true in the CLI parser. The fast-path now accumulates collections into a JSON array (["a","b"]) rather than overwriting a scalar. Unset -c renders as null.

New integration suite test/bin-qmd-daemon-fastpath.test.ts spins up a mock HTTP server on a random port, spawns the shell wrapper via async spawn (spawnSync would deadlock the mock's own event loop — a footgun caught during iteration), and asserts payload shape + all three bug scenarios.

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4a9eb760c0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/qmd
Comment on lines +109 to +113
-*)
shift ;;
*)
if [ -z "$_qmd_query" ]; then _qmd_query="$1"
else _qmd_query="$_qmd_query $1"; fi
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid leaking unknown option values into query text

With QMD_DAEMON_URL set, options that are valid for search/vsearch but not explicitly handled in this parser (for example --min-score 0.8 or --intent auth) pass through the -* branch and only the flag token is shifted. On the next iteration, the option value is treated as a positional term and appended to _qmd_query, so the daemon receives a mutated query (e.g. foo 0.8) while the option semantics are silently lost. This produces incorrect search behavior in fast-path mode instead of preserving CLI behavior or falling back.

Useful? React with 👍 / 👎.

Codex P1 on #5 round 2: the fast-path's catch-all for unrecognised
dashed flags was `shift` (drop the flag token only). Several upstream
`search` / `vsearch` options take a value — `--min-score <N>`,
`-C/--candidate-limit`, `--intent <text>`, etc. — while others are
booleans. A call like `qmd search foo --min-score 0.8` would drop
`--min-score`, then on the next loop the `*)` arm would treat `0.8`
as part of the query text. The daemon would receive `foo 0.8` and
the min-score semantics would vanish silently.

Rather than enumerate every upstream flag (brittle — breaks each time
upstream adds one), the unknown-flag branch now `return 1`s out of
the fast-path and lets the cold-start CLI handle the invocation.
The opt-in fast-path stays a strict subset of interactive CLI
semantics: if we can't decode the argument safely, the user gets
correct results via the cold-start path instead of wrong results
fast.

Tests cover both a value-taking unknown flag (`--min-score 0.8`)
and a boolean one (`--json`). Both now fall through; the mock
server records no `/search` request.
@lukeboyett
Copy link
Copy Markdown
Owner Author

Addressed in 661d203: unknown flags now return 1 out of the fast-path so the cold-start CLI (which knows the full flag set) handles them. Covers value-taking flags like --min-score and booleans like --json. New tests for both. Tradeoff: fast-path stays a strict subset of CLI semantics — better slow-but-correct than fast-but-wrong.

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 661d203f58

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/qmd Outdated
Comment on lines +78 to +79
-n) _qmd_limit="$2"; shift 2 ;;
--n) _qmd_limit="$2"; shift 2 ;;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard value flags before shifting argv

The fast-path consumes -n/--n with an unconditional shift 2; when users pass a missing-value form like qmd search foo -n (or similarly -c/--collection without a value), /bin/sh aborts with shift: can't shift that many and exits 2 before this function can return 1 to fall back to the cold-start CLI. This breaks the intended “safe fall-through” behavior whenever the daemon is reachable, so these branches should verify argument arity before shifting and return non-zero on malformed input.

Useful? React with 👍 / 👎.

Codex P2 on #5 round 3: `-n`, `--n`, `-c`, `--collection` unconditionally
called `shift 2`. If the user omitted the value (e.g. `qmd search foo -n`
or the flag happens to be the last token), `/bin/sh` aborted the whole
script with "shift: can't shift that many" (exit 2) instead of letting
the fast-path `return 1` and fall back to the cold-start CLI.

Added `[ $# -ge 2 ] || return 1` guards before every `shift 2` in the
fast-path. Malformed input is now indistinguishable from any other
reason to fall through — the cold-start CLI handles the error reporting,
which keeps the fast-path a strict subset of CLI semantics.

Tests:
- `dangling value flag (-n with no argument) falls through without
  aborting shell`
- `dangling -c with no argument also falls through cleanly`

Both assert stderr does NOT contain "can't shift" and that no
`/search` request was made.
@lukeboyett
Copy link
Copy Markdown
Owner Author

Addressed in ff67a6b.

P2 "Guard value flags before shifting argv" — valid catch. -n, --n, -c, --collection all had an unconditional shift 2; a dangling flag with no value would abort /bin/sh with "shift: can't shift that many" instead of falling through. Added [ $# -ge 2 ] || return 1 guards before each shift 2. New tests assert stderr does NOT contain "can't shift" for qmd search foo -n and qmd search foo -c.

P1 "Avoid leaking unknown option values into query text" — I believe this is the same concern as round 2 and is already resolved in 661d203 (the prior commit). Line 117 of the current bin/qmd is:

      -*)
        # Unknown flag. ... fall through to the cold-start CLI ...
        return 1 ;;

Any unhandled dashed token (including --min-score 0.8, --intent foo, boolean flags like --json) causes the function to return 1 and the cold-start CLI to run. The existing tests unknown flag falls through rather than eating its value into the query and boolean flags also fall through (e.g. --json) cover it — both assert the mock receives no /search. Let me know if there's a specific flag or invocation that the current -*) return 1 misses.

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff67a6b827

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/qmd Outdated

curl -sf --max-time 1 "${QMD_DAEMON_URL}/health" >/dev/null 2>&1 || return 1

_qmd_limit=10
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align daemon default limit with CLI default

When QMD_DAEMON_URL is set and the caller does not pass -n/--n, the fast-path always sends "limit": 10, so qmd search <query> returns a different number of results than the normal CLI path (which defaults to 5, or 20 only for explicit JSON/files output). This makes result cardinality depend on transport mode rather than user flags, which is a behavior regression for scripts that rely on the command’s default result count.

Useful? React with 👍 / 👎.

Codex P2 on #5 round 4: the fast-path defaulted to `limit: 10` when
the user didn't pass `-n`, but the interactive CLI defaults to 5
(src/cli/qmd.ts --help line 2773: "Max results (default 5, or 20
for --files/--json)"). Scripts written against the CLI's default
would see different result counts depending on whether
QMD_DAEMON_URL was set — a behavior regression.

Fix: `_qmd_limit=5`. The `--files` / `--json` special case (default
20) isn't reachable here because those flags are unknown to the
fast-path parser and already route through the cold-start branch.

Test: new `unset -n uses 5 to match the interactive CLI default`.
@lukeboyett
Copy link
Copy Markdown
Owner Author

Addressed in fdd7ceb.

P2 "Align daemon default limit with CLI default" — legit. Interactive CLI default is 5 (--help: "Max results (default 5, or 20 for --files/--json)"). Fast-path was sending limit: 10 when -n was unset, so script result counts depended on transport. Now defaults to 5. The --files/--json 20-default case can't leak through because those flags are already routed to cold-start by the unknown-flag branch. New test unset -n uses 5 to match the interactive CLI default covers it.

P1 "Avoid leaking unknown option values into query text" — re-flagged for the third consecutive round; I believe the comment is stale. The -*) return 1 unknown-flag guard is at line 117 of the current file:

      -*)
        # Unknown flag. ... fall through to the cold-start CLI ...
        return 1 ;;

Existing tests that specifically exercise the scenario described (--min-score 0.8, --json) both assert the mock receives no /search request. If there's a specific argv that still reaches the _qmd_query positional branch, a concrete example would help me add coverage — I haven't been able to construct one against the current parser.

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fdd7cebaeb

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/qmd
Comment on lines +88 to +91
_qmd_limit="$2"
shift 2
;;
--n=*) _qmd_limit="${1#*=}"; shift ;;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize -n values before building daemon payload

The fast-path forwards -n/--n as raw text into JSON, but the interactive CLI normalizes with parseInt(... ) || defaultLimit (src/cli/qmd.ts, parseCLI), so some inputs produce transport-dependent results. For example, qmd search foo -n 0 yields the default limit in cold-start mode but sends "limit":0 through the daemon (typically returning zero results), and --n=1e2 can become 100 instead of 1. Coercing and defaulting the limit in bin/qmd the same way as parseCLI would keep behavior consistent regardless of transport.

Useful? React with 👍 / 👎.

Codex P2 on #5 round 5: the fast-path forwarded the raw -n string into
the JSON payload, but the interactive CLI resolves the limit with
`parseInt(values.n, 10) || defaultLimit` (src/cli/qmd.ts:2550). As a
result some inputs produced transport-dependent results:

  - `qmd search foo -n 0` → CLI resolves to 5 (default), fast-path was
    sending `"limit":0` (typically zero results).
  - `-n 1e2` → CLI `parseInt("1e2",10)` = 1 (stops at 'e'), fast-path
    was forwarding "1e2" which the daemon would fail/misinterpret.
  - Any non-numeric (`-n abc`, `-n -1`, empty) → CLI → default,
    fast-path → garbage payload.

Fix: after parsing, normalise `_qmd_limit`. Empty, zero, or any value
containing a non-digit collapses to the default of 5 — matching the
`parseInt || default` semantics as closely as POSIX shell can.

Tests:
- `-n 0 normalises to the default 5`
- `-n <non-numeric> normalises to the default 5`
@lukeboyett
Copy link
Copy Markdown
Owner Author

Addressed in 79d29d2: fast-path now collapses empty/zero/non-numeric -n values to the default 5, matching parseInt(values.n,10) || defaultLimit in src/cli/qmd.ts. New tests for -n 0 and -n 1e2.

P1 "unknown option values" has now been re-flagged on 4 consecutive commits without a concrete reproducer, while the -*) return 1 guard has been in place since 661d203 and two tests (--min-score 0.8, --json) assert it works. I'm treating it as a stale false-positive unless a specific failing argv can be demonstrated.

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 79d29d2988

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/qmd Outdated
# alphanumeric, etc.) collapses to the default of 5, exactly as the
# interactive CLI would resolve it.
case "$_qmd_limit" in
''|0|*[!0-9]*) _qmd_limit=5 ;;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse -n with CLI semantics before building JSON payload

The fast-path -n normalization does not match src/cli/qmd.ts (parseInt(values.n, 10) || defaultLimit) and can emit invalid JSON numbers. With the current check, values like -n 00 remain 00, so the payload contains "limit":00 (invalid JSON for /search), while values like -n 1e2 are forced to 5 even though the CLI resolves them to 1. In both cases, daemon mode behavior diverges from normal qmd search and can silently fall back to cold-start output instead of returning daemon JSON.

Useful? React with 👍 / 👎.

Codex P1 on #5 round 6: the previous normalisation collapsed any
value containing a non-digit to the default, and left zero-padded
numbers like "00" intact. Both diverge from the interactive CLI:

  - `parseInt("1e2", 10)` === 1, not the default. The fast-path was
    collapsing "1e2" to 5, so a user passing `-n 1e2` would get
    different result counts depending on QMD_DAEMON_URL.
  - `"00"` with my all-digits regex stayed "00", producing
    `"limit":00` — invalid JSON — and causing the daemon request
    to fail (silently falling through to cold-start, but with
    user-observable confusion and no daemon speedup).

Fix mirrors parseInt more precisely:

  1. sed extracts the leading run of digits (`1e2` -> `1`, `5abc`
     -> `5`, `abc` -> empty).
  2. Empty leading digits -> default 5.
  3. Otherwise coerce via `$((expr))`, which normalises padded
     values (`007` -> 7) and emits plain integers.
  4. If the coerced value is <= 0, fall back to default — matching
     `parseInt(...) || defaultLimit`.

New tests exercise the specific divergence cases:
  - `-n 1e2` -> 1 (matches parseInt)
  - `-n 5abc` -> 5 (matches parseInt)
  - `-n abc` -> 5 (no leading digits -> default)
  - `-n 00` -> 5 (zero -> default, emits valid JSON)
  - `-n 007` -> 7 (strips leading zeros, emits valid JSON)

Each JSON payload is round-tripped through JSON.parse to prove it's
structurally valid — a payload with leading zeros would have thrown.
@lukeboyett
Copy link
Copy Markdown
Owner Author

Addressed in 4da193c.

P1 "Parse -n with CLI semantics before building JSON payload" — good catch on both edges. Now mirrors parseInt(n, 10) || default more faithfully:

  • sed extracts the leading digit run (1e21, 5abc5)
  • Empty / non-numeric → default 5
  • $((expr)) coerces + strips leading zeros (007 → 7)
  • Result ≤ 0 → default 5

New tests cover -n 1e2 (=1), -n 5abc (=5), -n abc (=5), -n 00 (=5, valid JSON), -n 007 (=7, valid JSON). Each payload is JSON.parsed — a leading-zero "limit":00 would throw.

P1/P2 repeat flags from prior rounds already addressed in 661d203 and 79d29d2; tests cover both. No new code change there.

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4da193c33a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/qmd Outdated
Comment on lines +146 to +148
_qmd_leading=$(printf '%s' "$_qmd_limit" | sed -n 's/^\([0-9][0-9]*\).*$/\1/p')
if [ -z "$_qmd_leading" ]; then
_qmd_limit=5
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve signed -n values in daemon limit parsing

The fast-path limit normalizer only captures unsigned leading digits (sed ... '^([0-9][0-9]*)'), so signed inputs are silently rewritten to the default limit in daemon mode. With QMD_DAEMON_URL set, qmd search foo -n -1 or qmd search foo --n=+7 will send "limit":5, while the cold-start CLI path uses parseInt(values.n, 10) || defaultLimit and therefore interprets those as -1 and 7. This creates transport-dependent behavior for the same command and can change result counts for scripts that pass signed -n values.

Useful? React with 👍 / 👎.

Codex rounds 5-7 on #5 kept surfacing new edge cases while trying to
match `parseInt(values.n, 10) || defaultLimit` in POSIX shell
(`1e2` → 1, `00` as invalid JSON, `-1`/`+7` signed values, zero-padding).
Each fix exposed another subtle divergence from JS number parsing.

Shifting approach: the fast-path now only accepts a plain positive
integer with no sign, no leading zeros, no exponent, no trailing
characters. Everything else (empty, `0`, `00`, `007`, `-1`, `+7`,
`1e2`, `5abc`, `abc`, …) returns 1 from the fast-path function so
the cold-start CLI handles the input with its real `parseInt` logic.

This matches the PR's stated philosophy — "fast-path stays a strict
subset of CLI semantics" — and eliminates the sh-vs-JS number-parsing
minefield entirely. Users passing weird `-n` values get the same
results they'd get without `QMD_DAEMON_URL` set, just slower.

Tests:
- `strict -n validation: non-plain-positive-integer values fall
  through` covers 0, 00, 007, -1, +7, 1e2, 5abc, abc.
- `plain positive integers in -n are forwarded as-is` covers 1, 3,
  20, 100.
@lukeboyett
Copy link
Copy Markdown
Owner Author

Pivoting strategy in 20a0ff7.

Rounds 5-7 chased parseInt(values.n, 10) || defaultLimit emulation in POSIX shell — each fix exposed another edge case (1e2, 00, -1, +7, zero-padding). The root issue is that faithful JS number-parsing in sh is a minefield.

New approach: strict validation. Fast-path accepts ONLY plain positive integers (no sign, no leading zeros, no exponent, no trailing chars). Everything else falls through to the cold-start CLI, which owns the real parseInt semantics. This matches the PR's stated philosophy that the fast-path is a strict subset of CLI behavior. Users passing weird -n values get the cold-start's (authoritative) interpretation — just slower.

One test walks the full list of rejected shapes: 0, 00, 007, -1, +7, 1e2, 5abc, abc. A second test confirms plain ints (1, 3, 20, 100) still hit the daemon.

The repeat P1/P2 flags from prior rounds no longer apply to the new parser.

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 20a0ff7a58

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/qmd Outdated
Comment on lines +161 to +164
curl -sf --max-time 30 -X POST \
-H "Content-Type: application/json" \
-d "$_qmd_payload" \
"${QMD_DAEMON_URL}/search" || return 1
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Suppress partial daemon output before cold-start fallback

When /search fails after sending some bytes (for example, connection reset or --max-time 30 expiring mid-response), curl can write partial JSON to stdout before exiting non-zero. This function then returns 1, so the wrapper falls through to the cold-start CLI and emits a second output format, producing mixed/truncated output on a single invocation. That breaks script consumers expecting one valid payload shape; buffer the daemon response and print it only after curl succeeds.

Useful? React with 👍 / 👎.

…fallback

Codex P2 on #5 round 8: `curl` was streaming the daemon response
straight to stdout. If the daemon started writing then disconnected
(connection reset, `--max-time` expiring mid-response), partial
JSON would hit stdout before the fast-path's `return 1` triggered
the cold-start fallback. Script consumers would then see
  `{"partial":` + cold-start text
concatenated on one invocation, breaking expectations.

Fix: capture the response in a shell variable, only `printf` it on
success. A curl error (any exit code) short-circuits via `|| return
1` and stdout stays clean. The cold-start CLI then runs with a
virgin output stream.

Test: `daemon that writes partial bytes then drops the connection
leaks no output` stands up a mock that responds 200 to /health,
opens /search with a partial `{"partial":` then destroys the socket.
Assertion: `stdout` contains no `partial`.
@lukeboyett
Copy link
Copy Markdown
Owner Author

Addressed in 9b81ab8.

P2 "Suppress partial daemon output before cold-start fallback" — good catch. curl was streaming straight to stdout, so a mid-response failure (socket reset, max-time mid-body) would emit partial JSON before fall-through. Now the response is captured in a shell variable and only printed on curl success. New test stands up a server that opens with {"partial": then destroys the socket and asserts stdout contains no partial.

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b81ab8dcf

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread bin/qmd
_qmd_collections_json="null"
fi

_qmd_payload="{\"searches\":[{\"type\":\"${_qmd_qtype}\",\"query\":\"${_qmd_q_escaped}\"}],\"collections\":${_qmd_collections_json},\"limit\":${_qmd_limit}}"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve vsearch semantics in daemon request

When the fast-path handles vsearch, the payload only includes type, query, collections, and limit, so the daemon uses /search defaults (minScore falls back to 0 and reranking stays enabled). In the normal CLI path, vsearch sets a default minScore of 0.3 and runs vectorSearch() (vector-only, no rerank), so enabling QMD_DAEMON_URL changes which results are returned and their ordering for the same command. This affects any script relying on qmd vsearch output consistency across daemon vs cold-start execution.

Useful? React with 👍 / 👎.

…ough /search

Codex P2 on #5 round 9: `qmd vsearch` cold-start sets `minScore: 0.3`
(vs 0 for search), runs a vector-only pipeline, and does not rerank.
The daemon's `/search` REST endpoint exposes none of those knobs —
it always calls `store.search(...)` (hybrid + rerank) with the
caller-supplied minScore (default 0). Routing vsearch through the
fast-path would silently change result ordering and filtering for
scripts that rely on vsearch's cold-start behaviour.

Rather than add pseudo-plumbing, vsearch is now *always* handled by
the cold-start CLI. The fast-path is search-only. This matches the
same strict-subset principle the PR has been applying to unknown
flags and weird -n values: if the semantics aren't reliably
expressible on the daemon side, cold-start owns it.

Changes:
- `_qmd_try_daemon` no longer accepts `vsearch`.
- Top-level dispatch is `case "${1:-}" in search)` only.
- Test `vsearch is intentionally NOT handled by the fast-path`
  asserts zero captures on the mock when vsearch is invoked with
  QMD_DAEMON_URL set.
- README and CHANGELOG updated: fast-path is search-only, vsearch
  semantics rationale documented.
@lukeboyett
Copy link
Copy Markdown
Owner Author

Addressed in d5b01e9.

P2 "Preserve vsearch semantics in daemon request" — valid, and the fix is structural: vsearch is now always handled by cold-start, even when QMD_DAEMON_URL is set. Its vector-only pipeline + 0.3 minScore default + no-rerank behavior can't be expressed through the daemon's /search REST endpoint, so the fast-path simply doesn't try. New test vsearch is intentionally NOT handled by the fast-path asserts zero daemon captures for qmd vsearch invocations. README and CHANGELOG updated to call out the vsearch exception explicitly.

The remaining two flags are the same stale P1 and P2 from rounds 2-6, both already covered by the strict parser and tests since earlier commits.

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@lukeboyett
Copy link
Copy Markdown
Owner Author

Closing — this is tracked upstream at tobi#582. Fork branch retained.

@lukeboyett lukeboyett closed this Apr 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant