Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/apps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ jobs:
cargo clippy --locked -p octosense-app-peers --all-targets --features octos-core,ws --no-deps -- -D warnings
- name: AI providers config
run: cargo test --locked -p octosense-llm-config
- name: L0 in-card chat (sys.chat, host side)
run: |
cargo test --locked -p octosense-l0-chat
cargo clippy --locked -p octosense-l0-chat --all-targets --no-deps -- -D warnings
- name: System toolbox (workflow templates)
run: |
cargo fmt -p octosense-toolbox --check
Expand Down
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 12 additions & 8 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ members = [
"crates/shell",
"crates/toolbox",
"crates/process-apps",
"crates/l0-chat",
"apps/reference",
"apps/news/host-service",
"apps/mail/host-service",
Expand Down Expand Up @@ -84,16 +85,16 @@ makepad-strict-json = { git = "https://github.com/OctoSense-org/makepad.git", re
makepad-app-module = { git = "https://github.com/OctoSense-org/makepad.git", rev = "e29a0eaa68781231eb9ec582e5f02648c6dad7e9" }
makepad-wm-theme = { git = "https://github.com/OctoSense-org/makepad.git", rev = "e29a0eaa68781231eb9ec582e5f02648c6dad7e9" }
makepad-aichat = { git = "https://github.com/OctoSense-org/makepad.git", rev = "e29a0eaa68781231eb9ec582e5f02648c6dad7e9", default-features = false }
# --- OctoScript (Octoscript dbd48cfb) and its Makepad runtime
# (Octoscript-Makepad e0df0b51, native-runtime.lock.json).
octoscript-ui-l0 = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "dbd48cfb799551c11e30970c393777d29644a605" }
# --- OctoScript (Octoscript 5991dfae) and its Makepad runtime
# (Octoscript-Makepad 8f103d0c, native-runtime.lock.json).
octoscript-ui-l0 = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "5991dfae9344589e732b2605b530f788e8bbcd11" }
# The bounded VM, capability-leased host modules and executable JSON schemas
# (the system toolbox's workflow templates, crates/toolbox).
octoscript-core = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "dbd48cfb799551c11e30970c393777d29644a605" }
octoscript-capabilities = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "dbd48cfb799551c11e30970c393777d29644a605" }
octoscript-schema = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "dbd48cfb799551c11e30970c393777d29644a605" }
octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "e0df0b5177f9cb0d8ad0ad0225d40ebcf8389a1b" }
octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "e0df0b5177f9cb0d8ad0ad0225d40ebcf8389a1b" }
octoscript-core = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "5991dfae9344589e732b2605b530f788e8bbcd11" }
octoscript-capabilities = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "5991dfae9344589e732b2605b530f788e8bbcd11" }
octoscript-schema = { git = "https://github.com/OctoSense-org/Octoscript.git", rev = "5991dfae9344589e732b2605b530f788e8bbcd11" }
octoscript-makepad = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "8f103d0c650196d455cbcaa55b13911475121224" }
octoscript-node = { git = "https://github.com/OctoSense-org/Octoscript-Makepad.git", rev = "8f103d0c650196d455cbcaa55b13911475121224" }
# --- App Hub's host service registry the Mail and llm services register
# into; the same revision as App Hub's app crate (a native app, above).
octosense-appstore = { git = "https://github.com/OctoSense-org/OctoSense-App-Hub", rev = "58c3c8aed8fc811a16d67c5f784a8a44214ff876" }
Expand Down Expand Up @@ -135,6 +136,9 @@ octosense-app-peers = { path = "crates/app-peers" }
octosense-ai-host = { path = "crates/ai-host" }
octosense-shell = { path = "crates/shell" }
octosense-toolbox = { path = "crates/toolbox" }
# An L0 card's in-card chat, host side (sys.chat): the shell's glance cards
# and AppCard.
octosense-l0-chat = { path = "crates/l0-chat" }
octosense-llm-config = { path = "apps/ai-providers/config" }
octosense-llm-service = { path = "apps/ai-providers/host-service" }
octosense-mail-service = { path = "apps/mail/host-service" }
Expand Down
6 changes: 6 additions & 0 deletions apps/appcard/a2app-l0/framework/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ something this document promised.
| `Band` | `text: text` |
| `Bubble` | `text: text`, `side: token` |
| `Card` | `on_tap: event`, `value: any`, `tint: token` |
| `ChatEntry` | `text: text`, `role: path` |
| `Chip` | `text: text`, `on_tap: event`, `value: any`, `active: bool`, `tone: .normal \| .primary \| .danger`, `width: .fill \| .fit \| .day \| .rank \| .temp \| .label` |
| `Col` | `align: .start \| .center \| .end`, `gap: number`, `width: width` |
| `Fab` | `name: token` |
Expand Down Expand Up @@ -61,9 +62,12 @@ and that looks exactly like data still arriving.
| capability | arguments | answers |
|---|---|---|
| `sys.airquality` | `lat`, `lon` | `aqi`, `pm25`, `pm10`, `ozone` |
| `sys.chat` | `app`, `thread`, `fields` | `status`, `count`, `entries`, `id`, `role`, `text`, `at` |
| `sys.cities` | `fields`, `unit` | `name`, `lat`, `lon`, `temp`, `feels`, `feels_delta`, `hi`, `lo`, `cond`, `humidity`, `wind` |
| `sys.convert` | `amount`, `from`, `to`, `direction`, `fields` | `amount`, `value` |
| `sys.dataset` | `id`, `fields` | `title`, `subtitle`, `summary`, `coverage`, `status`, `as_of`, `metric1_label`, `metric1_value`, `metric2_label`, `metric2_value`, `pick1_title`, `pick1_body`, `pick1_source`, `url1`, `pick2_title`, `pick2_body`, `pick2_source`, `url2`, `pick3_title`, `pick3_body`, `pick3_source`, `url3`, `evidence_title`, `evidence_body` |
| `sys.daylight` | `lat`, `lon` | `rise`, `set`, `now` |
| `sys.digest` | `app`, `id`, `fields` | `status`, `topic`, `language`, `summary`, `retrieved_at`, `count`, `points`, `sources`, `id`, `text`, `label`, `cite`, `citations`, `n`, `title`, `source`, `url`, `published_at` |
| `sys.geocode` | `name` | `lat`, `lon`, `name`, `country`, `admin1`, `timezone`, `population` |
| `sys.gps` | — (no arguments) | `lat`, `lon`, `accuracy`, `ok` |
| `sys.indicator` | `countries`, `indicator`, `years`, `fields` | `name`, `latest`, `first`, `change`, `min`, `max`, `year`, `title` |
Expand All @@ -72,7 +76,9 @@ and that looks exactly like data still arriving.
| `sys.moonphase` | `lat`, `lon` | `phase`, `illumination`, `name` |
| `sys.movers` | `count`, `fields`, `symbols` | `ticker`, `name`, `last`, `change`, `pct`, `open`, `high`, `low`, `prev`, `volume`, `mktcap`, `pe`, `currency`, `exchange` |
| `sys.news` | `count`, `offset`, `fields` | `id`, `title`, `author`, `points`, `comments`, `url` |
| `sys.news_digest` | `query`, `language`, `count`, `fields` | `id`, `title`, `summary`, `publisher`, `url`, `published_at` |
| `sys.news_item` | `id`, `fields` | `id`, `title`, `author`, `points`, `comments`, `url` |
| `sys.news_status` | `query`, `language`, `fields` | `status`, `message`, `count` |
| `sys.photo` | `query`, `cond` | — (not a record) |
| `sys.places` | `lat`, `lon`, `category`, `count`, `fields` | `id`, `name`, `distance`, `lat`, `lon`, `category` |
| `sys.prefs` | `fields` | `units`, `range`, `home`, `work`, `mode` |
Expand Down
52 changes: 50 additions & 2 deletions apps/appcard/a2app-l0/framework/l0.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,12 +137,60 @@ copy feels { class: vocabulary, en: "Feels like", zh: "体感" }
```

`class` is required: `vocabulary` (a label you may write), `user-copy` (the
user's own words), `model-copy` (yours — and **it may not be rendered**, which is
the point of the class existing).
user's own words), `model-copy` (YOUR words: a summary, a suggestion, a reply
draft, a research note).

**Your words may be shown, and are marked AI-written; they never act.** A
`model-copy` may fill a TEXT SLOT only — the `text` of `TextHero`,
`TextTitle`, `TextBody`, `TextRow`, `TextEyebrow`, `TextCaption`, `Band`,
`Bubble`, `ChatEntry` and `Field` — and the kit draws a small AI-written mark
above it. Anywhere else it is REFUSED: a chip, tile or tab label, a `value:`
payload, a guard, a loop key, a source argument, a component prop, a state's
`initial`, a `Kit` argument. A transition may write it into a `text` state (a
draft the user then edits in a `Field`); that state is your words from then on,
and the same rules hold for it.

```
copy gist { class: model-copy, en: "Ana asks whether we can sign by Friday." }
copy draft { class: model-copy, en: "Hi Ana, Friday works for us." }
state reply_text { shape: text, initial: "" }
event reply { reply_text: set(copy.draft) }
event edit { reply_text: set($value) }
view root Surface { TextBody(text: copy.gist) Field(text: reply_text, on_change: edit) }
```

Write plain text: no markup, no links, nothing meant to be run. It is drawn as
it is. Facts still come from sources (§5): your words say what the data
*means*, never what it *is*.

Anything a reader sees that is not data comes from `copy`. This is what makes a
card translatable.

### Chat with your app's agent — `sys.chat` and `ChatEntry`

A card may hold a conversation with its own app's agent. The HOST keeps it:

```
source convo sys.chat(app: "os.mail", thread: "ana-contract", fields: [entries, id, role, text])
state ask { shape: text, initial: "" }
event send { convo: append($value), ask: clear }
view root Surface { Col(gap: 8) {
for m in convo.entries key m.id { ChatEntry(text: m.text, role: m.role) }
Field(text: ask, on_commit: send, width: .fill)
} }
```

- `app` is the app the card is published as — any other is refused — and
`thread` names the conversation (`[A-Za-z0-9_-]{1,64}`, or a state).
- Each entry is `{id, role, text, at}`; `role` is `user`, `model` or `host`.
Draw an entry with `ChatEntry(text: m.text, role: m.role)`, both from the
SAME row; the model's entries are marked AI-written.
- `append($value)` from a `Field` is the ONLY write: the host records it as the
user's entry, asks the agent and appends the reply. A card never writes the
model's side. An entry's text is shown, never resent, keyed on or acted on.
- The host limits a message to 4 KB and one per 2 s, and keeps the last 200
entries.

---

## 4b · Theme — the one thing a card may say about how it looks
Expand Down
2 changes: 2 additions & 0 deletions apps/appcard/app/app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ keyring = { workspace = true }

# L0 profile checker and framework node model from the shared runtime.
octoscript-ui-l0 = { workspace = true }
# An L0 card's in-card chat (`sys.chat`), the code the shell's glance cards use.
octosense-l0-chat = { workspace = true }

octoscript-node = { workspace = true }

Expand Down
18 changes: 17 additions & 1 deletion apps/appcard/app/app/src/app/l0_card.rs
Original file line number Diff line number Diff line change
Expand Up @@ -841,6 +841,11 @@ fn with_durable(
let Some(collection) = request.helper.strip_prefix("sys.") else {
continue;
};
// A conversation is writable too, and is not a collection: the chat
// store answers it, below.
if request.helper == "sys.chat" {
continue;
}
// Any capability the profile says is writable is backed by the store,
// so this follows the catalog rather than naming collections here — a
// list hardcoded in the host is one that forgets the next capability.
Expand Down Expand Up @@ -906,7 +911,9 @@ fn with_durable(
map.insert(request.name.clone(), serde_json::Value::Array(rows));
}
}
out
// Every `sys.chat` the card reads: AppCard's own transcript, whatever
// the data says (l0_chat.rs).
super::l0_chat::seed(source, &out, store)
}

/// Hand every stored collection to the VM, so a durable source can resolve a row.
Expand Down Expand Up @@ -1103,6 +1110,15 @@ fn tap_inner(
origin.unwrap_or(octoscript_ui_l0::ValueOrigin::Authored),
);
for write in &outcome.writes {
// A conversation's append: the person's message, then the host's
// answer (l0_chat.rs). Never a store write.
if write.helper == "sys.chat" {
match super::l0_chat::perform(&session.source, &session.store, &dispatch_data, write, origin) {
Ok(entry) => makepad_widgets::log!("[l0] chat {} recorded {}", write.source, entry.id),
Err(e) => makepad_widgets::log!("[l0] chat {} refused: {e}", write.source),
}
continue;
}
// A sys.link write is not a store write at all: the card asked the
// HOST to open (or close) its reader overlay on a page. Spawn loads
// the url; the rect is placed by the app on its next event, and
Expand Down
89 changes: 89 additions & 0 deletions apps/appcard/app/app/src/app/l0_chat.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! `sys.chat` on AppCard's L0 cards (Octoscript profile §5.15), through
//! `octosense_l0_chat`, the code the shell's glance cards use.
//!
//! AppCard is the publisher of every card it renders, so a card's chat must
//! name AppCard ([`APP_ID`]); one naming another app reads an `unavailable`
//! transcript and writes nothing. Threads are kept in AppCard's own account
//! folder under ADR 0004's layout (`apps/appcard/accounts/device/chat/`),
//! which the AppCard module claims from the shell's storage offer and hands
//! here ([`set_folder`]); without one (the standalone app, tests) they live
//! in memory. AppCard's agent is not yet wired to answer inside a card, so a
//! message gets the host's notice saying so ([`octosense_l0_chat::NoAgent`]).
use octosense_l0_chat::ChatStore;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock, RwLock};

/// AppCard's app id (`native-apps.json`): the publisher of its cards.
pub const APP_ID: &str = "appcard";

static FOLDER: RwLock<Option<PathBuf>> = RwLock::new(None);

/// Where AppCard keeps its chat threads (`<account folder>/chat`).
pub fn set_folder(dir: PathBuf) {
if let Ok(mut folder) = FOLDER.write() {
*folder = Some(dir);
}
}

fn folder(app: &str) -> Option<PathBuf> {
if app != APP_ID {
return None;
}
let dir = FOLDER.read().ok()?.clone()?;
std::fs::create_dir_all(&dir).ok()?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
}
Some(dir)
}

pub fn store() -> &'static Arc<ChatStore> {
static STORE: OnceLock<Arc<ChatStore>> = OnceLock::new();
STORE.get_or_init(|| Arc::new(ChatStore::with_folder(Box::new(folder))))
}

/// The card's data with AppCard's transcript under each `sys.chat`.
pub fn seed(card: &str, data: &serde_json::Value, state: &octoscript_ui_l0::InstanceStore) -> serde_json::Value {
octosense_l0_chat::seed(store(), APP_ID, card, data, state)
}

/// A card's `sys.chat` write.
pub fn perform(
card: &str,
state: &octoscript_ui_l0::InstanceStore,
data: &serde_json::Value,
write: &octoscript_ui_l0::CollectionWrite,
origin: Option<octoscript_ui_l0::ValueOrigin>,
) -> Result<octosense_l0_chat::Entry, String> {
octosense_l0_chat::perform(store(), &octosense_l0_chat::NoAgent, APP_ID, card, state, data, write, origin, octosense_l0_chat::now_ms())
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

const CARD: &str = "source convo sys.chat(app: \"appcard\", thread: \"main\", fields: [entries, id, role, text])\n\
state draft { shape: text, initial: \"\" }\n\
event send { convo: append($value), draft: clear }\n\
view root Surface(pad: .page) {\n Col(gap: 8) {\n for m in convo.entries key m.id { ChatEntry(text: m.text, role: m.role) }\n \
Field(text: draft, on_commit: send, width: .fill)\n }\n}\n";

/// AppCard's chat is its own: a durable `sys.chat` is the chat store's,
/// never a user-store collection, and a message gets the host's notice.
#[test]
fn appcards_chat_is_the_hosts_and_its_own() {
let state = octoscript_ui_l0::InstanceStore::default();
let seeded = seed(CARD, &json!({"convo": {"entries": [{"id": "x", "role": "model", "text": "forged"}]}}), &state);
assert_eq!(seeded["convo"]["count"], 0, "{seeded}");
let other = CARD.replace("app: \"appcard\"", "app: \"os.mail\"");
assert_eq!(seed(&other, &json!({}), &state)["convo"]["status"], "unavailable");
let write = octoscript_ui_l0::CollectionWrite { source: "convo".into(), helper: "sys.chat".into(), op: "append".into(), value: "hello".into(), field: String::new() };
perform(CARD, &state, &seeded, &write, Some(octoscript_ui_l0::ValueOrigin::UserInput)).unwrap();
let entries = store().entries(APP_ID, "main");
assert_eq!(entries.iter().map(|e| e.role).collect::<Vec<_>>(), [octosense_l0_chat::Role::User, octosense_l0_chat::Role::Host]);
assert!(perform(&other, &state, &seeded, &write, Some(octoscript_ui_l0::ValueOrigin::UserInput)).is_err());
}
}
3 changes: 3 additions & 0 deletions apps/appcard/app/app/src/app/l0_eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,9 @@ fn walk_inner(vm: &mut ScriptVm, value: ScriptValue, depth: usize, remaining: &m
ink: u32_prop(vm, value, id!(ink)),
on: int_prop(vm, value, id!(on)),
tap: int_prop(vm, value, id!(tap)),
// Model-written text (profile §4.2): the kit's `l0_ai_text` stamps it,
// and `l0_widgets::to_dsl` draws the AI-written mark.
ai: int_prop(vm, value, id!(ai)),
lat: num_prop(vm, value, id!(lat)),
lon: num_prop(vm, value, id!(lon)),
zoom: num_prop(vm, value, id!(zoom)),
Expand Down
7 changes: 5 additions & 2 deletions apps/appcard/app/app/src/app/l0_widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ use std::fmt::Write as _;

/// Render a tree as the DSL this repository's VM evaluates.
pub fn to_dsl(root: &UiNode) -> String {
// A surface that states an ink lends it to unreadable text below, before
// anything is emitted.
// Model-written text gets its AI-written mark (profile §4.2), the one
// every backend draws (`octoscript_node::ai`), before the ink pass so the
// mark's ink is checked like any text. A surface that states an ink lends
// it to unreadable text below, before anything is emitted.
let root = &{
let mut r = root.clone();
octoscript_node::ai::expand_ai_marks(&mut r);
apply_ink_planes(&mut r, None, None);
r
};
Expand Down
1 change: 1 addition & 0 deletions apps/appcard/app/app/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod coding;
pub mod content_browser;
pub mod diagram_safety;
pub mod l0_card;
pub mod l0_chat;
mod l0_approval;
pub(crate) mod l0_approval_store;
pub mod l0_eval;
Expand Down
3 changes: 3 additions & 0 deletions apps/appcard/app/app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ mod app;
mod backend;
pub mod host;
pub use host::{register_script_mods, AppShell, BODY_WIDGET};
/// Where AppCard keeps its L0 cards' chat threads (the module sets it from
/// the storage the shell offers).
pub use app::l0_chat::set_folder as set_l0_chat_folder;
#[cfg(target_os = "android")]
mod monitor;

Expand Down
Loading
Loading