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
2 changes: 1 addition & 1 deletion .cursor/skills/verify-call-agent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ The script writes to `artifacts/verify-call-agent/<RUN_ID>/` in the repo (the `a
- `book-artifacts/<call_control_id>/`. A retain run's booking artifacts, copied from the scratch artifacts dir.
- `verify-book.txt`. The seven-line checklist and `7/7 passed`.
- `verify-message.txt`. The six-line MessagingPort checklist and `6/6 passed`.
- `verify-linq-call.txt`. The thirteen-line linq-to-call checklist and `13/13 passed`.
- `verify-linq-call.txt`. The fourteen-line linq-to-call checklist and `14/14 passed`.
- `verify-webhooks.txt`. The Worker inbox five-line checklist and `5/5 passed`.
- `verify-persistence.txt`. The four-line checklist, `sqlite ledger + local artifacts`, and `4/4 passed`. The partial-R2 refusal is in `transcript.txt`.

Expand Down
2 changes: 1 addition & 1 deletion .cursor/skills/verify-call-agent/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
- [Always-on webhooks versus local tunnel](./webhooks.md) covers the Worker prod path at `https://call-agent-webhooks.usetreena.workers.dev/linq/webhook`, the laptop tunnel, `PUBLIC_WEBHOOK_URL`, and the Linq hook.
- [Linq inbound (closed alpha)](./linq-inbound.md) covers the iMessage L1 ingress hook. SMS stays off.
- [Linq to call (closed alpha)](./linq-to-call.md) covers iMessage natural collect + confirm-before-dial → Fly `run_call` → result on the same chat.
- [Verify linq-to-call](./verify-linq-call.md) covers the thirteen-check stub lever for that path.
- [Verify linq-to-call](./verify-linq-call.md) covers the fourteen-check stub lever for that path.
- [Verify webhooks](./verify-webhooks.md) covers the always-on Worker lever in stub mode and against a deployed URL.
- [Verify persistence](./verify-persistence.md) covers the Neon and R2 port lever, the SQLite and local fallback, and the misconfig exit.
- [Default wipe at hangup](./privacy-wipe.md) covers write-then-delete after hangup unless retention is on. The privacy page stays not shipped.
2 changes: 1 addition & 1 deletion .cursor/skills/verify-call-agent/features/linq-inbound.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Preconditions:

- **Stub hook.** Run `.venv/bin/call-agent verify-webhooks`. Exit code `0`. The `linq_hook` line is `[PASS]`. Stub POST is 501 `missing_app_base_url`. Stub GET ping is 200 `provider:linq`.
- **Stub reply.** Run `.venv/bin/call-agent verify-message`. Exit code `0`. The `inbound_reply` line is `[PASS]` and names the inbound text.
- **Stub kickoff.** Run `.venv/bin/call-agent verify-linq-call`. Exit code `0`. Thirteen `[PASS]` lines and `13/13 passed`.
- **Stub kickoff.** Run `.venv/bin/call-agent verify-linq-call`. Exit code `0`. Fourteen `[PASS]` lines and `14/14 passed`.
- **Live register URL.** The string `https://call-agent-webhooks.usetreena.workers.dev/linq/webhook` is in `README.md`, `workers/webhooks/README.md`, and `docs/FEATURE_MAP.md`.
- **Proof.** Save `verify-webhooks` stdout as `verify-webhooks.txt`, `verify-message` stdout as `verify-message.txt`, and `verify-linq-call` stdout as `verify-linq-call.txt`.

Expand Down
6 changes: 4 additions & 2 deletions .cursor/skills/verify-call-agent/features/linq-to-call.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Linq to call (closed alpha)

Closed alpha testers text Treena on iMessage. A natural book ask on that chat collects party and time, then the restaurant phone number, then confirm/approve, then kicks off the same general `run_call` path FastAPI already uses for CLI book and quick-ask. Caller name, kickoff `user_id`, and Telnyx from-DID come from the `users` row for the inbound phone, not from the chat. The Worker forwards Linq POST to Fly. Collect and confirm reply 200. Approve returns 202, texts `Okay, I'm going to call them now.`, places the call, hangs up, and texts the result on the same Linq chat. The first message never dials. It does not pause the live call for mid-call chat. qa owns live prove.
Closed alpha testers text Treena on iMessage. A natural book ask on that chat collects party and time, then the restaurant phone number, then confirm/approve, then kicks off the same general `run_call` path FastAPI already uses for CLI book and quick-ask. If time is missing, Treena asks for time (or party+time) before the phone number. Caller name, kickoff `user_id`, and Telnyx from-DID come from the `users` row for the inbound phone, not from the chat. The Worker forwards Linq POST to Fly. Collect and confirm reply 200. Approve returns 202, texts `Okay, I'm going to call them now.`, places the call, hangs up, and texts the result on the same Linq chat. The first message never dials. It does not pause the live call for mid-call chat. qa owns live prove.

## Sub-features

- `parse-book` Natural book intent with party and time, and no restaurant number, becomes a pending collect. A complete first message `book <+E164> party <n> at <time>` asks to confirm and does not become a `BookRequest`.
- `collect-time` A reservation without time (including `at <restaurant>`) asks for time, or party+time together. "2 people" does not unlock the phone ask. Time is a clock / am/pm / noon span, not a place name.
- `parse-ask` `ask <+E164> hours|stock <item>|aisle <item>` still collects confirm before a `QuickAskRequest`. A leftover `name` token is ignored.
- `collect-phone` Treena texts `okay, sounds good. What's the restaurant's phone number?` and does not dial. The next text on that chat supplies the E164, then Treena asks to confirm.
- `confirm-before-dial` After the number, Treena texts `Okay, I'm going to call this number. Do you confirm? Reply "approve"`. User must say confirm / approve / yes. Then Treena texts `Okay, I'm going to call them now.` and dials. A first message that already has +E164 never dials.
Expand All @@ -23,6 +24,7 @@ Closed alpha testers text Treena on iMessage. A natural book ask on that chat co

- Have an account (website checkout, or `call-agent account upsert --phone <your iMessage> --name Nathan --from-did <Treena DID>`) with a first name and assigned Treena number.
- Text Treena `can you make a reservation with this restaurant at 7:30 for a party of two?`.
- If she asks for time (or party and time), reply with those before the restaurant number.
- Reply with the restaurant phone number when she asks.
- Reply `approve` (or confirm / yes) when she asks to confirm.
- Wait for `Okay, I'm going to call them now.`, then the result on the same chat.
Expand All @@ -34,7 +36,7 @@ Preconditions:

- Baseline env from `README.md`. Stub mode needs no Telnyx, OpenAI, or Linq keys.

- **Stub lever.** Run `.venv/bin/call-agent verify-linq-call`. Exit code `0`. Thirteen `[PASS]` lines and `13/13 passed`.
- **Stub lever.** Run `.venv/bin/call-agent verify-linq-call`. Exit code `0`. Fourteen `[PASS]` lines and `14/14 passed`.
- **Echo still works.** Run `.venv/bin/call-agent verify-message`. The `inbound_reply` line is `[PASS]`.
- **Proof.** Save stdout as `verify-linq-call.txt` in the evidence directory.

Expand Down
9 changes: 5 additions & 4 deletions .cursor/skills/verify-call-agent/features/verify-linq-call.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# Verify linq-to-call

`verify-linq-call` is the stub lever for iMessage natural book → collect phone → confirm/approve → `run_call` → Linq result reply with artifact cross-links. It seeds a `users` row for the inbound fixture phone, including `from_did=+15555550177`. It needs no Telnyx, OpenAI, or Linq keys and opens no sockets. It exits `0` only when all thirteen checks pass. It fails if dial happens before confirm.
`verify-linq-call` is the stub lever for iMessage natural book → collect time when missing → collect phone → confirm/approve → `run_call` → Linq result reply with artifact cross-links. It seeds a `users` row for the inbound fixture phone, including `from_did=+15555550177`. It needs no Telnyx, OpenAI, or Linq keys and opens no sockets. It exits `0` only when all fourteen checks pass. It fails if dial happens before confirm.

## Sub-features

- `verify-pass` prints thirteen `[PASS]` lines and `13/13 passed` in stub mode.
- `verify-pass` prints fourteen `[PASS]` lines and `14/14 passed` in stub mode.
- `verify-exit` exits `0` on pass and `1` on any failure.
- `parse-book` the natural reservation fixture collects party and time and asks for the restaurant phone number. No dial.
- `collect-time` `can you make a reservation at poke palace for me?` asks for party and time. `2 people` asks for time, not the restaurant number. `7:30` then asks for the number. No dial.
- `collect-phone` the +E164 follow-up asks to confirm and does not become a `BookRequest`. No dial.
- `confirm-before-dial` `approve` / `yes` / `I confirm` become a `BookRequest`. A complete first-message one-shot never dials. A non-affirmative while waiting for confirm texts `Call failed:`.
- `parse-ask` `ask +14155552671 hours name Nathan` asks to confirm, then `approve` becomes a hours `QuickAskRequest`. The `name` token is ignored; caller comes from the `users` row.
Expand All @@ -32,8 +33,8 @@ Preconditions:

- Baseline env from `README.md`. This verb opens no sockets. It writes under a temp artifacts dir, not the process `CALL_AGENT_ARTIFACTS_DIR`, unless you pass a workspace through pytest.

- **CI command.** Run `.venv/bin/call-agent verify-linq-call`. Exit code `0`. Stdout has exactly thirteen lines that start with `[PASS]`, in this order: `parse_book`, `collect_phone`, `confirm_before_dial`, `parse_quick_ask`, `parse_fail_loud`, `account_lookup`, `account_gate`, `kickoff_reply`, `call_ran`, `cross_link`, `same_host_dial`, `from_did_users_row`, `no_number_order`. The last line is `13/13 passed`.
- **Defaults.** Run `.venv/bin/call-agent verify-linq-call`. Same thirteen lines, exit code `0`.
- **CI command.** Run `.venv/bin/call-agent verify-linq-call`. Exit code `0`. Stdout has exactly fourteen lines that start with `[PASS]`, in this order: `parse_book`, `collect_time`, `collect_phone`, `confirm_before_dial`, `parse_quick_ask`, `parse_fail_loud`, `account_lookup`, `account_gate`, `kickoff_reply`, `call_ran`, `cross_link`, `same_host_dial`, `from_did_users_row`, `no_number_order`. The last line is `14/14 passed`.
- **Defaults.** Run `.venv/bin/call-agent verify-linq-call`. Same fourteen lines, exit code `0`.
- **Proof.** Save stdout as `verify-linq-call.txt` in the evidence directory.

## Gotchas
Expand Down
2 changes: 1 addition & 1 deletion .cursor/skills/verify-call-agent/scripts/verify_stub.sh
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ grep -q "6/6 passed" "$EVIDENCE/verify-message.txt"

step "verify-linq-call stub checklist"
run "$CLI" verify-linq-call | tee "$EVIDENCE/verify-linq-call.txt" >/dev/null
grep -q "13/13 passed" "$EVIDENCE/verify-linq-call.txt"
grep -q "14/14 passed" "$EVIDENCE/verify-linq-call.txt"

step "linq register-webhook fails loud without token"
set +e
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ call-agent verify-message

You should see six `[PASS]` lines and `6/6 passed`. The checks are stub send shape, inbound `message.received` mapping, inbound reply + artifact text, Standard Webhooks signature accept/reject, live constructor without `LINQ_API_TOKEN`, and SMS ignored. See [docs/linq.md](docs/linq.md).

`call-agent verify-linq-call` proves iMessage natural collect + confirm-before-dial → stub `run_call` → result reply on the same chat, plus artifact cross-links. Thirteen `[PASS]` lines and `13/13 passed`, including `from_did_users_row` and `no_number_order`. It fails if dial happens before confirm. It does not dial Telnyx or hit Linq live. qa owns the Fly prove.
`call-agent verify-linq-call` proves iMessage natural collect + confirm-before-dial → stub `run_call` → result reply on the same chat, plus artifact cross-links. Fourteen `[PASS]` lines and `14/14 passed`, including `collect_time`, `from_did_users_row` and `no_number_order`. It fails if dial happens before confirm. It does not dial Telnyx or hit Linq live. qa owns the Fly prove.

`call-agent verify-persistence` checks the Neon and R2 ports. With those vars unset it uses SQLite and a local directory, prints three `[PASS]` lines, and exits 0. If some `R2_*` vars are set and others are not, or if `NEON_DATABASE_URL` is not a `postgresql://` URL, it exits 2 and names the problem. CI runs it with the vars unset. It never opens Neon or R2.

Expand Down
80 changes: 52 additions & 28 deletions call_agent/kickoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,23 @@
r"^\s*(" + _PARTY_TOKEN + r")\s*(?:people|guests|persons?)?\s*$",
re.IGNORECASE,
)
_AT_TIME_RE = re.compile(
r"\bat\s+(.+?)(?=\s+for\s+a\s+party|\s+for\s+a\s+table|\s+party\b"
r"|\s+name\b|\s+notes\b|$)",
_DAY_WORD = (
r"today|tonight|tomorrow|"
r"this\s+(?:morning|afternoon|evening)|"
r"monday|tuesday|wednesday|thursday|friday|saturday|sunday"
)
_CLOCK = (
r"(?:noon|midnight|midday)"
r"|(?:1[0-2]|0?[1-9])(?::[0-5]\d)?\s*(?:a\.?m\.?|p\.?m\.?)"
r"|(?:[01]?\d|2[0-3]):[0-5]\d"
r"|(?:1[0-2]|0?[1-9])\s*o'?clock"
)
_TIME_LIKE_RE = re.compile(
rf"(?:at\s+)?(?:(?P<day>{_DAY_WORD})\s+)?(?:at\s+)?(?P<clock>{_CLOCK})",
re.IGNORECASE,
)
_AT_BARE_HOUR_RE = re.compile(
r"\bat\s+(?P<clock>(?:1[0-2]|0?[1-9]|2[0-3]))\b(?!:)",
re.IGNORECASE,
)
_NOTES_RE = re.compile(r"\bnotes\s+(.+)$", re.IGNORECASE)
Expand Down Expand Up @@ -672,9 +686,10 @@ def _decide_book(
confirmed: bool,
) -> KickoffParsed | KickoffCollecting | KickoffFailed:
missing: list[WaitingFor] = []
time_text = _time_span(extracted.time_text or "")
if extracted.party_size is None:
missing.append("party")
if not extracted.time_text:
if not time_text:
missing.append("time")
if missing:
waiting: WaitingFor = (
Expand Down Expand Up @@ -704,7 +719,7 @@ def _decide_book(
caller=caller,
mode=_mode(settings),
party_size=extracted.party_size or 0,
time_text=extracted.time_text or "",
time_text=time_text or "",
constraints=extracted.constraints,
retain_call_artifacts=(
True if account.retain_call_artifacts else None
Expand Down Expand Up @@ -776,7 +791,7 @@ def _pending_from_extracted(
waiting_for=waiting_for,
to=str(extracted.to) if extracted.to is not None else None,
party_size=extracted.party_size,
time_text=extracted.time_text,
time_text=_time_span(extracted.time_text or ""),
constraints=extracted.constraints,
question_kind=(
extracted.question_kind.value
Expand All @@ -796,12 +811,16 @@ def _extract_fields(
return _Extracted(
intent=_intent(text, waiting_for),
to=_extract_e164(text),
party_size=_extract_party(text, loose=waiting_for in {"party", "party_and_time"}),
time_text=_extract_time(text, loose=waiting_for in {"time", "party_and_time"}),
constraints=_extract_notes(text),
question_kind=_extract_kind(text),
question_item=_extract_item(text),
)
party_size=_extract_party(
text, loose=waiting_for in {"party", "party_and_time"}
),
time_text=_extract_time(
text, loose=waiting_for in {"time", "party_and_time"}
),
constraints=_extract_notes(text),
question_kind=_extract_kind(text),
question_item=_extract_item(text),
)


def _extract_cli(text: str) -> _Extracted | None:
Expand Down Expand Up @@ -888,21 +907,23 @@ def _extract_party(text: str, *, loose: bool) -> int | None:


def _extract_time(text: str, *, loose: bool) -> str | None:
matched = _AT_TIME_RE.search(text)
del loose
return _time_span(text)


def _time_span(text: str) -> str | None:
"""Clock, am/pm, noon, or `at <hour>`. Not `at <restaurant>`."""
matched = _TIME_LIKE_RE.search(text)
if matched is not None:
value = " ".join(matched.group(1).split()).strip(" .,")
if value and not _E164_RE.fullmatch(value):
return value
if not loose:
return None
stripped = " ".join(text.split()).strip(" .,")
if not stripped or _extract_e164(stripped) is not None:
return None
if _BARE_PARTY_RE.fullmatch(stripped) is not None:
return None
if re.search(r"\d", stripped) is None:
day = " ".join((matched.group("day") or "").split())
clock = " ".join((matched.group("clock") or "").split())
if clock:
return f"{day} {clock}".strip()
bare = _AT_BARE_HOUR_RE.search(text)
if bare is None:
return None
return stripped
clock = (bare.group("clock") or "").strip()
return clock or None


def _extract_notes(text: str) -> str | None:
Expand Down Expand Up @@ -955,7 +976,8 @@ def _merge(pending: PendingCollect, extracted: _Extracted) -> _Extracted:
intent=pending.verb,
to=to,
party_size=extracted.party_size or pending.party_size,
time_text=extracted.time_text or pending.time_text,
time_text=_time_span(extracted.time_text or "")
or _time_span(pending.time_text or ""),
constraints=extracted.constraints or pending.constraints,
question_kind=kind,
question_item=extracted.question_item or pending.question_item,
Expand All @@ -967,11 +989,13 @@ def _progressed(pending: PendingCollect, extracted: _Extracted) -> bool:
if waiting == "to":
return extracted.to is not None
if waiting == "time":
return bool(extracted.time_text)
return bool(_time_span(extracted.time_text or ""))
if waiting == "party":
return extracted.party_size is not None
if waiting == "party_and_time":
return extracted.party_size is not None or bool(extracted.time_text)
return extracted.party_size is not None or bool(
_time_span(extracted.time_text or "")
)
if waiting == "kind":
return extracted.question_kind is not None
if waiting == "item":
Expand Down
Loading
Loading