From 8a7dfcee4c087cd10eb857c7b9855ed26ab4a9fb Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:39:22 +0000 Subject: [PATCH 01/29] feat(onboarding): cancel an import; CSV route for Firefox/Safari passwords The onboarding import handler gains a browserosOnboardingCancelImport message that stops a running import through ExternalProcessImporterHost:: Cancel() and reports a new "cancelled" status (items already finished stay in the profile). Firefox and Safari are detected by the importer list already; this makes the step honest about them: saved passwords are dropped from the offered items for Firefox, Safari and Chromium-family sources, and each source now carries "kind" and "passwordsViaCsv" so the page can point at the export-a-CSV route. --- .../onboarding/browseros_onboarding.cc | 99 ++++++++++++++++++- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/vendor/browseros/chromium_patches/chrome/browser/browseros/onboarding/browseros_onboarding.cc b/vendor/browseros/chromium_patches/chrome/browser/browseros/onboarding/browseros_onboarding.cc index 49bb70654..bd136cc40 100644 --- a/vendor/browseros/chromium_patches/chrome/browser/browseros/onboarding/browseros_onboarding.cc +++ b/vendor/browseros/chromium_patches/chrome/browser/browseros/onboarding/browseros_onboarding.cc @@ -1,9 +1,9 @@ diff --git a/chrome/browser/browseros/onboarding/browseros_onboarding.cc b/chrome/browser/browseros/onboarding/browseros_onboarding.cc new file mode 100644 -index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf88b3d9646 +index 0000000000000000000000000000000000000000..b828c3d8d302dc934f4dead3560dc624bbe296a0 --- /dev/null +++ b/chrome/browser/browseros/onboarding/browseros_onboarding.cc -@@ -0,0 +1,794 @@ +@@ -0,0 +1,883 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. @@ -28,6 +28,7 @@ index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf8 +#include "base/supports_user_data.h" +#include "base/task/sequenced_task_runner.h" +#include "base/values.h" ++#include "build/build_config.h" +#include "chrome/browser/browser_process.h" +#include "chrome/browser/importer/external_process_importer_host.h" +#include "chrome/browser/importer/importer_list.h" @@ -38,6 +39,7 @@ index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf8 +#include "chrome/grit/browseros_onboarding_resources.h" +#include "chrome/grit/browseros_onboarding_resources_map.h" +#include "components/user_data_importer/common/importer_data_types.h" ++#include "components/user_data_importer/common/importer_type.h" +#include "content/public/browser/navigation_entry.h" +#include "content/public/browser/navigation_handle.h" +#include "content/public/browser/navigation_throttle.h" @@ -161,6 +163,57 @@ index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf8 + return items; +} + ++// Redrob: sources whose saved passwords this step does not copy. Firefox keeps ++// them NSS-encrypted, Safari keeps them in the system keychain, and the ++// Chromium-family importer deliberately carries no passwords. For these the ++// step offers the CSV route instead: export from that browser, then import ++// the file in the password manager. ++bool PasswordsNeedCsvExport( ++ const user_data_importer::SourceProfile& source_profile) { ++ switch (source_profile.importer_type) { ++ case user_data_importer::TYPE_FIREFOX: ++#if BUILDFLAG(IS_MAC) ++ case user_data_importer::TYPE_SAFARI: ++#endif ++ case user_data_importer::TYPE_CHROMIUM_BASED: ++ return true; ++ default: ++ return false; ++ } ++} ++ ++// Redrob: what the step offers and imports for one source. Upstream's Firefox ++// detection advertises PASSWORDS on Windows and Linux (it loads NSS into the ++// browser process); that path is out of scope here, so the bit is dropped for ++// every source that takes the CSV route, and the checklist never promises it. ++uint16_t SupportedImportItems( ++ const user_data_importer::SourceProfile& source_profile) { ++ uint16_t items = ++ source_profile.services_supported & kBrowserOSImportableItems; ++ if (PasswordsNeedCsvExport(source_profile)) { ++ items &= ~static_cast(user_data_importer::PASSWORDS); ++ } ++ return items; ++} ++ ++// Redrob: a stable family name for the source, so the page can pick the ++// product mark and the copy that fits it without parsing display names. ++const char* SourceKind( ++ const user_data_importer::SourceProfile& source_profile) { ++ switch (source_profile.importer_type) { ++ case user_data_importer::TYPE_FIREFOX: ++ return "firefox"; ++#if BUILDFLAG(IS_MAC) ++ case user_data_importer::TYPE_SAFARI: ++ return "safari"; ++#endif ++ case user_data_importer::TYPE_BOOKMARKS_FILE: ++ return "bookmarksFile"; ++ default: ++ return "chromium"; ++ } ++} ++ +} // namespace + +BrowserOSOnboardingSetupState::BrowserOSOnboardingSetupState() = default; @@ -196,6 +249,9 @@ index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf8 + kImporting, + kSucceeded, + kFailed, ++ // Redrob: the user stopped the import. Whatever finished before the stop ++ // stays in the profile; the page returns to the picker. ++ kCancelled, + }; + + struct ImportRequestSelection { @@ -232,6 +288,24 @@ index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf8 + "browserosOnboardingRetrySetup", + base::BindRepeating(&BrowserOSOnboardingHandler::HandleRetrySetup, + base::Unretained(this))); ++ // Redrob: Cancel on the import step. ++ web_ui()->RegisterMessageCallback( ++ "browserosOnboardingCancelImport", ++ base::BindRepeating(&BrowserOSOnboardingHandler::HandleCancelImport, ++ base::Unretained(this))); ++ } ++ ++ // Redrob: stops a running import. ExternalProcessImporterHost::Cancel() ++ // stops the utility-process importer, then reports ImportEnded() to this ++ // observer synchronously and deletes itself, so the terminal state is sent ++ // from ImportEnded() and importer_host_ is cleared there. A cancel with no ++ // import running is ignored: the page may race a click against the end. ++ void HandleCancelImport(const base::ListValue& args) { ++ if (!IsImportRunning() || !importer_host_) { ++ return; ++ } ++ import_cancelled_ = true; ++ importer_host_->Cancel(); + } + + void OnJavascriptDisallowed() override { @@ -489,8 +563,7 @@ index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf8 + uint16_t GetEffectiveImportItems( + const user_data_importer::SourceProfile& source_profile, + const ImportRequestSelection& selection) const { -+ uint16_t supported_items = -+ source_profile.services_supported & kBrowserOSImportableItems; ++ uint16_t supported_items = SupportedImportItems(source_profile); + return selection.has_selected_items + ? (selection.selected_items & supported_items) + : supported_items; @@ -509,7 +582,7 @@ index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf8 + for (size_t i = 0; importer_list_ && i < importer_list_->count(); ++i) { + const user_data_importer::SourceProfile& source_profile = + importer_list_->GetSourceProfileAt(i); -+ uint16_t services = source_profile.services_supported; ++ uint16_t services = SupportedImportItems(source_profile); + std::string browser_name = + base::UTF16ToUTF8(source_profile.importer_name); + std::string profile_name = base::UTF16ToUTF8(source_profile.profile); @@ -524,6 +597,11 @@ index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf8 + source.Set("isManaged", source_profile.is_managed); + source.Set("supportedItems", ImportItemsFromMask(services)); + source.Set("recommendedItems", ImportItemsFromMask(services)); ++ // Redrob: which browser family this is, and whether its saved passwords ++ // take the export-a-CSV route. Optional on the page, which ships ++ // separately from this binary. ++ source.Set("kind", SourceKind(source_profile)); ++ source.Set("passwordsViaCsv", PasswordsNeedCsvExport(source_profile)); + sources.Append(std::move(source)); + } + return sources; @@ -538,6 +616,8 @@ index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf8 + return "succeeded"; + case ImportSourceResultStatus::kFailed: + return "failed"; ++ case ImportSourceResultStatus::kCancelled: ++ return "cancelled"; + } + NOTREACHED(); + } @@ -669,6 +749,11 @@ index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf8 + } + + current_item_ = user_data_importer::NONE; ++ if (import_cancelled_) { ++ import_result_->status = ImportSourceResultStatus::kCancelled; ++ SendState("cancelled"); ++ return; ++ } + import_result_->status = import_did_succeed_ + ? ImportSourceResultStatus::kSucceeded + : ImportSourceResultStatus::kFailed; @@ -707,6 +792,7 @@ index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf8 + completed_items_ = user_data_importer::NONE; + imported_items_ = user_data_importer::NONE; + import_did_succeed_ = false; ++ import_cancelled_ = false; + import_result_.reset(); + } + @@ -723,6 +809,9 @@ index 0000000000000000000000000000000000000000..0809951dcf660ba1f9a3704bdcf5cbf8 + uint16_t imported_items_ = user_data_importer::NONE; + bool importer_list_loaded_ = false; + bool import_did_succeed_ = false; ++ // Redrob: set by HandleCancelImport() so ImportEnded() reports a stop, not ++ // a failure. ++ bool import_cancelled_ = false; + base::WeakPtrFactory weak_ptr_factory_{this}; +}; + From 05ec4d968fcbe0dbb390c4233415304afa6dcff8 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:40:49 +0000 Subject: [PATCH 02/29] feat(agent): send the task's page as redrob_context so engine skills arm (CAP-1, CAP-2) Every built-in-engine completion request now carries redrob_context.page_url and page_title: the agent's own tab once it has opened one, else the http(s) tab the user was on when the task started. Not sent to a user's own provider. Capability map rows for skills, auto-arming and the skill writer point at the engine. --- docs/CAPABILITY-MAP.md | 6 +- sources/chrome/browser/redrob/task_store.cc | 67 +++++++++++++++++++++ sources/chrome/browser/redrob/task_store.h | 10 +++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/docs/CAPABILITY-MAP.md b/docs/CAPABILITY-MAP.md index f70a6c3f2..a37ca0a23 100644 --- a/docs/CAPABILITY-MAP.md +++ b/docs/CAPABILITY-MAP.md @@ -56,9 +56,9 @@ Item ids in the last column refer to `docs/PRODUCT-BACKLOG.md`. | Code as the tool interface | The model writes a script instead of calling one tool at a time | yes — ours reaches MCP servers | — | | Typed domain objects in that script | First-class handles for the page, the screen, the mail account, the vault — not a tool list | no | CAP-12 (new) | | A real document toolchain in that script | Read AND write Word, Excel, PowerPoint, PDF with the actual libraries | no | CAP-13 (new) | -| Reusable skills as data | A folder with instructions; the engine already loads exactly this shape | partial — loader exists, we ship none | CAP-1 | -| Skills that arm themselves | Frontmatter naming URL patterns and keywords, so a skill loads when the tab or prompt matches | no | CAP-2 | -| A skill that writes skills | Turn a repeated instruction into a saved one | no | CAP-1 | +| Reusable skills as data | A folder with instructions; the engine already loads exactly this shape | yes — ten site skills ship in the engine (redrob-code `packages/core/src/plugin/skill/browser/`) | CAP-1 | +| Skills that arm themselves | Frontmatter naming URL patterns and keywords, so a skill loads when the tab or prompt matches | yes — engine arms on `urls:`/`keywords:`; the browser sends `redrob_context.page_url` (task_store.cc `RedrobContext`) | CAP-2 | +| A skill that writes skills | Turn a repeated instruction into a saved one | yes — engine built-in `skill-writer` | CAP-1 | | Curated tool-server presets | Named presets for desktop apps, not a blank server form | no | CAP-14 (new) | | Tool servers over local process and HTTP, with per-server tool inventories | Bring your own tools, and see which of them are live | partial — we support MCP; no inventory or presets | — | | Solving a human-verification challenge | The agent gets past a checkbox instead of stopping | no | — | diff --git a/sources/chrome/browser/redrob/task_store.cc b/sources/chrome/browser/redrob/task_store.cc index 18fc416a8..8fc75fb0d 100644 --- a/sources/chrome/browser/redrob/task_store.cc +++ b/sources/chrome/browser/redrob/task_store.cc @@ -29,6 +29,9 @@ #include "chrome/browser/redrob/redrob_prefs.h" #include "chrome/browser/profiles/profile.h" #include "chrome/browser/ui/thumbnails/thumbnail_tab_helper.h" +#include "chrome/browser/ui/browser_window/public/browser_window_interface.h" +#include "chrome/browser/ui/browser_window/public/profile_browser_collection.h" +#include "chrome/browser/ui/tabs/tab_strip_model.h" #include "components/tabs/public/tab_interface.h" #include "components/prefs/pref_service.h" #include "content/public/browser/web_contents.h" @@ -471,6 +474,7 @@ std::pair, std::optional> TaskStor } const std::string task_id = CreateTask(prompt, mode, effort, model); + CaptureContextTab(task_id); StartTurn(task_id); return {task_id, std::nullopt}; } @@ -580,6 +584,15 @@ void TaskStore::StartTurn(const std::string& task_id) { request.Set("messages", conversation->second.messages.Clone()); request.Set("tools", BrowserTools(task->second->mode, task->second->effort, memory_ && memory_->enabled())); + // CAP-1/CAP-2: the page the task is on, so the engine can arm the skills whose `urls:` match it. + // Built-in engine only: a user's own provider is a third-party endpoint, where an unknown field + // can be refused outright and where the URL the user is reading has no reason to go. + if (!provider) { + std::optional context = RedrobContext(conversation->second); + if (context.has_value()) { + request.Set("redrob_context", std::move(context).value()); + } + } std::optional body = base::WriteJson(request); if (!body.has_value()) { @@ -1570,4 +1583,58 @@ void TaskStore::Notify(const std::string& task_id) { } } +// --- CAP-1/CAP-2: page context for skill arming ------------------------------------------------- + +void TaskStore::CaptureContextTab(const std::string& task_id) { + auto conversation = conversations_.find(task_id); + if (conversation == conversations_.end() || !profile_) { + return; + } + BrowserWindowInterface* browser = + ProfileBrowserCollection::GetForProfile(profile_)->FindTabbedBrowser(); + if (!browser || !browser->GetTabStripModel()) { + return; + } + content::WebContents* active = browser->GetTabStripModel()->GetActiveWebContents(); + if (!active) { + return; + } + const GURL& url = active->GetLastCommittedURL(); + // Only an http(s) page counts as context. A chrome:// surface (the new tab + // page the task was typed on, Settings) says nothing about which site skill applies. + if (!url.is_valid() || !url.SchemeIsHTTPOrHTTPS()) { + return; + } + conversation->second.context_url = url.spec(); + conversation->second.context_title = base::UTF16ToUTF8(active->GetTitle()); +} + +// static +std::optional TaskStore::RedrobContext(const Conversation& conversation) { + std::string url; + std::string title; + // The agent's own tab wins: once the task has opened a page, that page is what its next step acts + // on, so it is the page a site skill has to describe. + if (content::WebContents* page = conversation.page.get()) { + const GURL& committed = page->GetLastCommittedURL(); + if (committed.is_valid() && committed.SchemeIsHTTPOrHTTPS()) { + url = committed.spec(); + title = base::UTF16ToUTF8(page->GetTitle()); + } + } + if (url.empty()) { + url = conversation.context_url; + title = conversation.context_title; + } + if (url.empty()) { + return std::nullopt; + } + base::DictValue context; + context.Set("page_url", url); + if (!title.empty()) { + context.Set("page_title", title); + } + return context; +} + } // namespace redrob diff --git a/sources/chrome/browser/redrob/task_store.h b/sources/chrome/browser/redrob/task_store.h index 29f91e829..e0a5a1d53 100644 --- a/sources/chrome/browser/redrob/task_store.h +++ b/sources/chrome/browser/redrob/task_store.h @@ -164,6 +164,11 @@ class TaskStore : public KeyedService, public SidecarClient::CompletionDelegate // turns' text appended to one string ran together mid-sentence, which read as one confused // statement rather than as an answer and then a correction. bool turn_text_started = false; + // CAP-1: the http(s) tab the user was on when they started the task, sent as + // redrob_context until the agent opens a tab of its own. URL and title only -- never the + // page's contents, and never anything the agent may act on. + std::string context_url; + std::string context_title; }; void OnSidecarStatusChanged(SidecarClient::Status status); @@ -203,6 +208,11 @@ class TaskStore : public KeyedService, public SidecarClient::CompletionDelegate // Prose arrives token by token; a Mojo message per token would send the whole task hundreds of times // for one paragraph. Coalesced so the page updates promptly without that traffic. void ScheduleNotify(const std::string& task_id); + // CAP-1: records the user's active http(s) tab on a new task (see Conversation::context_url). + void CaptureContextTab(const std::string& task_id); + // CAP-1: {page_url, page_title} of the task's agent tab, else of the tab it started from; nullopt + // when neither is an http(s) page. + static std::optional RedrobContext(const Conversation& conversation); std::map tasks_; std::map conversations_; From 316ad571a056a93044502d81350a8ae36d5f6ce0 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:41:52 +0000 Subject: [PATCH 03/29] feat(onboarding): per-item import progress, Cancel, password CSV row The import step now shows one progress bar per item type under the overall bar (done / copying / waiting, from the importer's item start and end callbacks) and a Cancel button that sends browserosOnboardingCancelImport; a cancelled import returns to the picker with a note that anything already copied stays. Sources that do not hand over saved passwords (Firefox, Safari, Chromium-family) get a row explaining the CSV export route, with a button opening the password manager. redrobPrivate's import path drops the Firefox password bit the same way. Strings in l10n/extension/70-onboarding.tsv; CAPABILITY-MAP rows for ONB-4 and ONB-5 updated. --- .../app-onboard/src/onboarding/Onboarding.tsx | 4 +- .../onboarding/browseros-onboarding-api.ts | 8 ++ .../onboarding/browseros-onboarding-bridge.ts | 41 ++++++++- .../components/ImportingProgressCard.tsx | 89 ++++++++++++++++++- .../src/onboarding/onboarding-v2.helpers.ts | 12 +++ .../src/onboarding/steps/ImportStep.tsx | 60 +++++++++++++ agent/packages/i18n/src/messages.generated.ts | 18 ++++ docs/CAPABILITY-MAP.md | 4 +- l10n/extension/70-onboarding.tsv | 11 +++ .../api/redrob_private/redrob_private_api.cc | 7 +- .../redrob_private_import_manager.cc | 7 +- 11 files changed, 253 insertions(+), 8 deletions(-) diff --git a/agent/apps/app-onboard/src/onboarding/Onboarding.tsx b/agent/apps/app-onboard/src/onboarding/Onboarding.tsx index 1fb58d08a..a86b073d6 100644 --- a/agent/apps/app-onboard/src/onboarding/Onboarding.tsx +++ b/agent/apps/app-onboard/src/onboarding/Onboarding.tsx @@ -39,7 +39,7 @@ const initialOnboardingState: BrowserOSOnboardingState = { sources: [], } -/** Maps Chromium importer status into the local three-step onboarding screen state. */ +/** Maps Chromium importer status into the local three-step onboarding screen state. A cancelled import returns to the picker. */ export function importPhaseFor(status: BrowserOSImportStatus): ImportPhase { if (status === 'importing') return 'importing' if (status === 'failed') return 'failed' @@ -174,6 +174,8 @@ export function Onboarding() { onImport={startImport} onRefresh={() => bridge.refreshSources()} onContinue={() => goTo(2)} + onCancel={() => bridge.cancelImport()} + onOpenPasswordManager={() => bridge.openPasswordManager()} /> )} {step === 2 && } diff --git a/agent/apps/app-onboard/src/onboarding/browseros-onboarding-api.ts b/agent/apps/app-onboard/src/onboarding/browseros-onboarding-api.ts index 979752c68..f19278409 100644 --- a/agent/apps/app-onboard/src/onboarding/browseros-onboarding-api.ts +++ b/agent/apps/app-onboard/src/onboarding/browseros-onboarding-api.ts @@ -23,6 +23,8 @@ export type BrowserOSImportStatus = | 'succeeded' | 'failed' | 'completed' + /** The user stopped the import; anything already copied stays. */ + | 'cancelled' /** Native owns extension preparation independently of the browser-data importer. */ export type BrowserOSSetupState = 'idle' | 'preparing' | 'failed' | 'ready' @@ -33,6 +35,7 @@ export const BrowserOSOnboardingMessage = { START_IMPORT: 'browserosOnboardingStartImport', COMPLETE: 'browserosOnboardingComplete', RETRY_SETUP: 'browserosOnboardingRetrySetup', + CANCEL_IMPORT: 'browserosOnboardingCancelImport', } as const export type BrowserOSOnboardingMessage = @@ -47,6 +50,10 @@ export interface BrowserOSImportSource { isManaged: boolean supportedItems: BrowserOSImportItem[] recommendedItems: BrowserOSImportItem[] + /** Browser family, e.g. 'firefox', 'safari', 'chromium'. Absent from older native builds. */ + kind?: string + /** Saved passwords are not copied from this source; the step offers the CSV export route. */ + passwordsViaCsv?: boolean } export interface BrowserOSImportProgress { @@ -68,6 +75,7 @@ export type BrowserOSImportSourceResultStatus = | 'importing' | 'succeeded' | 'failed' + | 'cancelled' export interface BrowserOSImportSourceResult { sourceId: string diff --git a/agent/apps/app-onboard/src/onboarding/browseros-onboarding-bridge.ts b/agent/apps/app-onboard/src/onboarding/browseros-onboarding-bridge.ts index a82944982..0523f3154 100644 --- a/agent/apps/app-onboard/src/onboarding/browseros-onboarding-bridge.ts +++ b/agent/apps/app-onboard/src/onboarding/browseros-onboarding-bridge.ts @@ -27,8 +27,15 @@ export interface BrowserOSOnboardingBridge { receiveState: (state: BrowserOSOnboardingState) => void, ): () => void startImport(request: BrowserOSStartImportRequest): void + /** Stops a running import. Items already copied stay in the profile. */ + cancelImport(): void + /** Opens the password manager, where an exported CSV is imported. */ + openPasswordManager(): void } +/** Where a CSV exported from another browser is imported. */ +export const PASSWORD_MANAGER_URL = 'chrome://password-manager/settings' + interface BrowserOSOnboardingBridgeOptions { chrome?: BrowserOSOnboardingChrome | null mockTiming?: 'delayed' | 'sync' @@ -75,10 +82,17 @@ function emitMockState(state: BrowserOSOnboardingState) { getHostWindow()?.browserosOnboarding?.receiveState(state) } +// Bumped by a mock cancel so progress scheduled for the stopped run is dropped. +let mockImportGeneration = 0 + function scheduleMockState(state: BrowserOSOnboardingState, delayMs: number) { const hostWindow = getHostWindow() const schedule = hostWindow?.setTimeout ?? globalThis.setTimeout - schedule(() => emitMockState(state), delayMs) + const generation = mockImportGeneration + schedule(() => { + if (generation !== mockImportGeneration) return + emitMockState(state) + }, delayMs) } function recommendedItemsFor(request: BrowserOSStartImportRequest) { @@ -302,5 +316,30 @@ export function createBrowserOSOnboardingBridge( } emitMockImport(request, mockIsSync) }, + cancelImport() { + if (currentState?.status !== 'importing') return + if (!isMock) { + chromeBridge.send(BrowserOSOnboardingMessage.CANCEL_IMPORT) + return + } + mockImportGeneration += 1 + const progress = currentState.progress + emitMockState( + createState( + 'cancelled', + progress ? { ...progress, currentItem: undefined } : undefined, + currentState.results?.map((result) => ({ + ...result, + status: 'cancelled' as const, + })), + ), + ) + }, + openPasswordManager() { + // window.open, not navigation: this page must stay where it is. In the + // first-run window Chromium hosts the opened page in its own popup; in a + // tab it opens beside this one. + getHostWindow()?.open(PASSWORD_MANAGER_URL, '_blank') + }, } } diff --git a/agent/apps/app-onboard/src/onboarding/components/ImportingProgressCard.tsx b/agent/apps/app-onboard/src/onboarding/components/ImportingProgressCard.tsx index e16a14c24..207d5bc82 100644 --- a/agent/apps/app-onboard/src/onboarding/components/ImportingProgressCard.tsx +++ b/agent/apps/app-onboard/src/onboarding/components/ImportingProgressCard.tsx @@ -8,21 +8,56 @@ */ import { t } from '@browseros/i18n' -import { RefreshCw } from '@browseros/icons' +import { RefreshCw, X } from '@browseros/icons' +import { Button } from '@/components/ui/button' +import type { BrowserOSImportItem } from '../browseros-onboarding-api' +import { importItemLabel } from '../onboarding-v2.helpers' interface ImportingProgressCardProps { currentItemLabel?: string progress: number sourceLabel?: string total: number + /** The items this import was asked for, in checklist order. */ + items?: readonly BrowserOSImportItem[] + completedItems?: readonly BrowserOSImportItem[] + currentItem?: BrowserOSImportItem + onCancel?: () => void } -/** Displays Chromium import progress while selected source data is copied. */ +type ItemState = 'done' | 'copying' | 'waiting' + +function itemStateFor( + item: BrowserOSImportItem, + completedItems: readonly BrowserOSImportItem[], + currentItem: BrowserOSImportItem | undefined, +): ItemState { + if (completedItems.includes(item)) return 'done' + if (item === currentItem) return 'copying' + return 'waiting' +} + +function itemStateLabel(state: ItemState): string { + if (state === 'done') return t('onboardItemStateDone') + if (state === 'copying') return t('onboardItemStateCopying') + return t('onboardItemStateWaiting') +} + +/** + * Displays import progress while selected source data is copied: an overall + * bar, then one bar per item type. The importer reports when each item starts + * and ends, not how far through one it is, so an item's bar is empty, moving + * (indeterminate) or full -- never a made-up percentage. + */ export function ImportingProgressCard({ currentItemLabel, progress, sourceLabel, total, + items = [], + completedItems = [], + currentItem, + onCancel, }: ImportingProgressCardProps) { const percent = total === 0 ? 0 : Math.min(100, (progress / total) * 100) return ( @@ -51,6 +86,56 @@ export function ImportingProgressCard({
{t('onboardItems', progress, total)}
+ {items.length > 0 && ( +
    + {items.map((item) => { + const state = itemStateFor(item, completedItems, currentItem) + return ( +
  • + + {importItemLabel(item)} + +
    +
    +
    + + {itemStateLabel(state)} + +
  • + ) + })} +
+ )} + {onCancel && ( +
+ +
+ )} ) } diff --git a/agent/apps/app-onboard/src/onboarding/onboarding-v2.helpers.ts b/agent/apps/app-onboard/src/onboarding/onboarding-v2.helpers.ts index bd1e0bd9f..98cad7ec3 100644 --- a/agent/apps/app-onboard/src/onboarding/onboarding-v2.helpers.ts +++ b/agent/apps/app-onboard/src/onboarding/onboarding-v2.helpers.ts @@ -65,6 +65,18 @@ export const MOCK_BROWSEROS_IMPORT_SOURCES: readonly BrowserOSImportSource[] = [ supportedItems: ['history', 'bookmarks', 'cookies', 'passwords'], recommendedItems: ['history', 'bookmarks', 'cookies'], }, + { + id: 'firefox-default', + displayName: 'Firefox - default-release', + browserName: 'Firefox', + profileName: 'default-release', + accountName: '', + isManaged: false, + kind: 'firefox', + passwordsViaCsv: true, + supportedItems: ['history', 'bookmarks', 'autofill'], + recommendedItems: ['history', 'bookmarks', 'autofill'], + }, ] export const DEFAULT_BROWSEROS_IMPORT_SOURCE_ID = diff --git a/agent/apps/app-onboard/src/onboarding/steps/ImportStep.tsx b/agent/apps/app-onboard/src/onboarding/steps/ImportStep.tsx index a4edd4195..4215f2a2c 100644 --- a/agent/apps/app-onboard/src/onboarding/steps/ImportStep.tsx +++ b/agent/apps/app-onboard/src/onboarding/steps/ImportStep.tsx @@ -13,6 +13,8 @@ import { ArrowRight, ChevronDown, Download, + ExternalLink, + KeyRound, Lock, RefreshCw, } from '@browseros/icons' @@ -54,6 +56,44 @@ interface ImportStepProps { onImport: () => void onRefresh: () => void onContinue: () => void + /** Stops a running import. */ + onCancel?: () => void + /** Opens the password manager for the CSV route. */ + onOpenPasswordManager?: () => void +} + +/** Sources whose saved passwords are not copied: say how to bring them anyway. */ +function PasswordsViaCsvNotice({ + browserName, + onOpenPasswordManager, +}: { + browserName: string + onOpenPasswordManager?: () => void +}) { + return ( +
+ +
+
+ {t('onboardPasswordsViaCsvTitle')} +
+

+ {t('onboardPasswordsViaCsvBody', browserName)} +

+ {onOpenPasswordManager && ( + + )} +
+
+ ) } function importButtonLabelFor( @@ -89,6 +129,8 @@ export function ImportStep({ onImport, onRefresh, onContinue, + onCancel, + onOpenPasswordManager, }: ImportStepProps) { const [permissionOpen, setPermissionOpen] = useState(false) const [showAllProfiles, setShowAllProfiles] = useState(false) @@ -274,6 +316,20 @@ export function ImportStep({ onToggle={toggleImportItem} /> )} + {selectedSource?.passwordsViaCsv && ( + + )} + {state.status === 'cancelled' && ( +
+ {t('onboardImportCancelled')} +
+ )} {state.error && (
{state.error.message} @@ -322,6 +378,10 @@ export function ImportStep({ progress={completedItems} sourceLabel={currentSourceLabel} total={totalItems} + items={checkedItems} + completedItems={importedItems} + currentItem={state.progress?.currentItem} + onCancel={onCancel} /> {needsPermission && } diff --git a/agent/packages/i18n/src/messages.generated.ts b/agent/packages/i18n/src/messages.generated.ts index 5668d7b49..4db83b0ac 100644 --- a/agent/packages/i18n/src/messages.generated.ts +++ b/agent/packages/i18n/src/messages.generated.ts @@ -700,6 +700,15 @@ const en = { onboardPreparingRedrob: "Preparing Redrob. This should only take a moment.", onboardSetupFailed: "Setup didn’t finish", onboardPleaseTryAgain: "Please try again.", + onboardCancelImport: "Cancel", + onboardImportCancelled: "Import stopped. Anything already copied stays in Redrob.", + onboardImportProgressByItem: "Progress by item", + onboardItemStateDone: "Done", + onboardItemStateCopying: "Copying…", + onboardItemStateWaiting: "Waiting", + onboardPasswordsViaCsvTitle: "Saved passwords come across as a file", + onboardPasswordsViaCsvBody: "$1 does not hand its saved passwords to other browsers. Export them from $1 as a CSV file, then import that file in Password Manager.", + onboardOpenPasswordManager: "Open Password Manager", clawLogins: "logins.", clawSet: "set!", clawAgents: "agents.", @@ -1758,6 +1767,15 @@ const ko: Record = { onboardPreparingRedrob: "Redrob을 준비하고 있습니다. 잠시만 기다려 주세요.", onboardSetupFailed: "설정을 마치지 못했습니다", onboardPleaseTryAgain: "다시 시도해 주세요.", + onboardCancelImport: "취소", + onboardImportCancelled: "가져오기를 멈췄습니다. 이미 복사된 항목은 Redrob에 남아 있습니다.", + onboardImportProgressByItem: "항목별 진행 상황", + onboardItemStateDone: "완료", + onboardItemStateCopying: "복사 중…", + onboardItemStateWaiting: "대기 중", + onboardPasswordsViaCsvTitle: "저장된 비밀번호는 파일로 가져옵니다", + onboardPasswordsViaCsvBody: "$1은(는) 저장된 비밀번호를 다른 브라우저에 직접 넘겨주지 않습니다. $1에서 비밀번호를 CSV 파일로 내보낸 뒤, 비밀번호 관리자에서 그 파일을 가져오세요.", + onboardOpenPasswordManager: "비밀번호 관리자 열기", clawLogins: "로그인 정보", clawSet: "준비 완료!", clawAgents: "에이전트", diff --git a/docs/CAPABILITY-MAP.md b/docs/CAPABILITY-MAP.md index f70a6c3f2..b74fdeb45 100644 --- a/docs/CAPABILITY-MAP.md +++ b/docs/CAPABILITY-MAP.md @@ -100,9 +100,9 @@ Item ids in the last column refer to `docs/PRODUCT-BACKLOG.md`. | capability | what a person gets | us | item | |---|---|---|---| | Import from other browsers | Bookmarks, history, cookies | yes — six Chromium-family browsers | — | -| Import from Firefox and Safari | The two families we cannot read today | no | ONB-5 | +| Import from Firefox and Safari | Firefox bookmarks, history and form data; Safari bookmarks and history (macOS). Saved passwords go by CSV export into the password manager, which the import step explains with a button | yes — upstream's own importers, offered by the onboarding import step; not yet proved against a real profile | ONB-5 | | Import a browser's ENCRYPTED store | Saved passwords and cookies protected by the OS. Needs a privileged service; the platform's app-bound encryption already ships with our fork, we just have nothing that uses it | no | ONB-5 | -| Import progress, cancellable | A long import you can watch and stop | no | ONB-4 | +| Import progress, cancellable | A long import you can watch and stop | yes — overall bar plus one bar per item type (done / copying / waiting; the importer reports item boundaries, not percentages) and a Cancel that stops the importer | ONB-4 | | A credential vault | Login, passkey, TOTP, identity, card, note, SSH key, API credential; biometric unlock | no — Chromium's own store is our default | CAP-9 | | First-class support for OTHER password managers | The product works with the manager you already use | no | — | | Multiple profiles with a switcher | Separate identities in one install | partial — Chromium profiles exist; no surface of ours | — | diff --git a/l10n/extension/70-onboarding.tsv b/l10n/extension/70-onboarding.tsv index 946f3aa4f..0d9918ea0 100644 --- a/l10n/extension/70-onboarding.tsv +++ b/l10n/extension/70-onboarding.tsv @@ -95,3 +95,14 @@ onboardFinishingSetup Finishing setup 설정을 마무리하는 중 Heading whil onboardPreparingRedrob Preparing Redrob. This should only take a moment. Redrob을 준비하고 있습니다. 잠시만 기다려 주세요. onboardSetupFailed Setup didn’t finish 설정을 마치지 못했습니다 onboardPleaseTryAgain Please try again. 다시 시도해 주세요. + +# Import: cancel, per-item progress, and the CSV route for passwords +onboardCancelImport Cancel 취소 Button that stops a running import +onboardImportCancelled Import stopped. Anything already copied stays in Redrob. 가져오기를 멈췄습니다. 이미 복사된 항목은 Redrob에 남아 있습니다. Shown on the picker after the user cancelled an import +onboardImportProgressByItem Progress by item 항목별 진행 상황 Accessible name of the per-item progress list +onboardItemStateDone Done 완료 One item finished copying +onboardItemStateCopying Copying… 복사 중… One item is being copied now +onboardItemStateWaiting Waiting 대기 중 One item has not started yet +onboardPasswordsViaCsvTitle Saved passwords come across as a file 저장된 비밀번호는 파일로 가져옵니다 Heading of the row shown for sources whose passwords cannot be copied directly +onboardPasswordsViaCsvBody $1 does not hand its saved passwords to other browsers. Export them from $1 as a CSV file, then import that file in Password Manager. $1은(는) 저장된 비밀번호를 다른 브라우저에 직접 넘겨주지 않습니다. $1에서 비밀번호를 CSV 파일로 내보낸 뒤, 비밀번호 관리자에서 그 파일을 가져오세요. $1 is the source browser's name, e.g. Firefox +onboardOpenPasswordManager Open Password Manager 비밀번호 관리자 열기 Button that opens the password manager to import a CSV file diff --git a/sources/chrome/browser/extensions/api/redrob_private/redrob_private_api.cc b/sources/chrome/browser/extensions/api/redrob_private/redrob_private_api.cc index 3dbf03f8d..2a8410759 100644 --- a/sources/chrome/browser/extensions/api/redrob_private/redrob_private_api.cc +++ b/sources/chrome/browser/extensions/api/redrob_private/redrob_private_api.cc @@ -169,7 +169,12 @@ void RedrobPrivateGetImportSourcesFunction::OnSourcesDetected() { const uint16_t services = profile.services_supported; source.has_bookmarks = (services & user_data_importer::FAVORITES) != 0; source.has_history = (services & user_data_importer::HISTORY) != 0; - source.has_passwords = (services & user_data_importer::PASSWORDS) != 0; + // Firefox passwords are NSS-encrypted and not carried (the onboarding step + // points at the CSV export instead), so the bit upstream sets on Windows and + // Linux is not passed on. + source.has_passwords = + (services & user_data_importer::PASSWORDS) != 0 && + profile.importer_type != user_data_importer::TYPE_FIREFOX; source.has_search_engines = (services & user_data_importer::SEARCH_ENGINES) != 0; // Always false, and not an oversight: upstream defines a COOKIES bit and diff --git a/sources/chrome/browser/extensions/api/redrob_private/redrob_private_import_manager.cc b/sources/chrome/browser/extensions/api/redrob_private/redrob_private_import_manager.cc index 7299c5583..f0264902b 100644 --- a/sources/chrome/browser/extensions/api/redrob_private/redrob_private_import_manager.cc +++ b/sources/chrome/browser/extensions/api/redrob_private/redrob_private_import_manager.cc @@ -89,7 +89,12 @@ void RedrobPrivateImportManager::OnSourcesDetected(int source_index) { const uint16_t wanted = user_data_importer::FAVORITES | user_data_importer::HISTORY | user_data_importer::PASSWORDS | user_data_importer::SEARCH_ENGINES; - const uint16_t items = source.services_supported & wanted; + uint16_t items = source.services_supported & wanted; + // Firefox passwords are NSS-encrypted and out of scope: the user exports a + // CSV from Firefox and imports it in the password manager instead. + if (source.importer_type == user_data_importer::TYPE_FIREFOX) { + items &= ~static_cast(user_data_importer::PASSWORDS); + } if (!items) { Broadcast(redrob_private::ToString(redrob_private::ImportState::kFailed), "There is nothing in that browser this one can read."); From 5899e6d3768d111077c43540ef16b2b5ef889031 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:41:55 +0000 Subject: [PATCH 04/29] feat(memory): file-based MemoryStore with runs, snapshots and revert (CAP-19) --- sources/chrome/browser/redrob/memory_store.cc | 978 +++++++++++++++--- sources/chrome/browser/redrob/memory_store.h | 187 +++- sources/chrome/browser/redrob/redrob_prefs.cc | 6 + sources/chrome/browser/redrob/redrob_prefs.h | 16 +- 4 files changed, 1010 insertions(+), 177 deletions(-) diff --git a/sources/chrome/browser/redrob/memory_store.cc b/sources/chrome/browser/redrob/memory_store.cc index 818e2a8d3..af460201f 100644 --- a/sources/chrome/browser/redrob/memory_store.cc +++ b/sources/chrome/browser/redrob/memory_store.cc @@ -5,13 +5,20 @@ #include "chrome/browser/redrob/memory_store.h" #include +#include #include +#include +#include "base/files/file_enumerator.h" +#include "base/files/file_util.h" #include "base/functional/bind.h" +#include "base/strings/string_number_conversions.h" #include "base/strings/string_split.h" #include "base/strings/string_util.h" +#include "base/strings/stringprintf.h" +#include "base/task/thread_pool.h" #include "base/time/time.h" -#include "base/uuid.h" +#include "base/values.h" #include "chrome/browser/profiles/profile.h" #include "chrome/browser/redrob/redrob_prefs.h" #include "components/prefs/pref_service.h" @@ -20,16 +27,26 @@ namespace redrob { namespace { -// One screen's worth, and a hard stop. Memory that outgrows the list that shows it has stopped being -// reviewable, and unreviewable memory is the failure mode this feature has to avoid. -constexpr size_t kMaxEntries = 200; -constexpr size_t kMaxTextBytes = 2000; constexpr size_t kMinTokenLength = 3; +constexpr size_t kMaxPathBytes = 200; +constexpr size_t kMaxSnippetBytes = 240; +constexpr char kSnapshotsDir[] = ".snapshots"; +constexpr char kEpisodicDir[] = "episodic"; -std::set Tokens(const std::string& text) { +constexpr char kIndexHeader[] = + "# Memory index\n\n" + "One line per file: what it holds. Rewritten by consolidation.\n\n" + "- USER.md: who the user is\n"; +constexpr char kUserHeader[] = "# About the user\n\n"; + +int64_t NowMs() { + return base::Time::Now().InMillisecondsSinceUnixEpoch(); +} + +std::set Tokens(std::string_view text) { std::set tokens; for (const std::string& piece : - base::SplitString(base::ToLowerASCII(text), " \t\n.,;:!?()[]{}\"'/\\-_", + base::SplitString(base::ToLowerASCII(text), " \t\n.,;:!?()[]{}\"'/\\-_#*`|<>=", base::TRIM_WHITESPACE, base::SPLIT_WANT_NONEMPTY)) { if (piece.size() >= kMinTokenLength) { tokens.insert(piece); @@ -38,191 +55,558 @@ std::set Tokens(const std::string& text) { return tokens; } -int64_t NowMs() { - return base::Time::Now().InMillisecondsSinceUnixEpoch(); +std::vector Lines(std::string_view text) { + return base::SplitString(text, "\n", base::KEEP_WHITESPACE, base::SPLIT_WANT_ALL); +} + +// +/- as a multiset difference of lines. Not a real diff -- a moved line counts as unchanged -- but it is +// the number a reviewer wants: how much did this run add, and how much did it take away. +std::pair LineDelta(const std::string& before, const std::string& after) { + std::map counts; + if (!before.empty()) { + for (const std::string& line : Lines(before)) { + ++counts[line]; + } + } + int added = 0; + if (!after.empty()) { + for (const std::string& line : Lines(after)) { + auto it = counts.find(line); + if (it != counts.end() && it->second > 0) { + --it->second; + } else { + ++added; + } + } + } + int removed = 0; + for (const auto& [line, count] : counts) { + removed += count; + } + return {added, removed}; } -MemoryStore::Entry EntryFromDict(const base::DictValue& dict) { - MemoryStore::Entry entry; +std::string DateName(base::Time time) { + base::Time::Exploded exploded; + time.LocalExplode(&exploded); + return base::StringPrintf("%04d-%02d-%02d", exploded.year, exploded.month, + exploded.day_of_month); +} + +const char* KindName(MemoryStore::RunKind kind) { + switch (kind) { + case MemoryStore::RunKind::kExtract: + return "extract"; + case MemoryStore::RunKind::kConsolidate: + return "consolidate"; + case MemoryStore::RunKind::kUser: + return "user"; + case MemoryStore::RunKind::kRevert: + return "revert"; + } + return "extract"; +} + +MemoryStore::RunKind KindFromName(const std::string& name) { + if (name == "consolidate") { + return MemoryStore::RunKind::kConsolidate; + } + if (name == "user") { + return MemoryStore::RunKind::kUser; + } + if (name == "revert") { + return MemoryStore::RunKind::kRevert; + } + return MemoryStore::RunKind::kExtract; +} + +MemoryStore::Run RunFromDict(const base::DictValue& dict) { + MemoryStore::Run run; if (const std::string* id = dict.FindString("id")) { - entry.id = *id; + run.id = *id; } - if (const std::string* text = dict.FindString("text")) { - entry.text = *text; + if (const std::string* kind = dict.FindString("kind")) { + run.kind = KindFromName(*kind); } - if (const std::string* source = dict.FindString("source")) { - entry.source = *source; + // Doubles because prefs have no 64-bit integer type; they hold this range exactly. + run.started_ms = static_cast(dict.FindDouble("started_ms").value_or(0)); + run.duration_ms = static_cast(dict.FindDouble("duration_ms").value_or(0)); + run.tokens = static_cast(dict.FindDouble("tokens").value_or(0)); + run.finished = dict.FindBool("finished").value_or(false); + run.has_snapshot = dict.FindBool("has_snapshot").value_or(false); + if (const base::ListValue* files = dict.FindList("files")) { + for (const base::Value& value : *files) { + const base::DictValue* file = value.GetIfDict(); + if (!file) { + continue; + } + MemoryStore::FileChange change; + if (const std::string* path = file->FindString("path")) { + change.path = *path; + } + change.added = file->FindInt("added").value_or(0); + change.removed = file->FindInt("removed").value_or(0); + run.files.push_back(std::move(change)); + } } - // Stored as a double because prefs have no 64-bit integer type and an epoch in milliseconds does not - // fit in the int one. Doubles hold this range exactly, so nothing is lost. - entry.created_at_ms = static_cast(dict.FindDouble("created_ms").value_or(0)); - entry.pinned = dict.FindBool("pinned").value_or(false); - return entry; + return run; } -base::DictValue DictFromEntry(const MemoryStore::Entry& entry) { +base::DictValue DictFromRun(const MemoryStore::Run& run) { base::DictValue dict; - dict.Set("id", entry.id); - dict.Set("text", entry.text); - dict.Set("source", entry.source); - dict.Set("created_ms", static_cast(entry.created_at_ms)); - dict.Set("pinned", entry.pinned); + dict.Set("id", run.id); + dict.Set("kind", KindName(run.kind)); + dict.Set("started_ms", static_cast(run.started_ms)); + dict.Set("duration_ms", static_cast(run.duration_ms)); + dict.Set("tokens", static_cast(run.tokens)); + dict.Set("finished", run.finished); + dict.Set("has_snapshot", run.has_snapshot); + base::ListValue files; + for (const MemoryStore::FileChange& change : run.files) { + base::DictValue file; + file.Set("path", change.path); + file.Set("added", change.added); + file.Set("removed", change.removed); + files.Append(std::move(file)); + } + dict.Set("files", std::move(files)); return dict; } -} // namespace +// --- Blocking side. Everything below runs on file_runner_ and only there. ---------------------------- -MemoryStore::MemoryStore(Profile* profile) : profile_(profile) { - Sweep(); - ScheduleSweep(); +base::FilePath Resolve(const base::FilePath& root, const std::string& relative) { + return root.Append(base::FilePath::FromUTF8Unsafe(relative)); } -MemoryStore::~MemoryStore() = default; +void EnsureLayout(const base::FilePath& root) { + if (!base::DirectoryExists(root)) { + base::CreateDirectory(root); + } + for (const char* dir : {kEpisodicDir, "sites", "people", "projects"}) { + const base::FilePath sub = root.AppendASCII(dir); + if (!base::DirectoryExists(sub)) { + base::CreateDirectory(sub); + } + } + const base::FilePath index = root.AppendASCII("MEMORY.md"); + if (!base::PathExists(index)) { + base::WriteFile(index, kIndexHeader); + } + const base::FilePath user = root.AppendASCII("USER.md"); + if (!base::PathExists(user)) { + base::WriteFile(user, kUserHeader); + } +} -bool MemoryStore::enabled() const { - return profile_ && profile_->GetPrefs()->GetBoolean(prefs::kMemoryEnabled); +// Every .md under the root except the snapshots. The relative path is re-validated, so a file someone +// dropped in by hand with a name the tools would refuse is not listed either: what the agent cannot +// address, it should not be shown. +std::vector ListTree(const base::FilePath& root) { + std::vector out; + if (!base::DirectoryExists(root)) { + return out; + } + base::FileEnumerator walk(root, /*recursive=*/true, base::FileEnumerator::FILES); + for (base::FilePath file = walk.Next(); !file.empty(); file = walk.Next()) { + base::FilePath relative; + if (!root.AppendRelativePath(file, &relative)) { + continue; + } + const std::string path = relative.NormalizePathSeparatorsTo('/').AsUTF8Unsafe(); + std::optional valid = MemoryStore::ValidatePath(path); + if (!valid) { + continue; + } + const base::FileEnumerator::FileInfo info = walk.GetInfo(); + MemoryStore::FileInfo entry; + entry.path = *valid; + entry.bytes = info.GetSize(); + entry.modified_ms = info.GetLastModifiedTime().InMillisecondsSinceUnixEpoch(); + out.push_back(std::move(entry)); + } + std::sort(out.begin(), out.end(), + [](const auto& a, const auto& b) { return a.path < b.path; }); + return out; } -int MemoryStore::retention_days() const { - return profile_ ? profile_->GetPrefs()->GetInteger(prefs::kMemoryRetentionDays) : 0; +std::vector IndexOnDisk(base::FilePath root) { + EnsureLayout(root); + return ListTree(root); +} + +int64_t TotalBytes(const std::vector& files) { + int64_t total = 0; + for (const auto& file : files) { + total += file.bytes; + } + return total; } -std::string MemoryStore::Remember(const std::string& text, const std::string& source) { - if (!enabled()) { - return std::string(); +// Copies the live tree into .snapshots//. The directory is created even for an empty tree, so +// "this run has a snapshot" never depends on whether there was anything to copy. +bool Snapshot(const base::FilePath& root, const std::string& run_id) { + const base::FilePath target = root.AppendASCII(kSnapshotsDir).AppendASCII(run_id); + if (base::DirectoryExists(target)) { + return true; } - const std::string trimmed(base::TrimWhitespaceASCII(text, base::TRIM_ALL)); - if (trimmed.empty() || trimmed.size() > kMaxTextBytes) { - return std::string(); + if (!base::CreateDirectory(target)) { + return false; } + for (const MemoryStore::FileInfo& file : ListTree(root)) { + const base::FilePath to = Resolve(target, file.path); + if (!base::CreateDirectory(to.DirName()) || !base::CopyFile(Resolve(root, file.path), to)) { + return false; + } + } + return true; +} - ScopedListPrefUpdate update(profile_->GetPrefs(), prefs::kMemoryEntries); - for (const base::Value& value : *update) { - if (value.is_dict()) { - const std::string* existing = value.GetDict().FindString("text"); - if (existing && *existing == trimmed) { - // Already known. Returning the existing id rather than nothing lets the caller answer the - // engine truthfully -- the fact is remembered, it just did not need remembering twice. - const std::string* id = value.GetDict().FindString("id"); - return id ? *id : std::string(); - } +// Keeps the snapshots whose run rows are still kept, and nothing else. +void PruneSnapshots(const base::FilePath& root, const std::set& keep) { + const base::FilePath dir = root.AppendASCII(kSnapshotsDir); + if (!base::DirectoryExists(dir)) { + return; + } + base::FileEnumerator walk(dir, /*recursive=*/false, base::FileEnumerator::DIRECTORIES); + for (base::FilePath sub = walk.Next(); !sub.empty(); sub = walk.Next()) { + if (!keep.count(sub.BaseName().AsUTF8Unsafe())) { + base::DeletePathRecursively(sub); + } + } +} + +} // namespace + +struct MemoryStore::ChangeResult { + bool ok = false; + std::string message; + bool snapshotted = false; + FileChange change; + std::vector files; +}; + +namespace { + +MemoryStore::ChangeResult ChangeOnDisk(base::FilePath root, + std::string run_id, + bool needs_snapshot, + int op, + std::string path, + std::string content, + std::string old_text) { + MemoryStore::ChangeResult result; + EnsureLayout(root); + const base::FilePath file = Resolve(root, path); + + std::string before; + const bool exists = base::PathExists(file); + if (exists && !base::ReadFileToStringWithMaxSize(file, &before, MemoryStore::kMaxFileBytes * 2)) { + result.message = "That memory file could not be read."; + result.files = ListTree(root); + return result; + } + + // 0 = write, 1 = edit, 2 = delete; matches MemoryStore::Op. + std::string after; + if (op == 0) { + after = content; + } else if (op == 1) { + if (!exists) { + result.message = "There is no memory file at " + path + ". Use memory_write to create it."; + result.files = ListTree(root); + return result; + } + if (old_text.empty()) { + result.message = "memory_edit needs the exact text to replace."; + result.files = ListTree(root); + return result; } + const size_t first = before.find(old_text); + if (first == std::string::npos) { + result.message = "That text is not in " + path + ". Read the file and copy it exactly."; + result.files = ListTree(root); + return result; + } + if (before.find(old_text, first + old_text.size()) != std::string::npos) { + result.message = "That text appears more than once in " + path + + ". Include more of the surrounding line so it matches once."; + result.files = ListTree(root); + return result; + } + after = before; + after.replace(first, old_text.size(), content); + } else if (!exists) { + result.ok = true; + result.files = ListTree(root); + return result; } - Entry entry; - entry.id = base::Uuid::GenerateRandomV4().AsLowercaseString(); - entry.text = trimmed; - entry.source = source.substr(0, 200); - entry.created_at_ms = NowMs(); - update->Append(DictFromEntry(entry)); - while (update->size() > kMaxEntries) { - update->erase(update->begin()); + if (op != 2) { + if (after.size() > MemoryStore::kMaxFileBytes) { + result.message = "A memory file may hold at most 64 KiB. Summarise, or split it into files."; + result.files = ListTree(root); + return result; + } + const std::vector current = ListTree(root); + const int64_t total = TotalBytes(current) - static_cast(before.size()) + + static_cast(after.size()); + if (total > static_cast(MemoryStore::kMaxTotalBytes)) { + result.message = + "Memory is full (4 MiB in all). Consolidate or delete older episodic files first."; + result.files = current; + return result; + } } - return entry.id; + + // Snapshot BEFORE the first write of the run, on this same sequence, so nothing can land between. + if (needs_snapshot) { + if (!Snapshot(root, run_id)) { + result.message = "Memory could not be snapshotted, so nothing was changed."; + result.files = ListTree(root); + return result; + } + result.snapshotted = true; + } + + bool written = false; + if (op == 2) { + written = base::DeleteFile(file); + } else { + written = base::CreateDirectory(file.DirName()) && base::WriteFile(file, after); + } + if (!written) { + result.message = "That memory file could not be written."; + result.files = ListTree(root); + return result; + } + + const auto [added, removed] = LineDelta(before, after); + result.ok = true; + result.change.path = path; + result.change.added = added; + result.change.removed = removed; + result.files = ListTree(root); + return result; } -std::vector MemoryStore::Search(const std::string& query, - size_t limit) const { - std::vector hits; - if (!enabled() || limit == 0) { - return hits; +std::optional ReadOnDisk(base::FilePath root, std::string path) { + EnsureLayout(root); + std::string content; + if (!base::ReadFileToStringWithMaxSize(Resolve(root, path), &content, + MemoryStore::kMaxFileBytes * 2)) { + return std::nullopt; } + return content; +} + +std::vector SearchOnDisk(base::FilePath root, + std::string query, + size_t limit) { + std::vector hits; const std::set wanted = Tokens(query); - if (wanted.empty()) { + if (wanted.empty() || limit == 0) { return hits; } - - std::vector> scored; - for (const base::Value& value : profile_->GetPrefs()->GetList(prefs::kMemoryEntries)) { - if (!value.is_dict()) { + struct Scored { + size_t score; + MemoryStore::SearchHit hit; + }; + std::vector scored; + for (const MemoryStore::FileInfo& file : ListTree(root)) { + std::string content; + if (!base::ReadFileToStringWithMaxSize(Resolve(root, file.path), &content, + MemoryStore::kMaxFileBytes * 2)) { continue; } - Entry entry = EntryFromDict(value.GetDict()); - const std::set have = Tokens(entry.text); - size_t score = 0; - for (const std::string& token : wanted) { - if (have.count(token)) { - ++score; + // The file's name counts as part of every line in it: a search for a site should find its file. + const std::set name_tokens = Tokens(file.path); + int number = 0; + for (const std::string& line : Lines(content)) { + ++number; + const std::string_view trimmed = base::TrimWhitespaceASCII(line, base::TRIM_ALL); + if (trimmed.empty() || trimmed.front() == '#') { + continue; } + const std::set have = Tokens(trimmed); + size_t score = 0; + for (const std::string& token : wanted) { + if (have.count(token)) { + score += 2; + } else if (name_tokens.count(token)) { + score += 1; + } + } + if (score == 0) { + continue; + } + MemoryStore::SearchHit hit; + hit.path = file.path; + hit.line = number; + hit.snippet = std::string(trimmed.substr(0, kMaxSnippetBytes)); + scored.push_back({score, std::move(hit)}); } - // A pinned entry is one the user said matters, so it needs less overlap to be worth surfacing -- - // but it still needs some, or every search would return it. - if (score == 0) { - continue; - } - if (entry.pinned) { - ++score; - } - scored.emplace_back(score, std::move(entry)); } std::stable_sort(scored.begin(), scored.end(), - [](const auto& a, const auto& b) { return a.first > b.first; }); - for (auto& [score, entry] : scored) { + [](const Scored& a, const Scored& b) { return a.score > b.score; }); + for (Scored& entry : scored) { if (hits.size() >= limit) { break; } - hits.push_back(std::move(entry)); + hits.push_back(std::move(entry.hit)); } return hits; } -std::vector MemoryStore::List() const { - std::vector entries; - if (!profile_) { - return entries; +MemoryStore::ChangeResult RevertOnDisk(base::FilePath root, + std::string target_run_id, + std::string revert_run_id) { + MemoryStore::ChangeResult result; + const base::FilePath source = root.AppendASCII(kSnapshotsDir).AppendASCII(target_run_id); + if (!base::DirectoryExists(source)) { + result.message = "That run's snapshot is no longer kept."; + result.files = ListTree(root); + return result; } - // Newest first, which is the order someone auditing this reads it in. - for (const base::Value& value : profile_->GetPrefs()->GetList(prefs::kMemoryEntries)) { - if (value.is_dict()) { - entries.push_back(EntryFromDict(value.GetDict())); - } + // The revert is a run of its own, so the state it replaces is snapshotted and can come back. + if (!Snapshot(root, revert_run_id)) { + result.message = "Memory could not be snapshotted, so nothing was reverted."; + result.files = ListTree(root); + return result; } - std::reverse(entries.begin(), entries.end()); - return entries; + result.snapshotted = true; + for (const MemoryStore::FileInfo& file : ListTree(root)) { + base::DeleteFile(Resolve(root, file.path)); + } + for (const MemoryStore::FileInfo& file : ListTree(source)) { + const base::FilePath to = Resolve(root, file.path); + base::CreateDirectory(to.DirName()); + base::CopyFile(Resolve(source, file.path), to); + } + EnsureLayout(root); + result.ok = true; + result.files = ListTree(root); + return result; } -bool MemoryStore::Remove(const std::string& id) { - if (!profile_ || id.empty()) { - return false; +std::vector SweepOnDisk(base::FilePath root, + int days, + std::set keep_snapshots) { + EnsureLayout(root); + if (days > 0) { + // Episodic files are named for their day, so the name IS the age; the mtime is not, because + // extraction appends to today's file and a revert copies files back with a fresh mtime. + const std::string cutoff = DateName(base::Time::Now() - base::Days(days)); + base::FileEnumerator walk(root.AppendASCII(kEpisodicDir), /*recursive=*/false, + base::FileEnumerator::FILES, FILE_PATH_LITERAL("*.md")); + for (base::FilePath file = walk.Next(); !file.empty(); file = walk.Next()) { + const std::string name = file.BaseName().RemoveFinalExtension().AsUTF8Unsafe(); + if (name.size() == 10 && name < cutoff) { + base::DeleteFile(file); + } + } } - ScopedListPrefUpdate update(profile_->GetPrefs(), prefs::kMemoryEntries); - for (auto it = update->begin(); it != update->end(); ++it) { - if (!it->is_dict()) { + PruneSnapshots(root, keep_snapshots); + return ListTree(root); +} + +struct LegacyEntry { + std::string text; + std::string source; + int64_t created_ms = 0; + bool pinned = false; +}; + +std::vector MigrateOnDisk(base::FilePath root, + std::vector entries) { + EnsureLayout(root); + std::map episodic; + std::string user; + for (const LegacyEntry& entry : entries) { + const std::string line = "- " + entry.text + + (entry.source.empty() ? std::string() : " (from: " + entry.source + ")") + + "\n"; + if (entry.pinned) { + user += line; continue; } - const std::string* existing = it->GetDict().FindString("id"); - if (existing && *existing == id) { - update->erase(it); - return true; + const base::Time created = entry.created_ms > 0 + ? base::Time::FromMillisecondsSinceUnixEpoch( + static_cast(entry.created_ms)) + : base::Time::Now(); + episodic[DateName(created)] += line; + } + for (const auto& [day, lines] : episodic) { + const base::FilePath file = root.AppendASCII(kEpisodicDir).AppendASCII(day + ".md"); + std::string existing; + if (!base::ReadFileToString(file, &existing)) { + existing = "# " + day + "\n\n"; } + base::WriteFile(file, existing + lines); } - return false; + if (!user.empty()) { + const base::FilePath file = root.AppendASCII("USER.md"); + std::string existing; + base::ReadFileToString(file, &existing); + base::WriteFile(file, existing + user); + } + return ListTree(root); } -bool MemoryStore::SetPinned(const std::string& id, bool pinned) { - if (!profile_ || id.empty()) { - return false; +void ClearOnDisk(base::FilePath root) { + base::DeletePathRecursively(root); + EnsureLayout(root); +} + +} // namespace + +// static +std::optional MemoryStore::ValidatePath(const std::string& raw) { + const std::string path(base::TrimWhitespaceASCII(raw, base::TRIM_ALL)); + if (path.empty() || path.size() > kMaxPathBytes || path.front() == '/' || path.back() == '/') { + return std::nullopt; } - ScopedListPrefUpdate update(profile_->GetPrefs(), prefs::kMemoryEntries); - for (base::Value& value : *update) { - if (!value.is_dict()) { - continue; + // The character class is the traversal check: no backslash, no colon, no drive letter, no NUL. + for (char c : path) { + const bool allowed = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || + c == '_' || c == '/' || c == '.'; + // The two upper-case names the layout defines are allowed; nothing else upper-case is. + if (!allowed && !(path == "MEMORY.md" || path == "USER.md")) { + return std::nullopt; } - const std::string* existing = value.GetDict().FindString("id"); - if (existing && *existing == id) { - value.GetDict().Set("pinned", pinned); - return true; + } + if (!base::EndsWith(path, ".md")) { + return std::nullopt; + } + for (const std::string& segment : + base::SplitString(path, "/", base::KEEP_WHITESPACE, base::SPLIT_WANT_ALL)) { + // Empty ("a//b"), "." and ".." are refused, and so is any segment starting with a dot: that is + // what keeps .snapshots out of reach of the tools. + if (segment.empty() || segment.front() == '.') { + return std::nullopt; } } - return false; + return path; } -void MemoryStore::Clear() { +MemoryStore::MemoryStore(Profile* profile) + : profile_(profile), + file_runner_(base::ThreadPool::CreateSequencedTaskRunner( + {base::MayBlock(), base::TaskPriority::USER_VISIBLE, + // A write that has started finishes: half a memory file is worse than a late one. + base::TaskShutdownBehavior::BLOCK_SHUTDOWN})) { if (profile_) { - // Everything, pinned included. "Forget everything" that quietly kept some of it would be a lie - // told at the exact moment the user was trying to be sure. - profile_->GetPrefs()->SetList(prefs::kMemoryEntries, base::ListValue()); + root_ = profile_->GetPath().AppendASCII("Redrob").AppendASCII("memory"); } + MigrateFromPrefs(); + Sweep(); + ScheduleSweep(); +} + +MemoryStore::~MemoryStore() = default; + +bool MemoryStore::enabled() const { + return profile_ && profile_->GetPrefs()->GetBoolean(prefs::kMemoryEnabled); } void MemoryStore::SetEnabled(bool enabled) { @@ -231,6 +615,10 @@ void MemoryStore::SetEnabled(bool enabled) { } } +int MemoryStore::retention_days() const { + return profile_ ? profile_->GetPrefs()->GetInteger(prefs::kMemoryRetentionDays) : 0; +} + void MemoryStore::SetRetentionDays(int days) { if (!profile_) { return; @@ -241,27 +629,333 @@ void MemoryStore::SetRetentionDays(int days) { Sweep(); } -void MemoryStore::Sweep() { - if (!profile_) { +bool MemoryStore::history_input() const { + return profile_ && profile_->GetPrefs()->GetBoolean(prefs::kMemoryHistoryInput); +} + +void MemoryStore::SetHistoryInput(bool enabled) { + if (profile_) { + profile_->GetPrefs()->SetBoolean(prefs::kMemoryHistoryInput, enabled); + } +} + +std::string MemoryStore::BeginRun(RunKind kind) { + // Unix milliseconds, bumped past the previous id so two runs in one millisecond never share a + // snapshot directory. + int64_t now = NowMs(); + for (const Run& run : Runs()) { + int64_t existing = 0; + if (base::StringToInt64(run.id, &existing)) { + last_run_id_ms_ = std::max(last_run_id_ms_, existing); + } + } + if (now <= last_run_id_ms_) { + now = last_run_id_ms_ + 1; + } + last_run_id_ms_ = now; + + Run run; + run.id = base::NumberToString(now); + run.kind = kind; + run.started_ms = NowMs(); + if (profile_) { + ScopedListPrefUpdate update(profile_->GetPrefs(), prefs::kMemoryRuns); + update->Append(DictFromRun(run)); + while (update->size() > kMaxRuns) { + update->erase(update->begin()); + } + } + return run.id; +} + +void MemoryStore::EndRun(const std::string& run_id, int64_t tokens) { + std::optional run = FindRun(run_id); + if (!run || run->finished) { return; } - const int days = retention_days(); - ScopedListPrefUpdate update(profile_->GetPrefs(), prefs::kMemoryEntries); - if (days > 0) { - const int64_t cutoff = NowMs() - base::Days(days).InMilliseconds(); - update->EraseIf([cutoff](const base::Value& value) { - if (!value.is_dict()) { - return true; + run->finished = true; + run->duration_ms = std::max(0, NowMs() - run->started_ms); + run->tokens = tokens; + SaveRun(*run); + // Snapshots beyond the kept runs go now rather than at the next daily sweep. + if (!root_.empty()) { + std::set keep; + for (const Run& kept : Runs()) { + keep.insert(kept.id); + } + file_runner_->PostTask(FROM_HERE, base::BindOnce(&PruneSnapshots, root_, std::move(keep))); + } + if (run->kind == RunKind::kConsolidate && profile_) { + profile_->GetPrefs()->SetDouble(prefs::kMemoryLastConsolidationMs, + static_cast(run->started_ms)); + } +} + +std::vector MemoryStore::Runs() const { + std::vector runs; + if (!profile_) { + return runs; + } + for (const base::Value& value : profile_->GetPrefs()->GetList(prefs::kMemoryRuns)) { + if (value.is_dict()) { + runs.push_back(RunFromDict(value.GetDict())); + } + } + std::reverse(runs.begin(), runs.end()); + return runs; +} + +int64_t MemoryStore::last_consolidation_ms() const { + return profile_ ? static_cast( + profile_->GetPrefs()->GetDouble(prefs::kMemoryLastConsolidationMs)) + : 0; +} + +int MemoryStore::ExtractionRunsSinceConsolidation() const { + const int64_t since = last_consolidation_ms(); + int count = 0; + for (const Run& run : Runs()) { + if (run.kind == RunKind::kExtract && run.finished && run.started_ms > since) { + ++count; + } + } + return count; +} + +std::optional MemoryStore::FindRun(const std::string& run_id) const { + if (!profile_ || run_id.empty()) { + return std::nullopt; + } + for (const base::Value& value : profile_->GetPrefs()->GetList(prefs::kMemoryRuns)) { + if (value.is_dict()) { + const std::string* id = value.GetDict().FindString("id"); + if (id && *id == run_id) { + return RunFromDict(value.GetDict()); } - if (value.GetDict().FindBool("pinned").value_or(false)) { - return false; + } + } + return std::nullopt; +} + +void MemoryStore::SaveRun(const Run& run) { + if (!profile_) { + return; + } + ScopedListPrefUpdate update(profile_->GetPrefs(), prefs::kMemoryRuns); + for (base::Value& value : *update) { + if (!value.is_dict()) { + continue; + } + const std::string* id = value.GetDict().FindString("id"); + if (id && *id == run.id) { + value = base::Value(DictFromRun(run)); + return; + } + } +} + +void MemoryStore::ReadFile(const std::string& path, ReadCallback callback) { + std::optional valid = ValidatePath(path); + if (!valid || root_.empty()) { + std::move(callback).Run(std::nullopt); + return; + } + file_runner_->PostTaskAndReplyWithResult(FROM_HERE, + base::BindOnce(&ReadOnDisk, root_, *valid), + std::move(callback)); +} + +void MemoryStore::WriteFile(const std::string& run_id, + const std::string& path, + const std::string& content, + DoneCallback callback) { + Change(run_id, Op::kWrite, path, content, std::string(), std::move(callback)); +} + +void MemoryStore::EditFile(const std::string& run_id, + const std::string& path, + const std::string& old_text, + const std::string& new_text, + DoneCallback callback) { + Change(run_id, Op::kEdit, path, new_text, old_text, std::move(callback)); +} + +void MemoryStore::DeleteFile(const std::string& run_id, + const std::string& path, + DoneCallback callback) { + Change(run_id, Op::kDelete, path, std::string(), std::string(), std::move(callback)); +} + +void MemoryStore::Change(const std::string& run_id, + Op op, + const std::string& path, + const std::string& content, + const std::string& old_text, + DoneCallback callback) { + std::optional run = FindRun(run_id); + if (!run || run->finished) { + // No run, no write. This is the boundary: nothing reaches the disk without a run row to show for it. + std::move(callback).Run(false, "There is no open memory run for that change."); + return; + } + std::optional valid = ValidatePath(path); + if (!valid || root_.empty()) { + std::move(callback).Run( + false, + "That is not a memory path. Use a relative path ending in .md, with only lower-case letters, " + "digits, '-', '_' and '/', for example sites/example.com.md or episodic/2026-01-31.md."); + return; + } + const bool needs_snapshot = !run->has_snapshot; + if (needs_snapshot) { + // Marked now rather than on reply: a second change in the same run is posted behind this one on the + // same sequence, so it must not ask for a second snapshot of a tree the first one already changed. + run->has_snapshot = true; + SaveRun(*run); + } + file_runner_->PostTaskAndReplyWithResult( + FROM_HERE, + base::BindOnce(&ChangeOnDisk, root_, run_id, needs_snapshot, static_cast(op), *valid, + content, old_text), + base::BindOnce(&MemoryStore::OnChanged, weak_factory_.GetWeakPtr(), run_id, + std::move(callback))); +} + +void MemoryStore::OnChanged(std::string run_id, DoneCallback callback, ChangeResult result) { + files_ = std::move(result.files); + if (result.ok && !result.change.path.empty()) { + if (std::optional run = FindRun(run_id)) { + auto existing = std::find_if(run->files.begin(), run->files.end(), + [&](const FileChange& c) { return c.path == result.change.path; }); + if (existing == run->files.end()) { + run->files.push_back(result.change); + } else { + existing->added += result.change.added; + existing->removed += result.change.removed; } - return static_cast(value.GetDict().FindDouble("created_ms").value_or(0)) < cutoff; - }); + SaveRun(*run); + } + } + std::move(callback).Run(result.ok, result.message); +} + +void MemoryStore::Search(const std::string& query, size_t limit, SearchCallback callback) { + if (!enabled() || root_.empty()) { + std::move(callback).Run({}); + return; + } + file_runner_->PostTaskAndReplyWithResult( + FROM_HERE, base::BindOnce(&SearchOnDisk, root_, query, limit), std::move(callback)); +} + +void MemoryStore::Revert(const std::string& run_id, DoneCallback callback) { + std::optional target = FindRun(run_id); + if (!target || !target->has_snapshot || root_.empty()) { + std::move(callback).Run(false, "That run changed nothing, or its snapshot is no longer kept."); + return; + } + const std::string revert_id = BeginRun(RunKind::kRevert); + if (std::optional revert = FindRun(revert_id)) { + revert->has_snapshot = true; + SaveRun(*revert); + } + file_runner_->PostTaskAndReplyWithResult( + FROM_HERE, base::BindOnce(&RevertOnDisk, root_, run_id, revert_id), + base::BindOnce( + [](base::WeakPtr store, std::string revert_id, DoneCallback callback, + ChangeResult result) { + if (!store) { + return; + } + store->files_ = std::move(result.files); + store->EndRun(revert_id, 0); + std::move(callback).Run(result.ok, result.message); + }, + weak_factory_.GetWeakPtr(), revert_id, std::move(callback))); +} + +void MemoryStore::Clear() { + if (!profile_) { + return; + } + profile_->GetPrefs()->SetList(prefs::kMemoryRuns, base::ListValue()); + profile_->GetPrefs()->SetDouble(prefs::kMemoryLastConsolidationMs, 0); + files_.clear(); + if (!root_.empty()) { + file_runner_->PostTaskAndReply(FROM_HERE, base::BindOnce(&ClearOnDisk, root_), + base::BindOnce(&MemoryStore::RefreshIndex, + weak_factory_.GetWeakPtr())); + } +} + +void MemoryStore::RefreshIndex() { + if (root_.empty()) { + return; + } + file_runner_->PostTaskAndReplyWithResult( + FROM_HERE, base::BindOnce(&IndexOnDisk, root_), + base::BindOnce(&MemoryStore::OnIndex, weak_factory_.GetWeakPtr())); +} + +void MemoryStore::OnIndex(std::vector files) { + files_ = std::move(files); +} + +void MemoryStore::MigrateFromPrefs() { + if (!profile_ || root_.empty() || profile_->GetPrefs()->GetBoolean(prefs::kMemoryMigrated)) { + return; + } + std::vector entries; + for (const base::Value& value : profile_->GetPrefs()->GetList(prefs::kMemoryEntries)) { + const base::DictValue* dict = value.GetIfDict(); + if (!dict) { + continue; + } + LegacyEntry entry; + if (const std::string* text = dict->FindString("text")) { + entry.text = *text; + } + if (const std::string* source = dict->FindString("source")) { + entry.source = *source; + } + entry.created_ms = static_cast(dict->FindDouble("created_ms").value_or(0)); + entry.pinned = dict->FindBool("pinned").value_or(false); + // A line break inside an entry would split it across two bullets; the old store never had one, + // but it was a renderer-reachable string. + std::string flat; + base::ReplaceChars(entry.text, "\r\n", " ", &flat); + entry.text = std::move(flat); + if (!entry.text.empty()) { + entries.push_back(std::move(entry)); + } + } + // The flag and the old list are cleared only once the files are written, so a crash mid-move + // repeats the move rather than losing the entries. + file_runner_->PostTaskAndReplyWithResult( + FROM_HERE, base::BindOnce(&MigrateOnDisk, root_, std::move(entries)), + base::BindOnce( + [](base::WeakPtr store, std::vector files) { + if (!store || !store->profile_) { + return; + } + store->files_ = std::move(files); + store->profile_->GetPrefs()->SetList(prefs::kMemoryEntries, base::ListValue()); + store->profile_->GetPrefs()->SetBoolean(prefs::kMemoryMigrated, true); + }, + weak_factory_.GetWeakPtr())); +} + +void MemoryStore::Sweep() { + if (root_.empty()) { + return; } - while (update->size() > kMaxEntries) { - update->erase(update->begin()); + std::set keep; + for (const Run& run : Runs()) { + keep.insert(run.id); } + file_runner_->PostTaskAndReplyWithResult( + FROM_HERE, base::BindOnce(&SweepOnDisk, root_, retention_days(), std::move(keep)), + base::BindOnce(&MemoryStore::OnIndex, weak_factory_.GetWeakPtr())); } void MemoryStore::ScheduleSweep() { diff --git a/sources/chrome/browser/redrob/memory_store.h b/sources/chrome/browser/redrob/memory_store.h index 9eda66ce2..433763aa7 100644 --- a/sources/chrome/browser/redrob/memory_store.h +++ b/sources/chrome/browser/redrob/memory_store.h @@ -5,11 +5,16 @@ #ifndef CHROME_BROWSER_REDROB_MEMORY_STORE_H_ #define CHROME_BROWSER_REDROB_MEMORY_STORE_H_ +#include #include #include +#include "base/files/file_path.h" +#include "base/functional/callback.h" #include "base/memory/raw_ptr.h" +#include "base/memory/scoped_refptr.h" #include "base/memory/weak_ptr.h" +#include "base/task/sequenced_task_runner.h" #include "base/timer/wall_clock_timer.h" #include "components/keyed_service/core/keyed_service.h" @@ -17,28 +22,82 @@ class Profile; namespace redrob { -// What the agent is allowed to remember between tasks. +// What the agent is allowed to remember between tasks: a folder of markdown files in the profile. // -// Prefs, not a database. That is a deliberate ceiling rather than a shortcut: memory the user cannot -// read and delete in one screen is memory they cannot reason about, and a store bounded small enough -// to render in one list is a store that stays reviewable. When this stops being enough the honest move -// is a paged UI first and a database second. +// /Redrob/memory/ +// MEMORY.md the index, one line per file +// USER.md who the user is +// episodic/YYYY-MM-DD.md what happened, day by day +// sites/.md paths through a site that worked +// people/ projects/ durable facts, moved there by consolidation +// .snapshots// the whole tree as it was before a run first wrote to it // -// Nothing is remembered implicitly. An entry exists because the agent called `remember` while working -// on a task the user asked for, and every entry carries the task's prompt as its source so the answer -// to "why do you know that" is in the row. +// Why it changed. This used to be one bounded list of sentences in prefs, chosen so the whole of it +// fitted on one screen and stayed reviewable. That bound was the only review it had: you could read the +// list and delete rows, but not see what a task had changed, and not undo it. Files are reviewable in a +// stronger way -- every write belongs to a RUN, every run snapshots the tree before its first write and +// records which files it touched with +/- line counts, and any run can be reverted from Settings. The +// size ceiling is still here (64 KiB a file, 4 MiB in all); it is just no longer the review mechanism. +// +// The write boundary. Nothing writes these files except this class, and this class writes only inside +// a run. The engine never touches the disk: it proposes memory_write / memory_edit tool calls, and +// TaskStore declares those tools ONLY to its hidden memory tasks (extraction after a task, and +// consolidation), so an ordinary task cannot rewrite memory as a side effect of browsing. A user edit +// in Settings is a run too ("user"), so it is snapshotted and revertible like the agent's. +// +// All disk work happens on one sequenced blocking task runner, which is also what orders "snapshot, +// then write" for a run without a lock. class MemoryStore : public KeyedService { public: - struct Entry { + enum class RunKind { + kExtract, + kConsolidate, + // An edit made by the user in Settings. + kUser, + // A revert of an earlier run; snapshotted itself, so a revert can be reverted. + kRevert, + }; + + struct FileInfo { + // Relative to the memory root, with '/' separators, e.g. "sites/example.com.md". + std::string path; + int64_t bytes = 0; + int64_t modified_ms = 0; + }; + + struct FileChange { + std::string path; + int added = 0; + int removed = 0; + }; + + struct Run { + // The unix time in milliseconds the run began, as a string. Also its snapshot's directory name. std::string id; - std::string text; - // The task that produced it, in the user's own words. - std::string source; - int64_t created_at_ms = 0; - // Pinned entries survive the retention sweep. The user's decision, never the agent's. - bool pinned = false; + RunKind kind = RunKind::kExtract; + int64_t started_ms = 0; + // 0 while the run is still going. + int64_t duration_ms = 0; + // 0 when the engine did not report usage, which is the common case. + int64_t tokens = 0; + std::vector files; + bool finished = false; + // Whether a snapshot was taken, i.e. whether the run wrote anything. Only such a run can be reverted. + bool has_snapshot = false; }; + struct SearchHit { + std::string path; + // 1-based. + int line = 0; + std::string snippet; + }; + + // `ok` false carries a message written for the engine (or the settings page) to act on. + using DoneCallback = base::OnceCallback; + using ReadCallback = base::OnceCallback content)>; + using SearchCallback = base::OnceCallback hits)>; + explicit MemoryStore(Profile* profile); MemoryStore(const MemoryStore&) = delete; MemoryStore& operator=(const MemoryStore&) = delete; @@ -46,34 +105,96 @@ class MemoryStore : public KeyedService { // False when memory is switched off, which is the one case a caller must not treat as failure. bool enabled() const; + void SetEnabled(bool enabled); - // Returns the new entry's id, or empty when memory is off or the text is unusable. Deduplicates on - // exact text: an agent that re-learns the same fact each task should not fill the list with it. - std::string Remember(const std::string& text, const std::string& source); - - // The entries most relevant to `query`, best first, at most `limit`. Token overlap, deliberately: - // a fork does not get an embedding model for free, and a naive scorer that the user can see the - // consequences of beats a clever one whose misses are unexplainable. - std::vector Search(const std::string& query, size_t limit) const; + // Retention in days for episodic files; 0 means keep until deleted. Only episodic/ ages out: the + // other files are what consolidation decided was durable. Setting it sweeps immediately. + int retention_days() const; + void SetRetentionDays(int days); - std::vector List() const; - bool Remove(const std::string& id); - bool SetPinned(const std::string& id, bool pinned); + // Whether extraction may be told the titles and URLs the agent's own tab visited during the task. + // Off by default; never the user's wider history either way. + bool history_input() const; + void SetHistoryInput(bool enabled); + + // The tree as last seen, sorted by path. A cache refreshed after every change, so Settings can render + // it synchronously; read the file itself with ReadFile. + const std::vector& files() const { return files_; } + + // Opens a run and returns its id. Every write names the run it belongs to. + std::string BeginRun(RunKind kind); + // Closes it: records the duration and any token count. Unknown ids are ignored. + void EndRun(const std::string& run_id, int64_t tokens); + // Newest first. At most kMaxRuns, matching the snapshots kept on disk. + std::vector Runs() const; + // How many extraction runs finished since the last consolidation, and when that was (0 = never). + int ExtractionRunsSinceConsolidation() const; + int64_t last_consolidation_ms() const; + + // File access. `path` comes from model output or a renderer and is validated here: relative, .md, + // [a-z0-9-_/.] only, no "..", no hidden segment, under the root. + void ReadFile(const std::string& path, ReadCallback callback); + void WriteFile(const std::string& run_id, + const std::string& path, + const std::string& content, + DoneCallback callback); + // Replaces exactly one occurrence of `old_text`. Zero or several occurrences is a refusal, because an + // edit the caller cannot aim is an edit that lands in the wrong place. + void EditFile(const std::string& run_id, + const std::string& path, + const std::string& old_text, + const std::string& new_text, + DoneCallback callback); + void DeleteFile(const std::string& run_id, const std::string& path, DoneCallback callback); + + // Token overlap across every file, best lines first, at most `limit`. Deliberately naive: a scorer the + // user can see the consequences of beats a clever one whose misses are unexplainable. + void Search(const std::string& query, size_t limit, SearchCallback callback); + + // Puts the tree back the way it was before `run_id` first wrote. Recorded as a kRevert run. + void Revert(const std::string& run_id, DoneCallback callback); + + // Deletes every file, every snapshot and every run row. "Forget everything" that quietly kept a + // snapshot to revert to would be a lie told at the exact moment the user was trying to be sure. void Clear(); - // Retention in days; 0 means keep until deleted. Setting it sweeps immediately, because a user who - // has just chosen 30 days expects the 90-day entries to be gone now rather than tomorrow. - int retention_days() const; - void SetRetentionDays(int days); - void SetEnabled(bool enabled); + // Path validation, exposed for TaskStore's own messages. Returns the normalised relative path. + static std::optional ValidatePath(const std::string& path); + + static constexpr size_t kMaxRuns = 30; + static constexpr size_t kMaxFileBytes = 64 * 1024; + static constexpr size_t kMaxTotalBytes = 4 * 1024 * 1024; + + // What one change did on disk. Public only so the blocking-side helpers in the .cc can return it. + struct ChangeResult; private: - // Drops unpinned entries older than the retention window, and trims to the size ceiling. Runs at - // construction and once a day after that. + enum class Op { kWrite, kEdit, kDelete }; + + // The ONE write path. Snapshots first when the run has not written yet, applies the change, and + // records the file's +/- on the run. + void Change(const std::string& run_id, + Op op, + const std::string& path, + const std::string& content, + const std::string& old_text, + DoneCallback callback); + void OnChanged(std::string run_id, DoneCallback callback, ChangeResult result); + void RefreshIndex(); + void OnIndex(std::vector files); + void SaveRun(const Run& run); + std::optional FindRun(const std::string& run_id) const; + // One-time move of the old pref entries into files. + void MigrateFromPrefs(); + void Sweep(); void ScheduleSweep(); raw_ptr profile_; + base::FilePath root_; + scoped_refptr file_runner_; + std::vector files_; + int64_t last_run_id_ms_ = 0; base::WallClockTimer sweep_timer_; base::WeakPtrFactory weak_factory_{this}; }; diff --git a/sources/chrome/browser/redrob/redrob_prefs.cc b/sources/chrome/browser/redrob/redrob_prefs.cc index 78fe0ad10..4c5754ee0 100644 --- a/sources/chrome/browser/redrob/redrob_prefs.cc +++ b/sources/chrome/browser/redrob/redrob_prefs.cc @@ -22,6 +22,12 @@ void RegisterProfilePrefs(user_prefs::PrefRegistrySyncable* registry) { // 90 days rather than forever. A default that keeps everything for the life of the profile is a // decision made on the user's behalf that they never see the consequences of until they look. registry->RegisterIntegerPref(kMemoryRetentionDays, 90); + registry->RegisterBooleanPref(kMemoryMigrated, false); + registry->RegisterListPref(kMemoryRuns); + registry->RegisterDoublePref(kMemoryLastConsolidationMs, 0); + // Off: the agent's own tab history is still browsing history, and handing it to a model is the + // user's call to make, not a default. + registry->RegisterBooleanPref(kMemoryHistoryInput, false); registry->RegisterListPref(kModelProviders); registry->RegisterStringPref(kSelectedProviderId, std::string()); diff --git a/sources/chrome/browser/redrob/redrob_prefs.h b/sources/chrome/browser/redrob/redrob_prefs.h index d7a0f98b4..1976af655 100644 --- a/sources/chrome/browser/redrob/redrob_prefs.h +++ b/sources/chrome/browser/redrob/redrob_prefs.h @@ -28,11 +28,23 @@ inline constexpr char kWorkingFolder[] = "redrob.working_folder"; // last_run_ms, next_run_ms, task_id}. See routine_store.h for the field contract. inline constexpr char kRoutines[] = "redrob.routines"; -// Memory. Entries are a list of dicts {id, text, source, created_ms, pinned}. Retention is in days; -// 0 means keep indefinitely, and the sweep runs on startup and daily after that. +// Memory. The memory itself is files under /Redrob/memory (memory_store.h); these are its +// settings and its run log. Retention is in days for episodic files; 0 means keep indefinitely, and the +// sweep runs on startup and daily after that. inline constexpr char kMemoryEnabled[] = "redrob.memory.enabled"; +// The pre-files store, a list of dicts {id, text, source, created_ms, pinned}. Read once by the +// migration into files and then emptied; kept registered so that read has something to read. inline constexpr char kMemoryEntries[] = "redrob.memory.entries"; inline constexpr char kMemoryRetentionDays[] = "redrob.memory.retention_days"; +// True once kMemoryEntries has been moved into files. +inline constexpr char kMemoryMigrated[] = "redrob.memory.migrated"; +// Memory runs, oldest first, at most 30: dicts {id, kind, started_ms, duration_ms, tokens, finished, +// has_snapshot, files: [{path, added, removed}]}. Each row's id names its snapshot directory. +inline constexpr char kMemoryRuns[] = "redrob.memory.runs"; +// When the last consolidation run started, unix ms. 0 = never. +inline constexpr char kMemoryLastConsolidationMs[] = "redrob.memory.last_consolidation_ms"; +// Whether extraction is shown the titles and URLs the agent's tab visited during the task. Off. +inline constexpr char kMemoryHistoryInput[] = "redrob.memory.history_input"; // Model providers: a list of dicts {id, kind, label, model, endpoint, secret_b64}. `secret_b64` is // OSCrypt-encrypted bytes, base64'd for prefs -- never a plaintext key, and never sent to a renderer. From 76eaaab561e8fbd033b0e58868f2dcf3511ea772 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:45:02 +0000 Subject: [PATCH 05/29] feat(memory): extraction runs and memory_* tools in TaskStore (CAP-19) --- sources/chrome/browser/redrob/memory_store.cc | 5 +- sources/chrome/browser/redrob/task_store.cc | 682 ++++++++++++++---- sources/chrome/browser/redrob/task_store.h | 40 +- 3 files changed, 599 insertions(+), 128 deletions(-) diff --git a/sources/chrome/browser/redrob/memory_store.cc b/sources/chrome/browser/redrob/memory_store.cc index af460201f..f8eaecabe 100644 --- a/sources/chrome/browser/redrob/memory_store.cc +++ b/sources/chrome/browser/redrob/memory_store.cc @@ -841,7 +841,10 @@ void MemoryStore::OnChanged(std::string run_id, DoneCallback callback, ChangeRes void MemoryStore::Search(const std::string& query, size_t limit, SearchCallback callback) { if (!enabled() || root_.empty()) { - std::move(callback).Run({}); + // Answered asynchronously even here: TaskStore starts a task's first turn from this callback and + // relies on it not running inside the call that created the task. + base::SequencedTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, base::BindOnce(std::move(callback), std::vector())); return; } file_runner_->PostTaskAndReplyWithResult( diff --git a/sources/chrome/browser/redrob/task_store.cc b/sources/chrome/browser/redrob/task_store.cc index 18fc416a8..1430b51c5 100644 --- a/sources/chrome/browser/redrob/task_store.cc +++ b/sources/chrome/browser/redrob/task_store.cc @@ -4,6 +4,7 @@ #include "chrome/browser/redrob/task_store.h" +#include #include #include "base/functional/bind.h" @@ -13,6 +14,7 @@ #include "base/strings/escape.h" #include "base/strings/utf_string_conversions.h" #include "base/strings/string_number_conversions.h" +#include "base/strings/stringprintf.h" #include "base/time/time.h" #include "base/uuid.h" #include "base/base64.h" @@ -96,8 +98,64 @@ constexpr char kToolClick[] = "click"; constexpr char kToolType[] = "type_text"; constexpr char kToolSaveFile[] = "save_file"; constexpr char kToolPlan[] = "plan_research"; -constexpr char kToolRemember[] = "remember"; constexpr char kToolRecall[] = "recall"; +// Memory files. Read and list are offered to every task while memory is on; write and edit only to the +// hidden memory tasks, so an ordinary task cannot rewrite memory as a side effect of what it browsed. +constexpr char kToolMemoryRead[] = "memory_read"; +constexpr char kToolMemoryList[] = "memory_list"; +constexpr char kToolMemoryWrite[] = "memory_write"; +constexpr char kToolMemoryEdit[] = "memory_edit"; + +// Tool rounds a memory task may take: enough to list, read a handful of files and write a few. +constexpr int kMemoryToolBudget = 16; +// How much of a finished task's answer extraction is shown. The tail, because that is the conclusion. +constexpr size_t kExtractionAnswerBytes = 6000; +constexpr size_t kExtractionMaxSteps = 60; + +// The one system message every memory task gets. +constexpr char kMemorySystemPrompt[] = + "You maintain the memory of the Redrob browser's agent: a folder of markdown files the user can " + "read, edit and revert. Use memory_list and memory_read to see what is there, and memory_write or " + "memory_edit to change it. Write short, plain markdown bullets a person can check at a glance. " + "Record only what is durable and useful for future tasks: what the user wants, how they like " + "things done, and paths through websites that worked. Never record passwords, payment details, " + "one-time codes, or anything the user did not show you. When you are done, answer with one line " + "saying what you changed, or that nothing was worth keeping."; + +// Extraction: what to keep from ONE finished task. English on purpose: it is model input, not UI. +constexpr char kExtractionPrompt[] = + "A browser task just finished. Decide what from it is worth remembering, then:\n" + "1. Append to the episodic file named below (create it with a '# YYYY-MM-DD' heading if it does " + "not exist): one bullet per thing worth keeping -- what the user asked for, what worked, and " + "anything they told you about themselves. Do not copy page contents.\n" + "2. For each site where a path through it worked (a search URL, which menu led where, a form " + "that needed a particular order), update sites/.md, e.g. sites/example.com.md, with that " + "path. Edit the existing file rather than rewriting it when one exists.\n" + "3. If you created a new file, add one line for it to MEMORY.md.\n" + "If nothing in the task is worth keeping, change nothing and say so."; + +// Consolidation: the offline review of the episodic log. +constexpr char kConsolidationPrompt[] = + "Review the memory as a whole. Read MEMORY.md, USER.md and the files in episodic/. Then:\n" + "1. Move durable facts out of the episodic files into where they belong: USER.md for who the " + "user is and what they prefer, people/.md for people they work with, projects/.md " + "for ongoing work, and sites/.md for paths through websites. Merge duplicates and drop " + "anything later episodes contradict.\n" + "2. Leave the episodic files themselves in place; they age out on their own.\n" + "3. Rewrite MEMORY.md as the index: one line per file, saying what it holds.\n" + "Keep every file short. Prefer editing over rewriting."; + +std::string TodayName() { + base::Time::Exploded exploded; + base::Time::Now().LocalExplode(&exploded); + return base::StringPrintf("%04d-%02d-%02d", exploded.year, exploded.month, + exploded.day_of_month); +} + +bool IsMemoryToolName(const std::string& name) { + return name == kToolRecall || name == kToolMemoryRead || name == kToolMemoryList || + name == kToolMemoryWrite || name == kToolMemoryEdit; +} // How many remembered entries a new task is given unasked, and how many `recall` returns. Small on // purpose: memory that fills the context is memory that crowds out the task. @@ -170,6 +228,70 @@ base::DictValue StringArrayProperty(const std::string& description) { return property; } +// The memory tools. `writable` adds memory_write and memory_edit, and is true ONLY for a memory task. +void AppendMemoryTools(base::ListValue& tools, bool writable) { + { + base::DictValue properties; + properties.Set("query", StringProperty("What you are trying to recall.")); + base::ListValue required; + required.Append("query"); + tools.Append(MakeTool(kToolRecall, + "Search the memory files for lines matching a query. The most relevant lines " + "are given to you at the start of a task, so use this only when you need " + "something the task did not begin with.", + std::move(properties), std::move(required))); + } + tools.Append(MakeTool(kToolMemoryList, + "List the memory files, with their sizes. MEMORY.md is the index.", + base::DictValue(), base::ListValue())); + { + base::DictValue properties; + properties.Set("path", StringProperty( + "A memory file's relative path, e.g. USER.md or sites/example.com.md.")); + base::ListValue required; + required.Append("path"); + tools.Append(MakeTool(kToolMemoryRead, "Read one memory file in full.", std::move(properties), + std::move(required))); + } + if (!writable) { + return; + } + { + base::DictValue properties; + properties.Set("path", StringProperty( + "Relative path ending in .md, lower-case letters, digits, '-', '_' and '/' only.")); + properties.Set("content", StringProperty("The complete new content of the file, in markdown.")); + base::ListValue required; + required.Append("path"); + required.Append("content"); + tools.Append(MakeTool(kToolMemoryWrite, + "Create or replace a memory file. At most 64 KiB a file.", + std::move(properties), std::move(required))); + } + { + base::DictValue properties; + properties.Set("path", StringProperty("The memory file to edit.")); + properties.Set("old", StringProperty( + "Text that occurs exactly once in the file. Use an empty-looking but real anchor such as the " + "last line when appending.")); + properties.Set("new", StringProperty("What to replace it with.")); + base::ListValue required; + required.Append("path"); + required.Append("old"); + required.Append("new"); + tools.Append(MakeTool(kToolMemoryEdit, + "Replace one exact passage in a memory file. Prefer this over memory_write " + "for an existing file.", + std::move(properties), std::move(required))); + } +} + +base::ListValue MemoryTaskTools() { + base::ListValue tools; + AppendMemoryTools(tools, /*writable=*/true); + return tools; +} + base::ListValue BrowserTools(mojom::PermissionMode mode, mojom::Effort effort, bool memory_enabled) { base::ListValue tools; @@ -211,34 +333,10 @@ base::ListValue BrowserTools(mojom::PermissionMode mode, mojom::Effort effort, b std::move(properties), std::move(required))); } - // Memory, when the user has it on. Declared in every permission mode because it touches nothing - // outside the profile's own memory list -- and it is the ONE thing the agent stores that the user can - // read back, edit and delete in Settings, which is what makes storing it defensible at all. + // Memory, when the user has it on. Declared in every permission mode because reading touches nothing + // outside the profile's own memory folder. Writing is NOT declared here: only a memory task may write. if (memory_enabled) { - { - base::DictValue properties; - properties.Set("fact", StringProperty( - "One durable fact or preference about the user, in a sentence they would recognise as true " - "about themselves. Not a page's contents and not a fact about the world.")); - base::ListValue required; - required.Append("fact"); - tools.Append(MakeTool(kToolRemember, - "Remember something about this user for future tasks. Use it for a stated " - "preference or a stable detail they will not want to repeat -- never for " - "what a page said, and never for anything they did not tell you.", - std::move(properties), std::move(required))); - } - { - base::DictValue properties; - properties.Set("query", StringProperty("What you are trying to recall.")); - base::ListValue required; - required.Append("query"); - tools.Append(MakeTool(kToolRecall, - "Look through what you already remember about this user. The most relevant " - "entries are given to you at the start of a task, so use this only when you " - "need something the task did not begin with.", - std::move(properties), std::move(required))); - } + AppendMemoryTools(tools, /*writable=*/false); } if (mode != mojom::PermissionMode::kReadOnly) { @@ -380,40 +478,19 @@ std::string TaskStore::CreateTask(const std::string& prompt, Conversation conversation; conversation.messages.Append(TextMessage("system", SystemPrompt(effort))); + conversation.messages.Append(TextMessage("user", prompt)); // What we already know, searched with the task's own words. Given to the engine as a system message // AND recorded as a step, because an answer shaped by something the user told the browser weeks ago - // is only fair if they can see that it was. - if (memory_ && memory_->enabled()) { - const std::vector hits = memory_->Search(prompt, kMemoryHits); - if (!hits.empty()) { - std::string recalled = - "Things this user has told you before, which may or may not be relevant here:"; - for (const MemoryStore::Entry& entry : hits) { - recalled += "\n- " + entry.text; - } - conversation.messages.Append(TextMessage("system", recalled)); - // The step is added after the task is in the map, which it now is. - mojom::Step* step = nullptr; - { - mojom::StepPtr owned = mojom::Step::New(); - owned->kind = mojom::StepKind::kSearchedMemory; - owned->label = "Recalled " + base::NumberToString(hits.size()) + - (hits.size() == 1 ? " thing you told me" : " things you told me"); - owned->started_at_ms = NowMs(); - tasks_[task_id]->steps.push_back(std::move(owned)); - step = tasks_[task_id]->steps.back().get(); - } - for (const MemoryStore::Entry& entry : hits) { - mojom::StepDetailPtr detail = mojom::StepDetail::New(); - detail->text = entry.text; - detail->source = entry.source; - step->details.push_back(std::move(detail)); - } - } - } - - conversation.messages.Append(TextMessage("user", prompt)); + // is only fair if they can see that it was. The search reads files, so it answers later; the first + // turn waits for it (see StartTurn). + const bool search_memory = memory_ && memory_->enabled(); + conversation.awaiting_memory = search_memory; conversations_[task_id] = std::move(conversation); + if (search_memory) { + memory_->Search(prompt, kMemoryHits, + base::BindOnce(&TaskStore::OnMemoryPrefill, weak_factory_.GetWeakPtr(), + task_id)); + } // Bounded history. Without this the map is a renderer-driven allocation for the life of the profile. while (tasks_.size() > kMaxTasks) { @@ -451,7 +528,8 @@ std::pair, std::optional> TaskStor size_t running = 0; for (const auto& [id, task] : tasks_) { - if (!IsTerminal(task->state)) { + // Memory tasks are the browser's own housekeeping; they never count against the user's tasks. + if (!IsTerminal(task->state) && !IsMemoryTask(id)) { ++running; } } @@ -484,7 +562,8 @@ std::string TaskStore::StartRoutineTask(const std::string& title, } size_t running = 0; for (const auto& [id, task] : tasks_) { - if (!IsTerminal(task->state)) { + // Memory tasks are the browser's own housekeeping; they never count against the user's tasks. + if (!IsTerminal(task->state) && !IsMemoryTask(id)) { ++running; } } @@ -547,6 +626,13 @@ void TaskStore::StartTurn(const std::string& task_id) { return; } + // The memory search for this task's prompt has not answered yet; OnMemoryPrefill starts the turn. + if (conversation->second.awaiting_memory) { + task->second->state = mojom::TaskState::kQueued; + Notify(task_id); + return; + } + // One stream per client. A second task must wait rather than silently replace the first, which the // concurrency ceiling in StartTask is what keeps bounded. if (!active_task_id_.empty() && active_task_id_ != task_id) { @@ -554,6 +640,11 @@ void TaskStore::StartTurn(const std::string& task_id) { Notify(task_id); return; } + // Already streaming this very task: a second start would be a duplicate request. Reachable now that + // StartNextQueued can start a task that also has its own StartTurn posted. + if (active_task_id_ == task_id) { + return; + } // Whose model answers this. Resolved per turn rather than per task, so switching provider in // Settings takes effect on the next turn instead of at the next browser launch. @@ -578,8 +669,10 @@ void TaskStore::StartTurn(const std::string& task_id) { request.Set("stream", true); request.Set("reasoning_effort", ReasoningEffort(task->second->effort)); request.Set("messages", conversation->second.messages.Clone()); - request.Set("tools", BrowserTools(task->second->mode, task->second->effort, - memory_ && memory_->enabled())); + request.Set("tools", IsMemoryTask(task_id) + ? MemoryTaskTools() + : BrowserTools(task->second->mode, task->second->effort, + memory_ && memory_->enabled())); std::optional body = base::WriteJson(request); if (!body.has_value()) { @@ -734,7 +827,9 @@ void TaskStore::OnCompletionFinished(const std::string& finish_reason) { return; } - if (conversation->second.tool_rounds >= ToolBudget(task->second->effort)) { + const int budget = + IsMemoryTask(task_id) ? kMemoryToolBudget : ToolBudget(task->second->effort); + if (conversation->second.tool_rounds >= budget) { FinishTask(task_id, mojom::TaskState::kFailed, "The task reached the number of steps this effort setting allows. Ask again with a " "deeper effort, or narrow the question."); @@ -764,7 +859,10 @@ bool TaskStore::ModeAllows(mojom::PermissionMode mode, const std::string& tool) } // Memory is the profile's own list, reviewable and deletable in Settings. Gated by the memory // setting -- which is why these are not even declared when it is off -- not by permission mode. - if (tool == kToolRemember || tool == kToolRecall) { + // + // memory_write and memory_edit are NOT allowed here in any mode: they belong to memory tasks, which + // RunTool dispatches before this question is asked. + if (tool == kToolRecall || tool == kToolMemoryRead || tool == kToolMemoryList) { return true; } if (tool == kToolClick || tool == kToolType || tool == kToolSaveFile) { @@ -780,10 +878,26 @@ void TaskStore::RunTool(const std::string& task_id, const ProposedToolCall& call return; } + // A memory task may use the memory tools and nothing else -- no tabs, no files, no clicks. + const bool memory_task = IsMemoryTask(task_id); + if (memory_task && !IsMemoryToolName(call.name)) { + FinishTool(task_id, call, "This is a memory task. Only the memory tools are available here."); + return; + } + // Refusals are recorded in the trace, not swallowed. Someone watching should see what the agent // wanted to do and did not get to do -- that is the difference between a permission boundary and a // silent failure. - if (!ModeAllows(task->second->mode, call.name)) { + if (!memory_task && !ModeAllows(task->second->mode, call.name)) { + if (call.name == kToolMemoryWrite || call.name == kToolMemoryEdit) { + AddStep(task_id, mojom::StepKind::kRefused, + l10n_util::GetStringUTF8(IDS_REDROB_TASK_MEMORY_WRITE_REFUSED)); + Notify(task_id); + FinishTool(task_id, call, + "Memory is written after the task, by the browser's own memory run, not during it. " + "Carry on with the task."); + return; + } AddStep(task_id, mojom::StepKind::kRefused, "Refused: " + call.name + " is not allowed in this mode"); Notify(task_id); @@ -800,63 +914,7 @@ void TaskStore::RunTool(const std::string& task_id, const ProposedToolCall& call return; } - if (call.name == kToolRemember) { - const std::string* fact = arguments->FindString("fact"); - if (!fact) { - FinishTool(task_id, call, "remember needs a fact argument."); - return; - } - if (!memory_ || !memory_->enabled()) { - FinishTool(task_id, call, - "Memory is switched off for this browser, so nothing was stored. Do not ask again " - "during this task."); - return; - } - // The task's own prompt is the entry's source, so Settings can answer "why do you know this". - const std::string id = memory_->Remember(*fact, task->second->prompt); - if (id.empty()) { - FinishTool(task_id, call, "That could not be remembered."); - return; - } - mojom::Step* step = AddStep(task_id, mojom::StepKind::kSearchedMemory, "Remembered a detail"); - if (step) { - mojom::StepDetailPtr detail = mojom::StepDetail::New(); - detail->text = *fact; - step->details.push_back(std::move(detail)); - } - Notify(task_id); - FinishTool(task_id, call, - "Remembered. The user can see and delete it in Settings, so do not treat it as private " - "to you."); - return; - } - - if (call.name == kToolRecall) { - const std::string* query = arguments->FindString("query"); - if (!query) { - FinishTool(task_id, call, "recall needs a query argument."); - return; - } - if (!memory_ || !memory_->enabled()) { - FinishTool(task_id, call, "Memory is switched off for this browser. There is nothing to recall."); - return; - } - const std::vector hits = memory_->Search(*query, kMemoryHits); - mojom::Step* step = AddStep(task_id, mojom::StepKind::kSearchedMemory, - "Looked back for \"" + *query + "\""); - std::string answer; - for (const MemoryStore::Entry& entry : hits) { - answer += "- " + entry.text + "\n"; - if (step) { - mojom::StepDetailPtr detail = mojom::StepDetail::New(); - detail->text = entry.text; - detail->source = entry.source; - step->details.push_back(std::move(detail)); - } - } - Notify(task_id); - FinishTool(task_id, call, - answer.empty() ? "Nothing remembered matches that." : answer); + if (IsMemoryToolName(call.name) && RunMemoryTool(task_id, call, *arguments)) { return; } @@ -904,6 +962,9 @@ void TaskStore::RunTool(const std::string& task_id, const ProposedToolCall& call // Weak: the tab belongs to the tab strip and the user can close it between one tool call and the // next, so every later page tool has to re-check that it is still there. conversation->second.page = contents->GetWeakPtr(); + if (conversation->second.visited.size() < kExtractionMaxSteps) { + conversation->second.visited.emplace_back(url.spec(), std::string()); + } mojom::Step* step = AddStep(task_id, mojom::StepKind::kOpenedPage, reason && !reason->empty() ? *reason : "Opened a page"); @@ -926,6 +987,21 @@ void TaskStore::RunTool(const std::string& task_id, const ProposedToolCall& call } if (call.name == kToolReadPage) { + // The title is known by the time the agent reads the page; recorded for extraction's history input. + { + const std::string committed = page->GetLastCommittedURL().spec(); + const std::string title = base::UTF16ToUTF8(page->GetTitle()); + bool found = false; + for (auto& [visited_url, visited_title] : conversation->second.visited) { + if (visited_url == committed) { + visited_title = title; + found = true; + } + } + if (!found && conversation->second.visited.size() < kExtractionMaxSteps) { + conversation->second.visited.emplace_back(committed, title); + } + } ++conversation->second.pending_tools; PageTools::ReadPageText( page, base::BindOnce( @@ -1124,6 +1200,21 @@ void TaskStore::FinishTask(const std::string& task_id, task->second->error = error; } Notify(task_id); + + if (IsMemoryTask(task_id)) { + // The run closes whatever the outcome: a failed run that wrote half its changes still has a + // snapshot and a row, and the user can revert it from Settings. The engine reports no token usage + // over this endpoint today, so the count stays 0 until it does. + memory_->EndRun(memory_tasks_[task_id].run_id, 0); + base::SequencedTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, + base::BindOnce(&TaskStore::ForgetMemoryTask, weak_factory_.GetWeakPtr(), task_id)); + } else if (state == mojom::TaskState::kSucceeded && memory_ && memory_->enabled()) { + StartExtraction(task_id); + } + // Posted: this can run inside the stream that just finished, and starting a turn replaces it. + base::SequencedTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, base::BindOnce(&TaskStore::StartNextQueued, weak_factory_.GetWeakPtr())); } std::vector TaskStore::ListModels() const { @@ -1372,14 +1463,16 @@ std::vector TaskStore::ListTasks() const { std::vector out; out.reserve(tasks_.size()); for (const auto& [id, task] : tasks_) { - out.push_back(task->Clone()); + if (!IsMemoryTask(id)) { + out.push_back(task->Clone()); + } } return out; } mojom::TaskPtr TaskStore::GetTask(const std::string& task_id) const { auto it = tasks_.find(task_id); - return it == tasks_.end() ? nullptr : it->second->Clone(); + return it == tasks_.end() || IsMemoryTask(task_id) ? nullptr : it->second->Clone(); } bool TaskStore::SetPermissionMode(const std::string& task_id, mojom::PermissionMode mode) { @@ -1454,13 +1547,21 @@ void TaskStore::OnSidecarStatusChanged(SidecarClient::Status status) { } const std::string message = l10n_util::GetStringUTF8(message_id); active_task_id_.clear(); + std::vector failed_memory_tasks; for (auto& [id, task] : tasks_) { if (!IsTerminal(task->state)) { task->state = mojom::TaskState::kFailed; task->error = message; Notify(id); + if (IsMemoryTask(id)) { + failed_memory_tasks.push_back(id); + } } } + for (const std::string& id : failed_memory_tasks) { + memory_->EndRun(memory_tasks_[id].run_id, 0); + ForgetMemoryTask(id); + } } mojom::Step* TaskStore::AddStep(const std::string& task_id, @@ -1562,7 +1663,8 @@ void TaskStore::ScheduleNotify(const std::string& task_id) { void TaskStore::Notify(const std::string& task_id) { auto it = tasks_.find(task_id); - if (it == tasks_.end()) { + // Memory tasks are hidden: no page renders them, so no observer hears about them. + if (it == tasks_.end() || IsMemoryTask(task_id)) { return; } for (Observer& observer : observers_) { @@ -1570,4 +1672,332 @@ void TaskStore::Notify(const std::string& task_id) { } } +// --- Memory runs -------------------------------------------------------------------------------------- + +bool TaskStore::IsMemoryTask(const std::string& task_id) const { + return memory_tasks_.count(task_id) > 0; +} + +void TaskStore::OnMemoryPrefill(std::string task_id, std::vector hits) { + auto task = tasks_.find(task_id); + auto conversation = conversations_.find(task_id); + if (task == tasks_.end() || conversation == conversations_.end() || + !conversation->second.awaiting_memory) { + return; + } + conversation->second.awaiting_memory = false; + if (!hits.empty()) { + std::string recalled = + "Lines from this user's memory files that may or may not be relevant here (path:line):"; + for (const MemoryStore::SearchHit& hit : hits) { + recalled += "\n- " + hit.path + ":" + base::NumberToString(hit.line) + ": " + hit.snippet; + } + // Before the user's message, which is the last one in a task that has not had a turn yet. + base::ListValue& messages = conversation->second.messages; + auto position = messages.end(); + if (!messages.empty()) { + --position; + } + messages.Insert(position, base::Value(TextMessage("system", recalled))); + mojom::Step* step = + AddStep(task_id, mojom::StepKind::kSearchedMemory, + l10n_util::GetStringFUTF8(IDS_REDROB_TASK_MEMORY_RECALLED, + base::NumberToString16(hits.size()))); + if (step) { + for (const MemoryStore::SearchHit& hit : hits) { + mojom::StepDetailPtr detail = mojom::StepDetail::New(); + detail->text = hit.snippet; + detail->source = hit.path; + step->details.push_back(std::move(detail)); + } + } + } + if (!IsTerminal(task->second->state)) { + StartTurn(task_id); + } +} + +bool TaskStore::RunMemoryTool(const std::string& task_id, + const ProposedToolCall& call, + const base::DictValue& arguments) { + auto conversation = conversations_.find(task_id); + if (conversation == conversations_.end()) { + return false; + } + if (!memory_ || !memory_->enabled()) { + FinishTool(task_id, call, + "Memory is switched off for this browser. Do not use the memory tools in this task."); + return true; + } + const bool memory_task = IsMemoryTask(task_id); + + if (call.name == kToolMemoryList) { + std::string answer; + for (const MemoryStore::FileInfo& file : memory_->files()) { + answer += file.path + " (" + base::NumberToString(file.bytes) + " bytes)\n"; + } + FinishTool(task_id, call, answer.empty() ? "There are no memory files yet." : answer); + return true; + } + + if (call.name == kToolRecall) { + const std::string* query = arguments.FindString("query"); + if (!query) { + FinishTool(task_id, call, "recall needs a query argument."); + return true; + } + ++conversation->second.pending_tools; + memory_->Search( + *query, kMemoryHits, + base::BindOnce( + [](base::WeakPtr store, std::string task_id, ProposedToolCall call, + std::string query, std::vector hits) { + if (!store) { + return; + } + mojom::Step* step = store->AddStep(task_id, mojom::StepKind::kSearchedMemory, + "Looked back for \"" + query + "\""); + std::string answer; + for (const MemoryStore::SearchHit& hit : hits) { + answer += hit.path + ":" + base::NumberToString(hit.line) + ": " + hit.snippet + "\n"; + if (step) { + mojom::StepDetailPtr detail = mojom::StepDetail::New(); + detail->text = hit.snippet; + detail->source = hit.path; + step->details.push_back(std::move(detail)); + } + } + store->Notify(task_id); + store->FinishTool(task_id, call, + answer.empty() ? "Nothing in memory matches that." : answer); + }, + weak_factory_.GetWeakPtr(), task_id, call, *query)); + return true; + } + + const std::string* path = arguments.FindString("path"); + if (!path) { + FinishTool(task_id, call, call.name + " needs a path argument."); + return true; + } + + if (call.name == kToolMemoryRead) { + ++conversation->second.pending_tools; + memory_->ReadFile( + *path, base::BindOnce( + [](base::WeakPtr store, std::string task_id, ProposedToolCall call, + std::string path, std::optional content) { + if (!store) { + return; + } + if (!content) { + store->FinishTool(task_id, call, + "There is no memory file at " + path + + ". memory_list shows what exists."); + return; + } + store->AddStep(task_id, mojom::StepKind::kSearchedMemory, + l10n_util::GetStringFUTF8(IDS_REDROB_TASK_MEMORY_READ, + base::UTF8ToUTF16(path))); + store->Notify(task_id); + store->FinishTool(task_id, call, + content->empty() ? "(empty file)" : *content); + }, + weak_factory_.GetWeakPtr(), task_id, call, *path)); + return true; + } + + if (call.name != kToolMemoryWrite && call.name != kToolMemoryEdit) { + return false; + } + // The write boundary, checked a second time where the write happens rather than only where the tool + // is declared: a model can name a tool it was never offered. + if (!memory_task) { + AddStep(task_id, mojom::StepKind::kRefused, + l10n_util::GetStringUTF8(IDS_REDROB_TASK_MEMORY_WRITE_REFUSED)); + Notify(task_id); + FinishTool(task_id, call, "Only the browser's memory run may change memory."); + return true; + } + const std::string run_id = memory_tasks_[task_id].run_id; + auto done = base::BindOnce( + [](base::WeakPtr store, std::string task_id, ProposedToolCall call, + std::string path, bool ok, const std::string& message) { + if (!store) { + return; + } + store->FinishTool(task_id, call, ok ? "Saved " + path + "." : message); + }, + weak_factory_.GetWeakPtr(), task_id, call, *path); + if (call.name == kToolMemoryWrite) { + const std::string* content = arguments.FindString("content"); + if (!content) { + FinishTool(task_id, call, "memory_write needs path and content."); + return true; + } + ++conversation->second.pending_tools; + memory_->WriteFile(run_id, *path, *content, std::move(done)); + return true; + } + const std::string* old_text = arguments.FindString("old"); + const std::string* new_text = arguments.FindString("new"); + if (!old_text || !new_text) { + FinishTool(task_id, call, "memory_edit needs path, old and new."); + return true; + } + ++conversation->second.pending_tools; + memory_->EditFile(run_id, *path, *old_text, *new_text, std::move(done)); + return true; +} + +std::string TaskStore::StartMemoryTask(bool consolidate, const std::string& prompt) { + if (!memory_ || !memory_->enabled() || prompt.empty()) { + return std::string(); + } + sidecar_->EnsureStarted( + url_loader_factory_, + base::BindRepeating(&TaskStore::OnSidecarStatusChanged, base::Unretained(this))); + const bool direct = models_ && models_->Selected().has_value(); + if (!direct && (sidecar_->status() == SidecarClient::Status::kUnavailable || + sidecar_->status() == SidecarClient::Status::kFailed)) { + return std::string(); + } + + const std::string task_id = base::Uuid::GenerateRandomV4().AsLowercaseString(); + mojom::TaskPtr task = mojom::Task::New(); + task->id = task_id; + task->prompt = consolidate ? "Memory consolidation" : "Memory extraction"; + task->state = mojom::TaskState::kQueued; + task->started_at_ms = NowMs(); + // Read-only: the browser tools a mode would grant are not declared to a memory task at all, but the + // record should not claim a permission the task does not have. + task->mode = mojom::PermissionMode::kReadOnly; + task->effort = mojom::Effort::kStandard; + tasks_[task_id] = std::move(task); + + Conversation conversation; + conversation.messages.Append(TextMessage("system", kMemorySystemPrompt)); + conversation.messages.Append(TextMessage("user", prompt)); + conversations_[task_id] = std::move(conversation); + + MemoryTaskInfo info; + info.consolidate = consolidate; + info.run_id = memory_->BeginRun(consolidate ? MemoryStore::RunKind::kConsolidate + : MemoryStore::RunKind::kExtract); + memory_tasks_[task_id] = std::move(info); + + base::SequencedTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, base::BindOnce(&TaskStore::StartTurn, weak_factory_.GetWeakPtr(), task_id)); + return task_id; +} + +void TaskStore::StartExtraction(const std::string& task_id) { + auto task = tasks_.find(task_id); + auto conversation = conversations_.find(task_id); + if (task == tasks_.end() || conversation == conversations_.end()) { + return; + } + std::string prompt = kExtractionPrompt; + prompt += "\n\nThe episodic file for today is episodic/" + TodayName() + ".md."; + prompt += "\n\n## What the user asked\n" + task->second->prompt; + + const std::string answer = task->second->narration.value_or(std::string()); + prompt += "\n\n## The final answer\n"; + prompt += answer.size() > kExtractionAnswerBytes + ? answer.substr(answer.size() - kExtractionAnswerBytes) + : answer; + + // The step list carries labels and hosts, never page text. + prompt += "\n\n## What the agent did\n"; + std::set hosts; + size_t listed = 0; + for (const mojom::StepPtr& step : task->second->steps) { + if (step->kind == mojom::StepKind::kThinking) { + continue; + } + if (step->kind == mojom::StepKind::kOpenedPage) { + for (const mojom::StepDetailPtr& detail : step->details) { + if (!detail->source.empty()) { + hosts.insert(detail->source); + } + } + } + if (listed++ >= kExtractionMaxSteps) { + break; + } + prompt += "- " + step->label + "\n"; + } + if (!hosts.empty()) { + prompt += "\n## Sites the agent used\n"; + for (const std::string& host : hosts) { + prompt += "- " + host + "\n"; + } + } + // Titles and URLs only when the user turned history input on, and only this task's own tab. + if (memory_->history_input() && !conversation->second.visited.empty()) { + prompt += "\n## Pages the agent's tab visited during this task\n"; + for (const auto& [url, title] : conversation->second.visited) { + prompt += "- " + (title.empty() ? url : title + " -- " + url) + "\n"; + } + } + StartMemoryTask(/*consolidate=*/false, prompt); +} + +bool TaskStore::StartConsolidation(bool force) { + if (!memory_ || !memory_->enabled()) { + return false; + } + for (const auto& [id, info] : memory_tasks_) { + if (info.consolidate) { + return false; + } + } + if (!force) { + if (memory_->ExtractionRunsSinceConsolidation() < 3) { + return false; + } + const int64_t last = memory_->last_consolidation_ms(); + if (last > 0 && NowMs() - last < base::Days(1).InMilliseconds()) { + return false; + } + } + std::string prompt = kConsolidationPrompt; + prompt += "\n\nToday is " + TodayName() + "."; + return !StartMemoryTask(/*consolidate=*/true, prompt).empty(); +} + +void TaskStore::StartNextQueued() { + if (!active_task_id_.empty()) { + return; + } + // Oldest first, so the user's waiting task goes before a memory run queued behind it. + std::string next; + int64_t oldest = 0; + for (const auto& [id, task] : tasks_) { + if (task->state != mojom::TaskState::kQueued) { + continue; + } + auto conversation = conversations_.find(id); + if (conversation == conversations_.end() || conversation->second.awaiting_memory) { + continue; + } + if (next.empty() || task->started_at_ms < oldest) { + next = id; + oldest = task->started_at_ms; + } + } + if (!next.empty()) { + StartTurn(next); + } +} + +void TaskStore::ForgetMemoryTask(const std::string& task_id) { + if (!IsMemoryTask(task_id) || active_task_id_ == task_id) { + return; + } + memory_tasks_.erase(task_id); + conversations_.erase(task_id); + tasks_.erase(task_id); +} + } // namespace redrob diff --git a/sources/chrome/browser/redrob/task_store.h b/sources/chrome/browser/redrob/task_store.h index 29f91e829..83b0e795a 100644 --- a/sources/chrome/browser/redrob/task_store.h +++ b/sources/chrome/browser/redrob/task_store.h @@ -16,6 +16,7 @@ #include "base/timer/timer.h" #include "base/values.h" #include "base/files/file_path.h" +#include "chrome/browser/redrob/memory_store.h" #include "chrome/browser/redrob/sidecar_client.h" #include "chrome/browser/ui/thumbnails/thumbnail_image.h" #include "chrome/browser/ui/webui/redrob/redrob.mojom.h" @@ -30,7 +31,6 @@ class WebContents; namespace redrob { -class MemoryStore; class ModelStore; // The tasks of one profile, the sidecar that answers them, and the tool loop between the two. @@ -125,6 +125,12 @@ class TaskStore : public KeyedService, public SidecarClient::CompletionDelegate // routine that starts a fresh one. False when the id is unknown. bool WakeTask(const std::string& task_id, const std::string& prompt); + // Starts a hidden consolidation run over the memory files. Without `force` it runs only when at least + // three extraction runs finished since the last one and the last one is a day old -- the built-in + // routine calls it that way; Settings' "Run consolidation now" passes force. False when nothing + // started: memory off, not due, one already running, or no engine. + bool StartConsolidation(bool force); + // SidecarClient::CompletionDelegate void OnCompletionText(std::string_view text) override; void OnCompletionReasoning(std::string_view text) override; @@ -164,6 +170,19 @@ class TaskStore : public KeyedService, public SidecarClient::CompletionDelegate // turns' text appended to one string ran together mid-sentence, which read as one confused // statement rather than as an answer and then a correction. bool turn_text_started = false; + // True from creation until the memory search for this task's prompt has answered. The first turn + // waits for it, because the hits go into the history BEFORE the user's message. + bool awaiting_memory = false; + // Pages the agent's tab visited, {url, title}. Handed to extraction only when the user turned + // history input on; never the user's own history. + std::vector> visited; + }; + + // A hidden task that maintains memory: extraction after a task, or consolidation. Not listed, not + // notified, opens no tabs, and is the ONLY kind of task offered memory_write / memory_edit. + struct MemoryTaskInfo { + std::string run_id; + bool consolidate = false; }; void OnSidecarStatusChanged(SidecarClient::Status status); @@ -204,6 +223,25 @@ class TaskStore : public KeyedService, public SidecarClient::CompletionDelegate // for one paragraph. Coalesced so the page updates promptly without that traffic. void ScheduleNotify(const std::string& task_id); + // --- Memory runs ---------------------------------------------------------------------------------- + bool IsMemoryTask(const std::string& task_id) const; + // Creates and posts the first turn of a hidden memory task. Returns its id, or empty. + std::string StartMemoryTask(bool consolidate, const std::string& prompt); + // Queues the extraction run for a task that just succeeded. + void StartExtraction(const std::string& task_id); + // The memory_* and recall tools. Returns false when `call` is not one of them. + bool RunMemoryTool(const std::string& task_id, + const ProposedToolCall& call, + const base::DictValue& arguments); + void OnMemoryPrefill(std::string task_id, std::vector hits); + // Starts the oldest queued task when nothing is streaming. Posted from FinishTask, because a task + // that finished while another waited used to leave the waiting one queued for good. + void StartNextQueued(); + // Drops a finished memory task from every map. + void ForgetMemoryTask(const std::string& task_id); + + std::map memory_tasks_; + std::map tasks_; std::map conversations_; // Held for the life of the task: the subscription IS the request, so dropping it stops the frames. From a01e873af9c38c02e2198120fe3bed7edec21635 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:50:06 +0000 Subject: [PATCH 06/29] feat(memory): settings page, redrobPrivate memory functions, consolidation routine (CAP-19) --- .../components/sidebar/SettingsSidebar.tsx | 2 + agent/apps/app/entrypoints/app/App.tsx | 3 + agent/apps/app/lib/memory/memory-api.ts | 100 +++++ .../memory-settings/MemorySettingsPage.tsx | 382 ++++++++++++++++++ agent/packages/i18n/src/messages.generated.ts | 92 +++++ l10n/45-memory.tsv | 11 + l10n/extension/46-memory.tsv | 53 +++ .../extensions/api/_api_features.json.yaml | 20 + .../extension_function_histogram_value.h.yaml | 5 + .../api/redrob_private/redrob_private_api.cc | 166 +++++++- .../api/redrob_private/redrob_private_api.h | 60 +++ .../resources/redrob_localized_strings.h | 5 + .../redrob/resources/redrob_strings.grd | 5 + .../translations/redrob_strings_ko.xtb | 5 + .../chrome/browser/redrob/routine_store.cc | 70 +++- sources/chrome/browser/redrob/routine_store.h | 6 + .../common/extensions/api/redrob_private.idl | 87 +++- .../common/extensions/api/_api_features.json | 22 +- .../extension_function_histogram_value.h | 7 +- 19 files changed, 1064 insertions(+), 37 deletions(-) create mode 100644 agent/apps/app/lib/memory/memory-api.ts create mode 100644 agent/apps/app/screens/memory-settings/MemorySettingsPage.tsx create mode 100644 l10n/45-memory.tsv create mode 100644 l10n/extension/46-memory.tsv diff --git a/agent/apps/app/components/sidebar/SettingsSidebar.tsx b/agent/apps/app/components/sidebar/SettingsSidebar.tsx index e9b472f49..fec288c28 100644 --- a/agent/apps/app/components/sidebar/SettingsSidebar.tsx +++ b/agent/apps/app/components/sidebar/SettingsSidebar.tsx @@ -12,6 +12,7 @@ import { Activity, ArrowLeft, Bot, + Brain, MessageSquare, Palette, Server, @@ -81,6 +82,7 @@ const primarySettingsSections: NavSection[] = [ to: '/settings/customization', icon: Palette, }, + { name: t('stNavMemory'), to: '/settings/memory', icon: Brain }, { name: t('stNavRedrobMcp'), to: '/settings/mcp', icon: Server }, ], }, diff --git a/agent/apps/app/entrypoints/app/App.tsx b/agent/apps/app/entrypoints/app/App.tsx index d202ffbd1..3b5088eb9 100644 --- a/agent/apps/app/entrypoints/app/App.tsx +++ b/agent/apps/app/entrypoints/app/App.tsx @@ -19,6 +19,7 @@ import { ConnectMCP } from '@/screens/connect-mcp/ConnectMCP' import { CustomizationPage } from '@/screens/customization/CustomizationPage' import { LlmHubPage } from '@/screens/llm-hub/LlmHubPage' import { MCPSettingsPage } from '@/screens/mcp-settings/MCPSettingsPage' +import { MemorySettingsPage } from '@/screens/memory-settings/MemorySettingsPage' import { NewTabChat } from '@/screens/newtab/index/NewTabChat' import { NewTabLayout } from '@/screens/newtab/layout/NewTabLayout' import { Personalize } from '@/screens/newtab/personalize/Personalize' @@ -35,6 +36,7 @@ const OptionsRedirect: FC = () => { 'connect-mcp': '/connect-apps', mcp: '/settings/mcp', customization: '/settings/customization', + memory: '/settings/memory', search: '/settings/ai', scheduled: '/scheduled', } @@ -65,6 +67,7 @@ export const App: FC = () => { } /> } /> } /> + } /> } diff --git a/agent/apps/app/lib/memory/memory-api.ts b/agent/apps/app/lib/memory/memory-api.ts new file mode 100644 index 000000000..6ea732a53 --- /dev/null +++ b/agent/apps/app/lib/memory/memory-api.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2026 Redrob and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * The memory half of chrome.redrobPrivate, typed and promised. + * + * Memory is a folder of markdown files the browser owns (docs/MEMORY.md). This page never reaches the + * disk: every read and write goes through the browser, and every write the page makes is recorded as a + * "user" run there, snapshotted first, so it can be reverted like the agent's own runs. + */ + +export type MemoryFile = { path: string; bytes: number; modifiedMs: number } + +export type MemoryRunKind = 'extract' | 'consolidate' | 'user' | 'revert' + +export type MemoryRun = { + id: string + kind: MemoryRunKind + startedMs: number + durationMs: number + tokens: number + files: { path: string; added: number; removed: number }[] + finished: boolean + revertible: boolean +} + +export type MemorySettings = { + enabled: boolean + retentionDays: number + historyInput: boolean + files: MemoryFile[] + runs: MemoryRun[] + lastConsolidationMs: number +} + +type MemoryApi = { + getSettings(callback: (settings: { memory: MemorySettings }) => void): void + setMemoryEnabled(enabled: boolean): void + setMemoryRetentionDays(days: number): void + setMemoryHistoryInput(enabled: boolean): void + forgetAllMemory(): void + removeMemory(path: string, callback: (ok: boolean) => void): void + readMemoryFile(path: string, callback: (content?: string) => void): void + writeMemoryFile( + path: string, + content: string, + callback: (ok: boolean) => void, + ): void + revertMemoryRun(runId: string, callback: (ok: boolean) => void): void + runMemoryConsolidation(callback: (ok: boolean) => void): void +} + +/** The API, or null outside the browser (a dev build in stock Chromium, the UI capture harness). */ +export function memoryApi(): MemoryApi | null { + const candidate = ( + globalThis as { chrome?: { redrobPrivate?: Partial } } + ).chrome?.redrobPrivate + return typeof candidate?.readMemoryFile === 'function' && + typeof candidate.getSettings === 'function' + ? (candidate as MemoryApi) + : null +} + +function need(): MemoryApi { + const api = memoryApi() + if (!api) throw new Error('redrobPrivate memory API is unavailable') + return api +} + +export const readMemorySettings = (): Promise => + new Promise((resolve) => need().getSettings((s) => resolve(s.memory))) + +export const readMemoryFile = (path: string): Promise => + new Promise((resolve) => need().readMemoryFile(path, resolve)) + +export const writeMemoryFile = ( + path: string, + content: string, +): Promise => + new Promise((resolve) => need().writeMemoryFile(path, content, resolve)) + +export const deleteMemoryFile = (path: string): Promise => + new Promise((resolve) => need().removeMemory(path, resolve)) + +export const revertMemoryRun = (runId: string): Promise => + new Promise((resolve) => need().revertMemoryRun(runId, resolve)) + +export const runMemoryConsolidation = (): Promise => + new Promise((resolve) => need().runMemoryConsolidation(resolve)) + +export const setMemoryEnabled = (enabled: boolean) => + need().setMemoryEnabled(enabled) +export const setMemoryRetentionDays = (days: number) => + need().setMemoryRetentionDays(days) +export const setMemoryHistoryInput = (enabled: boolean) => + need().setMemoryHistoryInput(enabled) +export const forgetAllMemory = () => need().forgetAllMemory() diff --git a/agent/apps/app/screens/memory-settings/MemorySettingsPage.tsx b/agent/apps/app/screens/memory-settings/MemorySettingsPage.tsx new file mode 100644 index 000000000..d7b3c07c5 --- /dev/null +++ b/agent/apps/app/screens/memory-settings/MemorySettingsPage.tsx @@ -0,0 +1,382 @@ +/** + * @license + * Copyright 2026 Redrob and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { formatRelative, t } from '@browseros/i18n' +import { type FC, useCallback, useEffect, useState } from 'react' +import { toast } from 'sonner' +import { + SettingsGroup, + SettingsHeader, + SettingsRow, +} from '@/components/settings/SettingsLayout' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { + deleteMemoryFile, + forgetAllMemory, + type MemoryRun, + type MemoryRunKind, + type MemorySettings, + memoryApi, + readMemoryFile, + readMemorySettings, + revertMemoryRun, + runMemoryConsolidation, + setMemoryEnabled, + setMemoryHistoryInput, + setMemoryRetentionDays, + writeMemoryFile, +} from '@/lib/memory/memory-api' + +const RETENTION = [ + { days: 30, label: 'mem30Days' }, + { days: 90, label: 'mem90Days' }, + { days: 0, label: 'memForever' }, +] as const + +const KIND_LABEL: Record[0]> = { + extract: 'memKindExtract', + consolidate: 'memKindConsolidate', + user: 'memKindUser', + revert: 'memKindRevert', +} + +/** One file open in the editor: what was loaded, and what is in the textarea now. */ +type Open = { path: string; loaded: string; draft: string } + +const RunRow: FC<{ + run: MemoryRun + busy: boolean + onRevert: (run: MemoryRun) => void +}> = ({ run, busy, onRevert }) => { + const meta = [ + formatRelative(run.startedMs), + run.finished + ? t('memSeconds', Math.max(1, Math.round(run.durationMs / 1000))) + : t('memRunning'), + run.tokens > 0 ? t('memTokens', run.tokens) : null, + ].filter(Boolean) + return ( +
+
+ {t(KIND_LABEL[run.kind])} +

{meta.join(' · ')}

+ {run.files.length === 0 ? ( +

{t('memNoChanges')}

+ ) : ( +
    + {run.files.map((file) => ( +
  • + {file.path} +{file.added} −{file.removed} +
  • + ))} +
+ )} +
+
+ {run.revertible && run.finished && ( + + )} +
+
+ ) +} + +export const MemorySettingsPage: FC = () => { + const [settings, setSettings] = useState(null) + const [open, setOpen] = useState(null) + const [busy, setBusy] = useState(false) + const [confirmForget, setConfirmForget] = useState(false) + const available = memoryApi() !== null + + const reload = useCallback(async () => { + if (!memoryApi()) return + setSettings(await readMemorySettings()) + }, []) + + useEffect(() => { + reload() + }, [reload]) + + const guarded = async (action: () => Promise | void) => { + setBusy(true) + try { + await action() + } catch { + toast.error(t('memUpdateFailed')) + } finally { + setBusy(false) + await reload() + } + } + + const openFile = (path: string) => + guarded(async () => { + const content = await readMemoryFile(path) + if (content === undefined) { + toast.error(t('memLoadFailed')) + return + } + setOpen({ path, loaded: content, draft: content }) + }) + + const saveFile = () => + guarded(async () => { + if (!open) return + if (await writeMemoryFile(open.path, open.draft)) { + setOpen({ ...open, loaded: open.draft }) + toast.success(t('memSaved')) + } else { + toast.error(t('memSaveFailed')) + } + }) + + const deleteFile = (path: string) => + guarded(async () => { + if (await deleteMemoryFile(path)) { + if (open?.path === path) setOpen(null) + toast.success(t('memDeleted')) + } else { + toast.error(t('memUpdateFailed')) + } + }) + + const revert = (run: MemoryRun) => + guarded(async () => { + if (await revertMemoryRun(run.id)) { + // The open file may be one the revert changed; reading it again beats showing a stale draft. + setOpen(null) + toast.success(t('memReverted')) + } else { + toast.error(t('memRevertFailed')) + } + }) + + const consolidate = () => + guarded(async () => { + if (await runMemoryConsolidation()) { + toast.success(t('memConsolidateStarted')) + } else { + toast.error(t('memConsolidateFailed')) + } + }) + + if (!available) { + return ( +
+ +
+ ) + } + + return ( +
+ + {t('memConsolidateNow')} + + } + /> + + + + + guarded(() => setMemoryEnabled(checked)) + } + /> + + + + + + + guarded(() => setMemoryHistoryInput(checked)) + } + /> + + + + + {!settings || settings.files.length === 0 ? ( +
+

{t('memFilesEmpty')}

+
+ ) : ( + settings.files.map((file) => ( +
+
+ {file.path} +

+ {t('memBytes', file.bytes)} ·{' '} + {formatRelative(file.modifiedMs)} +

+
+
+ + +
+
+ )) + )} + {open && ( +
+ {open.path} +