@agentshouse/skills 0.1.0-ccce31cfa5fec → 0.1.0-cdd2bd42c3fbd
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -10
- package/agent-instructions.json +2 -2
- package/dist/index.d.ts +10 -14
- package/dist/index.js +25 -3
- package/guide/apps/build.md +14 -0
- package/guide/apps/chat.md +23 -0
- package/guide/apps/client.md +21 -0
- package/guide/apps/content-safety.md +18 -0
- package/guide/apps/data.md +13 -0
- package/guide/apps/example-function.md +67 -0
- package/guide/apps/example-tasks-2.md +101 -0
- package/guide/apps/example-tasks-3.md +52 -0
- package/guide/apps/example-tasks.md +75 -0
- package/guide/apps/functions.md +13 -0
- package/guide/apps/local-first.md +16 -0
- package/guide/apps/local-query-results.md +27 -0
- package/guide/apps/local-query.md +44 -0
- package/guide/apps/local-search.md +35 -0
- package/guide/apps/page.md +13 -0
- package/guide/apps/room-drafts.md +45 -0
- package/guide/apps/runtime-api.md +43 -0
- package/guide/apps/runtime-files.md +55 -0
- package/guide/apps/runtime-refusals.md +19 -0
- package/guide/apps/runtime.md +14 -0
- package/guide/apps/start.md +12 -0
- package/guide/apps/verify.md +16 -0
- package/guide/get-started.md +11 -0
- package/guide/index.md +12 -0
- package/{how-we-work.md → guide/owner-rules.md} +12 -5
- package/guide/reference/access-level.md +9 -0
- package/guide/reference/app-manifest.md +12 -0
- package/guide/reference/routine.md +15 -0
- package/guide/reference/schema.md +28 -0
- package/guide/reference/triggers.md +13 -0
- package/guide/routines/work-on-ticket.md +15 -0
- package/guide/sites/build.md +18 -0
- package/guide/sites/entry-data-contract.md +25 -0
- package/guide/sites/runtime.md +19 -0
- package/guide/skills/build-app.md +25 -0
- package/guide/skills/house-onboarding.md +29 -0
- package/guide/skills/prototype-app.md +40 -0
- package/guide/skills/wizard.md +22 -0
- package/{how-we-work-earlier.json → owner-rules-earlier.json} +4 -1
- package/package.json +10 -6
- package/app-guide.json +0 -346
- package/set/build-app/SKILL.md +0 -23
- package/set/prototype-app/SKILL.md +0 -40
- package/set/wizard/SKILL.md +0 -16
package/README.md
CHANGED
|
@@ -5,22 +5,27 @@ The Skills agents.house serves its agents, and the texts its agents read.
|
|
|
5
5
|
- `set/` holds one directory per Skill, each with its `SKILL.md`: every engineering and
|
|
6
6
|
productivity skill of [mattpocock/skills](https://github.com/mattpocock/skills) but its setup
|
|
7
7
|
skill and `wizard`, file for file beside upstream's `LICENSE`. `upstream.json` names the
|
|
8
|
-
upstream commit, its date and the skills taken from it.
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- `app
|
|
13
|
-
|
|
8
|
+
upstream commit, its date and the skills taken from it.
|
|
9
|
+
- `guide/` is the Guide, the pages agents.house serves its agents by path: `index` is its root, and
|
|
10
|
+
each page opens with its title and one line on what it is for. `apps/` is the guide to building
|
|
11
|
+
an App, `sites/` what a Site adds, and `skills/` agents.house's own Skills, `house-onboarding`,
|
|
12
|
+
`prototype-app`, `build-app` and `wizard`, each page carrying the `name` and `description` a
|
|
13
|
+
`SKILL.md` carries.
|
|
14
|
+
`routines/` holds the Routines agents.house writes into every Private Room, frontmatter and
|
|
15
|
+
all. `owner-rules` is the Owner rules' text, and `owner-rules-earlier.json` the SHA-256 of every
|
|
16
|
+
earlier text.
|
|
17
|
+
- `agent-instructions.json` is the lines that open an agent's first prompt.
|
|
14
18
|
|
|
15
19
|
```ts
|
|
16
|
-
import {
|
|
20
|
+
import { OWNER_RULES, listSkills, readSkill } from '@agentshouse/skills';
|
|
17
21
|
|
|
18
22
|
for (const { name } of listSkills()) console.log(name, readSkill(name).length);
|
|
19
|
-
console.log(
|
|
23
|
+
console.log(OWNER_RULES);
|
|
20
24
|
```
|
|
21
25
|
|
|
22
|
-
`agentInstructions(houseRef, copies)` returns the first prompt's lines, `
|
|
23
|
-
|
|
26
|
+
`agentInstructions(houseRef, copies)` returns the first prompt's lines, `GUIDE` every Guide page
|
|
27
|
+
with its path, title, first line and text and `EARLIER_OWNER_RULES_SHA256` the earlier texts'
|
|
28
|
+
digests.
|
|
24
29
|
|
|
25
30
|
The skills come from [mattpocock/skills](https://github.com/mattpocock/skills); thank you, Matt
|
|
26
31
|
Pocock.
|
package/agent-instructions.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"lines": [
|
|
3
3
|
"From House:",
|
|
4
|
-
"Work with House through the `house` CLI: run `house --help`, and `house
|
|
5
|
-
"To give the User a file, upload it with `house upload` and link it; for an App, a Site or a website, follow `house
|
|
4
|
+
"Work with House through the `house` CLI: run `house --help`, and `house open_guide` opens `index`, the root page of House's guides and formats.",
|
|
5
|
+
"To give the User a file, upload it with `house upload` and link it; for an App, a Site or a website, follow the Guide from `index` with `house open_guide`. Never start a server.",
|
|
6
6
|
"Optional per-Room instructions: `/rooms/private/rooms/<handle>/AGENTS.md`.",
|
|
7
7
|
"For another Agent's help, `send_routine_request`; its mail comes back into this conversation. If an Agent of your User has no Routine to call, write one in the Private Room and `create_routine_access` with your User as its only caller."
|
|
8
8
|
],
|
package/dist/index.d.ts
CHANGED
|
@@ -1,14 +1,3 @@
|
|
|
1
|
-
export type GuideLine = string | {
|
|
2
|
-
readonly act: string;
|
|
3
|
-
readonly text: string;
|
|
4
|
-
readonly without?: string;
|
|
5
|
-
};
|
|
6
|
-
export interface Guide {
|
|
7
|
-
readonly name: string;
|
|
8
|
-
readonly aliases: readonly GuideLine[];
|
|
9
|
-
readonly words: readonly GuideLine[];
|
|
10
|
-
readonly text: readonly GuideLine[];
|
|
11
|
-
}
|
|
12
1
|
export interface Skill {
|
|
13
2
|
readonly name: string;
|
|
14
3
|
readonly directory: string;
|
|
@@ -17,9 +6,16 @@ export interface SkillFile {
|
|
|
17
6
|
readonly path: string;
|
|
18
7
|
readonly content: string;
|
|
19
8
|
}
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
|
|
9
|
+
export interface GuidePage {
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly title: string;
|
|
12
|
+
readonly line: string;
|
|
13
|
+
readonly text: string;
|
|
14
|
+
}
|
|
15
|
+
export declare const GUIDE_ROOT = "index";
|
|
16
|
+
export declare const GUIDE: readonly GuidePage[];
|
|
17
|
+
export declare const OWNER_RULES: string;
|
|
18
|
+
export declare const EARLIER_OWNER_RULES_SHA256: readonly string[];
|
|
23
19
|
export declare function agentInstructions(houseRef: string, copies: readonly string[]): string[];
|
|
24
20
|
export declare function listSkills(): readonly Skill[];
|
|
25
21
|
export declare function readSkill(name: string): readonly SkillFile[];
|
package/dist/index.js
CHANGED
|
@@ -5,12 +5,34 @@ const PACKAGE = fileURLToPath(new URL('..', import.meta.url));
|
|
|
5
5
|
const SET = join(PACKAGE, 'set');
|
|
6
6
|
const ENTRY = 'SKILL.md';
|
|
7
7
|
const REF_PLACEHOLDER = '<house-ref>';
|
|
8
|
+
const TREE = join(PACKAGE, 'guide');
|
|
9
|
+
const PAGE = '.md';
|
|
10
|
+
const FRONTMATTER = /^---\n[\s\S]*?\n---\n\n?/u;
|
|
11
|
+
const HEADING = /^# /u;
|
|
12
|
+
export const GUIDE_ROOT = 'index';
|
|
13
|
+
const OWNER_RULES_PAGE = 'owner-rules';
|
|
8
14
|
function packaged(name) {
|
|
9
15
|
return readFileSync(join(PACKAGE, name), 'utf8');
|
|
10
16
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
17
|
+
function guidePage(file) {
|
|
18
|
+
const text = readFileSync(join(TREE, file), 'utf8');
|
|
19
|
+
const [heading = '', , line = ''] = text.replace(FRONTMATTER, '').split('\n');
|
|
20
|
+
return {
|
|
21
|
+
path: file.slice(0, -PAGE.length).split(sep).join('/'),
|
|
22
|
+
title: heading.replace(HEADING, ''),
|
|
23
|
+
line,
|
|
24
|
+
text,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export const GUIDE = readdirSync(TREE, {
|
|
28
|
+
recursive: true,
|
|
29
|
+
withFileTypes: true,
|
|
30
|
+
})
|
|
31
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(PAGE))
|
|
32
|
+
.map((entry) => guidePage(relative(TREE, join(entry.parentPath, entry.name))))
|
|
33
|
+
.sort((left, right) => (left.path < right.path ? -1 : 1));
|
|
34
|
+
export const OWNER_RULES = GUIDE.find((page) => page.path === OWNER_RULES_PAGE).text;
|
|
35
|
+
export const EARLIER_OWNER_RULES_SHA256 = JSON.parse(packaged('owner-rules-earlier.json'));
|
|
14
36
|
const INSTRUCTIONS = JSON.parse(packaged('agent-instructions.json'));
|
|
15
37
|
export function agentInstructions(houseRef, copies) {
|
|
16
38
|
if (copies.length === 0)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Build an App
|
|
2
|
+
|
|
3
|
+
How to build an App over your User's Rooms and publish it, one page per step.
|
|
4
|
+
|
|
5
|
+
An App is a web page over your User's Rooms, for your User and whom they share it with (`create_share`). House serves the built files and gives the page the App runtime, which holds every bound Room in the browser and syncs it with House. There is no server, backend or House API of yours to call.
|
|
6
|
+
|
|
7
|
+
- [Start an App](apps/start)
|
|
8
|
+
- [An App's data](apps/data)
|
|
9
|
+
- [Local first](apps/local-first)
|
|
10
|
+
- [Example: tasks](apps/example-tasks)
|
|
11
|
+
- [An App's page](apps/page)
|
|
12
|
+
- [Verify an App](apps/verify)
|
|
13
|
+
- [The App runtime](apps/runtime)
|
|
14
|
+
- [App functions](apps/functions)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# The Chat
|
|
2
|
+
|
|
3
|
+
How an App mounts House's Chat through its bridge and themes it.
|
|
4
|
+
|
|
5
|
+
In House the page's bridge places the Chat, one data layer for every App, which House
|
|
6
|
+
serves with the page and keeps current: install, import and bundle nothing for it, and add
|
|
7
|
+
no dependency. `const chat = await houseBridge.chat.mount(element, options)`, on the bridge
|
|
8
|
+
object House hands the page as `window.houseBridge`, which `houseBridge()` of
|
|
9
|
+
`@agentshouse/app-bridge` returns, draws it in `element` over the App's own client and
|
|
10
|
+
resolves to a handle: `chat.update(options)` replaces its options and `chat.unmount()`
|
|
11
|
+
removes it. `conversation` opens one, `null` for a
|
|
12
|
+
new chat, `agent` is the Agent a new chat goes to, `showChats` adds its conversation list,
|
|
13
|
+
`width` is `full` (the element), or `3/4`, `1/2` or `1/3` of the window docked at its right
|
|
14
|
+
edge, whole on a narrow window, `onOpen` hears each conversation it opens as `{
|
|
15
|
+
conversation, agent }`, and `onLink` takes a link without a scheme in place of the browser.
|
|
16
|
+
Theme its own styles with CSS custom properties on the element or an ancestor:
|
|
17
|
+
`--chat-background`, `--chat-surface`, `--chat-text`, `--chat-text-muted`,
|
|
18
|
+
`--chat-accent`, `--chat-line`, `--chat-attention`, `--chat-danger`, `--chat-font`,
|
|
19
|
+
`--chat-font-mono`, `--chat-font-size`, `--chat-radius` and `--chat-density` (a factor for
|
|
20
|
+
spacing); an unset one keeps House's light or dark look. It shows conversations only where
|
|
21
|
+
the release declares them; otherwise it lists no conversation and no Agent. A secret
|
|
22
|
+
question is never answered here; typed answers live in the Presentation State members named
|
|
23
|
+
`typed:…`: leave them.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# App code takes the client its host hands it and never constructs a Runtime
|
|
2
|
+
|
|
3
|
+
Which runtime entries App code takes, and which it never imports.
|
|
4
|
+
|
|
5
|
+
No module outside an Adapter package imports, names, or special-cases a Provider, and
|
|
6
|
+
every App and MD UI reach data only through the client entry. In House,
|
|
7
|
+
`houseBridge().runtime` from `@agentshouse/app-bridge` resolves to the client House built
|
|
8
|
+
into the page. App code imports only types from `@agentshouse/mdruntime/client`, never
|
|
9
|
+
calls `connectMdRuntime` or `serveMdRuntime`, and never imports the package main, the host
|
|
10
|
+
entry or the Adapter package; the in-memory Adapter at `@agentshouse/mdruntime/memory` is
|
|
11
|
+
for tests and ships in no bundle.
|
|
12
|
+
|
|
13
|
+
A React App may observe and save through `@agentshouse/mdruntime/react`, with React as a
|
|
14
|
+
peer dependency. `useObserve(client, topic, ...input)` takes the arguments of `observe`
|
|
15
|
+
and returns `{ state: 'loading' }` until the topic's first value arrives, then `{ state:
|
|
16
|
+
'ready', value }`, re-rendering on each change; a `null` client stays loading.
|
|
17
|
+
`useSave(client)` returns `busy`, the last `outcome` or `failure`, and `save(room,
|
|
18
|
+
parts)`, which saves through `saveParts` and rejects with a recorded failure.
|
|
19
|
+
`saveWritten(room, roomDrafts)` saves Room Drafts the App wrote, each named with the
|
|
20
|
+
revision its last write returned: one another tab wrote since is refused as `stale`, and
|
|
21
|
+
nothing is saved. React-admin and Refine Apps use `@agentshouse/mdruntime-families`.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Content safety and launch promises
|
|
2
|
+
|
|
3
|
+
What the runtime keeps from App code, and what MD UI promises.
|
|
4
|
+
|
|
5
|
+
The Provider authorization belongs to the Runtime host and is never readable by the App's
|
|
6
|
+
code. The App's code reaches the network only at the external origins its manifest
|
|
7
|
+
declares, empty by default, and the Provider enforces that set. Rendered Markdown never
|
|
8
|
+
executes scripts, renders raw HTML only for the details and summary elements, and loads
|
|
9
|
+
images and other resources only from origins the Provider's policy allows. An agent must
|
|
10
|
+
never read the authorization, load code from a Room, call the Provider directly, or rebase
|
|
11
|
+
a refused Change Batch.
|
|
12
|
+
|
|
13
|
+
MD UI uses semantic HTML, is fully operable from a keyboard with visible focus and
|
|
14
|
+
labelled controls, and lays out from a 360 pixel wide viewport to a desktop. Its strings
|
|
15
|
+
are English only, dates and numbers follow the browser locale, and content is never
|
|
16
|
+
translated. Light and dark themes come from CSS custom properties, defaulting to the
|
|
17
|
+
browser's preference and respecting an App's override. What an agent adds carries none of
|
|
18
|
+
these promises by itself. Data lives only in the App Creator's browser and the Provider.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# An App's data
|
|
2
|
+
|
|
3
|
+
How an App keeps what it shows as typed documents in the Rooms bound to it.
|
|
4
|
+
|
|
5
|
+
- Design the Room schema first. Each thing the App shows is one Markdown document in a Room: its frontmatter `type` names its MD type, the rest of its frontmatter holds the fields, and long text goes in `##` sections. `schema.yaml` declares the types ([Schema](reference/schema)).
|
|
6
|
+
- Keep each type in its own folder, such as `tasks/`, and read it by `pathPrefix`: a query naming `types` fails with `unknown-type` while no bound Room declares them.
|
|
7
|
+
- Link types with `reference` fields instead of copying values; a query follows them with `hop` and `backlinks`.
|
|
8
|
+
- `app.json` `room_slots` names the Rooms the App needs, each with `access` `read` or `write` and the `types` it uses ([App manifest](reference/app-manifest)). Bind Rooms with `set_app_bindings`; App code cannot bind and reaches only bound Rooms, so it opens `bridge.ceremony('/bind/<app_ref>?slot=<name>')`, the House page where your User binds Rooms, which returns to the App.
|
|
9
|
+
- A bound Room must hold each slot type identically in its own `settings/schema.yaml`, beside any other types, or your User is warned. Write it with `edit` before binding; `create_room` makes a new Room.
|
|
10
|
+
- Read an existing Room's `settings/schema.yaml` before inventing types. Two Apps over one schema are two interfaces over the same data, and your User's agents read and write those documents too.
|
|
11
|
+
- House's own facts, such as your User's Rooms, come from House acts: name each in `app.json` `house_acts` and call it with `runtime.call('<act>', input)`, such as `runtime.call('rooms/read', {})`. A call needs a connection, so offline show what the bound Rooms hold. A slot holding the Private Room brings its Triggers and none of the other documents House writes under its `house/`.
|
|
12
|
+
- What no browser can do, such as acting when documents change or at a set time, is a Trigger (`create_trigger`) that notifies or runs one of the App's [functions](apps/functions) on House's servers; what it writes reaches the App at once.
|
|
13
|
+
- A Trigger may also start a [Routine](reference/routine) on an Agent of your User.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Example: assign a lead
|
|
2
|
+
|
|
3
|
+
How a function hands each new lead to the manager on shift with the fewest leads since their shift began, records its decision and calls the CRM.
|
|
4
|
+
|
|
5
|
+
A CRM posts each new lead to the Room's push address (`create_push_address`) as `{"event": "lead.created", "card": {"id": 101}}`. The Room holds three types: `staff/<key>.md` with `manager_id`, the manager's id at the CRM; `shifts/<key>.md` with `staff`, the key, and `starts` and `ends` as ISO instants; and `decisions/<card>.md` with `card`, `staff` and `decided_at`. The App's `external_origins` lists the CRM's origin, the Room holds a Service key for it, and a Trigger runs the function for each lead:
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{
|
|
9
|
+
"on": "items",
|
|
10
|
+
"address": "<push address>",
|
|
11
|
+
"match": { "path": "/json/event", "eq": "lead.created" }
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
export default async (event, house) => {
|
|
17
|
+
const lead = JSON.parse(event.item.body);
|
|
18
|
+
const now = event.moment;
|
|
19
|
+
const card = lead.card.id;
|
|
20
|
+
const read = async (query) => house.query({ select: { fields: 'all' }, ...query });
|
|
21
|
+
const [held] = (await read({ types: ['decision'], paths: [`decisions/${card}.md`] })).documents;
|
|
22
|
+
if (held !== undefined) return;
|
|
23
|
+
const { documents: shifts } = await read({
|
|
24
|
+
types: ['shift'],
|
|
25
|
+
where: { '/starts': { $lte: now }, '/ends': { $gt: now } },
|
|
26
|
+
});
|
|
27
|
+
if (shifts.length === 0) throw new Error('no manager is on shift');
|
|
28
|
+
const leads = async (shift) =>
|
|
29
|
+
(
|
|
30
|
+
await read({
|
|
31
|
+
types: ['decision'],
|
|
32
|
+
where: { '/staff': shift.fields.staff, '/decided_at': { $gte: shift.fields.starts } },
|
|
33
|
+
limit: 1,
|
|
34
|
+
total: true,
|
|
35
|
+
})
|
|
36
|
+
).total;
|
|
37
|
+
const counted = await Promise.all(
|
|
38
|
+
shifts.map(async (shift) => ({ shift, leads: await leads(shift) })),
|
|
39
|
+
);
|
|
40
|
+
const [{ shift: chosen }] = counted.sort((left, right) => left.leads - right.leads);
|
|
41
|
+
const [manager] = (await read({ types: ['staff'], paths: [`staff/${chosen.fields.staff}.md`] }))
|
|
42
|
+
.documents;
|
|
43
|
+
const answer = await house.callExternal({
|
|
44
|
+
url: `https://crm.example/cards/${card}`,
|
|
45
|
+
method: 'PUT',
|
|
46
|
+
headers: { 'content-type': 'application/json', 'idempotency-key': event.id },
|
|
47
|
+
body: JSON.stringify({ status: 'assigned', manager: manager.fields.manager_id }),
|
|
48
|
+
room_ref: event.room,
|
|
49
|
+
});
|
|
50
|
+
if (answer.status !== 200) throw new Error(`the CRM answered ${answer.status}: ${answer.body}`);
|
|
51
|
+
const saved = await house.saveParts(event.room, [
|
|
52
|
+
{
|
|
53
|
+
path: `decisions/${card}.md`,
|
|
54
|
+
parts: {
|
|
55
|
+
creation: {
|
|
56
|
+
type: 'decision',
|
|
57
|
+
fields: { card, staff: chosen.fields.staff, decided_at: now },
|
|
58
|
+
sections: {},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
]);
|
|
63
|
+
if (saved.outcome !== 'saved') throw new Error(JSON.stringify(saved));
|
|
64
|
+
};
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
A lead the CRM delivers again finds its decision and calls nothing. Two leads that arrive together run one after another, so the second counts the first's decision and goes to another manager when two are tied. Each count is a query's `total`, so a long shift is never cut at a page. A failed call fails the run with the CRM's answer and tells the Room's owner; the next lead still runs.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Example: tasks, the forms
|
|
2
|
+
|
|
3
|
+
The forms of the tasks example's `main.ts`, after its start.
|
|
4
|
+
|
|
5
|
+
`main.ts`, continued:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
function taskCreation(draft: Draft) {
|
|
9
|
+
const creation = draft?.parts.creation;
|
|
10
|
+
return creation !== undefined && 'type' in creation && creation.type === 'task'
|
|
11
|
+
? creation
|
|
12
|
+
: undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function titleOf(draft: Draft): unknown {
|
|
16
|
+
return draft?.parts.fields?.title?.value ?? taskCreation(draft)?.fields.title;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function el<K extends keyof HTMLElementTagNameMap>(
|
|
20
|
+
tag: K,
|
|
21
|
+
props: object,
|
|
22
|
+
...children: (Node | string)[]
|
|
23
|
+
) {
|
|
24
|
+
const node = Object.assign(document.createElement(tag), props);
|
|
25
|
+
node.append(...children);
|
|
26
|
+
return node;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function titleForm(
|
|
30
|
+
action: string,
|
|
31
|
+
room: string,
|
|
32
|
+
path: () => string,
|
|
33
|
+
shown: unknown,
|
|
34
|
+
parts: (title: string, held: Draft) => Parts,
|
|
35
|
+
done: (title: HTMLInputElement) => void,
|
|
36
|
+
) {
|
|
37
|
+
let held = drafts.current().find((draft) => draft.room === room && draft.path === path()) ?? null;
|
|
38
|
+
const title = el('input', {
|
|
39
|
+
value: String(titleOf(held) ?? shown),
|
|
40
|
+
required: true,
|
|
41
|
+
ariaLabel: 'Title',
|
|
42
|
+
});
|
|
43
|
+
const keep = () =>
|
|
44
|
+
queued(async () => {
|
|
45
|
+
const written = await write(room, path(), held, parts(title.value, held));
|
|
46
|
+
held = written.draft;
|
|
47
|
+
if (written.stale) title.value = String(titleOf(held) ?? shown);
|
|
48
|
+
return written.stale;
|
|
49
|
+
});
|
|
50
|
+
title.oninput = () => void keep();
|
|
51
|
+
const form = el('form', {}, title, el('button', {}, action));
|
|
52
|
+
form.onsubmit = async (event) => {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
const stale = await keep();
|
|
55
|
+
if (stale === null) return;
|
|
56
|
+
if (stale) return void (note.textContent = 'Not saved: stale.');
|
|
57
|
+
if (!(await save(room, held))) return;
|
|
58
|
+
held = null;
|
|
59
|
+
done(title);
|
|
60
|
+
};
|
|
61
|
+
return form;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function editForm(room: string, path: string, shown: unknown) {
|
|
65
|
+
const parts = (title: string): Parts => ({
|
|
66
|
+
fields: { title: { value: title, base: { value: shown } } },
|
|
67
|
+
});
|
|
68
|
+
return titleForm(
|
|
69
|
+
'Save',
|
|
70
|
+
room,
|
|
71
|
+
() => path,
|
|
72
|
+
shown,
|
|
73
|
+
parts,
|
|
74
|
+
() => void bridge.push('/'),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function addForm(room: string) {
|
|
79
|
+
const parts = (title: string, held: Draft): Parts => {
|
|
80
|
+
const kept = taskCreation(held) ?? { type: 'task', fields: { done: false }, sections: {} };
|
|
81
|
+
return { creation: { ...kept, fields: { ...kept.fields, title } } };
|
|
82
|
+
};
|
|
83
|
+
const adding = drafts
|
|
84
|
+
.current()
|
|
85
|
+
.find((draft) => draft.room === room && draft.path.startsWith('tasks/') && taskCreation(draft));
|
|
86
|
+
let path = adding?.path ?? `tasks/${crypto.randomUUID()}.md`;
|
|
87
|
+
return titleForm(
|
|
88
|
+
'Add',
|
|
89
|
+
room,
|
|
90
|
+
() => path,
|
|
91
|
+
'',
|
|
92
|
+
parts,
|
|
93
|
+
(title) => {
|
|
94
|
+
title.value = '';
|
|
95
|
+
path = `tasks/${crypto.randomUUID()}.md`;
|
|
96
|
+
},
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Next: [Example: tasks, the list](apps/example-tasks-3)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Example: tasks, the list
|
|
2
|
+
|
|
3
|
+
The list that ends the tasks example's `main.ts`.
|
|
4
|
+
|
|
5
|
+
`main.ts`, continued:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
async function toggle(room: string, path: string, done: boolean): Promise<void> {
|
|
9
|
+
const parts = { fields: { done: { value: !done, base: { value: done } } } };
|
|
10
|
+
const saved = await runtime.saveParts(room, [{ path, parts }]);
|
|
11
|
+
note.textContent = saved.outcome === 'saved' ? '' : `Not saved: ${saved.code}.`;
|
|
12
|
+
draw();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function draw(): void {
|
|
16
|
+
const room = rooms.current()[0];
|
|
17
|
+
const answer = tasks.current();
|
|
18
|
+
const held = 'pending' in answer ? [] : answer.documents;
|
|
19
|
+
const open = held.find((task) => `/${task.path}` === bridge.address());
|
|
20
|
+
const page = room === undefined ? '' : `${room.locator}/${open?.path ?? ''}`;
|
|
21
|
+
if (page !== drawn) {
|
|
22
|
+
drawn = page;
|
|
23
|
+
note.textContent = '';
|
|
24
|
+
if (room === undefined) main.replaceChildren('This App has no Room yet.');
|
|
25
|
+
else if (open !== undefined)
|
|
26
|
+
main.replaceChildren(editForm(open.room, open.path, open.fields!.title), note);
|
|
27
|
+
else main.replaceChildren(list, addForm(room.locator), note);
|
|
28
|
+
}
|
|
29
|
+
if (!('pending' in answer)) shown();
|
|
30
|
+
if (room === undefined) return;
|
|
31
|
+
const refused = new Set(
|
|
32
|
+
sync.current().attentionRequired.flatMap((entry) => ('path' in entry ? [entry.path] : [])),
|
|
33
|
+
);
|
|
34
|
+
const rows = held.map((task) => {
|
|
35
|
+
const done = task.fields!.done === true;
|
|
36
|
+
const box = el('input', { type: 'checkbox', checked: done, ariaLabel: 'Done' });
|
|
37
|
+
box.onchange = () => void queued(() => toggle(task.room, task.path, done));
|
|
38
|
+
const link = el('a', { href: task.path }, String(task.fields!.title));
|
|
39
|
+
link.onclick = (event) => {
|
|
40
|
+
event.preventDefault();
|
|
41
|
+
void bridge.push(`/${task.path}`);
|
|
42
|
+
};
|
|
43
|
+
return el('li', {}, box, link, refused.has(task.path) ? ' (refused)' : '');
|
|
44
|
+
});
|
|
45
|
+
const empty = 'pending' in answer ? '' : 'No tasks yet. Add the first one.';
|
|
46
|
+
list.replaceChildren(rows.length > 0 ? el('ul', {}, ...rows) : empty);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
for (const observed of [rooms, sync, tasks]) observed.subscribe(draw);
|
|
50
|
+
bridge.onAddress(draw);
|
|
51
|
+
draw();
|
|
52
|
+
```
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Example: tasks
|
|
2
|
+
|
|
3
|
+
A whole tasks App over one Room, keeping every rule of the App pages; its `main.ts` goes on over two more pages.
|
|
4
|
+
|
|
5
|
+
`schema.yaml`, also written into the Room's `settings/schema.yaml`:
|
|
6
|
+
|
|
7
|
+
```yaml
|
|
8
|
+
task:
|
|
9
|
+
properties:
|
|
10
|
+
title: { type: string }
|
|
11
|
+
done: { type: boolean }
|
|
12
|
+
required: [title, done]
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`app.json`, then `set_app_bindings` with slot `tasks`:
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{ "entry": "index.html", "room_slots": [{ "name": "tasks", "access": "write", "types": ["task"] }] }
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`main.ts`: a list at `/` and a form at each task's own address, keeping every rule of the App pages.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { houseBridge } from '@agentshouse/app-bridge';
|
|
25
|
+
|
|
26
|
+
const bridge = houseBridge();
|
|
27
|
+
let shown!: () => void;
|
|
28
|
+
const showing = new Promise<void>((resolve) => (shown = resolve));
|
|
29
|
+
let writing: Promise<unknown> = Promise.resolve();
|
|
30
|
+
bridge.cooperate({ placed: () => showing });
|
|
31
|
+
const runtime = await bridge.runtime;
|
|
32
|
+
const rooms = await runtime.observe('rooms');
|
|
33
|
+
const sync = await runtime.observe('sync-status');
|
|
34
|
+
const drafts = await runtime.observe('room-drafts');
|
|
35
|
+
const tasks = await runtime.observe('query', {
|
|
36
|
+
pathPrefix: 'tasks',
|
|
37
|
+
select: { fields: ['/title', '/done'] },
|
|
38
|
+
orderBy: [{ by: { field: '/title' } }],
|
|
39
|
+
});
|
|
40
|
+
const main = document.querySelector('main')!;
|
|
41
|
+
const list = document.createElement('div');
|
|
42
|
+
const note = document.createElement('p');
|
|
43
|
+
type Parts = Parameters<typeof runtime.roomDraft.write>[3];
|
|
44
|
+
type Draft = Awaited<ReturnType<typeof runtime.roomDraft.read>>;
|
|
45
|
+
let drawn: string | undefined;
|
|
46
|
+
|
|
47
|
+
async function write(room: string, path: string, held: Draft, parts: Parts) {
|
|
48
|
+
const { file: _file, ...kept } = { ...held?.parts };
|
|
49
|
+
const onto = { ...kept, ...parts, fields: { ...kept.fields, ...parts.fields } };
|
|
50
|
+
const written = await runtime.roomDraft.write(room, path, held?.revision ?? null, onto);
|
|
51
|
+
return written.outcome === 'written'
|
|
52
|
+
? { draft: written.roomDraft, stale: false }
|
|
53
|
+
: { draft: written.current, stale: true };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function save(room: string, draft: Draft): Promise<boolean> {
|
|
57
|
+
const saved =
|
|
58
|
+
draft === null
|
|
59
|
+
? null
|
|
60
|
+
: await runtime.save(room, [{ path: draft.path, revision: draft.revision }]);
|
|
61
|
+
note.textContent = saved?.outcome === 'saved' ? '' : `Not saved: ${saved?.code ?? 'stale'}.`;
|
|
62
|
+
return saved?.outcome === 'saved';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function queued<T>(work: () => Promise<T>): Promise<T | null> {
|
|
66
|
+
const done = writing.then(work).catch((error: { code?: unknown }) => {
|
|
67
|
+
note.textContent = `Not saved: ${String(error.code)}.`;
|
|
68
|
+
return null;
|
|
69
|
+
});
|
|
70
|
+
writing = done;
|
|
71
|
+
return done;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Next: [Example: tasks, the forms](apps/example-tasks-2)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# App functions
|
|
2
|
+
|
|
3
|
+
How an App declares a function that House runs on its servers when a Trigger fires, with no page open and no model.
|
|
4
|
+
|
|
5
|
+
- **Declare.** `app.json` lists `functions`, each `{"name": "assign lead", "entry": "functions/assign.js"}` ([App manifest](reference/app-manifest)). The entry is one ES module whose default export is the function and which imports nothing, so your build bundles each function, with all it imports, into `dist/`, such as `esbuild src/assign.ts --bundle --format=esm --outfile=dist/functions/assign.js`. A publish is refused whole for an entry that is missing, imports, has no default export or is over <function-size>, for a name used twice, and for more than <release-functions> functions.
|
|
6
|
+
- **Run.** A Trigger's `function` action names the App and one of its functions ([Triggers](reference/triggers)). House runs the App's latest release when the Event's turn comes, one Event of a Trigger after another, so a run reads what the run before it wrote.
|
|
7
|
+
- **The event.** `export default async (event, house) => {}` receives `kind`, the condition's `on`, with `room`, `trigger`, `id`, `moment` and what matched: `path` and `reference`, a document change's `transition`, an arriving item's `item` with its `headers` and raw `body` text, a dated document's `document`, or a delivered notification's `notification`, the facts of its web document. `Date` reads `moment`. The return value is ignored, so write what you keep into the Room.
|
|
8
|
+
- **The App API.** `house` is the page's App API over the Trigger's Room alone: `query(q)` ([Local Query](apps/local-query)), `saveParts(room, parts)`, `call(name, input)` for the Room acts such as `record.list` and `member.list`, and `callExternal(request)`. `query` and `callExternal` reject a refusal with its `code` for the function to catch, while `call` answers it as a result whose `kind` is `rejected` and `saveParts` as `outcome: 'refused'`, as on the page, so check what they answer. Its writes are the App's, by `App <App> (function <function>)`, fire Triggers, its own included, and count against the Room owner's Write limit.
|
|
9
|
+
- **Reach.** A run holds no `fetch`, timer, file, `console` or `Intl`: keep times as ISO instants, or convert a local time by its UTC offset yourself. `callExternal` takes the request `bridge.callExternal` takes and is the one way out, to an origin the release's `external_origins` lists; naming its Room, `room_ref: event.room`, carries that Room's Service key, which the function never sees, or is refused `service_key_not_found` naming the `origin`. Its calls count against the Room owner's `external_calls` limit.
|
|
10
|
+
- **Never repeated.** A run that started is never run again, even when it failed after a call reached the service. Send `event.id` as the service's idempotency key where it takes one, and name what you write by what the event carries, so a second delivery of the same thing finds it.
|
|
11
|
+
- **Failure and limits.** A throw settles the run `failed` with `function_failed` and its text, which `list_trigger_events` shows and House tells the Room's owner; the next Event still runs. A run ends `failed` at <function-time>, at <function-memory> or past <function-calls> external calls: `time_cap_reached`, `memory_cap_reached` or `external_call_cap_reached`.
|
|
12
|
+
|
|
13
|
+
Next: [Example: assign a lead](apps/example-function)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Local first
|
|
2
|
+
|
|
3
|
+
How an App reads and writes its Rooms on the device, offline included.
|
|
4
|
+
|
|
5
|
+
- The runtime holds every bound Room on the device and streams in every change, from any device, App or agent. `query`, `search` and `observe` answer from the device and never wait on the network.
|
|
6
|
+
- Draw from `observe` at once, with no loading spinner for Room data and no request of your own; an observation hears each later change by itself, so never poll, refetch or subscribe to House. An answer is `{ pending }` while a Room it reads has not reached this device or been prepared on it, or the search index has not: draw the page without those documents, and keep the empty state for a complete answer that has none. `rooms` answers once the runtime knows the Rooms the page reaches. Offer binding a Room when it is empty while the sync status names House reachable (`providerUnreachable` false) and `observe('connection-refusal')` answers `null`; an empty list at any other moment says only that nothing can be listed yet.
|
|
7
|
+
- Write optimistically: write a Room Draft, then `save`. The change shows in every query at once as a Pending Change and reaches House when it can. `save` refuses at once, with its `code`, a change that is `stale`, `changed-underneath` or against the schema: say so and keep the form.
|
|
8
|
+
- Show what the runtime reports: `observe('queue', room)` lists changes House has not confirmed yet, and `observe('sync-status')`'s `attentionRequired` lists each change House refused, returned as a Room Draft with why, to save again or discard.
|
|
9
|
+
- Keep what the User types in the Room Draft (`roomDraft.write`) as they type; it survives a new release replacing the page, a reload and another tab. Draw a form again only when its page changes, never on a streamed change.
|
|
10
|
+
- A document has one Room Draft, shared by every tab and App, and `save` takes all of it. A write replaces every part and names the revision it read, so write onto the parts it holds; one from an older revision answers `stale` with the current Room Draft: show that one. A field part names as its `base` the value the page showed, `{ value }`, or `null` when the document has no such field.
|
|
11
|
+
- Save a change the User does not type, such as a checkbox, with `saveParts(room, [{ path, parts }])`: it writes no Room Draft, so the one another tab holds stays.
|
|
12
|
+
- Offline, all of this works and saves wait. `bridge.onStatus` reports `offline`; say so quietly.
|
|
13
|
+
- A Room your User holds at the Open level locks once the browser has been offline 2 minutes, and from the moment it opens until House confirms the access. While it is locked, `rooms` lists it `locked: true`, a query or search that does not name it leaves it out, one naming it answers `{ pending }` with `awaits: 'unlock'`, and every other read and write naming it, `save` included, answers the code `room-locked` and keeps nothing. Draw it as locked until it unlocks and keep what the User types in the page; what was saved before stays and is sent then.
|
|
14
|
+
- An outside service is reached only at the origins `app.json` lists in `external_origins`, directly or through `bridge.callExternal`, which House's server sends. A call that names a Room bound to the App in a `write` slot with `room_ref` carries that Room's Service key for the origin, which the page never sees; any other call carries no key. A call through a Room that holds no key for the origin is refused `service_key_not_found` naming the `origin`: tell the person that the Room's owner or a manager enters the key on the Room's Service keys, in its Info, since no App opens that page.
|
|
15
|
+
- The page's own storage is a disposable cache, the Room the only durable home of the App's data; handle any code or value you do not know through `code` and `retryable`, which every refusal and failure carries.
|
|
16
|
+
- React: `useObserve` and `useSave` from `@agentshouse/mdruntime/react`. react-admin and Refine: the data providers of `@agentshouse/mdruntime-families`. The whole client is described in [The App runtime](apps/runtime).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Local Query changes, order and results
|
|
2
|
+
|
|
3
|
+
How a Local Query answers each change, orders, groups and pages its result, and fails.
|
|
4
|
+
|
|
5
|
+
- **Change, file ID and revision.** `change` is `{ author, time }` of the Document's head revision
|
|
6
|
+
as the Provider last delivered it: `author` is `{ kind: 'user' | 'inbox', id, name }`, or
|
|
7
|
+
`null` for a write the Provider made itself, and `time` its ISO 8601 change time, or the
|
|
8
|
+
whole change `null` where it delivered no time. `fileId` is the Provider's
|
|
9
|
+
Stable File ID of the file at the Path, or `null`. An unsent edit keeps both, an unsent
|
|
10
|
+
move keeps the change and carries the file ID to the new Path, so a file ID that leaves
|
|
11
|
+
one Path for another is the same file moved, and an unsent creation has neither until it
|
|
12
|
+
is Accepted. `revision` is the Provider's head revision, `null` for an unsent creation or edit,
|
|
13
|
+
and moves with an unsent move. A Room's last change is its Document with the latest `time`.
|
|
14
|
+
- **Order and groups.** `orderBy` sorts by `room`, `path`, `type`, `folder` or `{ field }`: missing
|
|
15
|
+
and null first, last with `nulls: 'last'`, then numbers and booleans, then text by `locale` or
|
|
16
|
+
code point with `stringSort: 'lexical'`, then lists item by item; ties end with Room, then Path.
|
|
17
|
+
`groupBy` by `room`, `type`, `folder` or `{ field }` answers `{ groups: [{ key, count }] }`,
|
|
18
|
+
counted over every match and ordered by `count` or a key. A sort or group takes only a
|
|
19
|
+
single-value pointer. `limit`, at most 1,000 whatever a query names, bounds the Documents or the
|
|
20
|
+
groups, and a result it cuts short names `next`: the same query with it as `after` reads the next
|
|
21
|
+
window, in the same order, ties included. `total: true` adds `total`, every match's count. An
|
|
22
|
+
observed window hears rows entering, leaving or changing, and `next`, `total`.
|
|
23
|
+
- **Results and errors.** A result carries no Room Version: read freshness from Rooms and
|
|
24
|
+
Sync Status. `coverage` names each Room read as `{ room, generation, current }`,
|
|
25
|
+
`current` false while a newer copy of that Room is being fetched, and the queued changes
|
|
26
|
+
it includes as `overlay`, which a Save in the Room changes. An invalid query fails with
|
|
27
|
+
`invalid-query`, `unknown-type`, `unknown-field` or `wrong-kind`, never an empty answer.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Local Query rules
|
|
2
|
+
|
|
3
|
+
How `query` and `observe('query', q)` filter and select Documents on the device.
|
|
4
|
+
|
|
5
|
+
`query(q)` answers once from local state; `observe('query', q)` holds the whole result and
|
|
6
|
+
hears it again only when it changes, keeping every unchanged Document the same object, so
|
|
7
|
+
a component can tell what changed by identity. `{ documents, coverage }` or `{ groups,
|
|
8
|
+
coverage }` is complete for the Rooms its `coverage` names, and `{ pending }` names each
|
|
9
|
+
Room not yet acquired, prepared or unlocked, `[{ room, awaits }]`: never read it as empty,
|
|
10
|
+
draw it as loading and observe the query to hear the complete answer. A query is data:
|
|
11
|
+
|
|
12
|
+
- **Filter.** `rooms` names Room locators and defaults to every Room. `types` names MD
|
|
13
|
+
Types, and `any` names untyped Markdown Documents. `pathPrefix` is a directory: `notes`
|
|
14
|
+
keeps `notes/a.md` and not `notes2/a.md`; `folder` keeps only what lies directly in it.
|
|
15
|
+
`paths` keeps only the Paths it names: read named Documents so, never from a list.
|
|
16
|
+
- **Fields.** Every frontmatter value is named by JSON Pointer: `/status`,
|
|
17
|
+
`/shipping/city`, `~1` for a `/` and `~0` for a `~` in a key. A segment that meets a
|
|
18
|
+
list applies to every item and a number names one item. `where` names pointers, ANDs
|
|
19
|
+
them, and nests `$and` and `$or`; a pointer through a list holds if any item satisfies
|
|
20
|
+
it. A bare value is equality; the operators are `$eq`, `$ne`, `$in`, `$nin`, `$gt`,
|
|
21
|
+
`$gte`, `$lt`, `$lte`, `$all`, `$size` and `$exists`. Equality on a list asks if it
|
|
22
|
+
contains the value. Missing, null and empty differ: `$exists: false` keeps a missing
|
|
23
|
+
field, `null` an explicit null, `''` empty text and `$size: 0` an empty list. A date
|
|
24
|
+
compares in calendar order and never with a date-time. A reference compares by the
|
|
25
|
+
Room Path it resolves to. Untyped frontmatter, and a value under `oneOf` or a
|
|
26
|
+
conditional, is plain values. There is no text containment or regular expression.
|
|
27
|
+
- **Relations.** Within one Room, `hop: { field, types, where, pathPrefix }` keeps Documents
|
|
28
|
+
whose reference at pointer `field`, at any depth, points to a matching Document, and
|
|
29
|
+
`backlinks` with the same keys those a matching Document references, each once. A body
|
|
30
|
+
link or a `cross_room` reference may name another Room's `/rooms/<handle>/<path>`:
|
|
31
|
+
`resolve(room, path, destination)` answers its `document`, the `deleted` one's last
|
|
32
|
+
`revision`, or `unreadable` where the App does not bind it or it is locked.
|
|
33
|
+
- **Parts.** A Document query must `select`: `'all'`, or `fields`, `sections`,
|
|
34
|
+
`preamble`, `markdown`, `backlinks`, `change`, `fileId` and `revision`, where `fields` is `'all'`
|
|
35
|
+
or a list of pointers and `sections` `'all'` or a list of names. Selected pointers
|
|
36
|
+
return the frontmatter holding only them, nested as written. A backlink names the
|
|
37
|
+
referring Path, MD Type and the pointer it came from as `field`. Room and Path always
|
|
38
|
+
return; a Markdown Document also returns its MD Type, and at most its first 1,000
|
|
39
|
+
backlinks: a query on the referring field reads the rest. A non-Markdown Document
|
|
40
|
+
returns only Room, Path and a selected `fileId` and `revision`, a binary Path entry its `binaryFile`
|
|
41
|
+
record too, and a text file its text as a selected `markdown`, kept out of Search. An unselected part is absent. Select only what a screen shows: a list
|
|
42
|
+
that selects the Markdown hears every edit of it.
|
|
43
|
+
|
|
44
|
+
Next: [Local Query changes, order and results](apps/local-query-results)
|