@akanjs/cli 3.0.0-beta.2 → 3.0.0-beta.20
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/.build-stamp +1 -1
- package/CodeAgent-awv258wb.js +9 -0
- package/CodeAgentRpcHost-p7h6n2hp.js +7 -0
- package/CodeAgentRpcListener-7p7z5dd7.js +97 -0
- package/CodeTui-1nqh35ez.js +1457 -0
- package/README.md +3 -0
- package/TunnelShare-scf4e4pd.js +9 -0
- package/{agent.command-d2gfxfck.js → agent.command-1s9ag7sb.js} +8 -9
- package/akanCode.js +102 -0
- package/{application.command-pj2m7584.js → application.command-49hwy6va.js} +51 -78
- package/{applicationBuildRunner-kxwbssgn.js → applicationBuildRunner-ybyp97pv.js} +63 -64
- package/{applicationReleasePackager-4yasxwv9.js → applicationReleasePackager-esnrphw8.js} +40 -60
- package/buildBatch.proc.js +19 -24
- package/{capacitorApp-36rbr858.js → capacitorApp-1synz4en.js} +3 -4
- package/cloud.command-d7rqtzt9.js +94 -0
- package/cloud.script-bkbpcr96.js +19 -0
- package/code.command-nn48xzej.js +154 -0
- package/commandManifest.json +1 -1
- package/consoleToStderr-h5bryj94.js +7 -0
- package/context.command-s3mcmvpa.js +48 -0
- package/context.runner-k99nb5pk.js +21 -0
- package/{dependencyScanner-shs82ynp.js → dependencyScanner-ye2e6pdz.js} +2 -2
- package/{devTui-40ggmd99.js → devTui-q7k7bakd.js} +122 -40
- package/fileSys-5ngz93ga.js +9 -0
- package/guideline.command-sct4qnt5.js +42 -0
- package/guidelines/agentRule/agentRule.instruction.md +206 -25
- package/guidelines/conventions/conventions.instruction.md +79 -24
- package/guidelines/fieldRule/fieldRule.instruction.md +1 -0
- package/guidelines/mcpRule/mcpRule.instruction.md +7 -5
- package/guidelines/queryRule/queryRule.instruction.md +17 -7
- package/guidelines/runtimeRule/runtimeRule.instruction.md +74 -0
- package/guidelines/ssrRule/ssrRule.instruction.md +4 -2
- package/guidelines/transportRule/transportRule.instruction.md +51 -0
- package/guidelines/workspaceRecipes/workspaceRecipes.instruction.md +66 -3
- package/incrementalBuilder.proc.js +31 -48
- package/{index-h7yw2z9n.js → index-0bq14vr1.js} +8 -14
- package/index-13ffe9wp.js +69 -0
- package/index-22d57v4c.js +266 -0
- package/{index-fz5q3gd7.js → index-31yr441d.js} +10 -17
- package/index-3hx1z96c.js +87 -0
- package/index-3sq321ak.js +53 -0
- package/{index-kcxmv48c.js → index-3t7g9e4r.js} +45 -79
- package/{index-q0y2jq9j.js → index-5b6tavv3.js} +6 -3
- package/{index-47pj3aee.js → index-5e0n4hqx.js} +11 -12
- package/{index-259dsnz3.js → index-5pc1qrbv.js} +5 -8
- package/index-7md2g9vw.js +6 -0
- package/index-8mna0947.js +187 -0
- package/{index-zry5nz81.js → index-8sf7wmdr.js} +51 -30
- package/{index-2tv3fdx4.js → index-9d9bgcx4.js} +342 -765
- package/index-9dp031by.js +12 -0
- package/{index-ne2qy0nk.js → index-ahemdr5n.js} +12 -12
- package/{index-ws5e0j51.js → index-as7xmyz8.js} +40 -24
- package/index-axzjaqve.js +1118 -0
- package/{index-3d9y066c.js → index-bjwxs904.js} +9 -8
- package/index-c6eggbee.js +123 -0
- package/index-d2242ksy.js +99 -0
- package/{index-a64esmtb.js → index-ddxvdd1p.js} +169 -305
- package/{index-92zztqy1.js → index-dy0jx9ay.js} +2 -17
- package/{index-pnqbkf5p.js → index-dzf3r49a.js} +6 -2
- package/{index-w8kmx1b6.js → index-fgq75haa.js} +198 -290
- package/{index-by5gpp8x.js → index-hj6w4ka1.js} +2 -1
- package/{index-hwkygn9s.js → index-khpfpcsd.js} +302 -33
- package/{index-wt7qrtd4.js → index-mxav6j3c.js} +93 -232
- package/{index-gyd220p2.js → index-myhc3c1j.js} +554 -577
- package/{index-600zhnve.js → index-nv5976x3.js} +56 -197
- package/index-p6pbz9n5.js +25 -0
- package/{index-4edzf1gr.js → index-przybvy5.js} +77 -150
- package/index-smme4r47.js +49 -0
- package/{index-mak3cd28.js → index-w2zdcw3k.js} +137 -255
- package/index-wa5jbxj1.js +760 -0
- package/{index-v0qh106m.js → index-ww4z397j.js} +527 -723
- package/{index-j9yhg404.js → index-xqjwd384.js} +4 -7
- package/{index-7737gxcp.js → index-xqpzbab5.js} +107 -241
- package/index-xsjh6nzh.js +2729 -0
- package/{index-ktb5njgy.js → index-ygn77a5w.js} +47 -99
- package/index-ztp3e73t.js +700 -0
- package/index.js +23 -22
- package/{library.command-nbsky672.js → library.command-1gx04jgn.js} +4 -5
- package/{localRegistry.command-yeq57wjr.js → localRegistry.command-zndxj1q6.js} +22 -42
- package/{module.command-29vqzrh8.js → module.command-3waddk95.js} +8 -11
- package/{package.command-axv0abmn.js → package.command-zrc7br1y.js} +6 -7
- package/package.json +14 -5
- package/{page.command-vs9s3zrh.js → page.command-vpjgardh.js} +4 -5
- package/{primitive.command-cbxmqbtc.js → primitive.command-sz84tk4t.js} +9 -22
- package/prompter-ptaa3nk0.js +8 -0
- package/{quality.command-gmbs7nws.js → quality.command-g869hafs.js} +203 -376
- package/{recipeScanner-fpagtgc1.js → recipeScanner-4b69ybe0.js} +1 -1
- package/{repair.command-bqsjptsj.js → repair.command-chsnjvvv.js} +11 -35
- package/{routeSourceValidator-apyqw7wk.js → routeSourceValidator-7ycnfz0e.js} +0 -6
- package/{scalar.command-6k1vhe9s.js → scalar.command-2v21newx.js} +7 -10
- package/skills/akan-code/SKILL.md +130 -0
- package/skills/akan-debug/SKILL.md +42 -0
- package/skills/akan-deps/SKILL.md +50 -0
- package/skills/akan-err/SKILL.md +39 -0
- package/skills/akan-module/SKILL.md +45 -0
- package/skills/akan-scaffold/SKILL.md +76 -0
- package/skills/akan-scalar/SKILL.md +52 -0
- package/skills/akan-service/SKILL.md +34 -0
- package/skills/akan-store/SKILL.md +67 -0
- package/skills/akan-ui/SKILL.md +63 -0
- package/skills/akan-validate/SKILL.md +44 -0
- package/{subspace.command-fwjfcts7.js → subspace.command-ckcbwfp1.js} +138 -67
- package/templates/__scalar/__model__/__model__.abstract.md.template +8 -0
- package/templates/appSample/lib/__scalar/workHistory/workHistory.abstract.ts +8 -39
- package/templates/appSample/lib/_noti/noti.abstract.ts +6 -29
- package/templates/appSample/lib/task/task.abstract.ts +12 -32
- package/templates/appSample/lib/task/task.signal.spec.ts +15 -18
- package/templates/appSample/lib/task/task.signal.test.ts +15 -1
- package/templates/appSample/lib/task/task.signal.ts +1 -1
- package/templates/appSample/srvkit/{AuthGuard.ts → guards.ts} +7 -4
- package/templates/appSample/srvkit/{SessionInternalArg.ts → internalArgs.ts} +6 -4
- package/templates/client.ts +7 -30
- package/templates/crudPages/[__model__Id]/edit/page.tsx +2 -1
- package/templates/crudPages/[__model__Id]/page.tsx +2 -1
- package/templates/crudPages/new/page.tsx +2 -1
- package/templates/crudPages/page.tsx +2 -1
- package/templates/crudSinglePage/page.tsx +2 -1
- package/templates/lib/__lib/lib.dictionary.ts +0 -2
- package/templates/lib/__lib/lib.document.ts +0 -2
- package/templates/lib/sig.ts +8 -30
- package/templates/localDev/docker-compose.yaml.template +3 -14
- package/templates/module/__model__.signal.ts +12 -4
- package/templates/pkgRoot/tsconfig.json.template +2 -7
- package/templates/server.ts +0 -2
- package/templates/service/__model__.abstract.md.template +8 -0
- package/tunnel.command-kd2yr4fx.js +129 -0
- package/{typeChecker-3xgrzc2v.js → typeChecker-px49mezc.js} +1 -1
- package/typecheck.proc.js +1 -1
- package/workflow.command-44252cqv.js +79 -0
- package/{workspace.command-8s050m0h.js → workspace.command-fqcztt5p.js} +77 -109
- package/abstractCompactor-mjh5ppsq.js +0 -101
- package/cloud.command-jshwdq22.js +0 -93
- package/context.command-ez8a0zyz.js +0 -80
- package/devTuiView-c08ccbat.js +0 -9
- package/getDirname-dr3jz1xg.js +0 -7
- package/guideline.command-xmqwx8zm.js +0 -359
- package/guidelines/agentRule/agentRule.generate.json +0 -31
- package/guidelines/componentRule/componentRule.generate.json +0 -31
- package/guidelines/conventions/conventions.generate.json +0 -25
- package/guidelines/cssRule/cssRule.generate.json +0 -31
- package/guidelines/docPageRule/docPageRule.generate.json +0 -31
- package/guidelines/docPageRule/docPageRule.instruction.md +0 -25
- package/guidelines/docSyncRule/docSyncRule.generate.json +0 -31
- package/guidelines/docSyncRule/docSyncRule.instruction.md +0 -25
- package/guidelines/enumConstant/enumConstant.generate.json +0 -31
- package/guidelines/enumConstant/enumConstant.instruction.md +0 -28
- package/guidelines/fieldRule/fieldRule.generate.json +0 -31
- package/guidelines/framework/framework.generate.json +0 -32
- package/guidelines/mcpRule/mcpRule.generate.json +0 -35
- package/guidelines/modelConstant/modelConstant.generate.json +0 -32
- package/guidelines/modelDictionary/modelDictionary.generate.json +0 -32
- package/guidelines/modelDocument/modelDocument.generate.json +0 -31
- package/guidelines/modelService/modelService.generate.json +0 -31
- package/guidelines/modelSignal/modelSignal.generate.json +0 -31
- package/guidelines/modelStore/modelStore.generate.json +0 -31
- package/guidelines/modelTemplate/modelTemplate.generate.json +0 -31
- package/guidelines/modelUnit/modelUnit.generate.json +0 -31
- package/guidelines/modelUtil/modelUtil.generate.json +0 -31
- package/guidelines/modelView/modelView.generate.json +0 -31
- package/guidelines/modelZone/modelZone.generate.json +0 -31
- package/guidelines/moduleCodegen/moduleCodegen.generate.json +0 -31
- package/guidelines/moduleCodegen/moduleCodegen.instruction.md +0 -28
- package/guidelines/moduleOverview/moduleOverview.generate.json +0 -31
- package/guidelines/queryRule/queryRule.generate.json +0 -31
- package/guidelines/recipeRule/recipeRule.generate.json +0 -31
- package/guidelines/runtimeRule/runtimeRule.generate.json +0 -25
- package/guidelines/scalarConstant/scalarConstant.generate.json +0 -32
- package/guidelines/scalarDictionary/scalarDictionary.generate.json +0 -32
- package/guidelines/scalarModule/scalarModule.generate.json +0 -31
- package/guidelines/sharedUiUsage/sharedUiUsage.generate.json +0 -31
- package/guidelines/sharedUiUsage/sharedUiUsage.instruction.md +0 -25
- package/guidelines/ssrRule/ssrRule.generate.json +0 -31
- package/guidelines/transportRule/transportRule.generate.json +0 -31
- package/guidelines/utilUiUsage/utilUiUsage.generate.json +0 -31
- package/guidelines/utilUiUsage/utilUiUsage.instruction.md +0 -25
- package/guidelines/workspaceOnboarding/workspaceOnboarding.generate.json +0 -24
- package/guidelines/workspaceRecipes/workspaceRecipes.generate.json +0 -33
- package/index-2hq52jq3.js +0 -465
- package/index-6wwzs1bc.js +0 -7
- package/index-8pm30m73.js +0 -841
- package/index-kn1h7p1x.js +0 -560
- package/index-meh558jy.js +0 -65
- package/index-rwg2aa4f.js +0 -229
- package/templates/__scalar/__model__/__model__.abstract.md +0 -28
- package/templates/module/__model__.abstract.md +0 -28
- package/templates/service/__model__.abstract.md +0 -28
- package/workflow.command-nwpcdc8p.js +0 -107
|
@@ -45,10 +45,16 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
45
45
|
clear and close. `chrome={false}` drops the header bar whole, for an `inline` chat inside a panel the app
|
|
46
46
|
already titles, and `defaultDraft` opens the composer with text in it without sending it. A panel driven by a
|
|
47
47
|
controlled `open` with **no** `onOpenChange` draws no close button at all rather than an inert one. Then the
|
|
48
|
-
slots: `AgentLauncher`, `AgentBubble`, `AgentComposer`, `AgentApproval`,
|
|
49
|
-
`AgentQuestion`, `AgentQueued`, `AgentMenu`, `AgentMarkdown` and `AgentCode` each replace one part in `_overrides.tsx`, and
|
|
48
|
+
slots: `AgentLauncher`, `AgentSteps`, `AgentBubble`, `AgentComposer`, `AgentApproval`,
|
|
49
|
+
`AgentQuestion`, `AgentToolCard`, `AgentQueued`, `AgentMenu`, `AgentMarkdown` and `AgentCode` each replace one part in `_overrides.tsx`, and
|
|
50
50
|
`akanjs/ui` exports every default beside them (`DefaultBubble`, `DefaultComposer`, …) so a skin composes the
|
|
51
|
-
one it is replacing. `
|
|
51
|
+
one it is replacing. **`AgentSteps` is one agent turn** — the messages between the user message that opened it
|
|
52
|
+
and the next one, with `isRunning` saying whether more are still coming — which is the grain a chat needs to
|
|
53
|
+
fold a turn's steps into a `details` and stand its answer outside them. It is the one boundary a per-message
|
|
54
|
+
slot cannot see, since neither message on either side of it knows it is at an edge. The default draws the turn's
|
|
55
|
+
messages flat into a Fragment, so a chat that binds nothing renders exactly what it did before the slot existed. The attachment row is exported on its own as `AgentAttachments`, because a replaced
|
|
56
|
+
`AgentBubble` that redraws it by hand forks the rule for which carrier is shown how — and then keeps whatever
|
|
57
|
+
that rule was on the day it was copied. `AgentCode` is where a highlighter binds — the fence's language reaches it — and a
|
|
52
58
|
replacement for `AgentBubble` carries its own `memo`, since the transcript re-renders on every delta. Only then
|
|
53
59
|
`AgentChat`, which replaces the panel whole: it also replaces the slash commands, the compaction notice and the
|
|
54
60
|
approval gate, so reach for it when the *layout* differs, not when the look does.
|
|
@@ -70,24 +76,41 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
70
76
|
- **The LLM is configured in `option.ts`, never through the environment.** `option.setLlm({ apiKey, model, host })`
|
|
71
77
|
— or `setLlm((options) => …)` to read the key out of the app's own env object, which is where a secret belongs —
|
|
72
78
|
fills whichever adaptor holds `LlmAdaptorRole`, reaching it as the `llmOption` use. The settings are the role's
|
|
73
|
-
rather than one provider's, so they survive a swap.
|
|
74
|
-
`https://api.
|
|
75
|
-
providers the way middleware is applied:
|
|
76
|
-
write its own — an `adapt()` class in a
|
|
77
|
-
ignoring `onDelta` still answers whole —
|
|
78
|
-
|
|
79
|
-
`
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
79
|
+
rather than one provider's, so they survive a swap. **`OpenaiLlm` is the built-in default**, pointed at
|
|
80
|
+
`https://api.openai.com/v1`; with no `apiKey` or no `model` the app still boots and the chat answers
|
|
81
|
+
`llmUnavailable`. Swap providers the way middleware is applied:
|
|
82
|
+
`option.applyAdaptor(LlmAdaptorRole, AnthropicLlm)`. An app may still write its own — an `adapt()` class in a
|
|
83
|
+
`srvkit/` implementing `LlmAdaptor.chat(request, onDelta?)`, where ignoring `onDelta` still answers whole — and
|
|
84
|
+
whatever settings it needs beyond `LlmOption` travel through the same `setLlm`, read back with
|
|
85
|
+
`use<MyLlmOption>()` from an interface extending it.
|
|
86
|
+
- **Two adaptors ship, one per wire — a vendor is a host and a model name, not a protocol.** `OpenaiLlm` is the
|
|
87
|
+
chat-completions dialect (`OpenaiDialect`) pointed at whatever `host` names, so OpenAI, DeepSeek, Groq,
|
|
88
|
+
Together, OpenRouter, Ollama and a self-hosted vLLM are all one class and one `option.setLlm({ host, model })`.
|
|
89
|
+
`AnthropicLlm` is the Messages API and declares `{ image: true, document: true }`; it is its own file rather
|
|
90
|
+
than a branch, because the system prompt is a field and not a message, tool calls and results are content
|
|
91
|
+
blocks, the results ride in a *user* turn, roles must alternate — so two wire turns that map to one role are
|
|
92
|
+
merged — and `max_tokens` is required. **Both require `model`**: a default would age into a 404, and worse, it
|
|
93
|
+
would decide the vision claim for the app.
|
|
94
|
+
- **`OpenaiLlm` claims vision for its default host and for nothing else.** OpenAI's own endpoint takes image
|
|
95
|
+
parts, so that is what it answers with no `host` set. A `host` the app named is a gateway the class knows
|
|
96
|
+
nothing about, and it stays text-only until `option.setLlm({ accepts })` says otherwise — handing bytes to a
|
|
97
|
+
model that cannot decode them kills the whole turn on a 400, where text-only degrades them to a note the model
|
|
98
|
+
can repeat back.
|
|
99
|
+
- **A refusal is `agent.error.llmRequestFailed`, and it names the host** — unless the prompt did not fit. The
|
|
100
|
+
provider's own sentence rides in `reason` and the hostname in `provider`. `LlmOverflow.refusal(host, status,
|
|
101
|
+
reason)` builds it, and reads the one refusal a chat can undo — a prompt past the window, recognized from each
|
|
102
|
+
provider's own sentence — as `agent.error.contextOverflow` with the `limit` it named, which the relay flags so
|
|
103
|
+
the chat compacts and asks again. An adaptor an app wrote throws through the same function, so its refusals
|
|
104
|
+
translate and recover the way the shipped ones do without a key in the framework's dictionary it cannot add.
|
|
86
105
|
- **`accepts` is answered per model, through `option.setLlm({ accepts })`.** An adaptor answers for an API and one
|
|
87
106
|
API serves models that differ, so the override rides beside the `model` it is a fact about. It is not a table
|
|
88
107
|
the framework keeps: a table is a claim about models that ship after it, and getting this wrong is the worst
|
|
89
108
|
failure available — a provider handed bytes it cannot decode either refuses the turn or accepts it having seen
|
|
90
109
|
nothing, and the model then answers confidently about a file it never read.
|
|
110
|
+
- **`contextWindow` is declared the same way: `option.setLlm({ contextWindow })`.** Every turn relays it with the
|
|
111
|
+
answer ceiling the adaptor actually sends (`LlmAdaptor.limits` — `AnthropicLlm` always sends one, `OpenaiLlm`
|
|
112
|
+
none), and the chat guards its window with it. It is not a table either; a model id is retired or renamed long
|
|
113
|
+
before a table notices.
|
|
91
114
|
- **An adaptor answers `null` for "not configured" and *throws* for a refusal it can explain.** The two are
|
|
92
115
|
different things to be told: collapsing both into `null`, the way the adapter convention otherwise reads, left a
|
|
93
116
|
user reading `llmUnavailable` — "no model is configured" — about a conversation that had merely outgrown the
|
|
@@ -110,8 +133,9 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
110
133
|
arrives as an image. `accepts.document` is one boolean over every non-image type, so an adaptor whose API has a
|
|
111
134
|
block for only some of them — Anthropic's `document` is PDF and nothing else — names the rest in the text rather
|
|
112
135
|
than dropping them, which is the wire's own rule applied at the one layer that knows which blocks exist. Persisting keeps each
|
|
113
|
-
attachment's name and drops its content: web storage is a few megabytes, one
|
|
114
|
-
a save that fails is silent — so keeping the bytes would quietly stop keeping
|
|
136
|
+
attachment's name, type, address and `ref` and drops only its content: web storage is a few megabytes, one
|
|
137
|
+
screenshot fills a chunk of it, and a save that fails is silent — so keeping the bytes would quietly stop keeping
|
|
138
|
+
the transcript, while an id is the handle a restored conversation has left to find the file with.
|
|
115
139
|
**The ceilings are the message's, not the file's**: 4 MB per file, 8 MB and five files per message, and the same
|
|
116
140
|
file twice is refused by name. The bytes ride inside one turn's JSON, so what a provider refuses is the sum —
|
|
117
141
|
and a request that cannot be sent is one the user has to empty the composer to escape, which is why the refusal
|
|
@@ -121,6 +145,26 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
121
145
|
reader produced**, not on the file that was picked: a reader that uploads and answers a `url` has already paid
|
|
122
146
|
what the ceiling exists for, so refusing its 6 MB photo would refuse it for a cost it does not incur. Only the
|
|
123
147
|
built-in path checks the source size, and there the file itself is the carrier.
|
|
148
|
+
- **An app may also build attachments itself and hand them to `session.send([{ role: "user", text, attachments }])`**,
|
|
149
|
+
which is why `MessageAttachment` is exported from `akanjs/ui` — the composer's path is typed by the `AttachReader`
|
|
150
|
+
callback, that one is not, and an object assembled with no nameable type is the one that gets a field wrong.
|
|
151
|
+
Nothing downstream trusts the result: an attachment whose `mimeType` is not a string is noted like any other
|
|
152
|
+
unreadable one, and its chip renders its name with no preview, because the host wrote the object and the declared
|
|
153
|
+
type is a claim rather than a check. A field that arrives wrong costs that attachment, never the turn.
|
|
154
|
+
- **A `url` carrier is an address the *provider* fetches, and bytes beside it win.** A reader that uploads answers
|
|
155
|
+
`{ url }` when its storage is publicly reachable, and `{ data }` — or both — when it is not: an attachment
|
|
156
|
+
carrying both is read from its bytes and displayed from its address, so the chip draws a thumbnail and the model
|
|
157
|
+
still gets the picture. That precedence exists because the other order fails the one way nothing reports: the
|
|
158
|
+
default storage backend serves a path only the app can resolve, the adaptor would hand it over as written, and
|
|
159
|
+
the model answers about a picture it never saw. Send a provider-reachable URL **alone** — paired with bytes it
|
|
160
|
+
costs the provider hop whatever the bytes weigh, for a fetch the provider would have done itself.
|
|
161
|
+
- **An image type the provider has no block for is refused at the adaptor, not upstream.** `accepts.image` is one
|
|
162
|
+
boolean, so `AgentService.readable` passes every `image/*` through, and the built-in composer reader base64s
|
|
163
|
+
every `image/*` as well — an `AttachReader` answering `null` means "not mine" and falls through to it, so an app
|
|
164
|
+
cannot gate one either. Both shipped vision adaptors therefore match an exact set (`jpeg`, `png`, `gif`, `webp`)
|
|
165
|
+
and name the rest in the text. It matters because an unsupported type is not one unread attachment: it is a
|
|
166
|
+
block the API refuses, so the **whole turn** dies on a vendor 400 — and `image/heic`, the iPhone camera default,
|
|
167
|
+
is the likeliest one an app meets.
|
|
124
168
|
- **`MessageAttachment.ref` is the host's own handle on the file — carried, never read.** A file id, a storage key,
|
|
125
169
|
whatever turns the attachment back into something a tool can be handed. The framework only moves it: the model is
|
|
126
170
|
never shown one, and a tool the app publishes (`useFileFieldTool` below) is what spends it. Without it a host that
|
|
@@ -141,6 +185,52 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
141
185
|
WebView, **which has neither on Android or iOS**, so a `speech.plugin.ts` declares the permission and
|
|
142
186
|
the packages the native build needs. An engine answering `available()` false renders no microphone at all,
|
|
143
187
|
the same rule as publishing no tool for a control the screen does not draw.
|
|
188
|
+
- **Data the user points at rides the message too, as `references` — and its value is a snapshot.** A file is
|
|
189
|
+
something they handed over; a reference is something they pointed at while asking, so it belongs in the same
|
|
190
|
+
place and for the same reason. Screen context does not do this job: `ContextBlock`s are rebuilt from the screen
|
|
191
|
+
every turn, so what was named three turns ago is simply not in the conversation any more. A `MessageReference`
|
|
192
|
+
carries the host's own `refName`, the id, a label, an optional dotted `path` into the document, and the `value`
|
|
193
|
+
those resolved to. **The value is never re-read on a later turn**, which is the point: re-reading rewrites what
|
|
194
|
+
the person was looking at when they spoke, and the usual next thing the agent does is edit the very field it was
|
|
195
|
+
pointed at — leaving a live reference showing the result with no record of what it was changed from.
|
|
196
|
+
`refName`/`refId`/`path` travel so a tool can read the current value when the answer needs it, and
|
|
197
|
+
`AgentService.referenced` folds the whole lot into the message text in one place, so every provider carries a
|
|
198
|
+
reference without either adaptor knowing the word. **The value is masked on the way in and nowhere else**: masking
|
|
199
|
+
needs the model class, which no wire carries, so the model a host names when it stages the reference is the whole
|
|
200
|
+
of the decision about what leaves the browser — name the class that actually holds the field, because a `Light`
|
|
201
|
+
one usually does not. **The ceiling is 20,000 characters per reference**, applied as it is staged and again on
|
|
202
|
+
the server; a reference is bulkier than a tool result and outlives one, riding every later turn and being the last
|
|
203
|
+
thing compaction folds. Persisting keeps the pointer and drops the value with a note saying so, which is a better
|
|
204
|
+
restored state than an attachment can offer — the pointer is still live, so the answer is to read it again.
|
|
205
|
+
Staging lives on the **session** (`stage` / `staged` / `unstage(key)` / `clearStaged()`), not on the composer where
|
|
206
|
+
staged files live, because a reference comes from whichever component drew the data rather than from the paperclip;
|
|
207
|
+
`unstage` is keyed on `refName/refId#path` because what orders a message's references is its text, not that list.
|
|
208
|
+
- **Two ways to point at data, and which one depends on who knows the value.** `<Agent.Chat reference={[…]} />`
|
|
209
|
+
takes one `ReferenceSource` per kind of document — `refName`, a `type`, the app's own `search`, and a `resolve` —
|
|
210
|
+
and the composer's `@` menu offers whole documents from them; the token is written the moment a row is picked and
|
|
211
|
+
the value is staged when `resolve` lands, so a slow fetch never freezes the menu and one that fails leaves a
|
|
212
|
+
pointer that reads as *go and read it*. For a field *inside* a document, the component drawing it calls
|
|
213
|
+
`useAgentReference()` — it already holds the value, so there is no round trip, and it is the only thing that
|
|
214
|
+
knows a rich-text field stored as `field(Any)` reads as a paragraph rather than as the editor document it is. The
|
|
215
|
+
hook no-ops with a warning outside a session, the same call `AgentValue.publishable` makes: a card carrying a
|
|
216
|
+
reference button must not cost a route its render.
|
|
217
|
+
- **The token in the draft is what carries a reference, and the chip only draws it.** Deleting
|
|
218
|
+
`@[label](mention:refName/id#path)` by hand drops the reference exactly as removing the chip does — removing the
|
|
219
|
+
chip removes the token — so the two can never disagree, and nothing re-derives a value from text somebody edited.
|
|
220
|
+
A token pasted out of an earlier message travels as a pointer with a note, which is the same shape a restored
|
|
221
|
+
conversation produces. Staged references are the **session's**, not the composer's, because `useAgentReference`
|
|
222
|
+
is called from components the composer cannot see; writing the token is the composer's half, and `refer` warns
|
|
223
|
+
rather than staging silently when no chat is mounted on that session — the case where a card sits outside the
|
|
224
|
+
`<Agent.Zone>` its chat is inside.
|
|
225
|
+
- **The composer draws a pointer as the name it points at, never as the token.** Where `reference` sources are
|
|
226
|
+
declared the input is a Lexical editor rather than a textarea, and each pointer is one atomic node — a backspace
|
|
227
|
+
takes the whole thing rather than a character of a label that would then name nothing. Nothing above it changes:
|
|
228
|
+
the node's text content *is* the `@[…](mention:…)` token, so the draft string the chat reads, the `@` query, the
|
|
229
|
+
chips, the wire and the server's framing all see what they always saw, and every offset the chat hands over is
|
|
230
|
+
an offset into that string. It is its own chunk behind the chat's, fetched only where a source was declared, and
|
|
231
|
+
`mentions={false}` keeps the plain textarea — for an app that overrides `AgentComposer`, or one that would
|
|
232
|
+
rather see the tokens it is sending. An override that draws its own textarea keeps working: it fills the
|
|
233
|
+
`inputRef` it is handed, and the chat reads the caret off that when no editor filled `handleRef`.
|
|
144
234
|
- **`attach` and `voice` both carry functions, so a server layout cannot pass either.** A closure does not cross
|
|
145
235
|
the RSC boundary — `non-scalar-props-restricted` says so on `page/**` — so an app that wants either mounts the
|
|
146
236
|
chat from a small client component in the app's own `ui/` that calls the hook.
|
|
@@ -212,6 +302,14 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
212
302
|
`data-akan-action` like a store setter does. A `remove*` name defaults to a confirm gate. Reach a store action
|
|
213
303
|
from the body — `.exec((id) => st.do.removeX(id))` — which is how an agent gets CRUD; `st.do` on its own
|
|
214
304
|
reaches nobody.
|
|
305
|
+
- **`.card()` is the other way the chain ends, for an answer that is the user's to give.** Where `.exec()` runs a
|
|
306
|
+
function, `.card(({ submit, cancel }, ...args) => <Form …/>)` parks the call in the chat and renders the app's own
|
|
307
|
+
component there; what `submit` is handed is what the model reads back, and `cancel` is the refusal it reads
|
|
308
|
+
instead. A name and a phone number, a date somebody has to look up, a signature — a model that fills those in
|
|
309
|
+
has answered its own question, and every other shape of "ask the user" is `askUser`. The declared arguments are
|
|
310
|
+
checked **before** the card is parked, so a bad one reaches the model as a refusal rather than throwing inside
|
|
311
|
+
the render; `confirm` is not read at all, because the card in front of the user is already the asking. The frame
|
|
312
|
+
draws its own way out even when the card does not, and the turn is never parked on something with no exit.
|
|
215
313
|
- **The second argument is how the call behaves, never what it is.** `{ settle, confirm, guard }`:
|
|
216
314
|
`settle: false` marks a read that returns what is already there and skips the wait; every other tool is waited
|
|
217
315
|
out before its effect on the screen is reported, because a write may still be landing when `exec` resolves. A
|
|
@@ -447,18 +545,29 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
447
545
|
- **A long conversation summarizes itself, because nothing else is keeping it inside the model's window.** The
|
|
448
546
|
loop runs in the browser and the relay holds no session, so an uncompacted chat grows until the provider
|
|
449
547
|
refuses the whole request — a refusal, never a shorter answer, which is why compaction runs *before* the turn
|
|
450
|
-
that would have overflowed
|
|
451
|
-
characters to a token, over the JSON the turn posts
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
548
|
+
that would have overflowed. **Two triggers, each before every assistant turn.** `compact.at` is a ceiling on
|
|
549
|
+
cost: past that many estimated transcript tokens (four characters to a token, over the JSON the turn posts —
|
|
550
|
+
except an inlined picture, counted at the ~1,600 tokens a provider bills for its pixels rather than its base64;
|
|
551
|
+
24k by default) the history compacts, because the relay resends the whole transcript every turn and the app
|
|
552
|
+
pays for each one. The **window guard** compacts once the prompt nears the window the server reports —
|
|
553
|
+
`window − answer ceiling (8,192 when unreported) − compact.buffer (13,000, the margin Claude Code leaves)` —
|
|
554
|
+
measured by the provider's own count of the last turn (`done.usage`, kept on that assistant message) plus the
|
|
555
|
+
four-character estimate for what arrived since, so a Korean transcript the character rule reads as small is
|
|
556
|
+
still caught. The window comes from `option.setLlm({ contextWindow })` or from the first refusal that names it;
|
|
557
|
+
with neither, only the ceiling runs. **A refusal for length is answered once**: a relay flags it `overflow` on
|
|
558
|
+
the wire, and the session compacts and sends the same turn again, and a second refusal in the same send fails
|
|
559
|
+
with `agent.error.contextOverflow`. Either way the history above the last `keep` messages
|
|
560
|
+
becomes one message standing in for it, flagged `summary` on the wire — `<Agent.Chat compact={{ at, keep, buffer }} />`
|
|
561
|
+
tunes it, `{ at: Infinity }` leaves only the window guard, `{ at: 0 }` turns off the ceiling, the guard and the recovery alike, and `/compact` does
|
|
562
|
+
the same on demand keeping nothing. The header draws the estimate against whichever trigger is nearer.
|
|
455
563
|
**The cut never lands on a `tool` message**: the kept half may not open with a result whose call was summarized
|
|
456
564
|
away, a shape every provider dialect rejects. A user message is preferred where the tail holds one — everything
|
|
457
565
|
above it is settled — but one assistant turn that ran ten tools has none, and that is the transcript that
|
|
458
566
|
outgrows the window, so a cut there opens on the assistant message instead. The summarizing
|
|
459
567
|
turn carries no tools and no screen context — it summarizes the conversation, it does not act on it — and it is
|
|
460
568
|
fed a *bounded* digest rather than the transcript itself, since the transcript being summarized is the one that
|
|
461
|
-
no longer fits.
|
|
569
|
+
no longer fits. The previous summary is the exception: it rides the digest whole, under its own label and outside
|
|
570
|
+
the bound, because it is the only record of what was compacted before it. A summary that cannot be produced leaves the transcript alone, the turn goes out as it would
|
|
462
571
|
have, and the next one asks again — a summarizer that was momentarily unreachable says nothing about whether
|
|
463
572
|
this transcript can shrink. One that *landed* and shrank nothing does, so that one is not retried until another
|
|
464
573
|
threshold's worth has been added. On the
|
|
@@ -610,4 +719,76 @@ apps and libs never import it directly (`no-import-external-library`) — everyt
|
|
|
610
719
|
is among them. The loop parks on the question exactly as it parks on an approval, a dismissal is the tool's
|
|
611
720
|
error result rather than a silent empty answer, and the settled exchange renders as question-and-answer instead
|
|
612
721
|
of a tool row. **Never re-implement it per screen** — a `st.tool("askAboutX")` that opens a modal is the same
|
|
613
|
-
thing with a worse transcript — and a hook tool named `askUser` shadows it like any other built-in.
|
|
722
|
+
thing with a worse transcript — and a hook tool named `askUser` shadows it like any other built-in. The line
|
|
723
|
+
between it and a `.card()` tool is what the answer *is*: a sentence or a pick off a list is `askUser` and belongs
|
|
724
|
+
to the conversation, while a shape the model must not invent — fields, a form, a signature — is a card the screen
|
|
725
|
+
declares, because only the screen knows what filling it in looks like.
|
|
726
|
+
|
|
727
|
+
## What The Page Draws
|
|
728
|
+
- **The page shows the agent's work, not just the transcript.** A chat panel is closed as often as it is open, and
|
|
729
|
+
a form that fills itself or a tab that switches on its own is a change the user watches happen with nothing
|
|
730
|
+
anywhere attributing it. So **the control a call was published from is ringed** where it stands, scrolled to
|
|
731
|
+
first when it is off screen, and **a pointer travels to it and presses it** — both on by default, and both
|
|
732
|
+
driven from the tool call itself rather than from a store action, which cannot tell the agent's write from the
|
|
733
|
+
user's.
|
|
734
|
+
- **It costs an app nothing, for the same reason `data-akan-action` does.** `onChange={st.do.setTitleOnTask}` by
|
|
735
|
+
reference is what annotates the control, and the annotation is what makes it findable — so the fields and verbs
|
|
736
|
+
that matter light up with no code. An inline arrow publishes no tool, carries no annotation, and therefore
|
|
737
|
+
draws nothing: one wrapper silently costs three things at once.
|
|
738
|
+
- **For a control the framework did not render, spread `agentAttrs`** from `akanjs/ui`:
|
|
739
|
+
`<button {...agentAttrs(run)} onClick={run}>`, where `run` is what `st.tool(…).exec(…)` returned. It reads the
|
|
740
|
+
name off the callable, so there is nothing to keep in sync and no `ref` to thread — and it earns the same E2E
|
|
741
|
+
selector and accessibility name an `akanjs/ui` control has. Never hand-write `data-akan-*`.
|
|
742
|
+
- **A control that shares its handler with its siblings takes a key**: `agentAttrs(switchTab, menu)`, in the same
|
|
743
|
+
vocabulary the call's argument uses. One tool serves a whole tab strip or a whole list, so without it every
|
|
744
|
+
namesake is interchangeable in the DOM and the page can say *what* the agent did but never *where* — which the
|
|
745
|
+
no-guessing rule then turns into drawing nothing. `Tab.Menu` passes its `menu` key; do the same for a row verb
|
|
746
|
+
whose id the call names.
|
|
747
|
+
- **Almost nothing is waited on.** The call starts the moment the effect is handed its event; a ring that lands a
|
|
748
|
+
frame later costs the turn nothing, and an animation that held a call would make the agent slower for a
|
|
749
|
+
decoration. A turn past four scrolls stops scrolling and keeps ringing — eight scrolls across a page is motion
|
|
750
|
+
sickness rather than attribution. **The one exception is `navigate`**, whose press has to land before the router
|
|
751
|
+
replaces the tree the link is in; it is capped at 600ms and the call goes either way.
|
|
752
|
+
- **What it refuses to draw is the point.** A name several rows answer to rings nothing rather than guessing a row,
|
|
753
|
+
a call an approval or a guard turned back is never drawn at all, a tool no control carries draws nothing, and a
|
|
754
|
+
backgrounded tab draws nothing. A ring on the wrong element is worse than no ring: it is the screen telling the
|
|
755
|
+
user something untrue about what just happened. **A control the screen is not actually showing is not pointed at
|
|
756
|
+
either, and the pointer goes rather than travelling to it**: `checkVisibility` answers about an element alone, so
|
|
757
|
+
one under a modal's backdrop, inside a drawer that has slid off, or faded to nothing all pass it while being
|
|
758
|
+
invisible, and a pointer sent there lands on a blank patch of overlay. The test is `elementFromPoint` at the
|
|
759
|
+
control's own centre, taken after the reveal has settled. **`navigate` is mostly in that last class** — the router is not
|
|
760
|
+
an element, so it draws nothing. A bar across the top of the page was tried for it and removed: it read as
|
|
761
|
+
chrome the page had grown rather than as the agent doing something, which is the opposite of attribution. What
|
|
762
|
+
it does draw is the **link already on screen that goes where it is going**, when exactly one does and the user
|
|
763
|
+
can already see it: that one is an element, and the pointer presses it before the route moves. Addresses are
|
|
764
|
+
compared as routes (`router.routeOf`), since the locale and base-path segments are on the `<a>` and never on the
|
|
765
|
+
tool argument. An off-screen link is left alone — scrolling to a link and then leaving the page it is on is two
|
|
766
|
+
motions for one act. **Link presence is a drawing condition, never a permission one**: the agent may navigate
|
|
767
|
+
anywhere the user could type, and a zone that must not leave one screen drops `navigate` through `builtins`.
|
|
768
|
+
- **Turn it off with `visual`** on `Agent.Chat` / `Agent.Zone` — `false` for all of it, `{ cursor: false }` or
|
|
769
|
+
`{ reveal: false }` for one effect. The pointer is chrome (`data-agent-ui`, `aria-hidden`,
|
|
770
|
+
`pointer-events: none`), so `readScreen` never reads it back and `highlight` can never aim at it.
|
|
771
|
+
- **`ToolActivity` is the channel underneath**, a `ToolRunner` host option and an `AgentSession.onActivity`, firing
|
|
772
|
+
`start` / `end` around the execution alone. It is deliberately not `progress`: that one fires only for a tool
|
|
773
|
+
that chose to report, and a tool that says nothing about itself is exactly the one whose effect arrives
|
|
774
|
+
unexplained. The `start` may answer a promise, and the call waits for it — the whole of what makes drawing a
|
|
775
|
+
click on a link possible.
|
|
776
|
+
- **`AgentSession.onTurn(running)` is the other half**, and the unit the pointer's life is measured in. A model's
|
|
777
|
+
calls arrive with its own writing between them, seconds each, so a pointer that lived for the length of a *call*
|
|
778
|
+
spent every turn vanishing and coming back. Only the conversation loop reports there — `compact` runs under the
|
|
779
|
+
same internal flag and drives nothing on screen.
|
|
780
|
+
- **The pointer presses on arrival, never on departure** — a press fired as it leaves is a click on whatever it
|
|
781
|
+
was still over. It glides only when the hop is far enough to be worth following (a third of a second to cross
|
|
782
|
+
fifty pixels reads as lag, not motion), and teleports otherwise. It appears at the first control it presses,
|
|
783
|
+
**drifts clear of what it pressed** and waits out the gap between calls there as a spinner — a person clicks and
|
|
784
|
+
takes the hand away, and a spinner left sitting on the button covers the change it just caused — and fades when
|
|
785
|
+
the turn ends; **a turn that
|
|
786
|
+
drove no control draws no pointer at all**, since parking one in a corner for a turn that only answered a
|
|
787
|
+
question claims something that did not happen. While a reveal scrolls, it holds still — as a person's does — and
|
|
788
|
+
carries a chevron pointing the way the view is travelling, because stillness over a sliding page otherwise reads
|
|
789
|
+
as a pointer that has come loose rather than as the one doing the scrolling. The ring answers *where* and the
|
|
790
|
+
pointer answers *who*, which is why they are one default rather than two.
|
|
791
|
+
- **A form patch fans out over the fields it named.** `fill<Model>Form` is published by the form rather than by any
|
|
792
|
+
one control, so it rings one control per field in the arguments — resolved through the same
|
|
793
|
+
`data-akan-state="<model>Form.<field>"` the setter annotates — capped at five. `writeOn<Model>(path, value)`
|
|
794
|
+
draws nothing, for the same reason it carries no annotation: there is no control to put one on.
|
|
@@ -28,7 +28,7 @@ you fetch on demand — `get_guideline` with the name, or `akan guideline show <
|
|
|
28
28
|
| name | covers |
|
|
29
29
|
|---|---|
|
|
30
30
|
| `ssrRule` | server-share targets, the `akan.ssr.*` warnings, the client-boundary playbook |
|
|
31
|
-
| `runtimeRule` | `web` / `csr` surfaces, gateway vs solo processes, logging, the generated image, shipped assets |
|
|
31
|
+
| `runtimeRule` | `web` / `csr` surfaces, gateway vs solo processes, logging, the generated image, shipped assets, database modes |
|
|
32
32
|
| `queryRule` | slices and hydration, the generated filter methods, full-text search, cascade removal |
|
|
33
33
|
| `transportRule` | guards across HTTP and websocket, socket identity and cleanup, binary pubsub, mutation verbs |
|
|
34
34
|
| `mcpRule` | MCP configuration, wire behaviour, resource URIs, OAuth metadata, protocol revisions |
|
|
@@ -216,6 +216,8 @@ Do not narrate code. Do document the thing the code cannot say. Both halves are
|
|
|
216
216
|
## Client / Server Boundaries (`apps/**`, `libs/**`, `pkgs/akanjs/**`)
|
|
217
217
|
|
|
218
218
|
- Use `"use client";` at the top of client component files.
|
|
219
|
+
- A `"use client"` file re-exports by name (`export { A, B } from "./x"`), never with `export *`: the server knows a
|
|
220
|
+
client module only by the export names it spells out, so `akan start` and `akan build` stop on a star re-export.
|
|
219
221
|
- Be careful when importing client-only code from page or layout modules.
|
|
220
222
|
- Keep page props serializable unless the existing route pattern clearly allows otherwise.
|
|
221
223
|
- In domain UI the boundary is mechanical, not a judgment call: `Template`, `Zone`, and `Util` are always client components with `"use client"` on line 1; `Unit` and `View` are always server components and never carry the directive.
|
|
@@ -273,8 +275,10 @@ are, because those are the ones the server could have done.
|
|
|
273
275
|
SEO snapshots, prerendering and pre-hydration E2E read, so await what the page needs immediately and stream
|
|
274
276
|
the rest. `xListInY` / `xInsightInY` hold hydrated model instances that React Flight refuses as client props:
|
|
275
277
|
consume them in a server component, never as a `Zone` prop.
|
|
276
|
-
6. **Use named `ReactNode` slots, not just `children`** —
|
|
277
|
-
|
|
278
|
+
6. **Use named `ReactNode` slots, not just `children`** — a client shell that takes one slot per region lets a
|
|
279
|
+
server component supply each of them, instead of absorbing the whole subtree into the bundle. `Layout.Navbar`
|
|
280
|
+
declares `title` / `left` / `right` alongside `back` and `children`, but renders only the last two today, so
|
|
281
|
+
copy the shape rather than the component.
|
|
278
282
|
7. **Let the server do the derived work.** Display and predicate logic belongs on `Light<Model>`; enum→class
|
|
279
283
|
lookups belong in a module-scope `as const` map.
|
|
280
284
|
8. **Gate auth on the server** — `getSelf({ unauthorize: "/signin" })` in `_layout.tsx`, before any HTML is sent.
|
|
@@ -335,6 +339,14 @@ Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runti
|
|
|
335
339
|
- **`assets: { pruneFonts, keepFonts }`** trims from the `dist` copy of `public/` the fonts nothing reads; source
|
|
336
340
|
trees are never touched. A font with `optimize` on is a build input, not a runtime asset. `keepFonts` belongs to
|
|
337
341
|
the `akan.config.ts` that owns the font, written against that scope's own `public/`.
|
|
342
|
+
- **Database modes are `database: { modes: [...] }`** — `single` (SQLite + Solid), `multiple` (one SQLite file on
|
|
343
|
+
a host volume + Redis), `cluster` (Postgres + Redis). The build carries the drivers of every declared mode and a
|
|
344
|
+
deployment picks one with `AKAN_DATABASE_MODE` — a declared one, named whenever the build declares several.
|
|
345
|
+
Where the data lives (`POSTGRES_URL`, `REDIS_URI`, `SQLITE_DATABASE_PATH`) is the deployment's env, ahead of
|
|
346
|
+
`env.server.ts`. `q.raw` is written in the dialect's own SQL, so an app running in two modes avoids it.
|
|
347
|
+
- **Backups run through the image's own `main.js`**: `bun main.js ops snapshot` / `ops restore` (offline), and
|
|
348
|
+
`/_akan/ops/*` for a control plane, mounted only when `AKAN_OPS_PUBLIC_KEY` is set and authenticated by a short
|
|
349
|
+
EdDSA token that key verifies. `GET /_akan/app/info` is public. `operationMode` gates none of it.
|
|
338
350
|
|
|
339
351
|
## React Components And Styling (`**/*.tsx`)
|
|
340
352
|
|
|
@@ -357,7 +369,7 @@ Full contract: `get_guideline` with `runtimeRule`, or `akan guideline show runti
|
|
|
357
369
|
nothing — they covered five control types, keyed on the translated label, and restored over server data.
|
|
358
370
|
- Static class strings stay plain strings. Reach for `cn` only for a conditional or to merge an incoming `className`, and merge the caller last: `cn("base classes", cond && "extra", className)`. `cn` comes from `akanjs/client` (token-aware tailwind-merge) and is the only class-combining function — no `clsx` (removed), no raw `twMerge` imports, no object syntax (`{ x: cond }` → `cond && "x"`).
|
|
359
371
|
- Multi-slot components take extra named props (`wrapperClassName`, `bodyClassName`), never a `classNames` object.
|
|
360
|
-
- Hoist enum→class lookups to a module-scope `as const` map typed `{ [key in cnst.XStatus["value"]]: string }`. Do not use `Record<...>`.
|
|
372
|
+
- Hoist enum→class lookups to a module-scope `as const` map typed `{ [key in cnst.XStatus["value"]]: string }`. Do not use `Record<...>`. When a second module needs it, the classes become a variant axis of a recipe in `ui/Recipe/`; only a map from the enum to another recipe's variant name (`BadgeVariants["variant"]`) moves to `webkit/`.
|
|
361
373
|
- Use `<Link>` from `akanjs/ui` for internal navigation; `<a>` only for `mailto:` and external links.
|
|
362
374
|
|
|
363
375
|
## Naming And Language
|
|
@@ -421,8 +433,11 @@ stores need none, because state and CRUD actions are generated. The body is thre
|
|
|
421
433
|
`this.setX(...)` → toast. The optimistic shape is mutate the client model, `void fetch.*`, then commit. Use
|
|
422
434
|
`this.pick(...)` when the value must exist, `this.get()` when it may not, and `this.set({...})` to write. Mutate lists
|
|
423
435
|
through the collection API (`this.set({ xList: xList.set(x).save() })`), not array spread. **An action returns
|
|
424
|
-
nothing** (`no-return-in-store-action.grit`). **
|
|
425
|
-
`
|
|
436
|
+
nothing** (`no-return-in-store-action.grit`). **Reach another store through `this`:**
|
|
437
|
+
`import type { RootStore } from "../st"`, then `(this as unknown as RootStore).logout()` to call its action or
|
|
438
|
+
`(this as unknown as RootStore).set({ … })` / `.get()` to write or read its state. Every store is mixed into one root
|
|
439
|
+
at runtime, so the cast only tells the type what `this` already is. Keep it `import type` — `st.ts` imports every
|
|
440
|
+
store, so a value import is a cycle.
|
|
426
441
|
|
|
427
442
|
**`<model>.dictionary.ts`** — fixed chain, with empty stages still written:
|
|
428
443
|
`.of() → .model() → .insight() → .query() → .sort() → .enum() → .slice() → .endpoint() → .error() → .translate()`.
|
|
@@ -464,6 +479,25 @@ Full contract — credential handshake, room revalidation, socket cleanup scopin
|
|
|
464
479
|
- **A cleanup registered with `ws.on("disconnect" | "unsubscribe", fn)` is scoped to the call that registered it**
|
|
465
480
|
— a room for a `pubsub` subscribe, the socket for a `message` handler. Cleanup that must happen either way
|
|
466
481
|
registers for both.
|
|
482
|
+
- **A call's deadline is the endpoint's `{ timeout: <ms> }`, and there is nowhere else to put one.** It bounds
|
|
483
|
+
both ends: the `Timeout` middleware — registered by default, and standing aside for every endpoint that
|
|
484
|
+
declares none — rejects the call with `base.error.gatewayTimeout`, and the same value is serialized to the
|
|
485
|
+
client as that call's request budget. Declared nowhere, a call takes the client's own default of **30
|
|
486
|
+
seconds**, so an endpoint whose real work runs longer (provisioning, a firmware flow, an external
|
|
487
|
+
orchestration) has to declare one or it dies mid-flight with a transport error that does not say whether the
|
|
488
|
+
server ever received it. A caller overrides it per call with `fetch.x(…, { timeout })` — `false` waits as long
|
|
489
|
+
as the runtime will — and an app moves its own default with `fetch.instance.setTimeout(ms)`. An upload is
|
|
490
|
+
never bounded. **Losing the race does not cancel the work**: the handler runs to completion with nobody
|
|
491
|
+
holding its result, so a deadline is an answer to the caller, not an undo.
|
|
492
|
+
- **A cached answer is the endpoint's `{ cache: <ms> }`, and only a `query` taking no internal argument may have
|
|
493
|
+
one.** It is a step of the call, not a middleware, and stands aside for every endpoint that declares nothing.
|
|
494
|
+
Internal arguments are how a call learns who is asking (`.with(Self)`), so an endpoint that has them answers
|
|
495
|
+
per caller and one shared entry would be one caller's answer handed to the next — that endpoint and every
|
|
496
|
+
`mutation` are named in the log and left uncached. The lookup runs after the guards, so a hit reaches only a
|
|
497
|
+
caller they admitted; what is cached is the handler's result, so `resolveReturn` still masks fields and resolves
|
|
498
|
+
relations per call; a cache backend that is down is warned about and the call runs uncached. The default
|
|
499
|
+
middleware chain is `Logging → Timeout → <lib middlewares>`, then guards → internal arguments → cache →
|
|
500
|
+
handler, and there is no `Retry` middleware.
|
|
467
501
|
|
|
468
502
|
### Authorization Defaults
|
|
469
503
|
|
|
@@ -530,8 +564,11 @@ Full contract — filter-arg `ref` pickers, `getQueryMeta` summary counters, `la
|
|
|
530
564
|
first "more". `isCumulativeOf<Model>` is the mode.
|
|
531
565
|
- **"Is there more" is `hasMoreOf<Model>`, never `count > page * limit`.** It comes from the length of the batch
|
|
532
566
|
the server returned, so it survives `{ insight: false }` and cannot drift from a count a live event moved.
|
|
533
|
-
- **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`)
|
|
534
|
-
client fetch results
|
|
567
|
+
- **Hydrated vs raw:** server queries return hydrated `cnst.<Model>` instances (with `set`/`save`/`refresh`),
|
|
568
|
+
and so do client fetch results — `FetchClient` parses every return with `crystalize` defaulting to **true**
|
|
569
|
+
(`pkgs/akanjs/fetch/client/fetchClient.ts`). The raw `GetStateObject` shape is what the `<ref>Obj` /
|
|
570
|
+
`<ref>ObjList` / `<ref>ObjInsight` handles carry, because those pass `crystalize: false` on purpose so the
|
|
571
|
+
value can cross the RSC boundary as a client prop.
|
|
535
572
|
- Every filter generates fourteen methods: `list` · `listIds` · `find` · `findId` · `pick` · `pickId` · `exists` ·
|
|
536
573
|
`count` · `insight` · `query` · **`remove`** · **`removeOne`** · **`update`** · **`updateOne`**. The last four
|
|
537
574
|
are query-level writes — one atomic UPDATE, **no hooks**, and therefore no `_postRemove` and no cascade. Use
|
|
@@ -569,7 +606,7 @@ Full contract — relevance ordering, `columns` / `weights`: `get_guideline` wit
|
|
|
569
606
|
- Injected dependencies resolve by field-name convention: a field named `<refName>Service` resolves to the service registered under `<refName>`, and `<refName>Signal` likewise (`pkgs/akanjs/service/injectInfo.ts`).
|
|
570
607
|
- The `Service`/`Signal` suffix is required — the injector strips it to derive the registry lookup key. Name the field after the target refName plus the suffix, not arbitrarily.
|
|
571
608
|
- Preference order inside a service: `service<srv.XService>()` for another module's service · `plug(AdapterClass)` or `plug(StorageAdaptorRole)` for an adapter · `use<T>()` only to reach an `option.ts`-registered legacy singleton · `env(...)` for config.
|
|
572
|
-
- **`memory(...)` takes a scalar or model class, not only a primitive** — `memory(Map, { of: CallStateInput })` serializes through the constant and travels as JSON text, so the Redis and sqlite-backed caches round-trip the same declaration. Never hand-encode JSON into a `String` memory.
|
|
609
|
+
- **`memory(...)` takes a scalar or model class, not only a primitive** — `memory(Map, { of: CallStateInput })` serializes through the constant and travels as JSON text, so the Redis and sqlite-backed caches round-trip the same declaration. Never hand-encode JSON into a `String` memory. A memory is scoped to the service or adaptor that declares it; `ttl` (milliseconds) sets how long each write lives unless the `set` call names its own `expireAt`, and a `Map` entry expires on its own.
|
|
573
610
|
|
|
574
611
|
### Adapters — `adapt()` And `plug()`
|
|
575
612
|
|
|
@@ -644,10 +681,14 @@ it.
|
|
|
644
681
|
(RFC 7591; Cursor's `cursor://` redirect admitted) plus the consent page `libs/shared/page/oauth/consent` — and
|
|
645
682
|
names itself as the issuer, so `/mcp` demands a bearer token, verifies its signature, and refuses one carrying no
|
|
646
683
|
`aud`. A token is the app's own access JWT plus `aud`/`iss`/`client_id`, so `AccountMiddleware` and the guards
|
|
647
|
-
judge it unchanged
|
|
648
|
-
|
|
649
|
-
`
|
|
650
|
-
|
|
684
|
+
judge it unchanged. The tokens this built-in server mints carry no scope; `auth.scopes` / `AKAN_MCP_SCOPES` are for
|
|
685
|
+
tokens an external issuer mints, and declared beside this server they refuse every one of its tokens with 403
|
|
686
|
+
`insufficient_scope`. Per-app knobs live in `env.server.*` under `oauth` (`consentPath` and
|
|
687
|
+
`signinPath` with the basePath, `clients`, `dynamicRegistration`, `allowedRedirectSchemes`, `accessTokenSeconds`,
|
|
688
|
+
`clientIdMetadata`, `enabled`, plus `issuer` / `resource` — set those when a tunnel or an edge makes the derived
|
|
689
|
+
origin wrong, because an MCP client compares the issuer byte for byte). `/mcp` reads the `Authorization` header only — a cookie is dropped at the door — and `JWT_SECRET` (or
|
|
690
|
+
`security.jwtSecret`) is mandatory whenever `operationMode` is not `local`, because the derived fallback is
|
|
691
|
+
forgeable from three public names; `AKAN_ALLOW_DERIVED_JWT_SECRET=1` accepts that risk explicitly. A grant is revoked
|
|
651
692
|
whole — `POST /oauth/revoke` (RFC 7009) by the client, `fetch.revokeOAuthConnection(sessionId)` by the account's
|
|
652
693
|
owner, `fetch.listOAuthConnections()` to see them — and a revoked access token is refused at its next call.
|
|
653
694
|
- **A person may, a model may not.** An agent's token names its `client_id` and `aud`; a browser session has
|
|
@@ -696,9 +737,12 @@ what every developer must know even when not building one.
|
|
|
696
737
|
every tool runs in the caller's own browser session, gated by guards and the approval card. Its guard is
|
|
697
738
|
`AgentRelayAccess`, which refuses every call until the app names a guard of its own —
|
|
698
739
|
`option.setAgentAccess(SignedIn)`. **The LLM is configured in `option.ts`, never through the environment**
|
|
699
|
-
(`option.setLlm({ apiKey, model, host })`).
|
|
700
|
-
|
|
701
|
-
`
|
|
740
|
+
(`option.setLlm({ apiKey, model, host })`). Two adaptors ship, one per wire: `OpenaiLlm` is the default and
|
|
741
|
+
speaks the chat-completions dialect to whatever `host` names — OpenAI, DeepSeek, Groq, OpenRouter, Ollama — and
|
|
742
|
+
`AnthropicLlm` speaks the Messages API and reads a PDF as well as a picture. Both require `model`. A provider
|
|
743
|
+
neither covers is an `adapt()` class in `srvkit/` implementing `LlmAdaptor`, applied with
|
|
744
|
+
`option.applyAdaptor(LlmAdaptorRole, TheClass)`; whatever settings it needs beyond `LlmOption` ride `setLlm`
|
|
745
|
+
too, read back with `use<MyLlmOption>()`.
|
|
702
746
|
- **Declare the tool beside the control that already does it**, never as a separate surface:
|
|
703
747
|
`st.tool("x").desc("…").arg("id", ID).exec(fn)` returns the callable to hand to `onClick` — one handler for the
|
|
704
748
|
person and the agent. Publish it only where the screen already renders the control; a falsy name declares the
|
|
@@ -727,6 +771,7 @@ what every developer must know even when not building one.
|
|
|
727
771
|
- Put display and predicate logic on the `Light<Model>` class rather than in a util module — see Module File Playbook.
|
|
728
772
|
- Defaults are a literal for scalars and a thunk for anything constructed. Arrays are `field([T])`; optional is the postfix `.optional()`.
|
|
729
773
|
- **`field.visual(T)` is a field the page renders and an agent never sees** — a blur placeholder, a rendered HTML body, a serialized geometry. It stays an ordinary stored `property` (persistence, search, forms and the page response untouched) and is stripped wherever a value is masked for an AI caller: every in-page-agent read and every MCP result, along with the MCP readable schema. Unlike `hidden`/`secret` it is cost, not secrecy — nothing is refused over one. Reach for it whenever a field is bulky and useless to a model; that is cheaper than every tool learning to avoid it.
|
|
774
|
+
- **A `field.hidden` / `field.secret` value reads `null` on the client, and the type still declares it.** `SignalContext.resolveReturn` skips both field types while it builds an endpoint's response, and hydration writes `null` over the key rather than leaving it absent — the first branch of the loop in `constant/getDefault.ts`, ahead of the field's own default. So the value is a deliberate `null` behind a type promising a `string`, every use of it typechecks, and the failure lands wherever it is finally dereferenced instead of where it was read. **Guard with `??` or `== null`**: `=== undefined`, a destructuring default and an optional parameter default all catch only a missing key and sail straight past this one. A projection (`pickById(id, { secret: true })`) widens the *server's* read, never the response. If a screen needs the value, the field is neither hidden nor secret; if it only needs to be cheap for a model rather than unseen, that is `field.visual`.
|
|
730
775
|
|
|
731
776
|
### Scalar & Field Type Reference
|
|
732
777
|
|
|
@@ -760,7 +805,8 @@ Full contract — the trigger-maintained mirror, tokenizer changes, `AKAN_SEARCH
|
|
|
760
805
|
backstop, including for a `text` field *underneath* one of those. Do not work around either.
|
|
761
806
|
- The role works on a relation (`image: field(File, { text: "thumb" })`) and on an array; an array of objects
|
|
762
807
|
indexes by leaf key. A field inside a `Map` indexes nothing — there is no fixed path to extract it from.
|
|
763
|
-
- Search runs
|
|
808
|
+
- Search runs in every database mode — fts5 on SQLite/libSQL, a weighted `tsvector` (`pg_trgm` for `trigram`) on
|
|
809
|
+
Postgres. The same text matches the same documents; only the order may differ on Postgres.
|
|
764
810
|
|
|
765
811
|
### Image & File Fields
|
|
766
812
|
|
|
@@ -809,10 +855,11 @@ shape, so `cascade` never means "related" — it means one of exactly these:
|
|
|
809
855
|
- Dynamic segments use `[id]`; route groups use directories like `(user)`, `(public)`, `(tab)`, or `(detail)`.
|
|
810
856
|
- **A route file exports one thing**: `export default page()…render(…)` for a page, `layout()…render(…)` for a
|
|
811
857
|
`_layout.tsx`, `rootLayout()…render(…)` for the app's (or a basePath's) root `_layout.tsx`. Every route setting
|
|
812
|
-
is a stage of the chain — `.param()`, `.search()`, `.config()`, `.head()
|
|
813
|
-
`.
|
|
814
|
-
|
|
815
|
-
|
|
858
|
+
is a stage of the chain — `.param()`, `.search()`, `.config()`, `.head()`, `.loading()`; `.notFound()` /
|
|
859
|
+
`.error()` on a layout; `.fonts()` / `.theme()` / `.manifest()` / `.reconnect()` / `.wsConnect()` /
|
|
860
|
+
`.layoutStyle()` on the root layout; `.prompt()` on a page. A named export beside the chain, or `page()` in a
|
|
861
|
+
`_layout.tsx`, fails the build. `.head()` takes JSX — `<title>`, `<meta>`, `<link>` — or a function of the
|
|
862
|
+
route's args returning it; there is no metadata object, and analytics is the app's own script, not a stage.
|
|
816
863
|
- **A page names every `[x]` segment of its path with `.param("x", Type)`** and reads a query key only through
|
|
817
864
|
`.search("k", Type)`. Values arrive typed — `ID`/`String` → string, `Int`/`Float` → number, `Boolean`, `Date` →
|
|
818
865
|
Dayjs, an `enumOf` class → its union, `[T]` → array — a path value the type refuses answers not-found, and a
|
|
@@ -884,10 +931,13 @@ export default page()
|
|
|
884
931
|
| `common/` | pure, isomorphic, zero-dependency; may import only sibling `common/*` and `akanjs/base`. Cannot import `Err`, so keep throwing code out of it. | camelCase file, filename equals the single export |
|
|
885
932
|
| `webkit/` | touches `window` / `navigator` / Capacitor, or is a React hook | `use<Thing>.tsx` — `.tsx` even with no JSX |
|
|
886
933
|
| `srvkit/` | touches `node:*`, `Bun`, `process.env`, a secret, or a server SDK | camelCase file, PascalCase class |
|
|
887
|
-
| `ui/` | renders JSX and is not bound to one model | PascalCase component, camelCase sidecar (`swipeCard.util.ts`) |
|
|
934
|
+
| `ui/` | renders JSX, or defines a look (a recipe in `Recipe/`, a lib's `tokens.css`), and is not bound to one model | PascalCase component, camelCase sidecar (`swipeCard.util.ts`), `Recipe/<name>.ts` |
|
|
888
935
|
| `plugin/` | build- or CLI-time `AkanPlugin` | `<name>.plugin.ts`, registered in `akan.config.ts` |
|
|
889
936
|
|
|
890
937
|
- Hooks return a named object of async closures, never a tuple.
|
|
938
|
+
- `ui/` is the presentation layer — markup and the looks it is built from — so a recipe lives there although it is
|
|
939
|
+
neither a component nor a hook. Not `webkit/`: its hooks are `"use client"`, the opposite signal for a function
|
|
940
|
+
server components call. Not `common/`: it cannot import `akanjs/ui`, where `recipe` / `tv` come from.
|
|
891
941
|
- `libs/<lib>/ui/tokens.css` is the one CSS file a lib owns: plain `:root` custom properties for colors that must **not** follow the theme (a vendor brand color, a fixed surface). Every app whose pages reach that lib compiles it automatically, ahead of the app's own stylesheets, so nothing is imported by hand and no app can forget it. Reference them as `bg-[var(--kakao)]`; `@theme` extensions stay in the app stylesheet, because the color vocabulary is closed per stylesheet. Theme-following colors are the app's, not the lib's.
|
|
892
942
|
- A layer-root `index.ts` is generated, but a `ui/<Folder>/index.tsx` that builds a namespace is hand-written source. The distinguishing test is that a generated barrel contains nothing but `export * from "./X";` lines.
|
|
893
943
|
- `ui/<Folder>/index_.tsx` (trailing underscore) is the `"use client"` + `lazy()` boundary, with a server-safe `index.tsx` beside it. Collapsing the pair into one file breaks RSC.
|
|
@@ -900,12 +950,15 @@ when two shapes disagree.
|
|
|
900
950
|
- `children: any` — newer files use `children: ReactNode`.
|
|
901
951
|
- Hard-coded API keys or secrets in source; they belong in `option.ts` or env.
|
|
902
952
|
- Large blocks of commented-out code left in place.
|
|
903
|
-
- Cross-store writes through a `RootStore` cast — it collides with the Bun SSR-bundler segfault.
|
|
904
953
|
- Stale `// TODO: Implement …` comments above implemented methods.
|
|
905
954
|
- `{cond && <X/>}` in JSX, hard-coded Korean bypassing `l()`, and `window.alert(...)` for user feedback.
|
|
906
955
|
- Bare `/* eslint-disable */` blocks — use `// biome-ignore lint/<rule>: <why>`.
|
|
907
956
|
- Raw palette grays such as `text-gray-400` instead of the semantic tokens (`text-foreground/70`).
|
|
908
957
|
|
|
958
|
+
A store calling `(this as unknown as RootStore).<action>()` or `.set({ … })` over
|
|
959
|
+
`import type { RootStore } from "../st"` is **not** one of these — it is the sanctioned way to reach another store's
|
|
960
|
+
actions and state (see `<model>.store.ts` above).
|
|
961
|
+
|
|
909
962
|
## Secrets And Env Safety (`.env`, `infra/**`, `*secret*`, `*credential*`)
|
|
910
963
|
|
|
911
964
|
- Never print, summarize, commit, or expose real secret values, credentials, tokens, private keys, or `.env` contents.
|
|
@@ -924,7 +977,9 @@ when two shapes disagree.
|
|
|
924
977
|
selected one's log beside it. `Tab` walks every row and `1`-`9` jump to that app (the rail shows each
|
|
925
978
|
app's digit), `↑↓` scroll (shift for a page, `G` back to following),
|
|
926
979
|
`/` greps, `e` shows stderr only, `c` clears, `y` copies, `Y` copies the log path, `o` opens the browser,
|
|
927
|
-
`r` restarts that app, `q` quits.
|
|
980
|
+
`r` restarts that app, `q` quits. Under `--share`, `s` copies the public URL — the selected app's, or every
|
|
981
|
+
app's from the merged row — and the header carries it ahead of the local one, because the line `--share`
|
|
982
|
+
printed before the view took the terminal is gone by its first repaint.
|
|
928
983
|
**`--plain` prints prefixed interleaved lines instead**, and a pipe, a redirect or an unsized terminal
|
|
929
984
|
downgrades to that on its own.
|
|
930
985
|
- **A supervised session writes `local/apps/<app>/runtime/dev.log`, and that path is how a log gets handed over.**
|
|
@@ -9,6 +9,7 @@ Use this whenever generating constants or scalar constants. It defines the curre
|
|
|
9
9
|
- `field(Type).optional()` marks a nullable or optional business value when absence is valid.
|
|
10
10
|
- `field.hidden(Type).optional()` is for internal state that should not be treated as a normal public field.
|
|
11
11
|
- `field.secret(Type).optional()` is for sensitive values that should not be selected by default.
|
|
12
|
+
- Neither survives an endpoint response: both are stripped on the way out and hydration writes `null` over the key, while the generated type still declares the field — so a client holds a deliberate `null` behind a type that promises a value, and only `??` / `== null` catch it. A field a browser has to read is neither.
|
|
12
13
|
- `field([Type])` defines arrays; provide empty array defaults when the app expects list operations.
|
|
13
14
|
|
|
14
15
|
## Current Akan Patterns
|
|
@@ -189,11 +189,13 @@ export default page()
|
|
|
189
189
|
- OAuth resource metadata is published at `/.well-known/oauth-protected-resource` (and at that path plus the mount
|
|
190
190
|
path, the spelling most clients try first). `auth.authorizationServers` / `AKAN_MCP_AUTH_SERVERS`,
|
|
191
191
|
`auth.scopes` / `AKAN_MCP_SCOPES`, `auth.resource` / `AKAN_MCP_RESOURCE` and `auth.verify` configure it;
|
|
192
|
-
`insufficient_scope` is enforced only once scopes are declared.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
issuer
|
|
192
|
+
`insufficient_scope` is enforced only once scopes are declared. Declare them only for tokens an external issuer
|
|
193
|
+
mints: the built-in authorization server's tokens carry no scope, so beside it every one of them would be refused
|
|
194
|
+
with 403. Naming an issuer changes three things at once: a request with no credential is answered 401 from
|
|
195
|
+
`initialize` on (an MCP client starts its OAuth flow on nothing else), a token carrying no `aud` is refused (that
|
|
196
|
+
issuer mints for its other resources too), and the challenge carries no `error` code (RFC 6750 §3.1 reserves it
|
|
197
|
+
for a credential that was presented). A server naming no issuer keeps anonymous access and accepts a first-party
|
|
198
|
+
token, which is bound by app and environment instead.
|
|
197
199
|
- **The boot log names every published entry with no dictionary `.desc()`.** An agent picks a tool by its
|
|
198
200
|
description, so a missing one is a broken tool. What the framework generates has no text of its own and borrows
|
|
199
201
|
the model's: the generated list reads the `.of()` label, and the base CRUD tools append the model's `.desc()` to
|