diff --git a/docs/README.md b/docs/README.md index b8530e1..54a7d94 100644 --- a/docs/README.md +++ b/docs/README.md @@ -39,6 +39,7 @@ Start with the local API, then choose an adapter. A deployment guide or versione - [Memory-first modules](memory-first-v0.1/README.md): independent memory, private storage, revisions, provider contracts, leased workers, source-grounded summaries and optional vector search. Model/vector services remain opt-in; synthetic tests are not production acceptance. - [ChatGPT core foundation](chatgpt-core-foundation-v0.1.md) and [core correctness](core-correctness-v0.2.md). - [Core optimization release notes](core-optimization-v0.2/release-notes.md), [retrieval design](core-optimization-v0.2/retrieval-decision.md), and [acceptance cases](core-optimization-v0.2/ACCEPTANCE_TESTS.md). +- [Console feature standard](console-feature-standard.md) (Chinese): the console feature map, feature statuses and the checklist for turning a planned console feature into a live one. - [Core review v0.3](core-review-v0.3/README.md): mixed-script retrieval, search readiness, and strict event acceptance receipts; that core-only review did not include OAuth. - [Optional OAuth and HTTP MCP](chatgpt-web-oauth-v0.1/README.md): separate authorization and read-only gateway, [dependency choices](chatgpt-web-oauth-v0.1/dependency-decision.md), and [verification scope](chatgpt-web-oauth-v0.1/implementation-report.md). diff --git a/docs/architecture/account-ownership.json b/docs/architecture/account-ownership.json index 07bab77..29a4406 100644 --- a/docs/architecture/account-ownership.json +++ b/docs/architecture/account-ownership.json @@ -2,7 +2,7 @@ "schema_version": "account-ownership-inventory-v1", "core": { "connection_and_owner": ["cloud_memory_bindings","cloud_memory_operations"], - "user_id": ["audit_events","checkpoints","credentials","events","handoff_drain_resumes","memories","memory_annotations","memory_category_overrides","memory_create_operations","memory_fingerprints","memory_index_outbox","memory_job_items","memory_jobs","memory_owner_model_usage","memory_owner_vector_usage","memory_privacy","memory_processing_outbox","memory_revisions","memory_source_links","memory_source_pins","memory_sources","memory_summaries","memory_summary_dependencies","memory_vector_documents","memory_vector_points","memory_web_grants","projects","resolver_selections","resume_delivery_receipts","resume_injection_events","resumes","task_bootstrap_previews","task_canonical_revisions","task_reconciliation_proposals","tasks","console_operations","console_models","console_imports","console_vector_requests","console_settings","memory_vector_owners","memory_vector_owner_active"], + "user_id": ["audit_events","checkpoints","credentials","events","handoff_drain_resumes","memories","memory_annotations","memory_category_overrides","memory_create_operations","memory_fingerprints","memory_index_outbox","memory_job_items","memory_jobs","memory_owner_model_usage","memory_owner_vector_usage","memory_privacy","memory_processing_outbox","memory_revisions","memory_source_links","memory_source_pins","memory_sources","memory_summaries","memory_summary_dependencies","memory_vector_documents","memory_vector_points","memory_web_grants","memory_web_policy","projects","resolver_selections","resume_delivery_receipts","resume_injection_events","resumes","task_bootstrap_previews","task_canonical_revisions","task_reconciliation_proposals","tasks","console_operations","console_models","console_imports","console_vector_requests","console_settings","memory_vector_owners","memory_vector_owner_active"], "summary_parent": ["memory_derived_outbox","memory_summary_claims"], "memory_parent_fts_internal": ["memory_search_docs","memory_search_fts","memory_search_fts_config","memory_search_fts_data","memory_search_fts_docsize","memory_search_fts_idx"], "operator_only_infrastructure": ["handoff_module_state","memory_model_budget","memory_profile_state","memory_search_state","memory_vector_active","memory_vector_calls","memory_vector_generations","settings"] diff --git a/docs/chatgpt-web-oauth-v0.1/web-memory-review.md b/docs/chatgpt-web-oauth-v0.1/web-memory-review.md index b47e126..aa4b0a0 100644 --- a/docs/chatgpt-web-oauth-v0.1/web-memory-review.md +++ b/docs/chatgpt-web-oauth-v0.1/web-memory-review.md @@ -27,6 +27,16 @@ table starts empty. Memory updates/revisions and privacy changes revoke grants. Changing a public classification is a separate local operation, not a grant revocation. Core local readers keep their original owner/scope behavior. +An owner can instead switch ChatGPT reads to **all** of their records with the +console action `memory.web_policy` (Privacy & retention → ChatGPT read scope). While +`memory_web_policy.read_all` is on, every `internal` and `sensitive` record of that +account is readable, including records added or corrected later, without per-revision +grants. `secret` and unknown classifications stay invisible, and other accounts are +unaffected. The switch is versioned (`expected_revision`, conflicts return +`SETTINGS_VERSION_CHANGED`), idempotent per operation ID and audited as +`memory.web_policy`. Switching it off restores the explicit-grant rule; existing grants +are kept. + Filtering occurs before lexical candidate limits, counters and conflict creation, and again after awaited vector requests. Details and historical lifecycle records apply the same rule. A summary requires **every dependency**, including omitted diff --git a/docs/console-design.md b/docs/console-design.md new file mode 100644 index 0000000..59ce800 --- /dev/null +++ b/docs/console-design.md @@ -0,0 +1,86 @@ +# Console design: Ink Archive + +The console presents memories like a personal archive: paper, ink and a single seal-red accent. Every +memory is a catalogued record with a source and a revision; the interface stays calm and quiet so that +content, provenance and permission boundaries carry the page. + +## Files + +- `web/console/styles.css`: design tokens, layout and components. +- `web/console/controls.css`: native-select enhancement, write dialogs and connections. +- `web/console/visuals.mjs`: page views, the escaping `html` template tag and the fixed local icon set. + +The server bundles the files listed in `STYLESHEETS` (`web/console/render.mjs`) into the single +`/assets/styles.css` response, so CSP and ingress rules are unchanged. There are no decorative skin layers. + +Browsers may load only the asset paths the public ingress allows (see +[`console-ingress.example.yml`](console-ingress.example.yml)): `styles.css`, `favicon.svg` and seven modules. A new browser module +must either be folded into an existing one or ship together with a matching ingress route; otherwise the +module graph fails behind the tunnel and pages stay on "Loading". A test walks the browser import graph and +checks it against that allowlist. + +## Logo + +A seal inside a pair of corner quotes 「■」: the quotes cite the source, the seal is the memory kept on record. +It carries the product's core promise — every memory keeps its provenance — and reuses the accent's meaning of +"current, confirmed". + +- 48×48 grid. Quotes: `M5 19V5h14` and `M43 29v14H29`, square caps, mitred corners. Seal: an 18×18 square at 15,15. +- Quote stroke 4 at display sizes, 4.5 in the 34px sidebar/sign-in mark, 5–6 at 32px and below. +- Colours: quotes follow the text colour (`--text`; paper on dark backgrounds), the seal follows `--accent`. + The wordmark is set in the serif face next to the mark. +- `web/console/favicon.svg` uses a fixed vermilion seal and switches its quotes to paper colour under + `prefers-color-scheme: dark`. It is served at `/assets/favicon.svg`, so the page CSP stays `img-src 'self'`. + +## Tokens + +All tokens live in `:root`. There is one light palette with a single vermilion accent and no theme +switching: + +| Token | Value | Use | +| --- | --- | --- | +| `--bg` | `#F5F1E8` paper | Page background, sidebar, top bar | +| `--surface` | `#FFFDF8` card stock | Cards, detail pane, sign-in form | +| `--surface-2` | `#F0EADD` | Tracks, quiet fills | +| `--soft` | `#EBE3D1` sand | Current page, selected row, focus halo | +| `--border` / `--line` | `#DCD4C3` / `#CFC6B3` | Hairline rules / quiet outlines | +| `--muted` | `#5F5A50` | Secondary text, icons, form-control borders | +| `--text` | `#1C1B18` ink | Body text, primary buttons, strong rules | +| `--accent` | `#AE3F2C` vermilion | Current marker, confirmations | +| `--good` / `--warn` / `--bad` | `#3B6B4F` / `#8A5A12` / `#9B2C1F` | Verified / in progress / danger | + +Geometry: `--radius` 2px everywhere, 36–38px controls, 1px hairlines with a 1.5px ink rule above +section headings, table heads and stat tiles. No gradients, glow, blur or glass. + +There is no dark mode and no appearance page. The only display preference is the interface language: a +compact select in the top bar and on the sign-in pages, saved per account in the browser. + +## Type + +- Headings, wordmark and figures: serif (`--font-serif`, system Songti / Palatino / Iowan). +- Body and controls: system sans (`--font-sans`, PingFang SC / Segoe UI / Microsoft YaHei). +- IDs, times, counts and small labels: monospace (`--font-mono`). + +System fonts only: the CSP allows no external font source, and CJK web fonts are large. + +## Components + +- **Navigation**: icon + label, grouped under small monospace labels. The current page gets a sand + background, bold label and an accent icon. No route codes. +- **Buttons**: primary = ink fill; secondary = ink outline; quiet = text only. The submit button of an + explicit operation dialog, and OAuth consent, use the accent (a "seal" confirming the action). + Revoke, retract, disable and cancel actions use the danger colour. +- **Status**: shape and words carry the meaning, colour only supports it — filled square for active, + hollow square for superseded or in progress, a dash and strike-through for retracted. +- **Notices and policy boxes**: a 1.5px ink rule on top, no coloured side bars. +- **Icons**: 24px grid, 1.5px stroke, square caps and mitred joins (`visuals.mjs`). +- **Feature status**: the same shape language — filled square for live, hollow for planned, half-filled for a + partly live page, a dash for features deliberately not offered on the web. Planned features render as dashed + cards with a disabled wireframe and collapsed developer notes; nothing in a placeholder can trigger a request. + What each page offers or will offer is listed in the feature map; see + [console-feature-standard.md](console-feature-standard.md) for the development rules. + +All motion stops under `prefers-reduced-motion`. + +The design canvas with the full set of screens (library, overview, connections, security, sign-in, +registration, authenticator, recovery codes and OAuth consent) is kept outside the repository. diff --git a/docs/console-feature-standard.md b/docs/console-feature-standard.md new file mode 100644 index 0000000..2f6e8fa --- /dev/null +++ b/docs/console-feature-standard.md @@ -0,0 +1,198 @@ +# 控制台功能开发标准 + +适用于 `web/console` 控制台的全部菜单页面。功能清单(`web/console/visuals.mjs` 中的 `featureMap`)和本文件一起构成开发标准:界面上的占位、系统状态页的「功能进度」、本文件末尾的功能表都来自这份清单。`services/oauth/test/console-feature-map.test.mjs` 会检查清单、界面、接口白名单、中英文文案和本文件是否一致,不一致时测试失败。 + +新增或调整功能时,先改清单,再写代码。 + +## 1. 功能清单字段 + +| 字段 | 含义 | +| --- | --- | +| `id` | 功能编号,格式 `ABC-00`。前缀按页面固定(见第 2 节),编号只增加、不复用。 | +| `status` | `live` 已上线 · `planned` 规划中 · `policy` 不开放(见第 3 节)。 | +| `read` | 读取的 console-api 视图名,浏览器请求 `GET /console-api/`。 | +| `write` | 写操作名,格式 `<领域>.<动词>`,经 `POST /console-api/action` 提交。 | +| `core` | 规划中的功能将复用的现有 Core 接口。 | +| `scope` | 这些 Core 接口检查的权限。 | +| `reauth` | 写操作需要当前密码和一个未使用过的动态验证码。 | +| `operator` | 仅平台管理员可用。 | +| `ui` | 规划中功能的线框:`table` 表格列、`form` 表单字段(`类型:键`,类型为 `select`、`number`、`text`、`check`)、`submit` 主按钮、`actions` 其他按钮、`stats` 数字卡片。 | + +文案不写在清单里。标题键是 `feat` 加去掉连字符的编号(`TSK-02` → `featTSK02`),说明键再加 `Note`(`featTSK02Note`)。线框里的列名、字段名、按钮名也是文案键。所有键都必须同时有中文和英文。 + +## 2. 页面与编号前缀 + +| 分组 | 页面 | 路由 | 前缀 | +| --- | --- | --- | --- | +| 我的空间 | 概览 | `/app/overview` | `OVW` | +| 我的空间 | 记忆库 | `/app/memories` | `MEM` | +| 我的空间 | 分类与摘要 | `/app/summaries` | `SUM` | +| 我的空间 | 项目与任务 | `/app/tasks` | `TSK` | +| 我的空间 | 接续交接 | `/app/resume` | `RES` | +| 我的空间 | 整理任务 | `/app/jobs` | `JOB` | +| 连接与设置 | 连接管理 | `/app/connections` | `CON` | +| 连接与设置 | 模型配置 | `/app/models` | `MOD` | +| 连接与设置 | 隐私与保留 | `/app/privacy` | `PRV` | +| 连接与设置 | 账户安全 | `/app/security` | `SEC` | +| 连接与设置 | 审计记录 | `/app/audit` | `AUD` | +| 连接与设置 | 存储与备份 | `/app/storage` | `STO` | +| 平台管理 | 注册码管理 | `/app/invitations` | `INV` | +| 平台管理 | 账户管理 | `/app/accounts` | `ACC` | +| 平台管理 | 系统状态 | `/app/system` | `SYS` | + +「项目与任务」「接续交接」「隐私与保留」「系统状态」是原型页面:整页由清单生成,每个功能一张卡片,已上线的排在前面。已上线的卡片显示真实数据,也可以带真实操作;规划中的卡片显示线框和开发说明,不带任何操作。其他页面保留原有的真实功能,并在底部用「功能规划」列出本页规划中和不开放的功能。 + +## 3. 状态与流转 + +- **planned 规划中**:只有界面占位。线框里的控件全部禁用,不带 `data-console-action`,不发请求,不显示假数据或假的成功提示。`write` 里的操作名在服务端允许的操作集合中**不得**已经存在;测试会检查,避免规划中的功能被悄悄调用。 +- **live 已上线**:有真实接口。`read` 视图由 BFF 提供并在 ingress 白名单内;`write` 操作在服务端允许的操作集合内。 +- **policy 不开放**:有意不在网页端提供。说明文案写清楚原因和替代途径(例如运维在服务器上处理)。不得声明 `read` 或 `write`。 +- **从规划中到已上线**必须在同一个提交内完成:实现接口、前端、测试,把清单状态改为 `live`,并同步本文件的功能表。 +- 从 `policy` 改为其他状态,涉及安全或隐私边界,先在 issue 中讨论。 +- 功能取消时保留编号,状态改为 `policy`,并在说明中写明原因。 + +## 4. 分层与数据流 + +```text +浏览器 app.mjs · actions.mjs · connections.mjs · visuals.mjs + │ GET /console-api/ POST /console-api/action(表单编码:csrf、account_id、operation_id、payload) + ▼ +BFF services/oauth/src/console.mjs · console-policy.mjs + │ 会话与 CSRF、账户一致性、操作白名单与配置开关、限流 + ▼ +Core server/lib/console-read.mjs(读取视图)· server/lib/console/service.mjs(写操作) + 每个查询都按当前账户过滤 +``` + +身份和账户类操作(`security.*`、`oauth.*`、`invitations.*`、`accounts.*`、`connections.*`)在 BFF 执行,其余写操作转给 Core。 + +## 5. 新增读取视图 + +1. **Core**:在 `consoleRead` 增加 `case ''`,把允许的参数加入该视图的参数白名单。只查询当前 `user_id` 的数据;分页 `limit` 不超过 100;不返回密钥、凭证或其他账户的数据。 +2. **Core 路由**:在 `server/lib/app.mjs` 的 `/v1/console/(...)` 列表中加入视图名。 +3. **BFF**:在 `console.mjs` 的透传正则中加入视图名。BFF 自己的数据单独写处理函数,并在 `await` 之后调用 `ids.session(token,'console')` 复核会话。 +4. **Ingress**:在 `docs/console-ingress.example.yml` 的 `console-api/(...)` 中加入视图名,并同步到 Cloudflare 隧道的路径规则。 +5. **审计**:透传路径会记录 `console.read.`;自写的处理函数自行记录。 +6. **测试**:Core 单测覆盖账户隔离、分页和参数白名单;BFF 测试覆盖未登录和跨账户请求。 + +## 6. 新增写操作 + +1. **命名**:`<领域>.<动词>`,小写,多个词用下划线连接,例如 `retention.prune`、`memory.batch_classify`。 +2. **注册**:Core 操作加入 `shared/console-contract.mjs` 的 `CONSOLE_ACTIONS`;身份和账户类操作加入 `services/oauth/src/console-policy.mjs`;两者都要受 `consoleActionAllowed` 的配置开关控制。 +3. **实现**:同一个 `operation_id` 重放时返回原结果(幂等)。有版本的对象带 `revision` 或 `expected_revision`,冲突时返回 `VERSION_CHANGED`。失败如实返回错误码,不伪造成功。 +4. **重新验证**:清单中标记 `reauth` 的操作,由服务端校验 `current_password` 和未使用过的 `otp`。 +5. **限流**:沿用 `console:write:<账户>` 每分钟 60 次;批量操作限制单次数量,并逐条返回结果。 +6. **审计**:记录账户审计事件,不含记忆正文和密钥。 +7. **前端**:用 `actionButton(action, labelKey)` 生成按钮,只在 `canAct(caps, action)` 为真时显示;在 `mountActions` 的 `begin()` 中加入表单字段,在 `payload()` 中组装请求。 +8. **错误码**:新的错误码在 catalog 中补齐中英文说明。 + +## 7. 新增页面 + +1. `web/console/routes.mjs` 加路由;`render.mjs` 的 `navGroups` 加菜单项;`visuals.mjs` 加图标(24 像素网格、1.5 线宽、方角)。 +2. catalog 加页面名 `` 和页面说明 `pageNote_`,中英文都要有。页面 ID 不能与已有文案键重名(例如 `projects` 已是分类名,所以项目页用 `tasks`)。 +3. 在清单中加入该页的功能;需要新前缀时,在第 2 节登记。 +4. ingress:`app/(...)` 加入页面名,同步 Cloudflare 隧道的路径规则。 +5. 浏览器测试的页面列表加入该页(`scripts/test-console-browser.py`、`scripts/test-console-connections.mjs`)。 +6. 仅平台管理员的页面,在 `actionPage` 中判断 `operator`;对应接口在服务端独立鉴权。隐藏按钮不等于授权。 +7. 新的浏览器模块必须同时加入 ingress 白名单,否则隧道后页面会一直停在「正在加载」;优先放进已有模块。 + +## 8. 界面规范 + +- 遵循 [console-design.md](console-design.md) 的令牌和组件。CSP 为 `style-src 'self'`:不写内联 `style`,不引入外部字体、脚本或图片。 +- 所有文案经 catalog(`data-i18n`)输出,中英文齐全。动态值一律经 `html` 模板转义;只有本地固定的标记才用 `trusted()`。 +- 每个数据区块都有加载、空和错误三种状态;某个卡片的数据读取失败时,只降级这一张卡片,不影响整页。 +- 状态用形状加文字表达,颜色只作辅助:已上线为实心方块,规划中为空心方块,部分上线为半实心方块,不开放为短横。 +- 表单控件都有 label;对话框可以用 Esc 关闭,关闭后焦点回到触发按钮;键盘能完成所有操作。 +- 规划中的占位只用清单的 `ui` 描述,不手写假数据。 + +## 9. 安全与隐私红线 + +- 数据按账户隔离。平台管理员只能管理账户,不能读取他人的记忆,也不能代替他人操作。 +- 响应、日志和审计中不出现密码、密钥、令牌或恢复码明文。一次性展示的新凭证除外,且只展示一次。 +- 网页端不提供整库备份与恢复,也不提供删除账户(清单中为 `policy`)。 +- 所有写操作都由服务端授权;前端禁用按钮不是授权。 +- 模型外发必须经用户明确许可并受预算限制,不静默改用共享的付费模型。 +- 接续的预览、确认、投递和完成回执是不同步骤,不能合并,也不能互相冒充。 + +## 10. 完成标准 + +一个功能从规划中改为已上线前,逐项确认: + +- [ ] 清单状态改为 `live`,本文件的功能表同步更新 +- [ ] Core 和 BFF 单测,包括越权和跨账户的负面用例 +- [ ] `npm run test:oauth` 和 `npm test` 通过 +- [ ] 浏览器测试(`console-browser` 工作流)通过 +- [ ] ingress 示例和 Cloudflare 隧道规则已更新(新页面、新视图、新模块) +- [ ] 中英文文案齐全 +- [ ] 相关文档已更新 +- [ ] `node scripts/check-publication.mjs --worktree` 通过 + +## 11. 功能表 + +「读取」列是 console-api 视图名,「写操作」列是操作名。测试会逐行核对编号和状态。 + +| 编号 | 页面 | 功能 | 状态 | 读取 | 写操作 | +| --- | --- | --- | --- | --- | --- | +| OVW-01 | 概览 | 记忆分布与计数 | live | overview | — | +| OVW-02 | 概览 | 最近写入与 30 天活动 | live | overview | — | +| OVW-03 | 概览 | 待处理事项 | planned | attention | — | +| MEM-01 | 记忆库 | 检索与筛选 | live | memories | — | +| MEM-02 | 记忆库 | 详情、来源与修订历史 | live | memory, memory-meta | — | +| MEM-03 | 记忆库 | 新建记忆 | live | — | memory.create | +| MEM-04 | 记忆库 | 修订与撤回 | live | — | memory.correct, memory.retract | +| MEM-05 | 记忆库 | 分类、敏感级别与 ChatGPT 可见性 | live | — | memory.classify, memory.sensitivity, memory.visibility | +| MEM-06 | 记忆库 | 批量整理 | planned | — | memory.batch_classify, memory.batch_retract | +| MEM-07 | 记忆库 | 版本对比 | planned | memory | — | +| SUM-01 | 分类与摘要 | 分类索引与派生摘要 | live | summaries, summary | — | +| SUM-02 | 分类与摘要 | 生成分类与摘要 | live | — | jobs.schedule | +| SUM-03 | 分类与摘要 | 自定义分类体系 | planned | taxonomy | taxonomy.save | +| TSK-01 | 项目与任务 | 项目列表 | live | projects | — | +| TSK-02 | 项目与任务 | 任务与来源分支 | planned | task-branches | — | +| TSK-03 | 项目与任务 | 项目上下文预览 | planned | project-context | — | +| TSK-04 | 项目与任务 | 检查点与权威任务状态 | planned | task-checkpoints | — | +| TSK-05 | 项目与任务 | 新建项目与任务 | planned | — | projects.bootstrap, tasks.bootstrap | +| TSK-06 | 项目与任务 | 任务对账 | planned | task-reconciliation | tasks.reconcile | +| RES-01 | 接续交接 | 接续预览 | planned | resume-preview | — | +| RES-02 | 接续交接 | 确认接续 | planned | — | resume.confirm | +| RES-03 | 接续交接 | 投递与完成回执 | planned | resume-deliveries | — | +| RES-04 | 接续交接 | 接续历史 | planned | resume-history | — | +| JOB-01 | 整理任务 | 任务队列与执行状态 | live | jobs, job | — | +| JOB-02 | 整理任务 | 创建与定期计划 | live | — | jobs.schedule | +| JOB-03 | 整理任务 | 取消与重试 | live | — | jobs.cancel, jobs.retry | +| CON-01 | 连接管理 | ChatGPT 网页版与应用授权 | live | connections | oauth.revoke | +| CON-02 | 连接管理 | 个人连接 | live | connections | connections.create, connections.update, connections.rotate, connections.disable, connections.enable, connections.revoke | +| CON-03 | 连接管理 | Agent 实例与设备 | live | connections | devices.revoke | +| CON-04 | 连接管理 | 捕获健康度 | planned | capture-status | — | +| CON-05 | 连接管理 | 登记与轮换 Agent 密钥 | planned | — | devices.register, devices.rotate | +| MOD-01 | 模型配置 | 整理模型与向量模型 | live | models | models.save, models.disable | +| MOD-02 | 模型配置 | 连通性测试 | live | — | models.test | +| MOD-03 | 模型配置 | 个人向量索引 | live | — | vector.schedule | +| MOD-04 | 模型配置 | 用量与预算 | planned | model-usage | — | +| PRV-01 | 隐私与保留 | 外发许可总览 | live | models | — | +| PRV-02 | 隐私与保留 | 新记忆默认设置 | planned | privacy-defaults | privacy.defaults | +| PRV-03 | 隐私与保留 | 数据保留策略 | planned | retention | retention.save | +| PRV-04 | 隐私与保留 | 清理过期数据 | planned | — | retention.prune | +| PRV-05 | 隐私与保留 | 删除账户与全部数据 | policy | — | — | +| PRV-06 | 隐私与保留 | ChatGPT 读取范围 | live | capabilities | memory.web_policy | +| SEC-01 | 账户安全 | 修改密码 | live | — | security.password | +| SEC-02 | 账户安全 | 更换验证器 | live | — | security.totp.begin, security.totp.complete | +| SEC-03 | 账户安全 | 轮换恢复码 | live | — | security.recovery_codes | +| SEC-04 | 账户安全 | 会话管理 | live | security | security.session.revoke, security.sessions.revoke_others | +| SEC-05 | 账户安全 | 登录记录 | planned | login-history | — | +| AUD-01 | 审计记录 | 账户事件时间线 | live | audit | — | +| AUD-02 | 审计记录 | 筛选与导出 | planned | audit | — | +| STO-01 | 存储与备份 | 个人数据导出 | live | export | storage.export | +| STO-02 | 存储与备份 | 导入为新记忆 | live | — | storage.import | +| STO-03 | 存储与备份 | 存储用量 | live | storage | — | +| STO-04 | 存储与备份 | 整库备份与恢复 | policy | — | — | +| INV-01 | 注册码管理 | 注册码清单 | live | invitations | — | +| INV-02 | 注册码管理 | 批量签发 | live | — | invitations.issue | +| INV-03 | 注册码管理 | 撤销 | live | — | invitations.revoke, invitations.revoke_batch | +| ACC-01 | 账户管理 | 账户清单 | live | accounts | — | +| ACC-02 | 账户管理 | 停用与启用 | live | — | accounts.disable, accounts.enable | +| ACC-03 | 账户管理 | 平台管理员角色 | live | — | accounts.role | +| ACC-04 | 账户管理 | 查看他人记忆 | policy | — | — | +| SYS-01 | 系统状态 | 平台开关 | live | capabilities | — | +| SYS-02 | 系统状态 | 服务健康 | planned | system-health | — | +| SYS-03 | 系统状态 | 版本与迁移 | planned | system-version | — | +| SYS-04 | 系统状态 | 备份状态 | planned | backups | — | diff --git a/docs/console-ingress.example.yml b/docs/console-ingress.example.yml index c37f2bd..01e977a 100644 --- a/docs/console-ingress.example.yml +++ b/docs/console-ingress.example.yml @@ -25,7 +25,7 @@ ingress: originRequest: httpHostHeader: memory.example.com - hostname: memory.example.com - path: '^/(login|recover(/(start|complete))?|register(/(reserve|account|totp|recovery-codes|ack|status))?|app(/(overview|memories|summaries|jobs|connections|models|security|audit|storage|appearance|invitations|accounts))?/?|console-api/(me|logout|capabilities|action|security|connections|audit|overview|memories|memory|memory-meta|summaries|summary|jobs|job|projects|storage|export|operation|models|invitations|accounts)|assets/(styles\.css|appearance\.mjs|catalog\.mjs|app\.mjs|session-state\.mjs|visuals\.mjs|actions\.mjs|connections\.mjs))$' + path: '^/(login|recover(/(start|complete))?|register(/(reserve|account|totp|recovery-codes|ack|status))?|app(/(overview|memories|summaries|tasks|resume|jobs|connections|models|privacy|security|audit|storage|invitations|accounts|system))?/?|console-api/(me|logout|capabilities|action|security|connections|audit|overview|memories|memory|memory-meta|summaries|summary|jobs|job|projects|storage|export|operation|models|invitations|accounts)|assets/(styles\.css|favicon\.svg|appearance\.mjs|catalog\.mjs|app\.mjs|session-state\.mjs|visuals\.mjs|actions\.mjs|connections\.mjs))$' service: http://127.0.0.1:47833 originRequest: httpHostHeader: memory.example.com diff --git a/docs/schema-migrations.md b/docs/schema-migrations.md new file mode 100644 index 0000000..0b6a4b7 --- /dev/null +++ b/docs/schema-migrations.md @@ -0,0 +1,18 @@ +# Core schema migrations + +The Core SQLite schema is versioned with `PRAGMA user_version`. + +- Steps live in `server/lib/store/schema.mjs` as `CORE_MIGRATIONS`, numbered contiguously from 1. + The runner is `server/lib/store/migrations.mjs`. +- On open, every pending step runs in its own `BEGIN IMMEDIATE` transaction and then records its version. + If a step fails, it rolls back and the stored version is unchanged. +- Steps marked `repeatable` only guarantee structure (`CREATE ... IF NOT EXISTS`, additive columns). They are + re-checked on every open, so a database created before versioning (`user_version = 0`) or a partially + restored copy repairs itself exactly as before. +- One-off data changes must **not** be `repeatable`. Append them as a new version; never renumber or edit a released step. +- A database whose version is newer than the running release is refused (`SCHEMA_VERSION_UNSUPPORTED`) + instead of being opened by code that does not understand it. Roll back the release together with a matching backup. +- Feature modules that own their own tables (revisions, derived memory, jobs, web visibility) still create them + idempotently; move them into versioned steps when they next change. + +Run `node --test server/test/schema-migrations.test.mjs` after adding a step. diff --git a/scripts/console_select_checks.py b/scripts/console_select_checks.py index 152496d..b7a722b 100644 --- a/scripts/console_select_checks.py +++ b/scripts/console_select_checks.py @@ -26,7 +26,8 @@ def check_selects(page, goto, check, previews): native = page.locator('[name="search_mode"]') expect(trigger).to_be_visible() check('Select enhancement retains exactly one native named control', native.count() == 1) - check('Preferences and filters share one combobox implementation', page.get_by_role('combobox').count() == 6) + # The language switch plus three library filters; there is no theme or colour-mode select. + check('Preferences and filters share one combobox implementation', page.get_by_role('combobox').count() == 4) check('Library preserves real column headers', page.locator('.memory-table th').count() == 4) page.evaluate('''() => { window.selectEvents = {input: 0, change: 0}; @@ -55,7 +56,7 @@ def check_selects(page, goto, check, previews): expect(native).to_have_value('semantic') check('Outside click dismisses without committing a preview', page.locator('.select-popup:popover-open').count() == 0) choose_select(page, '[name="search_mode"]', 'lexical') - trigger.click(); page.locator('[data-select-name="theme"]').click() + trigger.click(); page.locator('[data-select-name="locale"]').click() check('Only one select popup can be open', page.locator('.select-popup:popover-open').count() == 1) page.keyboard.press('Escape') page.locator('.account-menu summary').click(); trigger.click() @@ -66,37 +67,30 @@ def check_selects(page, goto, check, previews): listener = lambda request: requests.append(request.url) if '/console-api/' in request.url else None page.on('request', listener) choose_select(page, '#locale', 'en') - choose_select(page, '#theme', 'b') - choose_select(page, '#mode', 'dark') expect(page.locator('[name="query"]')).to_have_value('Synthetic draft C9800-CL') - check('Appearance selection neither submits business requests nor resets form drafts', not requests) + check('Language selection neither submits business requests nor resets form drafts', not requests) page.remove_listener('request', listener) trigger.click(); page.keyboard.press('s'); page.keyboard.press('Enter') expect(native).to_have_value('semantic') check('Typeahead uses translated option labels', 'Semantic' in trigger.inner_text()) - choose_select(page, '#locale', 'zh-CN'); choose_select(page, '#theme', 'a'); choose_select(page, '#mode', 'light') + choose_select(page, '#locale', 'zh-CN') choose_select(page, '[name="search_mode"]', 'lexical') page.locator('[name="query"]').fill('') - # Layout and open-popup geometry, including English labels and all palettes. + # Layout and open-popup geometry, including English labels. for width in [1280, 1440, 1920]: page.set_viewport_size({'width': width, 'height': 1000}) - for theme in ['a', 'b', 'c']: - for mode in ['light', 'dark']: - for locale in ['zh-CN', 'en']: - choose_select(page, '#theme', theme); choose_select(page, '#mode', mode); choose_select(page, '#locale', locale) - page.locator('[data-select-name="category"]').click() - pop = page.locator('.select-popup:popover-open'); r = pop.bounding_box() - fit = page.evaluate('() => document.documentElement.scrollWidth <= innerWidth') - check('Anchored dropdown / desktop layout ' + str((width, theme, mode, locale)), fit and r['x'] >= 0 and r['x'] + r['width'] <= width + 1 and r['y'] >= 0 and r['y'] + r['height'] <= 1001) - page.keyboard.press('Escape') + for locale in ['zh-CN', 'en']: + choose_select(page, '#locale', locale) + page.locator('[data-select-name="category"]').click() + pop = page.locator('.select-popup:popover-open'); r = pop.bounding_box() + fit = page.evaluate('() => document.documentElement.scrollWidth <= innerWidth') + check('Anchored dropdown / desktop layout ' + str((width, locale)), fit and r['x'] >= 0 and r['x'] + r['width'] <= width + 1 and r['y'] >= 0 and r['y'] + r['height'] <= 1001) + page.keyboard.press('Escape') page.set_viewport_size({'width': 1440, 'height': 1100}) - choose_select(page, '#theme', 'a'); choose_select(page, '#mode', 'light'); choose_select(page, '#locale', 'zh-CN') + choose_select(page, '#locale', 'zh-CN') trigger.click(); page.screenshot(path=str(previews / 'memory-search-dropdown.png'), full_page=True); page.keyboard.press('Escape') - page.locator('[data-select-name="theme"]').click(); page.screenshot(path=str(previews / 'theme-dropdown.png'), full_page=True); page.keyboard.press('Escape') - choose_select(page, '#theme', 'b'); choose_select(page, '#mode', 'dark') - trigger.click(); page.screenshot(path=str(previews / 'memory-dropdown-dark.png'), full_page=True); page.keyboard.press('Escape') - choose_select(page, '#theme', 'a'); choose_select(page, '#mode', 'light') + page.locator('[data-select-name="locale"]').click(); page.screenshot(path=str(previews / 'language-dropdown.png'), full_page=True); page.keyboard.press('Escape') # Synthetic DOM fixture exercises semantics not present in every business form. page.evaluate('''() => { diff --git a/scripts/test-console-browser.py b/scripts/test-console-browser.py index df00608..f2d1814 100644 --- a/scripts/test-console-browser.py +++ b/scripts/test-console-browser.py @@ -86,17 +86,26 @@ def close(): check('New web registration activates through configured provisioning',rp.locator('code').inner_text()=='active');registration.close() # B uses the real sign-in form, not an A session or an identity selector. bctx=browser.new_context();bp=bctx.new_page();bp.goto(url+'/login');bp.locator('[name=username]').fill(cfg['accounts'][1]['username']);bp.locator('[name=password]').fill(cfg['password']);bp.locator('[name=otp]').fill(cmd('otp',owner=1)['otp']);bp.locator('button[type=submit]').click();bp.wait_for_url('**/app');bp.goto(url+'/app/memories');bp.locator('[data-memory]').first.wait_for();check('Real username/password/TOTP login isolates B content','Synthetic private B sentinel' in bp.inner_text('body') and '蓝色纸船' not in bp.inner_text('body'));bp.goto(url+'/app/invitations');bp.wait_for_timeout(200);check('Member sees no operator invitation controls',bp.locator('[data-console-action="invitations.issue"]').count()==0);bctx.close() - for name in ['overview','memories','summaries','jobs','connections','models','security','audit','storage','appearance','invitations','accounts']: + for name in ['overview','memories','summaries','tasks','resume','jobs','connections','models','privacy','security','audit','storage','invitations','accounts','system']: goto(name) check('Functional route '+name,page.locator('#console-root h1').count()==1 and page.locator('#console-root [role=alert]').count()==0) + # Rendered geometry, not stylesheet text: restyling may change CSS freely as long as layout holds. + for width in [1280,1440,1920]: + page.set_viewport_size({'width':width,'height':900});goto('overview') + tops=page.eval_on_selector_all('.metrics > .metric','els=>els.map(e=>Math.round(e.getBoundingClientRect().top))') + check('Overview shows four metrics in one row at '+str(width),len(tops)==4 and len(set(tops))==1) + check('Overview has no horizontal overflow at '+str(width),page.evaluate('document.documentElement.scrollWidth<=innerWidth')) + check('Sidebar spans the viewport at '+str(width),page.evaluate("(()=>{const r=document.querySelector('.sidebar').getBoundingClientRect();return r.top>=0&&r.bottom<=innerHeight&&r.height>=innerHeight-40})()")) + goto('memories');page.locator('[data-memory]').first.click();page.locator('#memory-content .body-content').wait_for() + docked=page.evaluate("(()=>{const p=document.querySelector('#memory-dialog').getBoundingClientRect(),w=document.querySelector('.workspace').getBoundingClientRect();return {modal:document.querySelector('#memory-dialog').matches(':modal'),overlap:w.right-p.left}})()") + check('Detail pane docks beside the list at '+str(width),not docked['modal'] and docked['overlap']<=1 and page.evaluate('document.documentElement.scrollWidth<=innerWidth')) + page.keyboard.press('Escape');expect(page.locator('#memory-dialog')).not_to_be_visible() for width in [1280,1440,1920]: page.set_viewport_size({'width':width,'height':1080});goto('models') - for theme in ['a','b','c']: - for mode in ['light','dark']: - for locale in ['zh-CN','en']: - pick('#theme',theme);pick('#mode',mode);pick('#locale',locale) - check('Desktop model forms '+str((width,theme,mode,locale)),page.evaluate('document.documentElement.scrollWidth<=innerWidth')) - page.set_viewport_size({'width':1440,'height':1100});pick('#theme','a');pick('#mode','light');pick('#locale','zh-CN');goto('memories');page.screenshot(path=str(P/'memories-functional.png'),full_page=True) + for locale in ['zh-CN','en']: + pick('#locale',locale) + check('Desktop model forms '+str((width,locale)),page.evaluate('document.documentElement.scrollWidth<=innerWidth')) + page.set_viewport_size({'width':1440,'height':1100});pick('#locale','zh-CN');goto('memories');page.screenshot(path=str(P/'memories-functional.png'),full_page=True) begin('memory.create');page.screenshot(path=str(P/'new-memory-functional.png'),full_page=True);close();goto('models');page.screenshot(path=str(P/'models-functional.png'),full_page=True) goto('jobs');page.screenshot(path=str(P/'jobs-functional.png'),full_page=True);goto('invitations');page.screenshot(path=str(P/'invitations-functional.png'),full_page=True) check('No browser JavaScript or CSP errors',not errors) diff --git a/scripts/test-console-connections.mjs b/scripts/test-console-connections.mjs index 96b74f3..65b7e6f 100644 --- a/scripts/test-console-connections.mjs +++ b/scripts/test-console-connections.mjs @@ -25,6 +25,16 @@ let browser; try{ const cfg=await read();assert.equal(cfg.fixture,true); browser=await chromium.launch({headless:true,...(process.env.CHROMIUM_EXECUTABLE?{executablePath:process.env.CHROMIUM_EXECUTABLE}:{})}); + // Model an old browser/edge cache whose palette still requires the removed theme attribute. + const cachedContext=await browser.newContext();let staleStyles=0; + await cachedContext.route(url=>url.pathname==='/assets/styles.css'&&!url.search,route=>{ + staleStyles++; + return route.fulfill({contentType:'text/css',body:'[data-theme="a"]{--bg:#F5F1E8;--text:#1C1B18;--line:#CFC6B3}body{background:var(--bg)}input{border:1px solid var(--line)}button{background:var(--text);color:var(--bg)}'}); + }); + const cachedPage=await cachedContext.newPage();await cachedPage.goto(cfg.url+'/login');await cachedPage.locator('[name=username]').waitFor(); + const paint=await cachedPage.evaluate(()=>({bg:getComputedStyle(document.body).backgroundColor,border:getComputedStyle(document.querySelector('[name=username]')).borderTopStyle,button:getComputedStyle(document.querySelector('button[type=submit]')).backgroundColor})); + check('Login bypasses an obsolete unversioned stylesheet cache',staleStyles===0&&paint.bg==='rgb(245, 241, 232)'&&paint.border==='solid'&&paint.button==='rgb(28, 27, 24)'); + await cachedPage.screenshot({path:path.join(evidence,'login-versioned-styles.png'),fullPage:true});await cachedContext.close(); const context=await browser.newContext({viewport:{width:1440,height:1080},permissions:['clipboard-read','clipboard-write']}); await context.addCookies([{name:cfg.cookie,value:cfg.accounts[0].token,url:cfg.url,httpOnly:true,sameSite:'Lax'}]); const page=await context.newPage(),errors=[];let mutations=0; @@ -39,14 +49,24 @@ try{ const basic=async(label,profile='readonly')=>{await dialog.locator('[name=label]').fill(label);await pick('#connection-dialog [name=profile]',profile);await dialog.locator('button[type=submit]').click();await proof();}; const saveSecret=async()=>{const value=await dialog.locator('#connection-secret').inputValue();check('Secret starts masked',await dialog.locator('#connection-secret').getAttribute('type')==='password');check('Secret never appears in persistent appearance storage',!(await page.evaluate(()=>JSON.stringify(localStorage))).includes(value));await dialog.locator('[data-connection-secret-saved]').click();await dialog.locator('[data-connection-refresh]').waitFor();await command('provision-connections');await dialog.locator('[data-connection-refresh]').click();await dialog.locator('[data-connection-refresh]').waitFor();return value;}; const close=async()=>{await dialog.locator('[data-connection-close]').first().click();await dialog.waitFor({state:'hidden'});check('Closing clears the credential DOM',await page.locator('#connection-secret').count()===0);}; + const guardShortcut=async(label,locator)=>{ + const url=page.url(),value=await locator.inputValue(),before=mutations; + for(const key of ['Meta+k','Control+k']){ + await locator.focus();await page.keyboard.press(key); + await page.waitForTimeout(200); + check(`${label} survives ${key}`,page.url()===url&&await dialog.isVisible()&&await locator.inputValue()===value&&mutations===before); + } + }; await goto('connections');check('Real empty logical list retains legacy authorization separately',await page.locator('.connection-table [data-connection-detail]').count()===0&&await page.locator('.connection-system').count()===1); await openNew('generic_mcp');check('Readonly profile cannot select a write-time disclosure grant',await dialog.locator('[name=allow_submitted_revision_grant]').isDisabled());await dialog.locator('[name=label]').fill('Synthetic portable reader');await pick('#connection-dialog [name=profile]','memory_readwrite'); + await guardShortcut('Connection draft',dialog.locator('[name=label]')); const beforeAppearance=mutations; // Exercise the real preference event handler while the modal owns focus. - for(const [id,value] of [['theme','c'],['locale','en'],['locale','zh-CN']])await page.locator('#'+id).evaluate((select,value)=>{select.value=value;select.dispatchEvent(new Event('change',{bubbles:true}));},value); - check('Appearance changes preserve draft values without a mutation request',mutations===beforeAppearance&&await dialog.locator('[name=label]').inputValue()==='Synthetic portable reader'&&await dialog.locator('[name=profile]').inputValue()==='memory_readwrite'); + for(const [id,value] of [['locale','en'],['locale','zh-CN']])await page.locator('#'+id).evaluate((select,value)=>{select.value=value;select.dispatchEvent(new Event('change',{bubbles:true}));},value); + check('Language changes preserve draft values without a mutation request',mutations===beforeAppearance&&await dialog.locator('[name=label]').inputValue()==='Synthetic portable reader'&&await dialog.locator('[name=profile]').inputValue()==='memory_readwrite'); const trigger=dialog.locator('[data-select-name=profile]');await trigger.click();await page.keyboard.press('Escape');check('Escape closes the top-layer selector before the dialog',await dialog.isVisible()&&await page.locator('.select-popup:popover-open').count()===0); await dialog.locator('[name=allow_submitted_revision_grant]').check();await dialog.locator('button[type=submit]').click();await proof();await submit(); + await guardShortcut('Unsaved credential',dialog.locator('#connection-secret')); const first=await saveSecret();check('Generic credential is a personal resource token, never a Core key',first.startsWith('mcp_pat_'));check('Server configuration is not fabricated connection success',!(await dialog.innerText()).includes('记忆调用已验证'));await close(); await page.locator('.connection-table [data-connection-detail]').first().click();await dialog.locator('[data-connection-action=rotate]').click();await proof();await submit();const second=await saveSecret();check('Rotation generates a different actual credential',second!==first);await close(); await openNew('chatgpt_oauth');await basic('Synthetic browser ChatGPT');await submit();const clientSecret=await saveSecret();check('OAuth Client Secret is distinct from a generic token',!clientSecret.startsWith('mcp_pat_')&&!clientSecret.startsWith('mnm_'));await close(); @@ -62,15 +82,15 @@ try{ const bContext=await browser.newContext();await bContext.addCookies([{name:cfg.cookie,value:cfg.accounts[1].token,url:cfg.url,httpOnly:true,sameSite:'Lax'}]);const bp=await bContext.newPage();await bp.goto(cfg.url+'/app/connections');await bp.locator('[data-connection-new]').waitFor();check('Second account does not see first account connection names',!(await bp.innerText('body')).includes('Synthetic browser ChatGPT'));await bContext.close(); for(const width of [1280,1440,1920]){ await page.setViewportSize({width,height:1080}); - for(const theme of ['a','b','c'])for(const mode of ['light','dark'])for(const locale of ['zh-CN','en']){ - await pick('#theme',theme);await pick('#mode',mode);await pick('#locale',locale);await openNew('generic_mcp');await dialog.locator('[name=label]').fill('Synthetic long connection label / 合成名称 / '+'.'.repeat(24)); - check(`Desktop ${width}/${theme}/${mode}/${locale}`,await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)&&await dialog.locator('[name=label]').inputValue().then(s=>s.startsWith('Synthetic'))); - if(width===1440)await page.screenshot({path:path.join(evidence,`wizard-${theme}-${mode}-${locale}.png`),fullPage:true}); + for(const locale of ['zh-CN','en']){ + await pick('#locale',locale);await openNew('generic_mcp');await dialog.locator('[name=label]').fill('Synthetic long connection label / 合成名称 / '+'.'.repeat(24)); + check(`Desktop ${width}/${locale}`,await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)&&await dialog.locator('[name=label]').inputValue().then(s=>s.startsWith('Synthetic'))); + if(width===1440)await page.screenshot({path:path.join(evidence,`wizard-${locale}.png`),fullPage:true}); await close(); } } - await pick('#theme','c');await pick('#mode','light');await pick('#locale','zh-CN');await goto('memories');check('Theme persists through real navigation',await page.locator('html').getAttribute('data-theme')==='c'); - for(const name of ['overview','memories','summaries','jobs','connections','models','security','audit','storage','appearance','invitations','accounts']){await goto(name);check('Existing console route '+name,await page.locator('#console-root [role=alert]').count()===0);} + await pick('#locale','en');await goto('memories');check('Language persists through real navigation',await page.locator('html').getAttribute('lang')==='en');await pick('#locale','zh-CN'); + for(const name of ['overview','memories','summaries','tasks','resume','jobs','connections','models','privacy','security','audit','storage','invitations','accounts','system']){await goto(name);check('Existing console route '+name,await page.locator('#console-root [role=alert]').count()===0);} const operationDialog=page.locator('#operation-dialog'); const begin=async action=>{await page.locator(`[data-console-action="${action}"]`).first().click();await operationDialog.locator('form').waitFor();}; const runOperation=async()=>{await operationDialog.locator('button[type=submit]').click();await operationDialog.locator('.operation-result').first().waitFor();return JSON.parse(await operationDialog.locator('.operation-result').last().innerText());}; diff --git a/server/lib/console-read.mjs b/server/lib/console-read.mjs index 2b4d553..0c849b6 100644 --- a/server/lib/console-read.mjs +++ b/server/lib/console-read.mjs @@ -1,4 +1,5 @@ import {ValidationError,NotFoundError,ConflictError} from './errors.mjs'; +import {credentialView} from './console/credentials.mjs'; export const isConsoleReader=auth=>auth.agent_id==='mnemuron-console'; function pagination(params,maximum=50){ const offset=Number(params.offset??0),limit=Number(params.limit??25); @@ -7,6 +8,19 @@ function pagination(params,maximum=50){ } const snippet=m=>({memory_id:m.memory_id,content:[...String(m.content??'')].slice(0,160).join(''),memory_type:m.memory_type,status:m.status,created_at:m.created_at,...(m.category?{category:m.category}:{})}); const jobView=job=>({job_id:job.job_id,job_type:job.job_type,state:job.state,total:job.total,processed:job.processed,attempt_count:job.attempt_count,last_error_code:job.last_error_code,created_at:job.created_at,updated_at:job.updated_at,result_ref:job.result_ref}); +// Owner-scoped aggregates for overview charts. Counts only: no content, IDs or other accounts. +function overviewInsights(store,user){ + const db=store.db,since=new Date(Date.now()-29*86400000).toISOString().slice(0,10); + const byDay=new Map(db.prepare("SELECT substr(created_at,1,10) day,COUNT(*) n FROM memories WHERE user_id=? AND created_at>=? GROUP BY day").all(user,since).map(r=>[r.day,r.n])); + const activity=Array.from({length:30},(_,i)=>{const day=new Date(Date.parse(since)+i*86400000).toISOString().slice(0,10);return {day,count:byDay.get(day)||0};}); + const group=column=>db.prepare(`SELECT ${column} value,COUNT(*) count FROM memories WHERE user_id=?${column==='memory_type'?" AND status='active'":''} GROUP BY ${column} ORDER BY count DESC,value LIMIT 12`).all(user); + const categories=db.prepare(`SELECT COALESCE(o.category,a.category,'uncategorized') value,COUNT(*) count FROM memories m + LEFT JOIN memory_category_overrides o ON o.user_id=m.user_id AND o.memory_id=m.memory_id AND o.locked=1 + LEFT JOIN memory_annotations a ON a.user_id=m.user_id AND a.memory_id=m.memory_id AND a.taxonomy_version=? + AND a.revision=(SELECT MAX(revision) FROM memory_revisions WHERE user_id=m.user_id AND memory_id=m.memory_id) + WHERE m.user_id=? AND m.status='active' GROUP BY value ORDER BY count DESC,value`).all(store.consoleService.taxonomy().version,user); + return {window_days:30,activity,types:group('memory_type'),statuses:group('status'),categories}; +} export async function consoleRead(store,auth,view,params={}) { store.requireScope(auth,'console:read'); if(!isConsoleReader(auth))throw new NotFoundError('Console route not available.'); @@ -24,7 +38,8 @@ export async function consoleRead(store,auth,view,params={}) { case 'projects':return {projects:db.prepare('SELECT project_id,name FROM projects WHERE user_id=? ORDER BY name LIMIT 200').all(user)}; case 'overview':return {read_only:true,production_ready:false,counts:{memories:count('memories'),sources:count('memory_sources'),summaries:count('memory_summaries'),jobs:count('memory_jobs')}, - recent:db.prepare('SELECT memory_id,content,memory_type,status,created_at FROM memories WHERE user_id=? ORDER BY created_at DESC,memory_id LIMIT 5').all(user).map(m=>({...m,content:[...m.content].slice(0,160).join('')}))}; + recent:db.prepare('SELECT memory_id,content,memory_type,status,created_at FROM memories WHERE user_id=? ORDER BY created_at DESC,memory_id LIMIT 5').all(user).map(m=>({...m,content:[...m.content].slice(0,160).join('')})), + insights:overviewInsights(store,user)}; case 'memories': { const {offset,limit}=pagination(params),taxonomy=store.consoleService.taxonomy(); if(params.mode!==undefined&&!['lexical','hybrid','semantic'].includes(params.mode))throw new ValidationError('Invalid retrieval mode.'); @@ -76,7 +91,7 @@ export async function consoleRead(store,auth,view,params={}) { case 'jobs':{const {offset,limit}=pagination(params);if(params.job_id)return {read_only:true,job:jobView(store.consoleService.job(auth,params.job_id))}; const rows=db.prepare('SELECT job_id,job_type,state,total,processed,attempt_count,last_error_code,created_at,updated_at FROM memory_jobs WHERE user_id=? ORDER BY created_at DESC,job_id LIMIT ? OFFSET ?').all(user,limit+1,offset); return {read_only:true,worker_enabled:store.memoryConfig.console?.worker_enabled===true,settings:store.consoleService.settings(user),vector:db.prepare('SELECT generation,state,error_code,updated_at FROM console_vector_requests WHERE user_id=?').get(user)||null,jobs:rows.slice(0,limit),offset,limit,next_offset:rows.length>limit?offset+limit:null,operations:store.consoleService.capabilities(auth).writable?'available':'blocked_policy'};} - case 'connections':return {read_only:true,connections:db.prepare('SELECT credential_id,label,device_id,agent_id,agent_instance_id,created_at,last_used_at,revoked_at,expires_at,scopes_json FROM credentials WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(user),operations:store.consoleService.capabilities(auth).writable?'available':'blocked_policy'}; + case 'connections':return {read_only:true,connections:db.prepare('SELECT credential_id,label,device_id,agent_id,agent_instance_id,created_at,last_used_at,revoked_at,expires_at,scopes_json FROM credentials WHERE user_id=? ORDER BY created_at DESC LIMIT 100').all(user).map(row=>credentialView(row)),operations:store.consoleService.capabilities(auth).writable?'available':'blocked_policy'}; case 'audit':{const offset=Number(params.offset||0),limit=Number(params.limit||50);if(!Number.isSafeInteger(offset)||offset<0||offset>1000000||!Number.isSafeInteger(limit)||limit<1||limit>100)throw new ValidationError('Invalid pagination.'); const rows=db.prepare('SELECT audit_id,action,target_type,target_id,outcome,created_at FROM audit_events WHERE user_id=? ORDER BY created_at DESC,audit_id LIMIT ? OFFSET ?').all(user,limit+1,offset); return {read_only:true,entries:rows.slice(0,limit),next_offset:rows.length>limit?offset+limit:null};} diff --git a/server/lib/console/credentials.mjs b/server/lib/console/credentials.mjs new file mode 100644 index 0000000..8e1bcab --- /dev/null +++ b/server/lib/console/credentials.mjs @@ -0,0 +1,11 @@ +// Console view of an account's Core credentials. The console never revokes platform-managed keys +// (its own console key, the ChatGPT web gateway key) or any key with admin scopes; operators do. +export const MANAGED_AGENT_IDS = Object.freeze(['mnemuron-console', 'chatgpt-web']); + +export function credentialView(row, now = Date.now()) { + let scopes = []; + try { const parsed = JSON.parse(row.scopes_json || '[]'); if (Array.isArray(parsed)) scopes = parsed.map(String); } catch {} + const state = row.revoked_at ? 'revoked' : row.expires_at && Date.parse(row.expires_at) <= now ? 'expired' : 'active'; + const managed = MANAGED_AGENT_IDS.includes(row.agent_id) || scopes.some(scope => scope.startsWith('admin:')); + return {...row, scopes, state, managed, console_revocable: state === 'active' && !managed}; +} diff --git a/server/lib/console/service.mjs b/server/lib/console/service.mjs index 51de2c2..089835e 100644 --- a/server/lib/console/service.mjs +++ b/server/lib/console/service.mjs @@ -1,6 +1,7 @@ import {randomUUID} from 'node:crypto'; import {ConsoleState,object,id,number,fingerprint} from './state.mjs'; import {ConsoleModels} from './models.mjs'; +import {credentialView} from './credentials.mjs'; import {consoleActionWritable,CONSOLE_ACTIONS} from '../../../shared/console-contract.mjs'; import {AuthorizationError,ValidationError,ConflictError,NotFoundError} from '../errors.mjs'; import {MemoryWorker,scheduleLibrary} from '../memory-jobs/worker.mjs'; @@ -14,7 +15,7 @@ export class ConsoleService { require(auth,action){if(!consoleActionWritable(auth,action))throw new AuthorizationError('console:write');} taxonomy(){return this.store.memoryConfig.memory?.taxonomy||taxonomyDefault;} capabilities(auth){const actions=CONSOLE_ACTIONS.filter(action=>consoleActionWritable(auth,action));return {version:'console-actions-v1',writable:actions.length>0,actions,taxonomy:this.taxonomy(), - secret_storage:!!this.store.memoryConfig.console?.key_file,worker_enabled:this.store.memoryConfig.console?.worker_enabled===true,vector_enabled:this.store.memoryConfig.vector_store?.enabled===true,production_ready:false};} + web_policy:this.store.webVisibility.policy(auth),secret_storage:!!this.store.memoryConfig.console?.key_file,worker_enabled:this.store.memoryConfig.console?.worker_enabled===true,vector_enabled:this.store.memoryConfig.vector_store?.enabled===true,production_ready:false};} memory(auth,memoryId,revision){id(memoryId);const row=this.db.prepare('SELECT * FROM memories WHERE user_id=? AND memory_id=?').get(auth.user_id,memoryId);if(!row)throw new NotFoundError('Memory not found.','MEMORY_NOT_FOUND'); const current=this.store.revisions.latest(auth.user_id,memoryId);if(revision!==undefined&&number(revision,1,2147483647)!==current.revision)throw new ConflictError('Memory changed; review the current revision.','MEMORY_VERSION_CHANGED');return {row,current};} meta(auth,p){object(p,['memory_id']);const {row,current}=this.memory(auth,p.memory_id); @@ -31,7 +32,7 @@ export class ConsoleService { return this.state.sync(auth,action,p,operation,()=>this.apply(auth,action,p),{secret:['connections.create','connections.rotate'].includes(action)}); } apply(auth,action,p){const store=this.store; - if(action.startsWith('memory.')&&action!=='memory.create')number(p.revision,1,2147483647); + if(action.startsWith('memory.')&&!['memory.create','memory.web_policy'].includes(action))number(p.revision,1,2147483647); if(action==='memory.create'){object(p,['content','memory_type','scope','topic','project_id','task_id','workstream_id','session_id','sensitivity']); const {sensitivity='sensitive',...input}=p;this.sensitivity(sensitivity);const result=store.saveMemory(auth,{...input,source:'console_explicit'}); store.memorySources.setSensitivity(auth,result.memory.memory_id,sensitivity);return receipt(result);} @@ -42,6 +43,15 @@ export class ConsoleService { if(action==='memory.sensitivity'){object(p,['memory_id','revision','sensitivity']);this.memory(auth,p.memory_id,p.revision);this.sensitivity(p.sensitivity);store.memorySources.setSensitivity(auth,p.memory_id,p.sensitivity);return {status:'updated',...this.meta(auth,{memory_id:p.memory_id})};} if(action==='memory.classify'){object(p,['memory_id','revision','category']);this.memory(auth,p.memory_id,p.revision);return {status:'classified',...store.derivedMemory.setCategory(auth,p.memory_id,p.category,this.taxonomy())};} if(action==='memory.visibility'){object(p,['memory_id','revision','state_hash','allow']);this.memory(auth,p.memory_id,p.revision);return {status:'updated',...store.webVisibility.set(auth,p.memory_id,p)};} + if(action==='memory.web_policy'){object(p,['read_all','expected_revision']);return {status:'updated',...store.webVisibility.setPolicy(auth,p)};} + if(action==='devices.revoke'){object(p,['agent_instance_id']);id(p.agent_instance_id); + // Same effect as the admin revoke of an agent instance, limited to the owner's own unmanaged keys. + const rows=this.db.prepare('SELECT * FROM credentials WHERE user_id=? AND agent_instance_id=? AND revoked_at IS NULL').all(auth.user_id,p.agent_instance_id).map(row=>credentialView(row)); + if(!rows.length)throw new NotFoundError('Active agent credential not found.','CREDENTIAL_NOT_FOUND'); + if(rows.some(row=>row.managed))throw new ConflictError('Platform-managed and admin keys are revoked by an operator, not from the console.','MANAGED_CONNECTION'); + const revokedAt=new Date().toISOString(),result=this.db.prepare('UPDATE credentials SET revoked_at=? WHERE user_id=? AND agent_instance_id=? AND revoked_at IS NULL').run(revokedAt,auth.user_id,p.agent_instance_id); + store.audit({auth,action:'agent_instance.revoke',targetType:'agent_instance',targetId:p.agent_instance_id,metadata:{revoked_credentials:result.changes,source:'console'}}); + return {status:'revoked',agent_instance_id:p.agent_instance_id,revoked_at:revokedAt,revoked_credentials:result.changes};} if(action==='jobs.schedule'){ object(p,['type','timezone','periods','include_open','schedule_enabled','settings_revision']); if(!['classification','summary'].includes(p.type)||typeof p.timezone!=='string'||(p.include_open!==undefined&&typeof p.include_open!=='boolean'))throw new ValidationError('Invalid scheduling request.'); diff --git a/server/lib/memory/web-visibility.mjs b/server/lib/memory/web-visibility.mjs index 6319b9f..4bab735 100644 --- a/server/lib/memory/web-visibility.mjs +++ b/server/lib/memory/web-visibility.mjs @@ -6,16 +6,19 @@ export const WEB_READ_POLICY = 'web-memory-visibility-v1'; export const isWebReader = auth => auth?.agent_id === 'chatgpt-web'; // Destination comes from the authenticated credential, never from a tool argument or header. +// Internal/sensitive records need a grant for their current revision, unless the owner switched +// ChatGPT reads to all records (memory_web_policy.read_all). Secret records are never visible. export function webMemorySql(auth, alias='m') { if (!isWebReader(auth)) return '1=1'; return `(EXISTS (SELECT 1 FROM memory_privacy wp WHERE wp.user_id=${alias}.user_id AND wp.memory_id=${alias}.memory_id AND wp.sensitivity='public') OR (COALESCE((SELECT sensitivity FROM memory_privacy wp WHERE wp.user_id=${alias}.user_id AND wp.memory_id=${alias}.memory_id),'sensitive') IN ('internal','sensitive') - AND EXISTS (SELECT 1 FROM memory_web_grants wg JOIN memory_revisions wr + AND (EXISTS (SELECT 1 FROM memory_web_policy wpol WHERE wpol.user_id=${alias}.user_id AND wpol.read_all=1) + OR EXISTS (SELECT 1 FROM memory_web_grants wg JOIN memory_revisions wr ON wr.user_id=wg.user_id AND wr.memory_id=wg.memory_id AND wr.revision=wg.revision AND wr.state_hash=wg.state_hash WHERE wg.user_id=${alias}.user_id AND wg.memory_id=${alias}.memory_id AND wg.sensitivity=COALESCE((SELECT sensitivity FROM memory_privacy wp WHERE wp.user_id=${alias}.user_id AND wp.memory_id=${alias}.memory_id),'sensitive') AND wr.content=${alias}.content AND wr.status=${alias}.status - AND wr.revision=(SELECT MAX(revision) FROM memory_revisions WHERE user_id=wg.user_id AND memory_id=wg.memory_id))))`; + AND wr.revision=(SELECT MAX(revision) FROM memory_revisions WHERE user_id=wg.user_id AND memory_id=wg.memory_id)))))`; } export class WebMemoryVisibility { @@ -23,6 +26,8 @@ export class WebMemoryVisibility { this.store=store;this.db=store.db; this.db.exec(`CREATE TABLE IF NOT EXISTS memory_web_grants (user_id TEXT NOT NULL,memory_id TEXT NOT NULL, revision INTEGER NOT NULL,state_hash TEXT NOT NULL,sensitivity TEXT NOT NULL,PRIMARY KEY(user_id,memory_id)); + CREATE TABLE IF NOT EXISTS memory_web_policy (user_id TEXT PRIMARY KEY,read_all INTEGER NOT NULL CHECK(read_all IN (0,1)), + revision INTEGER NOT NULL,updated_at TEXT NOT NULL); CREATE TRIGGER IF NOT EXISTS memory_web_revoke_privacy_insert AFTER INSERT ON memory_privacy BEGIN DELETE FROM memory_web_grants WHERE user_id=NEW.user_id AND memory_id=NEW.memory_id; END; CREATE TRIGGER IF NOT EXISTS memory_web_revoke_privacy_update AFTER UPDATE ON memory_privacy BEGIN @@ -32,6 +37,25 @@ export class WebMemoryVisibility { CREATE TRIGGER IF NOT EXISTS memory_web_revoke_change AFTER UPDATE ON memories BEGIN DELETE FROM memory_web_grants WHERE user_id=NEW.user_id AND memory_id=NEW.memory_id; END;`); } + /** Account-level ChatGPT read scope. Off: explicit per-revision grants only. */ + policy(auth) { + const row=this.db.prepare('SELECT read_all,revision FROM memory_web_policy WHERE user_id=?').get(auth.user_id); + return {read_all:row?.read_all===1,revision:row?.revision||0,policy:WEB_READ_POLICY}; + } + setPolicy(auth,{read_all,expected_revision}={}) { + if(!consoleMemoryWritable(auth))this.store.requireScope(auth,'admin:tasks'); + if(typeof read_all!=='boolean'||!Number.isSafeInteger(expected_revision)||expected_revision<0) + throw new ValidationError('An explicit read_all choice and the reviewed policy revision are required.'); + return this.store.memoryTransaction(()=>{ + const current=this.policy(auth); + if(current.revision!==expected_revision)throw new ConflictError('The ChatGPT read policy changed; review it again.','SETTINGS_VERSION_CHANGED'); + const revision=current.revision+1; + this.db.prepare(`INSERT INTO memory_web_policy VALUES (?,?,?,?) ON CONFLICT(user_id) DO UPDATE SET + read_all=excluded.read_all,revision=excluded.revision,updated_at=excluded.updated_at`).run(auth.user_id,read_all?1:0,revision,new Date().toISOString()); + this.store.audit({auth,action:'memory.web_policy',targetType:'user',targetId:auth.user_id,metadata:{read_all,revision}}); + return {read_all,revision,policy:WEB_READ_POLICY}; + }); + } visible(auth,id) { return !!this.db.prepare(`SELECT 1 FROM memories m WHERE m.user_id=? AND m.memory_id=? AND ${webMemorySql(auth)}`).get(auth.user_id,id); } diff --git a/server/lib/store.mjs b/server/lib/store.mjs index 196a2f4..6e80e1f 100644 --- a/server/lib/store.mjs +++ b/server/lib/store.mjs @@ -4,6 +4,7 @@ import { randomUUID, } from "node:crypto"; import { mkdirSync } from "node:fs"; +import { migrateCoreSchema } from "./store/schema.mjs"; import { storageDoctor, realDestination } from "./storage-policy.mjs"; import { memoryRuntime, privateStoragePaths } from "./memory-runtime.mjs"; import { DerivedMemory,scopeKey } from './memory-derived/store.mjs'; @@ -48,591 +49,69 @@ import { reconciliationFingerprint, } from "./reconciliation.mjs"; -const DEFAULT_USER_ID = "user-local"; -const DEFAULT_AGENT_SCOPES = [ - "capture:write", - "memory:read", - "memory:write", - "resume:read", - "resume:confirm", - "task:bootstrap:preview", - "task:bootstrap:confirm", - "task:reconcile:read", - "task:reconcile:confirm", -]; -const ADMIN_SCOPES = [ - "audit:read", - "admin:devices", - "admin:retention", - "admin:tasks", - "project:bootstrap:preview", - "project:bootstrap:confirm", - "task:bootstrap:preview", - "task:bootstrap:confirm", - "task:reconcile:read", - "task:reconcile:confirm", -]; - -function asJson(value) { - return JSON.stringify(value ?? null); -} - -function fromJson(value, fallback = null) { - if (value === null || value === undefined || value === "") return fallback; - return JSON.parse(value); -} - -function nowIso() { - return new Date().toISOString(); -} - -function rawExpired(event) { - return Boolean(event.expired_at || (event.expires_at && Date.parse(event.expires_at) <= Date.now())); -} - -function normalize(value) { - return String(value ?? "") - .toLowerCase() - .normalize("NFKC") - .replace(/[^\p{L}\p{N}]+/gu, " ") - .trim(); -} - -const CHECKPOINT_TRIGGER_TYPES = new Set(["assistant_message", "session_end"]); -const CHECKPOINT_EVENT_LIMIT = 50; -const CHECKPOINT_TEXT_LIMIT = 1_200; -const RESUME_PREVIEW_TTL_MS = 30 * 60_000; -const TASK_BOOTSTRAP_SCHEMA_VERSION = "task-bootstrap-binding-v0.1"; -const TASK_BOOTSTRAP_PREVIEW_TTL_MS = 30 * 60_000; -const PROJECT_BOOTSTRAP_SCHEMA_VERSION = "project-bootstrap-initial-task-v0.1"; -const PROJECT_CONTEXT_SCHEMA_VERSION = "project-memory-preview-v0.1"; -const TASK_BRANCHES_SCHEMA_VERSION = "task-branches-preview-v0.1"; -const READ_PREVIEW_RESPONSE_BUDGET_BYTES = 128 * 1024; -const PROJECT_CONTEXT_TASK_LIMIT = 10; -const PROJECT_CONTEXT_MEMORY_LIMIT = 10; -const PROJECT_CONTEXT_ACTIVITY_LIMIT = 20; -const RESUME_INJECTION_PHASES = new Set(["injected", "acknowledged", "failed"]); -const RESUME_DELIVERY_RECEIPT_PHASES = new Set(["delivered", "acknowledged", "failed"]); -const STRUCTURED_MEMORY_RETRIEVAL_SCHEMA_VERSION = "structured-memory-retrieval-v0.1"; -const STRUCTURED_MEMORY_LIFECYCLE_SCHEMA_VERSION = "structured-memory-lifecycle-v0.1"; -const STRUCTURED_MEMORY_RETRIEVAL_CANDIDATE_LIMIT = 500; -const STRUCTURED_MEMORY_RETRIEVAL_RESULT_LIMIT = 20; -const STRUCTURED_MEMORY_STATUSES = new Set(["active", "superseded", "retracted"]); - -function textContent(value) { - if (typeof value === "string") return value.trim(); - if (value === null || value === undefined) return ""; - if (typeof value === "object") { - for (const key of ["text", "content", "message", "summary", "output"]) { - if (typeof value[key] === "string" && value[key].trim()) return value[key].trim(); - } - } - return ""; -} - -function compactText(value, limit = CHECKPOINT_TEXT_LIMIT) { - const text = textContent(value).replace(/\s+/gu, " ").trim(); - if (text.length <= limit) return text; - return `${text.slice(0, limit - 1).trimEnd()}…`; -} - -function boundedStrings(values, { limit = 4, textLimit = 240 } = {}) { - if (!Array.isArray(values)) return []; - return values.slice(0, limit).map((value) => { - if (typeof value === "string") return compactText(value, textLimit); - return JSON.parse(JSON.stringify(value, (_key, nested) => - typeof nested === "string" ? compactText(nested, textLimit) : nested)); - }); -} - -function compactCheckpointPreview(checkpoint) { - if (!checkpoint) return null; - const item = (value) => { - if (!value) return value; - if (typeof value === "string") return compactText(value, 300); - return { - ...value, - ...(typeof value.text === "string" ? { text: compactText(value.text, 300) } : {}), - }; - }; - return { - checkpoint_id: checkpoint.checkpoint_id, - task_id: checkpoint.task_id, - project_id: checkpoint.project_id, - workstream_id: checkpoint.workstream_id, - session_id: checkpoint.session_id, - version: checkpoint.version, - status: checkpoint.status, - goal: compactText(checkpoint.goal, 400), - active_request: item(checkpoint.active_request), - latest_outcome: item(checkpoint.latest_outcome), - completed_items: boundedStrings(checkpoint.completed_items, { limit: 3, textLimit: 240 }), - decisions: boundedStrings(checkpoint.decisions, { limit: 3, textLimit: 240 }), - blockers: boundedStrings(checkpoint.blockers, { limit: 3, textLimit: 240 }), - unfinished_items: boundedStrings(checkpoint.unfinished_items, { limit: 3, textLimit: 240 }), - recommended_next_steps: boundedStrings(checkpoint.recommended_next_steps, { - limit: 3, - textLimit: 240, - }), - source_event_ids: (checkpoint.source_event_ids || []).slice(0, 20), - provenance: checkpoint.provenance, - generation: { - method: checkpoint.generation?.method, - confidence: checkpoint.generation?.confidence, - confidence_label: checkpoint.generation?.confidence_label, - trigger_type: checkpoint.generation?.trigger_type, - warnings: boundedStrings(checkpoint.generation?.warnings, { limit: 5, textLimit: 240 }), - }, - created_at: checkpoint.created_at, - }; -} - -function compactWorkstream(workstream) { - if (typeof workstream === "string") { - return { workstream_id: compactText(workstream, 160), name: compactText(workstream, 160) }; - } - return { - workstream_id: workstream?.workstream_id, - name: compactText(workstream?.name, 200), - status: workstream?.status, - description: compactText(workstream?.description, 300), - agent_id: workstream?.agent_id, - device_id: workstream?.device_id, - agent_instance_id: workstream?.agent_instance_id, - updated_at: workstream?.updated_at, - }; -} - -function serializedBytes(value) { - return Buffer.byteLength(JSON.stringify(value)); -} - -function finalizeReadPreview(preview, projection) { - preview.projection = { - response_budget_bytes: READ_PREVIEW_RESPONSE_BUDGET_BYTES, - ...projection, - fallback_compaction_applied: false, - serialized_bytes: 0, - }; - let bytes = serializedBytes(preview); - if (bytes > READ_PREVIEW_RESPONSE_BUDGET_BYTES) { - preview.recent_activity = preview.recent_activity.slice(0, 10).map((activity) => ({ - ...activity, - content: activity.content === null ? null : compactText(activity.content, 120), - content_truncated: activity.content !== null, - })); - preview.structured_memories = preview.structured_memories.slice(0, 5).map((memory) => ({ - ...memory, - content: compactText(memory.content, 160), - content_truncated: true, - })); - if (Array.isArray(preview.tasks)) { - preview.tasks = preview.tasks.map((task) => { - const projected = { - ...task, - goal: compactText(task.goal, 240), - progress: boundedStrings(task.progress, { limit: 2, textLimit: 120 }), - decisions: boundedStrings(task.decisions, { limit: 2, textLimit: 120 }), - blockers: boundedStrings(task.blockers, { limit: 2, textLimit: 120 }), - next_steps: boundedStrings(task.next_steps, { limit: 2, textLimit: 120 }), - resources: boundedStrings(task.resources, { limit: 2, textLimit: 120 }), - workstreams: (task.workstreams || []).slice(0, 4), - conflicts: boundedStrings(task.conflicts, { limit: 2, textLimit: 160 }), - latest_checkpoints: (task.latest_checkpoints || []).slice(0, 2).map((checkpoint) => ({ - checkpoint_id: checkpoint.checkpoint_id, - workstream_id: checkpoint.workstream_id, - session_id: checkpoint.session_id, - version: checkpoint.version, - status: checkpoint.status, - latest_outcome: checkpoint.latest_outcome, - provenance: checkpoint.provenance, - created_at: checkpoint.created_at, - })), - }; - if (task.field_availability) projected.field_availability = taskFieldAvailability(task, projected); - return projected; - }); - } - if (Array.isArray(preview.branches)) { - preview.branches = preview.branches.slice(0, 12).map((branch) => ({ - ...branch, - latest_checkpoint: branch.latest_checkpoint - ? { - checkpoint_id: branch.latest_checkpoint.checkpoint_id, - workstream_id: branch.latest_checkpoint.workstream_id, - session_id: branch.latest_checkpoint.session_id, - version: branch.latest_checkpoint.version, - status: branch.latest_checkpoint.status, - latest_outcome: branch.latest_checkpoint.latest_outcome, - provenance: branch.latest_checkpoint.provenance, - created_at: branch.latest_checkpoint.created_at, - } - : null, - })); - } - preview.projection.fallback_compaction_applied = true; - bytes = serializedBytes(preview); - } - if (bytes > READ_PREVIEW_RESPONSE_BUDGET_BYTES) { - throw new Error("Read preview exceeded its response budget after bounded compaction."); - } - for (let index = 0; index < 3; index += 1) { - const measured = serializedBytes(preview); - if (preview.projection.serialized_bytes === measured) break; - preview.projection.serialized_bytes = measured; - } - return preview; -} - -function uniqueByText(items) { - const seen = new Set(); - return items.filter((item) => { - const key = normalize(typeof item === "string" ? item : item?.text); - if (!key || seen.has(key)) return false; - seen.add(key); - return true; - }); -} - -function statements(value) { - return textContent(value) - .split(/(?:\r?\n)+|(?<=[。!?!?;;])\s*/u) - .map((line) => line.replace(/^\s*(?:[-*•]|\d+[.)、])\s*/u, "").trim()) - .filter(Boolean) - .slice(0, 40); -} - -function derivedItem(text, eventId, category, confidence = "medium") { - return { - text: compactText(text, 500), - source: "derived_from_event", - source_event_id: eventId, - category, - confidence, - }; -} - -function canonicalItems(values, category) { - return (Array.isArray(values) ? values : []).map((value) => ({ - text: typeof value === "string" ? value : JSON.stringify(value), - source: "task_snapshot", - source_event_id: null, - category, - confidence: "high", - })); -} - -function classifyCheckpointStatements(events) { - const completed = []; - const decisions = []; - const blockers = []; - const nextSteps = []; - const decisionPattern = /(?:决定|确认采用|选择采用|确定使用|必须|不再|decision|decided|selected|must\b)/iu; - const completedPattern = /(?:已完成|完成了|已通过|通过验证|验证成功|已部署|部署完成|已配置|配置完成|已修复|成功完成|completed|verified|deployed|configured|fixed|passed)/iu; - const blockerPattern = /(?:阻塞|失败|无法|报错|错误|未通过|blocked|failed|cannot|error|unavailable)/iu; - const blockerNegationPattern = /(?:无阻塞|没有阻塞|未发现阻塞|0\s*blockers?|no\s+blockers?|not\s+blocked|没有失败|均通过)/iu; - const nextPattern = /(?:下一步|接下来|待完成|仍需|还需要|需要继续|TODO|next\s+steps?|remaining|remains?\s+to)/iu; - - for (const event of events) { - const content = rawExpired(event) ? null : fromJson(event.content, null); - for (const line of statements(content)) { - if (completedPattern.test(line)) completed.push(derivedItem(line, event.event_id, "completed")); - if (decisionPattern.test(line)) decisions.push(derivedItem(line, event.event_id, "decision")); - if (blockerPattern.test(line) && !blockerNegationPattern.test(line)) { - blockers.push(derivedItem(line, event.event_id, "blocker")); - } - if (nextPattern.test(line)) nextSteps.push(derivedItem(line, event.event_id, "next_step")); - } - } - return { - completed: uniqueByText(completed).slice(0, 12), - decisions: uniqueByText(decisions).slice(0, 12), - blockers: uniqueByText(blockers).slice(0, 12), - nextSteps: uniqueByText(nextSteps).slice(0, 12), - }; -} - -function checkpointConfidence({ activeRequest, latestOutcome, classified }) { - let score = 0.35; - if (activeRequest) score += 0.12; - if (latestOutcome) score += 0.18; - if (classified.completed.length || classified.decisions.length || - classified.blockers.length || classified.nextSteps.length) score += 0.1; - const bounded = Math.min(Number(score.toFixed(2)), 0.75); - return { - score: bounded, - label: bounded >= 0.7 ? "medium" : "low", - }; -} - -function memoryScopeScore(memory) { - return { - session: 1, - workstream: 0.9, - task: 0.8, - project: 0.7, - user: 0.6, - }[memory.scope] || 0.5; -} - -function memoryRecencyScore(memory, currentTime = Date.now()) { - const timestamp = Date.parse(memory.updated_at || memory.created_at); - if (!Number.isFinite(timestamp)) return 0; - const ageDays = Math.max(0, (currentTime - timestamp) / 86_400_000); - return Math.max(0, 1 - (ageDays / 365)); -} - -function hashKey(apiKey) { - return createHash("sha256").update(apiKey, "utf8").digest("hex"); -} - -function makeApiKey() { - return `mnm_${randomBytes(32).toString("base64url")}`; -} - -function assertIdentifier(value, label) { - if (typeof value !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(value)) { - throw new ValidationError(`${label} is invalid.`); - } -} - -function assertStringArray(value, label, { maxItems = 50, maxLength = 2_048 } = {}) { - if (!Array.isArray(value) || value.length > maxItems - || value.some((item) => typeof item !== "string" || !item.trim() || item.length > maxLength)) { - throw new ValidationError(`${label} must be an array of non-empty strings.`); - } -} - -function requiredBoundedString(value, label, maxLength) { - if (typeof value !== "string" || !value.trim() || value.trim().length > maxLength) { - throw new ValidationError(`${label} is required and must be at most ${maxLength} characters.`); - } - return value.trim(); -} - -function taskBootstrapIdentifier(userId, projectId, title) { - const slug = normalizeResolverText(title) - .replace(/[^a-z0-9]+/gu, "-") - .replace(/^-+|-+$/gu, "") - .slice(0, 72); - const digest = createHash("sha256") - .update(`${userId}\n${projectId}\n${normalizeResolverText(title)}`, "utf8") - .digest("hex") - .slice(0, 10); - return `task-${slug || "new"}-${digest}`; -} - -function projectBootstrapIdentifier(userId, name) { - const slug = normalizeResolverText(name) - .replace(/[^a-z0-9]+/gu, "-") - .replace(/^-+|-+$/gu, "") - .slice(0, 72); - const digest = createHash("sha256") - .update(`${userId}\n${normalizeResolverText(name)}`, "utf8") - .digest("hex") - .slice(0, 10); - return `project-${slug || "new"}-${digest}`; -} - -function taskBootstrapSimilarity(title, task) { - const proposed = normalizeResolverText(title); - const names = [task.title, ...(task.aliases || [])] - .map(normalizeResolverText) - .filter(Boolean); - if (names.includes(proposed)) return 1; - if (proposed.length >= 4 && names.some((name) => - name.includes(proposed) || proposed.includes(name))) return 0.9; - const proposedTokens = new Set(proposed.split(" ").filter(Boolean)); - let best = 0; - for (const name of names) { - const nameTokens = new Set(name.split(" ").filter(Boolean)); - const union = new Set([...proposedTokens, ...nameTokens]); - if (!union.size) continue; - const intersection = [...proposedTokens].filter((token) => nameTokens.has(token)).length; - best = Math.max(best, intersection / union.size); - } - return Number(best.toFixed(4)); -} - -function normalizedUniqueStrings(values) { - return [...new Set(values.map((value) => value.trim()))]; -} - -function sanitizeGitRemote(value) { - const remote = value.trim().replace(/[?#].*$/u, ""); - const scpLike = remote.match(/^(?:[^/@:\s]+@)?(\[[^\]]+\]|[^/:@\s]+):(.+)$/u); - if (scpLike && !/^[a-z][a-z0-9+.-]*:\/\//iu.test(remote)) { - const host = scpLike[1].toLowerCase(); - const repositoryPath = scpLike[2].replace(/^\/+|\/+$/gu, ""); - return `ssh://${host}/${repositoryPath}`; - } - try { - const parsed = new URL(remote); - parsed.username = ""; - parsed.password = ""; - parsed.search = ""; - parsed.hash = ""; - return parsed.toString().replace(/\/$/u, ""); - } catch { - return remote; - } -} - -function canonicalGitRemote(value) { - const sanitized = sanitizeGitRemote(String(value ?? "")); - try { - const parsed = new URL(sanitized); - const supportedTransport = new Set(["git:", "git+ssh:", "http:", "https:", "ssh:"]); - const hostname = parsed.hostname.toLowerCase(); - if (supportedTransport.has(parsed.protocol) && hostname) { - const defaultPort = (parsed.protocol === "http:" && parsed.port === "80") - || (parsed.protocol === "https:" && parsed.port === "443") - || (parsed.protocol === "ssh:" && parsed.port === "22"); - const authority = `${hostname}${parsed.port && !defaultPort ? `:${parsed.port}` : ""}`; - const repositoryPath = parsed.pathname - .replace(/^\/+|\/+$/gu, "") - .replace(/\.git$/iu, "") - .toLowerCase(); - return `${authority}/${repositoryPath}`.replace(/\/$/u, ""); - } - } catch { - // Fall through to an exact, metadata-free representation for non-URL remotes. - } - return sanitized - .toLowerCase() - .replace(/\.git$/iu, "") - .replace(/\/+$/u, ""); -} - -function projectBootstrapSimilarity(project, candidate) { - const candidateNames = { title: candidate.name, aliases: candidate.aliases || [] }; - const nameSimilarity = Math.max( - ...[project.name, ...(project.aliases || [])] - .map((name) => taskBootstrapSimilarity(name, candidateNames)), - ); - const exactIntersection = (left, right, normalizer) => { - const normalizedRight = new Set((right || []).map(normalizer).filter(Boolean)); - return (left || []).some((value) => normalizedRight.has(normalizer(value))); - }; - if (exactIntersection(project.git_remotes, candidate.git_remotes, canonicalGitRemote) - || exactIntersection( - project.repo_fingerprints, - candidate.repo_fingerprints, - normalizeResolverText, - )) { - return 1; - } - return nameSimilarity; -} - -function projectBootstrapCandidates(project, projects) { - return projects - .map((candidate) => ({ - project_id: candidate.project_id, - name: candidate.name, - aliases: candidate.aliases || [], - similarity: projectBootstrapSimilarity(project, candidate), - })) - .filter((candidate) => candidate.similarity >= 0.6) - .sort((left, right) => right.similarity - left.similarity - || left.project_id.localeCompare(right.project_id)); -} - -function resolverRequest(payload, { requireQuery = false, allowProjectId = false } = {}) { - const request = typeof payload === "string" ? { query: payload } : payload; - if (!request || typeof request !== "object" || Array.isArray(request)) { - throw new ValidationError("Resolver request must be an object."); - } - const query = request.query === undefined ? "" : request.query; - if (typeof query !== "string" || query.length > 4_096) { - throw new ValidationError(requireQuery - ? "query is required and must be at most 4096 characters." - : "query must be a string of at most 4096 characters."); - } - let signals = request.signals ?? {}; - if (!signals || typeof signals !== "object" || Array.isArray(signals)) { - throw new ValidationError("signals must be an object."); - } - signals = { ...signals }; - if (allowProjectId && Object.hasOwn(request, "project_id")) { - assertIdentifier(request.project_id, "project_id"); - if (signals.project_id !== undefined && signals.project_id !== request.project_id) { - throw new ValidationError("project_id conflicts with signals.project_id.", "CONFLICTING_PROJECT_ID"); - } - signals.project_id = request.project_id; - } - const allowed = new Set([ - "project_id", - "task_id", - "git_remote", - "repo_fingerprint", - "cwd", - "device_id", - "agent_id", - "agent_instance_id", - "session_id", - ]); - for (const [key, value] of Object.entries(signals)) { - if (!allowed.has(key)) throw new ValidationError(`Unsupported resolver signal: ${key}.`); - if (typeof value !== "string" || !value.trim() || value.length > 4_096) { - throw new ValidationError(`Resolver signal ${key} must be a non-empty string.`); - } - } - for (const key of ["project_id", "task_id", "device_id", "agent_id", "agent_instance_id", "session_id"]) { - if (signals[key] !== undefined) assertIdentifier(signals[key], `signals.${key}`); - } - if (requireQuery && !query.trim() && !(allowProjectId && signals.project_id)) { - throw new ValidationError(allowProjectId ? "query or project_id is required." : "query is required."); - } - if (!query.trim() && !Object.keys(signals).length) { - throw new ValidationError("query or at least one resolver signal is required."); - } - return { query: query.trim(), signals: { ...signals } }; -} - -function requestedSourceWorkstreamIds(payload) { - if (!payload || typeof payload !== "object" || Array.isArray(payload) - || payload.source_workstream_ids === undefined) { - return null; - } - assertStringArray(payload.source_workstream_ids, "source_workstream_ids", { - maxItems: 20, - maxLength: 128, - }); - if (!payload.source_workstream_ids.length) { - throw new ValidationError("source_workstream_ids must contain at least one Workstream."); - } - const workstreamIds = [...new Set( - payload.source_workstream_ids.map((value) => value.trim()), - )]; - for (const workstreamId of workstreamIds) { - assertIdentifier(workstreamId, "source_workstream_ids item"); - } - return workstreamIds; -} - -function assertIsoTimestamp(value, label) { - if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) { - throw new ValidationError(`${label} must be an ISO timestamp.`); - } -} - -function parseRetention(value, fallback = 30) { - const candidate = value ?? fallback; - if (candidate === null || ["permanent", "forever", "infinite"].includes(String(candidate).toLowerCase())) { - return null; - } - const days = Number(candidate); - if (!Number.isInteger(days) || days < 1) { - throw new ValidationError("raw_retention_days must be an integer >= 1 or 'permanent'."); - } - return days; -} - -function retentionExpiry(capturedAt, days) { - if (days === null) return null; - return new Date(Date.parse(capturedAt) + days * 86_400_000).toISOString(); -} +import { + DEFAULT_USER_ID, + DEFAULT_AGENT_SCOPES, + ADMIN_SCOPES, + asJson, + fromJson, + nowIso, + rawExpired, + normalize, + CHECKPOINT_TRIGGER_TYPES, + CHECKPOINT_EVENT_LIMIT, + CHECKPOINT_TEXT_LIMIT, + RESUME_PREVIEW_TTL_MS, + TASK_BOOTSTRAP_SCHEMA_VERSION, + TASK_BOOTSTRAP_PREVIEW_TTL_MS, + PROJECT_BOOTSTRAP_SCHEMA_VERSION, + PROJECT_CONTEXT_SCHEMA_VERSION, + TASK_BRANCHES_SCHEMA_VERSION, + READ_PREVIEW_RESPONSE_BUDGET_BYTES, + PROJECT_CONTEXT_TASK_LIMIT, + PROJECT_CONTEXT_MEMORY_LIMIT, + PROJECT_CONTEXT_ACTIVITY_LIMIT, + RESUME_INJECTION_PHASES, + RESUME_DELIVERY_RECEIPT_PHASES, + STRUCTURED_MEMORY_RETRIEVAL_SCHEMA_VERSION, + STRUCTURED_MEMORY_LIFECYCLE_SCHEMA_VERSION, + STRUCTURED_MEMORY_RETRIEVAL_CANDIDATE_LIMIT, + STRUCTURED_MEMORY_RETRIEVAL_RESULT_LIMIT, + STRUCTURED_MEMORY_STATUSES, + textContent, + compactText, + boundedStrings, + compactCheckpointPreview, + compactWorkstream, + serializedBytes, + finalizeReadPreview, + uniqueByText, + statements, + derivedItem, + canonicalItems, + classifyCheckpointStatements, + checkpointConfidence, + memoryScopeScore, + memoryRecencyScore, + hashKey, + makeApiKey, + assertIdentifier, + assertStringArray, + requiredBoundedString, + taskBootstrapIdentifier, + projectBootstrapIdentifier, + taskBootstrapSimilarity, + normalizedUniqueStrings, + sanitizeGitRemote, + canonicalGitRemote, + projectBootstrapSimilarity, + projectBootstrapCandidates, + resolverRequest, + requestedSourceWorkstreamIds, + assertIsoTimestamp, + parseRetention, + retentionExpiry, +} from "./store/helpers.mjs"; export class MnemuronStore { constructor(databasePath, options = {}) { @@ -679,419 +158,7 @@ export class MnemuronStore { } migrate() { - this.db.exec(` - CREATE TABLE IF NOT EXISTS credentials ( - credential_id TEXT PRIMARY KEY, - label TEXT NOT NULL, - user_id TEXT NOT NULL, - device_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - agent_instance_id TEXT NOT NULL, - key_hash TEXT NOT NULL UNIQUE, - scopes_json TEXT NOT NULL, - created_at TEXT NOT NULL, - last_used_at TEXT, - expires_at TEXT, - rotated_at TEXT, - revoked_at TEXT - ); - CREATE INDEX IF NOT EXISTS credentials_instance_idx - ON credentials(user_id, agent_instance_id, revoked_at); - - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value_json TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - - CREATE TABLE IF NOT EXISTS projects ( - project_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - aliases_json TEXT NOT NULL, - git_remotes_json TEXT NOT NULL, - repo_fingerprints_json TEXT NOT NULL, - path_hints_json TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS projects_user_name_idx - ON projects(user_id, name); - - CREATE TABLE IF NOT EXISTS tasks ( - task_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - project_id TEXT NOT NULL, - project_name TEXT NOT NULL, - title TEXT NOT NULL, - aliases_json TEXT NOT NULL, - goal TEXT NOT NULL, - status TEXT NOT NULL, - progress_json TEXT NOT NULL, - decisions_json TEXT NOT NULL, - blockers_json TEXT NOT NULL, - next_steps_json TEXT NOT NULL, - resources_json TEXT NOT NULL, - workstreams_json TEXT NOT NULL, - conflicts_json TEXT NOT NULL, - canonical_version INTEGER NOT NULL DEFAULT 1, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS tasks_user_activity_idx - ON tasks(user_id, updated_at DESC); - - CREATE TABLE IF NOT EXISTS events ( - event_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - credential_id TEXT NOT NULL, - device_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - agent_instance_id TEXT NOT NULL, - project_id TEXT, - task_id TEXT, - workstream_id TEXT, - session_id TEXT, - turn_id TEXT, - event_type TEXT NOT NULL, - hook_event_name TEXT, - captured_at TEXT NOT NULL, - received_at TEXT NOT NULL, - expires_at TEXT, - expired_at TEXT, - content TEXT, - raw_payload_json TEXT, - capture_capability_json TEXT, - cwd TEXT, - model TEXT, - tool_name TEXT, - tool_use_id TEXT, - FOREIGN KEY(credential_id) REFERENCES credentials(credential_id) - ); - CREATE INDEX IF NOT EXISTS events_task_activity_idx - ON events(user_id, task_id, captured_at DESC); - CREATE INDEX IF NOT EXISTS events_expiry_idx - ON events(expires_at, expired_at); - - CREATE TABLE IF NOT EXISTS memories ( - memory_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - credential_id TEXT NOT NULL, - device_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - agent_instance_id TEXT NOT NULL, - content TEXT NOT NULL, - scope TEXT NOT NULL, - project_id TEXT, - task_id TEXT, - workstream_id TEXT, - session_id TEXT, - source TEXT NOT NULL, - memory_type TEXT NOT NULL DEFAULT 'fact', - status TEXT NOT NULL DEFAULT 'active', - source_event_ids_json TEXT NOT NULL DEFAULT '[]', - source_checkpoint_id TEXT, - generation_method TEXT, - confidence REAL, - confidence_label TEXT, - warnings_json TEXT NOT NULL DEFAULT '[]', - content_fingerprint TEXT, - topic TEXT, - topic_key TEXT, - supersedes_memory_id TEXT, - superseded_by_memory_id TEXT, - lifecycle_reason TEXT, - retracted_at TEXT, - lifecycle_actor_json TEXT NOT NULL DEFAULT '{}', - created_at TEXT NOT NULL, - updated_at TEXT, - FOREIGN KEY(credential_id) REFERENCES credentials(credential_id) - ); - CREATE INDEX IF NOT EXISTS memories_task_idx - ON memories(user_id, task_id, created_at DESC); - - CREATE TABLE IF NOT EXISTS memory_create_operations ( - user_id TEXT NOT NULL, - agent_instance_id TEXT NOT NULL, - operation_type TEXT NOT NULL CHECK(operation_type = 'memory.create'), - operation_id TEXT NOT NULL, - request_hash TEXT NOT NULL, - memory_id TEXT NOT NULL, - credential_id TEXT NOT NULL, - submitted_identity_json TEXT NOT NULL, - effective_scope_json TEXT NOT NULL, - created_at TEXT NOT NULL, - PRIMARY KEY(user_id, agent_instance_id, operation_type, operation_id), - FOREIGN KEY(memory_id) REFERENCES memories(memory_id), - FOREIGN KEY(credential_id) REFERENCES credentials(credential_id) - ); - - CREATE TABLE IF NOT EXISTS checkpoints ( - checkpoint_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - task_id TEXT NOT NULL, - project_id TEXT NOT NULL, - workstream_id TEXT NOT NULL, - session_id TEXT NOT NULL, - version INTEGER NOT NULL, - status TEXT NOT NULL, - trigger_type TEXT NOT NULL, - trigger_event_id TEXT NOT NULL, - source_fingerprint TEXT NOT NULL, - content_json TEXT NOT NULL, - source_event_ids_json TEXT NOT NULL, - device_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - agent_instance_id TEXT NOT NULL, - generation_method TEXT NOT NULL, - confidence REAL NOT NULL, - confidence_label TEXT NOT NULL, - warnings_json TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY(trigger_event_id) REFERENCES events(event_id), - UNIQUE(user_id, task_id, workstream_id, version), - UNIQUE(user_id, source_fingerprint) - ); - CREATE INDEX IF NOT EXISTS checkpoints_task_version_idx - ON checkpoints(user_id, task_id, workstream_id, version DESC); - CREATE INDEX IF NOT EXISTS checkpoints_session_idx - ON checkpoints(user_id, session_id, created_at DESC); - - CREATE TABLE IF NOT EXISTS task_reconciliation_proposals ( - proposal_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - task_id TEXT NOT NULL, - project_id TEXT NOT NULL, - proposal_version INTEGER NOT NULL, - base_canonical_version INTEGER NOT NULL, - requested_by_credential_id TEXT, - source_checkpoint_ids_json TEXT NOT NULL, - source_event_ids_json TEXT NOT NULL, - source_workstreams_json TEXT NOT NULL, - operations_json TEXT NOT NULL, - conflicts_json TEXT NOT NULL, - policy_json TEXT NOT NULL, - source_fingerprint TEXT NOT NULL, - status TEXT NOT NULL, - created_at TEXT NOT NULL, - resolved_at TEXT, - resolved_by_credential_id TEXT, - FOREIGN KEY(requested_by_credential_id) REFERENCES credentials(credential_id), - FOREIGN KEY(resolved_by_credential_id) REFERENCES credentials(credential_id), - UNIQUE(user_id, source_fingerprint) - ); - CREATE INDEX IF NOT EXISTS task_reconciliation_task_idx - ON task_reconciliation_proposals(user_id, task_id, created_at DESC); - CREATE INDEX IF NOT EXISTS task_reconciliation_status_idx - ON task_reconciliation_proposals(user_id, status, created_at DESC); - - CREATE TABLE IF NOT EXISTS task_canonical_revisions ( - revision_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - task_id TEXT NOT NULL, - project_id TEXT NOT NULL, - canonical_version_before INTEGER NOT NULL, - canonical_version_after INTEGER NOT NULL, - proposal_id TEXT, - operations_json TEXT NOT NULL, - before_hash TEXT, - after_hash TEXT NOT NULL, - source_checkpoint_ids_json TEXT NOT NULL, - source_event_ids_json TEXT NOT NULL, - decision TEXT NOT NULL, - credential_id TEXT, - created_at TEXT NOT NULL, - FOREIGN KEY(proposal_id) REFERENCES task_reconciliation_proposals(proposal_id), - FOREIGN KEY(credential_id) REFERENCES credentials(credential_id), - UNIQUE(user_id, task_id, canonical_version_after) - ); - CREATE INDEX IF NOT EXISTS task_canonical_revision_task_idx - ON task_canonical_revisions(user_id, task_id, canonical_version_after DESC); - - CREATE TABLE IF NOT EXISTS task_bootstrap_previews ( - bootstrap_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - requested_by_credential_id TEXT NOT NULL, - bootstrap_kind TEXT NOT NULL DEFAULT 'task', - project_id TEXT NOT NULL, - proposed_task_id TEXT NOT NULL, - preview_version INTEGER NOT NULL, - status TEXT NOT NULL, - preview_json TEXT NOT NULL, - binding_packet_json TEXT, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - confirmed_at TEXT, - cancelled_at TEXT, - FOREIGN KEY(requested_by_credential_id) REFERENCES credentials(credential_id), - UNIQUE(user_id, bootstrap_id, preview_version) - ); - CREATE INDEX IF NOT EXISTS task_bootstrap_user_created_idx - ON task_bootstrap_previews(user_id, created_at DESC); - CREATE INDEX IF NOT EXISTS task_bootstrap_status_idx - ON task_bootstrap_previews(user_id, status, created_at DESC); - - CREATE TABLE IF NOT EXISTS resumes ( - resume_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - requested_by_credential_id TEXT NOT NULL, - task_id TEXT NOT NULL, - preview_version INTEGER NOT NULL, - status TEXT NOT NULL, - preview_json TEXT NOT NULL, - packet_json TEXT, - created_at TEXT NOT NULL, - expires_at TEXT NOT NULL, - confirmed_at TEXT, - cancelled_at TEXT, - FOREIGN KEY(requested_by_credential_id) REFERENCES credentials(credential_id) - ); - CREATE INDEX IF NOT EXISTS resumes_user_created_idx - ON resumes(user_id, created_at DESC); - - CREATE TABLE IF NOT EXISTS resolver_selections ( - selection_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - credential_id TEXT NOT NULL, - resume_id TEXT NOT NULL, - preview_version INTEGER NOT NULL, - query TEXT NOT NULL, - query_fingerprint TEXT NOT NULL, - project_id TEXT NOT NULL, - task_id TEXT NOT NULL, - signals_json TEXT NOT NULL, - candidate_snapshot_json TEXT NOT NULL, - created_at TEXT NOT NULL, - FOREIGN KEY(credential_id) REFERENCES credentials(credential_id), - FOREIGN KEY(resume_id) REFERENCES resumes(resume_id), - UNIQUE(user_id, resume_id, preview_version) - ); - CREATE INDEX IF NOT EXISTS resolver_selection_query_idx - ON resolver_selections(user_id, query_fingerprint, created_at DESC); - CREATE INDEX IF NOT EXISTS resolver_selection_task_idx - ON resolver_selections(user_id, task_id, created_at DESC); - - CREATE TABLE IF NOT EXISTS resume_injection_events ( - event_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - resume_id TEXT NOT NULL, - preview_version INTEGER NOT NULL, - attempt_id TEXT NOT NULL, - phase TEXT NOT NULL, - credential_id TEXT NOT NULL, - device_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - agent_instance_id TEXT NOT NULL, - session_id TEXT NOT NULL, - turn_id TEXT NOT NULL, - workstream_id TEXT NOT NULL, - injection_method TEXT NOT NULL, - occurred_at TEXT NOT NULL, - received_at TEXT NOT NULL, - error_code TEXT, - error_message TEXT, - FOREIGN KEY(resume_id) REFERENCES resumes(resume_id), - FOREIGN KEY(credential_id) REFERENCES credentials(credential_id), - UNIQUE(user_id, resume_id, attempt_id, phase) - ); - CREATE INDEX IF NOT EXISTS resume_injection_resume_idx - ON resume_injection_events(user_id, resume_id, occurred_at DESC); - CREATE INDEX IF NOT EXISTS resume_injection_attempt_idx - ON resume_injection_events(user_id, resume_id, attempt_id, occurred_at ASC); - - CREATE TABLE IF NOT EXISTS resume_delivery_receipts ( - receipt_event_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - resume_id TEXT NOT NULL, - preview_version INTEGER NOT NULL, - receipt_id TEXT NOT NULL, - phase TEXT NOT NULL, - credential_id TEXT NOT NULL, - device_id TEXT NOT NULL, - agent_id TEXT NOT NULL, - agent_instance_id TEXT NOT NULL, - session_id TEXT NOT NULL, - turn_id TEXT, - workstream_id TEXT NOT NULL, - delivery_method TEXT NOT NULL, - occurred_at TEXT NOT NULL, - received_at TEXT NOT NULL, - error_code TEXT, - error_message TEXT, - FOREIGN KEY(resume_id) REFERENCES resumes(resume_id), - FOREIGN KEY(credential_id) REFERENCES credentials(credential_id), - UNIQUE(user_id, resume_id, receipt_id, phase) - ); - CREATE INDEX IF NOT EXISTS resume_delivery_receipt_resume_idx - ON resume_delivery_receipts(user_id, resume_id, occurred_at DESC); - CREATE INDEX IF NOT EXISTS resume_delivery_receipt_attempt_idx - ON resume_delivery_receipts(user_id, resume_id, receipt_id, occurred_at ASC); - - CREATE TABLE IF NOT EXISTS audit_events ( - audit_id TEXT PRIMARY KEY, - user_id TEXT, - credential_id TEXT, - action TEXT NOT NULL, - target_type TEXT, - target_id TEXT, - outcome TEXT NOT NULL, - metadata_json TEXT, - created_at TEXT NOT NULL - ); - CREATE INDEX IF NOT EXISTS audit_created_idx - ON audit_events(created_at DESC); - `); - const taskColumns = this.db.prepare("PRAGMA table_info(tasks)").all(); - if (!taskColumns.some((column) => column.name === "canonical_version")) { - this.db.exec("ALTER TABLE tasks ADD COLUMN canonical_version INTEGER NOT NULL DEFAULT 1"); - } - const memoryColumns = new Set( - this.db.prepare("PRAGMA table_info(memories)").all().map((column) => column.name), - ); - const memoryMigrations = [ - ["memory_type", "ALTER TABLE memories ADD COLUMN memory_type TEXT NOT NULL DEFAULT 'fact'"], - ["status", "ALTER TABLE memories ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"], - ["source_event_ids_json", "ALTER TABLE memories ADD COLUMN source_event_ids_json TEXT NOT NULL DEFAULT '[]'"], - ["source_checkpoint_id", "ALTER TABLE memories ADD COLUMN source_checkpoint_id TEXT"], - ["generation_method", "ALTER TABLE memories ADD COLUMN generation_method TEXT"], - ["confidence", "ALTER TABLE memories ADD COLUMN confidence REAL"], - ["confidence_label", "ALTER TABLE memories ADD COLUMN confidence_label TEXT"], - ["warnings_json", "ALTER TABLE memories ADD COLUMN warnings_json TEXT NOT NULL DEFAULT '[]'"], - ["content_fingerprint", "ALTER TABLE memories ADD COLUMN content_fingerprint TEXT"], - ["topic", "ALTER TABLE memories ADD COLUMN topic TEXT"], - ["topic_key", "ALTER TABLE memories ADD COLUMN topic_key TEXT"], - ["supersedes_memory_id", "ALTER TABLE memories ADD COLUMN supersedes_memory_id TEXT"], - ["superseded_by_memory_id", "ALTER TABLE memories ADD COLUMN superseded_by_memory_id TEXT"], - ["lifecycle_reason", "ALTER TABLE memories ADD COLUMN lifecycle_reason TEXT"], - ["retracted_at", "ALTER TABLE memories ADD COLUMN retracted_at TEXT"], - ["lifecycle_actor_json", "ALTER TABLE memories ADD COLUMN lifecycle_actor_json TEXT NOT NULL DEFAULT '{}'"], - ["updated_at", "ALTER TABLE memories ADD COLUMN updated_at TEXT"], - ]; - for (const [column, sql] of memoryMigrations) { - if (!memoryColumns.has(column)) this.db.exec(sql); - } - const taskBootstrapColumns = new Set( - this.db.prepare("PRAGMA table_info(task_bootstrap_previews)") - .all().map((column) => column.name), - ); - if (!taskBootstrapColumns.has("bootstrap_kind")) { - this.db.exec( - "ALTER TABLE task_bootstrap_previews ADD COLUMN bootstrap_kind TEXT NOT NULL DEFAULT 'task'", - ); - } - this.db.exec(` - CREATE UNIQUE INDEX IF NOT EXISTS memories_content_fingerprint_idx - ON memories(user_id, content_fingerprint) - WHERE content_fingerprint IS NOT NULL; - CREATE INDEX IF NOT EXISTS memories_status_idx - ON memories(user_id, status, created_at DESC); - CREATE INDEX IF NOT EXISTS memories_topic_idx - ON memories(user_id, task_id, topic_key, status, updated_at DESC); - CREATE INDEX IF NOT EXISTS memories_lineage_idx - ON memories(user_id, superseded_by_memory_id, supersedes_memory_id); - CREATE INDEX IF NOT EXISTS task_bootstrap_kind_status_idx - ON task_bootstrap_previews(user_id, bootstrap_kind, status, created_at DESC); - `); + this.schema = migrateCoreSchema(this.db); const timestamp = nowIso(); this.db.prepare(` INSERT OR IGNORE INTO projects ( diff --git a/server/lib/store/helpers.mjs b/server/lib/store/helpers.mjs new file mode 100644 index 0000000..b962516 --- /dev/null +++ b/server/lib/store/helpers.mjs @@ -0,0 +1,592 @@ +// Pure helpers shared by MnemuronStore: identifiers, JSON, checkpoint text, +// bootstrap similarity and resolver request validation. No database access. +import { createHash, randomBytes } from "node:crypto"; +import { ValidationError } from "../errors.mjs"; +import { normalizeResolverText } from "../resolver.mjs"; +import { taskFieldAvailability } from "../../../shared/task-read-contract.mjs"; + +export const DEFAULT_USER_ID = "user-local"; +export const DEFAULT_AGENT_SCOPES = [ + "capture:write", + "memory:read", + "memory:write", + "resume:read", + "resume:confirm", + "task:bootstrap:preview", + "task:bootstrap:confirm", + "task:reconcile:read", + "task:reconcile:confirm", +]; +export const ADMIN_SCOPES = [ + "audit:read", + "admin:devices", + "admin:retention", + "admin:tasks", + "project:bootstrap:preview", + "project:bootstrap:confirm", + "task:bootstrap:preview", + "task:bootstrap:confirm", + "task:reconcile:read", + "task:reconcile:confirm", +]; + +export function asJson(value) { + return JSON.stringify(value ?? null); +} + +export function fromJson(value, fallback = null) { + if (value === null || value === undefined || value === "") return fallback; + return JSON.parse(value); +} + +export function nowIso() { + return new Date().toISOString(); +} + +export function rawExpired(event) { + return Boolean(event.expired_at || (event.expires_at && Date.parse(event.expires_at) <= Date.now())); +} + +export function normalize(value) { + return String(value ?? "") + .toLowerCase() + .normalize("NFKC") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim(); +} + +export const CHECKPOINT_TRIGGER_TYPES = new Set(["assistant_message", "session_end"]); +export const CHECKPOINT_EVENT_LIMIT = 50; +export const CHECKPOINT_TEXT_LIMIT = 1_200; +export const RESUME_PREVIEW_TTL_MS = 30 * 60_000; +export const TASK_BOOTSTRAP_SCHEMA_VERSION = "task-bootstrap-binding-v0.1"; +export const TASK_BOOTSTRAP_PREVIEW_TTL_MS = 30 * 60_000; +export const PROJECT_BOOTSTRAP_SCHEMA_VERSION = "project-bootstrap-initial-task-v0.1"; +export const PROJECT_CONTEXT_SCHEMA_VERSION = "project-memory-preview-v0.1"; +export const TASK_BRANCHES_SCHEMA_VERSION = "task-branches-preview-v0.1"; +export const READ_PREVIEW_RESPONSE_BUDGET_BYTES = 128 * 1024; +export const PROJECT_CONTEXT_TASK_LIMIT = 10; +export const PROJECT_CONTEXT_MEMORY_LIMIT = 10; +export const PROJECT_CONTEXT_ACTIVITY_LIMIT = 20; +export const RESUME_INJECTION_PHASES = new Set(["injected", "acknowledged", "failed"]); +export const RESUME_DELIVERY_RECEIPT_PHASES = new Set(["delivered", "acknowledged", "failed"]); +export const STRUCTURED_MEMORY_RETRIEVAL_SCHEMA_VERSION = "structured-memory-retrieval-v0.1"; +export const STRUCTURED_MEMORY_LIFECYCLE_SCHEMA_VERSION = "structured-memory-lifecycle-v0.1"; +export const STRUCTURED_MEMORY_RETRIEVAL_CANDIDATE_LIMIT = 500; +export const STRUCTURED_MEMORY_RETRIEVAL_RESULT_LIMIT = 20; +export const STRUCTURED_MEMORY_STATUSES = new Set(["active", "superseded", "retracted"]); + +export function textContent(value) { + if (typeof value === "string") return value.trim(); + if (value === null || value === undefined) return ""; + if (typeof value === "object") { + for (const key of ["text", "content", "message", "summary", "output"]) { + if (typeof value[key] === "string" && value[key].trim()) return value[key].trim(); + } + } + return ""; +} + +export function compactText(value, limit = CHECKPOINT_TEXT_LIMIT) { + const text = textContent(value).replace(/\s+/gu, " ").trim(); + if (text.length <= limit) return text; + return `${text.slice(0, limit - 1).trimEnd()}…`; +} + +export function boundedStrings(values, { limit = 4, textLimit = 240 } = {}) { + if (!Array.isArray(values)) return []; + return values.slice(0, limit).map((value) => { + if (typeof value === "string") return compactText(value, textLimit); + return JSON.parse(JSON.stringify(value, (_key, nested) => + typeof nested === "string" ? compactText(nested, textLimit) : nested)); + }); +} + +export function compactCheckpointPreview(checkpoint) { + if (!checkpoint) return null; + const item = (value) => { + if (!value) return value; + if (typeof value === "string") return compactText(value, 300); + return { + ...value, + ...(typeof value.text === "string" ? { text: compactText(value.text, 300) } : {}), + }; + }; + return { + checkpoint_id: checkpoint.checkpoint_id, + task_id: checkpoint.task_id, + project_id: checkpoint.project_id, + workstream_id: checkpoint.workstream_id, + session_id: checkpoint.session_id, + version: checkpoint.version, + status: checkpoint.status, + goal: compactText(checkpoint.goal, 400), + active_request: item(checkpoint.active_request), + latest_outcome: item(checkpoint.latest_outcome), + completed_items: boundedStrings(checkpoint.completed_items, { limit: 3, textLimit: 240 }), + decisions: boundedStrings(checkpoint.decisions, { limit: 3, textLimit: 240 }), + blockers: boundedStrings(checkpoint.blockers, { limit: 3, textLimit: 240 }), + unfinished_items: boundedStrings(checkpoint.unfinished_items, { limit: 3, textLimit: 240 }), + recommended_next_steps: boundedStrings(checkpoint.recommended_next_steps, { + limit: 3, + textLimit: 240, + }), + source_event_ids: (checkpoint.source_event_ids || []).slice(0, 20), + provenance: checkpoint.provenance, + generation: { + method: checkpoint.generation?.method, + confidence: checkpoint.generation?.confidence, + confidence_label: checkpoint.generation?.confidence_label, + trigger_type: checkpoint.generation?.trigger_type, + warnings: boundedStrings(checkpoint.generation?.warnings, { limit: 5, textLimit: 240 }), + }, + created_at: checkpoint.created_at, + }; +} + +export function compactWorkstream(workstream) { + if (typeof workstream === "string") { + return { workstream_id: compactText(workstream, 160), name: compactText(workstream, 160) }; + } + return { + workstream_id: workstream?.workstream_id, + name: compactText(workstream?.name, 200), + status: workstream?.status, + description: compactText(workstream?.description, 300), + agent_id: workstream?.agent_id, + device_id: workstream?.device_id, + agent_instance_id: workstream?.agent_instance_id, + updated_at: workstream?.updated_at, + }; +} + +export function serializedBytes(value) { + return Buffer.byteLength(JSON.stringify(value)); +} + +export function finalizeReadPreview(preview, projection) { + preview.projection = { + response_budget_bytes: READ_PREVIEW_RESPONSE_BUDGET_BYTES, + ...projection, + fallback_compaction_applied: false, + serialized_bytes: 0, + }; + let bytes = serializedBytes(preview); + if (bytes > READ_PREVIEW_RESPONSE_BUDGET_BYTES) { + preview.recent_activity = preview.recent_activity.slice(0, 10).map((activity) => ({ + ...activity, + content: activity.content === null ? null : compactText(activity.content, 120), + content_truncated: activity.content !== null, + })); + preview.structured_memories = preview.structured_memories.slice(0, 5).map((memory) => ({ + ...memory, + content: compactText(memory.content, 160), + content_truncated: true, + })); + if (Array.isArray(preview.tasks)) { + preview.tasks = preview.tasks.map((task) => { + const projected = { + ...task, + goal: compactText(task.goal, 240), + progress: boundedStrings(task.progress, { limit: 2, textLimit: 120 }), + decisions: boundedStrings(task.decisions, { limit: 2, textLimit: 120 }), + blockers: boundedStrings(task.blockers, { limit: 2, textLimit: 120 }), + next_steps: boundedStrings(task.next_steps, { limit: 2, textLimit: 120 }), + resources: boundedStrings(task.resources, { limit: 2, textLimit: 120 }), + workstreams: (task.workstreams || []).slice(0, 4), + conflicts: boundedStrings(task.conflicts, { limit: 2, textLimit: 160 }), + latest_checkpoints: (task.latest_checkpoints || []).slice(0, 2).map((checkpoint) => ({ + checkpoint_id: checkpoint.checkpoint_id, + workstream_id: checkpoint.workstream_id, + session_id: checkpoint.session_id, + version: checkpoint.version, + status: checkpoint.status, + latest_outcome: checkpoint.latest_outcome, + provenance: checkpoint.provenance, + created_at: checkpoint.created_at, + })), + }; + if (task.field_availability) projected.field_availability = taskFieldAvailability(task, projected); + return projected; + }); + } + if (Array.isArray(preview.branches)) { + preview.branches = preview.branches.slice(0, 12).map((branch) => ({ + ...branch, + latest_checkpoint: branch.latest_checkpoint + ? { + checkpoint_id: branch.latest_checkpoint.checkpoint_id, + workstream_id: branch.latest_checkpoint.workstream_id, + session_id: branch.latest_checkpoint.session_id, + version: branch.latest_checkpoint.version, + status: branch.latest_checkpoint.status, + latest_outcome: branch.latest_checkpoint.latest_outcome, + provenance: branch.latest_checkpoint.provenance, + created_at: branch.latest_checkpoint.created_at, + } + : null, + })); + } + preview.projection.fallback_compaction_applied = true; + bytes = serializedBytes(preview); + } + if (bytes > READ_PREVIEW_RESPONSE_BUDGET_BYTES) { + throw new Error("Read preview exceeded its response budget after bounded compaction."); + } + for (let index = 0; index < 3; index += 1) { + const measured = serializedBytes(preview); + if (preview.projection.serialized_bytes === measured) break; + preview.projection.serialized_bytes = measured; + } + return preview; +} + +export function uniqueByText(items) { + const seen = new Set(); + return items.filter((item) => { + const key = normalize(typeof item === "string" ? item : item?.text); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export function statements(value) { + return textContent(value) + .split(/(?:\r?\n)+|(?<=[。!?!?;;])\s*/u) + .map((line) => line.replace(/^\s*(?:[-*•]|\d+[.)、])\s*/u, "").trim()) + .filter(Boolean) + .slice(0, 40); +} + +export function derivedItem(text, eventId, category, confidence = "medium") { + return { + text: compactText(text, 500), + source: "derived_from_event", + source_event_id: eventId, + category, + confidence, + }; +} + +export function canonicalItems(values, category) { + return (Array.isArray(values) ? values : []).map((value) => ({ + text: typeof value === "string" ? value : JSON.stringify(value), + source: "task_snapshot", + source_event_id: null, + category, + confidence: "high", + })); +} + +export function classifyCheckpointStatements(events) { + const completed = []; + const decisions = []; + const blockers = []; + const nextSteps = []; + const decisionPattern = /(?:决定|确认采用|选择采用|确定使用|必须|不再|decision|decided|selected|must\b)/iu; + const completedPattern = /(?:已完成|完成了|已通过|通过验证|验证成功|已部署|部署完成|已配置|配置完成|已修复|成功完成|completed|verified|deployed|configured|fixed|passed)/iu; + const blockerPattern = /(?:阻塞|失败|无法|报错|错误|未通过|blocked|failed|cannot|error|unavailable)/iu; + const blockerNegationPattern = /(?:无阻塞|没有阻塞|未发现阻塞|0\s*blockers?|no\s+blockers?|not\s+blocked|没有失败|均通过)/iu; + const nextPattern = /(?:下一步|接下来|待完成|仍需|还需要|需要继续|TODO|next\s+steps?|remaining|remains?\s+to)/iu; + + for (const event of events) { + const content = rawExpired(event) ? null : fromJson(event.content, null); + for (const line of statements(content)) { + if (completedPattern.test(line)) completed.push(derivedItem(line, event.event_id, "completed")); + if (decisionPattern.test(line)) decisions.push(derivedItem(line, event.event_id, "decision")); + if (blockerPattern.test(line) && !blockerNegationPattern.test(line)) { + blockers.push(derivedItem(line, event.event_id, "blocker")); + } + if (nextPattern.test(line)) nextSteps.push(derivedItem(line, event.event_id, "next_step")); + } + } + return { + completed: uniqueByText(completed).slice(0, 12), + decisions: uniqueByText(decisions).slice(0, 12), + blockers: uniqueByText(blockers).slice(0, 12), + nextSteps: uniqueByText(nextSteps).slice(0, 12), + }; +} + +export function checkpointConfidence({ activeRequest, latestOutcome, classified }) { + let score = 0.35; + if (activeRequest) score += 0.12; + if (latestOutcome) score += 0.18; + if (classified.completed.length || classified.decisions.length || + classified.blockers.length || classified.nextSteps.length) score += 0.1; + const bounded = Math.min(Number(score.toFixed(2)), 0.75); + return { + score: bounded, + label: bounded >= 0.7 ? "medium" : "low", + }; +} + +export function memoryScopeScore(memory) { + return { + session: 1, + workstream: 0.9, + task: 0.8, + project: 0.7, + user: 0.6, + }[memory.scope] || 0.5; +} + +export function memoryRecencyScore(memory, currentTime = Date.now()) { + const timestamp = Date.parse(memory.updated_at || memory.created_at); + if (!Number.isFinite(timestamp)) return 0; + const ageDays = Math.max(0, (currentTime - timestamp) / 86_400_000); + return Math.max(0, 1 - (ageDays / 365)); +} + +export function hashKey(apiKey) { + return createHash("sha256").update(apiKey, "utf8").digest("hex"); +} + +export function makeApiKey() { + return `mnm_${randomBytes(32).toString("base64url")}`; +} + +export function assertIdentifier(value, label) { + if (typeof value !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(value)) { + throw new ValidationError(`${label} is invalid.`); + } +} + +export function assertStringArray(value, label, { maxItems = 50, maxLength = 2_048 } = {}) { + if (!Array.isArray(value) || value.length > maxItems + || value.some((item) => typeof item !== "string" || !item.trim() || item.length > maxLength)) { + throw new ValidationError(`${label} must be an array of non-empty strings.`); + } +} + +export function requiredBoundedString(value, label, maxLength) { + if (typeof value !== "string" || !value.trim() || value.trim().length > maxLength) { + throw new ValidationError(`${label} is required and must be at most ${maxLength} characters.`); + } + return value.trim(); +} + +export function taskBootstrapIdentifier(userId, projectId, title) { + const slug = normalizeResolverText(title) + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .slice(0, 72); + const digest = createHash("sha256") + .update(`${userId}\n${projectId}\n${normalizeResolverText(title)}`, "utf8") + .digest("hex") + .slice(0, 10); + return `task-${slug || "new"}-${digest}`; +} + +export function projectBootstrapIdentifier(userId, name) { + const slug = normalizeResolverText(name) + .replace(/[^a-z0-9]+/gu, "-") + .replace(/^-+|-+$/gu, "") + .slice(0, 72); + const digest = createHash("sha256") + .update(`${userId}\n${normalizeResolverText(name)}`, "utf8") + .digest("hex") + .slice(0, 10); + return `project-${slug || "new"}-${digest}`; +} + +export function taskBootstrapSimilarity(title, task) { + const proposed = normalizeResolverText(title); + const names = [task.title, ...(task.aliases || [])] + .map(normalizeResolverText) + .filter(Boolean); + if (names.includes(proposed)) return 1; + if (proposed.length >= 4 && names.some((name) => + name.includes(proposed) || proposed.includes(name))) return 0.9; + const proposedTokens = new Set(proposed.split(" ").filter(Boolean)); + let best = 0; + for (const name of names) { + const nameTokens = new Set(name.split(" ").filter(Boolean)); + const union = new Set([...proposedTokens, ...nameTokens]); + if (!union.size) continue; + const intersection = [...proposedTokens].filter((token) => nameTokens.has(token)).length; + best = Math.max(best, intersection / union.size); + } + return Number(best.toFixed(4)); +} + +export function normalizedUniqueStrings(values) { + return [...new Set(values.map((value) => value.trim()))]; +} + +export function sanitizeGitRemote(value) { + const remote = value.trim().replace(/[?#].*$/u, ""); + const scpLike = remote.match(/^(?:[^/@:\s]+@)?(\[[^\]]+\]|[^/:@\s]+):(.+)$/u); + if (scpLike && !/^[a-z][a-z0-9+.-]*:\/\//iu.test(remote)) { + const host = scpLike[1].toLowerCase(); + const repositoryPath = scpLike[2].replace(/^\/+|\/+$/gu, ""); + return `ssh://${host}/${repositoryPath}`; + } + try { + const parsed = new URL(remote); + parsed.username = ""; + parsed.password = ""; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/$/u, ""); + } catch { + return remote; + } +} + +export function canonicalGitRemote(value) { + const sanitized = sanitizeGitRemote(String(value ?? "")); + try { + const parsed = new URL(sanitized); + const supportedTransport = new Set(["git:", "git+ssh:", "http:", "https:", "ssh:"]); + const hostname = parsed.hostname.toLowerCase(); + if (supportedTransport.has(parsed.protocol) && hostname) { + const defaultPort = (parsed.protocol === "http:" && parsed.port === "80") + || (parsed.protocol === "https:" && parsed.port === "443") + || (parsed.protocol === "ssh:" && parsed.port === "22"); + const authority = `${hostname}${parsed.port && !defaultPort ? `:${parsed.port}` : ""}`; + const repositoryPath = parsed.pathname + .replace(/^\/+|\/+$/gu, "") + .replace(/\.git$/iu, "") + .toLowerCase(); + return `${authority}/${repositoryPath}`.replace(/\/$/u, ""); + } + } catch { + // Fall through to an exact, metadata-free representation for non-URL remotes. + } + return sanitized + .toLowerCase() + .replace(/\.git$/iu, "") + .replace(/\/+$/u, ""); +} + +export function projectBootstrapSimilarity(project, candidate) { + const candidateNames = { title: candidate.name, aliases: candidate.aliases || [] }; + const nameSimilarity = Math.max( + ...[project.name, ...(project.aliases || [])] + .map((name) => taskBootstrapSimilarity(name, candidateNames)), + ); + const exactIntersection = (left, right, normalizer) => { + const normalizedRight = new Set((right || []).map(normalizer).filter(Boolean)); + return (left || []).some((value) => normalizedRight.has(normalizer(value))); + }; + if (exactIntersection(project.git_remotes, candidate.git_remotes, canonicalGitRemote) + || exactIntersection( + project.repo_fingerprints, + candidate.repo_fingerprints, + normalizeResolverText, + )) { + return 1; + } + return nameSimilarity; +} + +export function projectBootstrapCandidates(project, projects) { + return projects + .map((candidate) => ({ + project_id: candidate.project_id, + name: candidate.name, + aliases: candidate.aliases || [], + similarity: projectBootstrapSimilarity(project, candidate), + })) + .filter((candidate) => candidate.similarity >= 0.6) + .sort((left, right) => right.similarity - left.similarity + || left.project_id.localeCompare(right.project_id)); +} + +export function resolverRequest(payload, { requireQuery = false, allowProjectId = false } = {}) { + const request = typeof payload === "string" ? { query: payload } : payload; + if (!request || typeof request !== "object" || Array.isArray(request)) { + throw new ValidationError("Resolver request must be an object."); + } + const query = request.query === undefined ? "" : request.query; + if (typeof query !== "string" || query.length > 4_096) { + throw new ValidationError(requireQuery + ? "query is required and must be at most 4096 characters." + : "query must be a string of at most 4096 characters."); + } + let signals = request.signals ?? {}; + if (!signals || typeof signals !== "object" || Array.isArray(signals)) { + throw new ValidationError("signals must be an object."); + } + signals = { ...signals }; + if (allowProjectId && Object.hasOwn(request, "project_id")) { + assertIdentifier(request.project_id, "project_id"); + if (signals.project_id !== undefined && signals.project_id !== request.project_id) { + throw new ValidationError("project_id conflicts with signals.project_id.", "CONFLICTING_PROJECT_ID"); + } + signals.project_id = request.project_id; + } + const allowed = new Set([ + "project_id", + "task_id", + "git_remote", + "repo_fingerprint", + "cwd", + "device_id", + "agent_id", + "agent_instance_id", + "session_id", + ]); + for (const [key, value] of Object.entries(signals)) { + if (!allowed.has(key)) throw new ValidationError(`Unsupported resolver signal: ${key}.`); + if (typeof value !== "string" || !value.trim() || value.length > 4_096) { + throw new ValidationError(`Resolver signal ${key} must be a non-empty string.`); + } + } + for (const key of ["project_id", "task_id", "device_id", "agent_id", "agent_instance_id", "session_id"]) { + if (signals[key] !== undefined) assertIdentifier(signals[key], `signals.${key}`); + } + if (requireQuery && !query.trim() && !(allowProjectId && signals.project_id)) { + throw new ValidationError(allowProjectId ? "query or project_id is required." : "query is required."); + } + if (!query.trim() && !Object.keys(signals).length) { + throw new ValidationError("query or at least one resolver signal is required."); + } + return { query: query.trim(), signals: { ...signals } }; +} + +export function requestedSourceWorkstreamIds(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload) + || payload.source_workstream_ids === undefined) { + return null; + } + assertStringArray(payload.source_workstream_ids, "source_workstream_ids", { + maxItems: 20, + maxLength: 128, + }); + if (!payload.source_workstream_ids.length) { + throw new ValidationError("source_workstream_ids must contain at least one Workstream."); + } + const workstreamIds = [...new Set( + payload.source_workstream_ids.map((value) => value.trim()), + )]; + for (const workstreamId of workstreamIds) { + assertIdentifier(workstreamId, "source_workstream_ids item"); + } + return workstreamIds; +} + +export function assertIsoTimestamp(value, label) { + if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) { + throw new ValidationError(`${label} must be an ISO timestamp.`); + } +} + +export function parseRetention(value, fallback = 30) { + const candidate = value ?? fallback; + if (candidate === null || ["permanent", "forever", "infinite"].includes(String(candidate).toLowerCase())) { + return null; + } + const days = Number(candidate); + if (!Number.isInteger(days) || days < 1) { + throw new ValidationError("raw_retention_days must be an integer >= 1 or 'permanent'."); + } + return days; +} + +export function retentionExpiry(capturedAt, days) { + if (days === null) return null; + return new Date(Date.parse(capturedAt) + days * 86_400_000).toISOString(); +} diff --git a/server/lib/store/migrations.mjs b/server/lib/store/migrations.mjs new file mode 100644 index 0000000..1f55a7c --- /dev/null +++ b/server/lib/store/migrations.mjs @@ -0,0 +1,49 @@ +// Minimal, dependency-free migration runner for node:sqlite databases. +// PRAGMA user_version records the last applied step; each step commits atomically. +// Steps marked `repeatable` are idempotent schema guarantees (CREATE ... IF NOT EXISTS, +// additive columns). They are re-checked on every open so a damaged or partially +// restored database self-heals exactly as before versioning; one-off data changes +// must not be repeatable. +export class SchemaVersionError extends Error { + constructor(message) { super(message); this.name = "SchemaVersionError"; this.code = "SCHEMA_VERSION_UNSUPPORTED"; } +} + +export function validateMigrations(migrations) { + if (!Array.isArray(migrations) || !migrations.length) throw new TypeError("At least one migration is required."); + migrations.forEach((migration, index) => { + if (!Number.isSafeInteger(migration?.version) || migration.version !== index + 1) throw new TypeError("Migration versions must be contiguous from 1."); + if (typeof migration.name !== "string" || !migration.name || typeof migration.up !== "function") throw new TypeError(`Migration ${migration.version} is incomplete.`); + }); + return migrations.at(-1).version; +} + +export function schemaVersion(db) { + return db.prepare("PRAGMA user_version").get().user_version; +} + +export function applyMigrations(db, migrations) { + const latest = validateMigrations(migrations); + const from = schemaVersion(db); + // Refuse to run an older release against a newer schema instead of guessing. + if (from > latest) throw new SchemaVersionError(`Database schema version ${from} is newer than this release supports (${latest}).`); + const applied = []; + const ensured = migrations.slice(0, from).filter((migration) => migration.repeatable); + if (ensured.length) { + db.exec("BEGIN IMMEDIATE"); + try { for (const migration of ensured) migration.up(db); db.exec("COMMIT"); } + catch (error) { db.exec("ROLLBACK"); throw error; } + } + for (const migration of migrations.slice(from)) { + db.exec("BEGIN IMMEDIATE"); + try { + migration.up(db); + db.exec(`PRAGMA user_version = ${migration.version}`); + db.exec("COMMIT"); + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + applied.push(migration.name); + } + return { from, to: latest, applied }; +} diff --git a/server/lib/store/schema.mjs b/server/lib/store/schema.mjs new file mode 100644 index 0000000..7bbd31c --- /dev/null +++ b/server/lib/store/schema.mjs @@ -0,0 +1,421 @@ +// Core database schema as ordered, versioned migrations (PRAGMA user_version). +// Every step is idempotent so databases created before versioning adopt it safely. +import { applyMigrations } from "./migrations.mjs"; + +const CORE_TABLES = ` + CREATE TABLE IF NOT EXISTS credentials ( + credential_id TEXT PRIMARY KEY, + label TEXT NOT NULL, + user_id TEXT NOT NULL, + device_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + agent_instance_id TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + scopes_json TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT, + expires_at TEXT, + rotated_at TEXT, + revoked_at TEXT + ); + CREATE INDEX IF NOT EXISTS credentials_instance_idx + ON credentials(user_id, agent_instance_id, revoked_at); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS projects ( + project_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + aliases_json TEXT NOT NULL, + git_remotes_json TEXT NOT NULL, + repo_fingerprints_json TEXT NOT NULL, + path_hints_json TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS projects_user_name_idx + ON projects(user_id, name); + + CREATE TABLE IF NOT EXISTS tasks ( + task_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + project_id TEXT NOT NULL, + project_name TEXT NOT NULL, + title TEXT NOT NULL, + aliases_json TEXT NOT NULL, + goal TEXT NOT NULL, + status TEXT NOT NULL, + progress_json TEXT NOT NULL, + decisions_json TEXT NOT NULL, + blockers_json TEXT NOT NULL, + next_steps_json TEXT NOT NULL, + resources_json TEXT NOT NULL, + workstreams_json TEXT NOT NULL, + conflicts_json TEXT NOT NULL, + canonical_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS tasks_user_activity_idx + ON tasks(user_id, updated_at DESC); + + CREATE TABLE IF NOT EXISTS events ( + event_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + device_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + agent_instance_id TEXT NOT NULL, + project_id TEXT, + task_id TEXT, + workstream_id TEXT, + session_id TEXT, + turn_id TEXT, + event_type TEXT NOT NULL, + hook_event_name TEXT, + captured_at TEXT NOT NULL, + received_at TEXT NOT NULL, + expires_at TEXT, + expired_at TEXT, + content TEXT, + raw_payload_json TEXT, + capture_capability_json TEXT, + cwd TEXT, + model TEXT, + tool_name TEXT, + tool_use_id TEXT, + FOREIGN KEY(credential_id) REFERENCES credentials(credential_id) + ); + CREATE INDEX IF NOT EXISTS events_task_activity_idx + ON events(user_id, task_id, captured_at DESC); + CREATE INDEX IF NOT EXISTS events_expiry_idx + ON events(expires_at, expired_at); + + CREATE TABLE IF NOT EXISTS memories ( + memory_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + device_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + agent_instance_id TEXT NOT NULL, + content TEXT NOT NULL, + scope TEXT NOT NULL, + project_id TEXT, + task_id TEXT, + workstream_id TEXT, + session_id TEXT, + source TEXT NOT NULL, + memory_type TEXT NOT NULL DEFAULT 'fact', + status TEXT NOT NULL DEFAULT 'active', + source_event_ids_json TEXT NOT NULL DEFAULT '[]', + source_checkpoint_id TEXT, + generation_method TEXT, + confidence REAL, + confidence_label TEXT, + warnings_json TEXT NOT NULL DEFAULT '[]', + content_fingerprint TEXT, + topic TEXT, + topic_key TEXT, + supersedes_memory_id TEXT, + superseded_by_memory_id TEXT, + lifecycle_reason TEXT, + retracted_at TEXT, + lifecycle_actor_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT, + FOREIGN KEY(credential_id) REFERENCES credentials(credential_id) + ); + CREATE INDEX IF NOT EXISTS memories_task_idx + ON memories(user_id, task_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS memory_create_operations ( + user_id TEXT NOT NULL, + agent_instance_id TEXT NOT NULL, + operation_type TEXT NOT NULL CHECK(operation_type = 'memory.create'), + operation_id TEXT NOT NULL, + request_hash TEXT NOT NULL, + memory_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + submitted_identity_json TEXT NOT NULL, + effective_scope_json TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY(user_id, agent_instance_id, operation_type, operation_id), + FOREIGN KEY(memory_id) REFERENCES memories(memory_id), + FOREIGN KEY(credential_id) REFERENCES credentials(credential_id) + ); + + CREATE TABLE IF NOT EXISTS checkpoints ( + checkpoint_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + task_id TEXT NOT NULL, + project_id TEXT NOT NULL, + workstream_id TEXT NOT NULL, + session_id TEXT NOT NULL, + version INTEGER NOT NULL, + status TEXT NOT NULL, + trigger_type TEXT NOT NULL, + trigger_event_id TEXT NOT NULL, + source_fingerprint TEXT NOT NULL, + content_json TEXT NOT NULL, + source_event_ids_json TEXT NOT NULL, + device_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + agent_instance_id TEXT NOT NULL, + generation_method TEXT NOT NULL, + confidence REAL NOT NULL, + confidence_label TEXT NOT NULL, + warnings_json TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(trigger_event_id) REFERENCES events(event_id), + UNIQUE(user_id, task_id, workstream_id, version), + UNIQUE(user_id, source_fingerprint) + ); + CREATE INDEX IF NOT EXISTS checkpoints_task_version_idx + ON checkpoints(user_id, task_id, workstream_id, version DESC); + CREATE INDEX IF NOT EXISTS checkpoints_session_idx + ON checkpoints(user_id, session_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS task_reconciliation_proposals ( + proposal_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + task_id TEXT NOT NULL, + project_id TEXT NOT NULL, + proposal_version INTEGER NOT NULL, + base_canonical_version INTEGER NOT NULL, + requested_by_credential_id TEXT, + source_checkpoint_ids_json TEXT NOT NULL, + source_event_ids_json TEXT NOT NULL, + source_workstreams_json TEXT NOT NULL, + operations_json TEXT NOT NULL, + conflicts_json TEXT NOT NULL, + policy_json TEXT NOT NULL, + source_fingerprint TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + resolved_at TEXT, + resolved_by_credential_id TEXT, + FOREIGN KEY(requested_by_credential_id) REFERENCES credentials(credential_id), + FOREIGN KEY(resolved_by_credential_id) REFERENCES credentials(credential_id), + UNIQUE(user_id, source_fingerprint) + ); + CREATE INDEX IF NOT EXISTS task_reconciliation_task_idx + ON task_reconciliation_proposals(user_id, task_id, created_at DESC); + CREATE INDEX IF NOT EXISTS task_reconciliation_status_idx + ON task_reconciliation_proposals(user_id, status, created_at DESC); + + CREATE TABLE IF NOT EXISTS task_canonical_revisions ( + revision_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + task_id TEXT NOT NULL, + project_id TEXT NOT NULL, + canonical_version_before INTEGER NOT NULL, + canonical_version_after INTEGER NOT NULL, + proposal_id TEXT, + operations_json TEXT NOT NULL, + before_hash TEXT, + after_hash TEXT NOT NULL, + source_checkpoint_ids_json TEXT NOT NULL, + source_event_ids_json TEXT NOT NULL, + decision TEXT NOT NULL, + credential_id TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(proposal_id) REFERENCES task_reconciliation_proposals(proposal_id), + FOREIGN KEY(credential_id) REFERENCES credentials(credential_id), + UNIQUE(user_id, task_id, canonical_version_after) + ); + CREATE INDEX IF NOT EXISTS task_canonical_revision_task_idx + ON task_canonical_revisions(user_id, task_id, canonical_version_after DESC); + + CREATE TABLE IF NOT EXISTS task_bootstrap_previews ( + bootstrap_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + requested_by_credential_id TEXT NOT NULL, + bootstrap_kind TEXT NOT NULL DEFAULT 'task', + project_id TEXT NOT NULL, + proposed_task_id TEXT NOT NULL, + preview_version INTEGER NOT NULL, + status TEXT NOT NULL, + preview_json TEXT NOT NULL, + binding_packet_json TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + confirmed_at TEXT, + cancelled_at TEXT, + FOREIGN KEY(requested_by_credential_id) REFERENCES credentials(credential_id), + UNIQUE(user_id, bootstrap_id, preview_version) + ); + CREATE INDEX IF NOT EXISTS task_bootstrap_user_created_idx + ON task_bootstrap_previews(user_id, created_at DESC); + CREATE INDEX IF NOT EXISTS task_bootstrap_status_idx + ON task_bootstrap_previews(user_id, status, created_at DESC); + + CREATE TABLE IF NOT EXISTS resumes ( + resume_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + requested_by_credential_id TEXT NOT NULL, + task_id TEXT NOT NULL, + preview_version INTEGER NOT NULL, + status TEXT NOT NULL, + preview_json TEXT NOT NULL, + packet_json TEXT, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + confirmed_at TEXT, + cancelled_at TEXT, + FOREIGN KEY(requested_by_credential_id) REFERENCES credentials(credential_id) + ); + CREATE INDEX IF NOT EXISTS resumes_user_created_idx + ON resumes(user_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS resolver_selections ( + selection_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + credential_id TEXT NOT NULL, + resume_id TEXT NOT NULL, + preview_version INTEGER NOT NULL, + query TEXT NOT NULL, + query_fingerprint TEXT NOT NULL, + project_id TEXT NOT NULL, + task_id TEXT NOT NULL, + signals_json TEXT NOT NULL, + candidate_snapshot_json TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(credential_id) REFERENCES credentials(credential_id), + FOREIGN KEY(resume_id) REFERENCES resumes(resume_id), + UNIQUE(user_id, resume_id, preview_version) + ); + CREATE INDEX IF NOT EXISTS resolver_selection_query_idx + ON resolver_selections(user_id, query_fingerprint, created_at DESC); + CREATE INDEX IF NOT EXISTS resolver_selection_task_idx + ON resolver_selections(user_id, task_id, created_at DESC); + + CREATE TABLE IF NOT EXISTS resume_injection_events ( + event_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + resume_id TEXT NOT NULL, + preview_version INTEGER NOT NULL, + attempt_id TEXT NOT NULL, + phase TEXT NOT NULL, + credential_id TEXT NOT NULL, + device_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + agent_instance_id TEXT NOT NULL, + session_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + workstream_id TEXT NOT NULL, + injection_method TEXT NOT NULL, + occurred_at TEXT NOT NULL, + received_at TEXT NOT NULL, + error_code TEXT, + error_message TEXT, + FOREIGN KEY(resume_id) REFERENCES resumes(resume_id), + FOREIGN KEY(credential_id) REFERENCES credentials(credential_id), + UNIQUE(user_id, resume_id, attempt_id, phase) + ); + CREATE INDEX IF NOT EXISTS resume_injection_resume_idx + ON resume_injection_events(user_id, resume_id, occurred_at DESC); + CREATE INDEX IF NOT EXISTS resume_injection_attempt_idx + ON resume_injection_events(user_id, resume_id, attempt_id, occurred_at ASC); + + CREATE TABLE IF NOT EXISTS resume_delivery_receipts ( + receipt_event_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + resume_id TEXT NOT NULL, + preview_version INTEGER NOT NULL, + receipt_id TEXT NOT NULL, + phase TEXT NOT NULL, + credential_id TEXT NOT NULL, + device_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + agent_instance_id TEXT NOT NULL, + session_id TEXT NOT NULL, + turn_id TEXT, + workstream_id TEXT NOT NULL, + delivery_method TEXT NOT NULL, + occurred_at TEXT NOT NULL, + received_at TEXT NOT NULL, + error_code TEXT, + error_message TEXT, + FOREIGN KEY(resume_id) REFERENCES resumes(resume_id), + FOREIGN KEY(credential_id) REFERENCES credentials(credential_id), + UNIQUE(user_id, resume_id, receipt_id, phase) + ); + CREATE INDEX IF NOT EXISTS resume_delivery_receipt_resume_idx + ON resume_delivery_receipts(user_id, resume_id, occurred_at DESC); + CREATE INDEX IF NOT EXISTS resume_delivery_receipt_attempt_idx + ON resume_delivery_receipts(user_id, resume_id, receipt_id, occurred_at ASC); + + CREATE TABLE IF NOT EXISTS audit_events ( + audit_id TEXT PRIMARY KEY, + user_id TEXT, + credential_id TEXT, + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + outcome TEXT NOT NULL, + metadata_json TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS audit_created_idx + ON audit_events(created_at DESC); + `; + +const addColumns = (table, columns) => (db) => { + const existing = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name)); + for (const [column, sql] of columns) if (!existing.has(column)) db.exec(sql); +}; + +export const CORE_MIGRATIONS = Object.freeze([ + { version: 1, repeatable: true, name: "core-tables", up: (db) => db.exec(CORE_TABLES) }, + { version: 2, repeatable: true, name: "tasks-canonical-version", up: addColumns("tasks", [ + ["canonical_version", "ALTER TABLE tasks ADD COLUMN canonical_version INTEGER NOT NULL DEFAULT 1"], + ]) }, + { version: 3, repeatable: true, name: "memories-structured-lifecycle", up: addColumns("memories", [ + ["memory_type", "ALTER TABLE memories ADD COLUMN memory_type TEXT NOT NULL DEFAULT 'fact'"], + ["status", "ALTER TABLE memories ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"], + ["source_event_ids_json", "ALTER TABLE memories ADD COLUMN source_event_ids_json TEXT NOT NULL DEFAULT '[]'"], + ["source_checkpoint_id", "ALTER TABLE memories ADD COLUMN source_checkpoint_id TEXT"], + ["generation_method", "ALTER TABLE memories ADD COLUMN generation_method TEXT"], + ["confidence", "ALTER TABLE memories ADD COLUMN confidence REAL"], + ["confidence_label", "ALTER TABLE memories ADD COLUMN confidence_label TEXT"], + ["warnings_json", "ALTER TABLE memories ADD COLUMN warnings_json TEXT NOT NULL DEFAULT '[]'"], + ["content_fingerprint", "ALTER TABLE memories ADD COLUMN content_fingerprint TEXT"], + ["topic", "ALTER TABLE memories ADD COLUMN topic TEXT"], + ["topic_key", "ALTER TABLE memories ADD COLUMN topic_key TEXT"], + ["supersedes_memory_id", "ALTER TABLE memories ADD COLUMN supersedes_memory_id TEXT"], + ["superseded_by_memory_id", "ALTER TABLE memories ADD COLUMN superseded_by_memory_id TEXT"], + ["lifecycle_reason", "ALTER TABLE memories ADD COLUMN lifecycle_reason TEXT"], + ["retracted_at", "ALTER TABLE memories ADD COLUMN retracted_at TEXT"], + ["lifecycle_actor_json", "ALTER TABLE memories ADD COLUMN lifecycle_actor_json TEXT NOT NULL DEFAULT '{}'"], + ["updated_at", "ALTER TABLE memories ADD COLUMN updated_at TEXT"], + ]) }, + { version: 4, repeatable: true, name: "task-bootstrap-kind", up: addColumns("task_bootstrap_previews", [ + ["bootstrap_kind", "ALTER TABLE task_bootstrap_previews ADD COLUMN bootstrap_kind TEXT NOT NULL DEFAULT 'task'"], + ]) }, + { version: 5, repeatable: true, name: "memory-lifecycle-indexes", up: (db) => db.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS memories_content_fingerprint_idx + ON memories(user_id, content_fingerprint) + WHERE content_fingerprint IS NOT NULL; + CREATE INDEX IF NOT EXISTS memories_status_idx + ON memories(user_id, status, created_at DESC); + CREATE INDEX IF NOT EXISTS memories_topic_idx + ON memories(user_id, task_id, topic_key, status, updated_at DESC); + CREATE INDEX IF NOT EXISTS memories_lineage_idx + ON memories(user_id, superseded_by_memory_id, supersedes_memory_id); + CREATE INDEX IF NOT EXISTS task_bootstrap_kind_status_idx + ON task_bootstrap_previews(user_id, bootstrap_kind, status, created_at DESC); + `) }, + // Owner-scoped time ordering: overview recency/activity and the unfiltered library. + { version: 6, repeatable: true, name: "memories-user-created-index", up: (db) => db.exec(` + CREATE INDEX IF NOT EXISTS memories_user_created_idx + ON memories(user_id, created_at DESC, memory_id); + `) }, +]); + +export const CORE_SCHEMA_VERSION = CORE_MIGRATIONS.at(-1).version; +export const migrateCoreSchema = (db) => applyMigrations(db, CORE_MIGRATIONS); diff --git a/server/test/console-actions.test.mjs b/server/test/console-actions.test.mjs index 715f117..74c71d5 100644 --- a/server/test/console-actions.test.mjs +++ b/server/test/console-actions.test.mjs @@ -22,7 +22,7 @@ const create=(f,owner=f.a,text='Synthetic console memory')=>f.act('memory.create test('CON-BASIC-01: narrow credentials allow own memory operations but no non-memory writes or export',async t=>{ const f=await setup(t),basic=f.store.issueCredential({userId:f.a.auth.user_id,deviceId:'synthetic-basic',agentId:'mnemuron-console',agentInstanceId:'synthetic-basic',scopes:[...CONSOLE_READ_SCOPES,'memory:write','memory:organize']}); const caps=(await f.get('capabilities',{},basic)).body; - assert.deepEqual(caps.actions,['memory.create','memory.correct','memory.retract','memory.classify','memory.sensitivity','memory.visibility']); + assert.deepEqual(caps.actions,['memory.create','memory.correct','memory.retract','memory.classify','memory.sensitivity','memory.visibility','memory.web_policy','devices.revoke']); const m=(await create(f,basic)).body.memory_id;assert.ok(m); let meta=(await f.get('memory-meta',{memory_id:m},basic)).body; assert.equal((await f.act('memory.classify',{memory_id:m,revision:meta.revision,category:'technical'},basic)).status,200); @@ -196,3 +196,42 @@ test('CON-15: replay identifies current lifecycle; category counts include curre assert.equal((await f.act('memory.create',payload,f.a,op)).body.current_status,'retracted'); assert.equal((await f.get('summaries')).body.categories.length,0); }); + +test('CON-BASIC-02: the account ChatGPT read policy is a versioned, idempotent memory action',async t=>{ + const f=await setup(t),basic=f.store.issueCredential({userId:f.a.auth.user_id,deviceId:'synthetic-basic',agentId:'mnemuron-console',agentInstanceId:'synthetic-basic',scopes:[...CONSOLE_READ_SCOPES,'memory:write','memory:organize']}); + assert.deepEqual((await f.get('capabilities',{},basic)).body.web_policy,{read_all:false,revision:0,policy:'web-memory-visibility-v1'}); + const operation_id=randomUUID(),enable=await f.act('memory.web_policy',{read_all:true,expected_revision:0},basic,operation_id); + assert.equal(enable.status,200);assert.equal(enable.body.read_all,true);assert.equal(enable.body.revision,1); + const replay=await f.act('memory.web_policy',{read_all:true,expected_revision:0},basic,operation_id); + assert.equal(replay.status,200);assert.equal(replay.body.revision,1); + assert.equal((await f.act('memory.web_policy',{read_all:false,expected_revision:0},basic)).body.error_code,'SETTINGS_VERSION_CHANGED'); + assert.equal((await f.act('memory.web_policy',{read_all:false,expected_revision:1,memory_id:'x'},basic)).status,400); + assert.equal((await f.act('memory.web_policy',{read_all:false,expected_revision:1},f.read)).status,403); + assert.equal((await f.get('capabilities',{},f.b)).body.web_policy.read_all,false); + assert.deepEqual((await f.get('capabilities',{},basic)).body.web_policy,{read_all:true,revision:1,policy:'web-memory-visibility-v1'}); +}); + +test('CON-DEVICES-01: owners revoke their own agent keys from the console; managed, admin and foreign keys are refused',async t=>{ + const f=await setup(t),basic=f.store.issueCredential({userId:f.a.auth.user_id,deviceId:'synthetic-basic',agentId:'mnemuron-console',agentInstanceId:'synthetic-basic',scopes:[...CONSOLE_READ_SCOPES,'memory:write','memory:organize']}); + const agent=(user,agentId,instance,scopes=['memory:read','capture:write'])=>f.store.issueCredential({userId:user,deviceId:'synthetic-device',agentId,agentInstanceId:instance,scopes}); + const laptop=agent(f.a.auth.user_id,'openclaw','synthetic-openclaw'),gateway=agent(f.a.auth.user_id,'chatgpt-web','synthetic-gateway',['memory:read','resume:read']); + agent(f.a.auth.user_id,'mnemuron','synthetic-admin',['memory:read','admin:devices']);const foreign=agent(f.b.auth.user_id,'hermes','synthetic-foreign'); + const view=(await f.get('connections',{},basic)).body.connections; + const row=id=>view.find(c=>c.agent_instance_id===id); + assert.deepEqual([row('synthetic-openclaw').state,row('synthetic-openclaw').managed,row('synthetic-openclaw').console_revocable],['active',false,true]); + assert.deepEqual(row('synthetic-openclaw').scopes,['memory:read','capture:write']); + for(const id of ['synthetic-gateway','synthetic-admin','synthetic-basic'])assert.deepEqual([row(id).managed,row(id).console_revocable],[true,false],id); + assert.ok(!view.some(c=>'key_hash' in c||'api_key' in c)); + for(const id of ['synthetic-gateway','synthetic-admin'])assert.equal((await f.act('devices.revoke',{agent_instance_id:id},basic)).body.error_code,'MANAGED_CONNECTION',id); + assert.equal((await f.act('devices.revoke',{agent_instance_id:'synthetic-foreign'},basic)).body.error_code,'CREDENTIAL_NOT_FOUND'); + assert.equal((await f.act('devices.revoke',{agent_instance_id:'synthetic-openclaw',extra:true},basic)).status,400); + assert.equal((await f.act('devices.revoke',{agent_instance_id:'synthetic-openclaw'},f.read)).status,403); + const revoked=await f.act('devices.revoke',{agent_instance_id:'synthetic-openclaw'},basic); + assert.equal(revoked.status,200);assert.equal(revoked.body.status,'revoked');assert.equal(revoked.body.revoked_credentials,1); + assert.throws(()=>f.store.authenticate(laptop.api_key)); + assert.ok(f.store.authenticate(gateway.api_key));assert.ok(f.store.authenticate(foreign.api_key)); + assert.equal((await f.get('connections',{},basic)).body.connections.find(c=>c.agent_instance_id==='synthetic-openclaw').state,'revoked'); + assert.equal((await f.act('devices.revoke',{agent_instance_id:'synthetic-openclaw'},basic)).body.error_code,'CREDENTIAL_NOT_FOUND'); + const audit=f.store.db.prepare("SELECT target_id,metadata_json FROM audit_events WHERE action='agent_instance.revoke' AND user_id=?").all(f.a.auth.user_id); + assert.deepEqual(audit.map(a=>[a.target_id,JSON.parse(a.metadata_json)]),[['synthetic-openclaw',{revoked_credentials:1,source:'console'}]]); +}); diff --git a/server/test/console-browsing.test.mjs b/server/test/console-browsing.test.mjs index ab88daf..d2f10f0 100644 --- a/server/test/console-browsing.test.mjs +++ b/server/test/console-browsing.test.mjs @@ -75,3 +75,21 @@ test('BROWSE-07: stable jobs/summary pagination and job detail never return a fo assert.equal((await f.get('jobs',{job_id:ids[0]})).body.job.job_id,ids[0]); assert.equal((await f.get('jobs',{job_id:foreignJob})).status,404); }); +test('BROWSE-08: overview preserves all supported categories and excludes other accounts and retracted records',async t=>{ + const f=await setup(t),taxonomy={version:'synthetic-overview-v1',categories:['uncategorized',...Array.from({length:63},(_,i)=>`synthetic-${i}`)]}; + f.store.memoryConfig.memory={taxonomy}; + const organizer=f.store.issueCredential({userId:f.a.auth.user_id,deviceId:'synthetic-organizer',agentId:'test',agentInstanceId:'overview-categories',scopes:['memory:organize']}); + const auth=f.store.authenticate(organizer.api_key); + for(const category of taxonomy.categories){ + const memory=f.store.saveMemory(f.a.auth,{scope:'user',content:`Synthetic overview ${category}`}).memory; + f.store.derivedMemory.setCategory(auth,memory.memory_id,category,taxonomy); + } + const removed=f.store.saveMemory(f.a.auth,{scope:'user',content:'Synthetic retracted overview memory'}).memory; + f.store.retractMemory(f.a.auth,removed.memory_id); + f.store.saveMemory(f.other.auth,{scope:'user',content:'Synthetic foreign overview memory'}); + const r=await f.get('overview');assert.equal(r.status,200); + assert.equal(r.body.insights.categories.length,64); + assert.equal(r.body.insights.categories.reduce((sum,c)=>sum+c.count,0),64); + assert.deepEqual(r.body.insights.categories.map(c=>c.value).sort(),[...taxonomy.categories].sort()); + assert.ok(r.body.insights.categories.every(c=>c.count===1)); +}); diff --git a/server/test/schema-migrations.test.mjs b/server/test/schema-migrations.test.mjs new file mode 100644 index 0000000..1d64380 --- /dev/null +++ b/server/test/schema-migrations.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import {DatabaseSync} from 'node:sqlite'; +import {MnemuronStore} from '../lib/store.mjs'; +import {CORE_SCHEMA_VERSION,CORE_MIGRATIONS} from '../lib/store/schema.mjs'; +import {applyMigrations,schemaVersion,SchemaVersionError} from '../lib/store/migrations.mjs'; + +const tempDb=t=>{const dir=fs.mkdtempSync(path.join(os.tmpdir(),'mnemuron-schema-'));t.after(()=>fs.rmSync(dir,{recursive:true,force:true}));return path.join(dir,'core.sqlite3');}; + +test('SCHEMA-01: a new database records the latest core schema version',t=>{ + const store=new MnemuronStore(tempDb(t));t.after(()=>store.close()); + assert.equal(schemaVersion(store.db),CORE_SCHEMA_VERSION); + assert.equal(store.schema.to,CORE_SCHEMA_VERSION); + assert.ok(store.db.prepare("SELECT 1 FROM sqlite_master WHERE type='index' AND name='memories_user_created_idx'").get()); +}); +test('SCHEMA-02: an unversioned legacy database adopts versioning additively',t=>{ + const file=tempDb(t),legacy=new DatabaseSync(file); + legacy.exec(`CREATE TABLE memories (memory_id TEXT PRIMARY KEY,user_id TEXT NOT NULL,credential_id TEXT NOT NULL,device_id TEXT NOT NULL,agent_id TEXT NOT NULL,agent_instance_id TEXT NOT NULL,content TEXT NOT NULL,scope TEXT NOT NULL,project_id TEXT,task_id TEXT,workstream_id TEXT,session_id TEXT,source TEXT NOT NULL,created_at TEXT NOT NULL,expires_at TEXT); + INSERT INTO memories VALUES ('legacy-1','user-local','c','d','a','i','legacy content','user',NULL,NULL,NULL,NULL,'explicit','2026-01-01T00:00:00.000Z',NULL);`); + legacy.close(); + const store=new MnemuronStore(file);t.after(()=>store.close()); + assert.equal(store.schema.from,0);assert.equal(schemaVersion(store.db),CORE_SCHEMA_VERSION); + const row=store.db.prepare("SELECT content,memory_type,status FROM memories WHERE memory_id='legacy-1'").get(); + assert.deepEqual({...row},{content:'legacy content',memory_type:'fact',status:'active'}); +}); +test('SCHEMA-03: an older release refuses a newer schema instead of guessing',t=>{ + const file=tempDb(t),db=new DatabaseSync(file);db.exec(`PRAGMA user_version=${CORE_SCHEMA_VERSION+1}`);db.close(); + assert.throws(()=>new MnemuronStore(file),SchemaVersionError); +}); +test('SCHEMA-04: a failing one-off step rolls back and keeps the prior version',t=>{ + const db=new DatabaseSync(tempDb(t));t.after(()=>db.close()); + const steps=[...CORE_MIGRATIONS,{version:CORE_SCHEMA_VERSION+1,name:'broken',up:d=>{d.exec('CREATE TABLE half_done (x INTEGER)');throw new Error('synthetic failure');}}]; + assert.throws(()=>applyMigrations(db,steps),/synthetic failure/); + assert.equal(schemaVersion(db),CORE_SCHEMA_VERSION); + assert.equal(db.prepare("SELECT 1 FROM sqlite_master WHERE name='half_done'").get(),undefined); + assert.throws(()=>applyMigrations(db,[{version:2,name:'gap',up(){}}]),/contiguous/); +}); +test('SCHEMA-05: owner recency reads use the owner/time index',t=>{ + const store=new MnemuronStore(tempDb(t));t.after(()=>store.close()); + const plan=store.db.prepare('EXPLAIN QUERY PLAN SELECT memory_id FROM memories WHERE user_id=? ORDER BY created_at DESC,memory_id LIMIT 5').all('u').map(r=>r.detail).join(' '); + assert.match(plan,/memories_user_created_idx/);assert.doesNotMatch(plan,/TEMP B-TREE/); +}); diff --git a/server/test/web-visibility-operations.test.mjs b/server/test/web-visibility-operations.test.mjs index 6bd5f27..9b13e64 100644 --- a/server/test/web-visibility-operations.test.mjs +++ b/server/test/web-visibility-operations.test.mjs @@ -26,3 +26,29 @@ test('local grant inventory is bounded, owner isolated, content-free and does no env:{...process.env,MNEMURON_DATABASE_PATH:f.databasePath,MNEMURON_ADMIN_API_KEY:f.a.api_key},encoding:'utf8'}); assert.equal(cli.status,0,cli.stderr);assert.equal(JSON.parse(cli.stdout).grants.length,1); }); + +test('account read-all opens internal and sensitive records to ChatGPT, never secret or other accounts, audited and reversible',async t=>{ + const f=await memoryFixture(t); + const web=f.store.authenticate(f.store.issueCredential({userId:f.a.auth.user_id,deviceId:'web',agentId:'chatgpt-web',agentInstanceId:'web',scopes:['memory:read','resume:read']}).api_key); + const privacy=f.store.authenticate(f.store.issueCredential({userId:f.a.auth.user_id,deviceId:'privacy',agentId:'test',agentInstanceId:'privacy',scopes:['memory:read','memory:retention']}).api_key); + const save=(content,sensitivity)=>{const id=f.store.saveMemory(f.a.auth,{scope:'user',content}).memory.memory_id;if(sensitivity)f.store.memorySources.setSensitivity(privacy,id,sensitivity);return id;}; + const plain=save('Synthetic kestrel harbour note'),internal=save('Synthetic kestrel internal note','internal'),secret=save('Synthetic kestrel secret note','secret'); + f.store.saveMemory(f.other.auth,{scope:'user',content:'Synthetic kestrel foreign note'}); + const found=async()=>(await f.store.searchMemories(web,{query:'kestrel',mode:'lexical'})).results.map(r=>r.memory_id).sort(); + assert.deepEqual(await found(),[]); + assert.deepEqual(f.store.webVisibility.policy(f.a.auth),{read_all:false,revision:0,policy:'web-memory-visibility-v1'}); + assert.throws(()=>f.store.webVisibility.setPolicy(f.a.auth,{read_all:true,expected_revision:1}),{errorCode:'SETTINGS_VERSION_CHANGED'}); + assert.throws(()=>f.store.webVisibility.setPolicy(f.a.auth,{read_all:'yes',expected_revision:0})); + assert.throws(()=>f.store.webVisibility.setPolicy({...f.a.auth,scopes:['memory:read']},{read_all:true,expected_revision:0})); + assert.deepEqual(f.store.webVisibility.setPolicy(f.a.auth,{read_all:true,expected_revision:0}),{read_all:true,revision:1,policy:'web-memory-visibility-v1'}); + assert.deepEqual(await found(),[internal,plain].sort()); + assert.equal(f.store.webVisibility.visible(web,secret),false); + // A correction stays readable without a new grant while read-all is on. + const corrected=f.store.supersedeMemory(f.a.auth,plain,{content:'Synthetic kestrel harbour note, corrected'}).replacement_memory.memory_id; + assert.deepEqual(await found(),[corrected,internal].sort()); + assert.equal(f.store.webVisibility.policy(f.other.auth).read_all,false); + f.store.webVisibility.setPolicy(f.a.auth,{read_all:false,expected_revision:1}); + assert.deepEqual(await found(),[]); + const audits=f.store.db.prepare("SELECT metadata_json FROM audit_events WHERE action='memory.web_policy' AND user_id=? ORDER BY rowid").all(f.a.auth.user_id).map(r=>JSON.parse(r.metadata_json)); + assert.deepEqual(audits,[{read_all:true,revision:1},{read_all:false,revision:2}]); +}); diff --git a/services/oauth/src/console-core.mjs b/services/oauth/src/console-core.mjs index 139244c..934587c 100644 --- a/services/oauth/src/console-core.mjs +++ b/services/oauth/src/console-core.mjs @@ -13,7 +13,7 @@ export class ConsoleCore { const codes=['MEMORY_VERSION_CHANGED','SOURCE_MANIFEST_CHANGED','SUMMARY_VERSION_CHANGED','INVALID_CURSOR','CURSOR_EXPIRED','MEMORY_NOT_FOUND', 'IDEMPOTENCY_CONFLICT','INVALID_CONSOLE_INPUT','MODEL_VERSION_CHANGED','MODEL_URL_DENIED','NOT_CONFIGURED','CONSOLE_KEY_REQUIRED','EGRESS_DENIED','ADDRESS_DENIED', 'BUDGET_EXHAUSTED','CONNECTION_REVOKED','MANAGED_CONNECTION','JOB_NOT_RETRYABLE','JOB_TERMINAL','JOB_NOT_FOUND','STALE_INPUT','OPERATION_PENDING','OPERATION_FAILED', - 'IMPORT_CONFLICT','CONTENT_TOO_LONG','EXPORT_RECORD_TOO_LARGE','VECTOR_DISABLED','VECTOR_NOT_READY','MEMORY_DISABLED','SETTINGS_VERSION_CHANGED','SEMANTIC_UNAVAILABLE','WEB_VISIBILITY_DENIED']; + 'IMPORT_CONFLICT','CONTENT_TOO_LONG','EXPORT_RECORD_TOO_LARGE','VECTOR_DISABLED','VECTOR_NOT_READY','MEMORY_DISABLED','SETTINGS_VERSION_CHANGED','SEMANTIC_UNAVAILABLE','WEB_VISIBILITY_DENIED','CREDENTIAL_NOT_FOUND']; throw new BoundaryError([400,403,404,409,413,429,503].includes(r.status)?r.status:503,codes.includes(r.data.error_code)?r.data.error_code:'CONSOLE_REQUEST_FAILED'); } return r.data; diff --git a/services/oauth/src/console-policy.mjs b/services/oauth/src/console-policy.mjs index b2125c6..3335940 100644 --- a/services/oauth/src/console-policy.mjs +++ b/services/oauth/src/console-policy.mjs @@ -1,8 +1,8 @@ import {CONSOLE_ACTIONS} from '../../../shared/console-contract.mjs'; import {connectionActions} from './connections.mjs'; const basicActions={ - memory:['memory.create','memory.correct','memory.retract','memory.classify','memory.sensitivity','memory.visibility'], - security:['security.password','security.totp.begin','security.totp.complete','security.session.revoke','security.sessions.revoke_others'], + memory:['memory.create','memory.correct','memory.retract','memory.classify','memory.sensitivity','memory.visibility','memory.web_policy'], + security:['security.password','security.totp.begin','security.totp.complete','security.session.revoke','security.sessions.revoke_others','devices.revoke'], oauth:['oauth.revoke'], }; const identityActions=[...basicActions.security,...basicActions.oauth,'security.recovery_codes','invitations.issue','invitations.revoke','invitations.revoke_batch','accounts.enable','accounts.disable','accounts.role']; diff --git a/services/oauth/src/console.mjs b/services/oauth/src/console.mjs index afdf894..b3d7ed8 100644 --- a/services/oauth/src/console.mjs +++ b/services/oauth/src/console.mjs @@ -1,11 +1,13 @@ import QRCode from 'qrcode'; import {BoundaryError,parseForm,readBody,sendJson} from '../../../shared/oauth-common.mjs'; import {routeTitle,sendPage,label,escapeHtml,serveAsset} from '../../../web/console/render.mjs'; +import {icon} from '../../../web/console/visuals.mjs'; import {text} from '../../../web/console/catalog.mjs'; import {consoleManagement,consoleActionAllowed,consoleAllowedActions} from './console-policy.mjs'; const field=(name,key,{type='text',autocomplete='off',pattern,maxlength=1024,value=''}={})=>`${type==='password'?``:''}`; const form=(action,csrf,fields,submit='continue')=>`
${fields}
`; +const steps=current=>`
    ${['stepInvitation','stepAccount','stepTotp','stepRecovery'].map((key,i)=>`${label(key)}`).join('')}
`; const redirect=(response,to)=>{response.writeHead(303,{location:to,'cache-control':'no-store'});response.end();}; const names=(config,purpose)=>`${config.isolated?'mnm_fixture_':'__Host-mnm_'}${purpose}`; function cookie(request,config,purpose) { @@ -78,7 +80,7 @@ export async function consoleRequest(request,response,{config,accounts,store,url } if(pathname==='/register'&&request.method==='GET') { const s=ids.newSession('registration_start',{ttl:600});setCookie(response,config,'registration_start',s.token); - return show('register',label('registrationSteps','p')+label('inviteNote','p')+form('/register/reserve',s.csrf,field('code','invitation',{maxlength:43}))+``); + return show('register',steps(0)+label('inviteNote','p')+form('/register/reserve',s.csrf,field('code','invitation',{maxlength:43}))+``); } if(pathname==='/register/reserve'&&request.method==='POST') { const old=submit('registration_start',['code']);store.limit(`registration:peer:${request.socket.remoteAddress}`,30,900); @@ -87,7 +89,7 @@ export async function consoleRequest(request,response,{config,accounts,store,url } const token=cookie(request,config,'registration');const state=ids.registrationState(token); if(pathname==='/register/account'&&request.method==='GET'&&state.status==='reserved') - return show('register',label('registrationCredentials','p')+form('/register/account',ids.formCsrf(token,'registration'),field('username','username',{autocomplete:'username',maxlength:100})+field('password','password',{type:'password',autocomplete:'new-password'})+field('password_confirm','passwordConfirm',{type:'password',autocomplete:'new-password'}))); + return show('register',steps(1)+label('registrationCredentials','p')+form('/register/account',ids.formCsrf(token,'registration'),field('username','username',{autocomplete:'username',maxlength:100})+field('password','password',{type:'password',autocomplete:'new-password'})+field('password_confirm','passwordConfirm',{type:'password',autocomplete:'new-password'}))); if(pathname==='/register/account'&&request.method==='POST') { submit('registration',['username','password','password_confirm']); store.limit(`registration:account:${String(body.get('username')||'').toLowerCase()}`,10,900); @@ -96,7 +98,7 @@ export async function consoleRequest(request,response,{config,accounts,store,url } if(pathname==='/register/totp'&&request.method==='GET') { const setup=ids.enrollment(token),qr=setup.uri?await QRCode.toString(setup.uri,{type:'svg',errorCorrectionLevel:'M',margin:4}):null; - return show('totp',(qr?label('totpNote','p')+`${escapeHtml(setup.secret)}`:label('alreadyShown','p'))+ + return show('totp',steps(2)+(qr?label('totpNote','p')+`
${escapeHtml(setup.secret)}
`:label('alreadyShown','p'))+ form('/register/totp',ids.formCsrf(token,'registration'),field('otp','otp',{autocomplete:'one-time-code',pattern:'[0-9]{6}',maxlength:6}),'verify')); } if(pathname==='/register/totp'&&request.method==='POST') { @@ -106,7 +108,7 @@ export async function consoleRequest(request,response,{config,accounts,store,url if(pathname==='/register/recovery-codes'&&request.method==='GET') { const codes=state.recovery_available?ids.takeRecoveryCodes(token):null; if(!['provisioning','active'].includes(state.status))throw new BoundaryError(400,'REGISTRATION_UNAVAILABLE'); - return show('recoveryCodes',(codes?label('recoveryNote','p')+`
    ${codes.map(c=>`
  • ${escapeHtml(c)}
  • `).join('')}
`:label('alreadyShown','p'))+form('/register/ack',ids.formCsrf(token,'registration'),'','acknowledge')); + return show('recoveryCodes',steps(3)+(codes?`
${icon('warning')}${label('recoveryNote','p')}
`+`
    ${codes.map(c=>`
  • ${escapeHtml(c)}
  • `).join('')}
`:label('alreadyShown','p'))+form('/register/ack',ids.formCsrf(token,'registration'),'','acknowledge')); } if(pathname==='/register/ack'&&request.method==='POST') { submit('registration',[]);ids.acknowledgeRecovery(token);redirect(response,'/register/status');return true; @@ -118,7 +120,7 @@ export async function consoleRequest(request,response,{config,accounts,store,url if(pathname==='/login') { if(request.method==='GET') { const s=ids.newSession('login',{ttl:600});setCookie(response,config,'login',s.token); - return show('consoleLogin',label('consoleLoginNote','p')+label('authNote','p')+form('/login',s.csrf,field('username','username',{autocomplete:'username',maxlength:100})+field('password','password',{type:'password',autocomplete:'current-password'})+field('otp','otp',{autocomplete:'one-time-code',pattern:'[0-9]{6}',maxlength:6}),'signIn')+``); + return show('consoleLogin',form('/login',s.csrf,field('username','username',{autocomplete:'username',maxlength:100})+field('password','password',{type:'password',autocomplete:'current-password'})+field('otp','otp',{autocomplete:'one-time-code',pattern:'[0-9]{6}',maxlength:6}),'signIn')+``); } if(request.method==='POST') { const old=submit('login',['username','password','otp']); @@ -163,6 +165,12 @@ export async function consoleRequest(request,response,{config,accounts,store,url if(action.startsWith('connections.'))result=await ids.connections.execute(account.account_id,session,action,payload,operation_id); else if(/^(security|oauth|invitations|accounts)\./.test(action))result=await ids.console.execute(account.account_id,session,action,payload,operation_id, {lifecycle:identityMaintenance?.enabled()?(id,action)=>identityMaintenance.setState(id,action):undefined}); + else if(action==='devices.revoke'){ + // Revoking an agent key is a security change: fresh factors here, then only the target goes to Core. + if(Object.keys(payload).some(key=>!['agent_instance_id','current_password','otp'].includes(key)))throw new BoundaryError(400,'INVALID_CONSOLE_INPUT'); + await ids.console.reauthenticate(account.account_id,session,payload); + result=await coreFor(account.subject).action({action,operation_id,payload:{agent_instance_id:payload.agent_instance_id}}); + } else result=await coreFor(account.subject).action({action,operation_id,payload}); if(result.login_required){await invalidateAuthorization();setCookie(response,config,'console','',0);} else ids.session(token,'console'); @@ -187,6 +195,8 @@ export async function consoleRequest(request,response,{config,accounts,store,url let core;try{core=await coreFor(account.subject).view('connections',{});}catch{core={connections:[],unavailable:true};}ids.session(token,'console'); const legacy=grants.filter(g=>g.client_id===config.chatgpt_client.client_id); sendJson(response,200,{...result,legacy_connections:legacy.length?[{kind:'legacy_system',label:'Legacy ChatGPT OAuth',grants:legacy,read_only:true,configuration_state:'ready',health:'unknown'}]:[], + system_chatgpt:{configured:true,last_token_at:ids.console.lastTokenAt(account.subject,config.chatgpt_client.client_id), + write_enabled:!!ids.db.prepare('SELECT 1 FROM identity_cloud_bindings WHERE account_id=? AND security_version=? AND checked=1').get(account.account_id,account.security_version)}, historical_grants:grants.filter(g=>g.client_id!==config.chatgpt_client.client_id&&!ids.connections.clientRow(g.client_id)), core_connections:core.connections,system_unavailable:core.unavailable===true,physical_device_verified:false});return true; } diff --git a/services/oauth/src/identity-console.mjs b/services/oauth/src/identity-console.mjs index fe66ac6..9d38418 100644 --- a/services/oauth/src/identity-console.mjs +++ b/services/oauth/src/identity-console.mjs @@ -29,7 +29,18 @@ export class IdentityConsole { FROM identity_accounts a LEFT JOIN identity_console_roles r ON r.account_id=a.account_id ORDER BY a.created DESC LIMIT 500`).all();} invitations(actor){this.requireOperator(actor);return this.db.prepare('SELECT invitation_id,batch_id,issuer,created,expires,state FROM identity_invitations ORDER BY created DESC,rowid DESC LIMIT 1000').all().map(r=>({...r,effective_state:['issued','reserved'].includes(r.state)&&r.expires<=seconds()?'expired':r.state}));} sessions(account,current){return this.db.prepare("SELECT digest,purpose,created,expires FROM identity_sessions WHERE account_id=? AND purpose='console' AND expires>? ORDER BY created DESC").all(account,seconds()).map(r=>({session_id:r.digest,purpose:r.purpose,created:r.created,expires:r.expires,current:r.digest===current}));} - grants(subject){return this.db.prepare("SELECT id,payload,expires FROM oauth_records WHERE model='Grant' AND json_extract(payload,'$.accountId')=? AND expires>?").all(subject,seconds()).map(r=>({grant_id:r.id,client_id:JSON.parse(r.payload).clientId,expires:r.expires}));} + grants(subject){return this.db.prepare("SELECT id,payload,expires FROM oauth_records WHERE model='Grant' AND json_extract(payload,'$.accountId')=? AND expires>?").all(subject,seconds()).map(r=>{const p=JSON.parse(r.payload); + const scopes=[String(p.openid?.scope||''),...Object.values(p.resources||{}).map(String)].flatMap(s=>s.split(' ')).filter(Boolean); + return {grant_id:r.id,client_id:p.clientId,expires:r.expires,created:Number.isSafeInteger(p.iat)?p.iat:null,scopes:[...new Set(scopes)]};});} + /** Last token issued to a client for this subject: the latest sign of ChatGPT using its authorization. */ + lastTokenAt(subject,clientId){return this.db.prepare("SELECT MAX(json_extract(payload,'$.iat')) at FROM oauth_records WHERE model IN ('AccessToken','RefreshToken') AND json_extract(payload,'$.accountId')=? AND json_extract(payload,'$.clientId')=?").get(subject,clientId)?.at??null;} + /** Fresh password and authenticator code for a console action that the Core then executes. */ + async reauthenticate(account,session,p){ + const verified=await this.proof(account,p); + const current=this.db.prepare("SELECT * FROM identity_sessions WHERE digest=? AND account_id=? AND purpose='console' AND expires>?").get(session.digest,account,seconds()); + if(!current||current.security_version!==verified.security_version)fail('SESSION_REQUIRED',401); + return verified; + } requestHash(account,operation,action,p){return createHmac('sha256',this.ids.key).update(JSON.stringify(['console-intent-v1',account,operation,action,fingerprint(p)])).digest('hex');} previous(account,operation,action,p){identifier(operation);const row=this.db.prepare('SELECT * FROM identity_console_operations WHERE account_id=? AND operation_id=?').get(account,operation);if(!row)return null; if(row.action!==action||row.request_hash!==this.requestHash(account,operation,action,p))fail('IDEMPOTENCY_CONFLICT',409); diff --git a/services/oauth/test/auth-purpose.test.mjs b/services/oauth/test/auth-purpose.test.mjs index 2f47790..f229d7d 100644 --- a/services/oauth/test/auth-purpose.test.mjs +++ b/services/oauth/test/auth-purpose.test.mjs @@ -8,7 +8,8 @@ test('console and OAuth login clearly distinguish purpose without changing authe const consolePage=await f.browser.request('/login'); assert.equal(consolePage.status,200); assert.match(consolePage.text,/data-i18n="consoleLogin"/); - assert.match(consolePage.text,/data-i18n="consoleLoginNote"/); + // The console sign-in states its purpose in the title alone; the OAuth sign-in keeps its explanatory notes. + assert.doesNotMatch(consolePage.text,/data-i18n="(?:consoleLoginNote|authNote)"/); assert.match(consolePage.text,/
/); const params=new URLSearchParams({client_id:f.config.chatgpt_client.client_id, redirect_uri:f.config.chatgpt_client.redirect_uris[0],response_type:'code',scope:'openid offline_access memory:read', diff --git a/services/oauth/test/connections-ui.test.mjs b/services/oauth/test/connections-ui.test.mjs index ec5f833..266c32f 100644 --- a/services/oauth/test/connections-ui.test.mjs +++ b/services/oauth/test/connections-ui.test.mjs @@ -12,3 +12,42 @@ test('C-02: disabled policies never expose an enabled create action; no fake con const html=connectionsView({connections:[],counts:{total:0},total:0},{allowed_actions:[],connection_management:{enabled:false}},{}); assert.doesNotMatch(html,/data-connection-new/);assert.match(html,/connPolicy/);assert.doesNotMatch(html,/demo|已连接|Connected/); }); +test('C-03: ChatGPT web and agent devices are counted and listed; managed and revoked keys only in history',async()=>{ + const {connectionInventory}=await import('../../../web/console/connections.mjs'); + const key=(agent_id,instance,extra={})=>({credential_id:`cred-${instance}`,agent_id,agent_instance_id:instance,label:`Synthetic ${agent_id}`,device_id:'synthetic-device',created_at:'2026-09-01T00:00:00Z',last_used_at:'2026-09-30T02:00:00Z',scopes:['memory:read','capture:write'],state:'active',managed:false,console_revocable:true,...extra}); + const data={connections:[],counts:{active:0,readonly:0,memory_readwrite:0,pending:0},total:0, + legacy_connections:[{grants:[{grant_id:'grant-1',client_id:'system-client',created:1757490455,expires:1791000000,scopes:['openid','memory:read','project:read']},{grant_id:'grant-2',client_id:'system-client',created:1759211470,expires:1791000000,scopes:['memory:read']}]}], + system_chatgpt:{configured:true,last_token_at:1759214539}, + core_connections:[key('openclaw','inst-openclaw'),key('hermes','inst-hermes',{scopes:['memory:read']}), + key('chatgpt-web','inst-gateway',{managed:true,console_revocable:false,last_used_at:'2026-09-30T06:42:01Z',scopes:['memory:read']}), + key('mnemuron','inst-admin',{managed:true,console_revocable:false,scopes:['admin:devices']}), + key('mnemuron-loadgen','inst-old',{state:'revoked',revoked_at:'2026-09-02T07:10:00Z',console_revocable:false})]}; + const caps={allowed_actions:['oauth.revoke','devices.revoke'],web_policy:{read_all:true},connection_management:{enabled:true}}; + const inv=connectionInventory(data,caps); + assert.deepEqual(inv.counts,{active:3,readonly:2,readwrite:1,pending:0}); + assert.deepEqual(inv.devices.map(c=>c.agent_id),['openclaw','hermes']); + assert.deepEqual(inv.managed.map(c=>c.agent_id),['chatgpt-web','mnemuron']);assert.deepEqual(inv.history.map(c=>c.agent_id),['mnemuron-loadgen']); + assert.equal(inv.chatgpt.authorized,true);assert.equal(inv.chatgpt.first,1757490455000);assert.equal(inv.chatgpt.last,Date.parse('2026-09-30T06:42:01Z')); + const html=connectionsView(data,caps,{}); + const card=html.match(/
[\s\S]*?<\/section>/)[0]; + assert.match(card,/data-state="enabled"/);assert.match(card,/data-i18n="connReadAll"/);assert.match(card,/href="\/app\/privacy"/); + assert.equal((card.match(/data-console-action="oauth.revoke"/g)||[]).length,2);assert.match(card,/project:read<\/code>/);assert.doesNotMatch(card,/openid<\/code>/); + const devices=html.match(/
[\s\S]*?<\/section>/)[0]; + assert.match(devices,/data-console-action="devices.revoke" data-id="inst-openclaw"/);assert.match(devices,/data-inspect="core_connections" data-id="cred-inst-hermes"/); + assert.doesNotMatch(devices,/inst-gateway|inst-admin|inst-old/); + const system=html.match(/
[\s\S]*<\/details>/)[0]; + assert.match(system,/data-i18n="agent_chatgpt_web"/);assert.match(system,/data-i18n="connState_revoked"/);assert.doesNotMatch(system,/data-console-action/); + assert.doesNotMatch(html,/已连接|Connected/); + const readonly=connectionsView(data,{allowed_actions:[],web_policy:{read_all:false}},{}); + assert.doesNotMatch(readonly,/data-console-action/);assert.match(readonly,/data-i18n="connReadGranted"/); + const none=connectionInventory({connections:[],counts:{},core_connections:[]},{}); + assert.deepEqual([none.chatgpt.configured,none.counts.active],[false,0]);assert.doesNotMatch(connectionsView({connections:[],counts:{}},{},{}),/connection-chatgpt/); + assert.match(card,/data-i18n="connWriteOff"/); + const pending={...data,system_chatgpt:{...data.system_chatgpt,write_enabled:true}}; + assert.match(connectionsView(pending,caps,{}),/data-i18n="connWritePending"/); + const written={...pending,legacy_connections:[{grants:[...data.legacy_connections[0].grants,{grant_id:'grant-3',client_id:'system-client',created:1759216000,expires:1791000000,scopes:['memory:read','memory:write']}]}]}; + const writeInv=connectionInventory(written,caps); + assert.equal(writeInv.chatgpt.write,true);assert.deepEqual(writeInv.counts,{active:3,readonly:1,readwrite:2,pending:0}); + const writeCard=connectionsView(written,caps,{}).match(/
[\s\S]*?<\/section>/)[0]; + assert.match(writeCard,/data-i18n="connReadWrite"/);assert.match(writeCard,/data-i18n="connWriteGranted"/); +}); diff --git a/services/oauth/test/console-actions.test.mjs b/services/oauth/test/console-actions.test.mjs index 8c812c3..d5acea4 100644 --- a/services/oauth/test/console-actions.test.mjs +++ b/services/oauth/test/console-actions.test.mjs @@ -239,3 +239,27 @@ test('HTTP-MGMT-03: scoped disable/enable preserves ownership and recreates only assert.ok(auth.scopes.every(s=>!s.endsWith(':write')&&!s.endsWith(':organize'))); } }); +test('HTTP-DEVICES-01: agent keys are revoked only with fresh factors, only for the owner, never for managed keys',async t=>{ + const x=await setup(t,{basic:{memory:true,security:true,oauth:true}}); + assert.ok((await x.get('capabilities')).body.allowed_actions.includes('devices.revoke')); + const issue=(owner,agentId,instance,scopes=['memory:read','capture:write'])=>x.core.store.issueCredential({userId:owner.record.user_id,deviceId:'synthetic-device',agentId,agentInstanceId:instance,scopes}); + const laptop=issue(x.a,'openclaw','synthetic-http-openclaw'),gateway=issue(x.a,'chatgpt-web','synthetic-http-gateway',['memory:read','resume:read']),foreign=issue(x.b,'hermes','synthetic-http-foreign'); + const listed=(await x.get('connections')).body,row=id=>listed.core_connections.find(c=>c.agent_instance_id===id); + assert.equal(row('synthetic-http-openclaw').console_revocable,true);assert.equal(row('synthetic-http-gateway').managed,true); + assert.equal(row('synthetic-http-foreign'),undefined); + assert.deepEqual(listed.system_chatgpt,{configured:true,last_token_at:null,write_enabled:false}); + const target={agent_instance_id:'synthetic-http-openclaw'}; + assert.notEqual((await x.act('devices.revoke',target)).status,200); + assert.notEqual((await x.act('devices.revoke',{...target,current_password:'Synthetic password with spaces ',otp:'000000'})).status,200); + assert.equal((await x.act('devices.revoke',{...target,...await x.proof(),note:'extra'})).status,400); + assert.ok(x.core.store.authenticate(laptop.api_key),'failed attempts never reach Core'); + const revoked=await x.act('devices.revoke',{...target,...await x.proof()}); + assert.equal(revoked.status,200,JSON.stringify(revoked.body));assert.equal(revoked.body.status,'revoked'); + assert.throws(()=>x.core.store.authenticate(laptop.api_key)); + const managed=await x.act('devices.revoke',{agent_instance_id:'synthetic-http-gateway',...await x.proof(x.a,30)}); + assert.equal(managed.status,409);assert.equal(managed.body.error_code,'MANAGED_CONNECTION');assert.ok(x.core.store.authenticate(gateway.api_key)); + const cross=await x.act('devices.revoke',{agent_instance_id:'synthetic-http-foreign',...await x.proof(x.b)},x.b); + assert.equal(cross.status,200,'B revokes its own device'); + assert.equal((await x.act('devices.revoke',{agent_instance_id:'synthetic-http-gateway',...await x.proof(x.b,30)},x.b)).body.error_code,'CREDENTIAL_NOT_FOUND'); + assert.ok(x.core.store.authenticate(gateway.api_key),'another account cannot reach A keys');assert.throws(()=>x.core.store.authenticate(foreign.api_key)); +}); diff --git a/services/oauth/test/console-browsing-ui.test.mjs b/services/oauth/test/console-browsing-ui.test.mjs index f8c34e5..3cb83cd 100644 --- a/services/oauth/test/console-browsing-ui.test.mjs +++ b/services/oauth/test/console-browsing-ui.test.mjs @@ -17,7 +17,7 @@ test('BROWSE-UI-02: model, connection and security inspection is independent of }); test('BROWSE-UI-03: overview metrics and processing stages navigate to their real pages',()=>{ const html=overviewView({counts:{memories:1,sources:2,summaries:0,jobs:0}},{t:text,memoryRows:()=>''}); - assert.match(html,/]*class="card metric"[^>]*href="\/app\/memories"/); + assert.match(html,/]*class="processing-stage"[^>]*href="\/app\/summaries"/); + assert.match(html,/fs.readFileSync(new URL(`../../../${path}`,import.meta.url),'utf8'); +const features=Object.entries(featureMap).flatMap(([page,list])=>list.map(f=>({...f,page}))); +const t=key=>text(key,'zh-CN'); +// Every write the server can ever allow: all switches on, an operator, a writable Core binding. +const serverActions=new Set(consoleAllowedActions({identity:{console_operations:true,connection_management:{enabled:true}}},{writable:true,actions:[...CONSOLE_ACTIONS]},true)); +// Views the BFF answers: its own handlers plus the Core passthrough, as written in the route source. +const bff=read('services/oauth/src/console.mjs'); +const servedViews=new Set([...bff.matchAll(/'\/console-api\/([a-z-]+)'/g)].map(m=>m[1]).concat(bff.match(/\/\^\\\/console-api\\\/\(([^)]+)\)\$\//)[1].split('|'))); +const ingress=read('docs/console-ingress.example.yml'); +const ingressGroup=name=>new Set(ingress.match({app:/app\(\/\(([a-z|]+)\)\)/,'console-api':/console-api\/\(([a-z|-]+)\)/}[name])[1].split('|')); + +test('Feature map: one entry per menu page, in navigation order',()=>{ + assert.deepEqual(Object.keys(featureMap),pages); + for(const page of prototypePages)assert.ok(pages.includes(page),page); + const nav=renderPage({title:'overview',page:'overview',account:{account_id:'synthetic',username:'Synthetic'}}); + assert.deepEqual([...nav.matchAll(/m[1]),pages); +}); + +test('Feature map: IDs are well formed, sequential per page and one prefix per page',()=>{ + const prefixes=new Map(); + for(const [page,list] of Object.entries(featureMap)){ + assert.ok(list.length>0,page); + const prefix=list[0].id.slice(0,3); + assert.ok(!prefixes.has(prefix),`prefix ${prefix} reused by ${page}`);prefixes.set(prefix,page); + list.forEach((f,i)=>assert.equal(f.id,`${prefix}-${String(i+1).padStart(2,'0')}`,`${page}: IDs are never reused or reordered`)); + } +}); + +test('Feature map: every title, note and wireframe label exists in both locales',()=>{ + for(const f of features){ + const labels=[featureKey(f.id),`${featureKey(f.id)}Note`,...(f.ui?.table||[]),...(f.ui?.form||[]).map(spec=>spec.split(':')[1]),...(f.ui?.actions||[]),...(f.ui?.stats||[]),...(f.ui?.submit?[f.ui.submit]:[])]; + for(const locale of ['zh-CN','en'])for(const key of labels)assert.ok(Object.hasOwn(catalog[locale],key),`${f.id}: ${locale}.${key}`); + for(const spec of f.ui?.form||[])assert.match(spec,/^(select|number|text|check):[A-Za-z]+$/,f.id); + } + for(const locale of ['zh-CN','en'])for(const key of ['live','planned','policy','partial'].map(s=>`featureStatus_${s}`).concat(pages,pages.filter(p=>p!=='overview').map(p=>`pageNote_${p}`))) + assert.ok(Object.hasOwn(catalog[locale],key),`${locale}.${key}`); +}); + +test('Feature map: live features are backed by served, routed views and allowed actions',()=>{ + const routedViews=ingressGroup('console-api'); + for(const f of features.filter(f=>f.status==='live')){ + assert.ok((f.read||[]).length+(f.write||[]).length>0,`${f.id} names its endpoints`); + for(const view of f.read||[]){assert.ok(servedViews.has(view),`${f.id}: BFF serves ${view}`);assert.ok(routedViews.has(view),`${f.id}: ingress routes ${view}`);} + for(const action of f.write||[])assert.ok(serverActions.has(action),`${f.id}: server allows ${action}`); + } +}); + +test('Feature map: planned features name their contract and cannot be called yet',()=>{ + for(const f of features.filter(f=>f.status==='planned')){ + assert.ok((f.read||[]).length+(f.write||[]).length>0,`${f.id} names the endpoints it will need`); + for(const action of f.write||[]){ + assert.match(action,/^[a-z]+\.[a-z_.]+$/,`${f.id}: ${action} is .`); + assert.ok(!serverActions.has(action),`${f.id}: ${action} is already allowed; mark the feature live`); + } + for(const view of f.read||[])assert.match(view,/^[a-z]+(?:-[a-z]+)*$/,f.id); + for(const api of f.core||[])assert.match(api,/^(GET|POST|PUT) \/(?:v1\/|readyz)/,f.id); + } + for(const f of features.filter(f=>f.status==='policy'))assert.ok(!f.read&&!f.write,`${f.id}: a feature that is not offered declares no endpoints`); + for(const f of features)assert.ok(['live','planned','policy'].includes(f.status),f.id); +}); + +test('Placeholders render every feature and never trigger a request',()=>{ + const caps={operator:true,enabled:true,writable:true,management:{invitations:true,accounts:true,roles:true},connection_management:{enabled:true}}; + for(const page of pages){ + const html=prototypePages.includes(page)?prototypeView(t,page,{data:{projects:[],models:[],web_policy:{read_all:false,revision:0}},caps}):roadmapCard(t,page); + const shown=[...html.matchAll(/data-feature="([A-Z]{3}-\d{2})"/g)].map(m=>m[1]); + const expected=(prototypePages.includes(page)?prototypeOrder(page):featureMap[page].filter(f=>f.status!=='live')).map(f=>f.id); + assert.deepEqual(shown,expected,page); + // Live cards may carry real actions; a placeholder (planned or not offered) never does. + const placeholders=prototypePages.includes(page)?(html.match(/
/g)||[]).join(''):html; + assert.doesNotMatch(placeholders,/data-console-action|data-connection-|]*>/g)||[])assert.match(control,/\sdisabled\b/,`${page}: ${control}`); + for(const f of featureMap[page].filter(f=>f.status==='planned'))assert.ok(html.includes(`console-feature-standard.md`)&&html.includes(`${f.id}`),`${page}: ${f.id} has developer notes`); + } + assert.equal(roadmapCard(t,'jobs'),'','a page with nothing planned shows no roadmap'); + assert.deepEqual(prototypePages.map(pageState),['partial','planned','partial','partial']); +}); + +test('Live cards on prototype pages escape data and degrade on their own',()=>{ + const tasks=prototypeView(t,'tasks',{data:{projects:[{project_id:'p"1',name:''}]}}); + assert.ok(tasks.includes('<img src=x onerror=alert(1)>'));assert.ok(tasks.includes('p"1')); + assert.doesNotMatch(tasks,/f.status==='planned').length,page); + } + const system=prototypeView(t,'system',{caps:{enabled:true,management:{invitations:true}}}); + assert.match(system,/class="card feature-progress"/); + assert.equal((system.match(/data-state="enabled"/g)||[]).length,2); +}); + +test('The development standard lists every feature with its current status and every page route',()=>{ + const doc=read('docs/console-feature-standard.md'); + const rows=new Map([...doc.matchAll(/^\| ([A-Z]{3}-\d{2}) \| [^|]+ \| [^|]+ \| (live|planned|policy) \|/gm)].map(m=>[m[1],m[2]])); + assert.deepEqual(rows,new Map(features.map(f=>[f.id,f.status]))); + for(const page of pages)assert.match(doc,new RegExp(`\\| \`/app/${page}\` \\| \`${featureMap[page][0].id.slice(0,3)}\` \\|`),page); +}); + +test('The ingress example routes every menu page',()=>{ + assert.deepEqual([...ingressGroup('app')].sort(),[...pages].sort()); +}); + +test('PRV-06: the ChatGPT read scope card shows the real policy and offers only the permitted switch',()=>{ + const caps={allowed_actions:['memory.web_policy']}; + const card=(data,c=caps)=>prototypeView(t,'privacy',{data,caps:c}).match(/
[\s\S]*?<\/section>/)[0]; + const off=card({web_policy:{read_all:false,revision:0}}); + assert.match(off,/data-state="disabled"/);assert.match(off,/data-console-action="memory.web_policy" data-enabled="true"/);assert.match(off,/data-i18n="webPolicyEnable"/); + const on=card({web_policy:{read_all:true,revision:3}}); + assert.match(on,/data-state="enabled"/);assert.match(on,/data-console-action="memory.web_policy" data-enabled="false"/);assert.match(on,/data-i18n="webReadAllOn"/); + assert.doesNotMatch(card({web_policy:{read_all:false,revision:0}},{allowed_actions:[]}),/data-console-action/); + assert.match(card({}),/data-i18n="unavailable"/); + assert.equal(prototypeOrder('privacy')[1].id,'PRV-06','live features come first on prototype pages'); +}); diff --git a/services/oauth/test/console-layout-a.test.mjs b/services/oauth/test/console-layout-a.test.mjs deleted file mode 100644 index 575f450..0000000 --- a/services/oauth/test/console-layout-a.test.mjs +++ /dev/null @@ -1,92 +0,0 @@ -// Presentation regressions; these do not replace authentication or isolation suites. -import test from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import {createHash} from 'node:crypto'; -import {renderPage,sendPage,serveAsset,pages} from '../../../web/console/render.mjs'; -import {overviewView,appearanceView,icon} from '../../../web/console/visuals.mjs'; -import {text,catalog} from '../../../web/console/catalog.mjs'; - -const view = (data={}) => overviewView(data,{t:text,memoryRows:()=>'
'}); -const response = () => ({writeHead(status,headers){this.status=status;this.headers=headers;},end(body){this.body=body;}}); - -test('Layout A: overview uses source-backed counts, not demo trends or connection claims',()=>{ - const html=view({counts:{memories:42,sources:68,summaries:7,jobs:3}}); - for(const value of [42,68,7,3])assert.ok(html.includes(`${value}`)); - assert.equal((html.match(/class="card metric"/g)||[]).length,4); - assert.match(html,/class="processing-card|class="card processing-card/); - assert.match(html,/class="synthetic-row"/); - assert.doesNotMatch(html,/1,248|24\s*\/\s*24|已连接|已完成|{ - const html=view({counts:{memories:-1,sources:'',summaries:NaN,jobs:Infinity}}); - assert.equal((html.match(/—<\/strong>/g)||[]).length,7); - assert.doesNotMatch(html,/0<\/strong>/); - const zero=view({counts:{memories:0}});assert.ok(zero.includes('0')); -}); -test('Layout A: appearance contains three theme previews and independent mode/language controls',()=>{ - const html=appearanceView(text); - assert.equal((html.match(/class="theme-option"/g)||[]).length,3); - assert.equal((html.match(/class="mini-shell"/g)||[]).length,3); - assert.equal((html.match(/aria-pressed="false" disabled/g)||[]).length,7); - for(const value of ['a','b','c','light','dark','zh-CN','en'])assert.ok(html.includes(`data-pref-value="${value}"`)); - assert.doesNotMatch(html,/{ - assert.deepEqual(Object.keys(catalog.en).sort(),Object.keys(catalog['zh-CN']).sort()); - for(const locale of ['zh-CN','en']){ - const t=key=>text(key,locale),html=appearanceView(t)+overviewView({}, {t,memoryRows:()=>''}); - for(const [,key] of html.matchAll(/data-i18n="([^"]+)"/g))assert.ok(Object.hasOwn(catalog[locale],key),key); - } - assert.ok(appearanceView(()=>'').includes('<script>')); - assert.doesNotMatch(appearanceView(()=>''),/'}}); - assert.match(html,/action="\/console-api\/logout" method="post"/); - assert.match(html,/name="csrf" value="" onfocus="synthetic"/); - assert.ok(html.includes('<script>synthetic</script>')); - assert.doesNotMatch(html,/'); - assert.doesNotMatch(svg,/
${inside}

`; + const initial=escapeHtml([...(account?.username||'·')][0]); + const nav=navGroups.map(([group,items])=>``).join(''); + const loading=`
${label(title,'h1')}${label('loading','p')}
${label('loading')}
`; + return `
+ +
+
+
${icon('search')}${label('shortcutSearch')}${languageControl()} +
+
${body||loading}
+
Mnemuron
+
+

${text('detail')}

`; +} + +function authShell({title,body,authPurpose}) { + // An OAuth interaction is not an ordinary console sign-in. Do not add bypass/navigation links. + const brand=authPurpose==='oauth'?`${brandMark}`:`${brandMark}`; + const points=[['history','authPoint_source'],['eye','authPoint_readonly'],['security','authPoint_isolation']]; + // The OAuth sign-in form carries its own "not your ChatGPT password" note; the shell adds no notes. + const cards=''; + return `
+
${brand}

${text('systemLabel')}

+

${label('authHeadlineFirst')}${label('authHeadlineSecond')}

${label('authDescription','p')} +
    ${points.map(([glyph,key])=>`
  • ${icon(glyph)}${label(key)}
  • `).join('')}
${cards}
+
${label('authFooter','small')}
+
${languageControl()}

${label('authEyebrow')}

${label(title,'h1')}${body}
+
`; +} + +export function renderPage({title,body='',auth=false,authPurpose='console',account=null,csrf='',page='overview'}) { + const inside=auth?authShell({title,body,authPurpose}):consoleShell({title,body,account,csrf,page}); + return `Mnemuron · ${escapeHtml(text(title))}${auth?'':''}${inside}

`; } +// Browser modules. The public ingress allows exactly these paths (docs/console-ingress.example.yml); +// add a module only together with its ingress route, or fold it into an existing one. +const MODULES=['appearance.mjs','catalog.mjs','app.mjs','session-state.mjs','visuals.mjs','actions.mjs','connections.mjs']; +const STYLESHEETS=['styles.css','controls.css']; +const stylesheetContent=()=>Buffer.concat(STYLESHEETS.flatMap(name=>[fs.readFileSync(new URL(name,import.meta.url)),Buffer.from('\n')])); +// New markup must not reuse a prior release's cached palette; keep the allowlisted path unchanged. +const stylesheetVersion=createHash('sha256').update(stylesheetContent()).digest('hex').slice(0,16); +const TYPES={css:'text/css; charset=utf-8',mjs:'text/javascript; charset=utf-8',svg:'image/svg+xml'}; export function serveAsset(request,response,pathname) { - const file=pathname.match(/^\/assets\/(styles\.css|appearance\.mjs|catalog\.mjs|app\.mjs|session-state\.mjs|visuals\.mjs|actions\.mjs|connections\.mjs)$/)?.[1]; - if(!file||request.method!=='GET')return false; + const file=pathname.match(/^\/assets\/([a-z-]+\.(?:mjs|css|svg))$/)?.[1]; + if(!file||request.method!=='GET'||!(MODULES.includes(file)||file==='styles.css'||file==='favicon.svg'))return false; // Keep one public stylesheet URL: existing ingress rules and CSP remain valid. - const content=file==='styles.css'?Buffer.concat([ - fs.readFileSync(new URL(file,import.meta.url)),Buffer.from('\n'), - fs.readFileSync(new URL('layout-polish.css',import.meta.url)), - ]):fs.readFileSync(new URL(file,import.meta.url)); - response.writeHead(200,{'content-type':file.endsWith('.css')?'text/css; charset=utf-8':'text/javascript; charset=utf-8','cache-control':'no-cache','x-content-type-options':'nosniff'});response.end(content);return true; + const content=file==='styles.css'?stylesheetContent():fs.readFileSync(new URL(file,import.meta.url)); + response.writeHead(200,{'content-type':TYPES[file.split('.').pop()],'cache-control':'no-cache','x-content-type-options':'nosniff'});response.end(content);return true; } export function sendPage(response,options,{status=200,redirectUri=''}={}) { response.writeHead(status,{'content-type':'text/html; charset=utf-8','cache-control':'no-store', diff --git a/web/console/routes.mjs b/web/console/routes.mjs new file mode 100644 index 0000000..685ce2e --- /dev/null +++ b/web/console/routes.mjs @@ -0,0 +1,4 @@ +// Console routes shared by the server shell and the browser controller. +// Keep in step with the sidebar (render.mjs), the feature map (visuals.mjs) and the ingress routes. +export const pages=['overview','memories','summaries','tasks','resume','jobs','connections','models','privacy','security','audit','storage','invitations','accounts','system']; +export const pageCode=page=>`MN-${String(Math.max(0,pages.indexOf(page))+1).padStart(2,'0')}`; diff --git a/web/console/styles.css b/web/console/styles.css index d700ed1..f72efd4 100644 --- a/web/console/styles.css +++ b/web/console/styles.css @@ -1,255 +1,438 @@ -/* Layout A: geometry is shared; theme selectors below contain color values only. */ -:root{--sidebar-width:232px;--topbar-height:72px;--radius-card:16px;--radius-control:9px;--gutter:32px;--gap:20px;--metric-gap:16px;--card-padding:22px;--text-size:14px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;color-scheme:light} -[data-theme="a"][data-mode="light"]{--bg:#F6F6FB;--surface:#FFFFFF;--surface-2:#FAFAFE;--text:#20212C;--muted:#646879;--border:#E5E5EF;--accent:#6554D9;--soft:#EFECFF;--on-accent:#FFFFFF} -[data-theme="a"][data-mode="dark"]{--bg:#11121B;--surface:#1B1D2B;--surface-2:#222434;--text:#F1F2F7;--muted:#A7ADBF;--border:#35384D;--accent:#AC9EFF;--soft:#2F294C;--on-accent:#171227} -[data-theme="b"][data-mode="light"]{--bg:#F2F7F6;--surface:#FFFFFF;--surface-2:#F7FBFA;--text:#16332F;--muted:#57706C;--border:#D6E5E1;--accent:#08776B;--soft:#DFF3EE;--on-accent:#FFFFFF} -[data-theme="b"][data-mode="dark"]{--bg:#0C1818;--surface:#142626;--surface-2:#193030;--text:#E9F5F1;--muted:#9FBBB5;--border:#2A4943;--accent:#53D5BD;--soft:#183D35;--on-accent:#09211C} -[data-theme="c"][data-mode="light"]{--bg:#F8F5EE;--surface:#FFFDF9;--surface-2:#F9F6EF;--text:#352C25;--muted:#776B5E;--border:#E7DED1;--accent:#8D5720;--soft:#F2E7D6;--on-accent:#FFFFFF} -[data-theme="c"][data-mode="dark"]{--bg:#1B1814;--surface:#28231D;--surface-2:#302A23;--text:#F4EADC;--muted:#C0AF99;--border:#4D4032;--accent:#E6B57B;--soft:#3D3022;--on-accent:#271C10} -[data-mode="light"]{--good:#16745D;--warn:#956112;--bad:#B33752} -[data-mode="dark"]{--good:#6BDBB6;--warn:#EDC579;--bad:#FF8CA3;color-scheme:dark} +/* Mnemuron console · Ink Archive. Paper, ink and one seal-red accent; hairline rules, 2px corners. */ +:root{--sidebar:248px;--topbar:64px;--pane:min(440px,36vw);--radius:2px;--radius-sm:2px;--gutter:32px;--gap:16px; + --font-serif:"Iowan Old Style","Palatino Linotype",Palatino,"Noto Serif SC","Source Han Serif SC","Songti SC",STSong,SimSun,serif; + --font-sans:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans SC",sans-serif; + --font-body:var(--font-sans); + --font-mono:"IBM Plex Mono","SF Mono",ui-monospace,Menlo,Consolas,monospace;--ease:cubic-bezier(.2,.6,.2,1); + --bg:#F5F1E8;--surface:#FFFDF8;--surface-2:#F0EADD;--soft:#EBE3D1;--text:#1C1B18;--muted:#5F5A50;--border:#DCD4C3;--line:#CFC6B3;--accent:#AE3F2C;--on-accent:#F5F1E8; + --field:#FFFFFF;--hover:#F0EADD;--good:#3B6B4F;--warn:#8A5A12;--bad:#9B2C1F; + --shadow:0 18px 48px #1c1b1824;--offset:4px 4px 0 #EBE3D1; + font-family:var(--font-body);font-size:14px;line-height:1.6;color-scheme:light} + +/* Base */ *{box-sizing:border-box} -body{margin:0;background:var(--bg);color:var(--text);line-height:1.65} -a{color:var(--accent);text-decoration:none} -button,input,select{font:inherit} -button,select{cursor:pointer} -button,.button{display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--border);border-radius:var(--radius-control);background:var(--surface);color:var(--text);padding:9px 14px;min-height:40px;line-height:1.4;text-align:center} -button.primary,.button.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);font-weight:600} -button:disabled{cursor:not-allowed;color:var(--muted);background:var(--surface-2)} -input,select{border:1px solid var(--border);border-radius:var(--radius-control);background:var(--surface);color:var(--text);padding:10px 12px;min-width:0} -input{width:100%;min-height:46px} -label{font-size:13px;display:block;margin:14px 0 6px} -button:not(.primary):not(:disabled),input,select{border-color:var(--muted)} -button:focus-visible,input:focus-visible,select:focus-visible,a:focus-visible,summary:focus-visible{outline:3px solid var(--accent);outline-offset:3px} -button:not(:disabled):hover,.button:hover{filter:brightness(.98)} -a:not(.button):not(.brand):hover{color:var(--accent)} +html{background:var(--bg)} +body{margin:0;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;min-height:100vh} +a{color:var(--text);text-decoration:none} +a:not(.button):hover{color:var(--accent);text-decoration:underline;text-underline-offset:4px} h1,h2,h3,p{margin-top:0} -h1{font-size:30px;line-height:1.35;letter-spacing:-.7px;margin-bottom:10px} -h2{font-size:17px;line-height:1.5} -h3{font-size:15px} +h1{font-family:var(--font-serif);font-size:30px;line-height:1.25;font-weight:600;letter-spacing:.01em;margin-bottom:6px} +h2{font-family:var(--font-serif);font-size:17px;line-height:1.4;font-weight:600} +h3{font-family:var(--font-mono);font-size:11px;font-weight:500;letter-spacing:.1em;color:var(--muted);margin-bottom:10px} p{color:var(--muted)} small{display:block;font-size:12px;color:var(--muted)} -.icon{display:inline-block;flex-shrink:0;width:18px;height:18px;vertical-align:middle} -.brand{display:flex;align-items:center;gap:10px;font-size:24px;letter-spacing:-.8px;font-weight:750;color:var(--text);line-height:1.3;white-space:nowrap} -.brand-icon{display:grid;place-items:center;flex-shrink:0;background:var(--accent);color:var(--on-accent);width:32px;height:32px;border-radius:10px} -.brand-icon .icon{width:23px;height:23px;stroke-width:1.8} -.eyebrow{font-size:10px;letter-spacing:1.6px;line-height:1.5;font-weight:500;color:var(--muted);margin:0 0 12px} -.sidebar{width:var(--sidebar-width);position:fixed;inset:0 auto 0 0;background:var(--surface);border-right:1px solid var(--border);padding:28px 16px 20px;overflow:auto;z-index:3;display:flex;flex-direction:column;scrollbar-width:thin} -.sidebar>.brand{margin-left:8px} -.sidebar>.eyebrow{margin:10px 0 0 50px;max-width:138px;font-size:8px;letter-spacing:1.6px} -.account-badge{display:flex;align-items:center;gap:10px;background:var(--surface-2);border:1px solid var(--border);border-radius:12px;padding:10px;margin:26px 0 8px;min-height:60px} +code,pre,kbd{font-family:var(--font-mono);font-size:12px} +code{overflow-wrap:anywhere;color:var(--muted)} +pre{white-space:pre-wrap;overflow-wrap:anywhere} +kbd{display:inline-grid;place-items:center;min-width:20px;height:20px;padding:0 5px;border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--muted);background:var(--bg);font-size:11px;line-height:1} +.icon{display:inline-block;flex:none;width:18px;height:18px;vertical-align:middle} +.muted{color:var(--muted)} +.eyebrow{font-family:var(--font-mono);font-size:11px;font-weight:500;letter-spacing:.12em;color:var(--muted);margin:0 0 8px} +.figure{font-family:var(--font-serif);font-variant-numeric:tabular-nums;font-weight:500;font-size:15px} + +/* Controls */ +button,input,select,textarea{font:inherit;color:inherit} +button,select{cursor:pointer} +button,.button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:36px;padding:6px 14px;border:1px solid var(--text);border-radius:var(--radius-sm);background:transparent;color:var(--text);font-size:13.5px;line-height:1.3;white-space:nowrap;transition:background .15s var(--ease),color .15s var(--ease),border-color .15s var(--ease)} +button .icon,.button .icon{width:16px;height:16px} +button:not(:disabled):hover,.button:hover{background:var(--hover);text-decoration:none} +button.primary,.button.primary{background:var(--text);border-color:var(--text);color:var(--bg);font-weight:600} +button.primary:not(:disabled):hover,.button.primary:hover{background:#000;border-color:#000;color:var(--bg)} +button.quiet{border-color:transparent;background:transparent;color:var(--muted)} +button.quiet:not(:disabled):hover{color:var(--text);background:var(--hover)} +button:disabled{cursor:not-allowed;border-style:dashed;border-color:var(--line);background:transparent;color:var(--muted)} +/* Irreversible or access-removing actions read as danger everywhere they appear. */ +button:is([data-console-action="memory.retract"],[data-console-action="jobs.cancel"],[data-console-action="oauth.revoke"],[data-console-action="security.session.revoke"],[data-console-action="security.sessions.revoke_others"],[data-console-action="accounts.disable"],[data-console-action="invitations.revoke"],[data-console-action="invitations.revoke_batch"],[data-connection-action="revoke"],[data-connection-action="disable"]):not(:disabled){border-color:var(--bad);color:var(--bad)} +input,select,textarea{border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--field);padding:8px 12px;min-width:0} +input{width:100%;min-height:38px} +input::placeholder,textarea::placeholder{color:#6F695E} +input,select{border-color:var(--muted)} +input:focus,select:focus,textarea:focus{outline:none;border-color:var(--text);box-shadow:0 0 0 3px var(--soft)} +:focus-visible{outline:2px solid var(--text);outline-offset:2px} +label{display:block;font-size:13px;font-weight:600;color:var(--text);margin:16px 0 6px} +.actions{display:flex;flex-wrap:wrap;gap:8px} +.tag{display:inline-flex;align-items:center;gap:6px;padding:0 7px;border:1px solid var(--line);border-radius:var(--radius-sm);background:transparent;color:var(--text);font-size:12px;line-height:20px;white-space:nowrap} +/* Lifecycle and state: shape and words carry meaning, colour only supports it. */ +.lifecycle-tag{padding:0;border:0;font-size:13px} +.lifecycle-tag::before,.state-dot::before{content:"";width:7px;height:7px;background:currentColor;flex:none} +.lifecycle-tag[data-status="superseded"]{color:var(--muted)} +.lifecycle-tag[data-status="superseded"]::before{background:transparent;box-shadow:inset 0 0 0 1.5px currentColor} +.lifecycle-tag[data-status="retracted"]{color:var(--muted);text-decoration:line-through} +.lifecycle-tag[data-status="retracted"]::before{width:8px;height:1.5px} +.state-dot{display:inline-flex;align-items:center;gap:7px;font-size:13px;color:var(--muted)} +.state-dot[data-state="enabled"],.state-dot[data-state="active"],.state-dot[data-state="succeeded"],.state-dot[data-state="ready"],.state-dot[data-state="issued"]{color:var(--good)} +.state-dot[data-state^="blocked"],.state-dot[data-state="dead_letter"],.state-dot[data-state="failed"],.state-dot[data-state="disabled"],.state-dot[data-state="expired"],.state-dot[data-state="revoked"]{color:var(--bad)} +.state-dot[data-state="running"],.state-dot[data-state="queued"],.state-dot[data-state="retry_wait"],.state-dot[data-state="reserved"],.state-dot[data-state="review_required"]{color:var(--warn)} +.state-dot[data-state="running"]::before,.state-dot[data-state="queued"]::before,.state-dot[data-state="retry_wait"]::before,.state-dot[data-state="reserved"]::before,.state-dot[data-state="review_required"]::before{background:transparent;box-shadow:inset 0 0 0 1.5px currentColor} +.type-chip{display:inline-flex;align-items:center;padding:0 7px;border:1px solid var(--line);border-radius:var(--radius-sm);color:var(--text);font-size:12px;line-height:20px;white-space:nowrap} +.empty{display:flex;flex-direction:column;align-items:center;gap:8px;padding:32px 20px;text-align:center;color:var(--muted);border:1px dashed var(--line);border-radius:var(--radius)} +.empty .icon{width:22px;height:22px} +.empty p{margin:0;font-size:13px} +.policy-box{margin:12px 0;padding:12px 14px;border-top:1.5px solid var(--text);background:var(--surface);color:var(--text);font-size:13px;overflow-wrap:anywhere} +.policy-box>p{margin:8px 0;color:var(--muted)} +.privacy-note{display:flex;gap:8px;align-items:flex-start;margin:14px 0 0;font-size:12.5px;line-height:1.7;color:var(--muted)} +.privacy-note .icon{width:15px;height:15px;margin-top:3px;color:var(--text)} +.privacy-note p{margin:0;font-size:12.5px} +.notice{display:flex;gap:10px;align-items:flex-start;margin:0 0 16px;padding:12px 14px;border-top:1.5px solid var(--text);background:var(--bg)} +.notice .icon{margin-top:2px} +.notice p{margin:0;color:var(--text);font-size:13.5px} +.notice.is-warning .icon{color:var(--bad)} +.card{position:relative;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:18px 20px;min-width:0} +.card+.card,.columns+.card,.card-grid+.card{margin-top:var(--gap)} +.columns>.card,.card-grid>.card,.connection-stats>.card,.connection-types>.card{margin-top:0} +.section-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px} +.section-head h2{margin:0} +.section-head>div,.section-head>a{display:flex;align-items:center;gap:8px} +.section-head>a{font-size:13px;text-decoration:underline;text-underline-offset:4px} +.section-head>a .icon{width:14px;height:14px} +.section-foot{display:flex;justify-content:flex-end;margin-top:10px} +.columns{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);gap:var(--gap);align-items:start} +.card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:var(--gap)} +.metadata-grid{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 20px;margin:0 0 14px;font-size:13.5px} +.metadata-grid dt{color:var(--muted);font-size:12.5px}.metadata-grid dd{margin:0;overflow-wrap:anywhere} +.status-line{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:12px;font-size:13px;color:var(--muted)} +.action-toolbar{margin:4px 0 12px} +.link-button{border:0;background:none;padding:0;min-height:0;color:var(--text);font-weight:600;text-decoration:underline;text-underline-offset:4px} +.link-button:not(:disabled):hover{background:none;color:var(--accent)} +.progress{display:inline-flex;align-items:center;gap:8px;font-family:var(--font-mono);font-size:12px} +.progress svg{width:72px;height:4px} +.track{fill:var(--surface-2)}.fill{fill:var(--text)} +.identity-row{display:flex;align-items:center;gap:14px;margin-bottom:16px} +.identity-row strong{display:block;font-family:var(--font-serif);font-size:19px;font-weight:600} +.identity-row .avatar{width:44px;height:44px;font-size:24px} +table{width:100%;border-collapse:collapse;table-layout:fixed;font-size:13.5px} +th,td{padding:11px 12px;border-bottom:1px solid var(--border);text-align:left;vertical-align:top;overflow-wrap:anywhere} +th{font-family:var(--font-mono);font-size:11px;font-weight:500;letter-spacing:.08em;color:var(--muted);border-bottom:1.5px solid var(--text)} +tbody tr:hover{background:var(--hover)} +td small{margin-top:3px} +.table-scroll{overflow-x:auto} +.table-scroll .actions{gap:6px} +.table-scroll .actions button{min-height:30px;padding:3px 10px;font-size:12.5px} +.pagination{display:flex;justify-content:flex-end;gap:8px} +.pagination:empty{display:none} +.pagination button{min-height:32px} +details{margin:8px 0}summary{cursor:pointer;color:var(--muted);font-size:13px} +.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;padding:0;border:0;margin:-1px} +.skip-link{position:fixed;left:12px;top:-60px;z-index:20;padding:8px 12px;background:var(--surface);border:1px solid var(--text)} +.skip-link:focus{top:12px} + +/* Shell: sidebar + workspace */ +.shell{position:relative;display:grid;grid-template-columns:var(--sidebar) minmax(0,1fr);min-height:100vh} +.sidebar{position:sticky;top:0;height:100vh;display:flex;flex-direction:column;padding:26px 16px 16px;border-right:1px solid var(--border);background:var(--bg);overflow:auto;scrollbar-width:thin;z-index:5} +.brand{display:flex;align-items:center;gap:12px;color:var(--text);font-family:var(--font-serif);font-weight:600;font-size:21px;letter-spacing:-.01em} +.brand:hover{color:var(--text);text-decoration:none} +.brand-mark{display:block;width:34px;height:34px;flex:none;color:var(--text)} +.brand-mark .logo{display:block;width:100%;height:100%} +.logo-seal{fill:var(--accent)} +.sidebar>.brand{margin:0 10px} +.sidebar>.eyebrow{margin:2px 0 0 56px;font-family:var(--font-sans);font-size:11.5px;letter-spacing:0;color:var(--muted)} +.sidebar nav{margin-top:24px} +.nav-group h2{margin:20px 10px 6px;font-family:var(--font-mono);font-size:11px;font-weight:500;letter-spacing:.1em;color:var(--muted)} +.nav-group:first-child h2{margin-top:0} +.sidebar nav a{display:flex;align-items:center;gap:12px;height:36px;padding:0 10px;margin:1px 0;border-radius:var(--radius-sm);color:var(--text);font-size:14px} +.sidebar nav a:hover{color:var(--text);background:var(--hover);text-decoration:none} +.sidebar nav a[aria-current]{background:var(--soft);font-weight:600} +.nav-icon{display:flex;color:var(--muted)}.nav-icon .icon{width:18px;height:18px} +.sidebar nav a:hover .nav-icon{color:var(--text)} +.sidebar nav a[aria-current] .nav-icon{color:var(--accent)} +.account-badge{margin-top:auto;display:flex;align-items:center;gap:10px;padding:14px 10px 0;border-top:1px solid var(--border)} .account-badge>div{min-width:0;flex:1} -.account-badge strong{font-size:12px;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.account-badge small{font-size:11px;margin-top:1px} -.account-badge>.icon{width:15px;color:var(--muted)} -.avatar{flex:none;display:grid;place-items:center;width:32px;height:36px;background:var(--soft);color:var(--accent);border-radius:9px;font-weight:600;text-transform:uppercase} -.sidebar nav h2{font-size:10px;color:var(--muted);font-weight:400;margin:21px 12px 8px;letter-spacing:.2px} -.sidebar nav a{display:flex;align-items:center;gap:12px;height:42px;border-radius:9px;padding:0 12px;color:var(--muted);line-height:1.35;margin:3px 0;font-size:12px} -.sidebar nav a:hover{background:var(--surface-2)} -.sidebar nav a[aria-current]{background:var(--soft);color:var(--accent);font-weight:600} -.nav-icon{display:flex;flex-shrink:0} -.nav-icon .icon{width:17px;height:17px} -.nav-current{width:4px;height:4px;flex:none;border-radius:50%;background:var(--accent);margin-left:auto} -.workspace{margin-left:var(--sidebar-width);min-width:0;min-height:100vh;display:flex;flex-direction:column} -.topbar{height:var(--topbar-height);display:flex;align-items:center;justify-content:space-between;gap:16px;padding:0 var(--gutter);background:var(--surface);border-bottom:1px solid var(--border);font-size:12px;position:relative;z-index:2} -.breadcrumb{display:flex;align-items:center;gap:10px;min-width:0;color:var(--muted)} -.breadcrumb>strong{color:var(--text);font-weight:500;white-space:nowrap} -.topbar-tools{display:flex;align-items:center;gap:14px;flex-shrink:0} -.top-search{display:flex;align-items:center;gap:10px;min-width:144px;min-height:36px;padding:6px 11px;border:1px solid var(--border);border-radius:9px;background:var(--surface-2);color:var(--muted)} +.account-badge strong{display:block;font-size:13.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.account-badge small{font-size:11.5px} +.account-badge>.icon{width:16px;height:16px;color:var(--good)} +.avatar{display:grid;place-items:center;width:30px;height:30px;flex:none;border:1px solid var(--text);border-radius:var(--radius-sm);font-family:var(--font-serif);font-size:16px;font-weight:600;text-transform:uppercase} +.workspace{display:flex;flex-direction:column;min-width:0;min-height:100vh} +.topbar{position:sticky;top:0;z-index:4;height:var(--topbar);display:flex;align-items:center;gap:18px;padding:0 var(--gutter);background:var(--bg);border-bottom:1px solid var(--border)} +.breadcrumb{display:flex;align-items:center;gap:8px;min-width:0;overflow:hidden;color:var(--muted);font-size:13px;white-space:nowrap} +.breadcrumb strong{color:var(--text);font-weight:600} +.topbar-tools{display:flex;align-items:center;gap:10px;flex:none;margin-left:auto} +body.pane-open .breadcrumb>span{display:none} +.top-search{display:flex;align-items:center;gap:10px;min-width:220px;height:36px;padding:0 6px 0 12px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--muted);font-size:13px} +.top-search>span{flex:1} .top-search .icon{width:16px;height:16px} -.appearance-controls{display:flex;gap:4px;align-items:center} -.preference-select{display:flex;align-items:center;position:relative;gap:4px} -.appearance-controls select{padding:7px 3px;font-size:11px;min-height:36px;max-width:128px;background:transparent;border-color:transparent} -.mode-select>.icon{width:14px;height:14px;color:var(--muted)} -.account-menu{position:relative;margin:0;max-width:160px} -.account-menu>summary{display:flex;align-items:center;gap:10px;list-style:none;padding:7px 12px;border:1px solid var(--border);border-radius:9px;min-height:36px;color:var(--text);font-size:12px} +.top-search:hover{border-color:var(--text);color:var(--text);text-decoration:none} +.language-control{display:flex;align-items:center} +.account-menu{position:relative;margin:0} +.account-menu>summary{display:flex;align-items:center;gap:8px;list-style:none;height:36px;padding:0 12px;border:1px solid var(--line);border-radius:var(--radius-sm);background:var(--surface);color:var(--text);cursor:pointer;font-size:13px} .account-menu>summary::-webkit-details-marker{display:none} +.account-menu>summary:hover,.account-menu[open]>summary{border-color:var(--text)} .account-name{max-width:110px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} -.account-menu-panel{position:absolute;right:0;top:calc(100% + 9px);width:240px;padding:16px;border:1px solid var(--border);border-radius:12px;background:var(--surface);box-shadow:0 12px 32px #00000014;overflow-wrap:anywhere} -.account-menu-panel>strong{display:block;margin:4px 0 16px} +.account-menu-panel{position:absolute;right:0;top:calc(100% + 8px);z-index:30;width:240px;padding:14px;border:1px solid var(--text);border-radius:var(--radius-sm);background:var(--surface);box-shadow:var(--offset);overflow-wrap:anywhere} +.account-menu-panel strong{display:block;margin:2px 0 12px} .account-menu-panel form{margin:0} -.account-menu-panel button{width:100%;font-size:12px;justify-content:flex-start} -#main{width:100%;padding:var(--gutter);max-width:1800px;margin:0 auto;outline:none;flex:1} -.page-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:26px;min-width:0} +.account-menu-panel button{width:100%;justify-content:flex-start} +#main{container-type:inline-size;flex:1;min-width:0;width:100%;max-width:1600px;margin:0 auto;padding:28px var(--gutter) 40px;outline:none} +footer{padding:14px var(--gutter);border-top:1px solid var(--border);display:flex;justify-content:center} +.footer-mark{font-family:var(--font-serif);font-size:12px;letter-spacing:.2em;color:var(--muted)} + +/* Page heading */ +.page-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:22px} .page-heading>div{min-width:0} -.page-heading>.tag,.browse-link{flex-shrink:0} -.page-heading p{margin-bottom:0;font-size:13px} -.page-heading .eyebrow{display:flex;align-items:center;gap:8px;margin-bottom:12px;font-size:9px} -.page-heading .eyebrow::before{content:"";width:22px;height:1px;background:var(--accent)} -.browse-link{border-color:var(--border);padding:9px 15px;font-size:12px} -.hero{display:flex;align-items:center;justify-content:space-between;position:relative;gap:24px;background:var(--soft);border:1px solid var(--border);border-radius:var(--radius-card);padding:28px;margin-bottom:20px;min-height:190px;overflow:hidden} -.hero-copy{position:relative;z-index:1;max-width:710px;min-width:0} -.hero .eyebrow{font-size:9px;letter-spacing:1.5px;color:var(--accent);margin-bottom:14px} -.hero h2{font-size:23px;letter-spacing:-.4px;margin:0 0 9px;line-height:1.4} -.hero p:not(.eyebrow){font-size:12px;margin-bottom:20px;max-width:630px} -.hero .button{font-size:12px;min-height:36px;padding:8px 13px} -.memory-orbit{width:236px;min-width:236px;height:174px;display:flex;align-items:center;justify-content:center;gap:10px;position:relative;isolation:isolate;color:var(--accent)} -.orbit-ring{position:absolute;width:150px;height:150px;border-radius:50%;border:1px solid var(--accent);opacity:.13;z-index:-1} -.orbit-ring.outer{width:220px;height:220px;opacity:.08} -.orbit-node{width:52px;height:52px;display:grid;place-items:center;background:var(--surface);border:1px solid var(--border);border-radius:16px} -.orbit-node>.icon{width:22px;height:22px} -.orbit-dots{font-size:25px;letter-spacing:3px;margin:0 2px} -.orbit-caption{position:absolute;bottom:10px;left:0;width:100%;text-align:center;font-size:7px;letter-spacing:1px} -.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--metric-gap);margin:20px 0 24px} -.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-card);padding:var(--card-padding);min-width:0} -.metric{min-height:128px;padding:19px 20px} -.metric,.processing-stage{color:var(--text);text-decoration:none} -.metric:hover,.category-link:hover{border-color:var(--accent);background:var(--soft)} -.processing-stage:hover{color:var(--accent)} -.metric-heading{display:flex;justify-content:space-between;align-items:center;gap:10px;font-size:12px;color:var(--muted)} -.metric-icon{display:inline-grid;place-items:center;flex-shrink:0;background:var(--soft);color:var(--accent);border-radius:9px;width:30px;height:30px} -.metric-icon .icon{width:16px;height:16px} -.metric strong{font-size:30px;letter-spacing:-.8px;line-height:1.2;display:block;margin:6px 0 7px;font-variant-numeric:tabular-nums} -.metric small{font-size:11px} -.tag{display:inline-block;background:var(--soft);color:var(--accent);border-radius:6px;padding:3px 8px;font-size:11px;overflow-wrap:anywhere;line-height:1.5} -.tag.warning{color:var(--warn);background:var(--surface-2)} -.columns{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(270px,1fr);gap:var(--gap)} -.overview-columns>.card{min-height:360px} -.card-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:16px} -.card-heading h2{margin:0} -.card-heading .eyebrow{margin-bottom:8px;font-size:9px;letter-spacing:1.2px} -.card-heading>a{display:flex;align-items:center;gap:6px;white-space:nowrap;font-size:11px;color:var(--muted)} -.card-heading>a .icon{width:13px} -.memory-row{display:flex;align-items:center;gap:12px;padding:19px 0;min-height:83px;border-top:1px solid var(--border)} -.memory-row .memory-link{border:0;padding:0;background:none;text-align:left;flex:1;min-width:0;overflow-wrap:anywhere;line-height:1.65;display:block;font-size:13px} -.memory-row .memory-link:hover{color:var(--accent);filter:none} -.memory-row .glyph{display:grid;place-items:center;width:32px;height:38px;flex:none;background:var(--soft);color:var(--accent);border-radius:9px} -.memory-row .glyph .icon{width:17px;height:17px} -.memory-row small{margin-top:5px;font-size:11px} -.memory-row>.tag{flex-shrink:0;max-width:95px;font-size:10px} -.memory-row>span:last-child:not(.tag):not(.glyph){font-size:12px;color:var(--muted)} -.recent-card>p:last-child{font-size:11px;margin:22px 0 0} -.processing-card{display:flex;flex-direction:column} -.processing-stage{display:flex;align-items:center;gap:12px;padding:17px 0;border-bottom:1px solid var(--border)} -.stage-icon{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:var(--surface-2);color:var(--accent);border:1px solid var(--border)} -.processing-stage>div{flex:1;font-size:13px} -.processing-stage small{margin-top:3px;font-size:11px} -.processing-stage strong{font-size:20px;font-weight:600;font-variant-numeric:tabular-nums} -.privacy-note{display:flex;align-items:flex-start;gap:10px;border:1px solid var(--border);background:var(--surface-2);border-radius:10px;padding:13px 14px} -.privacy-note>.icon{width:16px;height:16px;color:var(--muted);margin-top:2px} -.privacy-note p{font-size:11px;line-height:1.8;margin:0} -.processing-card .privacy-note{margin:22px 0} -.text-link{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--muted);margin-top:auto} -.policy-box,.empty{border:1px solid var(--border);background:var(--surface-2);border-radius:12px;padding:20px;margin:16px 0;overflow-wrap:anywhere} -.policy-box>p{font-size:13px;margin:12px 0 16px;max-width:670px} -.empty{padding:42px 24px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:14px;border-style:dashed} -.empty p{font-size:13px;margin:0} -.empty-icon{display:grid;place-items:center;width:48px;height:48px;border-radius:14px;background:var(--soft);color:var(--accent)} -.empty-icon .icon{width:22px;height:22px} -.toolbar{display:flex;align-items:end;gap:12px;margin:0 0 22px;padding-bottom:22px;border-bottom:1px solid var(--border)} -.toolbar label{flex:1;margin:0;min-width:0} -.toolbar input{margin-top:7px} -.toolbar button{min-height:46px} -.memory-filters{flex-wrap:wrap}.memory-filters .query-field{flex-basis:100%} -.memory-filters label{min-width:140px}.memory-filters select{margin-top:7px} -.category-link{display:flex;align-items:center;justify-content:space-between;padding:14px 10px;border-bottom:1px solid var(--border);text-decoration:none;color:var(--text)} -.metadata-grid{display:grid;grid-template-columns:minmax(110px,1fr) minmax(0,3fr);gap:14px 18px;margin:0 0 24px}.metadata-grid dt{color:var(--muted)}.metadata-grid dd{margin:0;overflow-wrap:anywhere} -.pagination{display:flex;justify-content:flex-end;gap:12px;margin-top:20px} -.pagination:empty{display:none} -table{border-collapse:collapse;width:100%;font-size:13px;table-layout:fixed} -td,th{text-align:left;vertical-align:top;border-bottom:1px solid var(--border);padding:15px 12px;overflow-wrap:anywhere} -th{font-weight:500;color:var(--muted);font-size:11px;background:var(--surface-2)} -tbody tr:last-child td{border-bottom:0} -tbody tr:hover{background:var(--surface-2)} -table+h2{margin-top:28px} -pre,code{font-family:ui-monospace,SFMono-Regular,monospace;overflow-wrap:anywhere;white-space:pre-wrap} -.body-content{white-space:pre-wrap;overflow-wrap:anywhere;line-height:1.9;padding:20px;background:var(--surface-2);border:1px solid var(--border);border-radius:12px} -footer{padding:14px var(--gutter);border-top:1px solid var(--border);background:var(--surface);color:var(--muted);font-size:10px;display:flex;gap:10px;align-items:center;justify-content:center} -.footer-mark{font-weight:600;letter-spacing:1px} -.actions{display:flex;gap:10px;flex-wrap:wrap} -/* Real appearance settings, not a second layout or a preview-only toggle. */ -.appearance-card>p{font-size:13px} -.theme-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:20px;margin-top:22px} -button.theme-option:not(:disabled){border-color:var(--border)} -.theme-option{display:block;text-align:left;padding:10px;border-radius:12px;min-width:0;background:var(--surface)} -button.theme-option[aria-pressed="true"]:not(:disabled){border:2px solid var(--accent);padding:9px;background:var(--surface)} -.theme-option-info{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:16px 5px 7px} -.theme-option-info strong{font-size:13px;font-weight:600;display:block} -.theme-option-info small{font-size:11px;margin-top:5px;line-height:1.5} -.selection-check{display:grid;place-items:center;width:20px;height:20px;border:1px solid var(--muted);border-radius:50%;flex:none;color:transparent} -.selection-check .icon{width:13px;height:13px} -[aria-pressed="true"]>.theme-option-info>.selection-check{background:var(--accent);border-color:var(--accent);color:var(--on-accent)} -.mini-shell{height:138px;display:grid;grid-template-columns:25% 75%;border:1px solid var(--border);border-radius:7px;overflow:hidden;background:var(--preview-bg)} -.theme-option[data-pref-value="a"]{--preview-bg:#F6F6FB;--preview-surface:#FFFFFF;--preview-accent:#6554D9;--preview-soft:#EFECFF} -.theme-option[data-pref-value="b"]{--preview-bg:#F2F7F6;--preview-surface:#FFFFFF;--preview-accent:#08776B;--preview-soft:#DFF3EE} -.theme-option[data-pref-value="c"]{--preview-bg:#F8F5EE;--preview-surface:#FFFDF9;--preview-accent:#8D5720;--preview-soft:#F2E7D6} -[data-mode="dark"] .theme-option[data-pref-value="a"]{--preview-bg:#11121B;--preview-surface:#1B1D2B;--preview-accent:#AC9EFF;--preview-soft:#2F294C} -[data-mode="dark"] .theme-option[data-pref-value="b"]{--preview-bg:#0C1818;--preview-surface:#142626;--preview-accent:#53D5BD;--preview-soft:#183D35} -[data-mode="dark"] .theme-option[data-pref-value="c"]{--preview-bg:#1B1814;--preview-surface:#28231D;--preview-accent:#E6B57B;--preview-soft:#3D3022} -.mini-sidebar{background:var(--preview-surface);display:flex;flex-direction:column;gap:10px;padding:14px 8px} -.mini-sidebar i{display:block;background:var(--preview-soft);height:5px;border-radius:2px} -.mini-sidebar i:first-child{background:var(--preview-accent);width:12px;height:12px;border-radius:4px;margin-bottom:5px} -.mini-main{padding:10px;display:flex;gap:9px;flex-direction:column} -.mini-main i{display:block;border-radius:3px} -.mini-topbar{background:var(--preview-surface);height:10px} -.mini-hero{background:var(--preview-soft);height:30px;border-left:3px solid var(--preview-accent)} -.mini-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;height:19px} -.mini-metrics i,.mini-columns i{background:var(--preview-surface)} -.mini-columns{display:grid;grid-template-columns:1.7fr 1fr;gap:7px;flex:1} -.preference-columns{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:20px} -.preference-columns p{font-size:12px;min-height:40px} -.preference-columns .card-heading>.icon,.language-mark{color:var(--muted)} -.segmented{display:flex;gap:6px;background:var(--surface-2);border:1px solid var(--border);padding:5px;border-radius:11px} -button.segment:not(:disabled){flex:1;min-width:0;background:transparent;border-color:transparent;color:var(--muted)} -button.segment[aria-pressed="true"]:not(:disabled){border-color:var(--muted);background:var(--surface);color:var(--text);font-weight:600} -.appearance-footnote{margin-top:20px} -/* Authentication uses the same 40/60 Layout A surface hierarchy and form contract. */ -#main.auth-layout{display:grid;grid-template-columns:40% 60%;min-height:100vh;padding:0;max-width:none;margin:0} -.auth-brand{padding:42px 44px;display:flex;flex-direction:column;justify-content:space-between;background:var(--surface);border-right:1px solid var(--border);min-width:0} -.auth-brand>.eyebrow,.auth-brand>div>.eyebrow{font-size:8px;letter-spacing:1.2px;margin:17px 0 0} -.auth-story{margin:70px 0 40px} -.auth-story h2{font-size:40px;line-height:1.45;font-weight:650;letter-spacing:-1.2px;max-width:460px;margin:0 0 28px} +.page-heading h1{margin-bottom:6px} +.page-heading p{margin:0;font-size:14px;max-width:760px} +.page-heading-actions{display:flex;align-items:center;gap:10px;flex:none} +.loading-card{display:grid;place-items:center;min-height:200px;border:1px dashed var(--line);color:var(--muted);font-family:var(--font-mono);font-size:12px} + +/* Overview */ +.radar-panel{display:grid;grid-template-columns:minmax(0,1fr) minmax(200px,260px);gap:32px;align-items:center;padding:24px 28px;margin-bottom:var(--gap)} +.radar-copy h2{font-size:24px;margin-bottom:6px} +.radar-copy>p{font-size:14px;max-width:560px} +.ask{display:flex;align-items:center;gap:12px;height:52px;padding:0 6px 0 16px;margin:18px 0;border:1.5px solid var(--text);border-radius:var(--radius-sm);background:var(--field)} +.ask:focus-within{box-shadow:0 0 0 3px var(--soft)} +.ask>.icon{width:20px;height:20px;color:var(--text)} +.ask input{flex:1;height:100%;min-height:0;border:0;background:transparent;padding:0;font-size:15px;box-shadow:none} +.ask input:focus{box-shadow:none} +.ask button{height:38px;padding:0 18px} +.radar-legend{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:6px 24px;max-width:600px} +.radar-legend li{display:grid;grid-template-columns:10px minmax(0,1fr) auto auto;align-items:center;gap:8px;font-size:13px} +.radar-legend i{width:8px;height:8px;background:var(--node)} +.radar-legend span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.radar-legend strong{font-family:var(--font-mono);font-size:12px;font-weight:500} +.radar-legend small{font-family:var(--font-mono);font-size:11px} +.distribution{position:relative;margin:0;justify-self:center;width:100%;max-width:260px} +.distribution svg{display:block;width:100%;height:auto} +.ring-track{fill:none;stroke:var(--surface-2);stroke-width:16} +.arc{fill:none;stroke:var(--node);stroke-width:16} +.ring-total{font-family:var(--font-serif);font-size:40px;font-weight:500;fill:var(--text);font-variant-numeric:tabular-nums} +.ring-caption{font-size:12px;fill:var(--muted)} +.radar-empty{position:absolute;inset:auto 0 40%;text-align:center;font-size:12px;color:var(--muted)} +.hue-0{--node:var(--accent)}.hue-1{--node:var(--text)}.hue-2{--node:var(--muted)}.hue-3{--node:#9C8F76}.hue-4{--node:#BDB29C}.hue-5{--node:#6F7F8C} +.metrics{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap);margin-bottom:var(--gap)} +.metric{display:flex;flex-direction:column;gap:2px;padding:12px 16px 14px;border-top:1.5px solid var(--text);background:var(--surface);color:var(--muted);font-size:12.5px} +.metric:hover{color:var(--muted);background:var(--hover);text-decoration:none} +.metric strong{font-family:var(--font-serif);font-size:34px;font-weight:500;color:var(--text);line-height:1.15;font-variant-numeric:tabular-nums} +.home-grid{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:24px;align-items:start} +.stream>p{margin:12px 0 0;font-size:12.5px} +.stream-list{list-style:none;margin:0;padding:0} +.memory-row{display:flex;align-items:flex-start;gap:12px;padding:0 4px 0 0;border-top:1px solid var(--border)} +.memory-row:first-child{border-top:0} +.memory-row>.tag{margin-top:13px} +.memory-link{display:block;flex:1;min-width:0;min-height:0;padding:11px 12px;border:0;border-radius:0;background:none;text-align:left;white-space:normal;color:var(--text);font-size:14px;line-height:1.6} +.memory-link:not(:disabled):hover{background:none;color:var(--accent)} +.memory-row:hover{background:var(--hover)} +.memory-row:has([data-selected]),tr:has([data-selected]),.summary-row:has([data-selected]){background:var(--soft)} +[data-selected] .memory-text{font-weight:600} +.memory-text{display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere} +.memory-meta{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:4px;font-size:12px;color:var(--muted)} +.memory-meta time,.memory-date time{font-family:var(--font-mono);font-size:11.5px} +.pulse{display:flex;flex-direction:column;gap:14px} +.pulse-block{position:relative;padding:14px 16px;border:1px solid var(--border);border-radius:var(--radius);background:var(--surface)} +.pulse-head{display:flex;align-items:baseline;justify-content:space-between} +.pulse-figure{font-family:var(--font-serif);font-size:22px;font-weight:500} +.activity-bars{display:block;width:100%;height:44px;margin-top:6px} +.bar{fill:var(--text)} +.bar:last-of-type:not(.is-zero){fill:var(--accent)} +.bar.is-zero{fill:var(--border)} +.strip-axis{display:flex;justify-content:space-between;margin-top:6px;font-family:var(--font-mono);font-size:11px;color:var(--muted)} +.breakdown{list-style:none;margin:0;padding:0;display:grid;gap:8px} +.breakdown li{display:grid;grid-template-columns:100px minmax(0,1fr) 30px;align-items:center;gap:10px} +.breakdown svg{width:100%;height:4px} +.breakdown-count{text-align:right;font-family:var(--font-mono);font-size:12px;color:var(--muted)} +.pipeline{display:flex;flex-direction:column} +.processing-stage{display:flex;align-items:center;gap:12px;padding:9px 0;color:var(--text);border-top:1px solid var(--border)} +.processing-stage:first-child{border-top:0} +.processing-stage:hover{text-decoration:none;color:var(--accent)} +.stage-icon{display:grid;place-items:center;width:32px;height:32px;border:1px solid var(--border);color:var(--muted)} +.stage-icon .icon{width:17px;height:17px} +.stage-text{flex:1;min-width:0;font-size:13.5px;font-weight:600} +.stage-text small{font-size:12px;font-weight:400} +.processing-stage strong{font-family:var(--font-serif);font-size:20px;font-weight:500} + +/* Library */ +.memory-filters{display:grid;grid-template-columns:minmax(240px,1fr) repeat(3,160px) auto;align-items:end;gap:10px;margin-bottom:20px} +.memory-filters label{margin:0;display:grid;gap:5px;font-size:12px;font-weight:500;color:var(--muted)} +.query-input{position:relative;display:block} +.query-input>.icon{position:absolute;left:12px;top:50%;translate:0 -50%;width:16px;height:16px;color:var(--muted);pointer-events:none} +.query-input input{padding-left:38px;height:38px} +.filter-actions{display:flex;gap:6px} +.filter-actions button{height:38px} +.memory-library{padding:0;overflow:hidden;border:0;background:transparent} +.memory-table .col-category{width:120px}.memory-table .col-state{width:110px}.memory-table .col-date{width:170px} +.memory-table th{padding:0 14px 10px} +.memory-table td{padding:0;vertical-align:middle} +.memory-table td:not(:first-child){padding:12px 14px} +.memory-table .memory-link{padding:12px 14px} +.memory-table .type-chip{margin-top:6px} +.memory-date{font-size:12px;color:var(--muted);white-space:nowrap} +.category-pill{display:inline-block;padding:0 7px;border:1px solid var(--line);border-radius:var(--radius-sm);font-size:12px;line-height:20px;white-space:nowrap} +.category-pill[data-category="uncategorized"]{color:var(--muted);border-style:dashed} +.library-note{margin:0 0 12px;padding:10px 14px;font-size:13px} +.memory-library .empty{margin:16px 0} +.library-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 4px 0;font-family:var(--font-mono);font-size:12px;color:var(--muted)} +.library-footer p{margin:0} +@container (max-width:1040px){.memory-filters{grid-template-columns:repeat(3,minmax(160px,1fr))}.memory-filters .query-field,.memory-filters .filter-actions{grid-column:1/-1}.home-grid,.split{grid-template-columns:minmax(0,1fr)}.radar-panel{grid-template-columns:minmax(0,1fr)}.memory-table .col-date{width:150px}} +@container (max-width:620px){.memory-filters{grid-template-columns:minmax(0,1fr)}.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}} + +/* Summaries, audit */ +.split{display:grid;grid-template-columns:280px minmax(0,1fr);gap:24px;align-items:start} +.index-panel h2,.list-panel h2{padding-bottom:8px;margin-bottom:0;border-bottom:1.5px solid var(--text);font-family:var(--font-mono);font-size:11px;font-weight:500;letter-spacing:.1em;color:var(--muted)} +.category-index{list-style:none;margin:0;padding:0} +.category-link{display:grid;grid-template-columns:minmax(0,1fr) 60px 30px;align-items:center;gap:10px;padding:10px 8px;border-bottom:1px solid var(--border);color:var(--text);font-size:13.5px} +.category-link:hover{background:var(--hover);color:var(--text);text-decoration:none} +.category-link svg{width:100%;height:4px} +.category-link strong{text-align:right;font-family:var(--font-mono);font-size:12px;color:var(--muted);font-weight:500} +.summary-list{list-style:none;margin:0 0 12px;padding:0} +.summary-row{display:flex;align-items:flex-start;gap:10px;padding-right:12px;border-bottom:1px solid var(--border)} +.summary-row>.tag{margin-top:13px} +.summary-title{font-weight:600} +.timeline{list-style:none;margin:0 0 10px;padding:0} +.timeline-item{display:grid;grid-template-columns:14px minmax(0,1fr);gap:12px;padding:11px 0;border-top:1px solid var(--border)} +.timeline-item:first-child{border-top:0} +.timeline-dot{width:8px;height:8px;margin-top:7px;box-shadow:inset 0 0 0 1.5px var(--muted)} +.timeline-item[data-outcome="success"] .timeline-dot{background:var(--text);box-shadow:none} +.timeline-item[data-outcome="denied"] .timeline-dot,.timeline-item[data-outcome="failure"] .timeline-dot{background:var(--bad);box-shadow:none} +.timeline-item strong{font-size:12.5px;font-weight:500;font-family:var(--font-mono)} + +/* Detail pane: docked beside the list when wide, a sheet when narrow */ +dialog{color:var(--text);background:var(--surface);border:1px solid var(--border);padding:0} +dialog::backdrop{background:#1c1b1859} +.pane{position:fixed;inset:0 0 0 auto;margin:0;width:var(--pane);max-width:100vw;height:100vh;max-height:100vh;border-width:0 0 0 1px;overflow:auto;z-index:10} +.pane:modal{width:min(560px,100vw)} +.pane[open]{animation:slide .2s var(--ease) both} +body.pane-open .workspace{margin-right:var(--pane)} +.dialog-header{position:sticky;top:0;z-index:1;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 20px;background:var(--surface);border-bottom:1px solid var(--border)} +.dialog-header h2{margin:0;font-family:var(--font-mono);font-size:11.5px;font-weight:500;letter-spacing:.08em;color:var(--muted)} +.close-button{width:32px;min-height:32px;padding:0;border-color:transparent;background:transparent;color:var(--muted)} +#memory-content{padding:18px 22px 28px} +.detail-meta{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-bottom:14px} +.detail-actions{padding:0 0 14px;margin-bottom:16px;border-bottom:1px solid var(--border)} +.detail-actions button{min-height:32px;padding:4px 12px;font-size:13px} +.body-content{white-space:pre-wrap;overflow-wrap:anywhere;font-family:var(--font-serif);font-size:19px;line-height:1.7;color:var(--text);margin:0 0 10px} +.detail-range{font-family:var(--font-mono);font-size:11px;margin-bottom:10px} +.lifecycle-links{display:flex;gap:6px;margin:6px 0 12px} +.detail-sources{margin-top:18px;padding-top:14px;border-top:1px solid var(--border)} +.source-list,.claim-list{list-style:none;margin:0;padding:0} +.detail-source{position:relative;padding:10px 0 10px 20px;border-left:1px solid var(--line);margin-left:4px} +.detail-source::before{content:"";position:absolute;left:-4px;top:17px;width:7px;height:7px;background:var(--text)} +.detail-source:first-child::before{background:var(--accent)} +.detail-source strong{display:block;font-size:13.5px} +.detail-source pre{margin:6px 0 0;padding:10px;background:var(--bg);border:1px solid var(--border);font-size:11px;color:var(--muted)} +.claim-list .body-content{font-size:16px;margin-bottom:4px} + +/* Feature map: status tags, prototype pages and roadmap lists. Same shape language as lifecycle: + filled square = live, hollow = planned, half = partly live, dash = deliberately not offered. */ +.status-tag{color:var(--muted)} +.status-tag::before{content:"";width:7px;height:7px;flex:none;box-shadow:inset 0 0 0 1.5px currentColor} +.status-tag[data-status="live"]{color:var(--text)} +.status-tag[data-status="live"]::before{background:var(--good);box-shadow:none} +.status-tag[data-status="partial"]{color:var(--text)} +.status-tag[data-status="partial"]::before{box-shadow:inset 0 0 0 1.5px currentColor,inset 3.5px 0 0 currentColor} +.status-tag[data-status="planned"]{border-style:dashed} +.status-tag[data-status="policy"]::before{width:8px;height:1.5px;background:currentColor;box-shadow:none} +.feature-id{font-family:var(--font-mono);font-size:11px;letter-spacing:.04em;color:var(--muted)} +.feature-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:var(--gap);align-items:start} +.feature-grid>.card{margin-top:0} +.feature-grid>.feature-card[data-status="live"]{grid-column:1/-1} +.feature-grid+.card{margin-top:var(--gap)} +.feature-card[data-status="planned"]{border-style:dashed;border-color:var(--line)} +.feature-card[data-status="policy"]{background:var(--bg)} +.feature-card>p{margin:0 0 14px;color:var(--muted);font-size:13.5px} +.blueprint{padding:12px 14px;border:1px dashed var(--line);background:var(--bg)} +.blueprint table{table-layout:auto} +.blueprint th{border-bottom-color:var(--line)} +.blueprint-empty{padding:16px 10px;text-align:center;color:var(--muted);font-family:var(--font-mono);font-size:11.5px} +.blueprint-form{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:4px 14px;align-items:end} +.blueprint-form label{margin:0 0 6px;color:var(--muted);font-weight:500} +.blueprint-form select{width:100%;min-height:38px} +.blueprint-form input:disabled,.blueprint-form select:disabled{border-style:dashed;border-color:var(--line);background:var(--surface);cursor:not-allowed} +.blueprint-form .check-field{align-self:stretch;margin-top:22px} +.blueprint .actions{margin-top:12px} +.blueprint-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px} +.blueprint-stats>div{display:flex;flex-direction:column;gap:2px;padding:10px 12px;border-top:1.5px solid var(--line);background:var(--surface);color:var(--muted);font-size:12.5px} +.blueprint-stats strong{font-family:var(--font-serif);font-size:24px;font-weight:500;line-height:1.2} +.dev-note{margin:12px 0 0} +.dev-note>summary{font-family:var(--font-mono);font-size:11px;letter-spacing:.04em} +.dev-note dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 14px;margin:10px 0 8px;font-size:12.5px} +.dev-note dt{color:var(--muted)} +.dev-note dd{display:flex;flex-wrap:wrap;gap:4px 6px;margin:0} +.dev-note code{padding:0 5px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:11.5px} +.dev-note p{margin:6px 0 0;color:var(--muted);font-size:12.5px} +.roadmap>p{margin:0 0 6px;color:var(--muted);font-size:13px} +.roadmap-list{list-style:none;margin:0;padding:0} +.roadmap-list>li{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 16px;padding:12px 0;border-top:1px solid var(--border)} +.roadmap-list>li:first-child{border-top:0} +.roadmap-list strong{font-weight:600} +.roadmap-list p{margin:2px 0 0;color:var(--muted);font-size:13px} +.roadmap-list .dev-note{grid-column:1/-1;margin-top:6px} +.roadmap-meta,.progress-total{display:flex;align-items:center;gap:8px} +.roadmap-meta{align-self:start} +.feature-progress .section-head>div{gap:16px} +.feature-progress>p{margin:0 0 10px;color:var(--muted);font-size:13px} +.feature-progress td.figure{font-size:14px} + +/* Sign-in: brand story on paper, the form on card stock */ +#main.auth-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(420px,560px);max-width:none;min-height:100vh;padding:0;margin:0;container-type:normal} +.auth-brand{display:flex;flex-direction:column;justify-content:space-between;gap:32px;padding:44px 64px 36px;background:var(--bg)} +.auth-brand-top{display:flex;align-items:center;gap:14px} +.auth-brand-top .eyebrow{margin:0;padding-left:14px;border-left:1px solid var(--line);font-family:var(--font-sans);font-size:12.5px;letter-spacing:0} +.auth-hero{display:flex;align-items:center;gap:48px} +.auth-story{max-width:420px} +.auth-story h2{font-family:var(--font-serif);font-size:52px;line-height:1.25;font-weight:600;letter-spacing:.01em;margin-bottom:18px} .auth-story h2>span{display:block} -.auth-story>p{max-width:365px;line-height:1.85;font-size:13px} -.auth-story .memory-orbit{height:205px;margin:38px auto 0} -.auth-brand-bottom .privacy-note{max-width:440px} -.auth-brand-bottom>small{margin-top:48px;font-size:10px} -.auth-form{display:flex;flex-direction:column;background:var(--bg);min-width:0;padding:24px 40px} -.auth-form>header{display:flex;justify-content:flex-end;min-height:48px} -.form-content{width:100%;max-width:440px;margin:60px auto 48px} -.form-content>.eyebrow{color:var(--accent);font-size:9px;margin-bottom:23px} -.form-content h1{font-size:27px;line-height:1.4;margin-bottom:14px} -.form-content>p{font-size:12px;line-height:1.8;margin-bottom:10px} -.form-content form{margin-top:26px} -.form-content form label{margin-top:22px;margin-bottom:8px;font-size:12px;color:var(--muted)} -.form-content form input{background:var(--surface);font-size:13px;padding:12px;min-height:46px} -.form-content form input[name="otp"]{letter-spacing:6px;font-size:18px;font-variant-numeric:tabular-nums} -.form-content form input[name="code"]{font-family:ui-monospace,SFMono-Regular,monospace} -.form-content form button.primary{width:100%;margin-top:26px;min-height:46px;font-size:13px} +.auth-story>p{font-size:16px;line-height:1.7} +.auth-points{list-style:none;margin:32px 0 0;padding:0;border-top:1.5px solid var(--text)} +.auth-points li{display:flex;align-items:center;gap:14px;padding:14px 0;border-bottom:1px solid var(--border);font-size:15px} +.auth-points .icon{width:20px;height:20px} +.auth-cards{position:relative;flex:none;width:290px;height:230px} +.auth-card{position:absolute;width:262px;height:196px;border:1px solid var(--border);background:#F9F5EC} +.auth-card:nth-child(1){left:28px;top:0} +.auth-card:nth-child(2){left:14px;top:14px;background:#FCF9F2} +.auth-card:nth-child(3){left:0;top:28px;display:flex;flex-direction:column;gap:12px;padding:24px 22px;border-color:var(--line);background:var(--surface);box-shadow:0 14px 30px #1c1b1812} +.auth-card i{display:block;height:7px;background:var(--border)} +.auth-card i:nth-child(1){width:36px;background:var(--accent)} +.auth-card i:nth-child(2){width:88%;margin-top:6px} +.auth-card i:nth-child(3){width:62%} +.auth-card b{position:absolute;right:22px;bottom:20px;width:58px;height:20px;border:1.5px solid var(--accent)} +.auth-brand-bottom .privacy-note{margin:0 0 12px;max-width:520px} +.auth-brand-bottom small{font-size:12px} +.auth-form{display:flex;flex-direction:column;padding:28px 56px;border-left:1px solid var(--border);background:var(--surface)} +.auth-form>header{display:flex;justify-content:flex-end} +.form-content{width:100%;max-width:420px;margin:auto} +.form-content>.eyebrow{margin-bottom:10px} +.form-content h1{margin-bottom:22px} +.form-content>p{font-size:13.5px;line-height:1.7} +.form-content form{margin-top:4px} +.form-content form label:first-of-type{margin-top:0} +.form-content form input{min-height:44px} +.form-content form input[name="otp"]{height:52px;font-family:var(--font-mono);font-size:20px;letter-spacing:.5em} +.form-content form input[name="code"]{font-family:var(--font-mono);font-size:13.5px} +.form-content form button.primary{width:100%;min-height:46px;margin-top:24px;font-size:15px} .form-content form .actions button{width:auto} -.form-content form button[data-password-toggle]{display:block;margin:6px 0 0 auto;border:0;padding:3px 0;min-height:24px;background:none;color:var(--accent);font-size:11px} -.form-links{display:flex;justify-content:space-between;gap:20px;margin-top:24px;font-size:12px} -.form-content>ul{border:1px solid var(--border);border-radius:12px;background:var(--surface);padding:18px 18px 18px 36px;font-size:13px} -.form-content>ul li{margin:10px 0} -.form-content>form+form{margin-top:12px} -.form-content>form+form>button{width:100%} -.auth-footer{margin-top:auto;text-align:center;padding-bottom:8px} -.auth-footer small{font-size:10px} -.qr{display:block;width:220px;max-width:100%;background:#fff;border:1px solid var(--border);border-radius:12px;margin:22px auto;padding:10px} +.form-content form button[data-password-toggle]{display:block;margin:6px 0 0 auto;min-height:0;padding:2px 0;border:0;background:none;color:var(--muted);font-size:12.5px;font-weight:400;text-decoration:underline;text-underline-offset:3px} +.form-content>form+form{margin-top:10px} +.form-content>form+form>button{width:100%;min-height:44px} +.form-content form[action$="/confirm"] button.primary{border-color:var(--accent);background:var(--accent);color:var(--on-accent)} +.form-content>ul{list-style:none;margin:6px 0 16px;padding:0;border-top:1px solid var(--border)} +.form-content>ul>li{position:relative;padding:10px 0 10px 26px;border-bottom:1px solid var(--border);color:var(--text);font-size:14px} +.form-content>ul>li::before{content:"";position:absolute;left:4px;top:15px;width:9px;height:5px;border-left:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg)} +.form-content>code{display:inline-block;margin:0 0 14px;padding:3px 8px;border:1px solid var(--border);background:var(--bg);color:var(--text)} +.form-links{display:flex;justify-content:space-between;gap:16px;margin-top:18px;font-size:13.5px} +.form-links a{text-decoration:underline;text-underline-offset:4px} +.auth-footer{padding:14px 0 0;text-align:center} +.auth-steps{list-style:none;margin:0 0 24px;padding:0;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;font-size:12.5px;color:var(--muted)} +.auth-steps li{display:flex;flex-direction:column;gap:8px} +.auth-steps li::before{content:"";height:3px;background:var(--border)} +.auth-steps li[data-done]{color:var(--text)} +.auth-steps li[data-done]::before{background:var(--text)} +.auth-steps li[aria-current]{color:var(--text);font-weight:600} +.auth-steps li[aria-current]::before{background:var(--accent)} +.totp-setup{display:flex;gap:20px;align-items:flex-start;margin:18px 0 8px} +.qr{display:block;width:168px;max-width:100%;flex:none;padding:8px;background:#fff;border:1px solid var(--line)} .qr svg{display:block;width:100%;height:auto} -.secret{display:block;font-size:13px;padding:14px;border:1px solid var(--border);border-radius:9px;background:var(--surface)} -.error-message{color:var(--bad);border:1px solid var(--bad);padding:14px;border-radius:9px} -.recovery-codes{font-family:ui-monospace,monospace;font-size:12px;padding:12px;background:var(--surface-2);overflow-wrap:anywhere} -/* Focus, dialogs and narrow desktop/zoom reflow. No theme-dependent geometry. */ -.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;padding:0;border:0;margin:-1px} -.skip-link{position:fixed;top:-80px;left:20px;background:var(--surface);padding:10px;z-index:10} -.skip-link:focus{top:8px} -dialog{margin:0 0 0 auto;max-width:min(640px,95vw);width:640px;max-height:100vh;height:100vh;border:0;border-left:1px solid var(--border);background:var(--surface);color:var(--text);padding:28px;overflow:auto;box-shadow:-16px 0 48px #00000012} -dialog::backdrop{background:#12132166} -.dialog-header{display:flex;align-items:center;justify-content:space-between;gap:16px;position:sticky;top:-28px;background:var(--surface);padding:16px 0;z-index:1;margin-bottom:16px} -.dialog-header h2{margin:0} -button.close-button:not(:disabled){width:36px;min-height:36px;padding:6px;border-color:var(--border)} -.detail-meta{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px} -.detail-source{padding:16px 0;border-bottom:1px solid var(--border);overflow-wrap:anywhere} -.detail-source pre{background:var(--surface-2);padding:14px;border-radius:8px;font-size:11px;color:var(--muted)} -details{margin:12px 0} -summary{cursor:pointer;color:var(--accent)} -.loading-card{min-height:180px;display:grid;place-items:center;color:var(--muted)} -@media(max-width:1280px){:root{--gutter:24px}.topbar-tools{gap:8px}.top-search{min-width:112px}.auth-brand{padding:34px}.hero{padding:25px}.hero h2{font-size:22px}.memory-orbit{min-width:190px;width:190px}.columns{grid-template-columns:minmax(0,1.65fr) minmax(270px,1fr)}} -@media(max-width:1100px){.top-search{display:none}.topbar-tools{gap:6px}.columns{grid-template-columns:minmax(0,1fr)}.theme-options{gap:10px}.auth-story h2{font-size:34px}.metric{padding:16px 14px}.hero .memory-orbit{min-width:155px;width:155px}.orbit-caption{font-size:6px}.hero h2{font-size:21px}} -@media(max-width:960px){.metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.breadcrumb>span:first-child{display:none}.theme-options{grid-template-columns:minmax(0,1fr)}.mini-shell{height:150px}.auth-brand{padding:24px}.auth-form{padding:24px}.auth-story h2{font-size:30px}.hero .memory-orbit{display:none}} -@media(max-width:760px){.sidebar{position:static;width:auto;border-right:0;padding:20px}.sidebar>.eyebrow,.account-badge{display:none}.sidebar nav{display:flex;flex-wrap:wrap;gap:4px;margin-top:16px}.sidebar nav h2{width:100%;margin:12px 0 2px}.sidebar nav a{padding:0 9px}.workspace{margin-left:0}.topbar{height:auto;min-height:72px;flex-wrap:wrap;padding:14px 20px}.topbar-tools{flex-wrap:wrap}.account-menu{max-width:140px}#main.auth-layout{grid-template-columns:minmax(0,1fr)}.auth-brand{display:none}.form-content{margin:32px auto}.page-heading{align-items:flex-start}.page-heading>.tag{display:none}.toolbar{flex-wrap:wrap}.toolbar label{flex-basis:100%}.hero h2{font-size:22px}.theme-options{grid-template-columns:minmax(0,1fr)}} -@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} +.totp-key{flex:1;min-width:0;display:flex;flex-direction:column;align-items:flex-start;gap:10px} +.secret{display:block;align-self:stretch;padding:10px 12px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;line-height:1.7;letter-spacing:.06em;word-break:break-all} +.copy-button{min-height:32px;padding:4px 12px;font-size:13px} +.error-message{padding:12px 14px;border-top:1.5px solid var(--bad);background:var(--bg);color:var(--bad)} +.recovery-codes{list-style:none;margin:0 0 8px;padding:0;border-top:1px solid var(--border);font-family:var(--font-mono);font-size:12.5px} +.recovery-codes li{padding:8px 4px;border-bottom:1px solid var(--border);color:var(--text);overflow-wrap:anywhere} +.consent-account{display:flex;align-items:baseline;gap:10px;margin-bottom:16px;padding:12px 0;border-top:1.5px solid var(--text);border-bottom:1px solid var(--border)} +.consent-account p{margin:0;font-size:12.5px} +.consent-account strong{font-weight:600} -/* Functional console forms reuse Layout A geometry and theme tokens. */ -#operation-dialog{width:min(720px,90vw);max-height:90vh}#operation-content{padding:24px}#operation-content form{display:grid;gap:16px}#operation-content label{display:grid;gap:7px}#operation-content textarea{font:inherit;color:var(--text);background:var(--surface);border:1px solid var(--muted);border-radius:10px;padding:12px;resize:vertical;min-height:150px}#operation-content .check-field{display:flex;align-items:center;gap:10px}#operation-content input[type=checkbox]{width:auto}#operation-content .reauth{display:grid;gap:12px;border:1px solid var(--border);border-radius:12px;padding:16px}#operation-content .operation-result{white-space:pre-wrap;overflow-wrap:anywhere;max-height:55vh;overflow:auto;font-size:12px}.action-toolbar{margin:12px 0}.table-scroll{overflow:auto}.table-scroll small{display:block;overflow-wrap:anywhere;max-width:320px}.table-scroll td{vertical-align:top}.table-scroll .actions{flex-wrap:wrap;gap:6px}.columns + .card{margin-top:20px}#operation-content [role=alert]{overflow-wrap:anywhere} +/* Responsive */ +@media(max-width:1180px){.auth-cards{display:none}} +@media(max-width:1100px){.top-search{display:none}.columns{grid-template-columns:minmax(0,1fr)}.feature-grid{grid-template-columns:minmax(0,1fr)}} +@media(max-width:860px){#main.auth-layout{grid-template-columns:minmax(0,1fr)}.auth-brand{display:none}.auth-form{border-left:0;padding:24px 20px}} +@media(max-width:760px){.shell{grid-template-columns:minmax(0,1fr)}.sidebar{position:static;height:auto;border-right:0;border-bottom:1px solid var(--border)}.sidebar>.eyebrow,.account-badge{display:none}.sidebar nav{display:flex;flex-wrap:wrap;gap:4px}.nav-group{display:contents}.nav-group h2{display:none}.topbar{position:relative;height:auto;min-height:56px;flex-wrap:wrap;padding:10px 16px}.page-heading{flex-direction:column;align-items:flex-start}} +@keyframes slide{from{transform:translateX(16px);opacity:0}to{transform:none;opacity:1}} +@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}} diff --git a/web/console/visuals.mjs b/web/console/visuals.mjs index 536a7f8..6b49d3a 100644 --- a/web/console/visuals.mjs +++ b/web/console/visuals.mjs @@ -1,54 +1,443 @@ -// Shared, dependency-free presentation. No API calls, account selection or authorization here. -// Icons are fixed local paths, never markup from a memory or an upstream response. +// Pure console views for the memory workbench. No API calls, account selection or +// authorization here: controllers pass owner-scoped data in and get markup strings out. +// The escaping template tag and the icon set live here too: browsers may load only the +// asset paths allowed by the console ingress (docs/console-ingress.example.yml). + +// Escaping template tag for console markup. Every interpolated value is HTML-escaped +// unless it is itself an html`` fragment or explicitly wrapped with trusted(). +// Arrays are joined; null, undefined and false render as nothing. +const ENTITIES = {'&':'&','<':'<','>':'>','"':'"',"'":'''}; +class Markup { + constructor(value) { this.value = value; } + toString() { return this.value; } +} +const render = value => value === null || value === undefined || value === false ? '' + : Array.isArray(value) ? value.map(render).join('') + : value instanceof Markup ? value.value + : String(value).replace(/[&<>"']/g, c => ENTITIES[c]); +export const html = (strings, ...values) => new Markup(strings.reduce((out, part, i) => out + part + (i < values.length ? render(values[i]) : ''), '')); +/** Mark fixed, locally generated markup (icons, other fragments) as already safe. Never pass user data. */ +export const trusted = value => new Markup(String(value)); + +// Fixed local icon paths: 24px grid, 1.5 stroke, square caps and mitred joins to match the +// console's 2px corners. Never markup from memories or upstream data. const paths = { - brand: '', - overview: '', - memories: '', - summaries: '', - jobs: '', - connections: '', - models: '', - security: '', - audit: '', - storage: '', - appearance: '', - invitations: '', - accounts: '', - search: '', - arrow: '', - close: '', - sun: '', - moon: '', - check: '', - logout: '', - lock: '', + home: '', + library: '', + summaries: '', + jobs: '', + connections: '', + models: '', + security: '', + audit: '', + storage: '', + tasks: '', + resume: '', + privacy: '', + system: '', + invitations: '', + accounts: '', + search: '', + arrow: '', + back: '', + close: '', + plus: '', + source: '', + history: '', + eye: '', + logout: '', + check: '', + lock: '', + copy: '', + info: '', + warning: '', +}; +export const icon = name => ``; + +const svg = name => trusted(icon(name)); +const i18n = (t, key, tag = 'span') => html`<${trusted(tag)} data-i18n="${key}">${t(key)}`; +const safeCount = value => Number.isSafeInteger(value) && value >= 0 ? value : null; +const rowsOf = rows => Array.isArray(rows) + ? rows.map(r => ({value: String(r?.value ?? ''), count: safeCount(r?.count)})).filter(r => r.value && r.count !== null && r.count > 0) + : []; +const fixed = n => Number(n.toFixed(2)); +const preview = value => [...String(value ?? '')].slice(0, 160).join(''); +export const formatDate = value => value ? new Date(typeof value === 'number' && value < 1e12 ? value * 1000 : value).toLocaleString(globalThis.document?.documentElement?.lang || 'zh-CN') : '—'; + +export const typeChip = (t, type = 'fact') => html`${t(type)}`; +export const statusTag = (t, status = 'active') => html`${t(status)}`; +export const emptyState = (t, key = 'empty') => html`
${svg('library')}${i18n(t, key, 'p')}
`; + +/** A memory in a stream: the whole row opens the detail pane. */ +export function memoryRows(rows = [], t) { + if (!rows?.length) return String(emptyState(t)); + return String(html`
    ${rows.map(m => html`
  1. + ${statusTag(t, m.status || 'active')}
  2. `)}
`); +} + +/** Page heading used by every console page. Exactly one H1 per page. */ +export function pageHeading(t, {title, note, actions = ''}) { + return String(html`
${i18n(t, title, 'h1')}${note ? i18n(t, note, 'p') : ''}
${actions ? html`
${trusted(actions)}
` : ''}
`); +} + +function activityStrip(insights, t) { + const days = Array.isArray(insights?.activity) ? insights.activity.filter(d => typeof d?.day === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d.day) && safeCount(d.count) !== null).slice(-30) : []; + if (!days.length) return ''; + const total = days.reduce((n, d) => n + d.count, 0), max = Math.max(1, ...days.map(d => d.count)), H = 44, step = 10; + const bars = days.map((d, i) => { const h = d.count ? Math.max(6, d.count / max * H) : 4; + return html`${d.day} · ${d.count}`; }); + return html`
${i18n(t, 'activityStrip', 'h3')}${total}
+ ${bars} +
${days[0].day.slice(5)}${days.at(-1).day.slice(5)}
`; +} + +function typeBreakdown(insights, t) { + const types = rowsOf(insights?.types).slice(0, 6); + if (!types.length) return ''; + const max = Math.max(...types.map(r => r.count)); + return html`
${i18n(t, 'typesLegend', 'h3')}
    ${types.map(r => html`
  • ${typeChip(t, r.value)} + + ${r.count}
  • `)}
`; +} + +function distributionCategories(insights) { + const categories = rowsOf(insights?.categories); + if (categories.length <= 6) return categories; + // Keep six colours without losing records or renormalizing a top-N subset to 100%. + return [...categories.slice(0, 5), {value: 'otherCategories', count: categories.slice(5).reduce((sum, c) => sum + c.count, 0)}]; +} + +/** Distribution ring: each arc is a real category share of active memories; the centre is the total. */ +function distribution(insights, t) { + const cats = distributionCategories(insights), total = cats.reduce((n, c) => n + c.count, 0); + const R = 84, C = 2 * Math.PI * R, gap = cats.length > 1 ? 3 : 0; // square ends: a hairline of paper between arcs + let offset = 0; + const arcs = cats.map((c, i) => { const len = Math.max(0.1, c.count / total * C - gap), arc = html`${t(c.value)} · ${c.count}`; offset += c.count / total * C; return arc; }); + return html`
+ ${arcs} + ${total}${t('activeMemories')} + ${cats.length ? '' : html`
${i18n(t, 'radarEmpty')}
`}
`; +} +function distributionLegend(insights, t) { + const cats = distributionCategories(insights), total = cats.reduce((n, c) => n + c.count, 0); + if (!cats.length) return ''; + return html`
    ${cats.map((c, i) => html`
  1. ${i18n(t, c.value)}${c.count}${Math.round(c.count / total * 100)}%
  2. `)}
`; +} + +/** Overview: distribution and search first, then counts, recent stream and pipeline. Never invents trends. */ +export function overviewView(data, {t, memoryRows: rows = list => memoryRows(list, t)}) { + const count = key => safeCount(data.counts?.[key]) === null ? '—' : data.counts[key].toLocaleString(); + const metric = (key, title, href) => html`${i18n(t, title)}${count(key)}`; + const stage = (href, glyphName, title, note, key) => html`${svg(glyphName)}${i18n(t, title)}${t(note)}${count(key)}`; + return String(html`

${i18n(t, 'radarLabel')}

${i18n(t, 'radarTitle', 'h2')}${i18n(t, 'radarNote', 'p')} + + ${distributionLegend(data.insights, t)}
${distribution(data.insights, t)}
+
${metric('memories', 'memoryCount', '/app/memories')}${metric('sources', 'sourceCount', '/app/memories?focus=sources')}${metric('summaries', 'summaryCount', '/app/summaries')}${metric('jobs', 'jobCount', '/app/jobs')}
+
+
${i18n(t, 'recentStream', 'h2')}${i18n(t, 'openLibrary')}${svg('arrow')}
+ ${trusted(rows(data.recent || []))}${i18n(t, 'inspectMemoryNote', 'p')}
+
`); +} + +/** Library: filters, a four-column table, and a pager. The detail opens in the side pane. */ +export function libraryView(t, {data, query = '', searchMode = 'lexical', category = '', status = '', categories = [], focusSources = false, readOnly = false, pagination = ''}) { + const option = (value, key, current) => html``; + const rows = data.results || []; + const table = rows.length ? html`
+ + ${rows.map(m => html` + `)}
${i18n(t, 'memories')}${i18n(t, 'category')}${i18n(t, 'status')}${i18n(t, 'created')}
${t(m.category || 'uncategorized')}${statusTag(t, m.status || 'active')}
` + : emptyState(t); + return String(html` +
${focusSources ? html`

${i18n(t, 'inspectSourcesNote')}

` : ''} + ${data.truncated || data.retrieval?.window_limited ? html`

${i18n(t, 'boundedSearchNote')} (${data.retrieval?.candidate_limit})

` : ''} +
${table}
+
`); +} + +export function summariesView(t, {data, pagination = ''}) { + const cats = (data.categories || []).filter(c => safeCount(c.count) !== null); + const max = Math.max(1, ...cats.map(c => c.count)); + const summaries = data.summaries || []; + return String(html`
+
${i18n(t, 'summaryIndex', 'h2')}${cats.length ? html`` : emptyState(t)}
+
${i18n(t, 'summaryList', 'h2')}${summaries.length ? html`
    ${summaries.map(s => { + const body = html`${t(s.category)}${i18n(t, 'revisions')} ${s.revision}${i18n(t, 'sourceCount')} ${s.coverage}${s.summary_id}`; + return html`
  1. ${s.status === 'current' + ? html`` + : html``}${html`${t(s.status)}`}
  2. `; + })}
` : emptyState(t)}${trusted(pagination)}
`); +} + +export function auditView(t, {entries = [], pagination = ''}) { + if (!entries.length) return String(emptyState(t)); + return String(html`
${i18n(t, 'auditTimeline', 'h2')}
+
    ${entries.map(e => html`
  1. +
    ${e.action}${e.outcome || '—'}${e.audit_id ? html`${e.audit_id}` : ''}
  2. `)}
${trusted(pagination)}
`); +} + +/** Memory detail for the side pane. `actions` is markup built from fixed action buttons. */ +export function memoryDetailView(t, data, {actions = '', canGoBack = false}) { + const m = data.memory || {}, content = String(m.content ?? ''), length = [...content].length; + const sources = data.source_manifest?.sources || []; + const lifecycle = m.lifecycle || {}; + const links = [[lifecycle.supersedes_memory_id, 'previousRecord'], [lifecycle.superseded_by_memory_id, 'replacementRecord']].filter(([id]) => id); + return String(html`
${typeChip(t, m.memory_type || 'fact')}${i18n(t, 'revisions')} ${data.revision}${statusTag(t, m.status || 'active')}
+ ${m.status === 'active' && actions ? html`
${trusted(actions)}
` : ''} +
${content}
+

${i18n(t, 'contentRange')} ${data.content_offset + 1}–${data.content_offset + length} / ${data.content_length} ${data.content_complete ? i18n(t, 'endOfContent') : ''}

+ + ${links.length ? html`` : ''} +
${i18n(t, 'sources', 'h3')}${sources.length ? html`
    ${sources.map(s => html`
  1. ${s.source_kind || s.source_id}${s.source_id} +
    ${i18n(t, 'sourceMetadata')}
    ${JSON.stringify(s, null, 2)}
  2. `)}
` : emptyState(t)} + ${data.next_source_request ? html`` : ''}
`); +} + +export function summaryDetailView(t, data, {canGoBack = false}) { + const summary = data.results?.[0] || {claims: []}; + return String(html`
${t(summary.category)}${i18n(t, 'revisions')} ${summary.revision}
+ ${summary.claims?.length ? html`
    ${summary.claims.map(c => html`
  1. ${c.quote}

    +
  2. `)}
` : emptyState(t)} + ${i18n(t, data.complete ? 'endOfContent' : 'next', 'p')} + `); +} + +/* Feature map: every menu page lists what it offers today and what is still planned, so the + * placeholders, the progress table, the docs and the tests all come from one list. + * Field rules and the path from planned to live: docs/console-feature-standard.md. + * status live: backed by a real endpoint · planned: placeholder only · policy: deliberately not on the web + * read console-api views it reads · write: console actions it performs + * core existing Core API a planned feature will wrap · scope: the Core scopes those APIs check + * reauth a write needs the current password and an unused TOTP · operator: platform operators only + * ui wireframe of a planned feature: table columns, form fields (type:key), buttons, stat tiles + * Titles and notes are catalog keys derived from the ID (featureKey). IDs are never reused. */ +export const featureMap = { + overview: [ + {id: 'OVW-01', status: 'live', read: ['overview']}, + {id: 'OVW-02', status: 'live', read: ['overview']}, + {id: 'OVW-03', status: 'planned', read: ['attention']}, + ], + memories: [ + {id: 'MEM-01', status: 'live', read: ['memories']}, + {id: 'MEM-02', status: 'live', read: ['memory', 'memory-meta']}, + {id: 'MEM-03', status: 'live', write: ['memory.create']}, + {id: 'MEM-04', status: 'live', write: ['memory.correct', 'memory.retract']}, + {id: 'MEM-05', status: 'live', write: ['memory.classify', 'memory.sensitivity', 'memory.visibility']}, + {id: 'MEM-06', status: 'planned', write: ['memory.batch_classify', 'memory.batch_retract']}, + {id: 'MEM-07', status: 'planned', read: ['memory']}, + ], + summaries: [ + {id: 'SUM-01', status: 'live', read: ['summaries', 'summary']}, + {id: 'SUM-02', status: 'live', write: ['jobs.schedule']}, + {id: 'SUM-03', status: 'planned', read: ['taxonomy'], write: ['taxonomy.save']}, + ], + tasks: [ + {id: 'TSK-01', status: 'live', read: ['projects']}, + {id: 'TSK-02', status: 'planned', read: ['task-branches'], core: ['POST /v1/task-branches/preview'], scope: ['resume:read'], + ui: {table: ['taskTitle', 'sourceBranch', 'lastCheckpoint', 'state']}}, + {id: 'TSK-03', status: 'planned', read: ['project-context'], core: ['POST /v1/project-context/preview'], scope: ['resume:read'], + ui: {form: ['select:project'], submit: 'generatePreview'}}, + {id: 'TSK-04', status: 'planned', read: ['task-checkpoints'], core: ['GET /v1/tasks/{task_id}/checkpoints', 'GET /v1/tasks/{task_id}/canonical-revisions'], + scope: ['memory:read', 'task:reconcile:read'], ui: {table: ['checkpoint', 'sources', 'created']}}, + {id: 'TSK-05', status: 'planned', write: ['projects.bootstrap', 'tasks.bootstrap'], core: ['POST /v1/project-bootstrap/preview', 'POST /v1/task-bootstrap/preview'], + scope: ['project:bootstrap:preview', 'project:bootstrap:confirm', 'task:bootstrap:preview', 'task:bootstrap:confirm'], ui: {actions: ['newProject', 'newTask']}}, + {id: 'TSK-06', status: 'planned', read: ['task-reconciliation'], write: ['tasks.reconcile'], + core: ['POST /v1/tasks/{task_id}/reconciliation/run', 'POST /v1/task-reconciliations/{id}/resolve'], scope: ['task:reconcile:read', 'task:reconcile:confirm'], + ui: {table: ['taskTitle', 'proposal', 'state'], actions: ['runReconciliation']}}, + ], + resume: [ + {id: 'RES-01', status: 'planned', read: ['resume-preview'], core: ['POST /v1/resume/preview'], scope: ['resume:read'], + ui: {form: ['select:project', 'select:task', 'select:sourceBranch'], submit: 'previewResume'}}, + {id: 'RES-02', status: 'planned', write: ['resume.confirm'], core: ['POST /v1/resume/{resume_id}/confirm'], scope: ['resume:confirm'], + ui: {actions: ['confirmResume']}}, + {id: 'RES-03', status: 'planned', read: ['resume-deliveries'], core: ['GET /v1/resume/{resume_id}/injection-status', 'GET /v1/resume/{resume_id}/delivery-receipt-status'], + scope: ['resume:read'], ui: {table: ['resumeId', 'targetAgent', 'deliveryState', 'completionAck']}}, + {id: 'RES-04', status: 'planned', read: ['resume-history'], scope: ['resume:read'], ui: {table: ['created', 'taskTitle', 'state']}}, + ], + jobs: [ + {id: 'JOB-01', status: 'live', read: ['jobs', 'job']}, + {id: 'JOB-02', status: 'live', write: ['jobs.schedule']}, + {id: 'JOB-03', status: 'live', write: ['jobs.cancel', 'jobs.retry']}, + ], + connections: [ + {id: 'CON-01', status: 'live', read: ['connections'], write: ['oauth.revoke']}, + {id: 'CON-02', status: 'live', read: ['connections'], + write: ['connections.create', 'connections.update', 'connections.rotate', 'connections.disable', 'connections.enable', 'connections.revoke']}, + {id: 'CON-03', status: 'live', read: ['connections'], write: ['devices.revoke'], reauth: true}, + {id: 'CON-04', status: 'planned', read: ['capture-status'], core: ['GET /v1/status'], scope: ['memory:read']}, + {id: 'CON-05', status: 'planned', write: ['devices.register', 'devices.rotate'], core: ['POST /v1/agent-instances/register', 'POST /v1/agent-instances/{id}/rotate-key'], + scope: ['admin:devices'], reauth: true}, + ], + models: [ + {id: 'MOD-01', status: 'live', read: ['models'], write: ['models.save', 'models.disable']}, + {id: 'MOD-02', status: 'live', write: ['models.test']}, + {id: 'MOD-03', status: 'live', write: ['vector.schedule']}, + {id: 'MOD-04', status: 'planned', read: ['model-usage']}, + ], + privacy: [ + {id: 'PRV-01', status: 'live', read: ['models']}, + {id: 'PRV-02', status: 'planned', read: ['privacy-defaults'], write: ['privacy.defaults'], + ui: {form: ['select:defaultSensitivity', 'check:defaultWebVisibility'], submit: 'save'}}, + {id: 'PRV-03', status: 'planned', read: ['retention'], write: ['retention.save'], core: ['GET /v1/retention', 'PUT /v1/retention'], scope: ['admin:retention'], + ui: {form: ['number:eventRetentionDays', 'number:checkpointRetentionDays'], submit: 'save'}}, + {id: 'PRV-04', status: 'planned', write: ['retention.prune'], core: ['POST /v1/retention/prune'], scope: ['admin:retention'], reauth: true, + ui: {actions: ['pruneNow']}}, + {id: 'PRV-05', status: 'policy'}, + {id: 'PRV-06', status: 'live', read: ['capabilities'], write: ['memory.web_policy']}, + ], + security: [ + {id: 'SEC-01', status: 'live', write: ['security.password'], reauth: true}, + {id: 'SEC-02', status: 'live', write: ['security.totp.begin', 'security.totp.complete'], reauth: true}, + {id: 'SEC-03', status: 'live', write: ['security.recovery_codes'], reauth: true}, + {id: 'SEC-04', status: 'live', read: ['security'], write: ['security.session.revoke', 'security.sessions.revoke_others']}, + {id: 'SEC-05', status: 'planned', read: ['login-history']}, + ], + audit: [ + {id: 'AUD-01', status: 'live', read: ['audit']}, + {id: 'AUD-02', status: 'planned', read: ['audit']}, + ], + storage: [ + {id: 'STO-01', status: 'live', read: ['export'], write: ['storage.export']}, + {id: 'STO-02', status: 'live', write: ['storage.import']}, + {id: 'STO-03', status: 'live', read: ['storage']}, + {id: 'STO-04', status: 'policy'}, + ], + invitations: [ + {id: 'INV-01', status: 'live', read: ['invitations'], operator: true}, + {id: 'INV-02', status: 'live', write: ['invitations.issue'], reauth: true, operator: true}, + {id: 'INV-03', status: 'live', write: ['invitations.revoke', 'invitations.revoke_batch'], reauth: true, operator: true}, + ], + accounts: [ + {id: 'ACC-01', status: 'live', read: ['accounts'], operator: true}, + {id: 'ACC-02', status: 'live', write: ['accounts.disable', 'accounts.enable'], reauth: true, operator: true}, + {id: 'ACC-03', status: 'live', write: ['accounts.role'], reauth: true, operator: true}, + {id: 'ACC-04', status: 'policy'}, + ], + system: [ + {id: 'SYS-01', status: 'live', read: ['capabilities'], operator: true}, + {id: 'SYS-02', status: 'planned', read: ['system-health'], core: ['GET /v1/status', 'GET /readyz'], operator: true, + ui: {stats: ['svcCore', 'svcWeb', 'svcAuth', 'svcWorker', 'svcVector']}}, + {id: 'SYS-03', status: 'planned', read: ['system-version'], operator: true, ui: {stats: ['releaseVersion', 'schemaVersion', 'runtimeVersion']}}, + {id: 'SYS-04', status: 'planned', read: ['backups'], operator: true, ui: {table: ['backupTime', 'backupSize', 'backupVerified']}}, + ], }; -export const icon = name => ``; -const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); -const label = (t, key, tag = 'span') => `<${tag} data-i18n="${key}">${esc(t(key))}`; -export const orbit = () => ``; - -export function overviewView(data, {t, memoryRows}) { - const l = (key, tag) => label(t, key, tag); - // The reference's trends and connected/completed badges were demo data. Never invent them. - const count = key => Number.isSafeInteger(data.counts?.[key]) && data.counts[key] >= 0 ? data.counts[key].toLocaleString() : '—'; - return `

${l('heroLabel')}

${l('connectionHeadline')}

${l('connectionDescription','p')}${l('manageConnections')}${icon('arrow')}
${orbit()}
-
${[['memories','memoryCount','memories'],['sources','sourceCount','audit'],['summaries','summaryCount','summaries'],['jobs','jobCount','jobs']].map(([key,title,glyph]) => `
${l(title)}${icon(glyph)}
${count(key)}${l('ownedRecords','small')}
`).join('')}
-

${l('recentLabel')}

${l('recent','h2')}
${l('viewAll')} ${icon('arrow')}
${memoryRows(data.recent)}${l('inspectMemoryNote','p')}
-

${l('processingLabel')}

${l('memoryProcessing','h2')}
${icon('jobs')}
- ${icon('memories')}
${l('memoryCount')}${l('atomicNote')}
${count('memories')}
- ${icon('summaries')}
${l('summaryCount')}${l('derivedNote')}
${count('summaries')}
- ${icon('jobs')}
${l('jobCount')}${l('jobStatusNote')}
${count('jobs')}
-
${icon('security')}${l('summaryBoundary','p')}
${l('viewJobs')} ${icon('arrow')}
`; -} - -export function appearanceView(t) { - const l = (key, tag) => label(t, key, tag); - const pick = (property, value, content, className) => ``; - const mini = ``; - return `

${l('personalizeLabel')}

${l('theme','h2')}
${l('fixedLayout')}
${l('appearanceNote','p')} -
${['a','b','c'].map(theme => pick('theme',theme,`${mini}${l(`themeName_${theme}`)}${l(`themeNote_${theme}`)}${icon('check')}`,'theme-option')).join('')}
-
${l('mode','h2')}${icon('sun')}
${l('modeNote','p')}
${['light','dark'].map(mode => pick('mode',mode,`${icon(mode==='light'?'sun':'moon')}${l(mode)}`,'segment')).join('')}
-
${l('language','h2')}
${l('languageNote','p')}
${pick('locale','zh-CN','简体中文','segment')}${pick('locale','en','English','segment')}
-
${icon('security')}${l('appearanceScopeNote','p')}
`; +/** Catalog keys for a feature: OVW-01 → featOVW01 (title) and featOVW01Note (description). */ +export const featureKey = id => `feat${id.replace('-', '')}`; +/** Menu destinations whose content is composed from the feature map. */ +export const prototypePages = ['tasks', 'resume', 'privacy', 'system']; +/** Page badge: live when nothing is planned, planned when nothing is live, partial otherwise. */ +export function pageState(page) { + const states = (featureMap[page] || []).map(f => f.status); + return !states.includes('live') ? 'planned' : states.includes('planned') ? 'partial' : 'live'; +} + +export const featureStatus = (t, status) => html`${i18n(t, `featureStatus_${status}`)}`; +const stateDot = (t, state) => html`${i18n(t, state)}`; +const sectionNote = (t, key) => html`

${i18n(t, key)}

`; + +/** Developer notes on a planned feature: the contract it needs and the standard it follows. */ +function devNote(t, f) { + const rows = [['contractRead', (f.read || []).map(view => `console-api/${view}`)], ['contractWrite', f.write || []], ['contractCore', f.core || []], ['contractScope', f.scope || []]] + .filter(([, values]) => values.length); + const flags = [f.reauth && 'contractReauth', f.operator && 'contractOperator'].filter(Boolean); + return html`
${i18n(t, 'devNotes')} · ${f.id} +
${rows.map(([key, values]) => html`
${i18n(t, key)}
${values.map(value => html`${value}`)}
`)}
+ ${flags.length ? html`

${flags.map((key, i) => html`${i ? ' · ' : ''}${i18n(t, key)}`)}

` : ''} +

${i18n(t, 'featureStandard')} docs/console-feature-standard.md

`; +} + +function control(t, spec) { + const [type, key] = spec.split(':'); + if (type === 'check') return html``; + if (type === 'select') return html``; + return html``; +} +/** Wireframe of a planned feature: the tiles, table, fields and buttons it will have, all disabled. */ +function wireframe(t, ui = {}) { + const buttons = [...(ui.submit ? [html``] : []), + ...(ui.actions || []).map(key => html``)]; + return html`
+ ${ui.stats ? html`
${ui.stats.map(key => html`
${i18n(t, key)}—
`)}
` : ''} + ${ui.table ? html`
${ui.table.map(key => html``)} +
${i18n(t, key)}
${i18n(t, 'plannedPlaceholder')}
` : ''} + ${ui.form ? html`
${ui.form.map(spec => control(t, spec))}
` : ''} + ${buttons.length ? html`
${buttons}
` : ''}
`; +} + +function featureCard(t, f, body = '') { + const key = featureKey(f.id); + return html`
+
${i18n(t, key, 'h2')}
${featureStatus(t, f.status)}${f.id}
+ ${i18n(t, `${key}Note`, 'p')}${f.status === 'planned' ? html`${wireframe(t, f.ui)}${devNote(t, f)}` : body}
`; +} + +/** TSK-01: the account's projects, name and ID only. */ +function projectList(t, data) { + if (data.unavailable) return sectionNote(t, 'unavailable'); + const rows = Array.isArray(data.projects) ? data.projects : []; + if (!rows.length) return emptyState(t, 'noProjects'); + return html`
+ ${rows.map(p => html``)}
${i18n(t, 'projectName')}${i18n(t, 'projectId')}
${p.name || '—'}${p.project_id}
`; +} +/** PRV-01: which models may receive memory content or search queries. Changes stay on the models page. */ +function egressSummary(t, data) { + if (data.unavailable) return sectionNote(t, 'unavailable'); + const models = Array.isArray(data.models) ? data.models : []; + const yes = value => i18n(t, value === true ? 'yes' : 'no'); + return html`
+ ${models.map(m => html``)}
${i18n(t, 'modelKind')}${i18n(t, 'state')}${i18n(t, 'egressAllowed')}${i18n(t, 'queryAllowed')}
${i18n(t, m.kind)}${stateDot(t, m.config?.enabled ? 'enabled' : 'disabled')}${yes(m.config?.egress_approved)}${yes(m.config?.query_approved)}
+ `; +} +/** PRV-06: whether ChatGPT reads every non-secret memory or only per-revision grants. */ +function readScope(t, data, caps) { + const policy = data.web_policy; + if (!policy) return sectionNote(t, 'unavailable'); + const on = policy.read_all === true, can = Array.isArray(caps.allowed_actions) && caps.allowed_actions.includes('memory.web_policy'); + return html`
${stateDot(t, on ? 'enabled' : 'disabled')}${i18n(t, 'webReadAll')}
${i18n(t, on ? 'webReadAllOn' : 'webReadAllOff', 'p')} + ${can ? html`
` : sectionNote(t, 'viewWithoutWrite')}`; +} +/** SYS-01: platform switches exactly as the server reports them in its capabilities. */ +function platformSwitches(t, caps) { + const rows = [['sysConsoleOperations', caps.enabled], ['sysCoreWritable', caps.writable], ['sysInvitations', caps.management?.invitations], ['sysAccounts', caps.management?.accounts], + ['sysRoles', caps.management?.roles], ['sysConnections', caps.connection_management?.enabled], ['sysRecovery', caps.recovery_configured], ['sysMaintenance', caps.maintenance_enabled]]; + return html``; +} +/** Development progress across the whole feature map, per page. */ +function featureProgress(t) { + const statuses = ['live', 'planned', 'policy'], count = (list, status) => list.filter(f => f.status === status).length, all = Object.values(featureMap).flat(); + return html`
${i18n(t, 'featureProgress', 'h2')} +
${statuses.map(s => html`${featureStatus(t, s)}${count(all, s)}`)}
${i18n(t, 'featureProgressNote', 'p')} +
${statuses.map(s => html``)} + ${Object.entries(featureMap).map(([page, list]) => html`${statuses.map(s => html``)}`)}
${i18n(t, 'featurePage')}${i18n(t, `featureStatus_${s}`)}
${i18n(t, page)}${count(list, s) || '—'}
`; +} + +/** Prototype pages list live features first, then planned ones, then what is deliberately not offered. */ +export const prototypeOrder = page => [...(featureMap[page] || [])].sort((a, b) => ['live', 'planned', 'policy'].indexOf(a.status) - ['live', 'planned', 'policy'].indexOf(b.status)); +/** New menu destinations: one card per feature. Live cards show real data; planned ones a wireframe. */ +export function prototypeView(t, page, {data = {}, caps = {}} = {}) { + const live = {'TSK-01': () => projectList(t, data), 'PRV-01': () => egressSummary(t, data), 'PRV-06': () => readScope(t, data, caps), 'SYS-01': () => platformSwitches(t, caps)}; + const cards = prototypeOrder(page).map(f => featureCard(t, f, f.status === 'live' ? live[f.id]?.() ?? '' : '')); + return String(html`
${cards}
${page === 'system' ? featureProgress(t) : ''}`); +} + +/** Existing pages: their planned and policy features as one compact list under the live content. */ +export function roadmapCard(t, page) { + const items = (featureMap[page] || []).filter(f => f.status !== 'live'); + if (!items.length) return ''; + return String(html`
${i18n(t, 'roadmapTitle', 'h2')}
${i18n(t, 'roadmapNote', 'p')} +
    ${items.map(f => html`
  1. ${i18n(t, featureKey(f.id), 'strong')}${i18n(t, `${featureKey(f.id)}Note`, 'p')}
    +
    ${featureStatus(t, f.status)}${f.id}
    ${f.status === 'planned' ? devNote(t, f) : ''}
  2. `)}
`); }