diff --git a/apps/README.md b/apps/README.md index 73dd4540..9a332793 100644 --- a/apps/README.md +++ b/apps/README.md @@ -65,13 +65,13 @@ OctoScript-App-Design-Flow: | App | Id | What it does | Capabilities (manifest) | Network hosts (manifest) | Host services | | --- | --- | --- | --- | --- | --- | -| [News](news/bundle) | `os.news` | Hacker News, TechMeme and Google News feeds in tabs (Today, HN, TechMeme, Google, Saved), with a reader for stories | `storage`, `net`, `images`, `web` | `hn.algolia.com`, `www.techmeme.com`, `news.google.com` | none | -| [Photos](photos/bundle) | `os.photos` | A sample library: moments, albums, people, favorites, a grid with selection, a full-screen viewer | `storage` | none | none (full-size files come from a shell asset mount, see below) | -| [Maps](maps/bundle) | `os.maps` | `MapView` map, place search, places, routes with a changeable start and up to two stops, and a drive mode with turn-by-turn and a 2D/3D view; starts at the device's GPS fix when there is one | `storage`, `net`, `location` | `photon.komoot.io`, `router.project-osrm.org`, `overpass-api.de`, `overpass.kumi.systems`, `maps.mail.ru`, `overpass.openstreetmap.fr` | none | -| [Camera](camera/bundle) | `os.camera` | Photo and video over the runtime's `CameraPreview` widget, flash and zoom, a thumbnail of the last shot and a viewer | `storage`, `camera`, `microphone`, `library` | none | none | +| [News](news/bundle) | `os.news` | Hacker News, TechMeme and Google News feeds in tabs (Today, HN, TechMeme, Google, Saved), with a reader for stories | `storage`, `net`, `images`, `web`, `news`, `glance` | `hn.algolia.com`, `www.techmeme.com`, `news.google.com`, `api.gdeltproject.org`, `feeds.bbci.co.uk`, `feeds.npr.org`, `www.theguardian.com`, `feeds.arstechnica.com` | [`news`](news/host-service) | +| [Photos](photos/bundle) | `os.photos` | A sample library: moments, albums, people, favorites, a grid with selection, a full-screen viewer | `storage`, `glance` | none | none (full-size files come from a shell asset mount, see below) | +| [Maps](maps/bundle) | `os.maps` | `MapView` map, place search, places, routes with a changeable start and up to two stops, and a drive mode with turn-by-turn and a 2D/3D view; starts at the device's GPS fix when there is one | `storage`, `net`, `location`, `glance` | `photon.komoot.io`, `router.project-osrm.org`, `overpass-api.de`, `overpass.kumi.systems`, `maps.mail.ru`, `overpass.openstreetmap.fr` | none | +| [Camera](camera/bundle) | `os.camera` (Home) | Photo and video over the runtime's `CameraPreview` widget, flash and zoom, a thumbnail of the last shot and a viewer | `storage`, `camera`, `microphone`, `library`, `glance` | none | none | | [Mail](mail/bundle) | `os.mail` | Accounts, folders, message list, reader (HTML rebuilt by the service) and composer; its agent puts notice cards on the glance screen (`mail.notify`) | `storage`, `mail`, `glance` | none (the service connects, not the app) | [`mail`](mail/host-service) | | [AI providers](ai-providers/bundle) | `os.ai-providers` | The assistant's LLM providers: a primary and fallbacks, each with a model pull-down from octos's catalog and Test connection; an add wizard (family, model, route, key, test); Show QR for phone and import by camera, image or paste | `storage`, `llm` | none (the service connects, not the app) | [`llm`](ai-providers/host-service) | -| [YouTube](youtube/bundle) | `os.youtube` | YouTube search (the runtime's keyless `sys.video`, which reads YouTube's own results page), result rows with thumbnails and LIVE or length badges, topic chips, playback of YouTube's mobile watch page in `WebReader`, and a history of what was played on this device | `storage`, `net` | `www.youtube.com`, `m.youtube.com`, `i.ytimg.com` | none | +| [YouTube](youtube/bundle) | `os.youtube` | YouTube search (the runtime's keyless `sys.video`, which reads YouTube's own results page), result rows with thumbnails and LIVE or length badges, topic chips, playback of YouTube's mobile watch page in `WebReader`, and a history of what was played on this device | `storage`, `net`, `glance` | `www.youtube.com`, `m.youtube.com`, `i.ytimg.com` | none | | [Calendar](calendar/bundle) | `os.calendar` (desktop) | Its agent keeps the person's events and puts event and agenda cards on the glance screen; its own window cannot list the events yet (it needs an App Hub `calendar` capability) | `storage`, `glance` | none | [`calendar`](calendar/host-service) (for Calendar's agent only) | | [AppCard](appcard) | native, opt-in | The AppCard assistant: a routing brain picks or composes an app agent, which generates a live Splash or webview card. Shells link it only with `app-appcard`; not shipped by default | n/a (not a bundle) | n/a | the shell's octos kernel | @@ -79,7 +79,9 @@ What each capability means is defined by App Hub's closed list (`KNOWN_CAPABILITIES` in `crates/app-policy/src/manifest.rs`): `images` shows pictures from any public https host, `web` opens a page in the system WebView, `library` offers captures to the system photo library, `mail` reaches the -host's mail service, `llm` reaches the host's LLM-provider service. `net` reaches only the hosts the manifest lists. +host's mail service, `llm` reaches the host's LLM-provider service, `news` +reads the host's news service, `glance` publishes cards to the glance +screen. `net` reaches only the hosts the manifest lists. ### Status and known gaps @@ -302,13 +304,12 @@ isolate under no app's policy. Calls from the sheet arrive marked | File | Role | | --- | --- | -| `lib.rs` | the service: `mail.accounts`, `add_account` (raises the sign-in sheet), `remove_account`, `folders`, `sync`, `list`, `message`, `mark_read`, `send`, `notify` (the agent's notice card, published through the shell's glance service as Mail); `register()`, `register_demo()`, `register_with*()`; the `Transport` trait; account events for the shell (`on_account_event`) | +| `lib.rs` | the service: `mail.accounts`, `add_account` (raises the sign-in sheet), `remove_account`, `folders`, `sync`, `list`, `message`, `mark_read`, `send`, `notify` (the agent's notice: handed to the shell, which publishes its notice card as Mail, `on_notify`); `register()`, `register_demo()`, `register_with*()`; the `Transport` trait; account events for the shell (`on_account_event`) | | `imap.rs` | IMAP client (folders, read flag back to the server) | | `network.rs` | POP3 and SMTP, MIME decoding; credentials never appear in errors | | `html.rs` | rebuilds a message as the few tags Mail's `Html` view draws, with nothing remote in it | | `vault.rs` | where passwords go: macOS/iOS Keychain, Android (a file sealed with an Android Keystore key), owner-only file elsewhere, the files in the host's secrets folder `/secrets/os.mail/`; `OCTOSENSE_MAIL_VAULT=file` forces the file store for unsigned dev builds | | `contacts.rs` | the addresses the person's accounts sent mail to, for the approval rule "recipients in my contacts" (off until the person turns it on in Settings) | -| `resources/notice.card` | the fixed L0 notice card `mail.notify` fills | Account metadata (no passwords) and fetched mail live under the host's own directory (`/mail`), outside every app's jail. Each account is @@ -361,9 +362,11 @@ model lane and tools. Which system apps have one, and how | App | `manifest.json` | `tools.json` | Cards | | --- | --- | --- | --- | -| News | `agent` block | `news.list`, `news.read` (read, shareable) | – | -| Mail | `agent` block, `glance`, `storage.accounts` (the agent acts for the signed-in account) | `mail.notify` (act, background) | `notice.card` | +| News | `agent` block, `glance` | `news.list`, `news.read` (read, shareable), `news.notify` (act, background) | the shell's notice card | +| Mail | `agent` block, `glance`, `storage.accounts` (the agent acts for the signed-in account) | `mail.notify` (act, background) | the shell's notice card | | Calendar | `agent` block, `glance` | `calendar.events` (read), `calendar.add_event` (act), `calendar.remove_event` (destructive, `confirm: host`), `calendar.notify`, `calendar.agenda` (act) | `event.card`, `agenda.card` | +| Photos, Maps, YouTube, Camera | `agent` block, `glance` | `photos.notify`, `maps.notify`, `youtube.notify`, `camera.notify` (act, background) | the shell's notice card | +| AI providers | none | none yet: App Hub takes a tool namespace only as `[a-z0-9_]` (and octos a tool name's segments only as `[a-z][a-z0-9_]`), so `ai-providers.notify` is refused | – | - **Declaring one.** The manifest's `agent` block names the kernel tools the agent may use (`"tools": ["ask_user_question"]`; a dotted name there asks @@ -378,7 +381,11 @@ model lane and tools. Which system apps have one, and how panel, with the bar's "Ask ", Shift+F8 or the menu row "Ask this app's agent", or when the system agent asks it with `agents.ask`). Then the shell prepares the peer, so the system agent can reach it with - `peer_send_input`. A turn starts only when the system agent, the person + `peer_send_input`. `agents.ask` waits for the person's answer and the + peer (it is declared `outward` with `confirm: app`, so the kernel holds + it as long as an approval, not a read tool's 30 s), then gives the system + agent the peer's slug, so the request goes on in the same turn. A turn + starts only when the system agent, the person or a card's in-card chat asks: there are no triggers or schedules yet (ADR 0002 M3, planned). - **Talking to it yourself.** The person can chat with the app's agent @@ -393,14 +400,25 @@ model lane and tools. Which system apps have one, and how shell's relay checked the grant, the schema and the budget. octos asks for an approval only for destructive and outward tools (here `calendar.remove_event`), which the person answers on a shell sheet. -- **Cards.** `mail.notify`, `calendar.notify` and `calendar.agenda` fill a - fixed L0 card the host service ships and publish it with `notify` through - the shell's `glance` service as the app (the app needs the `glance` - capability). The model only supplies the text; it never writes card code. +- **Cards.** `.notify {title, body, card_id?, priority?}` puts a + notice on the glance screen, with a notification: one fixed L0 card for + every app, which the shell ships + ([`../crates/shell/resources/glance/notice.card`](../crates/shell/resources/glance/notice.card), + filled by [`../crates/shell/src/glance_notice.rs`](../crates/shell/src/glance_notice.rs)), + with the app's icon and name, the time, and the agent's title (at most 80 + characters) and text (at most 600); the same `card_id` replaces the app's + earlier notice. Mail's and News's services hand `notify` to the shell; + Photos, Maps, YouTube and Camera have no service of their own, so the + shell's notice service answers it. `calendar.notify` and + `calendar.agenda` fill Calendar's own event and agenda cards. Every card + is published with `notify` through the shell's `glance` service as the + app (the app needs the `glance` capability). The model only supplies the + text; it never writes card code. A card can hold an in-card chat with the app's own agent (`sys.chat`, [`../crates/l0-chat`](../crates/l0-chat/README.md)). - **Trying it** on the desktop: open the assistant (F8) and ask the system - agent to have Mail's or Calendar's agent put a card on the glance screen; + agent to have an app's agent (Mail, Calendar, News, Photos, Maps or + YouTube) put a card on the glance screen; allow the agent on the sheet that comes up. Mail needs a signed-in account (the demo mailbox, below, will do). Mail's richer action card ([the plan](mail/docs/2026-10-01-email-action-card-plan.md)) is so far a diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md index 1adca50d..29c98f05 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -57,20 +57,21 @@ OctoScript-App-Design-Flow 的 `AGENTS.md`,再读 `docs/QUICKSTART.md`), | 应用 | Id | 功能 | 权限(manifest) | 网络主机(manifest) | 宿主服务 | | --- | --- | --- | --- | --- | --- | -| [News](news/bundle) | `os.news` | Hacker News、TechMeme 和 Google News 的订阅源,分标签页(Today、HN、TechMeme、Google、Saved),带文章阅读器 | `storage`、`net`、`images`、`web` | `hn.algolia.com`、`www.techmeme.com`、`news.google.com` | 无 | -| [Photos](photos/bundle) | `os.photos` | 示例相册:回忆、相簿、人物、收藏、可多选的网格、全屏查看器 | `storage` | 无 | 无(原图来自 Shell 的资源挂载,见下文) | -| [Maps](maps/bundle) | `os.maps` | `MapView` 地图、地点搜索、地点详情、可更改起点并最多添加两个途经点的路线,以及带逐向导航和 2D/3D 视图的驾驶模式;有 GPS 定位时从当前位置开始 | `storage`、`net`、`location` | `photon.komoot.io`、`router.project-osrm.org`、`overpass-api.de`、`overpass.kumi.systems`、`maps.mail.ru`、`overpass.openstreetmap.fr` | 无 | -| [Camera](camera/bundle) | `os.camera` | 基于运行时 `CameraPreview` 控件的拍照和录像,闪光灯和变焦,最近一张的缩略图和查看器 | `storage`、`camera`、`microphone`、`library` | 无 | 无 | +| [News](news/bundle) | `os.news` | Hacker News、TechMeme 和 Google News 的订阅源,分标签页(Today、HN、TechMeme、Google、Saved),带文章阅读器 | `storage`、`net`、`images`、`web`、`news`、`glance` | `hn.algolia.com`、`www.techmeme.com`、`news.google.com`、`api.gdeltproject.org`、`feeds.bbci.co.uk`、`feeds.npr.org`、`www.theguardian.com`、`feeds.arstechnica.com` | [`news`](news/host-service) | +| [Photos](photos/bundle) | `os.photos` | 示例相册:回忆、相簿、人物、收藏、可多选的网格、全屏查看器 | `storage`、`glance` | 无 | 无(原图来自 Shell 的资源挂载,见下文) | +| [Maps](maps/bundle) | `os.maps` | `MapView` 地图、地点搜索、地点详情、可更改起点并最多添加两个途经点的路线,以及带逐向导航和 2D/3D 视图的驾驶模式;有 GPS 定位时从当前位置开始 | `storage`、`net`、`location`、`glance` | `photon.komoot.io`、`router.project-osrm.org`、`overpass-api.de`、`overpass.kumi.systems`、`maps.mail.ru`、`overpass.openstreetmap.fr` | 无 | +| [Camera](camera/bundle) | `os.camera`(Home) | 基于运行时 `CameraPreview` 控件的拍照和录像,闪光灯和变焦,最近一张的缩略图和查看器 | `storage`、`camera`、`microphone`、`library`、`glance` | 无 | 无 | | [Mail](mail/bundle) | `os.mail` | 账户、文件夹、邮件列表、阅读(HTML 由服务重建)和写信;它的 Agent 把通知卡片放到 glance 屏幕上(`mail.notify`) | `storage`、`mail`、`glance` | 无(由服务联网,而不是应用) | [`mail`](mail/host-service) | | [AI providers](ai-providers/bundle) | `os.ai-providers` | 助手的大模型服务商:一个主用与若干备用,每项都有来自 octos 模型目录的型号下拉菜单和“测试连接”;添加向导(系列、型号、线路、密钥、测试);“为手机显示二维码”,以及通过相机、图片或粘贴导入 | `storage`、`llm` | 无(由服务联网,而不是应用) | [`llm`](ai-providers/host-service) | -| [YouTube](youtube/bundle) | `os.youtube` | YouTube 搜索(运行时无需密钥的 `sys.video`,读取 YouTube 自己的搜索结果页),带缩略图和直播或时长角标的结果列表、话题标签,在 `WebReader` 中播放 YouTube 移动版观看页,以及本机播放记录 | `storage`、`net` | `www.youtube.com`、`m.youtube.com`、`i.ytimg.com` | 无 | +| [YouTube](youtube/bundle) | `os.youtube` | YouTube 搜索(运行时无需密钥的 `sys.video`,读取 YouTube 自己的搜索结果页),带缩略图和直播或时长角标的结果列表、话题标签,在 `WebReader` 中播放 YouTube 移动版观看页,以及本机播放记录 | `storage`、`net`、`glance` | `www.youtube.com`、`m.youtube.com`、`i.ytimg.com` | 无 | | [Calendar](calendar/bundle) | `os.calendar`(桌面端) | 它的 Agent 保存用户的日程,并把日程卡片和议程卡片放到 glance 屏幕上;它自己的窗口还不能列出日程(需要 App Hub 提供 `calendar` 权限) | `storage`、`glance` | 无 | [`calendar`](calendar/host-service)(只供日历的 Agent 使用) | | [AppCard](appcard) | 原生,需显式启用 | AppCard 助手:路由大脑选择或组合一个应用 Agent,由它生成实时的 Splash 或 webview 卡片。Shell 只在启用 `app-appcard` 时链接它;默认不发布 | 不适用(不是 bundle) | 不适用 | Shell 的 octos 内核 | 每项权限的含义由 App Hub 的封闭列表定义(`crates/app-policy/src/manifest.rs` 中的 `KNOWN_CAPABILITIES`):`images` 可显示任意公网 https 主机的图片,`web` 在系统 WebView 中打开网页,`library` 把拍摄内容提供给系统相册,`mail` 访问 -宿主的邮件服务,`llm` 访问宿主的大模型服务商服务。`net` 只能访问 manifest 列出的主机。 +宿主的邮件服务,`llm` 访问宿主的大模型服务商服务,`news` 读取宿主的新闻服务, +`glance` 向速览屏发布卡片。`net` 只能访问 manifest 列出的主机。 ### 状态与已知问题 @@ -268,13 +269,12 @@ MAKEPAD_APP_CONFIG='{"mail_demo":true}' cargo run --release -p octosense-home -- | 文件 | 作用 | | --- | --- | -| `lib.rs` | 服务本体:`mail.accounts`、`add_account`(弹出登录面板)、`remove_account`、`folders`、`sync`、`list`、`message`、`mark_read`、`send`、`notify`(Agent 的通知卡片,以 Mail 的身份通过 Shell 的 glance 服务发布);`register()`、`register_demo()`、`register_with*()`;`Transport` trait;给 Shell 的账户事件(`on_account_event`) | +| `lib.rs` | 服务本体:`mail.accounts`、`add_account`(弹出登录面板)、`remove_account`、`folders`、`sync`、`list`、`message`、`mark_read`、`send`、`notify`(Agent 的通知:交给 Shell,由 Shell 以 Mail 的身份发布它的通知卡片,`on_notify`);`register()`、`register_demo()`、`register_with*()`;`Transport` trait;给 Shell 的账户事件(`on_account_event`) | | `imap.rs` | IMAP 客户端(文件夹、已读标记回写服务器) | | `network.rs` | POP3 和 SMTP、MIME 解码;错误信息中从不包含凭据 | | `html.rs` | 把邮件重建为 Mail 的 `Html` 视图能绘制的少量标签,不含任何远程内容 | | `vault.rs` | 密码的存放位置:macOS/iOS 钥匙串;Android 上用 Android Keystore 密钥加密的文件;其他平台为仅所有者可读的文件,这些文件放在宿主的密钥文件夹 `/secrets/os.mail/` 中;`OCTOSENSE_MAIL_VAULT=file` 强制使用文件存储,便于未签名的开发构建 | | `contacts.rs` | 用户的账户发过邮件的地址,供审批规则“收件人在我的联系人中”使用(用户在设置中打开之前不生效) | -| `resources/notice.card` | `mail.notify` 填充的固定 L0 通知卡片 | 账户元数据(不含密码)和已拉取的邮件存放在宿主自己的目录(`/mail`), 位于所有应用沙箱之外。每个账户只授权给添加它的应用。服务会先测试账户可用,再保存。 @@ -320,9 +320,11 @@ Shell 把日历 Agent 的 `calendar.*` 工具当作这个系统应用自己的 | 应用 | `manifest.json` | `tools.json` | 卡片 | | --- | --- | --- | --- | -| 新闻 | `agent` 块 | `news.list`、`news.read`(read,可共享) | – | -| 邮件 | `agent` 块、`glance`、`storage.accounts`(Agent 代表已登录的账户工作) | `mail.notify`(act,后台) | `notice.card` | +| 新闻 | `agent` 块、`glance` | `news.list`、`news.read`(read,可共享)、`news.notify`(act,后台) | Shell 的通知卡片 | +| 邮件 | `agent` 块、`glance`、`storage.accounts`(Agent 代表已登录的账户工作) | `mail.notify`(act,后台) | Shell 的通知卡片 | | 日历 | `agent` 块、`glance` | `calendar.events`(read)、`calendar.add_event`(act)、`calendar.remove_event`(destructive,`confirm: host`)、`calendar.notify`、`calendar.agenda`(act) | `event.card`、`agenda.card` | +| 照片、地图、YouTube、相机 | `agent` 块、`glance` | `photos.notify`、`maps.notify`、`youtube.notify`、`camera.notify`(act,后台) | Shell 的通知卡片 | +| AI providers | 无 | 暂无:App Hub 只接受 `[a-z0-9_]` 形式的工具命名空间(octos 也只接受由 `[a-z][a-z0-9_]` 段组成的工具名),所以 `ai-providers.notify` 会被拒绝 | – | - **声明。** manifest 的 `agent` 块列出 Agent 可以使用的内核工具(`"tools": ["ask_user_question"]`; 其中带点的名称表示申请另一个应用的可共享工具),`bundle/tools.json` 声明应用自己的工具: @@ -332,7 +334,9 @@ Shell 把日历 Agent 的 `calendar.*` 工具当作这个系统应用自己的 - **运行。** 在用户于首次使用面板上允许之前什么都不会运行(用户用顶栏的 “Ask ”、 Shift+F8 或菜单项 “Ask this app's agent” 打开 Shell 的 “Ask ” 面板时,或系统 Agent 用 `agents.ask` 询问时,弹出这个面板)。之后 Shell 准备好 peer,系统 Agent - 就能用 `peer_send_input` 找到它。只有系统 Agent、用户或卡片的卡内对话发起请求时才会 + 就能用 `peer_send_input` 找到它。`agents.ask` 会等待用户的回答和 peer 就绪(它声明为 + `outward` 且 `confirm: app`,内核会像对待审批一样一直等它,而不是只给读取类工具的 30 秒), + 然后把 peer 的 slug 交给系统 Agent,让请求在同一轮里继续。只有系统 Agent、用户或卡片的卡内对话发起请求时才会 开始一轮:还没有触发器或定时任务(ADR 0002 M3,计划中)。 - **直接与它对话。** 用户可以直接与应用的 Agent 对话,而不只是通过系统 Agent:在 Shell 为每个拥有 Agent 的应用提供的 “Ask ” 面板里(这些应用都不绘制自己的对话界面), @@ -342,12 +346,18 @@ Shell 把日历 Agent 的 `calendar.*` 工具当作这个系统应用自己的 - **它的工具在应用的宿主服务上运行**,以应用的身份运行,在此之前 Shell 的中转已检查授权、 schema 和预算。octos 只对破坏性和对外的工具请求审批(这里是 `calendar.remove_event`), 由用户在 Shell 的面板上回答。 -- **卡片。** `mail.notify`、`calendar.notify` 和 `calendar.agenda` 填充宿主服务自带的固定 - L0 卡片,并以应用的身份、带 `notify` 通过 Shell 的 `glance` 服务发布(应用需要 `glance` - 权限)。模型只提供文字,从不编写卡片代码。卡片里可以有与应用自己的 Agent 的卡内对话 +- **卡片。** `.notify {title, body, card_id?, priority?}` 以应用的名义在 glance 屏幕上 + 放一张通知卡片,并发出一条通知:所有应用共用 Shell 自带的一张固定 L0 卡片 + ([`../crates/shell/resources/glance/notice.card`](../crates/shell/resources/glance/notice.card), + 由 [`../crates/shell/src/glance_notice.rs`](../crates/shell/src/glance_notice.rs) 填充),带有应用的 + 图标和名称、时间,以及 Agent 写的标题(最多 80 个字符)和正文(最多 600 个字符);同一个 + `card_id` 会替换该应用之前的通知。邮件和新闻的服务把 `notify` 交给 Shell;照片、地图、YouTube + 和相机没有自己的服务,由 Shell 的通知服务应答。`calendar.notify` 和 `calendar.agenda` 填充日历 + 自己的日程卡片和议程卡片。每张卡片都以应用的身份、带 `notify` 通过 Shell 的 `glance` 服务发布 + (应用需要 `glance` 权限)。模型只提供文字,从不编写卡片代码。卡片里可以有与应用自己的 Agent 的卡内对话 (`sys.chat`,[`../crates/l0-chat`](../crates/l0-chat/README.md))。 -- **试一试**(桌面端):打开助手(F8),请系统 Agent 让邮件或日历的 Agent 在 glance 屏幕上 - 放一张卡片;在弹出的面板上允许该 Agent。邮件需要一个已登录的账户(下文的演示邮箱即可)。 +- **试一试**(桌面端):打开助手(F8),请系统 Agent 让某个应用的 Agent(邮件、日历、新闻、照片、 + 地图或 YouTube)在 glance 屏幕上放一张卡片;在弹出的面板上允许该 Agent。邮件需要一个已登录的账户(下文的演示邮箱即可)。 邮件更完整的操作卡片([计划(英文)](mail/docs/2026-10-01-email-action-card-plan.md))目前只是 使用假数据的演示:`OCTOSENSE_GLANCE_DEMO=mail` 会在启动时发布它。**未验证**(在 #267 的检查中 运行过,没有为本页重新运行)。 diff --git a/apps/camera/bundle/manifest.json b/apps/camera/bundle/manifest.json index e6ad0902..2ba6e065 100644 --- a/apps/camera/bundle/manifest.json +++ b/apps/camera/bundle/manifest.json @@ -4,5 +4,10 @@ "version": "1.0.0", "name": "Camera", "integrity": { "bundle_blake3": "" }, - "capabilities": ["storage", "camera", "microphone", "library"] + "capabilities": ["storage", "camera", "microphone", "library", "glance"], + "agent": { + "profile": "read-only", + "tools": ["ask_user_question"], + "model": { "needs": ["tool_calling"] } + } } diff --git a/apps/camera/bundle/tools.json b/apps/camera/bundle/tools.json new file mode 100644 index 00000000..18378901 --- /dev/null +++ b/apps/camera/bundle/tools.json @@ -0,0 +1,60 @@ +{ + "schema": 1, + "tools": [ + { + "name": "camera.notify", + "description": "Tell the person something now: put a notice card from Camera on the glance screen and show a notification. Give a short title (at most 80 characters) and the text (at most 600 characters); Camera draws the card. Use it when the person or the system agent asks Camera to notify them. The same card_id replaces an earlier notice.", + "input_schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 600 + }, + "card_id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "title", + "body" + ], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "card_id": { + "type": "string" + }, + "replaced": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + } + }, + "required": [ + "card_id" + ] + }, + "risk": "act", + "background": true, + "implemented_by": "host-service" + } + ] +} diff --git a/apps/mail/host-service/resources/notice.card b/apps/mail/host-service/resources/notice.card deleted file mode 100644 index a2fa4026..00000000 --- a/apps/mail/host-service/resources/notice.card +++ /dev/null @@ -1,23 +0,0 @@ -# ledger mail.notice@0.1.0 -# level: L0 -# profile: ui/l0 -# -# Mail's notice card (`mail.notify`): a title and a short text Mail's agent -# wrote, as `os.mail`. `note` is a `sys.dataset` record the service fills -# from the call's arguments; the card states nothing else. - -source note sys.dataset(fields: [title, summary, as_of]) - -copy app { class: vocabulary, en: "Mail" } - -view root Surface(pad: .tight) { - Col(gap: 8) { - Row(gap: 8, align: .center) { - Icon(name: .mail, size: .row) - TextCaption(text: copy.app, width: .fill) - TextCaption(value: note.as_of) - } - TextTitle(text: note.title, width: .fill) - TextBody(text: note.summary, width: .fill) - } -} diff --git a/apps/mail/host-service/src/lib.rs b/apps/mail/host-service/src/lib.rs index caac5ecd..74b2fa5e 100644 --- a/apps/mail/host-service/src/lib.rs +++ b/apps/mail/host-service/src/lib.rs @@ -14,7 +14,7 @@ //! | `mail.message` | `{account, folder?, message}` | `{id, sender, address, subject, body, html, attachments, date, time}` | //! | `mail.mark_read` | `{account, folder?, message}` | `{}` | //! | `mail.send` | `{account, to, subject, body}` | `{accepted}` | -//! | `mail.notify` | `{title, body, card_id?, priority?}` | `{card_id, replaced, expires_at}` once a notice card is on the glance screen, with a notification ([`on_publish_card`]) | +//! | `mail.notify` | `{title, body, card_id?, priority?}` | `{card_id, replaced, expires_at}` once the shell put Mail's notice card on the glance screen, with a notification ([`on_notify`]) | //! //! The app never sees a password or a socket. `mail.add_account` raises the //! host's sign-in sheet, a separate isolate over the app; only calls from @@ -85,49 +85,29 @@ fn account_event(event: AccountEvent) { } } -/// Publishes one glance card as the calling app: `(app id, glance.publish -/// arguments)`. The shell installs it once at startup; it decides whether -/// the app may publish (the `glance` grant). -pub type CardPublisher = Arc Result + Send + Sync>; +/// Puts a notice on the glance screen as the calling app: `(app id, +/// mail.notify arguments)` → `{card_id, replaced, expires_at}`. The shell +/// installs it once at startup: it draws the notice card every system app +/// shares, and decides whether the app may publish (the `glance` grant). +pub type Notifier = Arc Result + Send + Sync>; -fn card_publisher() -> &'static Mutex> { - static PUBLISHER: std::sync::OnceLock>> = std::sync::OnceLock::new(); - PUBLISHER.get_or_init(Default::default) +fn notifier() -> &'static Mutex> { + static NOTIFIER: std::sync::OnceLock>> = std::sync::OnceLock::new(); + NOTIFIER.get_or_init(Default::default) } -/// Install (or with `None` remove) what `mail.notify` publishes through. -pub fn on_publish_card(publisher: Option) { - *card_publisher().lock().unwrap_or_else(|e| e.into_inner()) = publisher; +/// Install (or with `None` remove) what `mail.notify` goes to. +pub fn on_notify(notify: Option) { + *notifier().lock().unwrap_or_else(|e| e.into_inner()) = notify; } -/// Mail's notice card (L0): a title and a short text, nothing else. -pub const NOTICE_CARD: &str = include_str!("../resources/notice.card"); -const NOTICE_TITLE_MAX: usize = 80; -const NOTICE_BODY_MAX: usize = 600; - -/// `mail.notify`'s `glance.publish` arguments: the notice card filled from -/// the call, opening Mail, with a notification. The card's text is the -/// caller's; the card itself is fixed, so a model never writes card code. -pub fn notice_publish_args(args: &Value, now_ms: u128) -> Result { - let title = text(args, "title").trim(); - let body = text(args, "body").trim(); - if title.is_empty() || title.chars().count() > NOTICE_TITLE_MAX { - return Err(format!("Provide a title of 1 to {NOTICE_TITLE_MAX} characters.")); +/// `mail.notify`: the call, as `app`, to the shell's notifier. +fn notify(app: &str, args: &Value) -> Result { + let notify = notifier().lock().unwrap_or_else(|e| e.into_inner()).clone(); + match notify { + Some(notify) => notify(app, args), + None => Err("This device shows no glance cards.".into()), } - if body.is_empty() || body.chars().count() > NOTICE_BODY_MAX { - return Err(format!("Provide a body of 1 to {NOTICE_BODY_MAX} characters.")); - } - let card_id = match text(args, "card_id") { - "" => format!("notice-{now_ms:x}"), - id => id.to_string(), - }; - let priority = args["priority"].as_i64().unwrap_or(60).clamp(0, 100); - let as_of = chrono::Local::now().format("%H:%M").to_string(); - Ok(json!({ - "card_id": card_id, "title": format!("Mail · {title}"), "source": NOTICE_CARD, - "data": {"note": {"title": title, "summary": body, "as_of": as_of}}, - "priority": priority, "open": {"app": "mail"}, "notify": true, - })) } /// How mail moves: IMAP or POP3 and SMTP in the shell, or a fake in tests. @@ -737,14 +717,7 @@ impl HostService for MailService { reply.send(sent) }); } - "notify" => { - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0); - let publisher = card_publisher().lock().unwrap_or_else(|e| e.into_inner()).clone(); - let Some(publisher) = publisher else { - return reply.send(Err("This device shows no glance cards.".into())); - }; - reply.send(notice_publish_args(&call.args, now).and_then(|args| publisher(&call.app_id, args))); - } + "notify" => reply.send(notify(&call.app_id, &call.args)), other => reply.send(Err(format!("mail has no method {other:?}"))), } } @@ -949,24 +922,23 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// `mail.notify` fills the fixed notice card from the call: the text is - /// the caller's, the card opens Mail and notifies, and a blank or long - /// title or body is refused before anything is published. + /// `mail.notify` goes to the shell, which draws the notice card (the + /// one every system app shares) and publishes it as the calling app: + /// the service hands it the caller and the call as they came, and + /// without the shell's notifier the call is refused. #[test] - fn a_notice_is_the_fixed_card_filled_with_the_callers_text() { - let args = notice_publish_args(&json!({"title": "Hello", "body": "From the system agent", "card_id": "hello"}), 1).unwrap(); - assert_eq!(args["card_id"], "hello"); - assert_eq!(args["title"], "Mail · Hello"); - assert_eq!(args["source"], NOTICE_CARD); - assert_eq!(args["data"]["note"]["title"], "Hello"); - assert_eq!(args["data"]["note"]["summary"], "From the system agent"); - assert_eq!((args["open"]["app"].as_str(), args["notify"].as_bool()), (Some("mail"), Some(true))); - assert_eq!(args["priority"], 60); - let generated = notice_publish_args(&json!({"title": "Hi", "body": "x", "priority": 500}), 0xabc).unwrap(); - assert_eq!((generated["card_id"].as_str(), generated["priority"].as_i64()), (Some("notice-abc"), Some(100))); - assert!(notice_publish_args(&json!({"title": " ", "body": "x"}), 0).is_err()); - assert!(notice_publish_args(&json!({"title": "x".repeat(81), "body": "x"}), 0).is_err()); - assert!(notice_publish_args(&json!({"title": "x", "body": "y".repeat(601)}), 0).is_err()); + fn a_notice_goes_to_the_shell_as_the_calling_app() { + assert_eq!(notify("os.mail", &json!({"title": "Hello", "body": "x"})).unwrap_err(), "This device shows no glance cards."); + let heard: Arc>> = Arc::default(); + let seen = heard.clone(); + on_notify(Some(Arc::new(move |app: &str, args: &Value| -> Result { + seen.lock().unwrap().push((app.to_string(), args.clone())); + Ok(json!({"card_id": "hello", "replaced": false})) + }))); + let args = json!({"title": "Hello", "body": "From the system agent", "card_id": "hello"}); + assert_eq!(notify("os.mail", &args).unwrap()["card_id"], "hello"); + assert_eq!(*heard.lock().unwrap(), [("os.mail".to_string(), args)]); + on_notify(None); } #[test] diff --git a/apps/maps/bundle/manifest.json b/apps/maps/bundle/manifest.json index ae4a52fe..327ebded 100644 --- a/apps/maps/bundle/manifest.json +++ b/apps/maps/bundle/manifest.json @@ -9,7 +9,8 @@ "capabilities": [ "storage", "net", - "location" + "location", + "glance" ], "network": { "hosts": [ @@ -20,5 +21,16 @@ "maps.mail.ru", "overpass.openstreetmap.fr" ] + }, + "agent": { + "profile": "read-only", + "tools": [ + "ask_user_question" + ], + "model": { + "needs": [ + "tool_calling" + ] + } } } diff --git a/apps/maps/bundle/tools.json b/apps/maps/bundle/tools.json new file mode 100644 index 00000000..6c0caf5d --- /dev/null +++ b/apps/maps/bundle/tools.json @@ -0,0 +1,60 @@ +{ + "schema": 1, + "tools": [ + { + "name": "maps.notify", + "description": "Tell the person something now: put a notice card from Maps on the glance screen and show a notification. Give a short title (at most 80 characters) and the text (at most 600 characters); Maps draws the card. Use it when the person or the system agent asks Maps to notify them. The same card_id replaces an earlier notice.", + "input_schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 600 + }, + "card_id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "title", + "body" + ], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "card_id": { + "type": "string" + }, + "replaced": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + } + }, + "required": [ + "card_id" + ] + }, + "risk": "act", + "background": true, + "implemented_by": "host-service" + } + ] +} diff --git a/apps/news/bundle/manifest.json b/apps/news/bundle/manifest.json index c3fc5819..a56dc3a1 100644 --- a/apps/news/bundle/manifest.json +++ b/apps/news/bundle/manifest.json @@ -15,7 +15,8 @@ "net", "images", "web", - "news" + "news", + "glance" ], "id": "os.news", "integrity": { diff --git a/apps/news/bundle/tools.json b/apps/news/bundle/tools.json index 381603c8..0189621f 100644 --- a/apps/news/bundle/tools.json +++ b/apps/news/bundle/tools.json @@ -46,6 +46,29 @@ "risk": "read", "shareable": true, "implemented_by": "host-service" + }, + { + "name": "news.notify", + "description": "Tell the person something now: put a notice card from News on the glance screen and show a notification. Give a short title (at most 80 characters) and the text (at most 600 characters); News draws the card. Use it when the person or the system agent asks News to notify them, or when stories need the person's attention. The same card_id replaces an earlier notice.", + "input_schema": { + "type": "object", + "properties": { + "title": { "type": "string", "minLength": 1, "maxLength": 80 }, + "body": { "type": "string", "minLength": 1, "maxLength": 600 }, + "card_id": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[A-Za-z0-9._-]+$" }, + "priority": { "type": "integer", "minimum": 0, "maximum": 100 } + }, + "required": ["title", "body"], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { "card_id": { "type": "string" }, "replaced": { "type": "boolean" }, "expires_at": { "type": "integer" } }, + "required": ["card_id"] + }, + "risk": "act", + "background": true, + "implemented_by": "host-service" } ] } diff --git a/apps/news/host-service/src/lib.rs b/apps/news/host-service/src/lib.rs index 1eed2731..f45870f7 100644 --- a/apps/news/host-service/src/lib.rs +++ b/apps/news/host-service/src/lib.rs @@ -18,6 +18,7 @@ //! | `news.refresh` | `{due?}` | the run's report `{at, new, total, clusters, sources}`, or `{busy: true}` | //! | `news.sources` | – | `{sources: [{id, label, kind, host, lang, topics, last_success, last_error, failures, next_due, items}]}` | //! | `news.feeds.import` | `{opml}` | `{added: [id], skipped: [{url, reason}]}` | +//! | `news.notify` | `{title, body, card_id?, priority?}` | `{card_id, replaced, expires_at}` once the shell put News's notice card on the glance screen, with a notification ([`Options::on_notify`]) | //! //! An item is `{id, title, url, source, feed, lang, published, fetched, //! summary, image?, topics, discussion?, points?, comments?, also?}`; `id` is @@ -101,6 +102,7 @@ pub struct SourceReport { } type Hook = Arc; +type Notify = Arc Result + Send + Sync>; type Clock = Arc i64 + Send + Sync>; /// How the service runs. `Options::default()` is what a shell wants. @@ -110,6 +112,7 @@ pub struct Options { fetcher: Arc, reader: Option>, on_fetch: Option, + on_notify: Option, clock: Clock, timer: bool, interval: Duration, @@ -133,6 +136,7 @@ impl Default for Options { fetcher: Arc::new(HttpFetcher::default()), reader: None, on_fetch: None, + on_notify: None, clock: Arc::new(|| chrono::Utc::now().timestamp()), timer: true, interval: Duration::from_secs(900), @@ -176,6 +180,14 @@ impl Options { self.on_fetch = Some(Arc::new(f)); self } + /// What `news.notify` goes to: `(app id, its arguments)` → + /// `{card_id, replaced, expires_at}`. The shell draws the notice card + /// every system app shares and publishes it as the calling app; without + /// one, `news.notify` is refused. + pub fn on_notify(mut self, f: impl Fn(&str, &Value) -> Result + Send + Sync + 'static) -> Self { + self.on_notify = Some(Arc::new(f)); + self + } /// Unix seconds; tests move time. pub fn clock(mut self, f: impl Fn() -> i64 + Send + Sync + 'static) -> Self { self.clock = Arc::new(f); @@ -399,6 +411,15 @@ impl News { Ok(json!({"item": item, "text": item.summary, "full_text": false})) } + /// `news.notify`, for `app`: to the shell's notifier + /// ([`Options::on_notify`]), as the call came. + pub fn notify(&self, app: &str, args: &Value) -> Result { + match &self.core.options.on_notify { + Some(notify) => notify(app, args), + None => Err("This device shows no glance cards.".into()), + } + } + pub fn topics(&self) -> Result, String> { self.with_data(|data, _| data.topics.clone()) } @@ -714,6 +735,7 @@ impl HostService for NewsService { }), "sources" => reply.send(news.source_status()), "feeds.import" => reply.send(news.import_opml(args["opml"].as_str().unwrap_or(""))), + "notify" => reply.send(news.notify(&call.app_id, &args)), other => reply.send(Err(format!("news has no method {other:?}"))), } } diff --git a/apps/news/host-service/tests/service.rs b/apps/news/host-service/tests/service.rs index 2d793958..e87402e3 100644 --- a/apps/news/host-service/tests/service.rs +++ b/apps/news/host-service/tests/service.rs @@ -414,6 +414,26 @@ fn read_gives_the_summary_or_a_granted_readers_text() { assert_eq!(read["text"], "The full text of https://www.bbc.com/news/articles/c2."); } +/// `news.notify` goes to the shell's notifier, which draws the notice card +/// every system app shares and publishes it as the calling app: the service +/// hands it the caller and the call as they came; without one it refuses. +#[test] +fn notify_goes_to_the_shell_as_the_calling_app() { + let rig = Rig::new("notify-none"); + assert_eq!(rig.news.notify("os.news", &json!({"title": "Hello", "body": "x"})).unwrap_err(), "This device shows no glance cards."); + let heard: Arc>> = Arc::default(); + let seen = heard.clone(); + let rig = Rig::with("notify", move |o| { + o.on_notify(move |app, args| { + seen.lock().unwrap().push((app.to_string(), args.clone())); + Ok(json!({"card_id": "hello", "replaced": false})) + }) + }); + let args = json!({"title": "Hello", "body": "Three new stories on fusion", "card_id": "hello"}); + assert_eq!(rig.news.notify("os.news", &args).unwrap()["card_id"], "hello"); + assert_eq!(*heard.lock().unwrap(), [("os.news".to_string(), args)]); +} + // --- Through App Hub's dispatch, as the Card runner calls it. static SERIAL: Mutex<()> = Mutex::new(()); @@ -472,6 +492,8 @@ fn the_card_runner_reaches_the_tools() { let sources = ask("os.news", &dir, "news.sources", json!({})).unwrap(); assert!(sources["sources"].as_array().unwrap().iter().any(|s| s["kind"] == "gdelt")); assert!(ask("os.news", &dir, "news.nope", json!({})).unwrap_err().contains("no method")); + // This service was registered without the shell's notifier. + assert!(ask("os.news", &dir, "news.notify", json!({"title": "Hello", "body": "x"})).unwrap_err().contains("no glance cards")); assert!(ask("com.example.app", &dir, "news.list", json!({})).unwrap_err().contains("system apps only")); assert!(dir.join("news/items.json").exists(), "the service keeps its files under /news"); } diff --git a/apps/photos/bundle/manifest.json b/apps/photos/bundle/manifest.json index ada12ba7..7b07ea45 100644 --- a/apps/photos/bundle/manifest.json +++ b/apps/photos/bundle/manifest.json @@ -4,5 +4,10 @@ "version": "1.0.0", "name": "Photos", "integrity": { "bundle_blake3": "" }, - "capabilities": ["storage"] + "capabilities": ["storage", "glance"], + "agent": { + "profile": "read-only", + "tools": ["ask_user_question"], + "model": { "needs": ["tool_calling"] } + } } diff --git a/apps/photos/bundle/tools.json b/apps/photos/bundle/tools.json new file mode 100644 index 00000000..b2ba7e2a --- /dev/null +++ b/apps/photos/bundle/tools.json @@ -0,0 +1,60 @@ +{ + "schema": 1, + "tools": [ + { + "name": "photos.notify", + "description": "Tell the person something now: put a notice card from Photos on the glance screen and show a notification. Give a short title (at most 80 characters) and the text (at most 600 characters); Photos draws the card. Use it when the person or the system agent asks Photos to notify them. The same card_id replaces an earlier notice.", + "input_schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 600 + }, + "card_id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "title", + "body" + ], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "card_id": { + "type": "string" + }, + "replaced": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + } + }, + "required": [ + "card_id" + ] + }, + "risk": "act", + "background": true, + "implemented_by": "host-service" + } + ] +} diff --git a/apps/youtube/bundle/manifest.json b/apps/youtube/bundle/manifest.json index de947dee..b9c53f71 100644 --- a/apps/youtube/bundle/manifest.json +++ b/apps/youtube/bundle/manifest.json @@ -4,12 +4,17 @@ "version": "1.0.0", "name": "YouTube", "integrity": { "bundle_blake3": "" }, - "capabilities": ["storage", "net"], + "capabilities": ["storage", "net", "glance"], "network": { "hosts": [ "www.youtube.com", "m.youtube.com", "i.ytimg.com" ] + }, + "agent": { + "profile": "read-only", + "tools": ["ask_user_question"], + "model": { "needs": ["tool_calling"] } } } diff --git a/apps/youtube/bundle/tools.json b/apps/youtube/bundle/tools.json new file mode 100644 index 00000000..1b61618a --- /dev/null +++ b/apps/youtube/bundle/tools.json @@ -0,0 +1,60 @@ +{ + "schema": 1, + "tools": [ + { + "name": "youtube.notify", + "description": "Tell the person something now: put a notice card from YouTube on the glance screen and show a notification. Give a short title (at most 80 characters) and the text (at most 600 characters); YouTube draws the card. Use it when the person or the system agent asks YouTube to notify them. The same card_id replaces an earlier notice.", + "input_schema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 80 + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 600 + }, + "card_id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9._-]+$" + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "required": [ + "title", + "body" + ], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "card_id": { + "type": "string" + }, + "replaced": { + "type": "boolean" + }, + "expires_at": { + "type": "integer" + } + }, + "required": [ + "card_id" + ] + }, + "risk": "act", + "background": true, + "implemented_by": "host-service" + } + ] +} diff --git a/crates/shell/resources/glance/notice.card b/crates/shell/resources/glance/notice.card new file mode 100644 index 00000000..1526881d --- /dev/null +++ b/crates/shell/resources/glance/notice.card @@ -0,0 +1,26 @@ +# ledger os.notice@0.2.0 +# level: L0 +# profile: ui/l0 +# +# A system app's notice card (`.notify`, glance_notice.rs): a +# title and a short text the app's agent wrote, as that app. `note` is a +# `sys.dataset` record the shell fills from the call; the card states +# nothing else. The app's name and icon are the shell's: it puts in the +# app's manifest name and a meaning from L0's closed icon set, never the +# caller's text. + +source note sys.dataset(fields: [title, summary, as_of]) + +copy app { class: vocabulary, en: "{app}" } + +view root Surface(pad: .tight) { + Col(gap: 8) { + Row(gap: 8, align: .center) { + Icon(name: .{icon}, size: .row) + TextCaption(text: copy.app, width: .fill) + TextCaption(value: note.as_of) + } + TextTitle(text: note.title, width: .fill) + TextBody(text: note.summary, width: .fill) + } +} diff --git a/crates/shell/src/agents.rs b/crates/shell/src/agents.rs index ba3367b5..61bfb3ff 100644 --- a/crates/shell/src/agents.rs +++ b/crates/shell/src/agents.rs @@ -28,7 +28,9 @@ //! tools on the system session, `agents.list` and `agents.ask` //! ([`declarations`], answered by the system chat itself: [`call`]). //! `agents.ask` shows the first-use sheet (over the system chat); only the -//! person answers it. +//! person answers it. The system chat holds the call until they did and the +//! agent's peer is ready ([`ask_settled`]), so the system agent goes on with +//! the person's request in the same turn instead of ending it to wait. use std::collections::{BTreeMap, BTreeSet}; use std::sync::{Arc, Mutex}; @@ -372,9 +374,16 @@ pub fn declarations() -> Vec { json!({ "name": ASK_TOOL, "app": OWNER, - "description": "Ask the person to allow one app's agent (the shell shows its first-use sheet; only the person answers). Use it when you need an app's agent that is not yet allowed. Returns where it stands now; once allowed, its peer appears in peer_list.", + "description": "Ask the person to allow one app's agent (the shell shows its first-use sheet; only the person answers). Use it when you need an app's agent that is not yet allowed. The call waits for their answer and for the agent's peer to start, then returns its peer slug: send the person's request to it with peer_send_input in this same turn. If they did not allow it, say so.", "input_schema": {"type": "object", "properties": {"app": {"type": "string", "description": "The app's name or id, e.g. News or os.news"}}, "required": ["app"], "additionalProperties": false}, - "risk": "read", + // The shell's own sheet asks the person, so the kernel holds the + // call as long as an approval (`confirm: app` on a gated tool); + // a `read` tool gets 30 s, and the person had not answered by + // then in the live run. `outward`: it lets another app's agent + // in, past the system agent. + "risk": "act", + "outward": true, + "confirm": "app", }), ] } @@ -389,23 +398,65 @@ pub fn is_agents_tool(tool: &str) -> bool { pub fn call(tool: &str, args: &Value) -> ToolOutcome { match tool { LIST_TOOL => ToolOutcome::Ok(list()), - ASK_TOOL => { - let name = args["app"].as_str().unwrap_or(""); - let Some(app) = find(name) else { - let names: BTreeSet = all().into_iter().map(|a| a.name).collect(); - return ToolOutcome::error("no_such_agent", format!("No app with an agent is called {name:?}. Apps with an agent: {}.", names.into_iter().collect::>().join(", "))); - }; - let now = ask(&app); - let text = match now { - Access::Allowed => format!("{}'s assistant is allowed. Its peer is being prepared; it shows in peer_list shortly (use its peer slug from peer_list or agents.list with peer_send_input, not the app id).", app.name), - Access::NotAsked => format!("{}'s assistant is not yet allowed: the person was asked on the first-use sheet. Wait for them; its peer shows in peer_list once they allow it.", app.name), - Access::Off => format!("{}'s assistant is off. Only the person can turn it on (Settings › Assistant › Approvals).", app.name), - }; - if now == Access::Allowed { - prepare(&app); + // Answered at once (the system chat holds the call instead, until + // the person answered: system_chat `pump`). + ASK_TOOL => match ask_app(args) { + Ok(app) => { + begin_ask(&app); + ask_settled(&app).unwrap_or_else(|| ask_pending(&app)) } - ToolOutcome::Ok(json!({"app": app.id, "name": app.name, "access": now.as_str(), "peer_slug": peer_slug(&app), "text": text})) - } + Err(outcome) => outcome, + }, other => ToolOutcome::error("unknown_tool", format!("{other} is not an agents tool")), } } + +/// The app `agents.ask` names, or the error it answers. +pub fn ask_app(args: &Value) -> Result { + let name = args["app"].as_str().unwrap_or(""); + find(name).ok_or_else(|| { + let names: BTreeSet = all().into_iter().map(|a| a.name).collect(); + ToolOutcome::error("no_such_agent", format!("No app with an agent is called {name:?}. Apps with an agent: {}.", names.into_iter().collect::>().join(", "))) + }) +} + +/// `agents.ask` for `app`: the first-use sheet if the person has not +/// decided, and its peer started once allowed. Whether the sheet is up. +pub fn begin_ask(app: &AgentApp) -> bool { + let now = ask(app); + if now == Access::Allowed { + prepare(app); + } + now == Access::NotAsked +} + +/// `agents.ask`'s answer once there is one: the person allowed the agent +/// and its peer is ready (its slug to pass to peer_send_input) or could not +/// start, or the agent is off. None while the person has not answered the +/// first-use sheet, or the allowed agent's peer is still starting. +pub fn ask_settled(app: &AgentApp) -> Option { + let now = access(&app.id); + let (text, slug) = match (now, prepared(&app.id)) { + (Access::Off, _) => (format!("{}'s assistant is off: the person did not allow it. Only they can turn it on (Settings › Assistant › Approvals); tell them.", app.name), None), + (Access::Allowed, _) if app.native => (format!("{}'s assistant is allowed. {}.", app.name, reach(app, peer_slug(app).as_deref())), peer_slug(app)), + (Access::Allowed, Some(Prepared::Ready)) => { + let slug = peer_slug(app)?; + (format!("{}'s assistant is allowed and ready. {} now, in this turn.", app.name, reach(app, Some(&slug))), Some(slug)) + } + (Access::Allowed, Some(Prepared::Failed(e))) => (format!("{}'s assistant is allowed but could not start: {e}", app.name), None), + _ => return None, + }; + Some(ToolOutcome::Ok(json!({"app": app.id, "name": app.name, "access": now.as_str(), "peer_slug": slug, "text": text}))) +} + +/// `agents.ask`'s answer while the person has not answered, or the peer +/// has not started (a call that waited too long). +pub fn ask_pending(app: &AgentApp) -> ToolOutcome { + let now = access(&app.id); + let text = match now { + Access::Allowed => format!("{}'s assistant is allowed. Its peer is still starting; it shows in peer_list shortly (use its peer slug from peer_list or agents.list with peer_send_input, not the app id).", app.name), + Access::NotAsked => format!("{}'s assistant is not yet allowed: the person has not answered the first-use sheet. Tell them it is waiting for them; its peer shows in peer_list once they allow it.", app.name), + Access::Off => format!("{}'s assistant is off. Only the person can turn it on (Settings › Assistant › Approvals).", app.name), + }; + ToolOutcome::Ok(json!({"app": app.id, "name": app.name, "access": now.as_str(), "peer_slug": peer_slug(app), "text": text})) +} diff --git a/crates/shell/src/app_chat/tests.rs b/crates/shell/src/app_chat/tests.rs index 6c93dd91..4c3716b1 100644 --- a/crates/shell/src/app_chat/tests.rs +++ b/crates/shell/src/app_chat/tests.rs @@ -417,10 +417,27 @@ fn the_panel_opens_a_sharing_context_and_sends_person_turns_there() { assert!(listed["what_to_do"].as_str().unwrap().contains(&format!("peer_send_input and the peer slug \"{SLUG}\"")), "{listed}"); let note = crate::agents::note_part(&app); assert!(note.contains(SLUG) && note.contains("peer_send_input") && note.contains("not the app id"), "{note}"); + // A held agents.ask answers once the agent is ready: its slug, to send + // the request to now, in the same turn (it ended the turn to wait for + // the sheet in the live run, and the request was lost). + match crate::agents::ask_settled(&app) { + Some(crate::ai_host::app_peers::host_tools::ToolOutcome::Ok(v)) => { + assert_eq!(v["peer_slug"], SLUG); + assert!(v["text"].as_str().unwrap().contains("in this turn"), "{v}"); + } + other => panic!("{other:?}"), + } assert_eq!(peers.0.lock().unwrap().len(), 1, "one peer per app"); const OTHER: &str = "org.example.asktest2"; - crate::approvals::with(|a| a.consent.set(&crate::approvals::rules::ApprovalGesture::sheet_tap(), OTHER, true, 2)); let other = AgentApp { id: OTHER.into(), name: "Other".into(), ..app.clone() }; + // The person has not answered the sheet: the call is held, and a call + // that waited too long says they have not. + assert!(crate::agents::ask_settled(&other).is_none(), "held while the sheet waits"); + match crate::agents::ask_pending(&other) { + crate::ai_host::app_peers::host_tools::ToolOutcome::Ok(v) => assert!(v["text"].as_str().unwrap().contains("has not answered"), "{v}"), + other => panic!("{other:?}"), + } + crate::approvals::with(|a| a.consent.set(&crate::approvals::rules::ApprovalGesture::sheet_tap(), OTHER, true, 2)); crate::agents::prepare(&other); wait("the preparation", || crate::agents::prepared(OTHER) == Some(crate::agents::Prepared::Ready)); let prepared = peers.0.lock().unwrap().iter().find(|(id, _)| *id == format!("card.{OTHER}")).map(|(_, p)| *p.prepared.lock().unwrap()); @@ -430,6 +447,10 @@ fn the_panel_opens_a_sharing_context_and_sends_person_turns_there() { // Turned off: the peer goes, the panel's context closes, and it says so. crate::approvals::with(|a| a.consent.turn_off(APP, 3)); + match crate::agents::ask_settled(&app) { + Some(crate::ai_host::app_peers::host_tools::ToolOutcome::Ok(v)) => assert_eq!(v["access"], "off", "{v}"), + other => panic!("{other:?}"), + } assert!(crate::ai_host::contained::revoke(APP)); assert!(peers.0.lock().unwrap().iter().find(|(id, _)| *id == format!("card.{APP}")).unwrap().1.released.load(Ordering::SeqCst)); super::pump(); @@ -533,8 +554,13 @@ fn the_system_agent_is_told_about_agents_it_cannot_list() { assert_eq!(crate::agents::strip_note(&sent), "ask News for a digest"); assert_eq!(crate::agents::strip_note("ask News"), "ask News"); // The tools it can call, answered by the shell. - let names: Vec = crate::agents::declarations().iter().map(|d| d["name"].as_str().unwrap().to_string()).collect(); + let declarations = crate::agents::declarations(); + let names: Vec = declarations.iter().map(|d| d["name"].as_str().unwrap().to_string()).collect(); assert_eq!(names, [crate::agents::LIST_TOOL, crate::agents::ASK_TOOL]); + // agents.ask's confirmation is the shell's own sheet, so the kernel + // holds the call as long as an approval, not a read tool's 30 s. + let ask = &declarations[1]; + assert_eq!((ask["risk"].as_str(), ask["outward"].as_bool(), ask["confirm"].as_str()), (Some("act"), Some(true), Some("app"))); match crate::agents::call(crate::agents::LIST_TOOL, &json!({})) { crate::ai_host::app_peers::host_tools::ToolOutcome::Ok(v) => { assert!(v["apps"].as_array().unwrap().iter().any(|a| a["app"] == "rinx" && a["what_to_do"].as_str().is_some()), "{v}"); diff --git a/crates/shell/src/apps.rs b/crates/shell/src/apps.rs index 66161ede..5cba914d 100644 --- a/crates/shell/src/apps.rs +++ b/crates/shell/src/apps.rs @@ -149,7 +149,8 @@ pub fn system_card_apps() -> Vec { /// instead (no keychain, no network): `MAKEPAD_APP_CONFIG='{"mail_demo":true}'`. /// `news` fetches News's feeds on a timer, with no model (ADR 0002), into /// the Card runner's host directory, so it keeps fetching while News is -/// closed. +/// closed. Last, every system app no service answers gets the notice +/// service (glance_notice.rs), for its agent's `.notify`. /// /// The `llm` service (AI providers, `os.ai-providers`) is the assistant's /// and registers with the kernel in `ai_host::start`, at startup, with the @@ -169,18 +170,18 @@ fn register_host_services() { } else { octosense_mail_service::register() } - // `mail.notify` (Mail's agent's tool) and Calendar's cards: a card - // published as the calling app, only when its manifest was granted - // `glance`. - let publish = |app: &str, args: serde_json::Value| { - let caller = crate::glance::Caller::Contained { app: app.to_string(), granted: crate::host_tools::script_apps::grants(app, "glance") }; - crate::glance::request(&caller, "glance.publish", &args) - }; - octosense_mail_service::on_publish_card(Some(std::sync::Arc::new(publish))); - // Calendar's events and cards (its agent's `calendar.*` tools). + // `mail.notify` (Mail's agent's tool): the shell's notice card, as + // Mail, only when its manifest was granted `glance`. + octosense_mail_service::on_notify(Some(std::sync::Arc::new(crate::glance_notice::notify))); + // Calendar's events and cards (its agent's `calendar.*` tools), + // published the same way. octosense_calendar_service::register(); - octosense_calendar_service::on_publish_card(Some(std::sync::Arc::new(publish))); + octosense_calendar_service::on_publish_card(Some(std::sync::Arc::new(|app: &str, args: serde_json::Value| crate::glance::publish_for(app, &args)))); register_news(); + // After every service of the shell's own: the notice service never + // stands in for one. + let served = crate::glance_notice::serve_system_apps(); + makepad_widgets::log!("glance: the notice service answers {served:?} (no service of their own)"); }); } @@ -190,16 +191,19 @@ fn register_host_services() { /// request instead. #[cfg(any(feature = "app-hub", native_mobile))] fn register_news() { - let mut options = octosense_news_service::Options::default().on_fetch(|report| { - // M3 routes this to News's peer, to wake its agent; logged for now. - let failed = report.sources.iter().filter(|s| s.status == "error").count(); - makepad_widgets::log!( - "news: fetched {} new, {} kept, {} sources ({failed} failed)", - report.new, - report.total, - report.sources.len() - ); - }); + let mut options = octosense_news_service::Options::default() + .on_fetch(|report| { + // M3 routes this to News's peer, to wake its agent; logged for now. + let failed = report.sources.iter().filter(|s| s.status == "error").count(); + makepad_widgets::log!( + "news: fetched {} new, {} kept, {} sources ({failed} failed)", + report.new, + report.total, + report.sources.len() + ); + }) + // `news.notify` (News's agent's tool): the shell's notice card, as News. + .on_notify(crate::glance_notice::notify); match octosense_app_hub_app::data_root_if_set() { Some(root) => { let host_dir = root.join(".host"); diff --git a/crates/shell/src/glance.rs b/crates/shell/src/glance.rs index fa3fff96..fd3c3e2d 100644 --- a/crates/shell/src/glance.rs +++ b/crates/shell/src/glance.rs @@ -525,6 +525,17 @@ pub fn register() { octosense_appstore::services::register_host_service(Box::new(GlanceService)); } +/// Publish a card for contained app `app` from one of its host services: +/// a tool call runs outside the app's isolate, where the Card runner's gate +/// does not, so the grant is the app's admitted manifest's `glance` +/// (`script_apps::grants`). Calendar's cards and every app's notice +/// (glance_notice.rs) go out this way. +#[cfg(any(feature = "app-hub", native_mobile))] +pub fn publish_for(app: &str, args: &Value) -> Result { + let caller = Caller::Contained { app: app.to_string(), granted: crate::host_tools::script_apps::grants(app, "glance") }; + request(&caller, "glance.publish", args) +} + // ---------------------------------------------------------------- the demo /// The sample News digest card (L0) and fake data, for trying the glance @@ -668,8 +679,6 @@ mod tests { assert_eq!(store.len(), 1); } - /// Mail's `mail.notify` card is a valid L0 card the store admits as - /// Mail's, and a Mail without the `glance` grant cannot publish it. /// Calendar's event and agenda cards are valid L0 cards the store /// admits as Calendar's. #[cfg(any(feature = "app-hub", native_mobile))] @@ -687,11 +696,13 @@ mod tests { assert!(shown.iter().all(|c| c.app == "os.calendar" && c.open_app == "calendar")); } + /// Mail's `mail.notify` card is a valid L0 card the store admits as + /// Mail's, and a Mail without the `glance` grant cannot publish it. #[cfg(any(feature = "app-hub", native_mobile))] #[test] fn mails_notice_card_is_admitted_as_mails_own() { let mut store = GlanceStore::default(); - let args = octosense_mail_service::notice_publish_args(&json!({"title": "Hello", "body": "From the system agent", "card_id": "hello"}), 1).unwrap(); + let args = crate::glance_notice::publish_args("os.mail", &json!({"title": "Hello", "body": "From the system agent", "card_id": "hello"}), 1).unwrap(); let ok = store.publish(&Caller::granted("os.mail"), &args, 1_000).unwrap(); assert_eq!(ok["card_id"], "hello"); let shown = store.shown(1_000, SHOWN_CARDS); @@ -700,6 +711,23 @@ mod tests { assert!(store.publish(&ungranted, &args, 1_000).is_err()); } + /// Every system app's notice (`.notify`) is a valid L0 card, + /// with that app's icon, that the store admits as that app's own and + /// that opens it; another app cannot publish it. + #[cfg(any(feature = "app-hub", native_mobile))] + #[test] + fn every_apps_notice_card_is_admitted_as_its_own() { + for app in ["os.news", "os.photos", "os.maps", "os.youtube", "os.camera", "os.calendar", "os.ai-providers"] { + let mut store = GlanceStore::default(); + let args = crate::glance_notice::publish_args(app, &json!({"title": "Hello", "body": "From the system agent", "card_id": "hello"}), 1).unwrap(); + let ok = store.publish(&Caller::granted(app), &args, 1_000); + assert!(ok.is_ok(), "{app}: {ok:?}"); + let shown = store.shown(1_000, SHOWN_CARDS); + assert_eq!((shown[0].app.as_str(), shown[0].open_app.as_str()), (app, app.strip_prefix("os.").unwrap())); + assert!(store.publish(&Caller::granted("os.mail"), &args, 1_000).unwrap_err().contains("opens the app that published it"), "{app}"); + } + } + #[test] fn a_contained_app_needs_the_glance_capability_whoever_it_is() { let mut store = GlanceStore::default(); diff --git a/crates/shell/src/glance_notice.rs b/crates/shell/src/glance_notice.rs new file mode 100644 index 00000000..f049aeb1 --- /dev/null +++ b/crates/shell/src/glance_notice.rs @@ -0,0 +1,258 @@ +//! A system app's notice (`.notify`): how an app's agent tells +//! the person something now, as that app, with a card on the glance screen +//! and a notification. +//! +//! | method | args | answer | +//! |---|---|---| +//! | `.notify` | `{title, body, card_id?, priority?}` | `{card_id, replaced, expires_at}` once the card is on the glance screen ([`notify`]) | +//! +//! **One card for every app.** [`NOTICE_CARD`] is a fixed L0 card: the +//! app's icon (a meaning from L0's closed set, [`icon`]) and name +//! ([`app_name`]), put in by the shell ([`card`]), and from the call the +//! title (1 to [`TITLE_MAX`] characters) and text (1 to [`BODY_MAX`]), with +//! the time ([`publish_args`]). A model writes text, never card code. The +//! card opens the app, posts a notification, and is published through the +//! glance service as the calling app, only when that app's admitted +//! manifest was granted `glance` ([`crate::glance::publish_for`]). The same +//! `card_id` replaces the app's earlier notice. +//! +//! **Who answers.** A tool runs on the host service of its namespace +//! (`host_tools::script_apps`). An app whose namespace has a service of its +//! own answers `notify` there and hands it here: Mail (`octosense_mail_ +//! service::on_notify`) and News (`octosense_news_service::Options:: +//! on_notify`). Every other system app gets [`NoticeService`]: registered +//! once, after the shell's own services ([`serve_system_apps`]), for each +//! system app no service answers, it serves that app alone, and only +//! `notify`. Its agent reaches it when the app's `tools.json` declares +//! `.notify` (Photos, Maps, YouTube, Camera). +use serde_json::{json, Value}; + +/// The notice card (L0), with slots for the app's icon and name ([`card`]). +pub const NOTICE_CARD: &str = include_str!("../resources/glance/notice.card"); +const ICON_SLOT: &str = "{icon}"; +const NAME_SLOT: &str = "\"{app}\""; +/// The caller's title and text, in characters. +pub const TITLE_MAX: usize = 80; +pub const BODY_MAX: usize = 600; +/// The priority of a notice that names none. +pub const PRIORITY_DEFAULT: i64 = 60; + +/// The app's icon on its notice: a meaning from L0's closed icon set. +pub fn icon(app: &str) -> &'static str { + match app.strip_prefix("os.").unwrap_or(app) { + "mail" => "mail", + "photos" => "image", + "maps" => "map", + "camera" => "camera", + "youtube" => "play", + "calendar" => "calendar", + _ => "bell", + } +} + +/// The app's name on its card and notification: the system app's manifest +/// name (`YouTube`), else a label made from its id. +pub fn app_name(app: &str) -> String { + octosense_app_hub_app::system_apps() + .into_iter() + .find(|a| a.id == app) + .map(|a| a.name.to_string()) + .unwrap_or_else(|| crate::approvals::sheet::app_label(app)) +} + +/// [`NOTICE_CARD`] for `app`: its icon, then its name as the card's copy, +/// a JSON string (what L0 reads a string as), so any name stays text. +pub fn card(app: &str, name: &str) -> String { + NOTICE_CARD.replace(ICON_SLOT, icon(app)).replace(NAME_SLOT, &Value::from(name).to_string()) +} + +/// `text` cut to `max` characters, an ellipsis last when it was longer. +fn clip(text: &str, max: usize) -> String { + if text.chars().count() <= max { + return text.to_string(); + } + let mut out: String = text.chars().take(max.saturating_sub(1)).collect(); + out.push('\u{2026}'); + out +} + +/// The `glance.publish` arguments for `app`'s notice: its [`card`], +/// filled from the call (`{title, body, card_id?, priority?}`), opening +/// `app`, with a notification titled ` · `. A blank or long +/// title or body is refused before anything is published; `now_ms` names a +/// notice that gives no `card_id`. +pub fn publish_args(app: &str, args: &Value, now_ms: u64) -> Result<Value, String> { + let field = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("").trim().to_string(); + let (title, body) = (field("title"), field("body")); + if title.is_empty() || title.chars().count() > TITLE_MAX { + return Err(format!("Provide a title of 1 to {TITLE_MAX} characters.")); + } + if body.is_empty() || body.chars().count() > BODY_MAX { + return Err(format!("Provide a body of 1 to {BODY_MAX} characters.")); + } + let card_id = match field("card_id") { + id if id.is_empty() => format!("notice-{now_ms:x}"), + id => id, + }; + let priority = args.get("priority").and_then(Value::as_i64).unwrap_or(PRIORITY_DEFAULT).clamp(0, 100); + let name = app_name(app); + let as_of = crate::shell::bar::local_time(c"%H:%M"); + Ok(json!({ + // The notification's title: the glance service takes at most + // `glance::TITLE_MAX` characters, the app's name included. + "card_id": card_id, "title": clip(&format!("{name} \u{00b7} {title}"), crate::glance::TITLE_MAX), + "source": card(app, &name), + "data": {"note": {"title": title, "summary": body, "as_of": as_of}}, + "priority": priority, "open": {"app": app.strip_prefix("os.").unwrap_or(app)}, "notify": true, + })) +} + +/// `<namespace>.notify` for `app`: its notice, published as `app`. +pub fn notify(app: &str, args: &Value) -> Result<Value, String> { + let publish = publish_args(app, args, crate::glance::now_ms())?; + crate::glance::publish_for(app, &publish) +} + +/// `<namespace>.notify` for one system app whose namespace no service of +/// its own answers. Only that app may call it. +pub struct NoticeService { + /// The system app (`os.photos`) and its namespace (`photos`). + app: &'static str, + family: &'static str, +} + +impl octosense_appstore::services::HostService for NoticeService { + fn family(&self) -> &'static str { + self.family + } + fn call(&mut self, call: octosense_appstore::services::ServiceCall, reply: octosense_appstore::services::Replier, _host: &mut dyn octosense_appstore::services::ServiceHost) { + if call.app_id != self.app { + return reply.send(Err(format!("{}.notify serves {} only", self.family, self.app))); + } + match call.method() { + "notify" => reply.send(notify(&call.app_id, &call.args)), + other => reply.send(Err(format!("{} has no method {other:?}", self.family))), + } + } +} + +/// Register [`NoticeService`] for every system app whose namespace no host +/// service answers: once, after the shell registered its own (Mail's, +/// Calendar's, News's), so it never replaces one. A service registered +/// later for that namespace replaces it, and answers `notify` itself. +/// The system apps that got one. +pub fn serve_system_apps() -> Vec<&'static str> { + let mut served = Vec::new(); + for app in octosense_app_hub_app::system_apps() { + let Some(family) = app.id.strip_prefix(octosense_appstore::system::SYSTEM_ID_PREFIX) else { continue }; + if octosense_appstore::services::has_service(family) { + continue; + } + octosense_appstore::services::register_host_service(Box::new(NoticeService { app: app.id, family })); + served.push(app.id); + } + served +} + +#[cfg(test)] +mod tests { + use super::*; + use octosense_appstore::services::{dispatch, take_replies_for, ServiceCall, ServiceHost}; + + /// A notice is the fixed card filled with the caller's text: it opens + /// the app and notifies, a missing `card_id` is made from the time and + /// the priority is clamped; a blank or long title or body is refused + /// before anything is published. Mail's is what `mail.notify` always + /// published. + #[test] + fn a_notice_is_the_fixed_card_filled_with_the_callers_text() { + let args = publish_args("os.mail", &json!({"title": "Hello", "body": "From the system agent", "card_id": "hello"}), 1).unwrap(); + assert_eq!(args["card_id"], "hello"); + assert_eq!(args["title"], "Mail \u{00b7} Hello"); + let source = args["source"].as_str().unwrap(); + assert_eq!(source, card("os.mail", "Mail")); + assert!(source.contains("copy app { class: vocabulary, en: \"Mail\" }") && source.contains("Icon(name: .mail, size: .row)"), "{source}"); + assert_eq!(args["data"]["note"]["title"], "Hello"); + assert_eq!(args["data"]["note"]["summary"], "From the system agent"); + assert_eq!((args["open"]["app"].as_str(), args["notify"].as_bool()), (Some("mail"), Some(true))); + assert_eq!(args["priority"], 60); + let generated = publish_args("os.mail", &json!({"title": "Hi", "body": "x", "priority": 500}), 0xabc).unwrap(); + assert_eq!((generated["card_id"].as_str(), generated["priority"].as_i64()), (Some("notice-abc"), Some(100))); + assert!(publish_args("os.mail", &json!({"title": " ", "body": "x"}), 0).is_err()); + assert!(publish_args("os.mail", &json!({"title": "x".repeat(81), "body": "x"}), 0).is_err()); + assert!(publish_args("os.mail", &json!({"title": "x", "body": "y".repeat(601)}), 0).is_err()); + // A full-length title still makes a notification the glance + // service takes: the app's name comes first, the end is cut. + let long = publish_args("os.youtube", &json!({"title": "t".repeat(80), "body": "x"}), 0).unwrap(); + let toast = long["title"].as_str().unwrap(); + assert!(toast.starts_with("YouTube \u{00b7} ttt") && toast.ends_with('\u{2026}') && toast.chars().count() == crate::glance::TITLE_MAX, "{toast}"); + assert_eq!(long["data"]["note"]["title"], "t".repeat(80), "the card keeps the whole title"); + } + + /// Each app's notice carries its own name and icon, and opens it. + #[test] + fn each_apps_notice_is_its_own() { + for (app, name, icon) in [("os.photos", "Photos", "image"), ("os.maps", "Maps", "map"), ("os.youtube", "YouTube", "play"), ("os.news", "News", "bell")] { + let args = publish_args(app, &json!({"title": "Hello", "body": "Hi"}), 1).unwrap(); + assert_eq!(args["title"], format!("{name} \u{00b7} Hello")); + let source = args["source"].as_str().unwrap(); + assert!(source.contains(&format!("en: \"{name}\" }}")) && source.contains(&format!("Icon(name: .{icon}, size: .row)")), "{app}: {source}"); + assert_eq!(args["open"]["app"], app.strip_prefix("os.").unwrap()); + } + assert_eq!(icon("os.camera"), "camera", "Camera ships on the phone only; its icon is in the set"); + } + + /// The name is the card's text, never its code: quotes, backslashes or + /// a line break in it leave a valid L0 card that shows them. + #[test] + fn an_apps_name_stays_text_in_its_card() { + let source = card("os.photos", "Odd \"name\" \\ with\na break"); + assert!(crate::glance::check_level(&source).is_ok(), "{source}"); + assert!(source.contains(r#"en: "Odd \"name\" \\ with\na break" }"#), "{source}"); + } + + struct NoSheets; + impl ServiceHost for NoSheets { + fn open_sheet(&mut self, _body: String) {} + fn close_sheet(&mut self) {} + } + + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(usize::MAX / 4); + + fn ask(app: &str, service: &str, args: Value) -> Result<Value, String> { + let heap = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let call = ServiceCall { app_id: app.into(), service: service.into(), args, from_sheet: false, may_prompt: false, host_dir: std::env::temp_dir() }; + dispatch(call, heap, 1, &mut NoSheets); + let (_, _, answer) = take_replies_for(&[heap]).pop().expect("answered at once"); + answer.map(|json| serde_json::from_str(&json).unwrap()) + } + + /// Once the shell's own services are registered, every system app no + /// service answers gets the notice service, which serves that app + /// alone and only `notify`; an app's own service (Mail's) is not + /// replaced. A hyphenated namespace (`ai-providers`) routes like any + /// other: the shell splits the family at the first dot. + #[test] + fn system_apps_without_a_service_of_their_own_get_the_notice_service() { + // Registers the shell's services, then the notice services. + let _ = crate::apps::system_card_apps(); + for family in ["photos", "maps", "youtube", "ai-providers"] { + assert!(octosense_appstore::services::has_service(family), "{family}"); + } + assert!(ask("os.maps", "photos.notify", json!({"title": "x", "body": "y"})).unwrap_err().contains("serves os.photos only")); + assert!(ask("os.photos", "photos.list", json!({})).unwrap_err().contains("no method")); + // Its own app reaches the notice (a blank title is refused there, + // before anything is published). + for (app, service) in [("os.photos", "photos.notify"), ("os.ai-providers", "ai-providers.notify")] { + let refused = ask(app, service, json!({"title": " ", "body": "Hi"})).unwrap_err(); + assert!(refused.contains("Provide a title"), "{service}: {refused}"); + } + // Mail answers its own namespace: its service, not a notice service. + let dir = std::env::temp_dir().join(format!("notice-mail-{}", std::process::id())); + let call = ServiceCall { app_id: "os.mail".into(), service: "mail.accounts".into(), args: json!({}), from_sheet: false, may_prompt: false, host_dir: dir.clone() }; + let heap = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + dispatch(call, heap, 1, &mut NoSheets); + assert_eq!(take_replies_for(&[heap]).pop().unwrap().2.unwrap(), "[]"); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/shell/src/host_tools/script_apps.rs b/crates/shell/src/host_tools/script_apps.rs index 4ebedb0c..53b1a3ee 100644 --- a/crates/shell/src/host_tools/script_apps.rs +++ b/crates/shell/src/host_tools/script_apps.rs @@ -20,7 +20,8 @@ //! `host.request("news.list", …)` would: with the app's identity, never //! from a sheet, and only when the app's manifest was granted that family, //! or the family is a system app's own namespace (`os.calendar` and its -//! `calendar` service, which ship with the shell). +//! `calendar` service, which ship with the shell; `os.photos`'s +//! `photos.notify` and the shell's notice service, `glance_notice`). //! A tool the app's own script implements (`implemented_by: "app"`) needs //! the app open, and is refused visibly until the Card runner can take it. //! Answers arrive on App Hub's reply queue; [`poll`] (from @@ -285,16 +286,20 @@ pub(crate) mod tests { } /// G3 (e): News's bundle offers its agent (and, shared, others) real - /// read tools on its host service. + /// read tools on its host service, and its agent alone `news.notify` + /// (a notice card as News: News is granted `glance`). #[test] fn news_offers_its_read_tools_from_its_bundle() { let dir = stamped_bundle("news", "tools", |_, _| {}); let loaded = from_bundle(&dir).unwrap(); let names: Vec<&str> = loaded.tools.iter().filter_map(|t| t["name"].as_str()).collect(); - assert_eq!(names, ["news.list", "news.read"]); - assert!(loaded.tools.iter().all(|t| t["risk"] == "read" && t["shareable"] == true && t.get("implemented_by").is_none())); - assert_eq!(loaded.host_service_tools.len(), 2); - assert!(loaded.families.contains("news"), "News is granted its service"); + assert_eq!(names, ["news.list", "news.read", "news.notify"]); + let (read, notify) = loaded.tools.split_at(2); + assert!(read.iter().all(|t| t["risk"] == "read" && t["shareable"] == true && t.get("implemented_by").is_none())); + assert_eq!((notify[0]["risk"].as_str(), notify[0]["shareable"].as_bool()), (Some("act"), Some(false)), "News's notices are its own agent's"); + assert!(loaded.tools.iter().all(|t| t["input_schema"]["type"] == "object" && t["output_schema"]["type"] == "object")); + assert_eq!(loaded.host_service_tools.len(), 3); + assert!(["news", "glance"].iter().all(|f| loaded.families.contains(*f)), "News is granted its service and glance"); assert_eq!(loaded.generic, ["ask_user_question"], "News's agent may ask the person"); // A tampered bundle is refused (App Hub's digest check). std::fs::write(dir.join("tools.json"), "{}").unwrap(); @@ -337,6 +342,48 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(dir); } + /// Photos, Maps, YouTube and Camera each give their agent one tool, + /// `<namespace>.notify`, on their own namespace: no service of their + /// own answers it, so the shell's notice service does + /// (glance_notice.rs). Each is granted `glance`, and nothing else new. + #[test] + fn photos_maps_youtube_and_camera_offer_notify_from_their_bundles() { + for (app, kept) in [("photos", &["storage"][..]), ("maps", &["storage", "net", "location"]), ("youtube", &["storage", "net"]), ("camera", &["storage", "camera", "microphone", "library"])] { + let dir = stamped_bundle(app, "notify", |_, _| {}); + let loaded = from_bundle(&dir).unwrap(); + let _ = std::fs::remove_dir_all(dir); + let names: Vec<&str> = loaded.tools.iter().filter_map(|t| t["name"].as_str()).collect(); + assert_eq!(names, [format!("{app}.notify")], "{app}"); + assert_eq!(loaded.host_service_tools.len(), 1, "{app}"); + let tool = &loaded.tools[0]; + assert!(tool["input_schema"]["type"] == "object" && tool["output_schema"]["type"] == "object", "{app}: octos takes object schemas only"); + assert_eq!((tool["risk"].as_str(), tool["shareable"].as_bool(), tool["background"].as_bool()), (Some("act"), Some(false), Some(true)), "{app}"); + let mut granted: Vec<&str> = kept.to_vec(); + granted.push("glance"); + assert_eq!(loaded.families, granted.iter().map(|f| f.to_string()).collect::<BTreeSet<String>>(), "{app}"); + assert_eq!(loaded.generic, ["ask_user_question"], "{app}"); + } + } + + /// AI providers (`os.ai-providers`) cannot declare tools yet: App Hub + /// takes a tool namespace only as `[a-z0-9_]` (and octos a tool name's + /// segments only as `[a-z][a-z0-9_]`), so `ai-providers.notify` is + /// refused, and with it the whole agent block. The shell's side takes a + /// hyphen (glance_notice.rs). When App Hub admits one, this fails: give + /// AI providers its agent then. + #[test] + fn a_hyphenated_namespace_cannot_declare_tools_yet() { + let dir = stamped_bundle("ai-providers", "notify", |dir, m| { + m["agent"] = json!({"profile": "read-only", "tools": ["ask_user_question"], "model": {"needs": ["tool_calling"]}}); + let mut tools: Value = serde_json::from_str(&std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps/photos/bundle/tools.json")).unwrap()).unwrap(); + tools["tools"][0]["name"] = json!("ai-providers.notify"); + std::fs::write(dir.join("tools.json"), tools.to_string()).unwrap(); + }); + let refused = from_bundle(&dir).unwrap_err(); + let _ = std::fs::remove_dir_all(dir); + assert!(refused.contains("namespace \"ai-providers\""), "{refused}"); + } + /// The manifest's `agent.tools`: a plain name is a kernel tool, and App /// Hub admits only `ask_user_question` (any other, `web_search` or /// octos's shell, refuses the bundle); dotted ones are other apps' diff --git a/crates/shell/src/lib.rs b/crates/shell/src/lib.rs index 3c5c374d..1eabdba8 100644 --- a/crates/shell/src/lib.rs +++ b/crates/shell/src/lib.rs @@ -72,6 +72,8 @@ pub mod ext; pub mod glance; pub mod glance_card; pub mod glance_chat; +#[cfg(any(feature = "app-hub", native_mobile))] +pub mod glance_notice; pub mod glance_panel; pub mod glance_sheet; use glance::NoteTargets as GlanceNoteTargets; diff --git a/crates/shell/src/shell/bar.rs b/crates/shell/src/shell/bar.rs index 79348d2b..e1892f04 100644 --- a/crates/shell/src/shell/bar.rs +++ b/crates/shell/src/shell/bar.rs @@ -244,7 +244,12 @@ fn run(cmd: &str, args: &[&str]) -> Option<String> { /// `date` reads). Forking `date` twice a second cost an idle phone 11% of a /// core on this thread alone. pub fn sample_clock(alt: bool) -> String { - let fmt: &std::ffi::CStr = if alt { c"%-d %B W%V %Y" } else { c"%A %H:%M" }; + local_time(if alt { c"%-d %B W%V %Y" } else { c"%A %H:%M" }) +} + +/// Now, in local time, formatted by `strftime`'s `fmt` (`%H:%M`): the C +/// library's `localtime_r` on Unix, `date` elsewhere. +pub fn local_time(fmt: &std::ffi::CStr) -> String { #[cfg(unix)] { let mut buf = [0u8; 64]; diff --git a/crates/shell/src/system_chat/mod.rs b/crates/shell/src/system_chat/mod.rs index 9ac7314e..8816c3d8 100644 --- a/crates/shell/src/system_chat/mod.rs +++ b/crates/shell/src/system_chat/mod.rs @@ -94,8 +94,24 @@ struct Chat { told: Option<String>, /// The shell's own lines in the conversation (an agents.ask sheet). notes: Vec<model::Item>, + /// `agents.ask` calls held until the person answered the first-use + /// sheet and the agent's peer started ([`settle_asks`]). + asks: Vec<HeldAsk>, } +/// An `agents.ask` call the system chat holds: the app, the call's reply, +/// and when it came. +struct HeldAsk { + app: crate::apps::AgentApp, + reply: crate::ai_host::app_peers::host_tools::ToolReply, + since: std::time::Instant, +} + +/// How long a held `agents.ask` waits for the person before it answers +/// that they have not (the system agent tells them; the sheet stays up). +/// The kernel holds it longer (`confirm: app`, agents.rs `declarations`). +const ASK_WAIT: Duration = Duration::from_secs(600); + /// The id prefix of a routed question in the conversation. pub const ROUTED_PREFIX: &str = "routed:"; @@ -150,6 +166,7 @@ fn with<R>(f: impl FnOnce(&mut Chat) -> R) -> R { routed: Vec::new(), told: None, notes: Vec::new(), + asks: Vec::new(), }); f(chat) } @@ -413,23 +430,41 @@ pub fn pump() { } } Effect::ApprovalGone(_) => {} - // `agents.list` / `agents.ask`: the shell's own answer (the - // sheet `agents.ask` shows is the person's to answer). - Effect::ToolCall { call, reply } if crate::agents::is_agents_tool(&call.name) => { - let outcome = crate::agents::call(&call.name, &call.args); - if call.name == crate::agents::ASK_TOOL { - let app = call.args["app"].as_str().unwrap_or("an app").to_string(); - with(|c| { - c.notes.push(model::Item::Notice(format!("The system agent asks to use {app}'s assistant: allow or deny it on the sheet."))); - c.ui_generation += 1; - }); + // `agents.ask`: the first-use sheet is the person's to answer. + // The call waits for the answer and the agent's peer, so the + // system agent goes on with the request in the same turn. + Effect::ToolCall { call, reply } if call.name == crate::agents::ASK_TOOL => match crate::agents::ask_app(&call.args) { + Err(outcome) => { + reply.finish(outcome); + } + Ok(app) => { + if crate::agents::begin_ask(&app) { + let name = app.name.clone(); + with(|c| { + c.notes.push(model::Item::Notice(format!("The system agent asks to use {name}'s assistant: allow or deny it on the sheet."))); + c.ui_generation += 1; + }); + } + match crate::agents::ask_settled(&app) { + Some(outcome) => { + reply.finish(outcome); + } + None => { + reply.acknowledge(); + with(|c| c.asks.push(HeldAsk { app, reply, since: std::time::Instant::now() })); + } + } } - reply.finish(outcome); + }, + // `agents.list`: the shell's own answer. + Effect::ToolCall { call, reply } if crate::agents::is_agents_tool(&call.name) => { + reply.finish(crate::agents::call(&call.name, &call.args)); } Effect::ToolCall { call, reply } => crate::host_tools::system_call(call, reply), Effect::ToolCancel(call_id) => crate::host_tools::system_cancel(&call_id), } } + settle_asks(); for (id, decision, _reason) in crate::approvals::take_system_chat_decisions() { if let Some(approval_id) = id.0.strip_prefix(HELD_PREFIX) { command(Command::Approval { approval_id: approval_id.to_string(), approve: decision.approved() }); @@ -437,6 +472,31 @@ pub fn pump() { } } +/// Answer the held `agents.ask` calls that can be: the person answered and +/// the peer started (or could not), or the wait ran out. A cancelled call +/// (the turn was stopped) is dropped. +fn settle_asks() { + let asks = with(|c| std::mem::take(&mut c.asks)); + let mut held = Vec::new(); + for ask in asks { + if !ask.reply.is_open() { + continue; + } + if let Some(outcome) = crate::agents::ask_settled(&ask.app) { + log!("system chat: agents.ask for {} answered: {}", ask.app.id, crate::agents::access(&ask.app.id).as_str()); + ask.reply.finish(outcome); + } else if ask.since.elapsed() >= ASK_WAIT { + log!("system chat: agents.ask for {} still waits for the person; answered so", ask.app.id); + ask.reply.finish(crate::agents::ask_pending(&ask.app)); + } else { + held.push(ask); + } + } + if !held.is_empty() { + with(|c| c.asks.extend(held)); + } +} + /// One of the conversation's approvals, to the router: the system agent's /// call, batched per request (its turn; the prompt is the plan). pub fn route_approval(ask: &model::ApprovalAsk) -> crate::approvals::Route {