@jini-ai/vibecoding 0.6.10 → 0.6.11
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/CHANGELOG.md +35 -0
- package/README.md +51 -7
- package/dist/core/history.d.ts +2 -0
- package/dist/core/history.js +1 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/project.d.ts +96 -0
- package/dist/core/project.js +10 -0
- package/dist/html/edit.d.ts +116 -0
- package/dist/html/edit.js +103 -0
- package/dist/html/index.d.ts +2 -0
- package/dist/html/index.js +1 -0
- package/dist/html/node/index.d.ts +2 -1
- package/dist/html/node/index.js +1 -1
- package/dist/html/node/parse5-region-parser.d.ts +13 -1
- package/dist/html/node/parse5-region-parser.js +164 -76
- package/dist/html/regions.d.ts +0 -53
- package/dist/html/regions.js +55 -1
- package/dist/node/index.d.ts +6 -0
- package/dist/node/index.js +4 -0
- package/dist/node/paths.d.ts +50 -0
- package/dist/node/paths.js +91 -0
- package/dist/node/project.d.ts +19 -0
- package/dist/node/project.js +367 -0
- package/dist/node/store.d.ts +102 -0
- package/dist/node/store.js +478 -0
- package/dist/react/session.d.ts +2 -107
- package/dist/react/session.js +2 -154
- package/dist/react/tools.d.ts +2 -90
- package/dist/react/tools.js +2 -245
- package/dist/session.d.ts +116 -0
- package/dist/session.js +179 -0
- package/dist/tools.d.ts +91 -0
- package/dist/tools.js +246 -0
- package/package.json +17 -4
|
@@ -45,10 +45,11 @@
|
|
|
45
45
|
* source text's occurrence count against what the tree walk actually found, independent of
|
|
46
46
|
* parse5's own error stream.
|
|
47
47
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
48
|
+
* `scanHtmlRegions` retains every tree-discovered handle, recording unsafe boundaries on that
|
|
49
|
+
* region and dropped-source handles as a document diagnostic. Reads can still inspect the page
|
|
50
|
+
* and edit another safely delimited region. The strict `findRegions` throws on either problem
|
|
51
|
+
* (never returns a silently-shortened list), and `checkWellFormed` reports the same problem through
|
|
52
|
+
* the port's ordinary rejection channel so `regions.ts`'s `validate` can surface it before writing.
|
|
52
53
|
*
|
|
53
54
|
* ## `checkWellFormed` is a courtesy diagnostic, not the safety mechanism
|
|
54
55
|
*
|
|
@@ -87,69 +88,155 @@ const VOID_ELEMENTS = new Set([
|
|
|
87
88
|
/** Matches `data-agent-element` as an attribute *name* only — deliberately not the value or its
|
|
88
89
|
* quoting style, so it survives single-quoted, double-quoted and unquoted attribute forms alike.
|
|
89
90
|
*
|
|
90
|
-
* This
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* false positive is a safe failure mode (the model is asked to retry), whereas the false negative
|
|
94
|
-
* this check exists to catch — a genuinely dropped region — is not.
|
|
91
|
+
* This remains a conservative source cross-check for genuinely dropped regions. Comments and
|
|
92
|
+
* raw-text bodies are blanked using parse5 locations first: a script building a handle string
|
|
93
|
+
* is not a region. Treating those examples as failures would break otherwise valid page reads.
|
|
95
94
|
* See docs/decisions/DR-001-conservative-region-detection.md.
|
|
96
95
|
*/
|
|
97
96
|
const RAW_HANDLE_ATTRIBUTE_RE = new RegExp(`\\b${AGENT_ELEMENT_ATTRIBUTE}\\s*=`, "gi");
|
|
98
97
|
function attributeValue(element, name) {
|
|
99
98
|
return element.attrs.find((a) => a.name === name)?.value;
|
|
100
99
|
}
|
|
101
|
-
/**
|
|
102
|
-
function*
|
|
103
|
-
|
|
100
|
+
/** Visit all nodes, including comments/raw text and nested template contents. */
|
|
101
|
+
function* walkNodes(root) {
|
|
102
|
+
const pending = [root];
|
|
103
|
+
// An explicit stack avoids overflowing the JS call stack on deeply nested author markup.
|
|
104
|
+
while (pending.length > 0) {
|
|
105
|
+
const node = pending.pop(); // Nonempty stack on this branch.
|
|
104
106
|
yield node;
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
const children = "content" in node ? node.content.childNodes : "childNodes" in node ? node.childNodes : undefined;
|
|
108
|
+
if (children === undefined)
|
|
109
|
+
continue;
|
|
110
|
+
for (let index = children.length - 1; index >= 0; index -= 1)
|
|
111
|
+
pending.push(children[index]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// RCDATA bodies also contain literal text rather than child markup. Keep their source examples
|
|
115
|
+
// out of the dropped-region check just like raw-text bodies; opening-tag attributes still count.
|
|
116
|
+
// `noscript` is raw text too: parse5 parses with scripting enabled, as a browser running the page does.
|
|
117
|
+
const TEXT_BODY_ELEMENTS = new Set([
|
|
118
|
+
"script", "style", "xmp", "iframe", "noembed", "noframes", "noscript", "plaintext", "textarea", "title",
|
|
119
|
+
]);
|
|
120
|
+
/** Whether a located node is a comment or the literal text body of a raw-text/RCDATA element. */
|
|
121
|
+
function isNonMarkup(node) {
|
|
122
|
+
if (node.nodeName === "#comment")
|
|
123
|
+
return true;
|
|
124
|
+
if (node.nodeName !== "#text" || !("parentNode" in node))
|
|
125
|
+
return false;
|
|
126
|
+
const parent = node.parentNode;
|
|
127
|
+
return parent !== null && "tagName" in parent && TEXT_BODY_ELEMENTS.has(parent.tagName);
|
|
110
128
|
}
|
|
111
129
|
/** A parse-error entry, phrased for the model rather than left as parse5's bare kebab-case code. */
|
|
112
130
|
function describeParseError(error) {
|
|
113
131
|
return `${error.code.replace(/-/g, " ")} near character ${error.startOffset}`;
|
|
114
132
|
}
|
|
115
|
-
|
|
133
|
+
/** Why a tagged element's boundaries are unsafe; optional closing tags are unsafe too. */
|
|
134
|
+
function regionProblem(element, handle) {
|
|
116
135
|
const location = element.sourceCodeLocation;
|
|
117
136
|
if (location === null || location === undefined || location.startTag === undefined) {
|
|
118
|
-
return {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"tag used outside a <table>).",
|
|
123
|
-
};
|
|
137
|
+
return `a "${AGENT_ELEMENT_ATTRIBUTE}" region ("${handle}") could not be located in the document — ` +
|
|
138
|
+
"the parser's error-recovery rules likely dropped or repositioned it. Rewrite the document " +
|
|
139
|
+
"so this element is reachable in ordinary document structure (for example, not a table-only " +
|
|
140
|
+
"tag used outside a <table>).";
|
|
124
141
|
}
|
|
125
142
|
if (location.endTag === undefined) {
|
|
126
|
-
return
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
"locate — its content boundaries cannot be determined safely. Close the tag explicitly.",
|
|
132
|
-
};
|
|
143
|
+
return VOID_ELEMENTS.has(element.tagName)
|
|
144
|
+
? `"${handle}" tags a <${element.tagName}> element, which is void and has no inner content — ` +
|
|
145
|
+
`void elements cannot be a ${AGENT_ELEMENT_ATTRIBUTE} region`
|
|
146
|
+
: `a "${AGENT_ELEMENT_ATTRIBUTE}" region ("${handle}") has no closing tag the parser could ` +
|
|
147
|
+
"locate — its content boundaries cannot be determined safely. Close the tag explicitly.";
|
|
133
148
|
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
/** Original-source offsets shared by region discovery and top-level tagging advice. */
|
|
152
|
+
function sourceOffsets(element) {
|
|
153
|
+
const location = element.sourceCodeLocation;
|
|
154
|
+
if (!location?.startTag)
|
|
155
|
+
return { start: -1, end: -1, innerStart: -1, innerEnd: -1 };
|
|
156
|
+
return {
|
|
157
|
+
start: location.startTag.startOffset,
|
|
158
|
+
end: location.endTag?.endOffset ?? location.endOffset,
|
|
159
|
+
innerStart: location.startTag.endOffset,
|
|
160
|
+
innerEnd: location.endTag?.startOffset ?? location.endOffset,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/** Keep unsafe regions discoverable; sentinel offsets are never used by locateRegion for edits. */
|
|
164
|
+
function analyzeRegion(element, handle) {
|
|
134
165
|
const role = attributeValue(element, "data-agent-role");
|
|
135
166
|
const label = attributeValue(element, "data-agent-label");
|
|
167
|
+
const problem = regionProblem(element, handle);
|
|
136
168
|
return {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
},
|
|
169
|
+
handle,
|
|
170
|
+
tag: element.tagName,
|
|
171
|
+
...sourceOffsets(element),
|
|
172
|
+
...(role === undefined ? {} : { role }),
|
|
173
|
+
...(label === undefined ? {} : { label }),
|
|
174
|
+
...(problem === undefined ? {} : { problem }),
|
|
144
175
|
};
|
|
145
176
|
}
|
|
177
|
+
/** Direct fragment children in source order; implied closes suffice for tagging advice, not edits. */
|
|
178
|
+
function topLevelElements(fragment) {
|
|
179
|
+
const elements = [];
|
|
180
|
+
for (const child of fragment.childNodes) {
|
|
181
|
+
if (!("tagName" in child))
|
|
182
|
+
continue;
|
|
183
|
+
const location = child.sourceCodeLocation;
|
|
184
|
+
if (!location?.startTag)
|
|
185
|
+
continue; // Implicit wrappers have no authored open tag to annotate.
|
|
186
|
+
const handle = attributeValue(child, AGENT_ELEMENT_ATTRIBUTE);
|
|
187
|
+
elements.push({
|
|
188
|
+
tag: child.tagName,
|
|
189
|
+
...(handle === undefined ? {} : { handle }),
|
|
190
|
+
...sourceOffsets(child),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return elements.sort((a, b) => a.start - b.start);
|
|
194
|
+
}
|
|
146
195
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
196
|
+
* Report each authored tag once. parse5 copies an unclosed or misnested formatting element's
|
|
197
|
+
* start-tag location onto every clone it reconstructs (`<p><b data-agent-element="x">a</p><p>b`
|
|
198
|
+
* yields two `b`s starting at the same offset), and a clone can even pick up a later `</b>` that
|
|
199
|
+
* does not delimit the authored element. Counting clones would make one handle "ambiguous", and an
|
|
200
|
+
* unrelated edit that adds text after it would then look like it newly duplicated that handle.
|
|
201
|
+
* Input must be sorted by `start`; the stable sort keeps the authored element (first in tree order).
|
|
151
202
|
*/
|
|
152
|
-
function
|
|
203
|
+
function collapseClones(sorted) {
|
|
204
|
+
const out = [];
|
|
205
|
+
for (const region of sorted) {
|
|
206
|
+
const previous = out[out.length - 1];
|
|
207
|
+
if (previous === undefined || region.start < 0 || previous.start !== region.start) {
|
|
208
|
+
out.push(region);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
out[out.length - 1] = {
|
|
212
|
+
...previous,
|
|
213
|
+
problem: previous.problem ?? `"${region.handle}" tags an element the parser had to split while ` +
|
|
214
|
+
"recovering misnested or unclosed tags — its content boundaries cannot be determined safely. " +
|
|
215
|
+
"Close the tag explicitly, inside the element that contains it.",
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
/** Cross-check only markup-bearing source; recovered/dropped tokens may have no tree node. */
|
|
221
|
+
function droppedRegionProblem(masked, regionCount) {
|
|
222
|
+
const rawMentions = masked.match(RAW_HANDLE_ATTRIBUTE_RE)?.length ?? 0;
|
|
223
|
+
if (rawMentions <= regionCount)
|
|
224
|
+
return undefined;
|
|
225
|
+
return `the source mentions ${AGENT_ELEMENT_ATTRIBUTE} ${rawMentions} time(s) but only ` +
|
|
226
|
+
`${regionCount} could be located in the parsed document — the parser's error-recovery ` +
|
|
227
|
+
"rules likely dropped one entirely (a common cause: a table-only tag, such as <td>, used " +
|
|
228
|
+
"outside a <table>). Rewrite the document so every tagged element sits in ordinary, " +
|
|
229
|
+
"non-table-recovery document structure.";
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Scan once without throwing for malformed markup: unsafe regions carry their own problem, while
|
|
233
|
+
* unaccounted-for source handles produce documentProblem. Every offset indexes the untouched input.
|
|
234
|
+
* Comments/raw-text bodies are blanked by parse5's own locations before the raw-mention check.
|
|
235
|
+
* @example const scan = scanHtmlRegions({ html: '<section data-agent-element="hero">Hi</section>' });
|
|
236
|
+
* @complexity One parse and node walk, plus source-order sorting: O(n + r log r) for n source code
|
|
237
|
+
* units and r discovered elements. Masked ranges are disjoint, so blanking is linear in source size.
|
|
238
|
+
*/
|
|
239
|
+
export function scanHtmlRegions({ html: html_ }, _options = {}) {
|
|
153
240
|
const parseErrorReasons = [];
|
|
154
241
|
/** Fresh `<div>` fragment-context element — see this module's doc for why `<div>`, not the default. */
|
|
155
242
|
const context = defaultTreeAdapter.createElement("div", html.NS.HTML, []);
|
|
@@ -157,33 +244,31 @@ function analyzeDocument(html_) {
|
|
|
157
244
|
sourceCodeLocationInfo: true,
|
|
158
245
|
onParseError: (error) => parseErrorReasons.push(describeParseError(error)),
|
|
159
246
|
});
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if ("
|
|
168
|
-
locationProblem = analyzed.locationProblem;
|
|
247
|
+
const found = [];
|
|
248
|
+
// split("") preserves UTF-16 code units, including CRLF, BOM and astral-character offsets.
|
|
249
|
+
const masked = html_.split("");
|
|
250
|
+
for (const node of walkNodes(fragment)) {
|
|
251
|
+
const location = node.sourceCodeLocation;
|
|
252
|
+
if (isNonMarkup(node) && location)
|
|
253
|
+
masked.fill(" ", location.startOffset, location.endOffset);
|
|
254
|
+
if (!("tagName" in node))
|
|
169
255
|
continue;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (locationProblem === undefined) {
|
|
174
|
-
const rawMentions = html_.match(RAW_HANDLE_ATTRIBUTE_RE)?.length ?? 0;
|
|
175
|
-
if (rawMentions > regions.length) {
|
|
176
|
-
locationProblem =
|
|
177
|
-
`the source mentions ${AGENT_ELEMENT_ATTRIBUTE} ${rawMentions} time(s) but only ` +
|
|
178
|
-
`${regions.length} could be located in the parsed document — the parser's error-recovery ` +
|
|
179
|
-
"rules likely dropped one entirely (a common cause: a table-only tag, such as <td>, used " +
|
|
180
|
-
"outside a <table>). Rewrite the document so every tagged element sits in ordinary, " +
|
|
181
|
-
"non-table-recovery document structure.";
|
|
182
|
-
}
|
|
256
|
+
const handle = attributeValue(node, AGENT_ELEMENT_ATTRIBUTE);
|
|
257
|
+
if (handle !== undefined)
|
|
258
|
+
found.push(analyzeRegion(node, handle));
|
|
183
259
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
260
|
+
const regions = collapseClones(found.sort((a, b) => a.start - b.start));
|
|
261
|
+
const documentProblem = droppedRegionProblem(masked.join(""), regions.length);
|
|
262
|
+
return {
|
|
263
|
+
regions,
|
|
264
|
+
topLevel: topLevelElements(fragment),
|
|
265
|
+
parseErrorReasons,
|
|
266
|
+
...(documentProblem === undefined ? {} : { documentProblem }),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/** Preserve strict rejection precedence: an unsafe discovered region before a dropped-source handle. */
|
|
270
|
+
function locationProblem(scan) {
|
|
271
|
+
return scan.regions.find((region) => region.problem !== undefined)?.problem ?? scan.documentProblem;
|
|
187
272
|
}
|
|
188
273
|
/**
|
|
189
274
|
* Build the parse5-backed `HtmlRegionParser`. Stateless — a single instance is safe to share as a
|
|
@@ -191,20 +276,23 @@ function analyzeDocument(html_) {
|
|
|
191
276
|
*
|
|
192
277
|
* @returns a parser ready to hand to `createHtmlRegionTarget`, with `checkWellFormed` always
|
|
193
278
|
* present even though the generic parser port allows implementations to omit it.
|
|
194
|
-
* @complexity 1 (delegates
|
|
279
|
+
* @complexity 1 (delegates to `scanHtmlRegions`)
|
|
195
280
|
*/
|
|
196
281
|
export function createParse5RegionParser() {
|
|
197
282
|
return {
|
|
198
283
|
findRegions({ html: html_ }) {
|
|
199
|
-
const {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
284
|
+
const scan = scanHtmlRegions({ html: html_ });
|
|
285
|
+
const problem = locationProblem(scan);
|
|
286
|
+
if (problem !== undefined)
|
|
287
|
+
throw new Error(problem);
|
|
288
|
+
return scan.regions;
|
|
203
289
|
},
|
|
204
290
|
checkWellFormed({ html: html_ }) {
|
|
205
|
-
const {
|
|
206
|
-
|
|
207
|
-
|
|
291
|
+
const scan = scanHtmlRegions({ html: html_ });
|
|
292
|
+
const problem = locationProblem(scan);
|
|
293
|
+
if (problem !== undefined)
|
|
294
|
+
return { ok: false, reason: problem };
|
|
295
|
+
const parseErrorReasons = scan.parseErrorReasons ?? [];
|
|
208
296
|
if (parseErrorReasons.length > 0) {
|
|
209
297
|
return { ok: false, reason: `the document does not parse cleanly: ${parseErrorReasons.join("; ")}` };
|
|
210
298
|
}
|
package/dist/html/regions.d.ts
CHANGED
|
@@ -1,56 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module html/regions
|
|
3
|
-
*
|
|
4
|
-
* An `EditTarget` whose parts are **tagged regions of one HTML document** — the shape a
|
|
5
|
-
* single-document host needs, as opposed to the file tree a multi-file host edits.
|
|
6
|
-
*
|
|
7
|
-
* This is the target the port was designed against but never had an implementation for, and it is
|
|
8
|
-
* where `validate` stops being a formality: a file cannot corrupt another file's syntax, but a
|
|
9
|
-
* region shares one document with every other region, so one unbalanced tag corrupts everything
|
|
10
|
-
* after it.
|
|
11
|
-
*
|
|
12
|
-
* ## Addressing reuses `data-agent-element`, deliberately
|
|
13
|
-
*
|
|
14
|
-
* Handles are the existing `data-agent-element` convention (`@jini-ai/agentic`'s
|
|
15
|
-
* `element-handles.ts`), including its handle grammar — lowercase words joined by single hyphens,
|
|
16
|
-
* bounded length, and no quote, bracket, backslash or whitespace. That grammar exists so a handle
|
|
17
|
-
* cannot break out of the attribute selector it gets interpolated into, and it serves equally well
|
|
18
|
-
* here: a `PartId` a model can name is a string a model authored.
|
|
19
|
-
*
|
|
20
|
-
* Minting a new product-specific attribute was considered and rejected twice over — the convention
|
|
21
|
-
* already exists with `region` as a defined role, and a product-named attribute could not live in
|
|
22
|
-
* this workspace anyway.
|
|
23
|
-
*
|
|
24
|
-
* ## THE SECURITY PROPERTY: a model may not extend its own allowlist
|
|
25
|
-
*
|
|
26
|
-
* `listParts` is an allowlist, and an allowlist a model can write to is not an allowlist. If a model
|
|
27
|
-
* may edit region `hero`, and it writes `data-agent-element="anything"` inside `hero`, then the very
|
|
28
|
-
* next `listParts()` publishes `anything` as addressable — the model has granted itself a new part
|
|
29
|
-
* without the host ever deciding to. Every subsequent turn inherits the expanded surface.
|
|
30
|
-
*
|
|
31
|
-
* So `validate` refuses any candidate whose prospective document does not carry **exactly the same
|
|
32
|
-
* multiset of handles** as the current one. That covers all three ways this breaks: a handle
|
|
33
|
-
* invented, a handle deleted, and a handle duplicated (which would make an id ambiguous and let one
|
|
34
|
-
* write land somewhere the host did not intend). Neither reference implementation has an equivalent
|
|
35
|
-
* check, because neither has sub-document parts — there was nothing to copy.
|
|
36
|
-
*
|
|
37
|
-
* ## Writes are byte-preserving outside the edited region
|
|
38
|
-
*
|
|
39
|
-
* `replacePart` is a string splice across the region's inner offsets: everything outside those
|
|
40
|
-
* offsets is carried through untouched, byte for byte. This is a deliberate departure from the
|
|
41
|
-
* visual editor studied for this design, which re-parses, regenerates and pretty-prints the whole
|
|
42
|
-
* file on every write — semantically faithful, but it reformats the document out from under the
|
|
43
|
-
* author, and it does so even to files it failed to parse. A host that stores author-written HTML
|
|
44
|
-
* cannot accept that.
|
|
45
|
-
*
|
|
46
|
-
* ## The parser is injected, not imported
|
|
47
|
-
*
|
|
48
|
-
* This package is `"runtime": "universal"`, and no HTML parser exists anywhere in this workspace, so
|
|
49
|
-
* hard-wiring one would either make this module Node-only or push a parser into every browser
|
|
50
|
-
* bundle. It arrives as a port instead — the same choice, for the same reason, that `EditTarget`
|
|
51
|
-
* itself is a port. A browser host can satisfy it with `DOMParser`; a server host with whatever it
|
|
52
|
-
* already depends on.
|
|
53
|
-
*/
|
|
54
1
|
import type { EditTarget } from "../core/target.js";
|
|
55
2
|
/** The attribute that publishes a region's handle. Mirrors `@jini-ai/agentic`'s convention. */
|
|
56
3
|
export declare const AGENT_ELEMENT_ATTRIBUTE = "data-agent-element";
|
package/dist/html/regions.js
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module html/regions
|
|
3
|
+
*
|
|
4
|
+
* An `EditTarget` whose parts are **tagged regions of one HTML document** — the shape a
|
|
5
|
+
* single-document host needs, as opposed to the file tree a multi-file host edits.
|
|
6
|
+
*
|
|
7
|
+
* This is the target the port was designed against but never had an implementation for, and it is
|
|
8
|
+
* where `validate` stops being a formality: a file cannot corrupt another file's syntax, but a
|
|
9
|
+
* region shares one document with every other region, so one unbalanced tag corrupts everything
|
|
10
|
+
* after it.
|
|
11
|
+
*
|
|
12
|
+
* ## Addressing reuses `data-agent-element`, deliberately
|
|
13
|
+
*
|
|
14
|
+
* Handles are the existing `data-agent-element` convention (`@jini-ai/agentic`'s
|
|
15
|
+
* `element-handles.ts`), including its handle grammar — lowercase words joined by single hyphens,
|
|
16
|
+
* bounded length, and no quote, bracket, backslash or whitespace. That grammar exists so a handle
|
|
17
|
+
* cannot break out of the attribute selector it gets interpolated into, and it serves equally well
|
|
18
|
+
* here: a `PartId` a model can name is a string a model authored.
|
|
19
|
+
*
|
|
20
|
+
* Minting a new product-specific attribute was considered and rejected twice over — the convention
|
|
21
|
+
* already exists with `region` as a defined role, and a product-named attribute could not live in
|
|
22
|
+
* this workspace anyway.
|
|
23
|
+
*
|
|
24
|
+
* ## THE SECURITY PROPERTY: a model may not extend its own allowlist
|
|
25
|
+
*
|
|
26
|
+
* `listParts` is an allowlist, and an allowlist a model can write to is not an allowlist. If a model
|
|
27
|
+
* may edit region `hero`, and it writes `data-agent-element="anything"` inside `hero`, then the very
|
|
28
|
+
* next `listParts()` publishes `anything` as addressable — the model has granted itself a new part
|
|
29
|
+
* without the host ever deciding to. Every subsequent turn inherits the expanded surface.
|
|
30
|
+
*
|
|
31
|
+
* So `validate` refuses any candidate whose prospective document does not carry **exactly the same
|
|
32
|
+
* multiset of handles** as the current one. That covers all three ways this breaks: a handle
|
|
33
|
+
* invented, a handle deleted, and a handle duplicated (which would make an id ambiguous and let one
|
|
34
|
+
* write land somewhere the host did not intend). Neither reference implementation has an equivalent
|
|
35
|
+
* check, because neither has sub-document parts — there was nothing to copy.
|
|
36
|
+
*
|
|
37
|
+
* ## Writes are byte-preserving outside the edited region
|
|
38
|
+
*
|
|
39
|
+
* `replacePart` is a string splice across the region's inner offsets: everything outside those
|
|
40
|
+
* offsets is carried through untouched, byte for byte. This is a deliberate departure from the
|
|
41
|
+
* visual editor studied for this design, which re-parses, regenerates and pretty-prints the whole
|
|
42
|
+
* file on every write — semantically faithful, but it reformats the document out from under the
|
|
43
|
+
* author, and it does so even to files it failed to parse. A host that stores author-written HTML
|
|
44
|
+
* cannot accept that.
|
|
45
|
+
*
|
|
46
|
+
* ## The parser is injected, not imported
|
|
47
|
+
*
|
|
48
|
+
* This package is `"runtime": "universal"`, and no HTML parser exists anywhere in this workspace, so
|
|
49
|
+
* hard-wiring one would either make this module Node-only or push a parser into every browser
|
|
50
|
+
* bundle. It arrives as a port instead — the same choice, for the same reason, that `EditTarget`
|
|
51
|
+
* itself is a port. A browser host can satisfy it with `DOMParser`; a server host with whatever it
|
|
52
|
+
* already depends on.
|
|
53
|
+
*/
|
|
54
|
+
import { replaceRegionInner } from "./edit.js";
|
|
1
55
|
/** The attribute that publishes a region's handle. Mirrors `@jini-ai/agentic`'s convention. */
|
|
2
56
|
export const AGENT_ELEMENT_ATTRIBUTE = "data-agent-element";
|
|
3
57
|
/**
|
|
@@ -53,7 +107,7 @@ export function createHtmlRegionTarget(deps, options = {}) {
|
|
|
53
107
|
const maxPartLength = options.maxPartLength ?? 256 * 1024;
|
|
54
108
|
/** Splices `content` into `region`'s inner range, preserving every byte outside it. */
|
|
55
109
|
function spliced(html, region, content) {
|
|
56
|
-
return html
|
|
110
|
+
return replaceRegionInner({ html, region, fragment: content });
|
|
57
111
|
}
|
|
58
112
|
return {
|
|
59
113
|
async listParts() {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Node-only local project adapter; universal contracts live in ../core/project. */
|
|
2
|
+
export { openFileTreeProject } from './project.js';
|
|
3
|
+
export type { OpenFileTreeProjectInput, OpenFileTreeProjectOptions } from './project.js';
|
|
4
|
+
export type { FileTreePolicy } from './paths.js';
|
|
5
|
+
export * from '../core/project.js';
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type ProjectLimits } from '../core/project.js';
|
|
2
|
+
export interface FileTreePolicy {
|
|
3
|
+
/** Host authority intersects mandatory exclusions, including for external captures. */
|
|
4
|
+
allows(input: {
|
|
5
|
+
readonly relativePath: string;
|
|
6
|
+
}): boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare const DEFAULT_PROJECT_LIMITS: ProjectLimits;
|
|
9
|
+
/** Portable comparison also catches APFS case folding aliases such as the Kelvin sign. */
|
|
10
|
+
export declare function portablePathKey({ path }: {
|
|
11
|
+
path: string;
|
|
12
|
+
}, _options?: {}): string;
|
|
13
|
+
/** Resolve positive integer bounds once; no silent clamping or partial tree captures.
|
|
14
|
+
* @complexity O(number of limits) time and space.
|
|
15
|
+
*/
|
|
16
|
+
export declare function projectLimits(input: {
|
|
17
|
+
limits?: Partial<ProjectLimits>;
|
|
18
|
+
}, _options?: {}): ProjectLimits;
|
|
19
|
+
/** Names must already be canonical: never normalize a traversal into an authorized filename.
|
|
20
|
+
* @throws ProjectError INVALID_PATH for nonportable or noncanonical names.
|
|
21
|
+
* @complexity O(path length) time and space; relative names are capped at 200 code units.
|
|
22
|
+
*/
|
|
23
|
+
export declare function canonicalPath({ relativePath, maxDepth }: {
|
|
24
|
+
relativePath: string;
|
|
25
|
+
maxDepth: number;
|
|
26
|
+
}, _options?: {}): string;
|
|
27
|
+
/** Validate all candidate file AND directory spellings; a delete/create case-only rename passes.
|
|
28
|
+
* @complexity O(total prefix lengths) time and space; no pairwise path comparisons.
|
|
29
|
+
*/
|
|
30
|
+
export declare function assertCandidatePaths({ paths }: {
|
|
31
|
+
paths: readonly string[];
|
|
32
|
+
}, _options?: {}): void;
|
|
33
|
+
/** Platform owns physical containment and bounded traversal; this adapter adds project policy.
|
|
34
|
+
* @complexity Construction O(1); path checks O(depth), listing bounded by configured limits.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createProjectPaths({ root, policy, limits }: {
|
|
37
|
+
root: string;
|
|
38
|
+
policy: FileTreePolicy;
|
|
39
|
+
limits: ProjectLimits;
|
|
40
|
+
}, _options?: {}): {
|
|
41
|
+
reader: import("@jini-ai/platform/fs").GuardedFileReader;
|
|
42
|
+
check: ({ relativePath }: {
|
|
43
|
+
relativePath: string;
|
|
44
|
+
}) => string;
|
|
45
|
+
allows: ({ relativePath }: {
|
|
46
|
+
relativePath: string;
|
|
47
|
+
}) => boolean;
|
|
48
|
+
};
|
|
49
|
+
export type ProjectPaths = ReturnType<typeof createProjectPaths>;
|
|
50
|
+
//# sourceMappingURL=paths.d.ts.map
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { createGuardedFileReader, createNodeGuardedReaderFilesystem } from '@jini-ai/platform/fs';
|
|
2
|
+
import { ProjectError } from '../core/project.js';
|
|
3
|
+
export const DEFAULT_PROJECT_LIMITS = {
|
|
4
|
+
maxFiles: 2_000, maxFileBytes: 2 * 1024 * 1024, maxTreeBytes: 64 * 1024 * 1024,
|
|
5
|
+
maxDepth: 32, maxChanges: 100, validationTimeoutMs: 30_000,
|
|
6
|
+
};
|
|
7
|
+
const EXCLUDED = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage',
|
|
8
|
+
'.next', '.nuxt', '.angular', '.vite', '.cache', '.turbo']);
|
|
9
|
+
const DEVICE = /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/i;
|
|
10
|
+
// Platform's atomic writer stages `.<name>.<pid>.<uuid>.tmp` beside the destination. A crash
|
|
11
|
+
// mid-materialize leaves one in the app root; never capture that debris as a managed file.
|
|
12
|
+
const ATOMIC_TEMP = /^\..+\.\d+\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.tmp$/i;
|
|
13
|
+
/** Portable comparison also catches APFS case folding aliases such as the Kelvin sign. */
|
|
14
|
+
export function portablePathKey({ path }, _options = {}) {
|
|
15
|
+
return path.normalize('NFC').toLowerCase().toUpperCase().toLowerCase();
|
|
16
|
+
}
|
|
17
|
+
/** Resolve positive integer bounds once; no silent clamping or partial tree captures.
|
|
18
|
+
* @complexity O(number of limits) time and space.
|
|
19
|
+
*/
|
|
20
|
+
export function projectLimits(input, _options = {}) {
|
|
21
|
+
const limits = { ...DEFAULT_PROJECT_LIMITS, ...input.limits };
|
|
22
|
+
for (const value of Object.values(limits)) {
|
|
23
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
24
|
+
throw new TypeError('project limits must be positive safe integers');
|
|
25
|
+
}
|
|
26
|
+
return limits;
|
|
27
|
+
}
|
|
28
|
+
/** Names must already be canonical: never normalize a traversal into an authorized filename.
|
|
29
|
+
* @throws ProjectError INVALID_PATH for nonportable or noncanonical names.
|
|
30
|
+
* @complexity O(path length) time and space; relative names are capped at 200 code units.
|
|
31
|
+
*/
|
|
32
|
+
export function canonicalPath({ relativePath, maxDepth }, _options = {}) {
|
|
33
|
+
if (!relativePath || relativePath.length > 200 || relativePath.includes('\\')) {
|
|
34
|
+
throw new ProjectError({ code: 'INVALID_PATH', message: 'use a relative slash-separated path of at most 200 characters' });
|
|
35
|
+
}
|
|
36
|
+
const segments = relativePath.split('/');
|
|
37
|
+
if (segments.length > maxDepth)
|
|
38
|
+
throw new ProjectError({ code: 'LIMIT', message: 'path exceeds depth limit' });
|
|
39
|
+
for (const segment of segments)
|
|
40
|
+
assertSegment({ segment });
|
|
41
|
+
return relativePath;
|
|
42
|
+
}
|
|
43
|
+
/** Each segment must survive Windows parsing without aliases, streams or device semantics. */
|
|
44
|
+
function assertSegment({ segment }) {
|
|
45
|
+
if (!segment || segment === '.' || segment === '..' || /[<>:"|?*\x00-\x1f\x7f]/.test(segment) || /[. ]$/.test(segment) || DEVICE.test(segment)) {
|
|
46
|
+
throw new ProjectError({ code: 'INVALID_PATH', message: `nonportable path segment: ${segment}` });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Validate all candidate file AND directory spellings; a delete/create case-only rename passes.
|
|
50
|
+
* @complexity O(total prefix lengths) time and space; no pairwise path comparisons.
|
|
51
|
+
*/
|
|
52
|
+
export function assertCandidatePaths({ paths }, _options = {}) {
|
|
53
|
+
const spellings = new Map();
|
|
54
|
+
const files = new Set(paths.map(path => portablePathKey({ path })));
|
|
55
|
+
for (const path of paths) {
|
|
56
|
+
const segments = path.split('/');
|
|
57
|
+
for (let end = 1; end <= segments.length; end += 1) {
|
|
58
|
+
const prefix = segments.slice(0, end).join('/');
|
|
59
|
+
const key = portablePathKey({ path: prefix });
|
|
60
|
+
const prior = spellings.get(key);
|
|
61
|
+
if ((prior !== undefined && prior !== prefix) || (end < segments.length && files.has(key))) {
|
|
62
|
+
throw new ProjectError({ code: 'INVALID_PATH', message: `candidate path collision: ${prefix}` });
|
|
63
|
+
}
|
|
64
|
+
spellings.set(key, prefix);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Platform owns physical containment and bounded traversal; this adapter adds project policy.
|
|
69
|
+
* @complexity Construction O(1); path checks O(depth), listing bounded by configured limits.
|
|
70
|
+
*/
|
|
71
|
+
export function createProjectPaths({ root, policy, limits }, _options = {}) {
|
|
72
|
+
const reader = createGuardedFileReader({ rootPath: root, filesystem: createNodeGuardedReaderFilesystem({}),
|
|
73
|
+
denyRules: { segments: EXCLUDED, filenamePatterns: [/^\.env/i, ATOMIC_TEMP] },
|
|
74
|
+
limits: { maxFileBytes: limits.maxFileBytes, binarySniffBytes: 1024,
|
|
75
|
+
maxListedFiles: limits.maxFiles + 1, maxWalkDepth: limits.maxDepth, maxWalkEntries: limits.maxFiles * (limits.maxDepth + 1) },
|
|
76
|
+
}, { excludedListingDirs: EXCLUDED });
|
|
77
|
+
function check({ relativePath }) {
|
|
78
|
+
const canonical = canonicalPath({ relativePath, maxDepth: limits.maxDepth });
|
|
79
|
+
for (const segment of canonical.split('/')) {
|
|
80
|
+
if (reader.isDeniedPathSegment({ segmentName: segment }) || reader.isDeniedFileName({ fileName: segment })) {
|
|
81
|
+
throw new ProjectError({ code: 'INVALID_PATH', message: `excluded path: ${relativePath}` });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!policy.allows({ relativePath: canonical }))
|
|
85
|
+
throw new ProjectError({ code: 'INVALID_PATH', message: `host policy refuses ${canonical}` });
|
|
86
|
+
return canonical;
|
|
87
|
+
}
|
|
88
|
+
return { reader, check, allows: ({ relativePath }) => relativePath.split('/').every(segment => !reader.isDeniedPathSegment({ segmentName: segment }) && !reader.isDeniedFileName({ fileName: segment }))
|
|
89
|
+
&& policy.allows({ relativePath }) };
|
|
90
|
+
}
|
|
91
|
+
//# sourceMappingURL=paths.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type FileTreeProject, type ProjectLimits, type ProjectValidator } from '../core/project.js';
|
|
2
|
+
import { type FileTreePolicy } from './paths.js';
|
|
3
|
+
export interface OpenFileTreeProjectInput {
|
|
4
|
+
readonly root: string;
|
|
5
|
+
/** Private host-owned directory outside the watched and served application tree. */
|
|
6
|
+
readonly stateDir: string;
|
|
7
|
+
readonly policy: FileTreePolicy;
|
|
8
|
+
readonly validator?: ProjectValidator;
|
|
9
|
+
}
|
|
10
|
+
export interface OpenFileTreeProjectOptions {
|
|
11
|
+
readonly limits?: Partial<ProjectLimits>;
|
|
12
|
+
readonly historyLimit?: number;
|
|
13
|
+
}
|
|
14
|
+
/** Open real local files with process-crash roll-forward and session-local pointer history.
|
|
15
|
+
* @throws ProjectError for conflicts, unsafe paths, corruption, limits or I/O failure.
|
|
16
|
+
* @complexity O(files + bytes) opening/capture; each turn is bounded by configured tree limits.
|
|
17
|
+
*/
|
|
18
|
+
export declare function openFileTreeProject(input: OpenFileTreeProjectInput, options?: OpenFileTreeProjectOptions): Promise<FileTreeProject>;
|
|
19
|
+
//# sourceMappingURL=project.d.ts.map
|