akanjs 3.1.1-rc.3 → 3.1.1-rc.5

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/client/router.ts CHANGED
@@ -180,7 +180,7 @@ export const getPathInfo = (requestUrl: string, lang: string, prefix: string) =>
180
180
  return { path, pathname, hash, search, href };
181
181
  };
182
182
  class Router {
183
- isInitialized = false;
183
+ isInitialized: boolean = false;
184
184
  #prefix = "";
185
185
  #lang = parseAkanI18nEnv().defaultLocale;
186
186
  #routePaths = new Set<string>();
@@ -193,6 +193,8 @@ class Router {
193
193
  #backsAwaitingPopstate = 0;
194
194
  #redirects = 0;
195
195
  #navigations = 0;
196
+
197
+ #beforeInit: (() => void)[] = [];
196
198
  #instance: RouterInstance = {
197
199
  push: (href: string) => {
198
200
  const { href: fullHref } = this.#getPathInfo(href);
@@ -227,6 +229,7 @@ class Router {
227
229
  else if (options.side === "client") this.#initSsrClientRouter(options);
228
230
  this.isInitialized = true;
229
231
  Logger.verbose("Router initialized");
232
+ for (const call of this.#beforeInit.splice(0)) call();
230
233
  }
231
234
  #initSsrClientRouter(options: SsrClientRouterOption) {
232
235
  this.#historyWrittenByNavigation = true;
@@ -282,6 +285,27 @@ class Router {
282
285
  #checkInitialized() {
283
286
  if (!this.isInitialized) throw new Error("Router is not initialized");
284
287
  }
288
+ #deferredUntilInit(call: () => void) {
289
+ if (this.isInitialized || getEnv().side === "server") return false;
290
+ this.#track(
291
+ new Promise<void>((resolve, reject) => {
292
+ this.#beforeInit.push(() => {
293
+ const pending = this.#navigation;
294
+ try {
295
+ call();
296
+ } catch (error) {
297
+ Logger.error(`A navigation made before the router was initialized failed: ${String(error)}`);
298
+ reject(error);
299
+ return;
300
+ }
301
+
302
+ if (this.#navigation === pending) resolve();
303
+ else this.#navigation.then(resolve, reject);
304
+ });
305
+ }),
306
+ );
307
+ return true;
308
+ }
285
309
 
286
310
  #ensureHistoryState() {
287
311
  if (getEnv().side === "server") return;
@@ -389,7 +413,9 @@ class Router {
389
413
  const target = `${path}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`;
390
414
  return dedupedStack.map((candidate, index) => (index === dedupedStack.length - 1 ? target : candidate));
391
415
  }
416
+ /** Before init the link waits for it, and the answer is `true`: the manifest that could refuse it is not loaded yet. */
392
417
  enterDeepLink(href: string, options: DeepLinkOptions = {}) {
418
+ if (this.#deferredUntilInit(() => this.enterDeepLink(href, options))) return true;
393
419
  this.#checkInitialized();
394
420
  if (!options.resetStack) {
395
421
  this.push(normalizeDeepLinkHref(href), options);
@@ -405,6 +431,7 @@ class Router {
405
431
  return true;
406
432
  }
407
433
  push(href: string, routeOptions?: RouteOptions) {
434
+ if (this.#deferredUntilInit(() => this.push(href, routeOptions))) return undefined as never;
408
435
  this.#checkInitialized();
409
436
  if (this.#refusesOffsite("push", href, routeOptions)) return undefined as never;
410
437
  this.#navigations += 1;
@@ -414,6 +441,7 @@ class Router {
414
441
  return undefined as never;
415
442
  }
416
443
  replace(href: string, routeOptions?: RouteOptions) {
444
+ if (this.#deferredUntilInit(() => this.replace(href, routeOptions))) return undefined as never;
417
445
  this.#checkInitialized();
418
446
  if (this.#refusesOffsite("replace", href, routeOptions)) return undefined as never;
419
447
  this.#navigations += 1;
package/document/by.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type Cls, FIELD_META, type GetActionObject } from "akanjs/base";
2
2
  import { applyMixins } from "akanjs/common";
3
3
  import { ConstantRegistry, type DocumentConstantModelRef, type DocumentModel, type FieldObject } from "akanjs/constant";
4
+ import type { DocumentTouchOptions } from "./documentQuery";
4
5
 
5
6
  export type DatabaseCls<Schema = unknown> = Cls<Schema, { refName: string; [FIELD_META]: FieldObject }>;
6
7
  export type DatabaseInstanceOf<DatabaseRef extends DatabaseCls> = DatabaseRef extends new (
@@ -14,7 +15,7 @@ export interface DefaultDocMtds<TDocument> {
14
15
  refresh(): Promise<this>;
15
16
  isModified(field?: keyof TDocument & string): boolean;
16
17
  set(data: Partial<TDocument>): this;
17
- save(): Promise<this>;
18
+ save(options?: DocumentTouchOptions): Promise<this>;
18
19
  toJSON(): DocumentModel<TDocument>;
19
20
  toObject(): DocumentModel<TDocument>;
20
21
  }
@@ -25,7 +26,7 @@ type HydratedDocumentMethods<TDocument, Self> = Omit<
25
26
  refresh(): Promise<Self>;
26
27
  isModified(field?: keyof TDocument & string): boolean;
27
28
  set(data: Partial<TDocument>): Self;
28
- save(): Promise<Self>;
29
+ save(options?: DocumentTouchOptions): Promise<Self>;
29
30
  };
30
31
  type HydratedDocumentWithId<TDocument> = TDocument & { id: string } & HydratedDocumentMethods<TDocument, TDocument>;
31
32
  interface ChainableHydratedMethods<TDocument>
@@ -33,7 +34,7 @@ interface ChainableHydratedMethods<TDocument>
33
34
  refresh(): Promise<this>;
34
35
  isModified(field?: keyof TDocument & string): boolean;
35
36
  set(data: Partial<TDocument>): this;
36
- save(): Promise<this>;
37
+ save(options?: DocumentTouchOptions): Promise<this>;
37
38
  }
38
39
  type ChainableHydratedDocument<TDocument> = TDocument & {
39
40
  id: string;
package/document/into.ts CHANGED
@@ -25,8 +25,8 @@ interface DefaultMdlStats<
25
25
  _FilterQuery extends DocumentQuery<TSchema> = DocumentQuery<TSchema>,
26
26
  _Projection = DocumentProjection<TSchema>,
27
27
  > {
28
- pickOneAndWrite: (query: _FilterQuery, rawData: _Partial) => Promise<TDocument>;
29
- pickAndWrite: (docId: string, rawData: _Partial) => Promise<TDocument>;
28
+ pickOneAndWrite: (query: _FilterQuery, rawData: _Partial, options?: DocumentTouchOptions) => Promise<TDocument>;
29
+ pickAndWrite: (docId: string, rawData: _Partial, options?: DocumentTouchOptions) => Promise<TDocument>;
30
30
  pickOne: (query: _FilterQuery, projection?: _Projection) => Promise<TDocument>;
31
31
  pickById: (docId: string | undefined, projection?: _Projection) => Promise<TDocument>;
32
32
  sample: (query: _FilterQuery, size?: number) => Promise<TDocument[]>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "3.1.1-rc.3",
3
+ "version": "3.1.1-rc.5",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
package/server/akanApp.ts CHANGED
@@ -24,6 +24,7 @@ import type { OpsRoute } from "./ops/opsRoute";
24
24
  import { StatusAccess } from "./ops/statusAccess";
25
25
  import { ProcessMetricsCollector } from "./processMetricsCollector";
26
26
  import { HostAllowlist } from "./routing/hostAllowlist";
27
+ import { WsUpgrade } from "./routing/wsUpgrade";
27
28
  import { SelfExec } from "./selfExec";
28
29
  import { resolveStaticPath } from "./staticPath";
29
30
  import { getWebConfigFromEnv } from "./types";
@@ -670,6 +671,7 @@ export class AkanApp {
670
671
  } catch {
671
672
  return new Response("Forbidden", { status: 403 });
672
673
  }
674
+ if (!WsUpgrade.isRequested(req)) return WsUpgrade.required();
673
675
  const upstreamWs = new WebSocket(`ws://${upstream.host}:${upstream.port}${url.pathname}${url.search}`, {
674
676
  headers: makeAkanChildProxyHeaders(req, child.idx, server.requestIP(req)),
675
677
  } as unknown as string[]);
@@ -278,9 +278,10 @@ export class DatabaseResolver {
278
278
  timedQuery(() => store.pickOne(query, { select: projection })),
279
279
  pickById,
280
280
 
281
- pickAndWrite: async (id: string, rawData: any) => await (await pickById(id)).set(rawData).save(),
282
- pickOneAndWrite: async (query: QueryOf<any>, rawData: any) =>
283
- await (await timedQuery(() => store.pickOne(query))).set(rawData).save(),
281
+ pickAndWrite: async (id: string, rawData: any, options?: DocumentTouchOptions) =>
282
+ await (await pickById(id)).set(rawData).save(...touchArgs(options)),
283
+ pickOneAndWrite: async (query: QueryOf<any>, rawData: any, options?: DocumentTouchOptions) =>
284
+ await (await timedQuery(() => store.pickOne(query))).set(rawData).save(...touchArgs(options)),
284
285
  exists: async (query: QueryOf<any>, options?: DocumentScopeOptions) =>
285
286
  await timedQuery(() => store.exists(query, ...scopeArgs(options))),
286
287
  sample: (query: QueryOf<any>, size = 1) => timedQuery(() => store.find(query, { sample: size, limit: size })),
@@ -15,6 +15,7 @@ import { SignalResolver } from "../resolver";
15
15
  import type { HttpRoutes, SignalRouteOptions, WebsocketRoutes } from "../types";
16
16
  import { AppWsData } from "./appWsData";
17
17
  import type { HostAllowlist } from "./hostAllowlist";
18
+ import { WsUpgrade } from "./wsUpgrade";
18
19
 
19
20
  export interface HmrStateSource {
20
21
  readonly state: {
@@ -93,6 +94,7 @@ export class ApiRouter {
93
94
  }
94
95
  const upgraded = upgradeAppWs(req, AppWsData.fromRequest(req));
95
96
  if (upgraded) return;
97
+ if (!WsUpgrade.isRequested(req)) return WsUpgrade.required();
96
98
  return new Response("Failed to upgrade to WebSocket", { status: 500 });
97
99
  },
98
100
  ...Object.fromEntries(endpointEntries),
@@ -0,0 +1,10 @@
1
+
2
+ export class WsUpgrade {
3
+ static isRequested(req: Request) {
4
+ return req.headers.get("upgrade")?.toLowerCase() === "websocket";
5
+ }
6
+
7
+ static required() {
8
+ return new Response("Upgrade Required", { status: 426, headers: { Upgrade: "websocket", Connection: "Upgrade" } });
9
+ }
10
+ }
@@ -35,13 +35,16 @@ export class AgentService extends serve("agent" as const, ({ plug }) => ({
35
35
 
36
36
  /**
37
37
  * Prepended to the app's instructions here, not per adaptor, where forgetting it would be silent. The batching and
38
- * no-re-read sentences were measured to cut turns against a provider; do not shorten them.
38
+ * no-re-read sentences were measured to cut turns against a provider; do not shorten them. The navigation sentence
39
+ * carves the batching rule: a call batched behind `navigate` was written against the screen being left.
39
40
  */
40
41
  static readonly preamble = [
41
42
  "You are an in-page assistant. Use the published tools to read and drive the screen the user is looking at.",
42
43
  "Issue every tool call that does not need another call's result in the same turn, rather than one call per turn.",
43
44
  "Prefer the tool that does the whole job in one call, such as a form's fill tool over one call per field.",
44
45
  "A tool result already reports what it changed, so never call a read afterwards to confirm work you have just done — answer the user from the results you already have.",
46
+ "Tools belong to the screen on display: navigate and goBack replace the screen's own tools, and the new screen's are listed only on your next turn, so end the turn at a navigation and call them once they are listed.",
47
+ "Call a tool only by a name listed to you — never compose or guess one.",
45
48
  ].join(" ");
46
49
 
47
50
  static instructed(request: LlmTurnRequest): LlmTurnRequest {
@@ -1,5 +1,13 @@
1
1
  import { adapt } from "../adapt";
2
- import type { LlmAccepts, LlmAdaptor, LlmLimits, LlmOption, LlmTurnAnswer, LlmTurnRequest } from "./llm.adaptor";
2
+ import {
3
+ type LlmAccepts,
4
+ type LlmAdaptor,
5
+ type LlmLimits,
6
+ type LlmOption,
7
+ type LlmTurnAnswer,
8
+ type LlmTurnRequest,
9
+ llmProviderOf,
10
+ } from "./llm.adaptor";
3
11
  import { LlmOverflow } from "./llmOverflow";
4
12
  import { type OpenaiAnswer, OpenaiDialect } from "./openaiDialect";
5
13
 
@@ -24,10 +32,18 @@ export class OpenaiLlm
24
32
  return this.llmOption.contextWindow ? { window: this.llmOption.contextWindow } : {};
25
33
  }
26
34
 
27
- /** A named host is text-only until `setLlm({ accepts })` says otherwise: undecodable bytes fail the whole turn. */
28
35
  get accepts(): LlmAccepts | undefined {
29
- if (this.llmOption.accepts) return this.llmOption.accepts;
30
- return this.llmOption.host ? undefined : { image: true };
36
+ return OpenaiLlm.acceptsOf(this.llmOption);
37
+ }
38
+
39
+ /**
40
+ * A named host is text-only until `setLlm({ accepts })` says otherwise: undecodable bytes fail the whole turn.
41
+ * OpenAI's own endpoint named outright is still OpenAI rather than a gateway. Static, so an app's own
42
+ * chat-completions adaptor claims what this one does instead of forking the rule.
43
+ */
44
+ static acceptsOf({ accepts, host }: LlmOption): LlmAccepts | undefined {
45
+ if (accepts) return accepts;
46
+ return !host || llmProviderOf(host) === llmProviderOf(OpenaiLlm.defaultHost) ? { image: true } : undefined;
31
47
  }
32
48
 
33
49
  async chat(request: LlmTurnRequest, onDelta?: (delta: string) => void): Promise<LlmTurnAnswer | null> {
@@ -412,7 +412,7 @@ export class SqlDocumentStore {
412
412
 
413
413
  async update(id: string, patch: DocumentRecord, options: WriteHookOptions = {}) {
414
414
  const current = await this.pickByIdForWrite(id);
415
- return await this.writeUpdatedDocument(id, { ...current, ...patch, id, updatedAt: dayjs() }, current, options);
415
+ return await this.writeUpdatedDocument(id, { ...current, ...patch, id }, current, options);
416
416
  }
417
417
 
418
418
  async remove(id: string) {
@@ -957,9 +957,9 @@ export class SqlDocumentStore {
957
957
  id: string,
958
958
  data: DocumentRecord,
959
959
  originalData: DocumentRecord,
960
- { runSaveHooks = true, crudType = "update" }: WriteHookOptions = {},
960
+ { runSaveHooks = true, crudType = "update", touch = true }: WriteHookOptions = {},
961
961
  ) {
962
- const prepared = this.prepareDocument({ ...data, id, updatedAt: dayjs() });
962
+ const prepared = this.prepareDocument({ ...data, id, updatedAt: touch ? dayjs() : originalData.updatedAt });
963
963
  this.#assertImmutableUnchanged(prepared, originalData);
964
964
  const doc = this.hydrate(prepared, originalData);
965
965
 
@@ -968,7 +968,7 @@ export class SqlDocumentStore {
968
968
  await this.runHooks(crudType, crudType, doc, "pre", previous);
969
969
  const row = this.toRow(doc);
970
970
  const stored = (originalData as Record<symbol, StoredRow | undefined>)[SqlDocumentStore.#storedRow];
971
- await this.#writeChanges(id, stored ?? this.toRow(originalData), row);
971
+ await this.#writeChanges(id, stored ?? this.toRow(originalData), row, touch);
972
972
  await this.runHooks(crudType, crudType, doc, "post", previous);
973
973
  if (runSaveHooks) await this.runHooks("save", crudType, doc, "post", previous);
974
974
  return this.#withStoredRow(doc, row);
@@ -978,11 +978,12 @@ export class SqlDocumentStore {
978
978
  * Merges only the fields that changed into the stored `_doc`, so a concurrent write to another field survives. A key
979
979
  * the stored row lacks is written out, which is what `q.missing` tells rows from before a field existed apart by.
980
980
  */
981
- async #writeChanges(id: string, read: StoredRow, written: StoredRow) {
981
+ async #writeChanges(id: string, read: StoredRow, written: StoredRow, touch: boolean) {
982
982
  const before = SqlDocumentStore.#encodedFields(read);
983
983
  const after = SqlDocumentStore.#encodedFields(written);
984
- const assignments = [`"updatedAt" = ?`];
985
- const params: unknown[] = [written.updatedAt];
984
+
985
+ const assignments = [touch ? `"updatedAt" = ?` : `"updatedAt" = "updatedAt"`];
986
+ const params: unknown[] = touch ? [written.updatedAt] : [];
986
987
  for (const column of ["createdAt", "removedAt"] as const) {
987
988
  if (before.get(column) === after.get(column)) continue;
988
989
  assignments.push(`${quoteIdent(column)} = ?`);
@@ -1201,10 +1202,10 @@ export class SqlDocumentStore {
1201
1202
  },
1202
1203
  },
1203
1204
  save: {
1204
- async value(this: DocumentRecord) {
1205
+ async value(this: DocumentRecord, { touch }: DocumentTouchOptions = {}) {
1205
1206
  if (!this.id) return store.create(this);
1206
1207
  store.#assertLiveRow(this);
1207
- return store.update(this.id as string, store.#changesOf(this));
1208
+ return store.update(this.id as string, store.#changesOf(this), touch === false ? { touch } : {});
1208
1209
  },
1209
1210
  },
1210
1211
  refresh: {
@@ -170,7 +170,7 @@ export type FindOneOptions = DocumentScopeOptions & {
170
170
  select?: ProjectionOption;
171
171
  };
172
172
  export type FindIdOptions = DocumentScopeOptions & { sort?: SortOption; skip?: number | null; sample?: boolean };
173
- export type WriteHookOptions = { runSaveHooks?: boolean; crudType?: "update" | "remove" };
173
+ export type WriteHookOptions = DocumentTouchOptions & { runSaveHooks?: boolean; crudType?: "update" | "remove" };
174
174
  export type QueryOperatorName = Extract<DocumentQueryNode, { kind: "op" }>["op"];
175
175
  export interface SqliteDocumentRow {
176
176
  id: string;
@@ -1,4 +1,3 @@
1
- import { router } from "akanjs/client";
2
1
  import type { ToolActivity } from "../../vendor/use-agentic";
3
2
  import { AgentCursor } from "./AgentCursor";
4
3
  import { ScreenFlash } from "./ScreenFlash";
@@ -108,28 +107,17 @@ export class AgentVisual {
108
107
  }
109
108
 
110
109
  static #linkTo(path: unknown): HTMLElement[] {
111
- const wanted = AgentVisual.#routeOf(typeof path === "string" ? path : "");
110
+ const wanted = ScreenTarget.routeOf(typeof path === "string" ? path : "");
112
111
  if (!wanted || !document.body) return [];
113
112
  const links = [...document.body.querySelectorAll<HTMLAnchorElement>("a[href]")].filter(
114
113
  (link) =>
115
- AgentVisual.#routeOf(link.getAttribute("href") ?? "") === wanted &&
114
+ ScreenTarget.routeOf(link.getAttribute("href") ?? "") === wanted &&
116
115
  ScreenTarget.visible(link) &&
117
116
  ScreenFlash.inView(link),
118
117
  );
119
118
  return AgentVisual.#only(links);
120
119
  }
121
120
 
122
- static #routeOf(href: string) {
123
- if (!href || href.startsWith("#")) return "";
124
- try {
125
- const url = new URL(href, window.location.href);
126
- if (url.origin !== window.location.origin) return "";
127
- return router.routeOf(url.pathname);
128
- } catch {
129
- return "";
130
- }
131
- }
132
-
133
121
  static #navigating(name: string) {
134
122
  return name.slice(name.lastIndexOf(".") + 1) === "navigate";
135
123
  }
@@ -1,3 +1,4 @@
1
+ import { router } from "akanjs/client";
1
2
  import { ScreenReader } from "./ScreenReader";
2
3
 
3
4
  const containerAttrs = ["data-agent-zone", "data-agent-scope", "data-agent-skip"] as const;
@@ -75,6 +76,30 @@ export class ScreenTarget {
75
76
  );
76
77
  }
77
78
 
79
+ static linkedRoutes(root?: HTMLElement | null): string[] {
80
+ const scope = root ?? ScreenTarget.#body();
81
+ if (!scope) return [];
82
+ const routes = new Set<string>();
83
+ for (const link of scope.querySelectorAll<HTMLAnchorElement>("a[href]")) {
84
+ const route = ScreenTarget.visible(link) ? ScreenTarget.routeOf(link.getAttribute("href") ?? "") : "";
85
+ if (route) routes.add(route);
86
+ if (routes.size >= nameCap) break;
87
+ }
88
+ return [...routes];
89
+ }
90
+
91
+ /** Through `router.routeOf`: an href carries locale and base-path segments a tool argument lacks. */
92
+ static routeOf(href: string) {
93
+ if (!href || href.startsWith("#")) return "";
94
+ try {
95
+ const url = new URL(href, window.location.href);
96
+ if (url.origin !== window.location.origin) return "";
97
+ return router.routeOf(url.pathname);
98
+ } catch {
99
+ return "";
100
+ }
101
+ }
102
+
78
103
  static #names(attrs: readonly string[], root?: HTMLElement | null): string[] {
79
104
  const scope = root ?? ScreenTarget.#body();
80
105
  if (!scope) return [];
@@ -1,4 +1,4 @@
1
- import { router } from "akanjs/client";
1
+ import { csrLocation, router } from "akanjs/client";
2
2
  import { AgenticSurface, type SurfaceSource, type ToolEntry, ToolReturn } from "../../vendor/use-agentic";
3
3
  import { AgentBridge } from "./AgentBridge";
4
4
  import { ScreenFlash } from "./ScreenFlash";
@@ -58,17 +58,38 @@ export class StoreSurfaceSource implements SurfaceSource {
58
58
  router.push(path);
59
59
  await router.navigation();
60
60
  } catch {
61
- throw new Error(
62
- `There is no route at ${path}, so the page did not move. Call readScreen — it prints each link on this screen with its own path — and navigate to one of those.`,
63
- );
61
+ throw new Error(`There is no route at ${path}, so the page did not move. ${StoreSurfaceSource.#linked()}`);
64
62
  }
65
63
 
66
64
  await ScreenSettle.wait({ appearMs: 800, timeoutMs: 5000 });
67
- return `Now on ${path}. Call readScreen to see it; this screen's own tools and state are listed from the next turn.`;
65
+ const landed = StoreSurfaceSource.#redirected(path);
66
+ return `${landed ? `Asked for ${path}, but the app sent the page to ${landed} instead.` : `Now on ${path}.`} ${StoreSurfaceSource.#arrived}`;
68
67
  },
69
68
  };
70
69
  }
71
70
 
71
+ static readonly #arrived =
72
+ "This screen's own tools and state replace the previous screen's and are listed on your next turn — call them then, by the names listed. Call readScreen to see the page.";
73
+
74
+ static #linked() {
75
+ const routes = ScreenTarget.linkedRoutes();
76
+ return routes.length
77
+ ? `Paths this screen links to: ${routes.join(", ")}. Navigate to one of those rather than composing a path.`
78
+ : "Call readScreen — it prints each link on this screen with its own path — and navigate to one of those.";
79
+ }
80
+
81
+ /** A redirect or the app's not-found page moves the page somewhere other than the path it was sent to. */
82
+ static #redirected(path: string): string | null {
83
+ const trimmed = (route: string) => (route.length > 1 ? route.replace(/\/+$/, "") : route);
84
+ try {
85
+ const landed = router.routeOf(csrLocation.pathname);
86
+ const wanted = router.routeOf(new URL(path, "http://route.invalid").pathname);
87
+ return trimmed(landed) === trimmed(wanted) ? null : landed;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
72
93
  static #goBack(): ToolEntry {
73
94
  return {
74
95
  name: "goBack",
@@ -79,7 +100,7 @@ export class StoreSurfaceSource implements SurfaceSource {
79
100
  run: async () => {
80
101
  router.back();
81
102
  await ScreenSettle.wait({ appearMs: 800, timeoutMs: 5000 });
82
- return `Back on ${router.getPath()}. Call readScreen to see it; this screen's own tools and state are listed from the next turn.`;
103
+ return `Back on ${router.getPath()}. ${StoreSurfaceSource.#arrived}`;
83
104
  },
84
105
  };
85
106
  }
@@ -87,6 +87,7 @@ declare class Router {
87
87
  page: number;
88
88
  }): void;
89
89
  resolveDeepLinkStack(href: string): string[];
90
+ /** Before init the link waits for it, and the answer is `true`: the manifest that could refuse it is not loaded yet. */
90
91
  enterDeepLink(href: string, options?: DeepLinkOptions): boolean;
91
92
  push(href: string, routeOptions?: RouteOptions): never;
92
93
  replace(href: string, routeOptions?: RouteOptions): never;
@@ -1,5 +1,6 @@
1
1
  import { type Cls, FIELD_META, type GetActionObject } from "akanjs/base";
2
2
  import { type DocumentConstantModelRef, type DocumentModel, type FieldObject } from "akanjs/constant";
3
+ import type { DocumentTouchOptions } from "./documentQuery.d.ts";
3
4
  export type DatabaseCls<Schema = unknown> = Cls<Schema, {
4
5
  refName: string;
5
6
  [FIELD_META]: FieldObject;
@@ -10,7 +11,7 @@ export interface DefaultDocMtds<TDocument> {
10
11
  refresh(): Promise<this>;
11
12
  isModified(field?: keyof TDocument & string): boolean;
12
13
  set(data: Partial<TDocument>): this;
13
- save(): Promise<this>;
14
+ save(options?: DocumentTouchOptions): Promise<this>;
14
15
  toJSON(): DocumentModel<TDocument>;
15
16
  toObject(): DocumentModel<TDocument>;
16
17
  }
@@ -18,7 +19,7 @@ type HydratedDocumentMethods<TDocument, Self> = Omit<DefaultDocMtds<TDocument>,
18
19
  refresh(): Promise<Self>;
19
20
  isModified(field?: keyof TDocument & string): boolean;
20
21
  set(data: Partial<TDocument>): Self;
21
- save(): Promise<Self>;
22
+ save(options?: DocumentTouchOptions): Promise<Self>;
22
23
  };
23
24
  type HydratedDocumentWithId<TDocument> = TDocument & {
24
25
  id: string;
@@ -27,7 +28,7 @@ interface ChainableHydratedMethods<TDocument> extends Omit<DefaultDocMtds<TDocum
27
28
  refresh(): Promise<this>;
28
29
  isModified(field?: keyof TDocument & string): boolean;
29
30
  set(data: Partial<TDocument>): this;
30
- save(): Promise<this>;
31
+ save(options?: DocumentTouchOptions): Promise<this>;
31
32
  }
32
33
  type ChainableHydratedDocument<TDocument> = TDocument & {
33
34
  id: string;
@@ -10,8 +10,8 @@ export type CRUDEventType = "create" | "update" | "remove";
10
10
  export type SaveEventType = "save" | CRUDEventType;
11
11
  export type SaveEventListener<Doc> = (doc: Doc, type: CRUDEventType, previous?: Doc) => PromiseOrObject<void>;
12
12
  interface DefaultMdlStats<TDocument, TSchema, _Partial extends Partial<TSchema> = Partial<TSchema>, _FilterQuery extends DocumentQuery<TSchema> = DocumentQuery<TSchema>, _Projection = DocumentProjection<TSchema>> {
13
- pickOneAndWrite: (query: _FilterQuery, rawData: _Partial) => Promise<TDocument>;
14
- pickAndWrite: (docId: string, rawData: _Partial) => Promise<TDocument>;
13
+ pickOneAndWrite: (query: _FilterQuery, rawData: _Partial, options?: DocumentTouchOptions) => Promise<TDocument>;
14
+ pickAndWrite: (docId: string, rawData: _Partial, options?: DocumentTouchOptions) => Promise<TDocument>;
15
15
  pickOne: (query: _FilterQuery, projection?: _Projection) => Promise<TDocument>;
16
16
  pickById: (docId: string | undefined, projection?: _Projection) => Promise<TDocument>;
17
17
  sample: (query: _FilterQuery, size?: number) => Promise<TDocument[]>;
@@ -0,0 +1,4 @@
1
+ export declare class WsUpgrade {
2
+ static isRequested(req: Request): boolean;
3
+ static required(): Response;
4
+ }
@@ -13,7 +13,8 @@ export declare class AgentService extends AgentService_base {
13
13
  }>;
14
14
  /**
15
15
  * Prepended to the app's instructions here, not per adaptor, where forgetting it would be silent. The batching and
16
- * no-re-read sentences were measured to cut turns against a provider; do not shorten them.
16
+ * no-re-read sentences were measured to cut turns against a provider; do not shorten them. The navigation sentence
17
+ * carves the batching rule: a call batched behind `navigate` was written against the screen being left.
17
18
  */
18
19
  static readonly preamble: string;
19
20
  static instructed(request: LlmTurnRequest): LlmTurnRequest;
@@ -1,4 +1,4 @@
1
- import type { LlmAccepts, LlmAdaptor, LlmLimits, LlmOption, LlmTurnAnswer, LlmTurnRequest } from "./llm.adaptor";
1
+ import { type LlmAccepts, type LlmAdaptor, type LlmLimits, type LlmOption, type LlmTurnAnswer, type LlmTurnRequest } from "./llm.adaptor";
2
2
  declare const OpenaiLlm_base: import("..").AdaptorCls<{}, {
3
3
  llmOption: import("..").InjectInfo<"use", LlmOption, never, never>;
4
4
  }>;
@@ -11,8 +11,13 @@ export declare class OpenaiLlm extends OpenaiLlm_base implements LlmAdaptor {
11
11
  static readonly defaultHost = "https://api.openai.com/v1";
12
12
  /** No answer ceiling: the dialect sends none, so the provider's own default is the one that applies. */
13
13
  get limits(): LlmLimits;
14
- /** A named host is text-only until `setLlm({ accepts })` says otherwise: undecodable bytes fail the whole turn. */
15
14
  get accepts(): LlmAccepts | undefined;
15
+ /**
16
+ * A named host is text-only until `setLlm({ accepts })` says otherwise: undecodable bytes fail the whole turn.
17
+ * OpenAI's own endpoint named outright is still OpenAI rather than a gateway. Static, so an app's own
18
+ * chat-completions adaptor claims what this one does instead of forking the rule.
19
+ */
20
+ static acceptsOf({ accepts, host }: LlmOption): LlmAccepts | undefined;
16
21
  chat(request: LlmTurnRequest, onDelta?: (delta: string) => void): Promise<LlmTurnAnswer | null>;
17
22
  static refusal(host: string, response: Response): Promise<Error>;
18
23
  }
@@ -170,7 +170,7 @@ export type FindIdOptions = DocumentScopeOptions & {
170
170
  skip?: number | null;
171
171
  sample?: boolean;
172
172
  };
173
- export type WriteHookOptions = {
173
+ export type WriteHookOptions = DocumentTouchOptions & {
174
174
  runSaveHooks?: boolean;
175
175
  crudType?: "update" | "remove";
176
176
  };
@@ -10,5 +10,8 @@ export declare class ScreenTarget {
10
10
  static containerNames(root?: HTMLElement | null): string[];
11
11
  static anchorNames(root?: HTMLElement | null): string[];
12
12
  static targetNames(root?: HTMLElement | null): string[];
13
+ static linkedRoutes(root?: HTMLElement | null): string[];
14
+ /** Through `router.routeOf`: an href carries locale and base-path segments a tool argument lacks. */
15
+ static routeOf(href: string): string;
13
16
  static visible(el: HTMLElement): boolean;
14
17
  }
@@ -192,6 +192,8 @@ export class AgentSession {
192
192
  #limits: TurnLimits = {};
193
193
  /** What the tools, the screen context and the instructions cost the last request, for a host's gauge. */
194
194
  #overhead = 0;
195
+ /** The tool names the model was handed on the turn now running — what its calls were written against. */
196
+ #listed: readonly string[] = [];
195
197
 
196
198
  constructor(surface: SurfaceView, runner: AgentRunner, options: AgentSessionOptions = {}) {
197
199
  this.#surface = surface;
@@ -211,7 +213,7 @@ export class AgentSession {
211
213
  fallback: async (call, signal) =>
212
214
  call.name === AgentSession.askUserTool.name
213
215
  ? await this.#ask(call, signal)
214
- : { id: call.id, name: call.name, error: `Unknown tool: ${call.name}` },
216
+ : { id: call.id, name: call.name, error: this.#unknown(call.name) },
215
217
  });
216
218
  this.#history = options.history;
217
219
  this.#onCompact = options.onCompact;
@@ -746,6 +748,7 @@ export class AgentSession {
746
748
  ): Promise<{ toolCalls: ToolCallRequest[]; stop: TurnStop; overflow?: { message: string; limit?: number } }> {
747
749
  const frame = this.#frame();
748
750
  this.#overhead = AgentSession.#tokensOfFrame(frame);
751
+ this.#listed = frame.tools.map((tool) => tool.name);
749
752
  const request: RunnerRequest = { messages: Transcript.wire(this.#messages), ...frame, signal };
750
753
  this.#append({ role: "assistant" });
751
754
  let text = "";
@@ -780,6 +783,20 @@ export class AgentSession {
780
783
  return Array.isArray(settled.result) ? settled.result.join(", ") : settled.result;
781
784
  }
782
785
 
786
+ /**
787
+ * Answered with the names that exist: a bare refusal sends a model guessing the next name. A screen that moved
788
+ * since the turn's list was written is said so, since that call was written against the screen it left.
789
+ */
790
+ #unknown(name: string): string {
791
+ const shown = this.#surface.snapshot().tools.map((tool) => tool.name);
792
+ const now = shown.includes(AgentSession.askUserTool.name) ? shown : [...shown, AgentSession.askUserTool.name];
793
+ const listed = new Set(this.#listed);
794
+ const moved = now.length !== listed.size || now.some((one) => !listed.has(one));
795
+ return moved
796
+ ? `Unknown tool: ${name}. The screen changed during this turn, so this call was written against tools that are no longer on it. The tools on screen now are listed with their arguments on your next turn — call one then by its exact name: ${now.join(", ")}.`
797
+ : `Unknown tool: ${name}. Call only a tool listed to you, by its exact name — never a guessed one. Listed now: ${now.join(", ")}.`;
798
+ }
799
+
783
800
  /** No surface call and so no diff to report: the only thing this changes is what the model knows. */
784
801
  async #ask(call: ToolCallRequest, signal: AbortSignal): Promise<ToolCallResult> {
785
802
  const base = { id: call.id, name: call.name };