From ef469652db12506db8799808a406880b7f22e1b7 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Tue, 13 Oct 2026 12:26:32 +0530 Subject: [PATCH] feat(sessions): FTS session search across transcripts + member scope; session links resolve (v0.461.0) session_history's query was a LIKE over title + seed task, own agent only. Asked to find "the session where we fixed the PayPal webhook" (work buried late in a run titled after its first job), an expresstech agent read the tenant DB with node:sqlite from Bash, grepped other agents' transcripts, then handed the human #/sessions/ses_..., a link it built itself that the console didn't route (the detail is the tmux name, aos-). - term_sessions_fts: external-content FTS5 (porter) over title/task/report_summary; UPDATE trigger scoped to those columns AND a real change, so activity/cost writes never re-tokenize. - session_text_fts: per-conversation digest of prompts, replies and action hints, filled off the scheduler tick in 150ms slices (indexSessionText), 32KB cap sampled evenly so a long run's last topic survives. Instapods copy: 1,641 convos in 5.2s of slices, 3.4MB, queries 3-11ms. - searchSessions: all-words matches before any-word, one hit per conversation, match snippet. - scope:'member' searches/opens what the run-as human can see in the console (canReadRow), audited session.searched / session.opened. A run acting for nobody stays own-agent only. - Results carry the console url; session_open accepts id, aos-, or a URL; the console rewrites #/sessions/ses_... to the routed form. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 23 ++++ CLAUDE.md | 12 +- docs/agent-mcp-tools.md | 4 +- package-lock.json | 4 +- package.json | 4 +- scripts/session-search-test.cjs | 190 ++++++++++++++++++++++++++++++++ src/edge/automations.ts | 2 + src/edge/session-search.ts | 109 ++++++++++++++++++ src/memory/memory-mcp.ts | 55 +++++---- src/server.ts | 64 ++++++++--- src/state/db.ts | 39 +++++++ src/terminal.ts | 157 +++++++++++++++++++++++++- web/src/App.tsx | 10 +- 13 files changed, 623 insertions(+), 50 deletions(-) create mode 100644 scripts/session-search-test.cjs create mode 100644 src/edge/session-search.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e6826f9b..9130ac8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ new version heading in the same commit. ## [Unreleased] +## [0.461.0] - 2026-10-13 +### Added +- **`session_history` is a real keyword search, and can find a run on another agent.** A query is now + ranked full-text search (FTS5, bm25, stemmed) over each run's title, seed task and report summary AND + over what was said in it: prompts, replies, and the files/URLs each action touched. The transcript half + is a per-conversation index filled off the scheduler tick in 150ms slices, capped at 32KB per + conversation and sampled evenly, so the last topic of a days-long run survives. Every-word matches rank + above any-word ones, the resumed runs of one conversation collapse to a single hit, and each hit shows + a snippet of where it matched. A new `scope:'member'` searches every session the person the run acts for + can see in the console (the console's own visibility rule, audited `session.searched`), and + `session_open` opens those too (audited `session.opened`). The motivating case: asked to find "the + session where we fixed the PayPal webhook", an agent found the old tool could not see work buried late in + a run titled after its first job, so it read the tenant DB with `node:sqlite` from Bash and grepped other + agents' transcripts instead. + **For users:** ask any agent to find a past session by what happened in it (for example "the session + where we fixed the PayPal webhook"). It searches what was said in the run, not just its title, and can + find sessions you were in with other agents. +### Fixed +- **Session links from agents open the session.** The console's session route expects the tmux name + (`#/sessions/aos-ses_…`), but agents built `#/sessions/ses_…` from the bare id, which opened a blank + pane. The console now accepts the bare-id form and rewrites the address bar. `session_history` and + `session_open` also return the canonical `url`, and accept a bare id, `aos-` or a full link. + ## [0.460.1] - 2026-10-12 ### Removed - **The sidebar's "N ended" toggle.** The sidebar session list now shows only live sessions (plus the diff --git a/CLAUDE.md b/CLAUDE.md index a79e9438..0edbe4b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -389,9 +389,15 @@ Key modules: + 13 conditional (chat-reply / egress / media / multi-org `github_token`, each exposed only when its env flag is set; full list in `docs/agent-mcp-tools.md`). Memory: `recall`/`remember`/`revise`/`forget` (recall returns each memory's id, the handle for revise/forget). Episodic self-query (the run-history companion to semantic memory): - `session_history` lists the agent's OWN past sessions (id/title/status/rating, own-scoped, "have I done - this before?") and `session_open` reopens any one — the friendly transcript timeline (`readConversation`) - or a `summary:true` recap — gated server-side to the caller's own agent. KB: + `session_history` searches past sessions — with a `query`, ranked FTS5/bm25 over each run's title/task/ + report summary (`term_sessions_fts`, trigger-kept) AND what was said in it (`session_text_fts`, one row per + conversation, filled off the scheduler tick by `indexSessionText` — see `src/edge/session-search.ts`). + Scope `agent` (default) = own runs; `member` = everything the run-as human can see in the console + (`canReadRow`, audited `session.searched`). `session_open` reopens one — the friendly transcript timeline + (`readConversation`) or a `summary:true` recap — own-agent, or member-visible (audited `session.opened`). + Both return the console `url` (`TerminalManager.sessionUrl`): the sessions route's detail is the TMUX name + `aos-`, so an agent-built `#/sessions/ses_…` used to land on a blank pane (the console now rewrites + that form too). KB: `kb_search`/`kb_read`/`kb_write`/`kb_history`/`kb_revert`. Operator/inbox: `ask`/`check_inbox`/`report`/`update`/`notify`/`publish`/`library_list` (session cards are **owner-scoped** — addressed to the run's `run_as`/spawner via the `sessionOwner` audience — so the diff --git a/docs/agent-mcp-tools.md b/docs/agent-mcp-tools.md index bf4b4ba0..9dde4dc2 100644 --- a/docs/agent-mcp-tools.md +++ b/docs/agent-mcp-tools.md @@ -79,8 +79,8 @@ scope/parity properties, but not the cost. | `skill_find` | `GET /api/skills/discover` | `TerminalManager.requestableSkills` (+ `searchSkillsh` when `q`) | R | the caller's installed library (each flagged `active` for this agent) + the bundled catalog — what's installable to ask for; with a `query` also returns matching **community** skills from the skills.sh directory, each with its `source` (`owner/repo`) | | `skill_request` | `POST /api/skills/request` | `TerminalManager.requestSkill` + messages | W | **asks** an owner/admin to install a skill (never installs itself). `source` omitted ⇒ the bundled catalog (validated against it); `source: 'owner/repo'` ⇒ a **remote GitHub repo** (`browseRepo` resolves the skill + its path at request time so a typo fails fast). Dedupes an open request for the same skill+source, posts a `skill.request` card to owner/admins; audited `skill.requested`. Human installs via `POST /api/skills/requests/:id/approve` (owner/admin; catalog → `SkillsStore.install`, remote → `fetchSkill` + `installFiles`; optional `scope:'agent'`) or dismisses via `POST /api/skills/requests/:id/dismiss`. On approve, `TerminalManager.refreshAgentSkills` delivers same-session to the requester's LIVE interactive sessions (materialise into the watched `.claude/skills` + inject `/reload-skills`, claude ≥2.1.152); headless/next-launch otherwise | | `library_list` | `GET /api/agent/artifacts` | `ArtifactStore.list` + `ArtifactStore.folders` | R | list scoped to the agent's own deliverables in the Library; also returns the tenant-wide Library folders so a `publish` files into the existing tree (discovery) | -| `session_history` | `GET /api/agent/sessions` | `TerminalManager.sessionsForAgent` | R | **episodic self-query** — the agent's OWN past runs ("have I done this before, how did it go?"), the run-history companion to the semantic memory plane. Own-scoped server-side to the caller's agent id (never a sibling's); excludes the current run; metadata only (id/title/status/rating/dates), not transcripts; optional `query` substring over title/task | -| `session_open` | `GET /api/agent/session` | `readConversation` / `summarizeConversation` (`src/edge`) | R | opens ONE of the caller's own past sessions and returns the friendly transcript **timeline** (`readConversation` → user/assistant/activity turns, tail-capped at 60), or with `summary:true` a throwaway-claude **recap** of the whole run. Own-scoped: the target must belong to the caller's agent (`sessionsForAgent` lookup) else **403**. Falls back to the raw `session-.log` pane tail for a headless run with no structured transcript; summary audited `session.summarized` (`principal: agent:`) | +| `session_history` | `GET /api/agent/sessions` | `TerminalManager.searchSessions` / `sessionsForAgent` / `recentSessionsForMember` | R | **episodic search** — past runs ("have I done this before?", "find the session where we fixed X"). With a `query`: ranked FTS5/bm25 over the row's title/task/report summary (`term_sessions_fts`) AND the run's own words (`session_text_fts`: prompts, replies, action hints — filled off the tick by `indexSessionText`, ≤32KB per conversation, sampled evenly so a long run's LAST topic survives); all-words matches rank first; runs of one conversation collapse to one hit; each hit carries a match `snippet`. `scope:'agent'` (default) = the caller's own runs; `scope:'member'` (+ optional `agent`) = every run the run-as human can see in the console (`canReadRow`), audited `session.searched`; a run acting for nobody gets `no_member`. Excludes the current run. Every row carries `url` — the console link, which agents must hand over verbatim | +| `session_open` | `GET /api/agent/session` | `readConversation` / `summarizeConversation` (`src/edge`) | R | opens ONE past session and returns the friendly transcript **timeline** (`readConversation` → user/assistant/activity turns, tail-capped at 60), or with `summary:true` a throwaway-claude **recap** of the whole run. Takes a bare id, `aos-`, or a console URL (`normalizeSessionRef`). Allowed for the caller's own agent's runs, or a run the run-as human can see (`memberCanViewSession`, audited `session.opened`) — else **403**. Meta includes `agent` + `url`. Falls back to the raw `session-.log` pane tail for a headless run with no structured transcript; summary audited `session.summarized` (`principal: agent:`) | | `schedule` | `POST /api/agent/schedule` | `Automations.schedule` (`type:'once'`) | W | one-shot deferred self-run; same agent + run-as; **resumes the scheduling conversation by default** (`resume_claude_id` → `--resume` the transcript; `resume:false` = fresh session); bounded 1 min–30 days, ≤25 pending/agent | | `unschedule` | `POST /api/agent/schedule/cancel` | `Automations.cancelScheduled` | W | cancel a pending one, scoped to the agent | | `stop` | `POST /api/agent/stop` | `TerminalManager.stopSession` (`by` = agent id) | W | the session ends ITSELF — same halt as the console kill (kills tmux, cancels its pending questions/approvals, blocks auto-resume, writes a `stopped` episode). Server acks then halts ~150ms later so the reply flushes first; audited `session.stopped` with the agent id + optional `reason` | diff --git a/package-lock.json b/package-lock.json index bbda791b..39da1b00 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.460.1", + "version": "0.461.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.460.1", + "version": "0.461.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index a277e822..bab65f38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.460.1", + "version": "0.461.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", @@ -27,7 +27,7 @@ "check-deps": "bash scripts/install-deps.sh --check", "dev": "ts-node src/cli.ts serve", "demo:dev": "ts-node src/demo.ts", - "test:governance": "node scripts/version-sync-test.cjs && node scripts/event-loop-freeze-test.cjs && node scripts/git-ceiling-test.cjs && node scripts/cron-supersede-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/script-body-gate-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/connector-auth-stub-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/capacity-queue-test.cjs && node scripts/runtime-pool-signed-out-test.cjs && node scripts/login-expiry-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/make-live-lock-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/unblock-run-dedupe-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/goal-heartbeat-test.cjs && node scripts/question-durability-test.cjs && node scripts/ask-block-link-test.cjs && node scripts/bets-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs && node scripts/app-card-close-test.cjs", + "test:governance": "node scripts/version-sync-test.cjs && node scripts/event-loop-freeze-test.cjs && node scripts/git-ceiling-test.cjs && node scripts/cron-supersede-test.cjs && node scripts/governance-conformance.cjs && node scripts/tier-a-policy-test.cjs && node scripts/policy-baseline-test.cjs && node scripts/heredoc-intent-test.cjs && node scripts/script-body-gate-test.cjs && node scripts/capability-registry-test.cjs && node scripts/composio-envelope-test.cjs && node scripts/connector-auth-stub-test.cjs && node scripts/composio-identity-test.cjs && node scripts/idle-reaper-test.cjs && node scripts/dm-continuity-test.cjs && node scripts/telegram-dm-lane-test.cjs && node scripts/cli-link-origin-test.cjs && node scripts/alert-staleness-test.cjs && node scripts/run-as-identity-test.cjs && node scripts/email-identity-guard-test.cjs && node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-usage-refresh-test.cjs && node scripts/keychain-credential-test.cjs && node scripts/credential-preflight-test.cjs && node scripts/capacity-queue-test.cjs && node scripts/runtime-pool-signed-out-test.cjs && node scripts/login-expiry-test.cjs && node scripts/runtime-login-test.cjs && node scripts/rotate-on-reload-test.cjs && node scripts/headless-resumable-test.cjs && node scripts/session-revive-gates-test.cjs && node scripts/session-pause-test.cjs && node scripts/make-live-lock-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs && node scripts/output-style-test.cjs && node scripts/session-cost-test.cjs && node scripts/chain-model-test.cjs && node scripts/task-workers-test.cjs && node scripts/tuning-patch-test.cjs && node scripts/task-runs-test.cjs && node scripts/task-pr-links-test.cjs && node scripts/task-draft-delete-test.cjs && node scripts/task-discussion-delivery-test.cjs && node scripts/task-resume-test.cjs && node scripts/task-unblock-test.cjs && node scripts/unblock-run-dedupe-test.cjs && node scripts/audience-session-access-test.cjs && node scripts/warm-chat-test.cjs && node scripts/poke-warm-caller-test.cjs && node scripts/wakeup-queue-test.cjs && node scripts/stranded-human-stop-test.cjs && node scripts/inject-submit-test.cjs && node scripts/blocked-routing-test.cjs && node scripts/self-dispatch-guard-test.cjs && node scripts/task-proposals-test.cjs && node scripts/npm-boundary-test.cjs && node scripts/agent-edit-guard-test.cjs && node scripts/per-agent-context-test.cjs && node scripts/goal-update-guard-test.cjs && node scripts/insights-signal-test.cjs && node scripts/outcome-derivation-test.cjs && node scripts/episode-quality-test.cjs && node scripts/memory-upkeep-test.cjs && node scripts/automem-health-test.cjs && node scripts/memory-store-switch-test.cjs && node scripts/memory-preload-test.cjs && node scripts/turn-lifecycle-test.cjs && node scripts/resume-seed-test.cjs && node scripts/outcome-vocabulary-test.cjs && node scripts/skill-presets-test.cjs && node scripts/skill-edit-proposal-test.cjs && node scripts/notify-hook-route-test.cjs && node scripts/review-notify-test.cjs && node scripts/turn-idle-background-guard-test.cjs && node scripts/waiting-brief-test.cjs && node scripts/runtime-death-alert-test.cjs && node scripts/github-per-member-test.cjs && node scripts/github-multi-org-test.cjs && node scripts/card-measurement-test.cjs && node scripts/scheduler-admission-test.cjs && node scripts/tick-liveness-test.cjs && node scripts/audit-mirror-test.cjs && node scripts/request-metrics-test.cjs && node scripts/tool-usage-test.cjs && node scripts/sessions-list-perf-test.cjs && node scripts/summarizer-degradation-test.cjs && node scripts/agent-history-scope-test.cjs && node scripts/session-search-test.cjs && node scripts/webhook-ingress-test.cjs && node scripts/slack-content-filter-test.cjs && node scripts/slack-ingress-test.cjs && node scripts/discord-ingress-test.cjs && node scripts/chat-attachments-test.cjs && node scripts/clickup-task-bridge-test.cjs && node scripts/agentric-commands-test.cjs && node scripts/whats-new-test.cjs && node scripts/opencode-gate-test.cjs && node scripts/protected-path-guard-test.cjs && node scripts/attach-grace-test.cjs && node scripts/attach-file-liveness-test.cjs && node scripts/feed-smoke.cjs && node scripts/activity-classify-test.cjs && node scripts/goal-room-test.cjs && node scripts/secret-rotation-test.cjs && node scripts/update-watch-test.cjs && node scripts/runtime-update-watch-test.cjs && node scripts/setup-wizard-test.cjs && node scripts/md-pdf-test.cjs && node scripts/proposal-surfacing-test.cjs && node scripts/process-janitor-test.cjs && node scripts/detached-work-steer-test.cjs && node scripts/statusline-install-test.cjs && node scripts/docs-create-agent-test.cjs && node scripts/agent-stats-rollup-test.cjs && node scripts/task-discussion-rollup-test.cjs && node scripts/session-insights-stamp-test.cjs && node scripts/loop-stall-attribution-test.cjs && node scripts/session-progress-test.cjs && node scripts/drift-nudge-test.cjs && node scripts/goal-metric-review-test.cjs && node scripts/goal-heartbeat-test.cjs && node scripts/question-durability-test.cjs && node scripts/ask-block-link-test.cjs && node scripts/bets-test.cjs && node scripts/capability-gap-test.cjs && node scripts/workflow-proposal-test.cjs && node scripts/automation-edit-proposal-test.cjs && node scripts/app-card-close-test.cjs", "test:alert-staleness": "node scripts/alert-staleness-test.cjs", "test:deps": "node scripts/deps-freshness-test.cjs && node scripts/runtime-account-test.cjs && node scripts/runtime-account-misattribution-test.cjs && node scripts/runtime-login-test.cjs && node scripts/claude-config-seed-test.cjs && node scripts/claude-config-isolation-test.cjs", "test:dm-continuity": "node scripts/dm-continuity-test.cjs", diff --git a/scripts/session-search-test.cjs b/scripts/session-search-test.cjs new file mode 100644 index 00000000..981bc1e4 --- /dev/null +++ b/scripts/session-search-test.cjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +/* Session search — `session_history` as a keyword search, and session links that resolve. + * + * The incident: a human asked an agent to find "the session where we fixed the PayPal webhook". That + * work lived late in a long interactive run titled after its FIRST job, so the old tool — a substring + * match over title + seed task, own agent only — could not see it. The agent opened the tenant DB with + * `node:sqlite` from Bash, grepped other agents' transcripts, found the run, and then handed the human + * `#/sessions/ses_…`, a link it had assembled itself and the console did not route (the detail segment + * is the tmux name, `aos-ses_…`). + * + * Pinned here: the pure helpers; FTS over the row's own prose kept current by triggers (and NOT + * re-tokenized by the constant activity writes); the transcript index finding what only the transcript + * says, catching up when a run continues, and collapsing a resumed conversation to one hit; the two + * scopes — own agent, or exactly what the run-as human can see in the console — through the real + * loopback routes; and the `url` every result carries. Isolated home; no tmux, no claude. */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); +const HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'aos-session-search-test-')); +process.env.AGENT_OS_HOME = HOME; +process.env.AGENT_OS_TENANT = 'testco'; +process.env.AOS_NO_TTYD = '1'; +process.env.CLAUDE_CONFIG_DIR = path.join(HOME, 'claude-config'); +delete process.env.AGENT_OS_SECRET_KEY; + +let pass = 0, fail = 0; +const assert = (c, name, d) => c ? (pass++, console.log(` \x1b[32m✓\x1b[0m ${name}`)) : (fail++, console.log(` \x1b[31m✗ ${name}\x1b[0m${d !== undefined ? ' — ' + JSON.stringify(d).slice(0, 400) : ''}`)); + +const { digestConversation, toSessionFtsQuery, normalizeSessionRef, sessionConsoleUrl } = require(path.join(ROOT, 'dist/edge/session-search.js')); + +console.log('\n\x1b[1m1) pure helpers\x1b[0m'); +assert(toSessionFtsQuery('PayPal webhook') === '"paypal" OR "webhook"', 'words are quoted and ORed', toSessionFtsQuery('PayPal webhook')); +assert(toSessionFtsQuery('a:b -c* NEAR(x)') === '"near"', 'operator characters cannot reach FTS5 (single letters dropped)', toSessionFtsQuery('a:b -c* NEAR(x)')); +assert(toSessionFtsQuery(' ') === '' && toSessionFtsQuery('!!') === '', 'no searchable word → empty (caller lists by recency)'); +assert(toSessionFtsQuery('order 7') === '"order" OR "7"', 'a lone digit is kept (order numbers, versions)'); +assert(normalizeSessionRef('ses_abc123') === 'ses_abc123', 'a bare id passes through'); +assert(normalizeSessionRef('aos-ses_abc123') === 'ses_abc123', 'the tmux name is accepted'); +assert(normalizeSessionRef('https://x.example.com/#/sessions/aos-ses_abc123?tab=chat') === 'ses_abc123', 'a full console URL is accepted'); +assert(normalizeSessionRef('https://x.example.com/#/sessions/ses_abc123') === 'ses_abc123', 'and so is the broken link agents used to build'); +assert(sessionConsoleUrl('https://x.example.com/', 'ses_1') === 'https://x.example.com/#/sessions/aos-ses_1', 'the console URL uses the tmux-name detail'); + +const turn = (kind, text) => ({ kind, text, ts: 0 }); +const long = [turn('user', 'build the feature pages'), ...Array.from({ length: 400 }, (_, i) => turn('assistant', `step ${i} `.repeat(40))), turn('user', 'now fix the paypal webhook')]; +const dig = digestConversation(long, 8 * 1024); +assert(dig.length <= 8 * 1024, 'the digest respects its cap', dig.length); +assert(dig.includes('feature pages') && dig.includes('paypal webhook'), 'squeezing keeps BOTH the first and the last topic of a long run'); +assert(!digestConversation([{ kind: 'activity', tool: 'Bash', label: 'Ran a command', detail: 'curl /wp-json/edd/webhooks/v1/paypal', status: 'ok' }]).includes('undefined') + && digestConversation([{ kind: 'activity', tool: 'Bash', label: 'Ran a command', detail: 'curl /wp-json/edd/webhooks/v1/paypal', status: 'ok' }]).includes('/wp-json/edd/webhooks'), + 'an action’s hint (a path, a URL) is searchable'); + +(async () => { + const { createHttpServer } = require(path.join(ROOT, 'dist/server.js')); + const { TenantRegistry } = require(path.join(ROOT, 'dist/tenant-registry.js')); + const registry = new TenantRegistry(ROOT, 0, path.join(ROOT, 'config/agent-os.config.json')); + registry.bootAll(); + const { os: aos, tm } = registry.default(); + const server = createHttpServer(registry); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const base = `http://127.0.0.1:${server.address().port}`; + + // A claude-code agent, so its runs have a transcript reader (an unknown/mock runtime has none). + aos.registerAgent({ id: 'ops', version: '1.0.0', description: 'ops', principal: 'svc-ops', policyContext: 'default@v3', runtime: 'claude-code', dir: HOME }); + const owner = aos.team.invite({ email: 'owner-test@example.com', role: 'owner' }).member; + const alice = aos.team.invite({ email: 'alice@example.com', role: 'member' }).member; + const bob = aos.team.invite({ email: 'bob@example.com', role: 'member' }).member; + + const projects = path.join(process.env.CLAUDE_CONFIG_DIR, 'projects', '-scratch'); + fs.mkdirSync(projects, { recursive: true }); + const writeTranscript = (uuid, lines) => fs.writeFileSync(path.join(projects, `${uuid}.jsonl`), lines.map((l, i) => JSON.stringify( + l.role === 'user' + ? { type: 'user', uuid: `${uuid}-${i}`, timestamp: new Date(1e12 + i).toISOString(), message: { role: 'user', content: l.text } } + : { type: 'assistant', uuid: `${uuid}-${i}`, timestamp: new Date(1e12 + i).toISOString(), message: { role: 'assistant', content: [{ type: 'text', text: l.text }] } }, + )).join('\n') + '\n'); + + const T0 = Date.now() - 86_400_000; + let n = 0; + const mkRun = (o = {}) => { + const id = `ses_${(++n).toString(16).padStart(16, '0')}`; + const cols = { + id, agent: 'engineer', title: 'run ' + n, task: 'routine work', tmux: 'aos-' + id, status: 'done', + spawned_by: null, run_as: null, claude_session_id: null, archived_at: null, secret: 's' + n, + created_at: T0 + n * 60_000, updated_at: T0 + n * 60_000, last_activity: T0 + n * 60_000, ...o, + }; + aos.db.prepare(`INSERT INTO term_sessions (id,agent,title,task,tmux,status,spawned_by,run_as,claude_session_id,archived_at,secret,created_at,updated_at,last_activity) + VALUES (@id,@agent,@title,@task,@tmux,@status,@spawned_by,@run_as,@claude_session_id,@archived_at,@secret,@created_at,@updated_at,@last_activity)`).run(cols); + return id; + }; + const ids = (hits) => hits.map((h) => h.session.id); + + console.log('\n\x1b[1m2) the row’s own prose — title, task, report summary\x1b[0m'); + const deploy = mkRun({ title: 'deploy the droplet' }); + const migrate = mkRun({ task: 'migrate the billing database to the new cluster' }); + const reported = mkRun(); + const sibling = mkRun({ agent: 'researcher', title: 'deploy notes for the droplet' }); + const archived = mkRun({ title: 'deploy something old', archived_at: Date.now() }); + assert(ids(tm.searchSessions({ query: 'droplet', agent: 'engineer' })).join() === deploy, 'a title word finds the run'); + assert(ids(tm.searchSessions({ query: 'deploying', agent: 'engineer' })).includes(deploy), 'stemmed: "deploying" finds "deploy"'); + assert(ids(tm.searchSessions({ query: 'billing cluster', agent: 'engineer' }))[0] === migrate, 'a seed-task word finds the run'); + // The real path: `report` writes a session.reported audit event, and the insights stamp copies its + // summary onto the row the next time the row is listed. + aos.audit.append({ ts: Date.now(), runId: reported, tenant: aos.tenant, principal: 'agent:engineer', type: 'session.reported', data: { outcome: 'success', summary: 'Rotated the Stripe webhook secret' } }); + tm.listSessions(undefined, undefined, [reported]); + assert(ids(tm.searchSessions({ query: 'stripe', agent: 'engineer' })).join() === reported, 'a report summary written AFTER the row is searchable (update trigger)'); + aos.db.prepare('UPDATE term_sessions SET last_activity = ?, cost_usd = 1.5 WHERE id = ?').run(Date.now(), reported); + assert(ids(tm.searchSessions({ query: 'stripe', agent: 'engineer' })).join() === reported, 'activity/cost writes leave the index intact'); + const own = ids(tm.searchSessions({ query: 'droplet deploy', agent: 'engineer' })); + assert(!own.includes(sibling), 'agent scope never returns a sibling agent’s run'); + assert(!own.includes(archived), 'an archived run stays hidden'); + assert(tm.searchSessions({ query: 'droplet' }).length === 0, 'no agent and no viewer → nothing (never the whole tenant)'); + + console.log('\n\x1b[1m3) what was said in the run — the transcript index\x1b[0m'); + const CONVO = '11111111-2222-3333-4444-555555555555'; + writeTranscript(CONVO, [ + { role: 'user', text: 'Build the accessible mobile menu feature pages' }, + { role: 'assistant', text: 'Built three feature pages.' }, + ]); + const longRun = mkRun({ agent: 'ops', title: 'accessible-mobile-menu feature pages', claude_session_id: CONVO, run_as: alice.id, spawned_by: alice.id, status: 'stopped' }); + assert(tm.indexSessionText(5_000) > 0, 'the sweep indexes conversations it has not seen'); + assert(tm.searchSessions({ query: 'paypal', agent: 'ops' }).length === 0, 'nothing about PayPal yet'); + // The run continues days later on a different topic: same conversation, new activity. + writeTranscript(CONVO, [ + { role: 'user', text: 'Build the accessible mobile menu feature pages' }, + { role: 'assistant', text: 'Built three feature pages.' }, + { role: 'user', text: 'Every PayPal IPN is failing with a 500 now, can you look?' }, + { role: 'assistant', text: 'The live PayPal client credentials were removed; restored them and the webhook retry succeeded.' }, + ]); + aos.db.prepare('UPDATE term_sessions SET last_activity = ? WHERE id = ?').run(Date.now(), longRun); + tm.indexSessionText(5_000); + const pp = tm.searchSessions({ query: 'paypal webhook', agent: 'ops' }); + assert(ids(pp).join() === longRun, 'a topic that exists ONLY in the transcript finds the run once it continues', ids(pp)); + assert(pp[0] && /«paypal»/i.test(pp[0].snippet || ''), 'with a snippet marking where it matched', pp[0] && pp[0].snippet); + const resumed = mkRun({ agent: 'ops', title: 'poke: follow up', claude_session_id: CONVO, run_as: alice.id, spawned_by: alice.id }); + const both = ids(tm.searchSessions({ query: 'paypal', agent: 'ops' })); + assert(both.length === 1 && (both[0] === longRun || both[0] === resumed), 'two runs of ONE conversation are one hit', both); + tm.indexSessionText(5_000); // picks up the resumed row's conversation if it looks newer + const before = aos.db.prepare('SELECT COUNT(*) AS c FROM session_text_fts').get().c; + assert(tm.indexSessionText(5_000) === 0, 'a sweep with no new activity reads no transcript'); + assert(aos.db.prepare('SELECT COUNT(*) AS c FROM session_text_fts').get().c === before, 'and one conversation is one indexed row, however many runs it has'); + + console.log('\n\x1b[1m4) the loopback routes — scopes, urls, session_open\x1b[0m'); + const bobRun = mkRun({ agent: 'ops', title: 'paypal refund for bob', run_as: bob.id, spawned_by: bob.id }); + const researcher = mkRun({ agent: 'researcher', title: 'strategy research', run_as: alice.id, spawned_by: alice.id, status: 'running', secret: 'caller-secret' }); + const unattended = mkRun({ agent: 'researcher', title: 'nightly digest', status: 'running', secret: 'nightly-secret' }); + const get = async (p, params, secret = 'caller-secret', session = researcher) => { + const u = new URL(base + p); + u.searchParams.set('session', session); + for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v); + const r = await fetch(u, { headers: { 'x-aos-secret': secret } }); + return { status: r.status, body: await r.json() }; + }; + + const ownScope = await get('/api/agent/sessions', { query: 'paypal' }); + assert(ownScope.status === 200 && ownScope.body.sessions.length === 0, 'default scope: the researcher has no PayPal runs of its own', ownScope.body); + const mem = await get('/api/agent/sessions', { query: 'paypal webhook', scope: 'member' }); + const memIds = mem.body.sessions.map((s) => s.id); + assert(memIds.includes(longRun) || memIds.includes(resumed), 'member scope finds the run its human was in, on another agent', mem.body); + assert(!memIds.includes(bobRun), 'but never a run its human cannot see in the console (bob’s)', memIds); + const hit = mem.body.sessions[0]; + assert(hit && hit.url === `${tm.sessionUrl(hit.id)}` && hit.url.includes(`/#/sessions/aos-${hit.id}`), 'every result carries the console url, in the routed aos- form', hit && hit.url); + assert(hit && hit.agent === 'ops', 'and names the agent that ran it'); + const audited = aos.db.prepare("SELECT COUNT(*) AS c FROM audit_events WHERE type = 'session.searched'").get().c; + assert(audited >= 1, 'a member-scope search is audited (session.searched)'); + const narrowed = await get('/api/agent/sessions', { scope: 'member', agent: 'researcher' }); + assert(narrowed.body.sessions.every((s) => s.agent === 'researcher') && !narrowed.body.sessions.some((s) => s.id === researcher), 'member scope + agent narrows; the calling run is excluded', narrowed.body.sessions.map((s) => s.id)); + const recent = await get('/api/agent/sessions', { scope: 'member' }); + assert(recent.body.sessions.length > 0 && !recent.body.sessions.some((s) => s.id === bobRun), 'member scope with no query lists what the human can see, newest first'); + const noHuman = await get('/api/agent/sessions', { scope: 'member', query: 'paypal' }, 'nightly-secret', unattended); + assert(noHuman.body.error === 'no_member' && noHuman.body.sessions.length === 0, 'a run acting for nobody gets no member scope', noHuman.body); + + const open = await get('/api/agent/session', { id: `https://x.example.com/#/sessions/ses_${longRun.slice(4)}` }); + assert(open.status === 200 && open.body.meta && open.body.meta.id === longRun, 'session_open takes the broken-form URL and opens the run', open.body); + assert(open.body.meta && open.body.meta.url && open.body.meta.agent === 'ops', 'and returns its url + agent'); + assert(aos.db.prepare("SELECT COUNT(*) AS c FROM audit_events WHERE type = 'session.opened'").get().c >= 1, 'opening another agent’s run is audited (session.opened)'); + const denied = await get('/api/agent/session', { id: bobRun }); + assert(denied.status === 403, 'session_open refuses a run the human cannot see'); + const deniedNoHuman = await get('/api/agent/session', { id: longRun }, 'nightly-secret', unattended); + assert(deniedNoHuman.status === 403, 'and a run acting for nobody can still open only its own agent’s runs'); + const ownOpen = await get('/api/agent/session', { id: `aos-${unattended}` }, 'caller-secret', researcher); + assert(ownOpen.status === 200, 'own-agent runs open as before (tmux-name form accepted)', ownOpen.body); + const ownerView = aos.team.getMember(owner.id); + assert(tm.memberCanViewSession(bobRun, ownerView) && !tm.memberCanViewSession(bobRun, alice), 'visibility is the console’s rule: owner sees all, a member sees their own'); + + server.close(); + try { registry.stopAll && registry.stopAll(); } catch { /* best effort */ } + fs.rmSync(HOME, { recursive: true, force: true }); + console.log(fail ? `\nsession-search-test: ${fail} FAILED, ${pass} passed` : `\nsession-search-test: ok (${pass} checks)`); + process.exit(fail ? 1 : 0); +})().catch((e) => { console.error(e); process.exit(1); }); diff --git a/src/edge/automations.ts b/src/edge/automations.ts index cf01b4e1..7073922f 100644 --- a/src/edge/automations.ts +++ b/src/edge/automations.ts @@ -2364,6 +2364,8 @@ export class Automations { this.sweepBets(now); this.sweepLoginExpiry(now); this.sweepExpiredShares(now); + // Keep session search's transcript index current (time-boxed; see TerminalManager.indexSessionText). + try { this.tm.indexSessionText(150, now.getTime()); } catch { /* search is best-effort, the tick is not */ } // Re-nudge stale human-in-the-loop prompts (approvals/questions blocking an agent) so a missed ask // doesn't strand the run forever. Wrapped so a bad row can't take down the scheduler. try { this.stalePromptSweeper?.(now.getTime()); } catch { /* best-effort, never kills the tick */ } diff --git a/src/edge/session-search.ts b/src/edge/session-search.ts new file mode 100644 index 00000000..51021605 --- /dev/null +++ b/src/edge/session-search.ts @@ -0,0 +1,109 @@ +/** + * SESSION SEARCH — the pure half of `session_history`'s keyword search (the SQL lives in + * TerminalManager.searchSessions / indexSessionText; the schema in state/db.ts). + * + * Why it exists: `session_history` used to be a single `LIKE '%q%'` over a run's title and seed task. + * That cannot find a run whose title describes its FIRST job when the thing you remember came later — + * the common shape for a long interactive session a human keeps returning to. Asked to find one such + * run, an agent on a live tenant gave up on the tool, opened the tenant DB with `node:sqlite` from + * Bash and grepped other agents' transcripts, then handed the human a link it had assembled itself + * (`#/sessions/ses_…`) that the console didn't route. Both halves are fixed here: the search reaches + * what was said in the run, and every result carries the console URL so nobody composes one. + */ +import type { ChatTurn } from './conversation'; +import { consolePage } from '../governance/chat-links'; + +/** Upper bound on one conversation's indexed text. Most runs are far below it; a days-long session is + * not, and indexing its whole transcript would make the FTS table the largest thing in the DB. */ +export const SESSION_TEXT_CAP = 32 * 1024; +/** Over the cap, a human prompt keeps this much and the agent's words/actions this much each. */ +const USER_PIECE = 400; +const OTHER_PIECE = 160; + +/** + * The searchable text of a conversation: what the human asked, what the agent said, and the short hint + * of each action (a file name, a URL, a query) — the identifiers people actually search by. Tool RESULTS + * are left out: they are mostly file contents and command output, and would drown every real match. + * + * Under the cap, everything goes in. Over it, never a head or tail cut — the thing being searched for is + * as likely to be the LAST topic of a long run as the first. Instead every human prompt goes in first + * (they are where a run changes subject), then the agent's side is sampled evenly across the whole run + * into what's left. Output keeps the run's order. + */ +export function digestConversation(turns: ChatTurn[], cap = SESSION_TEXT_CAP): string { + const pieces: { text: string; user: boolean }[] = []; + for (const t of turns) { + if (t.kind === 'user' || t.kind === 'assistant') { + const s = t.text.replace(/\s+/g, ' ').trim(); + if (s) pieces.push({ text: s, user: t.kind === 'user' }); + } else if (t.kind === 'activity') { + const s = [t.label, t.detail].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim(); + if (s) pieces.push({ text: s.slice(0, 200), user: false }); + } + } + const total = pieces.reduce((n, p) => n + p.text.length + 1, 0); + if (total <= cap) return pieces.map((p) => p.text).join('\n'); + + const clip = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + '…' : s); + const keep = new Map(); + let used = 0; + const users = pieces.map((p, i) => ({ p, i })).filter((x) => x.p.user); + const userCost = users.reduce((n, x) => n + Math.min(x.p.text.length, USER_PIECE) + 1, 0); + const userBudget = Math.floor(cap * 0.6); + const userPick = userCost <= userBudget ? users : sampleEvenly(users, Math.floor(userBudget / (USER_PIECE + 1))); + for (const { p, i } of userPick) { + const s = clip(p.text, USER_PIECE); + keep.set(i, s); + used += s.length + 1; + } + const others = pieces.map((p, i) => ({ p, i })).filter((x) => !x.p.user); + for (const { p, i } of sampleEvenly(others, Math.floor((cap - used) / (OTHER_PIECE + 1)))) { + const s = clip(p.text, OTHER_PIECE); + if (used + s.length + 1 > cap) break; + keep.set(i, s); + used += s.length + 1; + } + return [...keep.entries()].sort((x, y) => x[0] - y[0]).map(([, s]) => s).join('\n'); +} + +/** `k` items spread evenly across `arr`, always including the first and the last. */ +function sampleEvenly(arr: T[], k: number): T[] { + if (k <= 0) return []; + if (k >= arr.length) return arr; + if (k === 1) return [arr[arr.length - 1]]; + const out: T[] = []; + for (let j = 0; j < k; j++) out.push(arr[Math.round((j * (arr.length - 1)) / (k - 1))]); + return out; +} + +/** + * Free text → an FTS5 MATCH expression. Every word is quoted (so `-`, `:` and `*` in what an agent types + * can't become operators). `any` ORs them — nothing comes back empty because one word was off; `all` + * ANDs them, which the search runs FIRST so a run matching every word is never outranked by one that + * merely repeats a single word. '' when the query has no searchable word — the caller lists by recency. + */ +export function toSessionFtsQuery(query?: string, mode: 'any' | 'all' = 'any'): string { + if (!query) return ''; + const tokens = query.toLowerCase().match(/[\p{L}\p{N}]+/gu); + if (!tokens) return ''; + const words = [...new Set(tokens)].filter((t) => t.length > 1 || /\d/.test(t)).slice(0, 12); + return words.map((t) => `"${t}"`).join(mode === 'all' ? ' AND ' : ' OR '); +} + +/** + * Accept whatever an agent passes as a session reference and return the session id: the bare id + * (`ses_…`), the console's tmux-named detail (`aos-ses_…`), or a whole console URL. Agents copy all + * three around; failing on two of them sends the agent back to guessing. + */ +export function normalizeSessionRef(ref: string): string { + let s = String(ref ?? '').trim(); + const m = s.match(/#\/sessions\/([^?#\s]+)/); + if (m) s = decodeURIComponent(m[1]); + return s.replace(/^aos-/, ''); +} + +/** The console deep-link for a session. The sessions route's detail segment is the tmux NAME + * (`aos-`), not the bare id — the single place that encodes that. */ +export function sessionConsoleUrl(origin: string, id: string): string { + return consolePage(origin, 'sessions', `aos-${id}`); +} diff --git a/src/memory/memory-mcp.ts b/src/memory/memory-mcp.ts index a8ee6c0d..1866d808 100644 --- a/src/memory/memory-mcp.ts +++ b/src/memory/memory-mcp.ts @@ -999,35 +999,40 @@ const TOOLS = [ { name: 'session_history', description: - 'List your OWN past sessions — your episodic run history ("have I done this before, and how did it ' + - 'go?"). The companion to `recall`: memory holds distilled facts you chose to keep, this holds the ' + - 'actual runs, which you can reopen in full. Returns each run\'s id, title, status, human rating ' + - '(👍/👎 if scored), and seed task — newest first, not the transcript (use `session_open` for that). ' + - 'Scoped to THIS agent: you only ever see your own runs, never another agent\'s. Filter with `query` ' + - '(matches the title/task). Read-only.', + 'Search or list past sessions — the actual runs, which you can reopen in full with `session_open`. ' + + 'The companion to `recall`: memory holds distilled facts, this holds what happened. With a `query` ' + + 'it is a ranked KEYWORD search over each run\'s title, seed task, report summary AND what was said in ' + + 'it (prompts, replies, files/URLs touched), with a snippet showing where it matched; without one it ' + + 'lists newest first. Scope: `agent` (default) = your OWN runs ("have I done this before?"); `member` = ' + + 'every session the human this run acts for can see in the console, any agent ("find the session I was ' + + 'in where we fixed X") — narrow with `agent`. Each result has a `url`: give THAT to a human, never ' + + 'build a session link yourself. Read-only.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', additionalProperties: false, properties: { - query: { type: 'string', description: 'Optional text to match against past sessions\' title and seed task.' }, - limit: { type: 'number', minimum: 1, maximum: 100, default: 20, description: 'Max sessions, newest first (default 20).' }, + query: { type: 'string', description: 'Keywords to search for (any word matches; runs matching more of them rank higher). Omit to list newest first.' }, + scope: { type: 'string', enum: ['agent', 'member'], default: 'agent', description: '`agent` = your own runs; `member` = every run your human can see (needs a run acting for a person).' }, + agent: { type: 'string', description: 'With scope `member`: only this agent\'s runs.' }, + limit: { type: 'number', minimum: 1, maximum: 100, default: 20, description: 'Max sessions (default 20).' }, }, }, }, { name: 'session_open', description: - 'Open ONE of your past sessions (id from `session_history`) and read what happened — the friendly ' + + 'Open ONE past session (id from `session_history`) and read what happened — the friendly ' + 'timeline of messages and the actions you took, so you can see how you approached something before ' + 'and what the outcome was. Set `summary:true` for a short recap of the whole run instead of the full ' + - 'turn-by-turn timeline (best when the session was long). You can only open your OWN sessions. Read-only.', + 'turn-by-turn timeline (best when the session was long). Opens your own sessions, or any session ' + + '`session_history({ scope:\'member\' })` returned. Takes the id, `aos-`, or the session URL. Read-only.', annotations: { readOnlyHint: true }, inputSchema: { type: 'object', additionalProperties: false, properties: { - id: { type: 'string', description: 'The session id to open (from session_history).' }, + id: { type: 'string', description: 'The session to open: its id (from session_history), `aos-`, or its console URL.' }, summary: { type: 'boolean', description: 'Return a condensed recap of the whole session instead of the message-by-message timeline. Default false.' }, }, required: ['id'], @@ -2647,8 +2652,8 @@ async function artifactsList(args: Record): Promise { } // ── Episodic self-query: your own past sessions (the run-history companion to `recall`) ── -interface SessionLite { id: string; title: string; task: string; status: string; createdAt: number; updatedAt?: number; rating?: 'up' | 'down'; headless?: boolean; source?: string } -interface SessionMeta { id: string; title: string; task: string; status: string; createdAt: number; updatedAt?: number; rating?: 'up' | 'down' } +interface SessionLite { id: string; agent?: string; title: string; task: string; status: string; summary?: string; createdAt: number; updatedAt?: number; rating?: 'up' | 'down'; headless?: boolean; source?: string; snippet?: string; url?: string } +interface SessionMeta { id: string; agent?: string; title: string; task: string; status: string; createdAt: number; updatedAt?: number; rating?: 'up' | 'down'; url?: string } type ChatTurnLite = | { kind: 'user'; text: string } | { kind: 'assistant'; text: string } @@ -2672,13 +2677,25 @@ async function sessionHistory(args: Record): Promise { u.searchParams.set('session', SESSION); if (typeof args.limit === 'number') u.searchParams.set('limit', String(args.limit)); if (args.query) u.searchParams.set('query', String(args.query)); + const member = args.scope === 'member'; + if (member) u.searchParams.set('scope', 'member'); + if (args.agent) u.searchParams.set('agent', String(args.agent)); const res = await fetch(u, { headers: H() }); - const data = (await res.json()) as { sessions?: SessionLite[] }; + const data = (await res.json()) as { sessions?: SessionLite[]; error?: string }; + if (data.error === 'no_member') return 'This run is not acting for a person, so there are no teammate sessions to search — scope `member` needs a run started by (or for) a teammate. Your own runs are still searchable with the default scope.'; const sessions = data.sessions ?? []; - if (!sessions.length) return args.query ? 'None of your past sessions match that.' : 'You have no past sessions yet.'; + if (!sessions.length) return args.query ? 'No past session matches that.' : member ? 'No sessions to show.' : 'You have no past sessions yet.'; return sessions - .map((s) => `- ${s.id} · ${s.status}${verdictMark(s.rating)} · ${fmtWhen(s.updatedAt || s.createdAt)} — ${snip(s.title, 80)}${s.task ? `\n ↳ ${snip(s.task, 100)}` : ''}`) - .join('\n') + '\n\n(Use `session_open ` to read one, or add `summary:true` for a recap.)'; + .map((s) => { + const who = member && s.agent ? `${s.agent} · ` : ''; + const lines = [`- ${s.id} · ${who}${s.status}${verdictMark(s.rating)} · ${fmtWhen(s.updatedAt || s.createdAt)} — ${snip(s.title, 80)}`]; + if (s.summary) lines.push(` ↳ ${snip(s.summary, 140)}`); + else if (s.task) lines.push(` ↳ ${snip(s.task, 100)}`); + if (s.snippet) lines.push(` matched: ${snip(s.snippet, 200)}`); + if (s.url) lines.push(` ${s.url}`); + return lines.join('\n'); + }) + .join('\n') + '\n\n(Use `session_open ` to read one, or add `summary:true` for a recap. Link humans with the url shown.)'; } async function sessionOpen(args: Record): Promise { @@ -2690,11 +2707,11 @@ async function sessionOpen(args: Record): Promise { u.searchParams.set('id', id); if (wantSummary) u.searchParams.set('summary', '1'); const res = await fetch(u, { headers: H() }); - if (res.status === 403) return 'That session is not one of yours — you can only open your OWN past sessions (see session_history).'; + if (res.status === 403) return 'You can\'t open that session — only your own runs, or ones the person this run acts for can see (find those with session_history scope `member`).'; const data = (await res.json()) as { meta?: SessionMeta; turns?: ChatTurnLite[]; found?: boolean; summary?: string; log?: string; error?: string }; if (data.error) return `Could not open that session: ${data.error}.`; const m = data.meta; - const head = m ? `Session ${m.id} — ${snip(m.title, 100)} [${m.status}${verdictMark(m.rating)}]\nStarted ${fmtWhen(m.createdAt)}${m.task ? `\nSeed: ${snip(m.task, 200)}` : ''}\n\n` : ''; + const head = m ? `Session ${m.id}${m.agent ? ` (${m.agent})` : ''} — ${snip(m.title, 100)} [${m.status}${verdictMark(m.rating)}]\nStarted ${fmtWhen(m.createdAt)}${m.url ? `\nLink: ${m.url}` : ''}${m.task ? `\nSeed: ${snip(m.task, 200)}` : ''}\n\n` : ''; if (wantSummary) return head + (data.summary?.trim() || 'This session has no transcript to summarize yet.'); const turns = data.turns ?? []; if (data.found && turns.length) { diff --git a/src/server.ts b/src/server.ts index 90aa6687..ed8c0c36 100644 --- a/src/server.ts +++ b/src/server.ts @@ -22,7 +22,8 @@ import { pendingAlerts } from './edge/alerts'; import { exampleCapabilities } from './capabilities/examples'; import { governedCapabilities } from './capabilities/normalize'; import { evaluate } from './observability/evaluation'; -import { TerminalManager, AGENT_OS_OPERATING_NOTES, type ProposedAutomation } from './terminal'; +import { TerminalManager, AGENT_OS_OPERATING_NOTES, type ProposedAutomation, type Session } from './terminal'; +import { normalizeSessionRef, toSessionFtsQuery } from './edge/session-search'; import { classifyActivity, clipText, ActivityCategory, ActivityEffect, ActivityTarget } from './state/session-activity'; import { deriveProgress, parseClaim, ProgressClaim, SessionProgress } from './state/session-progress'; @@ -1291,41 +1292,68 @@ async function handle(os: AgentOS, tm: TerminalManager, autos: Automations, req: // existing tree instead of inventing a new folder; the artifact LIST stays own-scoped. return sendJson(res, 200, { artifacts, folders: os.artifacts.folders(), enabled: os.artifacts.enabled }); } - // agent reads back ITS OWN past sessions (episodic self-query — "have I done this before, how did it - // go?"), the run-history companion to the semantic memory plane. Own-scoped server-side to the caller's - // agent id; metadata only (id/title/status/rating/dates), never a transcript — that's `session_open`. + // agent searches past sessions (episodic self-query — "have I done this before, how did it go?", and + // "find the run I was in"). Two scopes, chosen by the caller: `agent` (default) = the caller's OWN runs; + // `member` = every session the run's run-as human can see in the console (optionally one `agent`), for + // an agent acting on that human's behalf. A query is a ranked keyword search over the row's prose AND + // what was said in the run (TerminalManager.searchSessions). Metadata + a match snippet + the console + // `url` — never a transcript (that's `session_open`), and never a link the agent has to assemble. if (method === 'GET' && p === '/api/agent/sessions') { const session = url.searchParams.get('session') || ''; const agent = tm.sessionAgent(session); if (!agent) return sendJson(res, 404, { error: 'unknown session' }); if (!sessionSecretOk(session)) return sendJson(res, 403, { error: 'bad session secret' }); const limit = Math.min(Math.max(Number(url.searchParams.get('limit')) || 20, 1), 100); - const q = (url.searchParams.get('query') || '').trim().toLowerCase(); - // Filters + limit are pushed into the store: it selects this agent's ids in SQL and derives only - // those rows (see sessionsForAgent) instead of building the whole tenant's list to discard it here. - const mine = tm.sessionsForAgent(agent, { query: q, excludeId: session, limit }); - const sessions = mine.map((s) => ({ - id: s.id, title: s.title, task: s.task, status: s.status, + const q = (url.searchParams.get('query') || '').trim(); + const scope = url.searchParams.get('scope') === 'member' ? 'member' : 'agent'; + const agentFilter = (url.searchParams.get('agent') || '').trim() || undefined; + let viewer: Member | undefined; + if (scope === 'member') { + const runAsId = tm.sessionRunAs(session); + viewer = runAsId ? os.team.getMember(runAsId) : undefined; + if (!viewer) return sendJson(res, 200, { sessions: [], error: 'no_member', scope }); + } + const searchAgent = scope === 'agent' ? agent : agentFilter; + let rows: { session: Session; snippet?: string }[]; + if (toSessionFtsQuery(q)) rows = tm.searchSessions({ query: q, agent: searchAgent, viewer, excludeId: session, limit }); + else if (viewer) rows = tm.recentSessionsForMember(viewer, { agent: searchAgent, excludeId: session, limit }).map((s) => ({ session: s })); + else rows = tm.sessionsForAgent(agent, { query: q.toLowerCase(), excludeId: session, limit }).map((s) => ({ session: s })); + if (scope === 'member') { + os.audit.append({ ts: Date.now(), runId: session, tenant: os.tenant, principal: `agent:${agent}`, type: 'session.searched', data: { scope, query: q.slice(0, 200), agent: agentFilter, runAs: viewer!.id, results: rows.length } }); + } + const sessions = rows.map(({ session: s, snippet }) => ({ + id: s.id, agent: s.agent, title: s.title, task: s.task, status: s.status, summary: s.summary, createdAt: s.createdAt, updatedAt: s.updatedAt, rating: s.rating, headless: s.headless, source: s.sourceKind, + snippet, url: tm.sessionUrl(s.id), })); - return sendJson(res, 200, { sessions }); + return sendJson(res, 200, { sessions, scope }); } - // agent OPENS one of its own past sessions to read what happened — the friendly transcript timeline, or - // (summary=1) a throwaway-claude recap of the whole run. Own-scoped: the target must belong to the - // caller's agent (resolved via `sessionsForAgent`), else 403. Falls back to the raw pane log for a - // headless run that tee'd only `session-.log` and has no structured transcript. + // agent OPENS a past session to read what happened — the friendly transcript timeline, or (summary=1) + // a throwaway-claude recap of the whole run. Allowed when the target is the caller's own agent's run, or + // one the run's run-as human can see in the console (the `member` scope of session_history) — the + // latter audited as `session.opened`. Accepts a bare id, the `aos-` tmux name, or a console URL. + // Falls back to the raw pane log for a headless run that tee'd only `session-.log`. if (method === 'GET' && p === '/api/agent/session') { const session = url.searchParams.get('session') || ''; const agent = tm.sessionAgent(session); if (!agent) return sendJson(res, 404, { error: 'unknown session' }); if (!sessionSecretOk(session)) return sendJson(res, 403, { error: 'bad session secret' }); - const id = url.searchParams.get('id') || ''; + const id = normalizeSessionRef(url.searchParams.get('id') || ''); // Ownership is one indexed row-test; the shape then comes from the by-id list path — instead of // building this agent's whole history to `.find()` one id in it. - if (!tm.sessionBelongsToAgent(id, agent)) return sendJson(res, 403, { error: 'not one of your sessions' }); + let via: 'agent' | 'member' = 'agent'; + if (!tm.sessionBelongsToAgent(id, agent)) { + const runAsId = tm.sessionRunAs(session); + const viewer = runAsId ? os.team.getMember(runAsId) : undefined; + if (!viewer || !tm.memberCanViewSession(id, viewer)) return sendJson(res, 403, { error: 'not one of your sessions' }); + via = 'member'; + } const target = tm.listSessions(undefined, undefined, [id])[0]; if (!target) return sendJson(res, 403, { error: 'not one of your sessions' }); - const meta = { id: target.id, title: target.title, task: target.task, status: target.status, createdAt: target.createdAt, updatedAt: target.updatedAt, rating: target.rating }; + if (via === 'member') { + os.audit.append({ ts: Date.now(), runId: session, tenant: os.tenant, principal: `agent:${agent}`, type: 'session.opened', data: { target: id, targetAgent: target.agent, via, runAs: tm.sessionRunAs(session) } }); + } + const meta = { id: target.id, agent: target.agent, title: target.title, task: target.task, status: target.status, createdAt: target.createdAt, updatedAt: target.updatedAt, rating: target.rating, url: tm.sessionUrl(target.id) }; const convo = tm.sessionConversation(id); if (url.searchParams.get('summary') === '1') { // Pool credentials, not the box default — see TerminalManager.outOfBandCredentialEnv. `reason` is diff --git a/src/state/db.ts b/src/state/db.ts index 08b71b6b..783f1ff9 100644 --- a/src/state/db.ts +++ b/src/state/db.ts @@ -1441,6 +1441,45 @@ function migrate(db: Db): void { // reviewer weighs, and assigning someone on the board is the human's act that accepts it (→ todo). // Adopted as the assignee when a proposal is accepted without one. NULL on every human-filed task. addColumn(db, 'tasks', 'suggested_assignee', 'TEXT'); + + // SESSION SEARCH (`session_history`): ranked keyword search over past runs. Two indexes, because the + // two halves of a session change at very different rates: + // - term_sessions_fts — external-content FTS over the row's own prose (title, seed task, report + // summary), kept in step by triggers. The UPDATE trigger is scoped to those columns AND to a real + // change: term_sessions is rewritten constantly (activity stamps, cost, turn state), and an + // un-scoped trigger would re-tokenize a whole seed task on every hook beacon. + // - session_text_fts — what was actually SAID in the run (prompts, replies, the files/URLs it + // touched), one row per CONVERSATION (`claude_session_id`, else the session id), filled off the + // scheduler tick by TerminalManager.indexSessionText. Title + task alone missed the case that + // motivated this: a long interactive run titled after its first job whose later work (a PayPal + // webhook fix) existed only in the transcript, so an agent asked to find it read the live DB and + // grepped other agents' transcripts from Bash instead. + const hadSessionFts = !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'term_sessions_fts'").get(); + db.exec(` + CREATE VIRTUAL TABLE IF NOT EXISTS term_sessions_fts USING fts5( + title, task, report_summary, content='term_sessions', content_rowid='rowid', tokenize='porter unicode61' + ); + CREATE TRIGGER IF NOT EXISTS term_sessions_fts_ai AFTER INSERT ON term_sessions BEGIN + INSERT INTO term_sessions_fts(rowid, title, task, report_summary) VALUES (new.rowid, new.title, new.task, new.report_summary); + END; + CREATE TRIGGER IF NOT EXISTS term_sessions_fts_ad AFTER DELETE ON term_sessions BEGIN + INSERT INTO term_sessions_fts(term_sessions_fts, rowid, title, task, report_summary) VALUES ('delete', old.rowid, old.title, old.task, old.report_summary); + END; + CREATE TRIGGER IF NOT EXISTS term_sessions_fts_au AFTER UPDATE OF title, task, report_summary ON term_sessions + WHEN old.title IS NOT new.title OR old.task IS NOT new.task OR old.report_summary IS NOT new.report_summary BEGIN + INSERT INTO term_sessions_fts(term_sessions_fts, rowid, title, task, report_summary) VALUES ('delete', old.rowid, old.title, old.task, old.report_summary); + INSERT INTO term_sessions_fts(rowid, title, task, report_summary) VALUES (new.rowid, new.title, new.task, new.report_summary); + END; + CREATE VIRTUAL TABLE IF NOT EXISTS session_text_fts USING fts5(convo UNINDEXED, body, tokenize='porter unicode61'); + -- Watermark per conversation: re-read a transcript only once its rows show activity after this. + CREATE TABLE IF NOT EXISTS session_text_index ( + convo TEXT PRIMARY KEY, + indexed_at INTEGER NOT NULL, + bytes INTEGER NOT NULL DEFAULT 0 + ); + `); + // Existing rows predate the triggers — index them once, on the boot that creates the table. + if (!hadSessionFts) db.exec("INSERT INTO term_sessions_fts(term_sessions_fts) VALUES ('rebuild')"); } /** Add a column only if it isn't already present (SQLite has no ADD COLUMN IF NOT EXISTS). */ diff --git a/src/terminal.ts b/src/terminal.ts index fab8d951..47132f01 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -51,6 +51,7 @@ import { readTranscriptEnd } from './edge/outcome'; import { pendingBackgroundWork, BACKGROUND_GRACE_MS, UNATTENDED_TURN_BRIEF, WAITING_BRIEF } from './edge/background-work'; import { findCodexRollout, readCodexCost, readCodexConversation } from './edge/codex-transcript'; import { readConversation, findTranscript, registerTranscriptRoot, Conversation } from './edge/conversation'; +import { digestConversation, sessionConsoleUrl, toSessionFtsQuery } from './edge/session-search'; // Video render tuning: a submitted job renders async. The in-call path polls briefly for the fast case; // the tick poller finishes the rest, bounded by a TTL + a poll ceiling so a stuck render can't linger. @@ -171,8 +172,9 @@ gateway before it runs. An action may be **allowed**, **denied**, or **suspended You have durable memory spanning your past runs (your own, plus anything shared workspace-wide). At most a short head start is placed in this prompt — you must reach for the rest: - \`recall\` **at the start of non-trivial work** to pull past decisions, fixes, and gotchas, so you - don't re-derive facts or repeat mistakes a previous run already solved. \`session_history\` / - \`session_open\` show your actual past runs when you need to know how something went last time. + don't re-derive facts or repeat mistakes a previous run already solved. \`session_history\` (keyword + search over what was said in each run; \`scope:'member'\` to find a run your human was in) / + \`session_open\` show actual past runs. Link a human to a session with the \`url\` those tools return. - \`remember\` at the **moments worth encoding** — when a result **surprised** you, when something took real **effort** to work out, when you made a **decision** future runs will reuse, or when you hit a **gotcha / constraint / root cause**. One self-contained fact per memory; skip routine steps and @@ -1947,6 +1949,155 @@ export class TerminalManager { .get(sessionId, agent); } + /** Whether `viewer` may see this (non-archived) session in the console — the same rule the Sessions + * list applies. Backs `session_open` on a run found with `session_history({ scope:'member' })`. */ + memberCanViewSession(sessionId: string, viewer: Member): boolean { + const r = this.db + .prepare('SELECT spawned_by, run_as FROM term_sessions WHERE id = ? AND archived_at IS NULL') + .get<{ spawned_by: string | null; run_as: string | null }>(sessionId); + return !!r && this.withRowCache(() => this.canReadRow(sessionId, r.spawned_by, r.run_as, viewer)); + } + + /** The console deep-link for a session — hand this to a human; never assemble one. */ + sessionUrl(id: string): string { + return sessionConsoleUrl(this.publicOrigin || this.baseUrl, id); + } + + /** + * Ranked keyword search over past sessions (`session_history` with a query). Matches the row's own + * prose (title, seed task, report summary) AND what was said in the run (`session_text_fts`, filled by + * {@link indexSessionText}), scored together with bm25 — a run matching in both outranks one matching + * in either. + * + * Scope is one of two, and the caller picks it, not the query: + * - `agent` (no `viewer`): that agent's OWN runs — the long-standing `session_history` rule. + * - `viewer`: every session that member can see in the console, optionally narrowed to one `agent`. + * This is the "find the session I was in" case: the agent is acting FOR a person, and the person + * can already open every one of these runs themselves. Visibility is the console's own rule + * (`canReadRow`), so this never shows an agent anything its human couldn't see. + * + * Runs that share a transcript (a `poke:`/resume run continues the same conversation) collapse to the + * best-scoring one, so one conversation is one hit. + */ + searchSessions(opts: { query: string; agent?: string; viewer?: Member; excludeId?: string; limit?: number }): { session: Session; snippet?: string }[] { + const any = toSessionFtsQuery(opts.query, 'any'); + if (!any) return []; + if (!opts.viewer && !opts.agent) return []; + const all = toSessionFtsQuery(opts.query, 'all'); + const limit = Math.min(Math.max(Math.floor(opts.limit ?? 20), 1), 100); + const filters: string[] = ['s.archived_at IS NULL']; + const scopeArgs: unknown[] = []; + if (opts.agent) { filters.push('s.agent = ?'); scopeArgs.push(opts.agent); } + if (opts.excludeId) { filters.push('s.id != ?'); scopeArgs.push(opts.excludeId); } + // Over-fetch: member visibility and conversation-collapsing both run after SQL. + const ranked = this.db.prepare(` + SELECT s.id, s.spawned_by, s.run_as, COALESCE(s.claude_session_id, s.id) AS convo, SUM(h.rank) AS score + FROM ( + SELECT ts.id AS id, bm25(term_sessions_fts, 4.0, 1.5, 3.0) AS rank + FROM term_sessions_fts JOIN term_sessions ts ON ts.rowid = term_sessions_fts.rowid + WHERE term_sessions_fts MATCH ? + UNION ALL + SELECT ts.id, bm25(session_text_fts) + FROM session_text_fts JOIN term_sessions ts ON ts.claude_session_id = session_text_fts.convo + WHERE session_text_fts MATCH ? + UNION ALL + SELECT ts.id, bm25(session_text_fts) + FROM session_text_fts JOIN term_sessions ts ON ts.id = session_text_fts.convo AND ts.claude_session_id IS NULL + WHERE session_text_fts MATCH ? + ) h JOIN term_sessions s ON s.id = h.id + WHERE ${filters.join(' AND ')} + GROUP BY s.id + ORDER BY score ASC, s.created_at DESC + LIMIT ?`); + type Cand = { id: string; spawned_by: string | null; run_as: string | null; convo: string; score: number }; + const fetch = (m: string): Cand[] => ranked.all(m, m, m, ...scopeArgs, Math.min(limit * 8, 400)); + // Every-word matches first (a run that says "dataforseo" AND "disconnect" beats one that says + // "disconnecting" five times), then any-word matches to fill. One word → the two are the same query. + const cand = all === any ? fetch(any) : [...fetch(all), ...fetch(any)]; + const viewer = opts.viewer; + const seen = new Set(); + const picked: { id: string; convo: string; match: string }[] = []; + this.withRowCache(() => { + for (const c of cand) { + if (picked.length >= limit) break; + if (seen.has(c.convo)) continue; + if (viewer && !this.canReadRow(c.id, c.spawned_by, c.run_as, viewer)) continue; + seen.add(c.convo); + picked.push({ id: c.id, convo: c.convo, match: any }); + } + }); + if (!picked.length) return []; + // Where in the run the words were said — enough for the agent to tell a real hit from a passing mention. + // Falls back to the row's own prose (title/task/summary) when the words aren't in the transcript. + const snip = this.db.prepare(`SELECT snippet(session_text_fts, 1, '«', '»', '…', 16) AS s FROM session_text_fts + WHERE session_text_fts MATCH ? AND convo = ? LIMIT 1`); + const rowSnip = this.db.prepare(`SELECT snippet(term_sessions_fts, -1, '«', '»', '…', 16) AS s + FROM term_sessions_fts JOIN term_sessions ts ON ts.rowid = term_sessions_fts.rowid + WHERE term_sessions_fts MATCH ? AND ts.id = ? LIMIT 1`); + const byId = new Map(this.listSessions(undefined, 400, picked.map((p) => p.id)).map((s) => [s.id, s])); + const out: { session: Session; snippet?: string }[] = []; + for (const p of picked) { + const session = byId.get(p.id); + if (!session) continue; + const raw = snip.get<{ s: string }>(p.match, p.convo)?.s ?? rowSnip.get<{ s: string }>(p.match, p.id)?.s; + const s = raw?.replace(/\s+/g, ' ').trim(); + out.push({ session, snippet: s || undefined }); + } + return out; + } + + /** Newest sessions a member can see (optionally one agent's) — `session_history({ scope:'member' })` + * with no query. Visibility runs over a bounded newest-first window, never the whole tenant. */ + recentSessionsForMember(viewer: Member, opts: { agent?: string; excludeId?: string; limit?: number } = {}): Session[] { + const limit = Math.min(Math.max(Math.floor(opts.limit ?? 20), 1), 100); + const filters = ['archived_at IS NULL']; + const args: unknown[] = []; + if (opts.agent) { filters.push('agent = ?'); args.push(opts.agent); } + if (opts.excludeId) { filters.push('id != ?'); args.push(opts.excludeId); } + const rows = this.db + .prepare(`SELECT id, spawned_by, run_as FROM term_sessions WHERE ${filters.join(' AND ')} ORDER BY created_at DESC LIMIT 500`) + .all<{ id: string; spawned_by: string | null; run_as: string | null }>(...args); + const ids = this.withRowCache(() => rows.filter((r) => this.canReadRow(r.id, r.spawned_by, r.run_as, viewer)).slice(0, limit).map((r) => r.id)); + return this.listSessions(undefined, 400, ids); + } + + /** + * Fill `session_text_fts` — what was said in each conversation — a slice at a time, off the scheduler + * tick. A conversation is (re)read once its rows show activity after its watermark, so a finished run + * is read once and a live one is caught up as it goes; the backlog of an existing tenant drains newest + * first over the first minutes after deploy. Bounded by `budgetMs` because transcript reads are + * synchronous and a days-long transcript is megabytes. Returns how many conversations it indexed. + */ + indexSessionText(budgetMs = 150, now = Date.now()): number { + const started = Date.now(); + const stale = this.db.prepare(` + SELECT COALESCE(s.claude_session_id, s.id) AS convo, MAX(s.id) AS id, -- any run of it reads the same transcript + MAX(COALESCE(s.last_activity, s.updated_at, s.created_at)) AS active + FROM term_sessions s LEFT JOIN session_text_index i ON i.convo = COALESCE(s.claude_session_id, s.id) + WHERE s.archived_at IS NULL + GROUP BY COALESCE(s.claude_session_id, s.id) + HAVING MAX(i.indexed_at) IS NULL OR active > MAX(i.indexed_at) + ORDER BY active DESC + LIMIT 40`).all<{ convo: string; id: string; active: number }>(); + const del = this.db.prepare('DELETE FROM session_text_fts WHERE convo = ?'); + const ins = this.db.prepare('INSERT INTO session_text_fts (convo, body) VALUES (?, ?)'); + const mark = this.db.prepare(`INSERT INTO session_text_index (convo, indexed_at, bytes) VALUES (?, ?, ?) + ON CONFLICT(convo) DO UPDATE SET indexed_at = excluded.indexed_at, bytes = excluded.bytes`); + let n = 0; + for (const row of stale) { + if (n > 0 && Date.now() - started > budgetMs) break; + let body = ''; + try { body = digestConversation(this.sessionConversation(row.id).turns); } catch { /* unreadable → index nothing, retry on next activity */ } + del.run(row.convo); + if (body) ins.run(row.convo, body); + // Stamp the watermark at the row's own activity time, not `now`: activity that lands while we read + // is still newer than the mark, so the next sweep picks it up. + mark.run(row.convo, Math.min(row.active ?? now, now), body.length); + n++; + } + return n; + } + /** * Session ids that have a persisted launch env (`session-.env`) — i.e. a session the ttyd attach * wrapper can resurrect via `claude --resume` (see `writeEnvFile`/`terminal/attach.sh`). Every @@ -4893,7 +5044,7 @@ export class TerminalManager { // A human-facing deep-link back to THIS session's console page (the tmux is `aos-`, which is the // sessions route's detail segment). Handed to agents so a PR/report/artifact can point a reviewer at // the run that produced it — the traceability spine from an external artifact back into the audited OS. - AOS_SESSION_URL: `${this.publicOrigin || this.baseUrl}/#/sessions/aos-${id}`, + AOS_SESSION_URL: this.sessionUrl(id), AOS_TENANT: this.os.tenant, // routes loopback agent calls to THIS tenant's runtime (multi-tenant) SESSION: id, AGENT: agent, diff --git a/web/src/App.tsx b/web/src/App.tsx index 29a3958e..e7acf4aa 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -969,7 +969,15 @@ function useHashRoute(): { route: Route; detail: string; query: string; nav: (r: const head = slash === -1 ? path : path.slice(0, slash) const rest = slash === -1 ? '' : path.slice(slash + 1) const route = (ROUTES as string[]).includes(head) ? (head as Route) : 'inbox' - return { route, detail: rest ? decodeDetail(rest) : '', query } + let detail = rest ? decodeDetail(rest) : '' + // The sessions detail is the tmux NAME (`aos-`), but a bare session id is what every API, + // tool and log line prints — so `#/sessions/ses_…` is the link people (and agents) actually build. + // Accept it and rewrite the address bar to the canonical form instead of opening a blank pane. + if (route === 'sessions' && /^ses_[A-Za-z0-9]+$/.test(detail)) { + detail = `aos-${detail}` + try { window.history.replaceState(null, '', `#/sessions/${detail}${query ? `?${query}` : ''}`) } catch { /* cosmetic */ } + } + return { route, detail, query } } const [state, setState] = useState(parse) // Where you came FROM, most recent last. A full-page room (a task, a goal) is reachable from several