agentus 0.0.0-stage → 0.1.0
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/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +459 -2
- package/bin/agentus.mjs +147 -0
- package/docs/android-notify-contract.md +658 -0
- package/docs/refs/aionui-acp-rules.md +46 -0
- package/docs/refs/hermes-studio-ui-ideas.md +191 -0
- package/docs/transcript-folding.md +139 -0
- package/package.json +75 -5
- package/packages/server/mcp/plan-server.mjs +217 -0
- package/packages/server/mock/agent.mjs +658 -0
- package/packages/server/package.json +22 -0
- package/packages/server/src/acp/backends.ts +196 -0
- package/packages/server/src/acp/errors.ts +46 -0
- package/packages/server/src/acp/registry.ts +579 -0
- package/packages/server/src/acp/session-manager.ts +1868 -0
- package/packages/server/src/auth.ts +688 -0
- package/packages/server/src/dashscope.ts +235 -0
- package/packages/server/src/fs.ts +165 -0
- package/packages/server/src/hotwords.ts +132 -0
- package/packages/server/src/index.ts +1404 -0
- package/packages/server/src/notify/center.ts +994 -0
- package/packages/server/src/notify/types.ts +222 -0
- package/packages/server/src/plan/plan.ts +126 -0
- package/packages/server/src/settings.ts +469 -0
- package/packages/server/src/store/export.ts +209 -0
- package/packages/server/src/store/store.ts +1238 -0
- package/packages/server/src/term.ts +160 -0
- package/packages/server/src/title.ts +109 -0
- package/packages/server/src/voice.ts +251 -0
- package/packages/shared/package.json +14 -0
- package/packages/shared/src/index.ts +271 -0
- package/packages/web/dist/assets/index-B52l55_4.css +1 -0
- package/packages/web/dist/assets/index-BRgC7oYt.js +246 -0
- package/packages/web/dist/coding-agents/hermes.png +0 -0
- package/packages/web/dist/coding-agents/qoder.svg +5 -0
- package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-icon.svg +301 -0
- package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-mark.svg +301 -0
- package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/packages/web/dist/icons/favicon.ico +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/icon-maskable-512.png +0 -0
- package/packages/web/dist/index.html +30 -0
- package/packages/web/dist/manifest.webmanifest +17 -0
- package/packages/web/dist/sw.js +61 -0
- package/scripts/make-cert.sh +101 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Notify contract v0.1 — the wire types shared by the Agentus server and the
|
|
2
|
+
// Android companion app. See docs/android-notify-contract.md at the workspace root
|
|
3
|
+
// (tasks/20261001-agentus/android-notify-contract.md) for the rationale.
|
|
4
|
+
//
|
|
5
|
+
// Rule of the contract: this file only ever GROWS. Adding a field is compatible;
|
|
6
|
+
// changing the meaning of an existing one is not. A device that does not know a
|
|
7
|
+
// field ignores it, and the server decides what to send from `capabilities`,
|
|
8
|
+
// never from a version number.
|
|
9
|
+
|
|
10
|
+
export type NotifyPlatform = "android" | "ios" | "web";
|
|
11
|
+
|
|
12
|
+
/** Device -> server handshake, sent once at pairing. */
|
|
13
|
+
export interface DeviceCapabilities {
|
|
14
|
+
platform: NotifyPlatform;
|
|
15
|
+
/** Android API level (36+ = Live Updates are possible). */
|
|
16
|
+
sdkInt: number;
|
|
17
|
+
appVersion: string;
|
|
18
|
+
/** Highest contract schema the app understands. */
|
|
19
|
+
schema: number;
|
|
20
|
+
/** Feature switches: live_update | progress | actions | remote_input | icon_url | channels | deeplink */
|
|
21
|
+
capabilities: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface NotifyDevice extends DeviceCapabilities {
|
|
25
|
+
deviceId: string;
|
|
26
|
+
name: string;
|
|
27
|
+
/** Device bearer token (issued at pairing; stored in the app's Keystore). */
|
|
28
|
+
token: string;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
lastSeenAt: number | null;
|
|
31
|
+
/** Highest activity seq this device has acknowledged (replay anchor). */
|
|
32
|
+
cursor: number;
|
|
33
|
+
/**
|
|
34
|
+
* The device reporting on itself — last report wins. Contains the app version, the ROM's
|
|
35
|
+
* focus-protocol generation, the exact island payload it would attach and whether it holds the
|
|
36
|
+
* microphone. Written by POST /api/notify/diag: the phone writes the answer down where the server
|
|
37
|
+
* can read it, which is the only way to diagnose a device that cannot be plugged into anything.
|
|
38
|
+
*/
|
|
39
|
+
diag?: { at: number; text: string } | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Server -> device, one notification. `ref` never leaves the server. */
|
|
43
|
+
export interface NotifyRef {
|
|
44
|
+
/** Handler family: "permission" | "open" | "stop" | ... — the server's switch. */
|
|
45
|
+
type: string;
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type NotifyOp = "upsert" | "dismiss" | "clear_all" | "probe";
|
|
50
|
+
|
|
51
|
+
export type ActionStyle = "primary" | "danger" | "default";
|
|
52
|
+
|
|
53
|
+
export interface ActivityAction {
|
|
54
|
+
id: string;
|
|
55
|
+
label: string;
|
|
56
|
+
style?: ActionStyle;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ActivityProgressSegment {
|
|
60
|
+
length: number;
|
|
61
|
+
color?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ActivityProgressPoint {
|
|
65
|
+
position: number;
|
|
66
|
+
color?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Progress. `segments`/`points` feed Android 16 Notification.ProgressStyle;
|
|
70
|
+
* a device without `progress` support falls back to a plain notification. */
|
|
71
|
+
export interface ActivityProgress {
|
|
72
|
+
value?: number | null;
|
|
73
|
+
indeterminate?: boolean;
|
|
74
|
+
styledByProgress?: boolean;
|
|
75
|
+
segments?: ActivityProgressSegment[];
|
|
76
|
+
points?: ActivityProgressPoint[];
|
|
77
|
+
/** Android drawable name already bundled in the app ("icon:name" convention). */
|
|
78
|
+
trackerIcon?: string;
|
|
79
|
+
startIcon?: string;
|
|
80
|
+
endIcon?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The server defines channels, the app creates them on demand — that way
|
|
84
|
+
* importance/sound/vibration are server-controlled and never need a new APK. */
|
|
85
|
+
export interface ChannelSpec {
|
|
86
|
+
id: string;
|
|
87
|
+
name: string;
|
|
88
|
+
importance?: "min" | "low" | "default" | "high";
|
|
89
|
+
sound?: boolean;
|
|
90
|
+
vibration?: boolean;
|
|
91
|
+
/** The server took the sound away for a reason of its own (today: a voice call is live, so a tone
|
|
92
|
+
* would be recorded by the agent's own microphone). Behavioural, not cosmetic: an Android channel's
|
|
93
|
+
* sound/importance cannot be changed after creation, so a muted frame must be routed to a quiet
|
|
94
|
+
* channel rather than an updated one. */
|
|
95
|
+
muted?: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface ActivityInput {
|
|
99
|
+
enabled: boolean;
|
|
100
|
+
placeholder?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ActivityObject {
|
|
104
|
+
schema: 1;
|
|
105
|
+
op: NotifyOp;
|
|
106
|
+
/** Stable id: one id = one notification slot. */
|
|
107
|
+
activityId: string;
|
|
108
|
+
/** Monotonic per activity; a device drops anything older than what it holds. */
|
|
109
|
+
revision: number;
|
|
110
|
+
/** Semantic label ("agent_running" | "approval" | "agent_done" | "error"). */
|
|
111
|
+
kind?: string;
|
|
112
|
+
priority?: "high" | "default" | "low";
|
|
113
|
+
/** Ongoing is a precondition for a promoted Live Update. */
|
|
114
|
+
ongoing?: boolean;
|
|
115
|
+
/** The server's wish; the app promotes only if the device/user allows it. */
|
|
116
|
+
promotable?: boolean;
|
|
117
|
+
/** Which promotion path this frame must take, for testing the two island APIs apart:
|
|
118
|
+
* absent/"" = the app's order (vendor island first, AOSP Live Update after),
|
|
119
|
+
* "xiaomi" = the vendor island only, "aosp" = Android 16's own Live Update only.
|
|
120
|
+
* Behavioural, not cosmetic: it is how "does HyperOS 3 render Android's own chip at all"
|
|
121
|
+
* becomes a button instead of another APK. */
|
|
122
|
+
path?: string;
|
|
123
|
+
title: string;
|
|
124
|
+
subtitle?: string | null;
|
|
125
|
+
body?: string | null;
|
|
126
|
+
progress?: ActivityProgress | null;
|
|
127
|
+
/** Large icon, fetched by the app (server-controlled visuals). */
|
|
128
|
+
iconUrl?: string | null;
|
|
129
|
+
/** Small (status bar) icon — must be a drawable already inside the APK. */
|
|
130
|
+
smallIcon?: string | null;
|
|
131
|
+
channel?: ChannelSpec | null;
|
|
132
|
+
visibility?: "public" | "private" | "secret";
|
|
133
|
+
actions?: ActivityAction[];
|
|
134
|
+
input?: ActivityInput | null;
|
|
135
|
+
/** Where tapping the notification goes (web UI path).
|
|
136
|
+
* @deprecated superseded by `open` — still emitted so an app built before the `open` contract keeps
|
|
137
|
+
* a working tap. Remove once no such app is in the field. */
|
|
138
|
+
deeplink?: string | null;
|
|
139
|
+
/** Where a tap goes, in the abstract: a URL plus how strongly the sender wants it opened in an app.
|
|
140
|
+
*
|
|
141
|
+
* The split is deliberate. The SENDER knows the destination; only the RECEIVER knows what is
|
|
142
|
+
* installed and which origins it trusts, so `prefer` is a hint and the device keeps the policy
|
|
143
|
+
* (see docs/android-notify-contract.md §5.2). This is also what lets a third party push a
|
|
144
|
+
* notification through the same contract: a sender that knows nothing about Agentus sends
|
|
145
|
+
* `{ url, prefer: "web" }` and gets a plain link, while our own server asks for its cockpit. */
|
|
146
|
+
open?: OpenTarget | null;
|
|
147
|
+
staleAt?: number;
|
|
148
|
+
expiresAt?: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** How to open an activity when it is tapped. */
|
|
152
|
+
export interface OpenTarget {
|
|
153
|
+
/** Absolute, or relative to the server the activity arrived from (a self-hosted server may not know
|
|
154
|
+
* its own public address — it sits behind a tunnel — so the receiver resolves the rest). */
|
|
155
|
+
url: string;
|
|
156
|
+
/** `app` = the sender asserts an app on this device handles the URL (our cockpit); `web` = always
|
|
157
|
+
* hand it to the platform's web handler; `auto` (default) = the receiver's own rule, which is
|
|
158
|
+
* "in-app if the origin is one of my servers, otherwise the browser". A receiver must never
|
|
159
|
+
* navigate its own authenticated UI to a foreign origin on a payload's say-so. */
|
|
160
|
+
prefer?: "app" | "web" | "auto";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Device -> server: the operator pressed something on a notification. */
|
|
164
|
+
export interface ActionCallback {
|
|
165
|
+
deviceId: string;
|
|
166
|
+
activityId: string;
|
|
167
|
+
revision: number;
|
|
168
|
+
actionId: string;
|
|
169
|
+
input?: string | null;
|
|
170
|
+
ts: number;
|
|
171
|
+
nonce?: string;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Server -> device WS frames. */
|
|
175
|
+
export type NotifyFrame =
|
|
176
|
+
| { t: "hello"; deviceId: string; serverTime: number; cursor: number; live: boolean }
|
|
177
|
+
| { t: "activity"; seq: number; activity: ActivityObject }
|
|
178
|
+
| { t: "bye"; reason: string };
|
|
179
|
+
|
|
180
|
+
/** Server-side record: what was published + how to interpret its actions. */
|
|
181
|
+
export interface ActivityRecord {
|
|
182
|
+
seq: number;
|
|
183
|
+
activity: ActivityObject;
|
|
184
|
+
/** Per-action handler refs, keyed by action id. Never sent to a device. */
|
|
185
|
+
actionRefs: Record<string, NotifyRef>;
|
|
186
|
+
publishedAt: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* What the phone is allowed to be told. These are the operator's switches, not the app's:
|
|
191
|
+
* the app is a generic renderer, so "do I want a notification for this" is a server-side
|
|
192
|
+
* decision that must be changeable without shipping a new APK.
|
|
193
|
+
*/
|
|
194
|
+
export interface NotifyRules {
|
|
195
|
+
/** A turn started (the ongoing "running" card). */
|
|
196
|
+
turnStart: boolean;
|
|
197
|
+
/** The agent is waiting for an approval — the one you answer from the lock screen. */
|
|
198
|
+
approval: boolean;
|
|
199
|
+
/** A turn ended (done / failed). */
|
|
200
|
+
completion: boolean;
|
|
201
|
+
/** Do not push while the operator is looking at that very session in the cockpit. */
|
|
202
|
+
quietWhenWatching: boolean;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export const DEFAULT_NOTIFY_RULES: NotifyRules = {
|
|
206
|
+
turnStart: true,
|
|
207
|
+
approval: true,
|
|
208
|
+
completion: true,
|
|
209
|
+
quietWhenWatching: true,
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** Which session the cockpit is showing right now (in-memory only — presence is ephemeral). */
|
|
213
|
+
export interface Presence {
|
|
214
|
+
sessionId: string | null;
|
|
215
|
+
visible: boolean;
|
|
216
|
+
/** The operator is on a VOICE CALL with `sessionId` right now (page-reported, same TTL). A call is
|
|
217
|
+
* strictly more than "watching": the call is already reading the replies aloud, and its capture is a
|
|
218
|
+
* plain AudioRecord with no echo cancellation — so any notification tone lands in the agent's own
|
|
219
|
+
* microphone. While a call is live nothing may make a sound (see the center's `onCall`). */
|
|
220
|
+
call?: boolean;
|
|
221
|
+
at: number;
|
|
222
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan semantics, in one place.
|
|
3
|
+
*
|
|
4
|
+
* ACP plan frames are whole-list snapshots (`plan` in v1 = replace semantics), and an agent's own
|
|
5
|
+
* todo state dies with its PROCESS — which is why a plan used to vanish from the cockpit after a
|
|
6
|
+
* restart (`tasks/20261001-agentus/design-plan-service.md` §1 has the forensics). So the server owns
|
|
7
|
+
* the plan object (Store's `plans` table) and every writer funnels through here, so that they agree
|
|
8
|
+
* on two things:
|
|
9
|
+
*
|
|
10
|
+
* · NORMALISATION — the item shape the card renders. Unknown statuses pass through on purpose:
|
|
11
|
+
* ACP v2 adds `cancelled` and reserves `_`-prefixed unknowns, and a client that drops what it
|
|
12
|
+
* does not understand renders a plan that is quietly wrong.
|
|
13
|
+
* · the TURN LIFECYCLE rules — only the run closes a plan, never the agent (Studio's rule). A
|
|
14
|
+
* leftover `in_progress` step is demoted to `pending` (nothing is running it anymore) and the
|
|
15
|
+
* snapshot carries an honest terminal stamp.
|
|
16
|
+
*/
|
|
17
|
+
import type { PlanItem } from "../../../shared/src/index.js";
|
|
18
|
+
|
|
19
|
+
/** Studio caps its plan remark at 1000 chars; take the same bound rather than invent one. */
|
|
20
|
+
export const MAX_EXPLANATION = 1000;
|
|
21
|
+
|
|
22
|
+
export type PlanTerminal = "ended" | "interrupted" | "failed";
|
|
23
|
+
export type PlanSource = "acp" | "mcp" | "server";
|
|
24
|
+
|
|
25
|
+
/** Coerce whatever a writer sent (agent frame, MCP tool) into what the card renders. */
|
|
26
|
+
export function normalizeItems(raw: unknown): PlanItem[] {
|
|
27
|
+
if (!Array.isArray(raw)) return [];
|
|
28
|
+
const out: PlanItem[] = [];
|
|
29
|
+
for (const entry of raw) {
|
|
30
|
+
if (!entry || typeof entry !== "object") continue;
|
|
31
|
+
const e = entry as Record<string, unknown>;
|
|
32
|
+
const content = String(e.content ?? "").trim();
|
|
33
|
+
if (!content) continue;
|
|
34
|
+
const item: PlanItem = {
|
|
35
|
+
content,
|
|
36
|
+
// An entry with no status is not "completed" and not "in_progress": it is something the
|
|
37
|
+
// agent listed and has not said anything else about.
|
|
38
|
+
status: typeof e.status === "string" && e.status ? e.status : "pending",
|
|
39
|
+
};
|
|
40
|
+
if (typeof e.priority === "string" && e.priority) item.priority = e.priority;
|
|
41
|
+
out.push(item);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Anything the run still owes the operator. `completed`/`cancelled` are closed; an UNKNOWN status
|
|
47
|
+
* counts as unfinished — a step we cannot interpret should not be reported as done. */
|
|
48
|
+
export function hasUnfinished(items: PlanItem[]): boolean {
|
|
49
|
+
return items.some((i) => i.status !== "completed" && i.status !== "cancelled");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Demote steps that cannot still be running, because the run that owned them is over. */
|
|
53
|
+
export function demoteInProgress(items: PlanItem[]): PlanItem[] {
|
|
54
|
+
return items.map((i) => (i.status === "in_progress" ? { ...i, status: "pending" } : i));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Studio's bound and Studio's tolerance: a non-string is dropped, not stringified. */
|
|
58
|
+
export function clampExplanation(raw: unknown): string | null {
|
|
59
|
+
if (typeof raw !== "string") return null;
|
|
60
|
+
const s = raw.trim();
|
|
61
|
+
if (!s) return null;
|
|
62
|
+
return s.length > MAX_EXPLANATION ? s.slice(0, MAX_EXPLANATION) : s;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One writer per session — and it is the AGENT's, whenever it has something to say.
|
|
66
|
+
*
|
|
67
|
+
* Both channels can exist for the same session, and two writers means two plans racing on one card.
|
|
68
|
+
* The rule is not "whoever typed first" but *native priority*: a frame is the agent's own plan, so
|
|
69
|
+
* it is NEVER refused — it takes the card over from a tool-written plan — and once a frame has
|
|
70
|
+
* landed, `current` is "acp" and the tool is refused from then on. Failures land in the visible
|
|
71
|
+
* direction: the plan on screen is the agent's.
|
|
72
|
+
*
|
|
73
|
+
* Since every backend is MCP-driven by default, `incoming: "acp"` is now only ever seen from a row
|
|
74
|
+
* that opted back into frames (session-manager's `#framesAccepted` drops the rest at the door), so
|
|
75
|
+
* this is a backstop rather than the everyday path. */
|
|
76
|
+
export function acceptsWriteFrom(current: PlanSource | undefined, incoming: PlanSource): boolean {
|
|
77
|
+
if (incoming === "acp") return true; // the agent's own channel outranks any tool
|
|
78
|
+
if (incoming === "server") return true; // the turn lifecycle is not a competing writer
|
|
79
|
+
if (!current || current === "server") return true;
|
|
80
|
+
return current === incoming;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Whether the plan TOOL may write this session's plan.
|
|
84
|
+
*
|
|
85
|
+
* The comparison above is between the object's STORED source and the incoming one, and on an
|
|
86
|
+
* MCP-driven row that is the wrong yardstick: the agent's frames are dropped at the door, so the
|
|
87
|
+
* tool is the only writer the session has, and it must be able to take over a plan that an older
|
|
88
|
+
* build wrote from frames. Judging by the stored source alone strands exactly those sessions — the
|
|
89
|
+
* card keeps pre-flip content that nothing can update, and the tool answers "the frames wrote this"
|
|
90
|
+
* about frames the cockpit is now ignoring.
|
|
91
|
+
*
|
|
92
|
+
* A row that opted back into `acp` keeps the old ranking: its frames win, the tool is refused. */
|
|
93
|
+
export function acceptsToolWrite(current: PlanSource | undefined, rowRendersFrames: boolean): boolean {
|
|
94
|
+
if (!rowRendersFrames) return true;
|
|
95
|
+
return acceptsWriteFrom(current, "mcp");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** ACP v2's plan updates (`session/update` → `plan_update`), behind an experiment switch.
|
|
99
|
+
*
|
|
100
|
+
* v2 is still a draft: the SDK ships it under an experimental subpath and both ends of this wire
|
|
101
|
+
* declare protocolVersion 1, so NOTHING may depend on it yet. Parsing it anyway costs one branch,
|
|
102
|
+
* and it buys the property that matters for a draft schema — a client that ignores a frame it does
|
|
103
|
+
* not understand renders a plan that is quietly wrong. Off unless asked for: an experiment switch,
|
|
104
|
+
* not a feature flag. */
|
|
105
|
+
export function v2PlanEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
106
|
+
return env.AGENTUS_ACP_V2_PLAN === "1";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Fold a v2 `plan_update` payload back into the v1 frame shape, or null when it carries nothing a
|
|
110
|
+
* step list can render.
|
|
111
|
+
*
|
|
112
|
+
* v2 wraps the entries one level deeper (`plan: { planId, type: "items", entries }`) and allows
|
|
113
|
+
* two other content kinds — `markdown` and `file` — which are NOT step lists; guessing a list out
|
|
114
|
+
* of them would put words on the card the agent never wrote as steps. It also allows several plans
|
|
115
|
+
* per session (`planId`), which is why the id is returned rather than ignored: the caller decides
|
|
116
|
+
* what to do with a second one. Kept pure (and exported) so the mapping can be unit-tested against
|
|
117
|
+
* the draft schema without a server. */
|
|
118
|
+
export function foldPlanUpdate(update: Record<string, unknown>): { entries: PlanItem[]; planId: string; meta: unknown } | null {
|
|
119
|
+
const plan = (update?.plan ?? null) as { planId?: unknown; type?: unknown; entries?: unknown } | null;
|
|
120
|
+
if (!plan || plan.type !== "items") return null;
|
|
121
|
+
return {
|
|
122
|
+
entries: normalizeItems(plan.entries),
|
|
123
|
+
planId: String(plan.planId ?? ""),
|
|
124
|
+
meta: update._meta ?? null,
|
|
125
|
+
};
|
|
126
|
+
}
|