acrylblends 0.0.0-stage → 0.2.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.
Files changed (74) hide show
  1. package/bin/acrylblends.js +4 -0
  2. package/dist/index.d.ts +117 -0
  3. package/dist/index.js +434 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +50 -4
  6. package/plugins/agent/agent.ts +152 -0
  7. package/plugins/agent/index.ts +20 -0
  8. package/plugins/agent/memory.ts +24 -0
  9. package/plugins/agent/model.ts +79 -0
  10. package/plugins/agent/plugin.json +10 -0
  11. package/plugins/agent/tools.ts +50 -0
  12. package/plugins/builder/index.ts +193 -0
  13. package/plugins/builder/plugin.json +11 -0
  14. package/plugins/builder/script.ts +162 -0
  15. package/plugins/events/index.ts +42 -0
  16. package/plugins/events/plugin.json +9 -0
  17. package/plugins/extensions/index.ts +738 -0
  18. package/plugins/extensions/ledger.ts +39 -0
  19. package/plugins/extensions/plugin.json +12 -0
  20. package/plugins/extensions/resolve.ts +30 -0
  21. package/plugins/extensions/stage.ts +44 -0
  22. package/plugins/http/index.ts +64 -0
  23. package/plugins/http/plugin.json +10 -0
  24. package/plugins/instance/index.ts +167 -0
  25. package/plugins/instance/plugin.json +10 -0
  26. package/plugins/manifest/index.ts +635 -0
  27. package/plugins/manifest/lock.ts +94 -0
  28. package/plugins/manifest/plugin.json +13 -0
  29. package/plugins/manifest/upgrade-cli.ts +70 -0
  30. package/plugins/manifest/upgrade.ts +97 -0
  31. package/plugins/openai/index.ts +159 -0
  32. package/plugins/openai/plugin.json +11 -0
  33. package/plugins/openai/prompt.ts +31 -0
  34. package/plugins/sandbox/index.ts +150 -0
  35. package/plugins/sandbox/plugin.json +8 -0
  36. package/plugins/sandbox/runner.mjs +18 -0
  37. package/plugins/save/cli.ts +19 -0
  38. package/plugins/save/index.ts +55 -0
  39. package/plugins/save/plugin.json +12 -0
  40. package/plugins/server/index.ts +138 -0
  41. package/plugins/server/plugin.json +10 -0
  42. package/plugins/settings/index.ts +270 -0
  43. package/plugins/settings/merge.ts +79 -0
  44. package/plugins/settings/plugin.json +13 -0
  45. package/plugins/tasks/index.ts +232 -0
  46. package/plugins/tasks/plugin.json +11 -0
  47. package/plugins/tasks/store.ts +86 -0
  48. package/plugins/tsconfig.json +19 -0
  49. package/templates/agent/Dockerfile +15 -0
  50. package/templates/agent/README.md +52 -0
  51. package/templates/agent/blend.yaml +58 -0
  52. package/templates/agent/dockerignore +3 -0
  53. package/templates/agent/gitignore +2 -0
  54. package/templates/agent/package.json +23 -0
  55. package/templates/agent/src/app.ts +98 -0
  56. package/templates/agent/src/main.ts +25 -0
  57. package/templates/agent/src/routes.ts +62 -0
  58. package/templates/agent/template.json +16 -0
  59. package/templates/agent/test/agent.test.ts +45 -0
  60. package/templates/agent/test/app.test.ts +46 -0
  61. package/templates/agent/test/builder.test.ts +186 -0
  62. package/templates/agent/test/model.test.ts +89 -0
  63. package/templates/agent/tsconfig.json +19 -0
  64. package/templates/app/README.md +26 -0
  65. package/templates/app/blend.yaml +23 -0
  66. package/templates/app/gitignore +2 -0
  67. package/templates/app/package.json +23 -0
  68. package/templates/app/src/app.ts +61 -0
  69. package/templates/app/src/main.ts +20 -0
  70. package/templates/app/src/routes.ts +14 -0
  71. package/templates/app/template.json +1 -0
  72. package/templates/app/test/app.test.ts +56 -0
  73. package/templates/app/tsconfig.json +19 -0
  74. package/README.md +0 -3
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The save plugin: gives an agent a `save_app` tool when the app has a tool registry. Saving itself is `saveApp` from
3
+ * `@webboxes/app-persistence` (ACRYL's own) over the user's own git; `npm run save` runs the same use case from a terminal (cli.ts).
4
+ */
5
+ import { readFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import type { Context } from '@deepseek-ai/cordis'
8
+ import { gitCli, githubHosting, type SaveResult, saveApp } from '@webboxes/app-persistence'
9
+
10
+ export {
11
+ findSecrets,
12
+ type GitPort,
13
+ gitCli,
14
+ githubHosting,
15
+ type HostingPort,
16
+ PersistenceError,
17
+ readAppIdentity,
18
+ type SaveResult,
19
+ type SecretFinding,
20
+ saveApp,
21
+ type Visibility,
22
+ } from '@webboxes/app-persistence'
23
+
24
+ /** Save the app folder: its blend.yaml decides the visibility. */
25
+ export function saveFolder(appRoot: string, message: string): SaveResult {
26
+ const manifestText = readFileSync(join(appRoot, 'blend.yaml'), 'utf8')
27
+ return saveApp({ manifestText, message }, gitCli(appRoot), githubHosting(appRoot))
28
+ }
29
+
30
+ interface ToolRegistry {
31
+ register(name: string, tool: { description: string; run(input: unknown): unknown }): () => void
32
+ }
33
+
34
+ export const save = {
35
+ name: 'save',
36
+ inject: ['appInstance'],
37
+ apply(ctx: Context) {
38
+ const registry = (ctx.get as (name: string) => unknown)('tools') as ToolRegistry | undefined
39
+ if (!registry) return
40
+ const root = (ctx.get as (name: string) => { root: string })('appInstance').root
41
+ ctx.effect(() =>
42
+ registry.register('save_app', {
43
+ description:
44
+ 'Commit the app (and push if it has a remote). Refuses a file that holds a secret. Input { message }.',
45
+ run: (input) => {
46
+ const message = (input as { message?: unknown } | undefined)?.message
47
+ return saveFolder(
48
+ root,
49
+ typeof message === 'string' && message.trim() ? message : 'save the app',
50
+ )
51
+ },
52
+ }),
53
+ )
54
+ },
55
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "save",
3
+ "description": "Save the app to its own git repository: refuses a file that holds a secret, and a private app going to a public remote.",
4
+ "requires": [],
5
+ "inject": ["appInstance"],
6
+ "provides": [],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4",
9
+ "@webboxes/app-persistence": "0.1.0"
10
+ },
11
+ "mount": "add a script \"save\": \"node src/plugins/save/cli.ts\" to package.json; mount the plugin to give the agent a save_app tool"
12
+ }
@@ -0,0 +1,138 @@
1
+ import {
2
+ createServer,
3
+ type IncomingMessage,
4
+ type Server as NodeServer,
5
+ type ServerResponse,
6
+ } from 'node:http'
7
+ import type { AddressInfo } from 'node:net'
8
+ import type { Context } from '@deepseek-ai/cordis'
9
+ import type { Send, Stream } from '../http/index.ts'
10
+
11
+ /** Where the server listens. Passed as this plugin's Cordis config. */
12
+ export interface ServerConfig {
13
+ readonly port: number
14
+ readonly hostname?: string
15
+ /** Try the next port when this one is taken (up to 20 more). */
16
+ readonly scan?: boolean
17
+ }
18
+
19
+ /** The running server, provided once it listens. */
20
+ export interface Server {
21
+ readonly url: string
22
+ readonly port: number
23
+ }
24
+
25
+ declare module '@deepseek-ai/cordis' {
26
+ interface Context {
27
+ server: Server
28
+ }
29
+ }
30
+
31
+ const SCAN_LIMIT = 20
32
+
33
+ async function readJson(
34
+ request: IncomingMessage,
35
+ ): Promise<{ ok: true; body: unknown } | { ok: false }> {
36
+ const chunks: Buffer[] = []
37
+ for await (const chunk of request) chunks.push(chunk as Buffer)
38
+ if (chunks.length === 0) return { ok: true, body: undefined }
39
+ try {
40
+ return { ok: true, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) }
41
+ } catch {
42
+ return { ok: false }
43
+ }
44
+ }
45
+
46
+ function listen(listener: NodeServer, port: number, hostname: string): Promise<void> {
47
+ return new Promise((resolve, reject) => {
48
+ const onError = (error: Error) => {
49
+ listener.off('listening', onListening)
50
+ reject(error)
51
+ }
52
+ const onListening = () => {
53
+ listener.off('error', onError)
54
+ resolve()
55
+ }
56
+ listener.once('error', onError)
57
+ listener.once('listening', onListening)
58
+ listener.listen(port, hostname)
59
+ })
60
+ }
61
+
62
+ const KEEPALIVE_MS = 15_000
63
+
64
+ /**
65
+ * Answer with server-sent events. Each event has a rising `id`, a `topic` as its event name and JSON `data`; a comment line every 15 s
66
+ * keeps proxies from closing an idle stream. When the client leaves (or the server stops) the stream's own cleanup runs.
67
+ */
68
+ function openStream(request: IncomingMessage, response: ServerResponse, stream: Stream): void {
69
+ response.writeHead(200, {
70
+ 'content-type': 'text/event-stream',
71
+ 'cache-control': 'no-cache',
72
+ connection: 'keep-alive',
73
+ })
74
+ response.write(': connected\n\n')
75
+ let id = 0
76
+ let open = true
77
+ const send: Send = (topic, data) => {
78
+ if (!open) return
79
+ id += 1
80
+ response.write(`id: ${id}\nevent: ${topic}\ndata: ${JSON.stringify(data)}\n\n`)
81
+ }
82
+ const leave = stream(send)
83
+ const keepalive = setInterval(() => response.write(': keepalive\n\n'), KEEPALIVE_MS)
84
+ const close = () => {
85
+ if (!open) return
86
+ open = false
87
+ clearInterval(keepalive)
88
+ leave()
89
+ }
90
+ request.on('close', close)
91
+ response.on('close', close)
92
+ }
93
+
94
+ /** Serves the `http` router with Node's own `node:http`. The returned disposer closes the server. */
95
+ export const server = {
96
+ name: 'server',
97
+ inject: ['http'],
98
+ async apply(ctx: Context, config: ServerConfig) {
99
+ const hostname = config.hostname ?? '127.0.0.1'
100
+ const listener = createServer(async (request, response) => {
101
+ const send = (status: number, body: unknown) => {
102
+ response.writeHead(status, { 'content-type': 'application/json' })
103
+ response.end(JSON.stringify(body))
104
+ }
105
+ try {
106
+ const parsed = await readJson(request)
107
+ if (!parsed.ok) return send(400, { error: 'invalid JSON' })
108
+ const path = new URL(request.url ?? '/', 'http://localhost').pathname
109
+ const reply = await ctx.http.handle({
110
+ method: request.method ?? 'GET',
111
+ path,
112
+ body: parsed.body,
113
+ })
114
+ if (reply.stream) return openStream(request, response, reply.stream)
115
+ send(reply.status, reply.body)
116
+ } catch (error) {
117
+ send(500, { error: error instanceof Error ? error.message : String(error) })
118
+ }
119
+ })
120
+ const attempts = config.scan && config.port !== 0 ? SCAN_LIMIT + 1 : 1
121
+ for (let attempt = 0; ; attempt++) {
122
+ try {
123
+ await listen(listener, config.port + attempt, hostname)
124
+ break
125
+ } catch (error) {
126
+ const inUse = (error as NodeJS.ErrnoException).code === 'EADDRINUSE'
127
+ if (!inUse || attempt + 1 >= attempts) throw error
128
+ }
129
+ }
130
+ const { port } = listener.address() as AddressInfo
131
+ ctx.provide('server', { url: `http://${hostname}:${port}`, port } satisfies Server)
132
+ return () =>
133
+ new Promise<void>((done) => {
134
+ listener.close(() => done())
135
+ listener.closeAllConnections()
136
+ })
137
+ },
138
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "server",
3
+ "description": "Serves the http router over node:http; moves to the next free port when asked; closes on stop.",
4
+ "requires": ["http"],
5
+ "inject": ["http"],
6
+ "provides": ["server"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4"
9
+ }
10
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * The settings service (`ctx.settings`): per-namespace sections kept in one YAML file in the app's home.
3
+ *
4
+ * Extracted from ACRYL (`plugins/acryl-settings`). Plugins register a namespace with a schemastery schema and read the resolved value,
5
+ * which layers schema defaults, the registrant's `base`, and the stored section, in that order. Writes are validated, atomic (write a
6
+ * temporary file, then rename) and run one at a time in commit order; watchers see them in that order; an event announces each change.
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
9
+ import { dirname, join } from 'node:path'
10
+ import { type Context, Service } from '@deepseek-ai/cordis'
11
+ import z from '@deepseek-ai/schemastery'
12
+ import { parseDocument, stringify } from 'yaml'
13
+ import type {} from '../instance/index.ts'
14
+ import { cloneJsonShaped, deepEqualJson, isPlainObject, mergeLayers } from './merge.ts'
15
+
16
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/
17
+ export const DEFAULT_FILENAME = 'settings.yaml'
18
+
19
+ /** When a namespace's changes take effect for its owner. */
20
+ export type SettingsApplies = 'live' | 'restart'
21
+
22
+ export interface SettingsRegisterOptions<T> {
23
+ /** Values resolved below the stored layer (for example, what the app's composition chose). */
24
+ base?: Partial<T>
25
+ /** When the owner applies a change; shown to settings screens. Defaults to `live`. */
26
+ applies?: SettingsApplies
27
+ /** Reject a resolved value the schema cannot express as invalid; a throw refuses the write that produced it. */
28
+ validate?: (value: T) => void
29
+ }
30
+
31
+ /** One registered namespace, as a settings screen sees it. */
32
+ export interface SettingsDescriptor {
33
+ readonly namespace: string
34
+ /** The schemastery schema, serialized. */
35
+ readonly schema: unknown
36
+ readonly value: unknown
37
+ /** Bumps on every committed write. */
38
+ readonly revision: number
39
+ readonly applies: SettingsApplies
40
+ /** The stored section, when there is one. */
41
+ readonly user?: unknown
42
+ }
43
+
44
+ /** The owner's handle on one namespace. */
45
+ export interface SettingsScope<T> {
46
+ /** Current resolved value: schema defaults, then `base`, then the stored section. */
47
+ get(): T
48
+ /** Observe committed changes; callbacks run one at a time in commit order. Returns the function that stops watching. */
49
+ watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
50
+ /** Merge a patch into the stored section and persist it. */
51
+ update(patch: object): Promise<void>
52
+ /** Replace the stored section; `replace({})` resets every key to its base or default. */
53
+ replace(section: object): Promise<void>
54
+ }
55
+
56
+ export interface SettingsConfig {
57
+ /** Absolute path of the YAML file; empty or absent means `<app home>/settings.yaml`. */
58
+ filename?: string
59
+ }
60
+
61
+ declare module '@deepseek-ai/cordis' {
62
+ interface Context {
63
+ settings: Settings
64
+ }
65
+
66
+ interface Events {
67
+ /** A committed change to one namespace's resolved value, after the file was written; never for an equal value. */
68
+ 'settings/updated'(namespace: string, next: unknown, prev: unknown): void
69
+ }
70
+ }
71
+
72
+ interface Entry {
73
+ readonly namespace: string
74
+ readonly schema: z<unknown>
75
+ readonly options: SettingsRegisterOptions<unknown>
76
+ resolved: unknown
77
+ revision: number
78
+ readonly watchers: Set<Watcher>
79
+ }
80
+
81
+ interface Watcher {
82
+ readonly callback: (next: unknown, prev: unknown) => void | Promise<void>
83
+ active: boolean
84
+ }
85
+
86
+ function assertNamespace(value: string): void {
87
+ if (!NAMESPACE_PATTERN.test(value))
88
+ throw new TypeError(`settings: namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`)
89
+ }
90
+
91
+ function readDocument(filename: string): Record<string, unknown> {
92
+ if (!existsSync(filename)) return {}
93
+ const parsed = parseDocument(readFileSync(filename, 'utf8'), { prettyErrors: true })
94
+ if (parsed.errors.length > 0) {
95
+ throw new Error(
96
+ `settings: invalid settings file ${filename}: ${parsed.errors.map((error) => error.message).join('; ')}`,
97
+ )
98
+ }
99
+ const value: unknown = parsed.toJS() ?? {}
100
+ if (!isPlainObject(value))
101
+ throw new Error(`settings: ${filename} must be a map of namespace sections`)
102
+ return value
103
+ }
104
+
105
+ /** The service. One instance serves one app home. */
106
+ export class Settings extends Service {
107
+ readonly filename: string
108
+ private document: Record<string, unknown>
109
+ private readonly entries = new Map<string, Entry>()
110
+ /** Writes and watcher notifications run one at a time, in commit order. */
111
+ private tail: Promise<void> = Promise.resolve()
112
+ private disposed = false
113
+
114
+ constructor(ctx: Context, config: SettingsConfig = {}) {
115
+ super(ctx, 'settings')
116
+ this.filename = config.filename ? config.filename : join(ctx.appInstance.home, DEFAULT_FILENAME)
117
+ this.document = readDocument(this.filename)
118
+ ctx.effect(
119
+ () => async () => {
120
+ this.disposed = true
121
+ await this.tail
122
+ },
123
+ 'settings: settle pending writes',
124
+ )
125
+ }
126
+
127
+ /** Register a namespace and return its handle. A stored section that fails the schema rejects the registration. */
128
+ register<T>(
129
+ namespace: string,
130
+ schema: z<T>,
131
+ options: SettingsRegisterOptions<T> = {},
132
+ ): SettingsScope<T> {
133
+ assertNamespace(namespace)
134
+ if (this.entries.has(namespace))
135
+ throw new Error(`settings: namespace "${namespace}" is already registered`)
136
+ const entry: Entry = {
137
+ namespace,
138
+ schema: schema as z<unknown>,
139
+ options: options as SettingsRegisterOptions<unknown>,
140
+ resolved: undefined,
141
+ revision: 0,
142
+ watchers: new Set(),
143
+ }
144
+ entry.resolved = this.resolve(entry, this.section(namespace))
145
+ this.entries.set(namespace, entry)
146
+ this.ctx.effect(
147
+ () => () => void this.entries.delete(namespace),
148
+ `settings: namespace ${namespace}`,
149
+ )
150
+ return this.scopeFor<T>(entry)
151
+ }
152
+
153
+ /** The resolved value of a registered namespace, or `undefined` when nobody registered it. */
154
+ get(namespace: string): unknown {
155
+ return this.entries.get(namespace)?.resolved
156
+ }
157
+
158
+ /** Merge a patch into a namespace by name (for a settings route that never held the scope). An unregistered namespace is refused. */
159
+ update(namespace: string, patch: Readonly<Record<string, unknown>>): Promise<void> {
160
+ const entry = this.entries.get(namespace)
161
+ if (entry === undefined)
162
+ return Promise.reject(new Error(`settings: namespace "${namespace}" is not registered`))
163
+ return this.commit(entry, (current) => mergeLayers(current, cloneJsonShaped({ ...patch })))
164
+ }
165
+
166
+ /** Every registered namespace, for settings screens. */
167
+ describe(): SettingsDescriptor[] {
168
+ return [...this.entries.values()].map((entry) => {
169
+ const stored = this.document[entry.namespace]
170
+ return {
171
+ namespace: entry.namespace,
172
+ schema: entry.schema.toJSON(),
173
+ value: entry.resolved,
174
+ revision: entry.revision,
175
+ applies: entry.options.applies ?? 'live',
176
+ ...(stored === undefined ? {} : { user: structuredClone(stored) }),
177
+ }
178
+ })
179
+ }
180
+
181
+ private section(namespace: string): Record<string, unknown> {
182
+ const stored = this.document[namespace]
183
+ if (stored === undefined) return {}
184
+ if (!isPlainObject(stored))
185
+ throw new Error(`settings: section "${namespace}" in ${this.filename} must be a map`)
186
+ return stored
187
+ }
188
+
189
+ private resolve(entry: Entry, user: Record<string, unknown>): unknown {
190
+ const base = (entry.options.base ?? {}) as Record<string, unknown>
191
+ const value = entry.schema(mergeLayers(base, user))
192
+ entry.options.validate?.(value)
193
+ return value
194
+ }
195
+
196
+ private scopeFor<T>(entry: Entry): SettingsScope<T> {
197
+ return {
198
+ get: () => entry.resolved as T,
199
+ watch: (callback) => {
200
+ const watcher: Watcher = { callback: callback as Watcher['callback'], active: true }
201
+ entry.watchers.add(watcher)
202
+ return () => {
203
+ watcher.active = false
204
+ entry.watchers.delete(watcher)
205
+ }
206
+ },
207
+ update: (patch) =>
208
+ this.commit(entry, (current) =>
209
+ mergeLayers(current, cloneJsonShaped(patch as Record<string, unknown>)),
210
+ ),
211
+ replace: (section) =>
212
+ this.commit(entry, () => cloneJsonShaped(section as Record<string, unknown>)),
213
+ }
214
+ }
215
+
216
+ /** One write: compute, validate, persist atomically, then notify. A refused write changes nothing. */
217
+ private commit(
218
+ entry: Entry,
219
+ next: (current: Record<string, unknown>) => Record<string, unknown>,
220
+ ): Promise<void> {
221
+ const run = async (): Promise<void> => {
222
+ if (this.disposed) throw new Error('settings: the service is disposed')
223
+ const nextSection = next(this.section(entry.namespace))
224
+ const nextValue = this.resolve(entry, nextSection)
225
+ const prevValue = entry.resolved
226
+ const nextDocument = { ...this.document, [entry.namespace]: nextSection }
227
+ if (Object.keys(nextSection).length === 0) delete nextDocument[entry.namespace]
228
+ this.persist(nextDocument)
229
+ this.document = nextDocument
230
+ entry.revision += 1
231
+ entry.resolved = nextValue
232
+ if (deepEqualJson(prevValue, nextValue)) return
233
+ this.ctx.emit('settings/updated', entry.namespace, nextValue, prevValue)
234
+ for (const watcher of [...entry.watchers]) {
235
+ if (!watcher.active) continue
236
+ try {
237
+ await watcher.callback(nextValue, prevValue)
238
+ } catch (cause) {
239
+ this.ctx.logger?.error?.(
240
+ `settings: watcher for "${entry.namespace}" failed: ${cause instanceof Error ? cause.message : String(cause)}`,
241
+ )
242
+ }
243
+ }
244
+ }
245
+ const result = this.tail.then(run, run)
246
+ this.tail = result.then(
247
+ () => undefined,
248
+ () => undefined,
249
+ )
250
+ return result
251
+ }
252
+
253
+ private persist(document: Record<string, unknown>): void {
254
+ mkdirSync(dirname(this.filename), { recursive: true })
255
+ const temporary = `${this.filename}.${String(process.pid)}.tmp`
256
+ writeFileSync(temporary, stringify(document), { mode: 0o600 })
257
+ renameSync(temporary, this.filename)
258
+ }
259
+ }
260
+
261
+ /** The plugin: mount it after `instance`. */
262
+ export const settings = {
263
+ name: 'settings',
264
+ inject: ['appInstance'],
265
+ apply(ctx: Context, config: SettingsConfig) {
266
+ new Settings(ctx, config ?? {})
267
+ },
268
+ }
269
+
270
+ export { z }
@@ -0,0 +1,79 @@
1
+ /** Pure helpers for layering a user section over a base and for detaching JSON data. */
2
+
3
+ /** Whether a value is a plain data object (not an array, null, or class instance). */
4
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
5
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
6
+ const proto: unknown = Object.getPrototypeOf(value)
7
+ return proto === Object.prototype || proto === null
8
+ }
9
+
10
+ /**
11
+ * Deep-merge `patch` over `base`: plain objects merge key by key, every other value (arrays included) replaces,
12
+ * and `undefined` entries in the patch are skipped.
13
+ */
14
+ export function mergeLayers(
15
+ base: Record<string, unknown>,
16
+ patch: Record<string, unknown>,
17
+ ): Record<string, unknown> {
18
+ const merged: Record<string, unknown> = { ...base }
19
+ for (const [key, value] of Object.entries(patch)) {
20
+ if (value === undefined) continue
21
+ const current = merged[key]
22
+ merged[key] =
23
+ isPlainObject(current) && isPlainObject(value) ? mergeLayers(current, value) : value
24
+ }
25
+ return merged
26
+ }
27
+
28
+ /**
29
+ * Detach one write input and reject anything YAML would distort on a reload round trip: only plain objects, arrays,
30
+ * strings, finite numbers, booleans and `null` pass.
31
+ * @throws TypeError naming the `$`-rooted path of the first rejected value.
32
+ */
33
+ export function cloneJsonShaped(root: Record<string, unknown>): Record<string, unknown> {
34
+ const visiting = new WeakSet<object>()
35
+ const clone = (value: unknown, path: string): unknown => {
36
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
37
+ if (typeof value === 'number') {
38
+ if (!Number.isFinite(value)) throw new TypeError(`settings: ${path} is a non-finite number`)
39
+ return value
40
+ }
41
+ if (Array.isArray(value)) {
42
+ if (visiting.has(value)) throw new TypeError(`settings: ${path} is a circular reference`)
43
+ visiting.add(value)
44
+ const entries = value.map((entry, index) => clone(entry, `${path}[${String(index)}]`))
45
+ visiting.delete(value)
46
+ return entries
47
+ }
48
+ if (isPlainObject(value)) {
49
+ if (visiting.has(value)) throw new TypeError(`settings: ${path} is a circular reference`)
50
+ visiting.add(value)
51
+ const out: Record<string, unknown> = {}
52
+ for (const [key, entry] of Object.entries(value)) {
53
+ if (entry === undefined) continue
54
+ out[key] = clone(entry, `${path}.${key}`)
55
+ }
56
+ visiting.delete(value)
57
+ return out
58
+ }
59
+ throw new TypeError(
60
+ `settings: ${path} is ${value === undefined ? 'undefined' : `a ${typeof value}`}, which cannot be stored`,
61
+ )
62
+ }
63
+ return clone(root, '$') as Record<string, unknown>
64
+ }
65
+
66
+ /** Structural JSON equality for two detached values. */
67
+ export function deepEqualJson(a: unknown, b: unknown): boolean {
68
+ if (a === b) return true
69
+ if (Array.isArray(a) && Array.isArray(b))
70
+ return a.length === b.length && a.every((entry, index) => deepEqualJson(entry, b[index]))
71
+ if (isPlainObject(a) && isPlainObject(b)) {
72
+ const keys = Object.keys(a)
73
+ return (
74
+ keys.length === Object.keys(b).length &&
75
+ keys.every((key) => Object.hasOwn(b, key) && deepEqualJson(a[key], b[key]))
76
+ )
77
+ }
78
+ return false
79
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "settings",
3
+ "description": "Validated settings per namespace in one YAML file in the app's home: defaults, base and stored layers, watch, update.",
4
+ "requires": ["instance"],
5
+ "inject": ["appInstance"],
6
+ "provides": ["settings"],
7
+ "dependencies": {
8
+ "@deepseek-ai/cordis": "4.0.4",
9
+ "@deepseek-ai/schemastery": "3.18.4",
10
+ "yaml": "2.9.0"
11
+ },
12
+ "mount": "mount it after instance: root.plugin(settings, {}); then ctx.settings.register('my-plugin', Schema)"
13
+ }