From 20375e30bfb3f111cb5fff0dd9bafae551ab542a Mon Sep 17 00:00:00 2001 From: TELVIN TEUM Date: Mon, 6 Jul 2026 10:27:50 +0300 Subject: [PATCH] =?UTF-8?q?feat(resume):=20add=20the=20Resume=20Pipeline?= =?UTF-8?q?=20=E2=80=94=20"continue=20exactly=20where=20you=20left=20off"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reopening a session now revalidates the repository against the state the session last saw and hands the agent a structured repository delta, so it resumes against verified repository reality instead of a stale transcript. The Claude Agent SDK persists the conversation (options.resume) but not filesystem/repository state; this platform service (owned by the app, like Memory/Search) closes that gap. Main process: - ResumeManager + delta.ts: per-session repository snapshots (HEAD + branch + content-free dirty hash), activation-time revalidation with a cheap short-circuit and a bounded deadline, and an argv-only git delta (ahead/behind, capped commit list, name-status merged with the dirty set, category flags incl. limboo.json, history-rewrite and root-change detection). Never blocks session switching. - Schema v10: session_snapshots + resume_deltas (persisted so a pending one-shot injection survives a restart). - Code intelligence (schema v11): search_files.content_hash skips unchanged files in incremental indexing; new search_refs regex import-edge table (parser-agnostic) powers importer counts; per-file symbol adds/removes diffed across a reindex. - Memory revalidation: memory_links now written on create/acceptProposal; memories whose linked files vanish are confidence-downgraded (x0.6, floor 0.1) and restored when the file returns. - Injection: AgentManager.resumeContextFor is a third context producer rendering a one-shot block, cached on the run record for recovery retries. - IPC: resume:getState/getDelta/dismiss/revalidate (string ids only; revalidate gated to the active session) + resume:state-changed; preload window.limboo.resume. Renderer (existing UI idioms only, pure-black tokens): - ResumeBanner, a "Revalidating..." header chip, ResumeDeltaDialog, useResumeStore; settings under Memory & Search (settings.resume, RESUME_LIMITS). Docs: deep subsystem doc (docs/architecture/subsystems/resume-pipeline.md), docs index + README updates, and a CLAUDE.md section. Verified: lint clean, renderer build + main/preload esbuild bundles clean, 25 delta assertions against a real git repo, and DDL/queries against a live SQLite engine. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 50 +- README.md | 57 +- docs/README.md | 1 + .../subsystems/resume-pipeline.md | 331 +++++++++++ src/main/db/database.ts | 57 ++ src/main/index.ts | 31 +- src/main/ipc/index.ts | 4 + src/main/ipc/memoryHandlers.ts | 11 + src/main/ipc/resumeHandlers.ts | 46 ++ src/main/managers/AgentManager.ts | 74 ++- src/main/managers/GitManager.ts | 11 + src/main/managers/SettingsManager.ts | 21 + src/main/managers/memory/MemoryManager.ts | 163 +++++- src/main/managers/resume/ResumeManager.ts | 542 ++++++++++++++++++ src/main/managers/resume/delta.ts | 334 +++++++++++ src/main/managers/search/SearchManager.ts | 120 +++- src/main/managers/search/refs.ts | 158 +++++ src/preload/index.ts | 20 + src/renderer/App.tsx | 5 + src/renderer/features/resume/ResumeBanner.tsx | 45 ++ .../features/resume/ResumeDeltaDialog.tsx | 201 +++++++ src/renderer/features/settings/catalog.tsx | 6 +- .../features/settings/panels/MemoryPanel.tsx | 63 +- .../features/workspace/CenterWorkspace.tsx | 14 + src/renderer/stores/useResumeStore.ts | 77 +++ src/shared/constants.ts | 34 +- src/shared/ipc-channels.ts | 8 + src/shared/types.ts | 104 ++++ 28 files changed, 2564 insertions(+), 24 deletions(-) create mode 100644 docs/architecture/subsystems/resume-pipeline.md create mode 100644 src/main/ipc/resumeHandlers.ts create mode 100644 src/main/managers/resume/ResumeManager.ts create mode 100644 src/main/managers/resume/delta.ts create mode 100644 src/main/managers/search/refs.ts create mode 100644 src/renderer/features/resume/ResumeBanner.tsx create mode 100644 src/renderer/features/resume/ResumeDeltaDialog.tsx create mode 100644 src/renderer/stores/useResumeStore.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3455ec0..2debb5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -276,7 +276,7 @@ window.limboo.system.{notify,openExternal,clipboardWrite,clipboardRead} window.limboo.app.getInfo() // version/electron/… window.limboo.events.onCommand(cb) // native menu/tray → command // …plus one namespace per platform service: workspace, session, agent, fs, -// terminal, git, worktree, services, memory, search, updates, voice +// terminal, git, worktree, services, memory, search, resume, updates, voice // (full surface: docs/reference/window-limboo-api.md) ``` @@ -606,11 +606,59 @@ ones at query time (memory, git, sessions, commands). filters, recent + saved searches. Backed by `useSearchStore`. Settings live in the **Memory & Search** category (`settings.search`). +### Resume Pipeline + +A provider-independent **platform service owned by the app** — "continue exactly +where you left off." SDK sessions persist the *conversation* (`options.resume`), +not repository state; this service reconciles the two. Owned entirely by the main +process (`managers/resume/ResumeManager.ts` + `resume/delta.ts`), wired in the +composition root like Memory/Search (setter injection + a *separate additive* +`sessions.onActiveChanged` listener, so the existing retarget path is untouched; +boot revalidation chains after `worktrees.recover().finally(retarget)`). + +- **Snapshots** (`session_snapshots`, schema v10) — one repository anchor per + session (HEAD + branch + a `dirty_hash` = sha256 over sorted + `status\0path\0size\0mtimeMs`, computed via `fs.lstat`, never reading file + contents), upserted at run-end (`AgentManager.send` finally → + `onRunFinished`), checkpoint creation (`GitManager.createCheckpoint` → + `onCheckpointCreated`), and session deactivation. +- **Revalidation** — on every activation + boot, async/best-effort, `Promise.race` + against `RESUME_LIMITS.revalidateTimeoutMs`; failures degrade to "no delta" and + **never block session switching**. Cheap short-circuit when HEAD + branch + + dirty-hash all match the snapshot (the common case). +- **Repository delta** (`resume/delta.ts`, argv-only via `runGit`; snap HEAD + regex-validated) — `cat-file -e`/`merge-base --is-ancestor` (rebase/gc → + `historyRewritten`), `rev-list --count` both ways, capped `git log`, `git diff + --name-status -z` (reuses `parseNameStatus`) merged with the dirty set, + categorized (manifest/lockfiles/**limboo.json**/migrations flagged). Persisted + in `resume_deltas` so the one-shot injection survives a restart. +- **Code-intelligence enrichment** — reuses the Search index: `search_files.content_hash` + (schema v11) skips unchanged files in incremental indexing; per-file symbol + adds/removes are diffed across a reindex; the new `search_refs` regex import-edge + table (`search/refs.ts`, parser-agnostic for a future tree-sitter upgrade) powers + "N files import X". No tree-sitter, no embeddings. +- **Memory revalidation** — `create`/`acceptProposal` now write `memory_links` + (`kind='file'`/`'symbol'`); on revalidation, memories whose linked files vanished + have confidence downgraded (×0.6, floor 0.1; `preDowngradeConfidence` stashed in + `meta`) and restored when the file returns. Retrieval already weights confidence. +- **Injection** — `AgentManager.resumeContextFor` is the third context producer + beside memory/search: renders a `` block (budgeted like the + others), one-shot per delta (marks the row `injected`), cached on the run record + so recovery retries re-inject the same block. +- **UI** — `ResumeBanner` (MissingWorktreeBanner idiom) + a "Revalidating…" header + chip + `ResumeDeltaDialog` (HooksConfirmDialog idiom), backed by `useResumeStore`; + results also land in the timeline via `AgentManager.recordStatus`. IPC: + `resume:getState/getDelta/dismiss/revalidate` (string session ids only; revalidate + gated to the active session) + `resume:state-changed`. Settings under the **Memory + & Search** category (`settings.resume`, bounds in `RESUME_LIMITS`). + **Still open / future** — Repository clone/track UI, a dedicated Permission System beyond the agent's `canUseTool`, merge-conflict resolution UI, remote management, and stash. Local vector embeddings on top of BM25 (both Memory and Search rankings are already fusion-ready) and recording File Writer mutations into the session activity timeline (today they land in the in-memory File History ring) are natural follow-ups. +A **tree-sitter upgrade** of the `search_symbols` / `search_refs` extractors (both +tables are parser-agnostic) would sharpen the resume symbol delta. --- diff --git a/README.md b/README.md index dac0728..4f78c0c 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,53 @@ project, its history, and its memory belong to the developer. git status into the session list. - **Local Memory System** — durable, provider-independent project knowledge with fully offline FTS5 / BM25 retrieval, injected into the agent prompt. +- **Resume Pipeline** — reopening a session revalidates the repository against the + state it last saw and hands the agent a structured **repository delta** (commits, + files, symbols, dependency manifests) so it continues against current reality, not + a stale transcript. Fully local, bounded git, never blocks switching. - **Unified streaming timeline** — one continuous, turn-grouped event stream of the conversation, tool calls, and status. - **Pure-black, dark-only UI** — a minimal three-pane shell tuned for a true `#000000` background. +## What makes Limboo different + +Most agent tools stop at "resume the conversation." That restores what the agent +*said*, but not the world it said it in. Limboo treats the environment as a +first-class, continuously-maintained system that cooperates with the agent instead +of leaving it to rediscover reality on every turn. + +- **The agent resumes against verified repository reality, not a transcript.** The + **Resume Pipeline** is the flagship of this idea. When you reopen a session — after + an hour or after three weeks of other people's commits, rebases, and dependency + bumps — Limboo revalidates the git worktree against the exact state the session + last saw and computes a structured *repository delta*: commits landed, files + changed (with dependency manifests and migrations flagged), symbols added or + removed, and which files import what changed. That delta is injected once, before + your next prompt, so the agent reconciles its assumptions up front instead of + burning turns re-reading the tree. It is fully local, uses only bounded argv-only + git, and never blocks you from switching sessions. See + [the Resume Pipeline](docs/architecture/subsystems/resume-pipeline.md). + +- **Isolation by default.** Every session can own its own git worktree — a real + isolated checkout and branch — so parallel work never collides, and "continue where + I left off" means a specific filesystem, not a shared one. + +- **The app owns the knowledge, not the model.** Durable project memory, the search + index, and now a symbol/dependency graph are platform services the app maintains + and injects — provider-independent and offline. Memory even references repository + symbols, so guidance whose code was deleted is automatically demoted until it + reappears. + +- **A hard security boundary you can trust.** The renderer performs nothing: all + filesystem, git, shell, and database access lives in the main process behind a + single typed IPC bridge with sender validation, deny-by-default permissions, and + parameterized SQL throughout. Git never runs through a shell; paths are guarded + against traversal; secrets are never stored. + +The result: continuation feels genuinely intelligent, because the agent begins each +resumed task with an accurate, up-to-date understanding of a living codebase. + ## Screenshots A tour of the shell: sessions on the left, the conversation in the center, and @@ -169,6 +211,9 @@ The documentation is organized as a subsystem, not a single file. Start at the [memory system](docs/guides/memory-system.md), [terminal](docs/guides/terminal.md), [keyboard shortcuts](docs/guides/keyboard-shortcuts.md). +- **Deep dives** — [the Resume Pipeline](docs/architecture/subsystems/resume-pipeline.md) + ("continue exactly where you left off") and the other + [subsystem docs](docs/architecture/subsystems/agent-manager.md). - **Reference** — [`window.limboo` API](docs/reference/window-limboo-api.md), [IPC channels](docs/reference/ipc-channels.md), [settings](docs/reference/settings.md), @@ -186,11 +231,13 @@ for contributors: [`CLAUDE.md`](CLAUDE.md) (the code-level working contract) and ## Project status Limboo is at `1.0.0`. The desktop foundation and platform services are built: -workspaces, sessions, the git engine, the integrated terminal, the File System -Layer, agent orchestration, the Local Memory System, and a hardened IPC layer over -a 14-table SQLite database. Planned work (repository clone/track UI, a standalone -permission system, a dedicated search engine, merge-conflict resolution, remote -management, and stash) is tracked in [ROADMAP.md](ROADMAP.md). +workspaces, sessions with per-session git worktrees, the git engine, the integrated +terminal, the File System Layer, agent orchestration, the Local Memory System, the +Search Engine, the Resume Pipeline ("continue exactly where you left off"), and a +hardened IPC layer over a local SQLite database. Planned work (repository clone/track +UI, a standalone permission system, merge-conflict resolution, remote management, +stash, and a tree-sitter / vector-embeddings upgrade of the code-intelligence layer) +is tracked in [ROADMAP.md](ROADMAP.md). ## Contributing diff --git a/docs/README.md b/docs/README.md index f3e7eff..b45ef8c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -73,6 +73,7 @@ architecture vision). [File System Layer](architecture/subsystems/file-system-layer.md), [Agent Manager](architecture/subsystems/agent-manager.md), [Memory System](architecture/subsystems/memory-system.md), + [Resume Pipeline](architecture/subsystems/resume-pipeline.md), [Database](architecture/subsystems/database.md), [Settings](architecture/subsystems/settings.md). diff --git a/docs/architecture/subsystems/resume-pipeline.md b/docs/architecture/subsystems/resume-pipeline.md new file mode 100644 index 0000000..7172fc8 --- /dev/null +++ b/docs/architecture/subsystems/resume-pipeline.md @@ -0,0 +1,331 @@ +# Subsystem: Resume Pipeline + +## Purpose + +The Resume Pipeline is what makes reopening a session mean **"continue exactly +where you left off"** rather than merely replaying a transcript. The Claude Agent +SDK persists the *conversation* — prompts, tool calls, results, and reasoning — and +resumes it through `options.resume` +([SDK sessions](https://code.claude.com/docs/en/agent-sdk/sessions)). But the SDK +explicitly does **not** reconstruct filesystem or repository state; it recommends +that application-level systems own that. A conversation records *why* decisions were +made. It cannot tell the agent whether another session committed to the same branch, +whether the history was rebased, whether a dependency manifest changed, or whether a +function it relied on was deleted while the session was inactive. + +The Resume Pipeline closes that gap. On every session activation it revalidates the +repository against the state the session last saw, computes a structured **repository +delta**, and hands the agent a one-shot context block so it resumes against verified +repository reality instead of remembered assumptions. It is a provider-independent +platform service owned by the app — like the [Memory System](memory-system.md) and +the Search Engine — and it is fully local: every git operation is argv-only, +bounded, and offline. + +Source: +[`src/main/managers/resume/ResumeManager.ts`](../../../src/main/managers/resume/ResumeManager.ts) +and +[`src/main/managers/resume/delta.ts`](../../../src/main/managers/resume/delta.ts). + +## Design principle: cooperation, not replacement + +The pipeline never tries to reconstruct engineering context from a single source. +Each subsystem remembers a different thing, and resume synchronizes them: + +| Subsystem | Remembers | +| --- | --- | +| Claude Agent SDK | what the agent *thought* (conversation) | +| Git | how the repository *evolved* (commits, trees) | +| Search index | *where* relevant symbols live | +| `search_refs` graph | *how* files depend on each other | +| Local Memory | *why* architectural decisions were made | +| Worktree | the isolated filesystem the task lives in | + +Git is treated as the authoritative source of repository evolution — object identity +via commit hashes and trees — never inferred from conversation. + +## Responsibilities + +- Record a **repository anchor** per session (a snapshot) at meaningful moments. +- On activation, **revalidate** the repo against that anchor (cheap short-circuit + when nothing changed) without ever blocking session switching. +- Compute a structured **repository delta** when the repo diverged. +- Enrich the delta with **code intelligence**: per-file symbol adds/removes and + importer counts. +- **Downgrade** memory confidence when referenced files disappear; restore it when + they return. +- Inject a one-shot `` block into the next agent prompt. + +## Placement and wiring + +The manager is constructed in the composition root +([`src/main/index.ts`](../../../src/main/index.ts)) after Search and Memory, and +wired with the same setter-injection pattern used by other platform services: + +- `agent.setResumeManager(resume)` — context injection + the run-end snapshot signal. +- `git.setResumeManager(resume)` — a snapshot signal after each checkpoint. +- `resume.setSearchManager(search)` / `resume.setMemoryManager(memory)` — enrichment + and memory revalidation. +- `resume.setSessionRootResolver(...)` — the same worktree-aware effective-root + resolver the Agent / Terminal / Git / Search managers use. +- `resume.setStatusRecorder((id, label, detail) => agent.recordStatus(...))` — so + revalidation results land in the session timeline via `agent_activity`. +- `sessions.onActiveChanged((s) => resume.onActiveSessionChanged(s))` — a **separate, + additive** listener registered next to the existing effective-root retarget + listener, so the retarget path (`retargetEffectiveRoot`) is untouched. +- Boot revalidation chains after worktree recovery: + `worktrees.recover().finally(() => { retargetEffectiveRoot(); resume.onBoot(); })` + — so a repaired or missing worktree never produces a bogus delta. + +Every entry point is fire-and-forget. Revalidation runs asynchronously and is +deadline-bounded; a timeout or git failure degrades to "no delta" and never blocks +the switch or surfaces an error. + +## Storage + +Two tables own the pipeline's durable state (schema v10; see +[the database](database.md)): + +- `session_snapshots` — one repository anchor per session (primary key + `session_id`, upserted). Columns: `workspace_id`, `root`, `head` (NULL for a + non-git or unborn HEAD), `branch` (NULL when detached), `dirty_hash`, + `dirty_files` (a capped JSON summary), `reason`, and timestamps. +- `resume_deltas` — the last computed delta per session (primary key `session_id`), + with `status` (`pending` | `injected` | `dismissed`) and the delta as JSON. + Persisting it means a pending injection survives an app restart between detection + and the next prompt. + +The Search Engine gains two schema-v11 additions the pipeline reuses (see +[Code-intelligence enrichment](#code-intelligence-enrichment)): a +`search_files.content_hash` column and a `search_refs` dependency table. + +All access is parameterized; no value is ever interpolated into SQL. + +## Snapshots + +A snapshot is the session's repository anchor, captured through the effective +execution root (the worktree checkout when the session owns one, else the workspace +path): + +1. `git rev-parse --verify HEAD` -> `head` (NULL on a non-git or unborn repo). +2. `git rev-parse --abbrev-ref HEAD` -> `branch` (`HEAD` means detached -> NULL). +3. `git status --porcelain=v1 -z` -> dirty entries, from which a **dirty hash** is + derived: a SHA-256 over the sorted `status\0path\0size\0mtimeMs` lines, using + `fs.lstat` for size and mtime. This detects content drift in an already-dirty + working tree **without ever reading file contents**. Each `lstat` target is + validated inside the root first (`isInsideRoot`), and the entry set is capped by + `RESUME_LIMITS.maxDirtyEntries` (an overflow marker keeps the hash stable). + +Snapshots are upserted (`INSERT ... ON CONFLICT(session_id) DO UPDATE`) at four +capture points: + +- **Run end** — `AgentManager.send()` finally block (`onRunFinished`): the agent may + have changed the repo. +- **Checkpoint** — after `GitManager.createCheckpoint` succeeds (`onCheckpointCreated`). + Snapshots never create checkpoints, so there is no recursion. +- **Deactivation** — the session being switched away from is anchored before the new + one is revalidated. +- **After revalidation** — a fresh anchor makes each delta one-shot (the next + activation short-circuits clean while the pending row waits for the first prompt). + +A quit-time snapshot is deliberately not attempted — async git during `before-quit` +is unreliable, and the four capture points already cover the important transitions. +The safe failure direction is to over-report the last uncommitted edits, not to miss +a change. + +## Revalidation + +`revalidate(sessionId)` runs on every activation and at boot. It is wrapped in a +`Promise.race` against `RESUME_LIMITS.revalidateTimeoutMs` (10 s); any failure lands +on phase `idle` with a logged warning and no UI. The flow: + +1. Gate on `settings.resume.enabled`. Broadcast phase `checking`. +2. **No snapshot yet** -> capture one (`activate`), phase `clean`. First-ever visit + never produces a delta. +3. **Optional freshness skip** — if `settings.resume.staleThresholdDays > 0` and the + snapshot is newer than that window, short-circuit `clean` (0 = always revalidate). +4. **Root changed** — the effective root differs from the snapshot root (worktree + recreated or detached): emit a delta with `rootChanged: true`, skip the commit + ranges (they would be meaningless), and re-anchor. +5. **Cheap short-circuit** — read the current head + branch + dirty hash; if all + three equal the snapshot, clear any stale pending delta and finish `clean`. This + is the overwhelmingly common case and costs roughly two `rev-parse` calls plus one + `status`. +6. Otherwise compute the delta, enrich it, persist it `pending`, broadcast phase + `delta`, record a timeline entry, and re-anchor. + +A dirty-hash-only wobble with no visible file/commit change (e.g. an mtime touch) is +treated as `clean` — it re-anchors but surfaces nothing. + +## Delta computation + +[`delta.ts`](../../../src/main/managers/resume/delta.ts) is a set of pure functions +over the shared `runGit` runner. The snapshot HEAD is validated against +`/^[0-9a-f]{4,64}$/i` before it ever enters an argv, as defense in depth on top of +argv-only execution. The algorithm: + +1. `git cat-file -e ^{commit}` — a miss means the snapshot commit was garbage + collected (typically after a rebase) -> `historyRewritten: true`, carry only + branch/dirty information. +2. `git merge-base --is-ancestor HEAD` — a non-zero exit means the snapshot is + no longer an ancestor (rebase or amend) -> `historyRewritten: true`. +3. `git rev-list --count ..HEAD` and `HEAD..` -> exact ahead/behind + counts. +4. `git log --format=%H%x1f%s%x1f%an%x1f%ct -n ..HEAD` -> + the capped commit list (subjects clipped), parsed on the 0x1f field separator. +5. `git diff --name-status -z HEAD` -> committed file changes, parsed by the + shared `parseNameStatus`, then merged with the current dirty entries (committed + status wins on the same path). Capped at `RESUME_LIMITS.maxFilesInDelta`, with the + true total kept separately. +6. Each path is **categorized**: `manifest` (package.json, lockfiles, Cargo/Go/Python + manifests, and **`limboo.json`** — a change here matters because of the + [config trust gate](../../reference/limboo-json.md)), `migration` (any + `/migrations/` segment), `config` (tsconfig, eslintrc, vite/forge configs), + `doc`, or `source`/`other`. Manifest and migration paths are surfaced prominently. + +Overflow degrades gracefully: the exact counts are always reported, and the capped +lists show heads with an "... and N more" tail. Every git call inherits the 15 s +`runGit` timeout, and the whole pass is bounded by the 10 s revalidation deadline. + +## Code-intelligence enrichment + +When the Search Engine is enabled, the delta is enriched using the local index — +no tree-sitter, no embeddings, no network. Two schema-v11 additions make this cheap: + +- **`search_files.content_hash`** — a SHA-256 of the (size-capped) file content + (path-only rows use a `size:mtimeMs` surrogate). During incremental indexing an + unchanged file is skipped entirely: no delete-then-insert, no FTS churn. +- **`search_refs`** — one row per import/require/use edge, extracted by a regex + per-language extractor + ([`search/refs.ts`](../../../src/main/managers/search/refs.ts)) during the same + indexing pass that owns `search_symbols`. Relative specifiers are resolved to + workspace paths by pure path math against the already-indexed path set — no + filesystem I/O, and any `..` escape is rejected. The table is parser-agnostic, so a + future tree-sitter extractor can repopulate it without a schema change. + +From these the pipeline derives, for the changed source files (bounded by +`FS_LIMITS.incrementalIndexMax`): + +- **`refImpacts`** — how many files import each changed/deleted file ("3 files import + `src/shared/types.ts`"). This works regardless of index timing because it queries + the current reference graph. +- **`symbols`** — per-file symbol adds/removes, computed by capturing the indexed + symbol identities before a reindex, reindexing the changed files, and diffing + against the identities after. Best-effort: because the index is continuously + maintained, this yields results primarily when the index lagged the change, and is + simply empty otherwise. + +## Memory revalidation + +The [Memory System](memory-system.md)'s previously write-only `memory_links` table +becomes active. `create` and `acceptProposal` now write links: a `kind='file'` link +for a memory's `filePath`, and `kind='symbol'` links (`path#name`) for its +`symbolRefs`. All paths are validated (bounded, relative, no traversal, no NUL) at +the IPC boundary and re-normalized in the manager. + +During revalidation, memories whose linked files were deleted have their confidence +scaled by 0.6 (floored at 0.1); the pre-downgrade value is stashed in the memory's +`meta` so it can be restored verbatim when the file reappears at a later revalidation. +Because retrieval already weights confidence, a downgraded memory naturally sinks in +ranking without any special-casing — obsolete engineering guidance stops surfacing +just because it once appeared in a conversation. + +## Context injection + +`AgentManager.resumeContextFor(sessionId)` is a third context producer alongside the +memory and search producers. It reads the `pending` delta, renders a +`` system-prompt block within `RESUME_LIMITS.injectCharBudget` +(the same trim-loop budgeting as the memory/search blocks), and marks the persisted +row `injected` — one delta, one injection. The rendered block is cached on the +in-flight run record so a recovery retry (`runWithRecovery`) re-injects the *same* +block rather than losing it. All three producers are joined with blank-line +separators into a single `systemPrompt.append` on the Claude Code preset, so this +never interacts with `options.resume` or the corrupted-resume self-heal. + +The block leads with an advisory ("reconcile your assumptions... re-read anything you +depend on"), then branch/HEAD movement, the capped commit subjects, changed files +tagged by category, a dependency-manifest warning line, any symbol changes and +importer counts, downgraded memories, and an uncommitted-changes note. + +## IPC surface + +Registered through the `handle()` wrapper (so every call inherits sender-origin +validation). The whole surface takes **string session ids only** — no +renderer-supplied object crosses the boundary, so there is no prototype-pollution +surface. + +- `resume:getState` (sessionId) -> `ResumeState`. +- `resume:getDelta` (sessionId) -> `RepoDelta | null` (the persisted row, pending or + injected, so the detail dialog works after injection). Never recomputed on demand. +- `resume:dismiss` (sessionId) -> void — drops the pending injection. +- `resume:revalidate` (sessionId) -> void — additionally gated to the **active** + session in the main process. +- Event: `resume:state-changed` pushes each `ResumeState` transition. + +Exposed on the preload bridge as the `window.limboo.resume` namespace; see the +[`window.limboo` API](../../reference/window-limboo-api.md). + +## User interface + +Matching the existing shell idioms exactly (pure-black tokens, no new patterns): + +- **`ResumeBanner`** — a row under the session header cloning the missing-worktree + banner (`h-9`, `border-b border-line bg-surface`): an info tone for ordinary + drift, a warning tone for a rewrite or root change. "Review" opens the detail + dialog; a dismiss control drops the injection. +- **Revalidating chip** — a "Revalidating..." pill (the plan-ready chip classes plus + a spinner) in the session header while the phase is `checking`. Activation stays + non-blocking; the composer is never disabled. +- **`ResumeDeltaDialog`** — a modal on the hooks-confirmation idiom with branch/HEAD + movement, the commit list, files grouped by category (manifest/migration + highlighted), and any symbol changes. + +Backed by `useResumeStore`, which subscribes to `resume:state-changed` and hydrates +per session. Revalidation results are also recorded in the Activity timeline. + +## Settings + +Under the **Memory & Search** settings category (`settings.resume`, bounds in +`RESUME_LIMITS`): + +- `enabled` — master switch for snapshots, revalidation, and the delta UI. +- `injectDelta` — inject the pending delta into the next prompt. +- `maxCommitsInDelta` — how many commit subjects the delta lists (counts stay exact). +- `staleThresholdDays` — skip revalidation for sessions touched within this window + (0 = always). + +See the [settings reference](../../reference/settings.md). + +## Guarantees and edge cases + +- **Never blocks switching** — every entry point is async and fire-and-forget; + revalidation is deadline-bounded and degrades to "no delta" on any failure. +- **Plain (non-worktree) sessions** share the workspace checkout, so they can see + deltas produced by sibling sessions' work. This is correct: the repository really + did change under them. +- **Non-git roots** — `rev-parse` fails, the snapshot stores `head = NULL`, and + revalidation always short-circuits `clean`. No delta, no error. +- **History rewrite / gc** — detected via `cat-file -e` / `merge-base + --is-ancestor`; the delta is flagged `historyRewritten` and avoids + unrelated-histories range diffs. +- **Recovery retries** — the rendered delta block is cached on the run record, so a + retry re-injects the same block (one delta, one injection). +- **Boot ordering** — revalidation runs strictly after worktree recovery settles; + the effective-root retarget path, the corrupted-resume self-heal, checkpoint + creation, and the incremental-vs-full index routing are all untouched. + +## Security boundary + +Argv-only git through the shared runner (no `shell: true`); the snapshot HEAD is +regex-validated before entering any argv; all SQL is parameterized; `lstat` targets +are `isInsideRoot`-guarded; renderer inputs are string session ids only, and memory +link paths are validated and normalized. Commit subjects and paths are never written +to the log (counts only). See the [security model](../security-model.md). + +## Planned + +A **tree-sitter** upgrade of the `search_symbols` / `search_refs` extractors — both +tables are parser-agnostic by design — would sharpen the symbol delta and enable a +true call/inheritance graph. A local **vector-embeddings** layer fused on top of BM25 +(the Memory and Search rankings are already fusion-ready) would let the injected +context prioritize by conceptual relevance. Both are tracked in +[ROADMAP.md](../../../ROADMAP.md). diff --git a/src/main/db/database.ts b/src/main/db/database.ts index 25add79..f38e14f 100644 --- a/src/main/db/database.ts +++ b/src/main/db/database.ts @@ -196,6 +196,37 @@ function migrate(database: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_git_checkpoints_session ON git_checkpoints (session_id, created_at DESC); + -- Resume Pipeline (schema v10) — one repository anchor per session, + -- upserted at meaningful moments (run end, checkpoint, deactivation). + -- head/branch are NULL when the effective root is not a git repo (or the + -- branch is detached). dirty_files is a capped JSON array of {path,status}; + -- dirty_hash is a sha256 over the sorted dirty entries + -- (status|path|size|mtimeMs) so content drift in an already-dirty tree is + -- detected without ever reading file contents. All values bound. + CREATE TABLE IF NOT EXISTS session_snapshots ( + session_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + root TEXT NOT NULL, + head TEXT, + branch TEXT, + dirty_hash TEXT NOT NULL DEFAULT '', + dirty_files TEXT NOT NULL DEFAULT '[]', + reason TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + -- The last computed repository delta per session, persisted so the + -- one-shot prompt injection survives an app restart between detection and + -- the next prompt. status: 'pending' | 'injected' | 'dismissed'. + -- delta is a JSON RepoDelta built entirely in the main process. + CREATE TABLE IF NOT EXISTS resume_deltas ( + session_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + delta TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + -- Local Memory System — durable, provider-independent project knowledge. -- workspace_id is NULL for global/user-scope memories (e.g. preferences). -- Only rows with status='active' are ever injected into an agent prompt; @@ -382,6 +413,27 @@ function migrate(database: Database.Database): void { ); CREATE INDEX IF NOT EXISTS idx_saved_searches_scope ON saved_searches (workspace_id, created_at DESC); + + -- Lightweight dependency / reference layer (schema v11) — one row per + -- import/require/use edge, extracted by regex during the same indexing pass + -- that owns search_symbols. Parser-agnostic: a future tree-sitter extractor + -- can repopulate the same columns without a schema change. The ref column is + -- the raw module specifier; ref_path is the workspace-relative resolution + -- when the specifier is relative (NULL for bare/package specifiers). Bounded + -- per file by SEARCH_LIMITS. All values bound, never string-interpolated. + CREATE TABLE IF NOT EXISTS search_refs ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + src_path TEXT NOT NULL, + ref TEXT NOT NULL, + ref_path TEXT, + kind TEXT NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_search_refs_src + ON search_refs (workspace_id, src_path); + CREATE INDEX IF NOT EXISTS idx_search_refs_target + ON search_refs (workspace_id, ref_path); `); // Idempotent column additions for databases created before a column existed. @@ -398,6 +450,11 @@ function migrate(database: Database.Database): void { addColumnIfMissing(database, 'sessions', 'base_ref', 'TEXT'); addColumnIfMissing(database, 'sessions', 'folder', 'TEXT'); addColumnIfMissing(database, 'sessions', 'tags', "TEXT NOT NULL DEFAULT '[]'"); + // Code Intelligence (schema v11) — a content hash per indexed file so the + // incremental pass can skip files whose bytes are unchanged (no FTS churn) + // and the resume delta engine can detect real content change. Path-only rows + // (binary/oversize) store a cheap "size:mtimeMs" surrogate instead. + addColumnIfMissing(database, 'search_files', 'content_hash', "TEXT NOT NULL DEFAULT ''"); const current = database .prepare('SELECT value FROM meta WHERE key = ?') diff --git a/src/main/index.ts b/src/main/index.ts index 8d90f5c..df08452 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -27,6 +27,7 @@ import { ServiceManager } from './managers/services/ServiceManager'; import { ProxyServer } from './managers/services/ProxyServer'; import { MemoryManager } from './managers/memory/MemoryManager'; import { SearchManager } from './managers/search/SearchManager'; +import { ResumeManager } from './managers/resume/ResumeManager'; import { AutoUpdateManager } from './managers/AutoUpdateManager'; import { VoiceManager } from './managers/voice/VoiceManager'; import { VoiceModelManager } from './managers/voice/VoiceModelManager'; @@ -87,6 +88,7 @@ function bootstrap(): void { let memory: MemoryManager; let attachments: AttachmentManager; let search: SearchManager; + let resume: ResumeManager; let updates: AutoUpdateManager; let voiceModels: VoiceModelManager; let voice: VoiceManager; @@ -143,6 +145,11 @@ function bootstrap(): void { // file/symbol index and federates every other subsystem behind one query // interface; also the primary context provider for the coding agent. search = new SearchManager(settings, workspace); + // The Resume Pipeline — repository revalidation when a session is activated. + // Anchors each session's repo state (snapshot), detects divergence on + // activation, and hands the agent a one-shot repository delta. A platform + // service like Memory/Search; never blocks session switching. + resume = new ResumeManager(workspace, sessions, settings); // In-app updater (electron-updater + GitHub releases). No-op in dev / non-AppImage. updates = new AutoUpdateManager(settings, notifications); // The agent mirrors its shell commands into the integrated terminal. @@ -164,6 +171,16 @@ function bootstrap(): void { search.setGitManager(git); search.setSessionManager(sessions); agent.setSearchManager(search); + // The agent consumes the pending repository delta (one-shot per divergence) + // and re-anchors the snapshot at the end of every run; checkpoints re-anchor + // too. Revalidation results land in the session timeline via recordStatus. + agent.setResumeManager(resume); + git.setResumeManager(resume); + resume.setSearchManager(search); + resume.setMemoryManager(memory); + resume.setStatusRecorder((sessionId, label, detail) => + agent.recordStatus(sessionId, label, detail), + ); // The File System Layer pushes live git status (branch + diff) into sessions // and notifies the Git workspace whenever the working tree changes. fileSystem.setSessionManager(sessions); @@ -175,6 +192,7 @@ function bootstrap(): void { // search scope). The resolvers are cheap, synchronous DB lookups. agent.setSessionRootResolver((sessionId) => worktrees.resolveSessionRoot(sessionId)); terminal.setSessionRootResolver((sessionId) => worktrees.resolveSessionRoot(sessionId)); + resume.setSessionRootResolver((sessionId) => worktrees.resolveSessionRoot(sessionId)); git.setActiveRootResolver((workspaceId) => worktrees.resolveActiveRoot(workspaceId)); search.setActiveRootResolver((workspaceId) => worktrees.resolveActiveRoot(workspaceId)); // The Voice subsystem — local speech (sherpa-onnx) as another input/output @@ -200,6 +218,7 @@ function bootstrap(): void { memory, attachments, search, + resume, updates, voice, voiceModels, @@ -248,6 +267,10 @@ function bootstrap(): void { // session) retarget the same way — the SessionManager only emits when the // active session's execution root could actually differ. sessions.onActiveChanged(() => retargetEffectiveRoot()); + // Resume Pipeline: a SEPARATE, additive listener — anchor the session being + // left, revalidate the one being entered. Fire-and-forget; the retarget + // path above is untouched and never waits on git. + sessions.onActiveChanged((active) => resume.onActiveSessionChanged(active)); // Before a worktree directory is removed, fully release the watcher handles // inside it (Windows EBUSY) — the post-removal broadcast retargets afresh. worktrees.setReleaseRootHook(async () => { @@ -261,7 +284,13 @@ function bootstrap(): void { void worktrees .recover() .catch((err) => logger.warn('worktree recovery failed', err)) - .finally(() => retargetEffectiveRoot()); + .finally(() => { + retargetEffectiveRoot(); + // Boot-time revalidation of the session that comes back active — only + // after worktree recovery settled so a repaired/missing worktree never + // produces a bogus delta. Async, best-effort, never awaited. + resume.onBoot(); + }); if (initialWs) memory.seedDefaults(initialWs.id); // Low-frequency memory maintenance (decay/flag stale entries). Off the hot diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 4427d7f..f99ccf9 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -14,6 +14,7 @@ import type { ServiceManager } from '../managers/services/ServiceManager'; import type { MemoryManager } from '../managers/memory/MemoryManager'; import type { AttachmentManager } from '../managers/attachments/AttachmentManager'; import type { SearchManager } from '../managers/search/SearchManager'; +import type { ResumeManager } from '../managers/resume/ResumeManager'; import type { AutoUpdateManager } from '../managers/AutoUpdateManager'; import type { VoiceManager } from '../managers/voice/VoiceManager'; import type { VoiceModelManager } from '../managers/voice/VoiceModelManager'; @@ -31,6 +32,7 @@ import { registerServiceHandlers } from './serviceHandlers'; import { registerMemoryHandlers } from './memoryHandlers'; import { registerAttachmentHandlers } from './attachmentHandlers'; import { registerSearchHandlers } from './searchHandlers'; +import { registerResumeHandlers } from './resumeHandlers'; import { registerUpdateHandlers } from './updateHandlers'; import { registerVoiceHandlers } from './voiceHandlers'; @@ -48,6 +50,7 @@ export interface IpcDeps { memory: MemoryManager; attachments: AttachmentManager; search: SearchManager; + resume: ResumeManager; updates: AutoUpdateManager; voice: VoiceManager; voiceModels: VoiceModelManager; @@ -68,6 +71,7 @@ export function registerAllIpc(deps: IpcDeps): void { registerMemoryHandlers(deps.memory); registerAttachmentHandlers(deps.attachments); registerSearchHandlers(deps.search); + registerResumeHandlers(deps.resume, deps.session); registerUpdateHandlers(deps.updates); registerVoiceHandlers(deps.voice, deps.voiceModels, deps.settings); } diff --git a/src/main/ipc/memoryHandlers.ts b/src/main/ipc/memoryHandlers.ts index 0e74126..ca43e8d 100644 --- a/src/main/ipc/memoryHandlers.ts +++ b/src/main/ipc/memoryHandlers.ts @@ -102,6 +102,15 @@ export function registerMemoryHandlers(memory: MemoryManager): void { if (typeof input?.body !== 'string' || input.body.length > MEMORY_LIMITS.bodyMax) { throw new Error('memory: invalid body'); } + // Source references (Resume Pipeline back-links) are re-validated in the + // manager; here we only length/shape-gate what the renderer may pass. + const filePath = + typeof input.filePath === 'string' && input.filePath.length <= 4096 + ? input.filePath + : null; + const symbolRefs = Array.isArray(input.symbolRefs) + ? input.symbolRefs.filter((s): s is string => typeof s === 'string' && s.length <= 4096).slice(0, 50) + : undefined; return memory.create({ workspaceId: input.workspaceId ?? null, tier: input.tier, @@ -109,6 +118,8 @@ export function registerMemoryHandlers(memory: MemoryManager): void { body: input.body, pinned: !!input.pinned, sessionId: typeof input.sessionId === 'string' ? input.sessionId : null, + filePath, + symbolRefs, }); }); diff --git a/src/main/ipc/resumeHandlers.ts b/src/main/ipc/resumeHandlers.ts new file mode 100644 index 0000000..2822058 --- /dev/null +++ b/src/main/ipc/resumeHandlers.ts @@ -0,0 +1,46 @@ +/** + * IPC handlers for the Resume Pipeline. Registered through `handle()`, so every + * call inherits sender-origin validation. The entire surface takes **string + * session ids only** (length-capped here in the main process, CLAUDE.md §6) — + * no renderer-supplied objects ever cross this boundary, so there is no + * prototype-pollution surface. `revalidate` is additionally restricted to the + * active session: the renderer may nudge a re-check of what it is looking at, + * never drive git work against arbitrary sessions. + */ +import { IpcChannels } from '@shared/ipc-channels'; +import { SESSION_LIMITS } from '@shared/constants'; +import type { RepoDelta, ResumeState } from '@shared/types'; +import { handle } from './registry'; +import type { ResumeManager } from '../managers/resume/ResumeManager'; +import type { SessionManager } from '../managers/SessionManager'; + +function assertSessionId(id: unknown): asserts id is string { + if (typeof id !== 'string' || id.length === 0 || id.length > SESSION_LIMITS.idMax) { + throw new Error('resume: invalid session id'); + } +} + +export function registerResumeHandlers(resume: ResumeManager, sessions: SessionManager): void { + handle(IpcChannels.resumeGetState, (_e, sessionId: unknown): ResumeState => { + assertSessionId(sessionId); + return resume.getState(sessionId); + }); + + handle(IpcChannels.resumeGetDelta, (_e, sessionId: unknown): RepoDelta | null => { + assertSessionId(sessionId); + return resume.getDelta(sessionId); + }); + + handle(IpcChannels.resumeDismiss, (_e, sessionId: unknown): void => { + assertSessionId(sessionId); + resume.dismiss(sessionId); + }); + + handle(IpcChannels.resumeRevalidate, (_e, sessionId: unknown): void => { + assertSessionId(sessionId); + if (sessionId !== sessions.getActive()?.id) { + throw new Error('resume: revalidate is limited to the active session'); + } + void resume.revalidate(sessionId); + }); +} diff --git a/src/main/managers/AgentManager.ts b/src/main/managers/AgentManager.ts index 7d6be32..2577033 100644 --- a/src/main/managers/AgentManager.ts +++ b/src/main/managers/AgentManager.ts @@ -74,6 +74,7 @@ import type { TerminalManager } from './TerminalManager'; import type { SessionManager } from './SessionManager'; import type { GitManager } from './GitManager'; import type { MemoryManager } from './memory/MemoryManager'; +import type { ResumeManager } from './resume/ResumeManager'; import { createMemoryMcpServer } from './memory/memoryTools'; import type { AttachmentManager } from './attachments/AttachmentManager'; import type { SearchManager } from './search/SearchManager'; @@ -349,6 +350,12 @@ interface ActiveRun { planCaptured?: boolean; /** Attachments riding this turn (manifest + vision blocks; reused on retry). */ attachmentIds?: string[]; + /** + * The `` block consumed for this run. Cached here because + * consuming marks the persisted row 'injected' (one-shot) while recovery + * retries recompose the context — the retry must re-inject the SAME block. + */ + resumeContext?: string; } export class AgentManager { @@ -474,6 +481,18 @@ export class AgentManager { this.memory = memory; } + /** Resume Pipeline, wired after construction (repository-delta injection). */ + private resume: ResumeManager | null = null; + + /** + * Inject the Resume Manager. Resume is a platform service owned by the app — + * the agent only *consumes* it: the one-shot `` block on + * the first prompt after a divergence, plus the run-end snapshot signal. + */ + setResumeManager(resume: ResumeManager): void { + this.resume = resume; + } + /** Attachment Manager, wired after construction (session-owned staged files). */ private attachments: AttachmentManager | null = null; @@ -571,6 +590,33 @@ export class AgentManager { } } + /** + * Build the system-prompt addition that injects the pending repository delta + * (repo changes since this session's last snapshot). One-shot per delta: the + * first consumption marks the persisted row 'injected'; the rendered block is + * cached on the in-flight run so recovery retries re-inject the same block. + * Fully local and best-effort: a failure never blocks the run. + */ + private resumeContextFor(sessionId: string): string | undefined { + if (!this.resume) return undefined; + const cfg = this.settings.getAll().resume; + if (!cfg.enabled || !cfg.injectDelta) return undefined; + try { + const run = this.runs.get(sessionId); + if (run?.resumeContext) return run.resumeContext; + const block = this.resume.consumePendingDelta(sessionId); + if (block) { + if (run) run.resumeContext = block; + this.diag('request', 'debug', 'Injected repository delta', undefined, sessionId); + this.pushActivity(sessionId, 'status', 'Injected repository delta', undefined, 'info'); + } + return block; + } catch (err) { + logger.warn('resume: context build failed', err); + return undefined; + } + } + /** * Create one automatic checkpoint per run, the first time the agent performs a * write/command, so the pre-edit state is always recoverable. Fire-and-forget: @@ -1197,6 +1243,9 @@ export class AgentManager { } finally { const captured = this.runs.get(sessionId)?.planCaptured; this.runs.delete(sessionId); + // Re-anchor the session's repository snapshot — the agent may have + // changed the repo. Fire-and-forget; never delays run teardown. + this.resume?.onRunFinished(sessionId); // A plan run that ended without presenting a plan (error/cancel) must not // leave the panel stuck "analyzing" — settle it back to a rejected state. if (isPlan && !captured) { @@ -1405,11 +1454,14 @@ export class AgentManager { const sdk = await loadSdk(); const { query } = sdk; // Compose the injected context: durable Memory knowledge + the Search - // Engine's ranked retrieval for this prompt. Both are appended to the Claude - // Code preset via a single systemPrompt.append (blank-line separated). + // Engine's ranked retrieval + the one-shot repository delta for this + // prompt. All are appended to the Claude Code preset via a single + // systemPrompt.append (blank-line separated). const memoryContext = this.memoryContextFor(sessionId, prompt); const searchContext = this.searchContextFor(sessionId, prompt); - const injectedContext = [memoryContext, searchContext].filter(Boolean).join('\n\n') || undefined; + const resumeContext = this.resumeContextFor(sessionId); + const injectedContext = + [memoryContext, searchContext, resumeContext].filter(Boolean).join('\n\n') || undefined; const options = this.buildOptions(sessionId, cwd, abort, agent, permMode, injectedContext); // Expose a live, read-only view of the Local Memory System so the agent can // actually list/search the developer's memories on demand (the injected @@ -2338,6 +2390,22 @@ export class AgentManager { this.pushEvent({ kind: 'activity', sessionId, item }); } + /** + * Public timeline recorder for platform services without their own activity + * feed (e.g. the Resume Pipeline logging a revalidation result). Delegates to + * the same `pushActivity` path so the entry lands in `agent_activity` and, + * therefore, in the session timeline automatically. + */ + recordStatus(sessionId: string, label: string, detail?: string): void { + this.pushActivity( + sessionId, + 'status', + label.slice(0, ACTIVITY_LIMITS.labelMax), + detail?.slice(0, ACTIVITY_LIMITS.detailMax), + 'info', + ); + } + private rememberSdkSession(sessionId: string, sdkSessionId: string): void { getDb() .prepare( diff --git a/src/main/managers/GitManager.ts b/src/main/managers/GitManager.ts index bf6bdb3..dd4ccc0 100644 --- a/src/main/managers/GitManager.ts +++ b/src/main/managers/GitManager.ts @@ -89,6 +89,14 @@ export class GitManager { this.memory = memory; } + /** Optional Resume Pipeline — checkpoints re-anchor the session snapshot. */ + private resume?: { onCheckpointCreated(sessionId: string): void }; + + /** Wire the Resume Manager so checkpoints refresh the session's repo anchor. */ + setResumeManager(resume: { onCheckpointCreated(sessionId: string): void }): void { + this.resume = resume; + } + /** Inject the active-root resolver (worktree-backed sessions). */ setActiveRootResolver(resolve: (workspaceId: string) => string | null): void { this.activeRootResolver = resolve; @@ -656,6 +664,9 @@ export class GitManager { this.pruneCheckpoints(root, sessionId); this.notifyCheckpoints(sessionId); + // The checkpointed tree is a state worth anchoring for resume + // revalidation. Fire-and-forget; snapshots never create checkpoints. + this.resume?.onCheckpointCreated(sessionId); return checkpoint; } catch (err) { logger.warn('createCheckpoint failed', err); diff --git a/src/main/managers/SettingsManager.ts b/src/main/managers/SettingsManager.ts index 43aa771..461b76c 100644 --- a/src/main/managers/SettingsManager.ts +++ b/src/main/managers/SettingsManager.ts @@ -17,6 +17,7 @@ import { GIT_LIMITS, LAYOUT_LIMITS, MEMORY_LIMITS, + RESUME_LIMITS, SEARCH_LIMITS, SETTINGS_VERSION, VOICE_LIMITS, @@ -205,6 +206,26 @@ export class SettingsManager { } plan.historyLimit = Math.round(clamp(plan.historyLimit, 1, 100)); + // Resume Pipeline — clamp the numeric knobs and coerce the toggles + // (renderer-supplied values bound git work on every session activation). + const resume = merged.resume; + resume.enabled = !!resume.enabled; + resume.injectDelta = !!resume.injectDelta; + resume.maxCommitsInDelta = Math.round( + clamp( + resume.maxCommitsInDelta, + RESUME_LIMITS.maxCommitsInDelta.min, + RESUME_LIMITS.maxCommitsInDelta.max, + ), + ); + resume.staleThresholdDays = Math.round( + clamp( + resume.staleThresholdDays, + RESUME_LIMITS.staleThresholdDays.min, + RESUME_LIMITS.staleThresholdDays.max, + ), + ); + // Attachments — clamp the numeric caps, coerce the toggles, and whitelist // the elevated-risk policy (renderer-supplied values gate real file I/O). const att = merged.attachments; diff --git a/src/main/managers/memory/MemoryManager.ts b/src/main/managers/memory/MemoryManager.ts index 9e784b0..05b382c 100644 --- a/src/main/managers/memory/MemoryManager.ts +++ b/src/main/managers/memory/MemoryManager.ts @@ -138,14 +138,42 @@ export class MemoryManager { expires_at: null, session_id: input.sessionId ?? null, commit_hash: null, - file_path: null, + file_path: normalizeRefPath(input.filePath), meta: '{}', }; this.insert(row); + this.writeLinks(row.id, row.file_path, input.symbolRefs); this.notifyChanged(); return rowToMemory(row); } + /** + * Record the source references a memory is about, so the Resume Pipeline can + * downgrade its confidence when the referents disappear (and restore it when + * they return). `filePath` writes a 'file' link; each `symbolRefs` entry is a + * `path#name` 'symbol' link. All values are bound; paths are validated by the + * caller (IPC boundary) and re-normalized here. + */ + private writeLinks(memoryId: string, filePath: string | null, symbolRefs?: string[]): void { + const insert = this.db.prepare( + 'INSERT INTO memory_links (memory_id, kind, ref) VALUES (?, ?, ?)', + ); + const tx = this.db.transaction(() => { + if (filePath) insert.run(memoryId, 'file', filePath); + if (Array.isArray(symbolRefs)) { + const seen = new Set(); + for (const raw of symbolRefs.slice(0, 50)) { + const ref = normalizeSymbolRef(raw); + if (ref && !seen.has(ref)) { + seen.add(ref); + insert.run(memoryId, 'symbol', ref); + } + } + } + }); + tx(); + } + update(id: string, patch: MemoryUpdateInput): Memory | null { const existing = this.row(id); if (!existing) return null; @@ -193,6 +221,102 @@ export class MemoryManager { return row ? rowToMemory(row) : null; } + /* ------------------------------------------------ resume revalidation */ + + /** + * Downgrade active memories whose linked files vanished from the repository + * (called by the Resume Pipeline during revalidation). Confidence is scaled + * ×0.6 and floored at 0.1; the pre-downgrade value is stashed in `meta` so it + * can be restored when the referent reappears. Idempotent — an already-flagged + * memory is not downgraded again. Returns the affected {id,title} for the + * delta summary. Best-effort: never throws. + */ + downgradeForMissingFiles( + workspaceId: string | null, + paths: string[], + ): { id: string; title: string }[] { + const affected: { id: string; title: string }[] = []; + try { + const select = this.db.prepare( + `SELECT DISTINCT m.* FROM memory_links ml + JOIN memories m ON m.id = ml.memory_id + WHERE ml.kind = 'file' AND ml.ref = ? + AND m.status = 'active' + AND (m.workspace_id IS ? OR m.workspace_id = ?)`, + ); + const now = Date.now(); + const tx = this.db.transaction(() => { + for (const p of paths) { + const ref = normalizeRefPath(p); + if (!ref) continue; + const rows = select.all(ref, workspaceId, workspaceId) as MemoryRow[]; + for (const row of rows) { + const meta = safeParseMeta(row.meta); + if (meta.referentMissing) continue; // already downgraded + const before = row.confidence; + const next = Math.max(0.1, before * 0.6); + meta.referentMissing = true; + meta.referentMissingAt = now; + meta.preDowngradeConfidence = before; + this.db + .prepare('UPDATE memories SET confidence = ?, meta = ?, updated_at = ? WHERE id = ?') + .run(next, JSON.stringify(meta), now, row.id); + affected.push({ id: row.id, title: row.title }); + } + } + }); + tx(); + if (affected.length > 0) this.notifyChanged(); + } catch (err) { + logger.warn('memory: downgrade for missing files failed', err); + } + return affected; + } + + /** + * Restore memories previously downgraded for a now-present file (referent + * reappeared). Reinstates `preDowngradeConfidence` and clears the flag. + * Best-effort: never throws. + */ + restoreForPresentFiles(workspaceId: string | null, paths: string[]): void { + try { + const select = this.db.prepare( + `SELECT DISTINCT m.* FROM memory_links ml + JOIN memories m ON m.id = ml.memory_id + WHERE ml.kind = 'file' AND ml.ref = ? + AND (m.workspace_id IS ? OR m.workspace_id = ?)`, + ); + const now = Date.now(); + let changed = false; + const tx = this.db.transaction(() => { + for (const p of paths) { + const ref = normalizeRefPath(p); + if (!ref) continue; + const rows = select.all(ref, workspaceId, workspaceId) as MemoryRow[]; + for (const row of rows) { + const meta = safeParseMeta(row.meta); + if (!meta.referentMissing) continue; + const restored = + typeof meta.preDowngradeConfidence === 'number' + ? clamp01(meta.preDowngradeConfidence) + : row.confidence; + delete meta.referentMissing; + delete meta.referentMissingAt; + delete meta.preDowngradeConfidence; + this.db + .prepare('UPDATE memories SET confidence = ?, meta = ?, updated_at = ? WHERE id = ?') + .run(restored, JSON.stringify(meta), now, row.id); + changed = true; + } + } + }); + tx(); + if (changed) this.notifyChanged(); + } catch (err) { + logger.warn('memory: restore for present files failed', err); + } + } + list(filter: MemoryListFilter): Memory[] { const limit = clampInt(filter.limit ?? 200, 1, MEMORY_LIMITS.listMax); const tiers = (filter.tiers ?? []).filter(isMemoryTier); @@ -680,6 +804,43 @@ function stripInternal(c: MemoryHit & { _relevance: number }): MemoryHit { return rest; } +/** Parse a memory's meta JSON blob defensively (never throws, no prototype keys). */ +function safeParseMeta(raw: string): Record { + try { + const parsed = JSON.parse(raw || '{}'); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}; + const out: Record = {}; + for (const key of Object.keys(parsed)) { + if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; + out[key] = (parsed as Record)[key]; + } + return out; + } catch { + return {}; + } +} + +/** Normalize a workspace-relative ref path (POSIX, bounded, no traversal). */ +function normalizeRefPath(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0 || value.length > 4096) return null; + if (value.includes('\0')) return null; + const posix = value.split('\\').join('/').replace(/^\.\//, ''); + if (posix.startsWith('/') || /^[A-Za-z]:/.test(posix)) return null; + if (posix.split('/').some((seg) => seg === '..')) return null; + return posix; +} + +/** Normalize a `path#name` symbol ref; null when malformed. */ +function normalizeSymbolRef(value: unknown): string | null { + if (typeof value !== 'string' || value.length === 0 || value.length > 4096) return null; + const hash = value.indexOf('#'); + if (hash <= 0 || hash === value.length - 1) return null; + const p = normalizeRefPath(value.slice(0, hash)); + const name = value.slice(hash + 1); + if (!p || !/^[\w$.-]{1,200}$/.test(name)) return null; + return `${p}#${name}`; +} + function clip(value: unknown, max: number): string { const s = typeof value === 'string' ? value : String(value ?? ''); return s.length > max ? s.slice(0, max) : s; diff --git a/src/main/managers/resume/ResumeManager.ts b/src/main/managers/resume/ResumeManager.ts new file mode 100644 index 0000000..4a1736d --- /dev/null +++ b/src/main/managers/resume/ResumeManager.ts @@ -0,0 +1,542 @@ +/** + * Resume Manager — the "continue exactly where you left off" orchestrator. + * + * The Claude Agent SDK resumes the *conversation* (via `options.resume`), but + * conversation history says nothing about how the repository evolved while a + * session was suspended. This manager closes that gap as a platform service + * owned by the app (like Memory/Search): it records a repository anchor per + * session (**snapshot**: HEAD + branch + a dirty-state hash) at meaningful + * moments, **revalidates** the repo against that anchor on every session + * activation (cheap short-circuit when nothing changed), computes a structured + * **repository delta** when it diverged, and hands the AgentManager a one-shot + * `` block for the next prompt. + * + * Never blocks session switching: revalidation is async, deadline-bounded, and + * best-effort — any failure degrades to "no delta". Security (CLAUDE.md §6): + * argv-only git through the shared runner, parameterized SQL only, lstat calls + * guarded by `isInsideRoot`, and no commit subjects/paths in the logs. + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { BrowserWindow } from 'electron'; +import type Database from 'better-sqlite3'; +import { IpcEvents } from '@shared/ipc-channels'; +import { FS_LIMITS, RESUME_LIMITS } from '@shared/constants'; +import type { RepoDelta, ResumeState, Session } from '@shared/types'; +import { getDb } from '../../db/database'; +import { logger } from '../../logger'; +import type { SettingsManager } from '../SettingsManager'; +import type { SessionManager } from '../SessionManager'; +import type { WorkspaceManager } from '../WorkspaceManager'; +import { gitText, runGit } from '../git/exec'; +import { isInsideRoot } from '../workspace/validate'; +import { + computeRepoDelta, + parsePorcelainZ, + renderDeltaBlock, + summarizeDelta, + type DirtyEntry, + type RepoAnchor, +} from './delta'; + +interface SnapshotRow { + session_id: string; + workspace_id: string; + root: string; + head: string | null; + branch: string | null; + dirty_hash: string; + dirty_files: string; + reason: string; + created_at: number; + updated_at: number; +} + +interface DeltaRow { + session_id: string; + status: string; + delta: string; + created_at: number; +} + +/** + * The subset of the Search Engine the resume delta uses for code-intelligence + * enrichment. A minimal interface keeps this manager decoupled from the full + * SearchManager surface (and off any import cycle). + */ +interface SearchIndex { + indexFiles(workspaceId: string, relPaths: string[]): Promise; + symbolIdentitiesForPath(workspaceId: string, relPath: string): Set; + importerCount(workspaceId: string, refPath: string): number; +} + +/** + * The subset of the Memory System the resume delta uses: downgrade memories + * whose linked files vanished, restore them when the files return. + */ +interface MemoryRevalidation { + downgradeForMissingFiles( + workspaceId: string | null, + paths: string[], + ): { id: string; title: string }[]; + restoreForPresentFiles(workspaceId: string | null, paths: string[]): void; +} + +/** Files whose symbols/imports we bother to diff (skip generated noise). */ +const INDEXABLE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|java|kt|scala|cs|c|cc|cpp|h|hpp)$/i; + +/** `kind:name` symbol identity → bare name for display. */ +function nameOf(identity: string): string { + const colon = identity.indexOf(':'); + return colon >= 0 ? identity.slice(colon + 1) : identity; +} + +export class ResumeManager { + constructor( + private readonly workspace: WorkspaceManager, + private readonly sessions: SessionManager, + private readonly settings: SettingsManager, + ) {} + + private get db(): Database.Database { + return getDb(); + } + + /* --------------------------------------------------------------- wiring */ + + /** Session → effective execution root (worktree-aware), injected at boot. */ + private resolveSessionRoot: ((sessionId: string) => string | null) | null = null; + + setSessionRootResolver(resolve: (sessionId: string) => string | null): void { + this.resolveSessionRoot = resolve; + } + + /** + * Timeline recorder (AgentManager.recordStatus), injected at boot so + * revalidation results land in the session's activity feed without this + * manager depending on the AgentManager type. + */ + private recordStatus: ((sessionId: string, label: string, detail?: string) => void) | null = + null; + + setStatusRecorder(record: (sessionId: string, label: string, detail?: string) => void): void { + this.recordStatus = record; + } + + /** Search Engine, injected at boot (symbol/reference delta enrichment). */ + private search: SearchIndex | null = null; + + setSearchManager(search: SearchIndex): void { + this.search = search; + } + + /** Memory System, injected at boot (confidence downgrade/restore). */ + private memory: MemoryRevalidation | null = null; + + setMemoryManager(memory: MemoryRevalidation): void { + this.memory = memory; + } + + /* ---------------------------------------------------------------- state */ + + /** Live revalidation phase per session (in-memory; deltas persist in SQLite). */ + private readonly states = new Map(); + private prevActiveId: string | null = null; + + getState(sessionId: string): ResumeState { + return this.states.get(sessionId) ?? { sessionId, phase: 'idle' }; + } + + /** The persisted delta (pending OR injected) so the detail view keeps working. */ + getDelta(sessionId: string): RepoDelta | null { + const row = this.db + .prepare(`SELECT * FROM resume_deltas WHERE session_id = ? AND status IN ('pending','injected')`) + .get(sessionId) as DeltaRow | undefined; + if (!row) return null; + try { + return JSON.parse(row.delta) as RepoDelta; + } catch { + return null; + } + } + + /** User dismissed the banner — the pending delta will not be injected. */ + dismiss(sessionId: string): void { + this.db + .prepare(`UPDATE resume_deltas SET status = 'dismissed' WHERE session_id = ? AND status = 'pending'`) + .run(sessionId); + this.setState({ sessionId, phase: 'clean' }); + } + + private setState(state: ResumeState): void { + this.states.set(state.sessionId, state); + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(IpcEvents.resumeStateChanged, state); + } + } + + /* ---------------------------------------------------------- entry points */ + + /** Boot: revalidate the session that comes back active (after worktree recovery). */ + onBoot(): void { + const active = this.sessions.getActive(); + this.prevActiveId = active?.id ?? null; + if (active) void this.revalidate(active.id); + } + + /** + * Session switch: anchor the session we're leaving, then revalidate the one + * we're entering. Fire-and-forget — never blocks the switch. + */ + onActiveSessionChanged(active: Session | null): void { + const prev = this.prevActiveId; + this.prevActiveId = active?.id ?? null; + if (prev && prev !== active?.id) void this.captureSnapshot(prev, 'deactivate'); + if (active && prev !== active.id) void this.revalidate(active.id); + } + + /** End of an agent run: refresh the anchor (the agent may have changed the repo). */ + onRunFinished(sessionId: string): void { + void this.captureSnapshot(sessionId, 'run-end'); + } + + /** A checkpoint was created: the working tree is a state worth anchoring. */ + onCheckpointCreated(sessionId: string): void { + void this.captureSnapshot(sessionId, 'checkpoint'); + } + + /* ------------------------------------------------------------- snapshots */ + + /** Read the repo's current anchor (head + branch + dirty hash) at `root`. */ + private async readAnchor(root: string): Promise { + const head = await gitText(root, ['rev-parse', '--verify', 'HEAD']); + const branchRaw = await gitText(root, ['rev-parse', '--abbrev-ref', 'HEAD']); + const branch = branchRaw && branchRaw !== 'HEAD' ? branchRaw : null; + let dirtyEntries: DirtyEntry[] = []; + let dirtyHash = ''; + if (head) { + const status = await runGit(root, ['status', '--porcelain=v1', '-z']); + if (status.ok) { + const all = parsePorcelainZ(status.stdout, RESUME_LIMITS.maxDirtyEntries + 1); + const overflowed = all.length > RESUME_LIMITS.maxDirtyEntries; + dirtyEntries = all.slice(0, RESUME_LIMITS.maxDirtyEntries); + dirtyHash = this.hashDirty(root, dirtyEntries, overflowed ? all.length : undefined); + } + } + return { head: head || null, branch, dirtyHash, dirtyEntries }; + } + + /** + * A cheap content-drift detector for an already-dirty tree: sha256 over the + * sorted `status\0path\0size\0mtimeMs` lines. No file contents are read; + * every lstat target is validated inside the root first. + */ + private hashDirty(root: string, entries: DirtyEntry[], overflowCount?: number): string { + if (entries.length === 0 && !overflowCount) return ''; + const lines: string[] = []; + for (const e of entries) { + let size = 0; + let mtime = 0; + const abs = path.resolve(root, e.path); + if (isInsideRoot(root, abs)) { + try { + const st = fs.lstatSync(abs); + size = st.size; + mtime = st.mtimeMs; + } catch { + /* deleted / unreadable — the zero entry still contributes */ + } + } + lines.push(`${e.status}\0${e.path}\0${size}\0${mtime}`); + } + lines.sort(); + if (overflowCount) lines.push(`overflow:${overflowCount}`); + return crypto.createHash('sha256').update(lines.join('\n')).digest('hex'); + } + + /** Record the session's repository anchor. Best-effort — never throws. */ + async captureSnapshot(sessionId: string, reason: string): Promise { + try { + const session = this.sessions.get(sessionId); + if (!session) return; + const root = this.resolveSessionRoot?.(sessionId) ?? this.workspaceRoot(session.workspaceId); + if (!root) return; + const anchor = await this.readAnchor(root); + const now = Date.now(); + this.db + .prepare( + `INSERT INTO session_snapshots + (session_id, workspace_id, root, head, branch, dirty_hash, dirty_files, reason, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + workspace_id = excluded.workspace_id, + root = excluded.root, + head = excluded.head, + branch = excluded.branch, + dirty_hash = excluded.dirty_hash, + dirty_files = excluded.dirty_files, + reason = excluded.reason, + updated_at = excluded.updated_at`, + ) + .run( + sessionId, + session.workspaceId, + root, + anchor.head, + anchor.branch, + anchor.dirtyHash, + JSON.stringify( + anchor.dirtyEntries + .slice(0, RESUME_LIMITS.maxDirtyFilesStored) + .map((e) => ({ path: e.path, status: e.status })), + ), + reason, + now, + now, + ); + } catch (err) { + logger.warn('resume: snapshot capture failed', err); + } + } + + private workspaceRoot(workspaceId: string): string | null { + const ws = this.workspace.getById(workspaceId); + return ws?.path ?? null; + } + + /* ----------------------------------------------------------- revalidation */ + + /** + * Compare the repo against the session's snapshot; publish a delta when they + * diverged. Deadline-bounded and best-effort: a timeout or git failure lands + * on phase 'idle' — never an error surface, never a blocked switch. + */ + async revalidate(sessionId: string): Promise { + const cfg = this.settings.getAll().resume; + if (!cfg.enabled) return; + try { + await Promise.race([ + this.revalidateInner(sessionId, cfg.maxCommitsInDelta, cfg.staleThresholdDays), + new Promise((_, reject) => + setTimeout(() => reject(new Error('revalidate timeout')), RESUME_LIMITS.revalidateTimeoutMs), + ), + ]); + } catch (err) { + logger.warn('resume: revalidation degraded to no-delta', err); + this.setState({ sessionId, phase: 'idle' }); + } + } + + private async revalidateInner( + sessionId: string, + maxCommits: number, + staleThresholdDays: number, + ): Promise { + const session = this.sessions.get(sessionId); + if (!session) return; + const root = this.resolveSessionRoot?.(sessionId) ?? this.workspaceRoot(session.workspaceId); + if (!root) return; + + const snap = this.db + .prepare('SELECT * FROM session_snapshots WHERE session_id = ?') + .get(sessionId) as SnapshotRow | undefined; + + // First-ever activation: bootstrap an anchor, nothing to compare against. + if (!snap) { + await this.captureSnapshot(sessionId, 'activate'); + this.setState({ sessionId, phase: 'clean' }); + return; + } + + // Optional freshness skip (0 = always revalidate). + if ( + staleThresholdDays > 0 && + Date.now() - snap.updated_at < staleThresholdDays * 86_400_000 + ) { + this.setState({ sessionId, phase: 'clean' }); + return; + } + + this.setState({ sessionId, phase: 'checking' }); + + // Root moved (worktree recreated/detached): the ranges are meaningless. + if (path.resolve(root) !== path.resolve(snap.root)) { + const anchor = await this.readAnchor(root); + const delta = await computeRepoDelta( + root, + sessionId, + { head: null, branch: snap.branch, dirtyHash: snap.dirty_hash, dirtyEntries: [], at: snap.updated_at }, + anchor, + maxCommits, + ); + delta.rootChanged = true; + await this.publishDelta(sessionId, delta); + await this.captureSnapshot(sessionId, 'revalidate'); + return; + } + + const current = await this.readAnchor(root); + + // Cheap short-circuit: nothing moved — the overwhelmingly common case. + if ( + current.head === snap.head && + current.branch === snap.branch && + current.dirtyHash === snap.dirty_hash + ) { + this.db + .prepare(`DELETE FROM resume_deltas WHERE session_id = ? AND status = 'pending'`) + .run(sessionId); + this.setState({ sessionId, phase: 'clean' }); + return; + } + + // Non-git root (or unborn HEAD) never produces a delta. + if (!current.head && !snap.head) { + this.setState({ sessionId, phase: 'clean' }); + return; + } + + const delta = await computeRepoDelta( + root, + sessionId, + { + head: snap.head, + branch: snap.branch, + dirtyHash: snap.dirty_hash, + dirtyEntries: [], + at: snap.updated_at, + }, + current, + maxCommits, + ); + + // A dirty-hash-only wobble with no visible change isn't worth surfacing. + if ( + !delta.headMoved && + !delta.branchChanged && + !delta.historyRewritten && + delta.filesTotal === 0 + ) { + this.setState({ sessionId, phase: 'clean' }); + await this.captureSnapshot(sessionId, 'revalidate'); + return; + } + + await this.enrichDelta(session.workspaceId, delta); + await this.publishDelta(sessionId, delta); + // Refresh the anchor so the delta is one-shot: the next activation + // short-circuits clean while the pending row waits for the first prompt. + await this.captureSnapshot(sessionId, 'revalidate'); + } + + /** + * Code-intelligence enrichment (Phase B): per-file symbol adds/removes and + * importer counts for the changed source files. Best-effort — a failure or a + * disabled search index leaves the delta unenriched. Bounded by the same + * incremental cap the indexer uses, and only when search is enabled. + */ + private async enrichDelta(workspaceId: string, delta: RepoDelta): Promise { + if (!this.search || !this.settings.getAll().search.enabled) return; + if (delta.rootChanged) return; // no meaningful per-file diff across roots + try { + const changed = delta.files + .filter((f) => f.status !== 'deleted' && INDEXABLE.test(f.path)) + .map((f) => f.path); + const deleted = delta.files.filter((f) => f.status === 'deleted').map((f) => f.path); + + // Memory revalidation: linked-file memories whose file vanished get their + // confidence downgraded; a file that reappeared restores its memories. + if (this.memory) { + if (deleted.length > 0) { + const downgraded = this.memory.downgradeForMissingFiles(workspaceId, deleted); + if (downgraded.length > 0) delta.downgradedMemories = downgraded.slice(0, 20); + } + const present = delta.files + .filter((f) => f.status === 'added' || f.status === 'modified') + .map((f) => f.path); + if (present.length > 0) this.memory.restoreForPresentFiles(workspaceId, present); + } + + // Importer counts work regardless of index timing (current ref graph). + const refImpacts: NonNullable = []; + for (const f of [...changed, ...deleted]) { + const importers = this.search.importerCount(workspaceId, f); + if (importers > 0) refImpacts.push({ path: f, importers }); + } + if (refImpacts.length > 0) { + refImpacts.sort((a, b) => b.importers - a.importers); + delta.refImpacts = refImpacts.slice(0, 20); + } + + // Symbol delta: capture BEFORE from the index, reindex, capture AFTER. + // Only worthwhile when the changed set is small enough for the indexer. + if (changed.length === 0 || changed.length > FS_LIMITS.incrementalIndexMax) return; + const before = new Map>(); + for (const p of changed) before.set(p, this.search.symbolIdentitiesForPath(workspaceId, p)); + await this.search.indexFiles(workspaceId, changed); + + const symbols: NonNullable = []; + for (const p of changed) { + const after = this.search.symbolIdentitiesForPath(workspaceId, p); + const prev = before.get(p) ?? new Set(); + const added = [...after].filter((s) => !prev.has(s)).map(nameOf).slice(0, 40); + const removed = [...prev].filter((s) => !after.has(s)).map(nameOf).slice(0, 40); + if (added.length > 0 || removed.length > 0) symbols.push({ path: p, added, removed }); + } + if (symbols.length > 0) delta.symbols = symbols; + } catch (err) { + logger.warn('resume: delta enrichment failed', err); + } + } + + private async publishDelta(sessionId: string, delta: RepoDelta): Promise { + const now = Date.now(); + this.db + .prepare( + `INSERT INTO resume_deltas (session_id, status, delta, created_at) + VALUES (?, 'pending', ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + status = 'pending', delta = excluded.delta, created_at = excluded.created_at`, + ) + .run(sessionId, JSON.stringify(delta), now); + const summary = summarizeDelta(delta); + this.setState({ sessionId, phase: 'delta', summary, deltaAt: now }); + // Counts only — never commit subjects or paths in the log. + logger.info( + `resume: delta for session ${sessionId} (${delta.commitsAhead} ahead, ${delta.filesTotal} files)`, + ); + try { + this.recordStatus?.(sessionId, 'Repository changed since last visit', summary); + } catch { + /* timeline is decoration — never fail revalidation over it */ + } + } + + /* -------------------------------------------------------------- injection */ + + /** + * Consume the pending delta for a prompt: render the `` + * block and mark the row injected (one delta, one block). The AgentManager + * caches the rendered block on the in-flight run so recovery retries re-use + * it instead of losing it. + */ + consumePendingDelta(sessionId: string): string | undefined { + try { + const row = this.db + .prepare(`SELECT * FROM resume_deltas WHERE session_id = ? AND status = 'pending'`) + .get(sessionId) as DeltaRow | undefined; + if (!row) return undefined; + const delta = JSON.parse(row.delta) as RepoDelta; + const block = renderDeltaBlock(delta); + this.db + .prepare(`UPDATE resume_deltas SET status = 'injected' WHERE session_id = ?`) + .run(sessionId); + this.setState({ sessionId, phase: 'clean' }); + return block; + } catch (err) { + logger.warn('resume: delta consumption failed', err); + return undefined; + } + } +} diff --git a/src/main/managers/resume/delta.ts b/src/main/managers/resume/delta.ts new file mode 100644 index 0000000..6ad0354 --- /dev/null +++ b/src/main/managers/resume/delta.ts @@ -0,0 +1,334 @@ +/** + * Repository-delta computation for the Resume Pipeline — pure functions over + * the bounded `runGit` runner. + * + * Security (CLAUDE.md §6): git is only ever invoked argv-only via the shared + * {@link runGit}/{@link gitText} helpers (no shell, 15 s timeout, locked-down + * env). The snapshot HEAD is regex-validated before it enters any argv as + * defense in depth, every list is capped by `RESUME_LIMITS`, and nothing here + * logs commit subjects or paths (counts only at the call sites). + */ +import { RESUME_LIMITS } from '@shared/constants'; +import type { + RepoDelta, + RepoDeltaCommit, + RepoDeltaFile, + RepoDeltaFileCategory, +} from '@shared/types'; +import { gitText, runGit } from '../git/exec'; +import { parseNameStatus } from '../git/parse'; + +/** A dirty working-tree entry parsed from `git status --porcelain=v1 -z`. */ +export interface DirtyEntry { + path: string; + status: string; +} + +/** What a snapshot recorded / what the repo looks like now. */ +export interface RepoAnchor { + head: string | null; + branch: string | null; + dirtyHash: string; + dirtyEntries: DirtyEntry[]; +} + +/** Only a real commit hash may enter a git argv (defense in depth). */ +export function isValidHead(head: unknown): head is string { + return typeof head === 'string' && /^[0-9a-f]{4,64}$/i.test(head); +} + +/** + * Parse `git status --porcelain=v1 -z` output. Entries are NUL-separated + * `XY path` records; renames carry the origin path as an extra NUL field, + * which is skipped (the delta reports the current path). + */ +export function parsePorcelainZ(raw: string, cap: number): DirtyEntry[] { + const entries: DirtyEntry[] = []; + const parts = raw.split('\0'); + for (let i = 0; i < parts.length && entries.length < cap; i++) { + const part = parts[i]; + if (part.length < 4 || part[2] !== ' ') continue; + const status = part.slice(0, 2).trim() || '??'; + entries.push({ path: part.slice(3), status }); + // `R`/`C` records are followed by the origin path as its own NUL field. + if (part[0] === 'R' || part[0] === 'C') i++; + } + return entries; +} + +const MANIFEST_BASENAMES = new Set([ + 'package.json', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', + 'bun.lockb', + 'cargo.toml', + 'cargo.lock', + 'go.mod', + 'go.sum', + 'requirements.txt', + 'pyproject.toml', + 'poetry.lock', + 'gemfile', + 'gemfile.lock', + 'composer.json', + 'composer.lock', + // limboo.json is the repo's scripts/services config behind the ack-hash + // trust gate — a change here must be prominent in the delta. + 'limboo.json', +]); + +const DOC_EXTENSIONS = new Set(['md', 'mdx', 'txt', 'rst', 'adoc']); + +/** Bucket a repo-relative POSIX path into a coarse delta category. */ +export function categorizePath(relPath: string): RepoDeltaFileCategory { + const lower = relPath.toLowerCase(); + const slash = lower.lastIndexOf('/'); + const base = slash >= 0 ? lower.slice(slash + 1) : lower; + if (MANIFEST_BASENAMES.has(base)) return 'manifest'; + if (/(^|\/)migrations?\//.test(lower)) return 'migration'; + if ( + base.startsWith('tsconfig') || + base.startsWith('.eslintrc') || + base.startsWith('forge.config.') || + /^vite[^/]*\.config\./.test(base) || + base === '.gitignore' || + base === '.npmrc' + ) { + return 'config'; + } + const dot = base.lastIndexOf('.'); + const ext = dot >= 0 ? base.slice(dot + 1) : ''; + if (DOC_EXTENSIONS.has(ext)) return 'doc'; + if (ext) return 'source'; + return 'other'; +} + +/** Map a parsed name-status entry onto the delta's file status vocabulary. */ +function toDeltaStatus(status: string): RepoDeltaFile['status'] { + switch (status) { + case 'added': + case 'deleted': + case 'renamed': + case 'modified': + return status; + default: + return 'modified'; + } +} + +/** + * Compute the structured delta between a snapshot anchor and the repo's + * current state. Every git call is bounded; overflow degrades to exact counts + * with a capped list. Throws only on programmer error — git failures shrink + * the delta instead of failing it. + */ +export async function computeRepoDelta( + root: string, + sessionId: string, + snapshot: RepoAnchor & { at: number }, + current: RepoAnchor, + maxCommits: number, +): Promise { + const delta: RepoDelta = { + sessionId, + snapshotAt: snapshot.at, + branchChanged: snapshot.branch !== current.branch, + fromBranch: snapshot.branch, + toBranch: current.branch, + headMoved: snapshot.head !== current.head, + fromHead: snapshot.head, + toHead: current.head, + commitsAhead: 0, + commitsBehind: 0, + commits: [], + historyRewritten: false, + rootChanged: false, + files: [], + filesTotal: 0, + manifestChanges: [], + }; + + const snapHead = snapshot.head; + const canRange = isValidHead(snapHead) && isValidHead(current.head) && delta.headMoved; + + if (canRange) { + // 1. Does the snapshot commit still exist? (gc after a rebase drops it.) + const exists = await runGit(root, ['cat-file', '-e', `${snapHead}^{commit}`]); + if (!exists.ok) { + delta.historyRewritten = true; + } else { + // 2. Fast-forward or rewrite? Non-ancestor = rebase/amend happened. + const ancestor = await runGit(root, ['merge-base', '--is-ancestor', snapHead, 'HEAD']); + if (!ancestor.ok) delta.historyRewritten = true; + + // 3. Exact counts in both directions. + const ahead = await gitText(root, ['rev-list', '--count', `${snapHead}..HEAD`]); + const behind = await gitText(root, ['rev-list', '--count', `HEAD..${snapHead}`]); + delta.commitsAhead = Number.parseInt(ahead ?? '0', 10) || 0; + delta.commitsBehind = Number.parseInt(behind ?? '0', 10) || 0; + + // 4. Capped commit list, newest first (0x1f-separated fields). + const log = await runGit(root, [ + 'log', + '--format=%H%x1f%s%x1f%an%x1f%ct', + '-n', + String(maxCommits), + `${snapHead}..HEAD`, + ]); + if (log.ok) { + for (const line of log.stdout.split('\n')) { + if (!line) continue; + const [hash, subject, author, ct] = line.split('\x1f'); + if (!isValidHead(hash)) continue; + const commit: RepoDeltaCommit = { + hash, + subject: (subject ?? '').slice(0, RESUME_LIMITS.subjectMax), + author: (author ?? '').slice(0, 80), + at: (Number.parseInt(ct ?? '0', 10) || 0) * 1000, + }; + delta.commits.push(commit); + if (delta.commits.length >= maxCommits) break; + } + } + + // 5. Committed file changes over the range. + const diff = await runGit(root, ['diff', '--name-status', '-z', snapHead, 'HEAD']); + if (diff.ok) { + for (const change of parseNameStatus(diff.stdout)) { + delta.files.push({ + path: change.path, + status: toDeltaStatus(change.status), + category: categorizePath(change.path), + }); + } + } + } + } + + // 6. Merge current dirty entries (committed status wins on the same path). + const seen = new Set(delta.files.map((f) => f.path)); + for (const dirty of current.dirtyEntries) { + if (seen.has(dirty.path)) continue; + seen.add(dirty.path); + delta.files.push({ path: dirty.path, status: 'dirty', category: categorizePath(dirty.path) }); + } + + delta.filesTotal = delta.files.length; + if (delta.files.length > RESUME_LIMITS.maxFilesInDelta) { + delta.files = delta.files.slice(0, RESUME_LIMITS.maxFilesInDelta); + } + delta.manifestChanges = delta.files + .filter((f) => f.category === 'manifest' || f.category === 'migration') + .map((f) => f.path); + + return delta; +} + +/** One-line human summary for the banner / activity feed. */ +export function summarizeDelta(delta: RepoDelta): string { + if (delta.rootChanged) return 'Execution root changed since last visit'; + const parts: string[] = []; + if (delta.historyRewritten) parts.push('history rewritten'); + if (delta.commitsAhead > 0) { + parts.push(`${delta.commitsAhead} commit${delta.commitsAhead === 1 ? '' : 's'}`); + } + if (delta.commitsBehind > 0) parts.push(`${delta.commitsBehind} behind`); + if (delta.filesTotal > 0) { + parts.push(`${delta.filesTotal} file${delta.filesTotal === 1 ? '' : 's'}`); + } + if (delta.branchChanged) { + parts.push(`branch ${delta.fromBranch ?? 'detached'} → ${delta.toBranch ?? 'detached'}`); + } + return parts.length > 0 ? parts.join(', ') : 'Repository changed'; +} + +/** + * Render the `` system-prompt block within the char budget + * (same trim-loop pattern as the memory/search context blocks). Advisory: the + * agent's own Read/Grep/Glob remain authoritative. + */ +export function renderDeltaBlock(delta: RepoDelta): string { + const budget = RESUME_LIMITS.injectCharBudget; + const lines: string[] = [ + '', + 'The repository changed since this conversation last worked in it. Reconcile', + 'your assumptions with these changes before relying on prior file/symbol', + 'knowledge; re-read anything you depend on.', + ]; + if (delta.rootChanged) { + lines.push('The session execution root changed (worktree recreated or detached).'); + } + if (delta.historyRewritten) { + lines.push('History was rewritten (rebase/amend) — prior commit hashes may be gone.'); + } + if (delta.branchChanged) { + lines.push(`Branch: ${delta.fromBranch ?? 'detached'} -> ${delta.toBranch ?? 'detached'}`); + } + if (delta.headMoved && delta.fromHead && delta.toHead) { + lines.push( + `HEAD: ${delta.fromHead.slice(0, 8)} -> ${delta.toHead.slice(0, 8)}` + + ` (${delta.commitsAhead} ahead, ${delta.commitsBehind} behind)`, + ); + } + + const out: string[] = []; + let used = 0; + const push = (line: string): boolean => { + if (used + line.length + 1 > budget) return false; + out.push(line); + used += line.length + 1; + return true; + }; + for (const line of lines) push(line); + + if (delta.commits.length > 0) { + push(`Commits (newest first, ${delta.commits.length} of ${delta.commitsAhead}):`); + for (const c of delta.commits) { + if (!push(`- ${c.hash.slice(0, 8)} ${c.subject}`)) break; + } + } + if (delta.files.length > 0) { + push(`Changed files (${delta.filesTotal}):`); + let listed = 0; + for (const f of delta.files) { + const tag = f.category === 'manifest' || f.category === 'migration' ? `[${f.category}] ` : ''; + if (!push(`- ${tag}${f.status}: ${f.path}`)) break; + listed++; + } + if (listed < delta.filesTotal) push(`- ... and ${delta.filesTotal - listed} more`); + } + if (delta.manifestChanges.length > 0) { + push( + `Dependency manifests / migrations changed: ${delta.manifestChanges + .slice(0, 8) + .join(', ')} — re-check installed dependencies and schema assumptions.`, + ); + } + if (delta.symbols && delta.symbols.length > 0) { + push('Symbol changes:'); + for (const s of delta.symbols) { + const added = s.added.length > 0 ? ` +${s.added.join(', +')}` : ''; + const removed = s.removed.length > 0 ? ` -${s.removed.join(', -')}` : ''; + if (!push(`- ${s.path}:${added}${removed}`)) break; + } + } + if (delta.refImpacts && delta.refImpacts.length > 0) { + for (const r of delta.refImpacts) { + if (!push(`- ${r.importers} file${r.importers === 1 ? '' : 's'} import ${r.path}`)) break; + } + } + if (delta.downgradedMemories && delta.downgradedMemories.length > 0) { + push( + `Project memories referencing removed files were downgraded: ${delta.downgradedMemories + .map((m) => m.title) + .slice(0, 5) + .join('; ')}.`, + ); + } + const dirtyCount = delta.files.filter((f) => f.status === 'dirty').length; + if (dirtyCount > 0) push(`(uncommitted changes present: ${dirtyCount} files)`); + + out.push(''); + return out.join('\n'); +} diff --git a/src/main/managers/search/SearchManager.ts b/src/main/managers/search/SearchManager.ts index 6d9e033..8731870 100644 --- a/src/main/managers/search/SearchManager.ts +++ b/src/main/managers/search/SearchManager.ts @@ -48,6 +48,7 @@ import { readWorkspaceFile } from '../fs/reader'; import { buildIgnoreMatcher } from '../fs/ignore'; import { isInsideRoot } from '../workspace/validate'; import { extractSymbols } from './symbols'; +import { extractRefs, resolveRelativeRef } from './refs'; import { isDocPath, langForPath, toFtsQuery, toLikePattern, toPrefixPattern, toTrigramQuery } from './query'; /** Directories always skipped even when `includeIgnored` is on (safety floor). */ @@ -234,36 +235,53 @@ export class SearchManager { } if (paths.length === 0) return; + const selectHash = this.db.prepare( + 'SELECT content_hash FROM search_files WHERE workspace_id = ? AND path = ?', + ); const deleteFile = this.db.prepare( 'DELETE FROM search_files WHERE workspace_id = ? AND path = ?', ); const deleteSymbols = this.db.prepare( 'DELETE FROM search_symbols WHERE workspace_id = ? AND path = ?', ); + const deleteRefs = this.db.prepare( + 'DELETE FROM search_refs WHERE workspace_id = ? AND src_path = ?', + ); const insertFile = this.db.prepare( - `INSERT INTO search_files (id, workspace_id, path, lang, size, content, updated_at) - VALUES (@id, @workspace_id, @path, @lang, @size, @content, @updated_at)`, + `INSERT INTO search_files (id, workspace_id, path, lang, size, content, content_hash, updated_at) + VALUES (@id, @workspace_id, @path, @lang, @size, @content, @content_hash, @updated_at)`, ); const insertSymbol = this.db.prepare( `INSERT INTO search_symbols (id, workspace_id, path, name, kind, lang, line, signature, updated_at) VALUES (@id, @workspace_id, @path, @name, @kind, @lang, @line, @signature, @updated_at)`, ); + const insertRef = this.db.prepare( + `INSERT INTO search_refs (id, workspace_id, src_path, ref, ref_path, kind, updated_at) + VALUES (@id, @workspace_id, @src_path, @ref, @ref_path, @kind, @updated_at)`, + ); + + // The current path set, for resolving relative import specifiers to files + // (pure path math — no filesystem I/O). Bounded by the index itself. + const indexed = new Set( + ( + this.db + .prepare('SELECT path FROM search_files WHERE workspace_id = ?') + .all(workspaceId) as { path: string }[] + ).map((r) => r.path), + ); const tx = this.db.transaction(() => { for (const rel of paths) { - // Delete-then-insert: a deleted/unreadable file simply stays deleted. - deleteFile.run(workspaceId, rel); - deleteSymbols.run(workspaceId, rel); - - let res: ReturnType; + let res: ReturnType | null = null; try { res = readWorkspaceFile(root, rel); } catch { - continue; // gone or unreadable — rows stay removed + res = null; // gone or unreadable } const lang = langForPath(rel) ?? null; let content = ''; if ( + res && cfg.indexContents && res.content && !res.isBinary && @@ -272,6 +290,23 @@ export class SearchManager { ) { content = res.content.slice(0, SEARCH_LIMITS.contentIndexChars); } + const contentHash = res ? this.hashContent(content, res.size) : ''; + + // Skip an unchanged file entirely — no delete/reinsert, no FTS churn. + if (res) { + const prev = selectHash.get(workspaceId, rel) as { content_hash?: string } | undefined; + if (prev && prev.content_hash && prev.content_hash === contentHash) continue; + } + + // Delete-then-insert: a deleted/unreadable file simply stays deleted. + deleteFile.run(workspaceId, rel); + deleteSymbols.run(workspaceId, rel); + deleteRefs.run(workspaceId, rel); + if (!res) { + indexed.delete(rel); + continue; + } + insertFile.run({ id: crypto.randomUUID(), workspace_id: workspaceId, @@ -279,8 +314,10 @@ export class SearchManager { lang, size: res.size, content, + content_hash: contentHash, updated_at: now, }); + indexed.add(rel); if (content) { for (const sym of extractSymbols(content, lang ?? undefined)) { insertSymbol.run({ @@ -295,6 +332,17 @@ export class SearchManager { updated_at: now, }); } + for (const ref of extractRefs(content, lang ?? undefined)) { + insertRef.run({ + id: crypto.randomUUID(), + workspace_id: workspaceId, + src_path: rel, + ref: ref.ref, + ref_path: resolveRelativeRef(rel, ref.ref, indexed), + kind: ref.kind, + updated_at: now, + }); + } } } }); @@ -327,13 +375,19 @@ export class SearchManager { const now = Date.now(); const insertFile = this.db.prepare( - `INSERT INTO search_files (id, workspace_id, path, lang, size, content, updated_at) - VALUES (@id, @workspace_id, @path, @lang, @size, @content, @updated_at)`, + `INSERT INTO search_files (id, workspace_id, path, lang, size, content, content_hash, updated_at) + VALUES (@id, @workspace_id, @path, @lang, @size, @content, @content_hash, @updated_at)`, ); const insertSymbol = this.db.prepare( `INSERT INTO search_symbols (id, workspace_id, path, name, kind, lang, line, signature, updated_at) VALUES (@id, @workspace_id, @path, @name, @kind, @lang, @line, @signature, @updated_at)`, ); + const insertRef = this.db.prepare( + `INSERT INTO search_refs (id, workspace_id, src_path, ref, ref_path, kind, updated_at) + VALUES (@id, @workspace_id, @src_path, @ref, @ref_path, @kind, @updated_at)`, + ); + // Full known path set (for resolving relative import specifiers to files). + const indexed = new Set(files); // Replace the workspace's rows atomically once, then stream inserts in batches // that periodically yield to the event loop (progress + UI stay live). @@ -367,6 +421,7 @@ export class SearchManager { lang, size, content, + content_hash: this.hashContent(content, size), updated_at: now, }); if (content) { @@ -383,6 +438,17 @@ export class SearchManager { updated_at: now, }); } + for (const ref of extractRefs(content, lang ?? undefined)) { + insertRef.run({ + id: crypto.randomUUID(), + workspace_id: workspaceId, + src_path: rel, + ref: ref.ref, + ref_path: resolveRelativeRef(rel, ref.ref, indexed), + kind: ref.kind, + updated_at: now, + }); + } } processed += 1; } @@ -442,6 +508,40 @@ export class SearchManager { private clearWorkspace(workspaceId: string): void { this.db.prepare('DELETE FROM search_files WHERE workspace_id = ?').run(workspaceId); this.db.prepare('DELETE FROM search_symbols WHERE workspace_id = ?').run(workspaceId); + this.db.prepare('DELETE FROM search_refs WHERE workspace_id = ?').run(workspaceId); + } + + /** + * Content identity for the incremental skip + the resume delta engine. Text + * files hash their (already size-capped) content; path-only rows (binary / + * oversize / unread) fall back to a cheap size surrogate so a size change is + * still detected without reading bytes. + */ + private hashContent(content: string, size: number): string { + if (content) return crypto.createHash('sha256').update(content).digest('hex'); + return `b:${size}`; + } + + /** + * The set of symbol identities (`kind:name`) currently indexed for a path. + * Used by the Resume Pipeline to compute per-file symbol adds/removes across + * a reindex. Bounded by the per-file symbol cap. All values bound. + */ + symbolIdentitiesForPath(workspaceId: string, relPath: string): Set { + const rows = this.db + .prepare('SELECT name, kind FROM search_symbols WHERE workspace_id = ? AND path = ?') + .all(workspaceId, relPath) as { name: string; kind: string }[]; + return new Set(rows.map((r) => `${r.kind}:${r.name}`)); + } + + /** How many indexed files import the given workspace-relative path. */ + importerCount(workspaceId: string, refPath: string): number { + const row = this.db + .prepare( + 'SELECT COUNT(DISTINCT src_path) AS n FROM search_refs WHERE workspace_id = ? AND ref_path = ?', + ) + .get(workspaceId, refPath) as { n: number } | undefined; + return row?.n ?? 0; } /** Drop a workspace's entire index (called when a workspace is removed). */ diff --git a/src/main/managers/search/refs.ts b/src/main/managers/search/refs.ts new file mode 100644 index 0000000..510ef92 --- /dev/null +++ b/src/main/managers/search/refs.ts @@ -0,0 +1,158 @@ +/** + * Lightweight, language-aware import/reference extractor for the Search Engine's + * dependency layer. Runs during the same background content-indexing pass that + * owns {@link extractSymbols} to build a coarse edge list — one row per + * `import`/`require`/`use`/`from` statement — without pulling in a full parser + * or native grammars (same "no heavyweight deps" stance as the symbol extractor). + * + * Accuracy is best-effort: it recognizes the common import forms per language. + * It never executes code and only reads the (already size-capped, text-only) + * content handed to it. Resolution of a relative specifier to a workspace path + * happens in the SearchManager against the already-indexed path set (pure path + * math, no filesystem I/O) — this module only reports the raw specifier + kind. + */ +import { SEARCH_LIMITS } from '@shared/constants'; + +export type RefKind = 'import' | 'require' | 'use' | 'include'; + +export interface ExtractedRef { + /** The raw module specifier as written (e.g. './foo', 'react', 'crypto'). */ + ref: string; + kind: RefKind; +} + +interface RefRule { + re: RegExp; + kind: RefKind; + group?: number; +} + +/** + * Extract import/reference edges from a file's text. `lang` selects the rule + * set; anything unknown yields no edges (conservative). Deduped by specifier, + * capped to {@link SEARCH_LIMITS.maxRefsPerFile}. + */ +export function extractRefs(content: string, lang: string | undefined): ExtractedRef[] { + const rules = RULES[lang ?? '']; + if (!rules) return []; + const lines = content.split(/\r?\n/); + const out: ExtractedRef[] = []; + const seen = new Set(); + + for (let i = 0; i < lines.length; i += 1) { + if (out.length >= SEARCH_LIMITS.maxRefsPerFile) break; + const raw = lines[i]; + const trimmed = raw.trimStart(); + if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('#')) continue; + + for (const rule of rules) { + // Reset lastIndex — rules may be global for multiple specifiers per line. + rule.re.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = rule.re.exec(raw)) !== null) { + const ref = m[rule.group ?? 1]; + if (ref && isSpecifier(ref)) { + const key = `${rule.kind}:${ref}`; + if (!seen.has(key)) { + seen.add(key); + out.push({ ref, kind: rule.kind }); + if (out.length >= SEARCH_LIMITS.maxRefsPerFile) break; + } + } + if (!rule.re.global) break; + } + } + } + return out; +} + +/** A plausible module specifier: bounded, printable, no whitespace/quotes/NUL. */ +function isSpecifier(s: string): boolean { + return s.length > 0 && s.length <= 512 && !/[\s"'`\0]/.test(s); +} + +/* -------------------------------------------------------- language rule sets */ + +// TS/JS: `import … from 'x'`, `import 'x'`, `export … from 'x'`, +// `require('x')`, dynamic `import('x')`. Global so multiple per line are caught. +const TS_RULES: RefRule[] = [ + { re: /(?:import|export)\s+(?:[^'"]*?\s+from\s+)?['"]([^'"]+)['"]/g, kind: 'import' }, + { re: /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g, kind: 'require' }, + { re: /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g, kind: 'import' }, +]; + +const PY_RULES: RefRule[] = [ + { re: /^\s*from\s+([.\w]+)\s+import\b/, kind: 'import' }, + { re: /^\s*import\s+([.\w]+)/, kind: 'import' }, +]; + +const GO_RULES: RefRule[] = [{ re: /^\s*(?:[A-Za-z_.]+\s+)?"([^"]+)"/, kind: 'import' }]; + +const RUST_RULES: RefRule[] = [{ re: /^\s*(?:pub\s+)?use\s+([A-Za-z_][\w:]*)/, kind: 'use' }]; + +const RUBY_RULES: RefRule[] = [ + { re: /^\s*require(?:_relative)?\s+['"]([^'"]+)['"]/, kind: 'require' }, +]; + +const JVM_RULES: RefRule[] = [{ re: /^\s*import\s+(?:static\s+)?([\w.]+)/, kind: 'import' }]; + +const CFAMILY_RULES: RefRule[] = [{ re: /^\s*#\s*include\s+[<"]([^>"]+)[>"]/, kind: 'include' }]; + +const RULES: Record = { + typescript: TS_RULES, + javascript: TS_RULES, + vue: TS_RULES, + svelte: TS_RULES, + astro: TS_RULES, + python: PY_RULES, + go: GO_RULES, + rust: RUST_RULES, + ruby: RUBY_RULES, + java: JVM_RULES, + kotlin: JVM_RULES, + scala: JVM_RULES, + csharp: JVM_RULES, + c: CFAMILY_RULES, + cpp: CFAMILY_RULES, +}; + +/** + * Resolve a *relative* specifier (`./x`, `../y`) to a workspace-relative POSIX + * path against the set of already-indexed paths. Bare/package specifiers return + * null. Pure path math — no filesystem I/O, no symlink risk. Any `..` escape + * outside the workspace is rejected (null). + */ +export function resolveRelativeRef( + srcPath: string, + ref: string, + indexed: Set, +): string | null { + if (!ref.startsWith('.')) return null; + const srcDir = srcPath.includes('/') ? srcPath.slice(0, srcPath.lastIndexOf('/')) : ''; + const combined = srcDir ? `${srcDir}/${ref}` : ref; + + // Normalize `.`/`..` segments manually (POSIX, no Node path to avoid OS sep). + const segments: string[] = []; + for (const part of combined.split('/')) { + if (part === '' || part === '.') continue; + if (part === '..') { + if (segments.length === 0) return null; // escapes the workspace root + segments.pop(); + } else { + segments.push(part); + } + } + const base = segments.join('/'); + if (!base) return null; + + // Exact hit, then common source extensions, then a directory index file. + if (indexed.has(base)) return base; + const exts = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.go', '.rs', '.rb']; + for (const ext of exts) { + if (indexed.has(base + ext)) return base + ext; + } + for (const ext of exts) { + if (indexed.has(`${base}/index${ext}`)) return `${base}/index${ext}`; + } + return null; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 1ff7ba9..7d644d8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -51,6 +51,8 @@ import type { MemoryTier, MemoryUpdateInput, RepoConfigState, + RepoDelta, + ResumeState, SavedSearch, SearchFilter, ServiceInfo, @@ -569,6 +571,23 @@ const searchApi = { subscribe(IpcEvents.searchIndexProgress, cb), }; +const resumeApi = { + /** The session's live revalidation state (for hydration on mount). */ + getState: (sessionId: string): Promise => + ipcRenderer.invoke(IpcChannels.resumeGetState, sessionId), + /** The persisted repository delta (pending or already injected). */ + getDelta: (sessionId: string): Promise => + ipcRenderer.invoke(IpcChannels.resumeGetDelta, sessionId), + /** Dismiss the pending delta — it will not be injected into a prompt. */ + dismiss: (sessionId: string): Promise => + ipcRenderer.invoke(IpcChannels.resumeDismiss, sessionId), + /** Re-run revalidation for the ACTIVE session (main enforces the gate). */ + revalidate: (sessionId: string): Promise => + ipcRenderer.invoke(IpcChannels.resumeRevalidate, sessionId), + onStateChanged: (cb: (state: ResumeState) => void): (() => void) => + subscribe(IpcEvents.resumeStateChanged, cb), +}; + const updatesApi = { /** The current updater status (for hydration on mount). */ getState: (): Promise => ipcRenderer.invoke(IpcChannels.updateGetState), @@ -645,6 +664,7 @@ const limbooApi = { services: servicesApi, memory: memoryApi, search: searchApi, + resume: resumeApi, updates: updatesApi, voice: voiceApi, attachment: attachmentApi, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index e2bf6e6..aea8ac6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -12,6 +12,7 @@ import { GlobalSearch } from '@/renderer/features/search/GlobalSearch'; import { SettingsModal } from '@/renderer/features/settings/SettingsModal'; import { SessionDeleteDialog } from '@/renderer/features/sessions/SessionDeleteDialog'; import { HooksConfirmDialog } from '@/renderer/features/sessions/HooksConfirmDialog'; +import { ResumeDeltaDialog } from '@/renderer/features/resume/ResumeDeltaDialog'; import { useServiceStore } from '@/renderer/stores/useServiceStore'; import { UpdateBanner } from '@/renderer/features/updates/UpdateBanner'; import { Toaster } from '@/renderer/components/feedback/Toaster'; @@ -29,6 +30,7 @@ import { useSearchStore } from '@/renderer/stores/useSearchStore'; import { useUpdateStore } from '@/renderer/stores/useUpdateStore'; import { useVoiceStore } from '@/renderer/stores/useVoiceStore'; import { useAttachmentStore } from '@/renderer/stores/useAttachmentStore'; +import { useResumeStore } from '@/renderer/stores/useResumeStore'; export function App() { useKeyboardShortcuts(); @@ -73,6 +75,8 @@ export function App() { useServiceStore.getState().hydrate(); // Subscribe to attachment set + staging-progress pushes (composer chips). useAttachmentStore.getState().hydrate(); + // Subscribe to resume revalidation pushes (banner + header chip + dialog). + useResumeStore.getState().hydrate(); }, []); return ( @@ -89,6 +93,7 @@ export function App() { + diff --git a/src/renderer/features/resume/ResumeBanner.tsx b/src/renderer/features/resume/ResumeBanner.tsx new file mode 100644 index 0000000..e6eb009 --- /dev/null +++ b/src/renderer/features/resume/ResumeBanner.tsx @@ -0,0 +1,45 @@ +/** + * Resume banner — shown under the session header when the repository diverged + * from the session's last snapshot (commits landed, a rebase happened, the + * worktree was recreated, …). Purely informational: Review opens the delta + * dialog, dismiss drops the pending prompt injection. Matches the + * MissingWorktreeBanner row idiom (h-9, border-b, bg-surface). + */ +import { AlertTriangle, Info, X } from 'lucide-react'; +import { IconButton } from '@/renderer/components/ui'; +import { useResumeStore } from '@/renderer/stores/useResumeStore'; + +export function ResumeBanner({ sessionId }: { sessionId: string }) { + const state = useResumeStore((s) => s.bySession[sessionId]); + const openDetail = useResumeStore((s) => s.openDetail); + const dismiss = useResumeStore((s) => s.dismiss); + + if (!state || state.phase !== 'delta') return null; + + // Warning tone for the structural cases; info tone for ordinary drift. + const warn = /history rewritten|root changed/i.test(state.summary ?? ''); + + return ( +
+ {warn ? ( + + ) : ( + + )} + + Repository changed since last visit + {state.summary ? — {state.summary} : null} + + + void dismiss(sessionId)}> + + +
+ ); +} diff --git a/src/renderer/features/resume/ResumeDeltaDialog.tsx b/src/renderer/features/resume/ResumeDeltaDialog.tsx new file mode 100644 index 0000000..7813275 --- /dev/null +++ b/src/renderer/features/resume/ResumeDeltaDialog.tsx @@ -0,0 +1,201 @@ +/** + * Repository-delta detail dialog — the structured view behind the resume + * banner's Review action: branch/HEAD movement, the capped commit list, + * changed files grouped by category, and specially-flagged manifest/migration + * changes. Read-only; the one-shot prompt injection is handled by the agent + * layer. Matches the app modal idiom (HooksConfirmDialog). + */ +import { useEffect } from 'react'; +import { GitCommit as GitCommitIcon, RefreshCw, X } from 'lucide-react'; +import type { RepoDelta, RepoDeltaFile } from '@shared/types'; +import { useResumeStore } from '@/renderer/stores/useResumeStore'; + +const CATEGORY_LABEL: Record = { + manifest: 'Dependency manifests', + migration: 'Migrations', + config: 'Configuration', + source: 'Source', + doc: 'Docs', + other: 'Other', +}; + +const CATEGORY_ORDER: RepoDeltaFile['category'][] = [ + 'manifest', + 'migration', + 'config', + 'source', + 'doc', + 'other', +]; + +export function ResumeDeltaDialog() { + const delta = useResumeStore((s) => s.delta); + const open = useResumeStore((s) => s.detailOpen); + const close = useResumeStore((s) => s.closeDetail); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') close(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, close]); + + if (!open || !delta) return null; + + const byCategory = new Map(); + for (const file of delta.files) { + const bucket = byCategory.get(file.category) ?? []; + bucket.push(file); + byCategory.set(file.category, bucket); + } + + return ( +
+
e.stopPropagation()} + > +
+ + + Repository changed since last visit + + +
+ +
+ + + {delta.commits.length > 0 && ( +
+ + Commits ({delta.commits.length} of {delta.commitsAhead}) + +
+ {delta.commits.map((c) => ( +
+ + + {c.hash.slice(0, 8)} + + {c.subject} +
+ ))} +
+
+ )} + + {CATEGORY_ORDER.map((category) => { + const files = byCategory.get(category); + if (!files || files.length === 0) return null; + const highlight = category === 'manifest' || category === 'migration'; + return ( +
+ + {CATEGORY_LABEL[category]} ({files.length}) + +
+ {files.map((f) => ( +
+ + {f.status} + + + {f.path} + +
+ ))} +
+
+ ); + })} + + {delta.filesTotal > delta.files.length && ( +

+ … and {delta.filesTotal - delta.files.length} more files. +

+ )} + + {delta.symbols && delta.symbols.length > 0 && ( +
+ + Symbol changes + +
+ {delta.symbols.map((s) => ( +
+ {s.path} + + {s.added.map((n) => ( + + +{n} + + ))} + {s.removed.map((n) => ( + + -{n} + + ))} + +
+ ))} +
+
+ )} +
+ +
+

+ The agent receives this delta with its next prompt. +

+ +
+
+
+ ); +} + +/** Branch / HEAD movement summary lines. */ +function Movement({ delta }: { delta: RepoDelta }) { + const rows: string[] = []; + if (delta.rootChanged) rows.push('The session execution root changed (worktree recreated or detached).'); + if (delta.historyRewritten) rows.push('History was rewritten (rebase or amend) — prior commit hashes may be gone.'); + if (delta.branchChanged) { + rows.push(`Branch: ${delta.fromBranch ?? 'detached'} → ${delta.toBranch ?? 'detached'}`); + } + if (delta.headMoved && delta.fromHead && delta.toHead) { + rows.push( + `HEAD: ${delta.fromHead.slice(0, 8)} → ${delta.toHead.slice(0, 8)} (${delta.commitsAhead} ahead, ${delta.commitsBehind} behind)`, + ); + } + if (rows.length === 0) return null; + return ( +
+ {rows.map((row) => ( +

{row}

+ ))} +
+ ); +} diff --git a/src/renderer/features/settings/catalog.tsx b/src/renderer/features/settings/catalog.tsx index 4f71fe0..1ce8508 100644 --- a/src/renderer/features/settings/catalog.tsx +++ b/src/renderer/features/settings/catalog.tsx @@ -259,8 +259,12 @@ export const SETTINGS_CATALOG: SettingsCategory[] = [ id: 'memory', label: 'Memory & Search', icon: Brain, - keywords: ['memory', 'knowledge', 'context', 'recall', 'retention', 'notes', 'decisions', 'conventions', 'inject', 'capture', 'search', 'index', 'indexing', 'files', 'symbols', 'find', 'global search', 'retrieval'], + keywords: ['memory', 'knowledge', 'context', 'recall', 'retention', 'notes', 'decisions', 'conventions', 'inject', 'capture', 'search', 'index', 'indexing', 'files', 'symbols', 'find', 'global search', 'retrieval', 'resume', 'revalidation', 'repository delta', 'continue'], fields: [ + { id: 'resumeEnabled', label: 'Repository revalidation on resume', keywords: ['resume', 'revalidate', 'continue', 'delta', 'repository'] }, + { id: 'resumeInjectDelta', label: 'Inject repository delta into prompts', keywords: ['resume', 'delta', 'prompt', 'agent', 'context'] }, + { id: 'resumeMaxCommits', label: 'Max commits in delta', keywords: ['resume', 'commits', 'delta', 'limit'] }, + { id: 'resumeStaleDays', label: 'Skip revalidation newer than', keywords: ['resume', 'stale', 'days', 'threshold'] }, { id: 'memoryEnabled', label: 'Enable memory', keywords: ['on', 'off', 'toggle'] }, { id: 'memoryInject', label: 'Inject into agent prompts', keywords: ['context', 'prompt', 'agent'] }, { id: 'memoryMaxInjected', label: 'Max memories per prompt', keywords: ['budget', 'limit', 'count'] }, diff --git a/src/renderer/features/settings/panels/MemoryPanel.tsx b/src/renderer/features/settings/panels/MemoryPanel.tsx index 186cd48..42358ff 100644 --- a/src/renderer/features/settings/panels/MemoryPanel.tsx +++ b/src/renderer/features/settings/panels/MemoryPanel.tsx @@ -4,7 +4,7 @@ * and usage. These knobs shape what is captured and how much is injected into the * agent prompt; the memory database itself lives under the app's user-data dir. */ -import { MEMORY_LIMITS, SEARCH_LIMITS, clamp } from '@shared/constants'; +import { MEMORY_LIMITS, RESUME_LIMITS, SEARCH_LIMITS, clamp } from '@shared/constants'; import { cn } from '@/renderer/lib/cn'; import { useSettingsStore } from '@/renderer/stores/useSettingsStore'; import { Field, Section, SegmentedControl, Select, StackedField, Toggle } from '../controls'; @@ -23,10 +23,71 @@ const SOURCE_CHIPS: { id: 'files' | 'symbols' | 'docs' | 'memory' | 'commits' | export function MemoryPanel() { const memory = useSettingsStore((s) => s.settings.memory); const search = useSettingsStore((s) => s.settings.search); + const resume = useSettingsStore((s) => s.settings.resume); const update = useSettingsStore((s) => s.update); return (
+
+ + void update({ resume: { enabled: v } })} /> + + {resume.enabled && ( + <> + + void update({ resume: { injectDelta: v } })} + /> + + + + value={clamp( + resume.maxCommitsInDelta, + RESUME_LIMITS.maxCommitsInDelta.min, + RESUME_LIMITS.maxCommitsInDelta.max, + )} + options={[10, 25, 50, 100].map((n) => ({ value: n, label: String(n) }))} + onChange={(v) => void update({ resume: { maxCommitsInDelta: v } })} + /> + + + + value={clamp( + resume.staleThresholdDays, + RESUME_LIMITS.staleThresholdDays.min, + RESUME_LIMITS.staleThresholdDays.max, + )} + options={[0, 1, 7, 30].map((n) => ({ + value: n, + label: n === 0 ? 'Always' : `${n} day${n === 1 ? '' : 's'}`, + }))} + onChange={(v) => void update({ resume: { staleThresholdDays: v } })} + /> + + + )} +
+
@@ -48,6 +50,8 @@ export function CenterWorkspace() { void loadSession(session.id); // Restore the session's attachment set (composer drafts + sent chips). void useAttachmentStore.getState().loadSession(session.id); + // Hydrate the session's revalidation state (banner + header chip). + void useResumeStore.getState().loadSession(session.id); } }, [session?.id, loadSession]); @@ -60,6 +64,8 @@ export function CenterWorkspace() { {session && } {/* Recovery affordance when the session's worktree directory vanished. */} {session?.worktreeStatus === 'missing' && } + {/* Repository drift since this session's last snapshot (resume pipeline). */} + {session && } {/* Scroll region — the single scroller. Messages live entirely above the docked composer below, so they are never overlapped or clipped. */} @@ -184,6 +190,8 @@ function SessionHeader({ const toggleGit = useLayoutStore((s) => s.toggleTab); // Dirty indicator on the git toggle so a glance shows uncommitted work. const gitDirty = useGitStore((s) => !!s.status && !s.status.clean); + // Non-blocking revalidation indicator while the resume pipeline checks git. + const revalidating = useResumeStore((s) => s.bySession[sessionId]?.phase === 'checking'); return (
{running ? : } @@ -191,6 +199,12 @@ function SessionHeader({ {branch} {running && Working…} + {!running && revalidating && ( + + + Revalidating… + + )} {!running && planStatus === 'ready' && ( Plan ready diff --git a/src/renderer/stores/useResumeStore.ts b/src/renderer/stores/useResumeStore.ts new file mode 100644 index 0000000..beb012c --- /dev/null +++ b/src/renderer/stores/useResumeStore.ts @@ -0,0 +1,77 @@ +/** + * Resume store — the renderer-side mirror of the main-process ResumeManager. + * Holds each session's live revalidation phase (checking / clean / delta) plus + * the currently-viewed repository delta for the detail dialog. Subscribes once + * to `resume:state-changed`; all data crosses via the validated IPC surface. + * + * Degrades to empty, read-only state in a plain browser preview (no preload). + */ +import { create } from 'zustand'; +import type { RepoDelta, ResumeState } from '@shared/types'; + +interface ResumeStoreState { + /** Live revalidation state per session (pushed from main). */ + bySession: Record; + /** The delta shown in the detail dialog (null = dialog closed). */ + delta: RepoDelta | null; + detailOpen: boolean; + hydrated: boolean; + + hydrate: () => void; + /** Pull the current state for a session (on session open). */ + loadSession: (sessionId: string) => Promise; + openDetail: (sessionId: string) => Promise; + closeDetail: () => void; + dismiss: (sessionId: string) => Promise; +} + +function resumeApi() { + return window.limboo?.resume; +} + +export const useResumeStore = create((set, get) => ({ + bySession: {}, + delta: null, + detailOpen: false, + hydrated: false, + + hydrate: () => { + if (get().hydrated) return; + set({ hydrated: true }); + resumeApi()?.onStateChanged((state) => { + set((s) => ({ bySession: { ...s.bySession, [state.sessionId]: state } })); + }); + }, + + loadSession: async (sessionId) => { + const api = resumeApi(); + if (!api) return; + try { + const state = await api.getState(sessionId); + set((s) => ({ bySession: { ...s.bySession, [sessionId]: state } })); + } catch { + /* best-effort hydration — the push stream corrects it */ + } + }, + + openDetail: async (sessionId) => { + const api = resumeApi(); + if (!api) return; + try { + const delta = await api.getDelta(sessionId); + if (delta) set({ delta, detailOpen: true }); + } catch { + /* no delta — nothing to show */ + } + }, + + closeDetail: () => set({ detailOpen: false, delta: null }), + + dismiss: async (sessionId) => { + try { + await resumeApi()?.dismiss(sessionId); + } catch { + /* the state push reflects whatever main decided */ + } + }, +})); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 291df1e..20204f8 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -174,6 +174,8 @@ export const SEARCH_LIMITS = { contentIndexChars: 200_000, /** Cap on symbols extracted per file (avoids pathological generated files). */ maxSymbolsPerFile: 400, + /** Cap on import/reference edges extracted per file (dependency layer). */ + maxRefsPerFile: 200, /** Recent-search history ring length (hard ceiling). */ historyMax: 50, /** User-configurable recent-search ring length (clamped to historyMax). */ @@ -184,6 +186,30 @@ export const SEARCH_LIMITS = { gitCacheTtlMs: 15_000, } as const; +/** + * Bounds + caps for the Resume Pipeline (main + renderer both clamp). + * Repository revalidation on session activation + the one-shot + * `` prompt block — all git work is argv-only and bounded. + */ +export const RESUME_LIMITS = { + /** Commit subjects listed in a delta (rev-list counts stay exact). */ + maxCommitsInDelta: { min: 1, max: 100, default: 25 }, + /** Changed files carried in a delta (true total kept separately). */ + maxFilesInDelta: 400, + /** Dirty-status entries folded into the snapshot dirty hash. */ + maxDirtyEntries: 500, + /** Dirty-file summaries stored per snapshot row (paths only). */ + maxDirtyFilesStored: 100, + /** Approx character budget for the injected `` block. */ + injectCharBudget: 4_000, + /** Whole-revalidation deadline; a miss degrades to "no delta". */ + revalidateTimeoutMs: 10_000, + /** Days before an untouched session skips revalidation (0 = always run). */ + staleThresholdDays: { min: 0, max: 365, default: 0 }, + /** Commit-subject length cap inside a delta. */ + subjectMax: 120, +} as const; + /** Bounds + caps for the Voice subsystem (main + renderer both clamp). */ export const VOICE_LIMITS = { /** VAD speech-probability threshold. */ @@ -424,6 +450,12 @@ export const DEFAULT_SETTINGS: AppSettings = { fuzzy: true, openOnClick: true, }, + resume: { + enabled: true, + injectDelta: true, + maxCommitsInDelta: RESUME_LIMITS.maxCommitsInDelta.default, + staleThresholdDays: RESUME_LIMITS.staleThresholdDays.default, + }, attachments: { enabled: true, maxFileSizeMB: ATTACHMENT_LIMITS.maxFileSizeMB.default, @@ -494,7 +526,7 @@ export function clamp(value: number, min: number, max: number): number { /* ------------------------------------------------------------------ */ /** Bumped whenever the workspace DB schema changes incompatibly. */ -export const WORKSPACE_SCHEMA_VERSION = 9; +export const WORKSPACE_SCHEMA_VERSION = 11; /** Input caps the main process enforces on renderer-supplied session values. */ export const SESSION_LIMITS = { diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index 2434023..f21dcf7 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -178,6 +178,12 @@ export const IpcChannels = { searchSavedCreate: 'search:savedCreate', searchSavedDelete: 'search:savedDelete', + // Resume Pipeline — repository revalidation + delta on session activation. + resumeGetState: 'resume:getState', + resumeGetDelta: 'resume:getDelta', + resumeDismiss: 'resume:dismiss', + resumeRevalidate: 'resume:revalidate', + // Attachment Manager — session-owned files staged for the agent's tool loop. attachmentList: 'attachment:list', attachmentPickFiles: 'attachment:pickFiles', @@ -271,6 +277,8 @@ export const IpcEvents = { searchChanged: 'search:changed', /** Progress of an in-flight search index pass. */ searchIndexProgress: 'search:index-progress', + /** A session's revalidation state advanced (checking / clean / delta). */ + resumeStateChanged: 'resume:state-changed', /** A session's attachment set changed (staged / sent / read / removed). */ attachmentsChanged: 'attachment:changed', /** Staging progress for one attachment (hash + copy percent). */ diff --git a/src/shared/types.ts b/src/shared/types.ts index 1d331ca..45184d5 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -343,6 +343,23 @@ export interface AppSettings { /** Title-bar search box opens the modal on click; off = only the ⌘P/Ctrl+P shortcut. */ openOnClick: boolean; }; + /** + * Resume Pipeline — repository revalidation when a session is activated. + * Compares the current repo state against the session's last snapshot and, + * when they diverge, surfaces a structured delta (banner + dialog) and + * injects a one-shot `` block into the next agent prompt. + * Fully local: bounded, argv-only git — never blocks session switching. + */ + resume: { + /** Master switch for snapshots + revalidation + the delta UI. */ + enabled: boolean; + /** Inject the pending repository delta into the next agent prompt. */ + injectDelta: boolean; + /** Max commit subjects listed in a delta (counts stay exact). */ + maxCommitsInDelta: number; + /** Days before an untouched session skips revalidation (0 = always run). */ + staleThresholdDays: number; + }; /** * Attachment Manager — user-supplied files attached in the composer become * session-owned staged copies under `userData/attachments//`. The @@ -803,6 +820,89 @@ export interface GitCheckpoint { createdAt: number; } +/* ------------------------------------------------------------------ */ +/* Resume Pipeline */ +/* ------------------------------------------------------------------ */ + +/** + * Revalidation phase of a session's resume pipeline. `checking` while the + * bounded git comparison runs, `clean` when the repo matches the snapshot, + * `delta` when it diverged (banner + pending prompt injection), `idle` when + * revalidation hasn't run / failed (failures degrade to "no delta"). + */ +export type ResumePhase = 'idle' | 'checking' | 'clean' | 'delta'; + +/** One commit in a repository delta (subject capped, newest first). */ +export interface RepoDeltaCommit { + hash: string; + subject: string; + author: string; + at: number; +} + +/** Coarse category a changed path is bucketed into for the delta summary. */ +export type RepoDeltaFileCategory = + | 'manifest' + | 'config' + | 'migration' + | 'source' + | 'doc' + | 'other'; + +/** One changed file in a repository delta ('dirty' = uncommitted). */ +export interface RepoDeltaFile { + path: string; + status: 'added' | 'modified' | 'deleted' | 'renamed' | 'dirty'; + category: RepoDeltaFileCategory; +} + +/** + * Structured repository delta between a session's last snapshot and the repo's + * current state — computed entirely in the main process from bounded, argv-only + * git. Persisted per session so the one-shot prompt injection survives an app + * restart between detection and the next prompt. + */ +export interface RepoDelta { + sessionId: string; + /** When the snapshot this delta was computed against was taken. */ + snapshotAt: number; + branchChanged: boolean; + fromBranch: string | null; + toBranch: string | null; + headMoved: boolean; + fromHead: string | null; + toHead: string | null; + /** Exact rev-list counts (the commit list below is capped). */ + commitsAhead: number; + commitsBehind: number; + commits: RepoDeltaCommit[]; + /** Snapshot HEAD unreachable / not an ancestor (rebase, amend, gc). */ + historyRewritten: boolean; + /** The effective execution root changed (worktree recreated/detached). */ + rootChanged: boolean; + /** Committed-range + dirty files merged (capped; true total below). */ + files: RepoDeltaFile[]; + filesTotal: number; + /** Dependency manifests / migrations that changed (flagged specially). */ + manifestChanges: string[]; + /** Symbol-level adds/removes per changed file (Phase B enrichment). */ + symbols?: { path: string; added: string[]; removed: string[] }[]; + /** Importer counts for changed files (Phase B reference layer). */ + refImpacts?: { path: string; importers: number }[]; + /** Memories downgraded because their referents vanished (Phase C). */ + downgradedMemories?: { id: string; title: string }[]; +} + +/** Live revalidation state pushed to the renderer per session. */ +export interface ResumeState { + sessionId: string; + phase: ResumePhase; + /** One-line human summary ("12 commits, 34 files changed"). */ + summary?: string; + /** When the pending delta was detected (phase 'delta' only). */ + deltaAt?: number; +} + /** Result of a guarded branch checkout — surfaces dirty-tree pre-flight info. */ export interface GitCheckoutResult { ok: boolean; @@ -966,6 +1066,10 @@ export interface MemoryCreateInput { confidence?: number; pinned?: boolean; sessionId?: string | null; + /** Workspace-relative file this memory is about (drives a 'file' back-link). */ + filePath?: string | null; + /** Symbols this memory references, each `path#name` (drives 'symbol' links). */ + symbolRefs?: string[]; } /** Renderer-supplied patch when editing a memory (all optional). */