dsh-ab-ocr 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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +378 -0
  3. package/cordis.patch.yml +7 -0
  4. package/lib/artifacts.d.ts +100 -0
  5. package/lib/artifacts.d.ts.map +1 -0
  6. package/lib/artifacts.js +97 -0
  7. package/lib/artifacts.js.map +1 -0
  8. package/lib/config.d.ts +77 -0
  9. package/lib/config.d.ts.map +1 -0
  10. package/lib/config.js +51 -0
  11. package/lib/config.js.map +1 -0
  12. package/lib/documents.d.ts +62 -0
  13. package/lib/documents.d.ts.map +1 -0
  14. package/lib/documents.js +173 -0
  15. package/lib/documents.js.map +1 -0
  16. package/lib/events.d.ts +161 -0
  17. package/lib/events.d.ts.map +1 -0
  18. package/lib/events.js +158 -0
  19. package/lib/events.js.map +1 -0
  20. package/lib/filename.d.ts +47 -0
  21. package/lib/filename.d.ts.map +1 -0
  22. package/lib/filename.js +77 -0
  23. package/lib/filename.js.map +1 -0
  24. package/lib/index.d.ts +85 -0
  25. package/lib/index.d.ts.map +1 -0
  26. package/lib/index.js +1761 -0
  27. package/lib/index.js.map +1 -0
  28. package/lib/levels.d.ts +24 -0
  29. package/lib/levels.d.ts.map +1 -0
  30. package/lib/levels.js +52 -0
  31. package/lib/levels.js.map +1 -0
  32. package/lib/plan.d.ts +103 -0
  33. package/lib/plan.d.ts.map +1 -0
  34. package/lib/plan.js +210 -0
  35. package/lib/plan.js.map +1 -0
  36. package/lib/recognize.d.ts +36 -0
  37. package/lib/recognize.d.ts.map +1 -0
  38. package/lib/recognize.js +390 -0
  39. package/lib/recognize.js.map +1 -0
  40. package/lib/records.d.ts +91 -0
  41. package/lib/records.d.ts.map +1 -0
  42. package/lib/records.js +130 -0
  43. package/lib/records.js.map +1 -0
  44. package/lib/render.d.ts +19 -0
  45. package/lib/render.d.ts.map +1 -0
  46. package/lib/render.js +45 -0
  47. package/lib/render.js.map +1 -0
  48. package/lib/sandbox.d.ts +54 -0
  49. package/lib/sandbox.d.ts.map +1 -0
  50. package/lib/sandbox.js +101 -0
  51. package/lib/sandbox.js.map +1 -0
  52. package/lib/types.d.ts +147 -0
  53. package/lib/types.d.ts.map +1 -0
  54. package/lib/types.js +7 -0
  55. package/lib/types.js.map +1 -0
  56. package/lib/worker.d.ts +107 -0
  57. package/lib/worker.d.ts.map +1 -0
  58. package/lib/worker.js +143 -0
  59. package/lib/worker.js.map +1 -0
  60. package/package.json +98 -0
  61. package/python/README.md +125 -0
  62. package/python/assemble.py +358 -0
  63. package/python/clean.py +197 -0
  64. package/python/layout.py +403 -0
  65. package/python/ocr_worker.py +516 -0
  66. package/python/requirements.txt +16 -0
  67. package/python/source.py +182 -0
  68. package/scripts/setup.mjs +251 -0
  69. package/tsconfig.json +30 -0
  70. package/tsdown.config.ts +18 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-ab-ocr contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,378 @@
1
+ ---
2
+ description: "The out-of-tree ocr tool: recognizes a scanned PDF or image page by page, merges the pages in order only after the last one, and saves one Markdown document with the heading levels the model decided."
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @deepseek-ai/dsh-ab-ocr
7
+
8
+ ## Summary
9
+
10
+ `dsh-ab-ocr` gives this machine's `dsh` profiles one way to read a document whose text cannot be selected. One `ocr` call renders, recognizes, and merges every page of a PDF or a still image, and saves the result as one Markdown document: page numbers and repeated edge lines removed, the whitespace a line box leaves behind removed, and paragraphs rejoined across page breaks.
11
+
12
+ The outline is a two-step shape. The merge infers a heading level for every line it can, and writes the lines a reader might call headings to a small outline file beside the document. The conversation's model reads that file, decides the levels, and calls `ocr` again with a `levels` value; the second call rewrites the document from the geometry it stored, without recognizing anything again. With no second call the document is still complete — it simply carries the inferred outline.
13
+
14
+ The OCR engine is RapidOCR, whose PP-OCRv6 detection, classification, and recognition weights ship inside the wheel, so installing the dependencies *is* installing the model. The environment is created from inside the package by `pnpm run setup`.
15
+
16
+ ## Table of Contents
17
+
18
+ - [Use this package](#use-this-package)
19
+ - [Install the engine](#install-the-engine)
20
+ - [Configuration](#configuration)
21
+ - [What one call records](#what-one-call-records)
22
+ - [Where the files go](#where-the-files-go)
23
+ - [Understand the implementation](#understand-the-implementation)
24
+ - [Model Experience](#model-experience)
25
+ - [Evidence](#evidence)
26
+ - [`./invariant`](#invariant)
27
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
28
+ - [Dev Note](#dev-note)
29
+
30
+ -----
31
+
32
+ <a id="use-this-package"></a>
33
+ ## Use this package
34
+
35
+ Mount the row in a profile when the agent should read a scan, a photographed page, a screenshot, or a PDF whose text cannot be selected. The row injects `ctx.tools` and `ctx.systemPrompt`: the tool reaches the model catalog through the ordinary schema assembly, and the prompt through the ordinary section assembly. Mounting it adds no provider — every file it writes goes through the filesystem capability the composition already has.
36
+
37
+ ### Mount it in a profile
38
+
39
+ ```yaml
40
+ # <DSH_HOME>/profiles/<profile>/cordis.patch.yml
41
+ - insert:
42
+ - id: ocr
43
+ name: '@deepseek-ai/dsh-ab-ocr'
44
+ config:
45
+ dpi: 200
46
+ maxPixels: 12000000
47
+ maxPages: 2000
48
+ maxDocuments: 20
49
+ timeoutMs: 1800000
50
+ startupTimeoutMs: 60000
51
+ maxOutlineCandidates: 400
52
+ engineLifetime: perDocument
53
+ writePageFiles: true
54
+ outlineCandidateRatio: 1.05
55
+ ```
56
+
57
+ ```json
58
+ // <DSH_HOME>/profiles/<profile>/package.json
59
+ "@deepseek-ai/dsh-ab-ocr": "link:<plugins root>/plugins/dsh-ab-ocr"
60
+ ```
61
+
62
+ Every bound that block states is required: the row is where this deployment says how much work, memory, time, and disk it is willing to carry, and a row that omits one fails to load naming the field. The recognition heuristics have documented defaults and appear here only when this deployment wants a different value.
63
+
64
+ Run `pnpm install` in the profile directory after editing either file.
65
+
66
+ <a id="install-the-engine"></a>
67
+ ### Install the engine
68
+
69
+ The Python environment is separate from pnpm and is created by a script inside this package:
70
+
71
+ ```sh
72
+ pnpm --dir <plugins root>/plugins/dsh-ab-ocr run setup
73
+ ```
74
+
75
+ It finds a base interpreter, creates `python/.venv`, installs `python/requirements.txt`, runs the worker's own `--self-test`, and reports the interpreter, the package versions, and the model files the wheel carries. It is idempotent: a run against a working environment verifies and exits without touching the network.
76
+
77
+ | Flag | Effect |
78
+ |---|---|
79
+ | `--python <path>` | Base interpreter to build the environment from (or `DSH_OCR_PYTHON`) |
80
+ | `--prefetch` | Also load the engine once, so the first recognition is not the one that waits for the models |
81
+ | `--force` | Remove the environment and build it again |
82
+ | `--json` | Print the summary as one JSON object |
83
+
84
+ Installing into the environment needs permission to create directories with a private mode, so run it outside a restricted shell. The tool reports what is missing — and names this script — when a call arrives without an environment.
85
+
86
+ <a id="configuration"></a>
87
+ ### Configuration
88
+
89
+ Every field is overridable from the row. A field the deployment owns is **required** — it has no value inside the package, because a hidden default would state an agreement the deployment never made — and a field whose default is a documented resolution rule or a recognition heuristic is **defaulted**.
90
+
91
+ | Field | Required | Default | Meaning |
92
+ |---|---|---|---|
93
+ | `pythonPath` | | (auto) | Interpreter carrying the OCR dependencies |
94
+ | `workerScript` | | (auto) | Worker script to run |
95
+ | `outputDir` | | (beside source) | Where the Markdown files go |
96
+ | `pageDirName` | | `.ocr-pages` | Per-document directory, relative to each output file |
97
+ | `writePageFiles` | yes | — | Write each page's text to its own file as it finishes |
98
+ | `dpi` | yes | — | PDF render resolution |
99
+ | `maxPixels` | yes | — | Ceiling on one rendered page; 0 disables it |
100
+ | `textScore` | | 0.5 | Recognition confidence floor |
101
+ | `detectHeadings` | | `true` | Render the inferred outline into the document |
102
+ | `headingMinRatio` | | 1.18 | Glyph-height ratio that makes a line a heading |
103
+ | `indentRatio` | | 1.0 | Left-edge offset, in body glyph heights, that marks an indented first line |
104
+ | `paragraphGapRatio` | | 0.85 | Vertical gap, in body glyph heights, that separates two paragraphs |
105
+ | `outlineCandidateRatio` | | 1.05 | Glyph-height ratio above which a line becomes an outline candidate |
106
+ | `maxOutlineCandidates` | yes | — | Most candidates one document's outline file may list |
107
+ | `detectColumns` | | `true` | Split a page at a vertical gutter |
108
+ | `removePageNumbers` | | `true` | Drop folios |
109
+ | `removeRunningHeads` | | `true` | Drop lines repeated in the edge bands |
110
+ | `runningHeadRatio` | | 0.6 | Fraction of pages such a line must appear on |
111
+ | `runningHeadMinPages` | | 3 | Absolute page minimum for that rule |
112
+ | `engineLifetime` | yes | — | Rebuild the engine per document, or share it across the call |
113
+ | `timeoutMs` | yes | — | Ceiling on one recognition; 0 disables it |
114
+ | `startupTimeoutMs` | yes | — | Ceiling on the worker's startup check before the call is refused |
115
+ | `maxPages` | yes | — | Pages a document may carry without an explicit selection; 0 disables it |
116
+ | `maxDocuments` | yes | — | Files one call may name |
117
+
118
+ The required group is what the tool may cost this machine: the render resolution, the per-page pixel ceiling, the page and file ceilings, the two time ceilings, the outline candidate ceiling, the engine lifetime, and whether per-page text is kept. The defaulted group is what a document reader wants: where the interpreter, the worker, and the artifacts are found, and how a page is read and merged.
119
+
120
+ `startupTimeoutMs` exists because the startup check is a separate process from the batch. An environment whose engine never loads has to be refused inside the deployment's startup window rather than held for the whole recognition ceiling.
121
+
122
+ <a id="what-one-call-records"></a>
123
+ ### What one call records
124
+
125
+ ```jsonc
126
+ // ocr({ path: "D:/docs/部署规范.pdf" })
127
+ {
128
+ "documents": [{
129
+ "input": "D:/docs/部署规范.pdf",
130
+ "output": "D:/docs/部署规范.md",
131
+ "pages": 3, "totalPages": 3, "selection": [1, 2, 3],
132
+ "lines": 16, "headings": 3, "chars": 289,
133
+ "droppedPageNumbers": 1, "droppedRunningHeads": 0, "joinedAcrossPages": 0,
134
+ "pageDir": "D:/docs/.ocr-pages/部署规范",
135
+ "digest": "9c41acae7ce7",
136
+ "outlinePath": "D:/docs/.ocr-pages/部署规范/9c41acae7ce7/outline.json",
137
+ "outlineTruncated": false,
138
+ "corrections": 0,
139
+ "seconds": 31.56
140
+ }],
141
+ "failures": []
142
+ }
143
+ ```
144
+
145
+ A call carrying `levels` reports `corrections` instead of a recognition, and rewrites the Markdown the earlier recognition produced. A document that fails does not discard the others; each failure carries its own path and reason.
146
+
147
+ <a id="where-the-files-go"></a>
148
+ ### Where the files go
149
+
150
+ For a source named `<stem>.<ext>` under a base directory `<base>` — the source's own directory, or `outputDir`:
151
+
152
+ | Artifact | Path |
153
+ |---|---|
154
+ | Markdown | `<base>/<stem>.md` |
155
+ | A second recognition of the same source | `<base>/<stem>.<digest12>.md` |
156
+ | Per-page text | `<base>/.ocr-pages/<stem>/<digest12>/page-00001.txt` |
157
+ | Recognition record | `<base>/.ocr-pages/<stem>/<digest12>/record.json` |
158
+ | Outline sheet | `<base>/.ocr-pages/<stem>/<digest12>/outline.json` |
159
+ | Most recent recognition | `<base>/.ocr-pages/<stem>/latest.json` |
160
+
161
+ The source's extension is replaced by `.md` and every space is removed, so the name is space-free on disk and in a link. Tabs, non-breaking spaces, and the full-width space are removed with it; characters a file system rejects are replaced, a reserved Windows device name gains a leading underscore, and a name that compacts to nothing becomes `document`.
162
+
163
+ `digest12` is the first twelve hex characters of a hash over the source path and content, the page selection, and the merge settings. The worker reports the source's own content digest as the document starts, so the name follows what the document *is* rather than how large it happens to be: a source edited without changing size is a different recognition, and an unchanged one is always the same recognition. That is how a `levels` call finds what to correct without scanning.
164
+
165
+ `<stem>.md` is never overwritten with different content: when it already holds another document, the new one takes the digest name. Repeating a recognition whose result is unchanged reuses the file it wrote. `writePageFiles: false` suppresses only the per-page text; the record and the outline are always written, because the correction pass reads them.
166
+
167
+ -----
168
+
169
+ <a id="understand-the-implementation"></a>
170
+ ## Understand the implementation
171
+
172
+ ### Design commitments
173
+
174
+ - **One page in memory.** A page is rendered, recognized, reported, and dropped before the next page is rendered.
175
+ - **A page is saved as it finishes.** The text is written when the page event arrives, so an interrupted run keeps every page it completed.
176
+ - **The document is merged only after the last page.** The merge is a separate pass in the worker, over records it kept; nothing is written to a document while a page is still being recognized.
177
+ - **The engine is released per document.** `engineLifetime: perDocument` rebuilds it for each document, so a batch never holds more than one.
178
+ - **Nothing reads a PDF's text layer.** Every page is rendered and recognized, which is what makes a scan work and what costs time on a text PDF.
179
+ - **Every write goes through ctx.fs.** See [Why every write goes through ctx.fs](#why-every-write-goes-through-ctxfs).
180
+
181
+ ### Why every write goes through ctx.fs
182
+
183
+ This deployment enforces its file policy in two different places, and only one of them reaches a plugin that opens files itself.
184
+
185
+ | Layer | What it confines |
186
+ |---|---|
187
+ | The process sandbox | A command started through the sandbox provider, and everything that command spawns |
188
+ | `ctx.fs` | Every mutation a plugin performs through the capability, at the path level |
189
+
190
+ The base bundle mounts the sandbox-enforcing filesystem provider, so `ctx.fs.writeText` fences each write against the session mode and the writable roots. A plugin that calls the host filesystem directly is fenced by neither. The worker therefore opens only the document it was given and writes to stdout; the page text, the geometry, the merged Markdown, and the outline all travel back as events, and this module writes them through `ctx.fs`.
191
+
192
+ The capability is read with `ctx.get('fs')` when a call runs and is not listed in the row's `inject`. Cordis satisfies a declared injection by waiting for the service, so declaring `fs` would make the row a provider requirement and leave it unmounted in a composition that has none.
193
+
194
+ ### Why every write carries a fence
195
+
196
+ A sandboxing backend fences a mutation by a per-call policy, and that policy — not the backend's own default — is the only thing naming the workspace the **calling session** runs in. Calling `writeText` without one makes the backend fall back to the deployment's root, which is the directory the server was launched from; a session working anywhere else then has every artifact refused, one page at a time. `src/sandbox.ts` owns that seam:
197
+
198
+ - `callFence(ctx, request)` resolves the calling session's policy once per call and refuses a composition that mounts a confining backend with no `ctx.sandboxPolicy`.
199
+ - `saveText(fs, target, content, fence, signal)` is the one path every persisted text goes through, so the fence is applied in one place. Every other write in the plugin (`writeJson`, `writeDocument`, the page files, the correction pass) delegates to it.
200
+ - The resolved fence's `workspaceRoot` is also the base a relative `outputDir` resolves against, so a call that names no directory writes inside the only root the fence will accept. `process.cwd()` is not consulted at all when a policy is available.
201
+
202
+ The guard has to live at the call rather than at load: `dsh-fs-sandbox` declares `sandboxPolicy` as a declared injection, so in a composition that mounts the backend **without** the policy service the backend is never constructed — `ctx.get('fs')` is `undefined` while this row applies, and the row mounts cleanly. The first call is the earliest point at which the composition's real shape is knowable.
203
+
204
+ A refused write is restated rather than passed through: the denial marker, the path, the mode, and the workspace root the artifact has to move under, with the backend's refusal kept as the `cause` and the `FS_SANDBOX_DENIED` code re-stamped, so a caller that keys a retry off the code still sees it.
205
+
206
+ ### Source map
207
+
208
+ | Path | Role |
209
+ |---|---|
210
+ | `src/index.ts` | plugin entry: identity, routing section, tool registration, pending-call presentation |
211
+ | `src/config.ts` | the deployment's bounds and the recognition heuristics, and their schema |
212
+ | `src/plan.ts` | request planning: interpreter, worker, and artifact resolution; the worker spec |
213
+ | `src/recognize.ts` | one call: startup check, the batch, the correction pass, the artifact writes |
214
+ | `src/events.ts` | the worker's event stream and the per-call state it accumulates |
215
+ | `src/documents.ts` | the artifact files of one recognition, and the report it produces |
216
+ | `src/artifacts.ts` | artifact paths and the recognition digest |
217
+ | `src/records.ts` | the stored record and outline formats, their readers, and the JSON write |
218
+ | `src/sandbox.ts` | the per-call fence, the guard for a composition that cannot resolve one, and the one policy-stamped write path |
219
+ | `src/levels.ts` | the `levels` argument |
220
+ | `src/filename.ts` | output naming |
221
+ | `src/worker.ts` | process transport: spec handoff, line framing, timeout, cancellation |
222
+ | `src/render.ts` | model-facing result text |
223
+ | `scripts/setup.mjs` | the environment installer |
224
+ | `python/ocr_worker.py` | worker entry: engine lifetime, page loop, merge and assemble modes |
225
+ | `python/source.py` | PDF and image page sources |
226
+ | `python/layout.py` | boxes to lines, column detection, outline levels |
227
+ | `python/clean.py` | folios, running heads, line joining |
228
+ | `python/assemble.py` | the merge pass, the candidate list, and the level overrides |
229
+
230
+ ### The per-page pass
231
+
232
+ For each page the worker renders the page, recognizes it, and turns the engine's boxes into reading-order lines. It reports the page's text and its geometry on a page event; this module writes the text to `page-NNNNN.txt` and accumulates the geometry. The page's pixels are dropped before the next render. A page split by a vertical gutter is read column by column; every other page is read top to bottom, with boxes that share a row joined left to right.
233
+
234
+ The page files carry five digits, so their order on disk is their order in the document past the 9999th page.
235
+
236
+ ### Page order and identity
237
+
238
+ The page selection is resolved to the explicit list of page numbers it names — `"1-5,8"` is 1, 2, 3, 4, 5, 8, and nothing else — and the worker emits pages in that ascending order with the source's own page number as the index. The merge looks each page's lines up by that number rather than by position, so a selection that does not start at page 1 merges like any other.
239
+
240
+ This module validates what the worker reports: a page number must be a positive integer, strictly greater than the one before it, and within the document's own page count. A violation aborts the call with a message naming the page, rather than producing a document with pages out of order or missing.
241
+
242
+ ### The merge pass
243
+
244
+ When the last page is done the worker merges its records in one pass.
245
+
246
+ - **Outline.** Heading levels come from the trailing outline number (`1.1`, `第 3 章`, `三、`), from a named section (`摘要`, `References`), or from glyph height measured against the document's own body size.
247
+ - **Furniture.** Folios and repeated edge lines are removed.
248
+ - **Whitespace.** Blank runs collapse, spaces that exist only because of a line box are removed between CJK characters, and a Latin word split by a hyphen at a line break is rejoined.
249
+ - **Page breaks.** A paragraph cut in half by a page break is rejoined when the page's first kept line continues it.
250
+
251
+ ### The outline pass
252
+
253
+ The merge also collects the lines a reader might call headings, and writes them to the outline file as candidates:
254
+
255
+ | Field | Meaning |
256
+ |---|---|
257
+ | `id` | `h1`, `h2`, … in document order |
258
+ | `page` | The page the line came from |
259
+ | `text` | The line as the document renders it |
260
+ | `level` | The level the document used: the correction when one was applied, else the inferred level, or `null` for body text |
261
+ | `inferred` | The level the merge inferred, before any correction |
262
+ | `ratio` | The line's glyph height over the document's body height |
263
+ | `numbered` | The section number the line opens with, or `null` |
264
+
265
+ A line becomes a candidate when the merge would call it a heading, when it opens with a section number, when it is taller than `outlineCandidateRatio` of the body height, or when it names a known section. Those last two are what put *body* lines in the list as well: a line the merge left alone is exactly the kind of line a reader can see is not a heading, and the model needs to be able to say so.
266
+
267
+ The model answers with `levels`, a compact list of `h<id>=<level>`. `1` to `6` makes that line a heading at that depth; `0` leaves it as body text, which is how a line the recognition promoted is demoted again. A second `ocr` call carrying `levels` reads the stored geometry, re-runs the same merge with those levels applied, and rewrites the Markdown. It does not render or recognize anything, so it costs the merge rather than the document — three pages answered in about a second where the recognition took thirty.
268
+
269
+ The corrections become part of the record, and the outline keeps both the applied level and the inferred one. Recognizing the same source again therefore reproduces the corrected document at the path it already owns rather than writing an uncorrected one beside it, and a caller re-reading the outline can see that its correction took.
270
+
271
+ ### Export shape
272
+
273
+ The entry module named-exports `name`, `inject`, `Config`, and `apply` and carries no default export. `Config` applies the documented defaults and requires every bound the deployment owns. The entry re-exports the pure core its suites drive — the request planning, the artifact digest and naming, the correction parser, the finished-document reader, the result renderer, and the line framing — and the orchestration and artifact-writing modules behind `recognize`, so the model-facing surface and the testable core are both reachable from the one `.` export.
274
+
275
+ -----
276
+
277
+ <a id="model-experience"></a>
278
+ ## Model Experience
279
+
280
+ ### System-prompt section
281
+
282
+ #### What the model sees
283
+
284
+ `tool:ocr` at order 2975, after the built-in tool band. It says when to reach for the tool, that the call saves a document and an outline file, that the heading levels can be corrected by calling again with `levels`, and that the document is what to pass to `present`. The text is empty wherever `ocr` is not visible in that scope.
285
+
286
+ #### Token effect
287
+
288
+ Three sentences, constant — 438 characters measured.
289
+
290
+ #### KV Cache effect
291
+
292
+ None; the section is a function of tool visibility, not of the conversation.
293
+
294
+ ### Tool schema
295
+
296
+ #### What the model sees
297
+
298
+ Five parameters: `path`, `paths`, `pages`, `outputDir`, and `levels`. `path` and `paths` are alternatives.
299
+
300
+ #### Token effect
301
+
302
+ Roughly constant — 1275 characters of JSON Schema measured on a booted row, of which the parameters are 953 and the description 277. The plugin's whole fixed per-request cost is 1713 characters against 1561 before the outline pass existed: the correction step and the `present` hand-off cost about ten percent more per request.
303
+
304
+ #### KV Cache effect
305
+
306
+ None. The schema does not vary with the conversation.
307
+
308
+ ### Tool-call history and result
309
+
310
+ #### What the model sees
311
+
312
+ The call as made, then one block per document (source path, saved path, page counts, line and heading counts, character count, what was removed, and the outline file), followed by one block per failure. A correction reports what it corrected instead.
313
+
314
+ #### Token effect
315
+
316
+ Proportional to the number of documents and failures, not to document size: the file path is reported instead of its contents, and the outline is a file rather than a list in the result. A three-page document costs 341 characters of result. The recognized text enters the conversation only if the model subsequently reads the Markdown it was told about.
317
+
318
+ #### KV Cache effect
319
+
320
+ None on its own. The result enters the transcript on the following turn like any other tool result.
321
+
322
+ -----
323
+
324
+ <a id="evidence"></a>
325
+ ## Evidence
326
+
327
+ | File | Proves |
328
+ |---|---|
329
+ | `tests/index.test.mjs` | Request planning, page validation, the artifact layout, the correction pass, every rejection, the deployment bounds that have no default, and the heuristics that keep one |
330
+ | `tests/transport.test.mjs` | A real child process: page events written to the right files, an out-of-order page aborting the call, a silent worker, the time ceiling, and one failed document leaving the rest of the batch intact |
331
+ | `tests/filename.test.mjs` | The stem rules, the digest name, and the five-digit page file |
332
+ | `tests/render.test.mjs` | The model-facing result of a recognition, a correction, a failure, and a truncated outline |
333
+ | `tests/presentation.test.mjs` | The presentation metadata, and that no result text carries deployment vocabulary |
334
+ | `tests/load-path.test.mjs` | The module form through the real Loader: no default export, `inject` kept |
335
+ | `tests/loader-composition.test.mjs` | A real `cordis.yml` boots, the row's bounds are live, an omitted bound fails the load, the section reaches the assembled prompt, and a call dispatched through the registered tool drives the worker and writes every artifact through the mounted capability |
336
+ | `tests/hmr-safety.test.mjs` | The registrations leave with their contributing fiber |
337
+ | `tests/sandbox-write.test.mjs` | The artifact writes under a real `workspace-write` backend: the fence is required, lands the document inside the session workspace, refuses a path outside it, restates the refusal, and leaves a non-denial alone |
338
+ | `tests/conventions.test.mjs` | The source rules the skill scaffolds a package with |
339
+ | `tests/deployment.mjs` | The bounds a suite configures the tool with, so no suite inherits one from the package |
340
+ | `python/tests/` | The pure layout, cleaning, merge, page-numbering, candidate, and override rules — 89 cases, no third-party import |
341
+
342
+ ## `./invariant`
343
+
344
+ The package publishes none. An invariant companion is warranted only when independent observations of one owned relation can diverge; here the merge is a pure function of the page records one process produced, and the artifact layout is derived from a digest of its own inputs. The worker's and the plugin's suites cover those relations instead.
345
+
346
+ ## Known Limitations and Deferred Work
347
+
348
+ - **The row must state every bound the deployment owns.** There is no fallback: a profile that mounts this tool without `dpi`, the pixel, page, file, outline, and time ceilings, the engine lifetime, and the per-page policy fails to load. That is deliberate — a hidden default would decide how much memory and time this machine spends without anyone having agreed to it — but it does mean a copied patch row from an earlier release needs those values added.
349
+ - **A read-only session saves nothing.** Every write goes through the sandboxed filesystem capability and is stamped with the calling session's per-call policy, so the call fails with the standard denial marker when the session forbids writing, and a write whose path falls outside the session workspace is refused by name.
350
+ - **A composition that mounts a confining filesystem without `ctx.sandboxPolicy` is refused at the first call.** The tool borrows both capabilities rather than declaring them, so the row itself still mounts; the misconfiguration surfaces as one readable error on the first call instead of as artifacts refused one page at a time.
351
+ - **The outline pass is one model turn.** A document whose structure the model gets wrong needs a second `levels` call. A correction replaces the levels it names and leaves the rest as the recognition inferred them, and the worker reports the inferred level alongside the applied one, so a correction can be revised rather than only repeated.
352
+ - **The candidate list is capped.** Past `maxOutlineCandidates` the remaining candidates are dropped and the result says the outline is incomplete.
353
+ - **Nothing here reads a PDF's embedded text layer.** Every page is rendered and recognized, which is what makes a scan work and what costs time on a text PDF.
354
+ - **Recognition quality is the engine's.** Small type, dense tables, handwriting, and mathematical notation are recognized poorly or not at all.
355
+ - **Tables are not reconstructed.** Cells on one row become one line, so a table is emitted as prose.
356
+ - **Column detection finds one gutter.** A page with three columns, or with full-width headings between columns, is read as two.
357
+ - **A running head needs repetition to be recognized.** A document shorter than `runningHeadMinPages`, or a page selection covering fewer pages, keeps its running head as body text.
358
+ - **The tool needs a local filesystem.** Its renderer opens the source by path, so a remote filesystem backend cannot serve it.
359
+ - **The engine's first use loads its models**, so expect a few seconds before the first page is recognized; `--prefetch` moves that cost to installation.
360
+ - **The environment is large** — about 260 MB, of which 30 MB is the model weights — and lives inside the package directory at `python/.venv`.
361
+
362
+ -----
363
+
364
+ <a id="dev-note"></a>
365
+ ## Dev Note
366
+
367
+ Built with the plugin-development skill's scaffolder, `node <checkout>/.dsh/skills/dsh-plugin-development/scripts/src/index.mjs ocr --tool --plugins-root .`, which wrote the package skeleton, the profile patch row, and the profile link dependency in one run.
368
+
369
+ ```sh
370
+ pnpm build # tsc -p tsconfig.json && tsdown
371
+ pnpm test # node --test over the built output
372
+ pnpm run setup # create python/.venv and verify it
373
+ pnpm test:python # the pure layout, cleaning, and merge rules
374
+ ```
375
+
376
+ `pnpm test` runs against `lib/`, so build first. The Python suite has no third-party dependency and runs under any Python 3; it does not need the environment `pnpm run setup` creates.
377
+
378
+ The two halves are specified by one wire protocol, documented in [python/README.md](python/README.md). The host validates what the worker reports rather than trusting it, and every field the host reads is emitted by a test fixture as well as by the real worker.
@@ -0,0 +1,7 @@
1
+ # Bundle patch for dsh-ab-ocr: contributes the ocr plugin row.
2
+ # Installed via `dsh plugin --profile <name> add <dir>`; the row references this
3
+ # package by name so Node resolution finds the built code. The browser half is
4
+ # discovered by the Web plugin table through the manifest dsh.client declaration.
5
+ - insert:
6
+ - id: ocr
7
+ name: 'dsh-ab-ocr'
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Where one source document's artifacts live, and the content digest that names
3
+ * the recognition they belong to.
4
+ *
5
+ * A source named `<stem>` under a base directory `<base>` keeps its Markdown at
6
+ * `<base>/<stem>.md`, its records under `<base>/<pageDirName>/<stem>/`, and one
7
+ * revision of those records under that page root's `<digest>` directory. The
8
+ * digest covers everything a recognition is: the source path and content, the page
9
+ * selection, and the merge options. The same digest therefore names the same record
10
+ * on a repeat call, so the record directory is found without scanning the page root
11
+ * — and a source whose content changed is a different recognition even when its
12
+ * length did not change with it.
13
+ * @module @deepseek-ai/dsh-ab-ocr/artifacts
14
+ */
15
+ import type { MergeOptions } from './types.ts';
16
+ /** File name of the pointer to a document's newest recognition. */
17
+ export declare const LATEST_NAME = "latest.json";
18
+ /** File name of a recognition's record. */
19
+ export declare const RECORD_NAME = "record.json";
20
+ /** File name of a recognition's heading outline. */
21
+ export declare const OUTLINE_NAME = "outline.json";
22
+ /**
23
+ * What the digest covers about the source file, as far as the filesystem
24
+ * capability reported it. A backend that reports no metadata leaves `size` at
25
+ * the empty string and `mtimeMs` at 0, which the record marks as unavailable.
26
+ */
27
+ export interface SourceStamp {
28
+ /** Byte size, or the empty string when the filesystem reported none. */
29
+ size: number | '';
30
+ /** Modification time in milliseconds, or 0 when the filesystem reported none. */
31
+ mtimeMs: number;
32
+ /** Whether the filesystem reported nothing at all for the source. */
33
+ unavailable: boolean;
34
+ }
35
+ /** The paths of one source document that do not depend on a recognition. */
36
+ export interface ArtifactRoot {
37
+ /** Directory the Markdown document and the page directory sit under. */
38
+ base: string;
39
+ /** The source's base name with its spaces removed. */
40
+ stem: string;
41
+ /** The preferred Markdown path, `<base>/<stem>.md`. */
42
+ document: string;
43
+ /** The document's page directory, `<base>/<pageDirName>/<stem>`. */
44
+ pageRoot: string;
45
+ /** The pointer to the newest recognition, `latest.json` under the page root. */
46
+ latest: string;
47
+ }
48
+ /** Every path one recognition's artifacts are written to. */
49
+ export interface ArtifactPaths extends ArtifactRoot {
50
+ /** Content digest of the recognition, 12 hex characters. */
51
+ digest: string;
52
+ /** The Markdown path of this digest, `<base>/<stem>.<digest>.md`. */
53
+ digestedDocument: string;
54
+ /** The revision's directory: its page files, record, and outline. */
55
+ recordDir: string;
56
+ /** The recognition's record. */
57
+ record: string;
58
+ /** The recognition's heading outline. */
59
+ outline: string;
60
+ }
61
+ /**
62
+ * Resolve the base directory one source document's artifacts sit under.
63
+ * @param sourcePath - the absolute path the model asked to recognize.
64
+ * @param outputDir - the call's output directory, or an empty string for the source's own.
65
+ * @returns the base directory.
66
+ */
67
+ export declare function artifactBase(sourcePath: string, outputDir: string): string;
68
+ /**
69
+ * Resolve the paths of one source document that do not depend on a recognition.
70
+ * @param sourcePath - the absolute path the model asked to recognize.
71
+ * @param base - the directory the document's artifacts sit under.
72
+ * @param pageDirName - the page directory's name under the base directory.
73
+ * @returns the document's stable artifact paths.
74
+ */
75
+ export declare function artifactRoot(sourcePath: string, base: string, pageDirName: string): ArtifactRoot;
76
+ /**
77
+ * Resolve every path one recognition's artifacts are written to.
78
+ * @param sourcePath - the absolute path the model asked to recognize.
79
+ * @param base - the directory the document's artifacts sit under.
80
+ * @param pageDirName - the page directory's name under the base directory.
81
+ * @param digest - the recognition's content digest.
82
+ * @returns the document's artifact paths, including this recognition's revision.
83
+ */
84
+ export declare function artifactPaths(sourcePath: string, base: string, pageDirName: string, digest: string): ArtifactPaths;
85
+ /**
86
+ * Identify one recognition from the values that decided its text.
87
+ *
88
+ * The digest follows the source's content, not merely its length: a source edited
89
+ * without changing size is a different recognition, and an unchanged one is the
90
+ * same recognition however often it is read.
91
+ * @param sourcePath - the absolute path the model asked to recognize.
92
+ * @param stamp - the source metadata the digest covers alongside its content.
93
+ * @param sourceDigest - digest of the source bytes, or an empty string when the
94
+ * worker did not report one.
95
+ * @param pageSelection - the call's page selection, or null for the whole document.
96
+ * @param merge - the merge options the call sends.
97
+ * @returns the first 12 hex characters of the digest.
98
+ */
99
+ export declare function digest12(sourcePath: string, stamp: SourceStamp, sourceDigest: string, pageSelection: string | null, merge: MergeOptions): string;
100
+ //# sourceMappingURL=artifacts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"artifacts.d.ts","sourceRoot":"","sources":["../src/artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAE9C,mEAAmE;AACnE,eAAO,MAAM,WAAW,gBAAgB,CAAA;AAExC,2CAA2C;AAC3C,eAAO,MAAM,WAAW,gBAAgB,CAAA;AAExC,oDAAoD;AACpD,eAAO,MAAM,YAAY,iBAAiB,CAAA;AAK1C;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,wEAAwE;IACxE,IAAI,EAAE,MAAM,GAAG,EAAE,CAAA;IACjB,iFAAiF;IACjF,OAAO,EAAE,MAAM,CAAA;IACf,qEAAqE;IACrE,WAAW,EAAE,OAAO,CAAA;CACrB;AAED,4EAA4E;AAC5E,MAAM,WAAW,YAAY;IAC3B,wEAAwE;IACxE,IAAI,EAAE,MAAM,CAAA;IACZ,sDAAsD;IACtD,IAAI,EAAE,MAAM,CAAA;IACZ,uDAAuD;IACvD,QAAQ,EAAE,MAAM,CAAA;IAChB,oEAAoE;IACpE,QAAQ,EAAE,MAAM,CAAA;IAChB,gFAAgF;IAChF,MAAM,EAAE,MAAM,CAAA;CACf;AAED,6DAA6D;AAC7D,MAAM,WAAW,aAAc,SAAQ,YAAY;IACjD,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,gBAAgB,EAAE,MAAM,CAAA;IACxB,qEAAqE;IACrE,SAAS,EAAE,MAAM,CAAA;IACjB,gCAAgC;IAChC,MAAM,EAAE,MAAM,CAAA;IACd,yCAAyC;IACzC,OAAO,EAAE,MAAM,CAAA;CAChB;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAE1E;AAED;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,YAAY,CAUhG;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAC3B,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,GACb,aAAa,CAWf;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,QAAQ,CACtB,UAAU,EAAE,MAAM,EAClB,KAAK,EAAE,WAAW,EAClB,YAAY,EAAE,MAAM,EACpB,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,KAAK,EAAE,YAAY,GAClB,MAAM,CASR"}
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Where one source document's artifacts live, and the content digest that names
3
+ * the recognition they belong to.
4
+ *
5
+ * A source named `<stem>` under a base directory `<base>` keeps its Markdown at
6
+ * `<base>/<stem>.md`, its records under `<base>/<pageDirName>/<stem>/`, and one
7
+ * revision of those records under that page root's `<digest>` directory. The
8
+ * digest covers everything a recognition is: the source path and content, the page
9
+ * selection, and the merge options. The same digest therefore names the same record
10
+ * on a repeat call, so the record directory is found without scanning the page root
11
+ * — and a source whose content changed is a different recognition even when its
12
+ * length did not change with it.
13
+ * @module @deepseek-ai/dsh-ab-ocr/artifacts
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { dirname, join } from 'node:path';
17
+ import { markdownDigestFileName, markdownFileName, markdownStem } from "./filename.js";
18
+ /** File name of the pointer to a document's newest recognition. */
19
+ export const LATEST_NAME = 'latest.json';
20
+ /** File name of a recognition's record. */
21
+ export const RECORD_NAME = 'record.json';
22
+ /** File name of a recognition's heading outline. */
23
+ export const OUTLINE_NAME = 'outline.json';
24
+ /** Characters of the digest that name a record directory. */
25
+ const DIGEST_LENGTH = 12;
26
+ /**
27
+ * Resolve the base directory one source document's artifacts sit under.
28
+ * @param sourcePath - the absolute path the model asked to recognize.
29
+ * @param outputDir - the call's output directory, or an empty string for the source's own.
30
+ * @returns the base directory.
31
+ */
32
+ export function artifactBase(sourcePath, outputDir) {
33
+ return outputDir === '' ? dirname(sourcePath) : outputDir;
34
+ }
35
+ /**
36
+ * Resolve the paths of one source document that do not depend on a recognition.
37
+ * @param sourcePath - the absolute path the model asked to recognize.
38
+ * @param base - the directory the document's artifacts sit under.
39
+ * @param pageDirName - the page directory's name under the base directory.
40
+ * @returns the document's stable artifact paths.
41
+ */
42
+ export function artifactRoot(sourcePath, base, pageDirName) {
43
+ const stem = markdownStem(sourcePath);
44
+ const pageRoot = join(base, pageDirName, stem);
45
+ return {
46
+ base,
47
+ stem,
48
+ document: join(base, markdownFileName(sourcePath)),
49
+ pageRoot,
50
+ latest: join(pageRoot, LATEST_NAME),
51
+ };
52
+ }
53
+ /**
54
+ * Resolve every path one recognition's artifacts are written to.
55
+ * @param sourcePath - the absolute path the model asked to recognize.
56
+ * @param base - the directory the document's artifacts sit under.
57
+ * @param pageDirName - the page directory's name under the base directory.
58
+ * @param digest - the recognition's content digest.
59
+ * @returns the document's artifact paths, including this recognition's revision.
60
+ */
61
+ export function artifactPaths(sourcePath, base, pageDirName, digest) {
62
+ const root = artifactRoot(sourcePath, base, pageDirName);
63
+ const recordDir = join(root.pageRoot, digest);
64
+ return {
65
+ ...root,
66
+ digest,
67
+ digestedDocument: join(base, markdownDigestFileName(sourcePath, digest)),
68
+ recordDir,
69
+ record: join(recordDir, RECORD_NAME),
70
+ outline: join(recordDir, OUTLINE_NAME),
71
+ };
72
+ }
73
+ /**
74
+ * Identify one recognition from the values that decided its text.
75
+ *
76
+ * The digest follows the source's content, not merely its length: a source edited
77
+ * without changing size is a different recognition, and an unchanged one is the
78
+ * same recognition however often it is read.
79
+ * @param sourcePath - the absolute path the model asked to recognize.
80
+ * @param stamp - the source metadata the digest covers alongside its content.
81
+ * @param sourceDigest - digest of the source bytes, or an empty string when the
82
+ * worker did not report one.
83
+ * @param pageSelection - the call's page selection, or null for the whole document.
84
+ * @param merge - the merge options the call sends.
85
+ * @returns the first 12 hex characters of the digest.
86
+ */
87
+ export function digest12(sourcePath, stamp, sourceDigest, pageSelection, merge) {
88
+ const material = JSON.stringify([
89
+ sourcePath,
90
+ sourceDigest === '' ? stamp.size : sourceDigest,
91
+ stamp.mtimeMs,
92
+ pageSelection,
93
+ merge,
94
+ ]);
95
+ return createHash('sha256').update(material, 'utf8').digest('hex').slice(0, DIGEST_LENGTH);
96
+ }
97
+ //# sourceMappingURL=artifacts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"artifacts.js","sourceRoot":"","sources":["../src/artifacts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AACxC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAA;AACzC,OAAO,EAAE,sBAAsB,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,eAAe,CAAA;AAGtF,mEAAmE;AACnE,MAAM,CAAC,MAAM,WAAW,GAAG,aAAa,CAAA;AAExC,2CAA2C;AAC3C,MAAM,CAAC,MAAM,WAAW,GAAG,aAAa,CAAA;AAExC,oDAAoD;AACpD,MAAM,CAAC,MAAM,YAAY,GAAG,cAAc,CAAA;AAE1C,6DAA6D;AAC7D,MAAM,aAAa,GAAG,EAAE,CAAA;AA4CxB;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAAC,UAAkB,EAAE,SAAiB;IAChE,OAAO,SAAS,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;AAC3D,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,UAAkB,EAAE,IAAY,EAAE,WAAmB;IAChF,MAAM,IAAI,GAAG,YAAY,CAAC,UAAU,CAAC,CAAA;IACrC,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,CAAC,CAAA;IAC9C,OAAO;QACL,IAAI;QACJ,IAAI;QACJ,QAAQ,EAAE,IAAI,CAAC,IAAI,EAAE,gBAAgB,CAAC,UAAU,CAAC,CAAC;QAClD,QAAQ;QACR,MAAM,EAAE,IAAI,CAAC,QAAQ,EAAE,WAAW,CAAC;KACpC,CAAA;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAC3B,UAAkB,EAClB,IAAY,EACZ,WAAmB,EACnB,MAAc;IAEd,MAAM,IAAI,GAAG,YAAY,CAAC,UAAU,EAAE,IAAI,EAAE,WAAW,CAAC,CAAA;IACxD,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAA;IAC7C,OAAO;QACL,GAAG,IAAI;QACP,MAAM;QACN,gBAAgB,EAAE,IAAI,CAAC,IAAI,EAAE,sBAAsB,CAAC,UAAU,EAAE,MAAM,CAAC,CAAC;QACxE,SAAS;QACT,MAAM,EAAE,IAAI,CAAC,SAAS,EAAE,WAAW,CAAC;QACpC,OAAO,EAAE,IAAI,CAAC,SAAS,EAAE,YAAY,CAAC;KACvC,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,QAAQ,CACtB,UAAkB,EAClB,KAAkB,EAClB,YAAoB,EACpB,aAA4B,EAC5B,KAAmB;IAEnB,MAAM,QAAQ,GAAG,IAAI,CAAC,SAAS,CAAC;QAC9B,UAAU;QACV,YAAY,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,YAAY;QAC/C,KAAK,CAAC,OAAO;QACb,aAAa;QACb,KAAK;KACN,CAAC,CAAA;IACF,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,aAAa,CAAC,CAAA;AAC5F,CAAC"}