@jini-ai/vibecoding 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -0
- package/README.md +154 -0
- package/dist/core/apply.d.ts +69 -0
- package/dist/core/apply.js +69 -0
- package/dist/core/history.d.ts +144 -0
- package/dist/core/history.js +132 -0
- package/dist/core/index.d.ts +19 -0
- package/dist/core/index.js +3 -0
- package/dist/core/target.d.ts +92 -0
- package/dist/core/target.js +2 -0
- package/dist/core/types.d.ts +86 -0
- package/dist/core/types.js +19 -0
- package/dist/html/index.d.ts +12 -0
- package/dist/html/index.js +2 -0
- package/dist/html/node/index.d.ts +10 -0
- package/dist/html/node/index.js +10 -0
- package/dist/html/node/parse5-region-parser.d.ts +12 -0
- package/dist/html/node/parse5-region-parser.js +213 -0
- package/dist/html/regions.d.ts +137 -0
- package/dist/html/regions.js +165 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/react/components/DocumentPreview.d.ts +59 -0
- package/dist/react/components/DocumentPreview.js +44 -0
- package/dist/react/components/PartsViewer.d.ts +49 -0
- package/dist/react/components/PartsViewer.js +61 -0
- package/dist/react/components/VibecodingWorkbench.d.ts +38 -0
- package/dist/react/components/VibecodingWorkbench.js +96 -0
- package/dist/react/index.d.ts +29 -0
- package/dist/react/index.js +7 -0
- package/dist/react/session.d.ts +108 -0
- package/dist/react/session.js +160 -0
- package/dist/react/tools.d.ts +91 -0
- package/dist/react/tools.js +246 -0
- package/dist/react/use-vibecoding-session.d.ts +30 -0
- package/dist/react/use-vibecoding-session.js +44 -0
- package/package.json +99 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.4.0 — 2026-10-02
|
|
4
|
+
|
|
5
|
+
### BREAKING
|
|
6
|
+
|
|
7
|
+
- Pure kernel containment and isolated HTML/React entries remain; shared ports and public object-argument contracts replace local vocabulary.
|
|
8
|
+
- Distribution includes runtime output, release documentation and required assets only. Process records and per-job neutrality checks are no longer part of the package surface.
|
|
9
|
+
|
|
10
|
+
## Unreleased
|
|
11
|
+
|
|
12
|
+
- Fixed compilation against the current core contracts: tool input errors use `{ message }`,
|
|
13
|
+
and registry integration tests use object arguments for registry creation and listing.
|
|
14
|
+
The parse5 factory return type now exposes its always-present `checkWellFormed` method.
|
|
15
|
+
Error messages and parser behavior are unchanged.
|
|
16
|
+
|
|
17
|
+
- Integration follow-up: reconciled the `./core`, `./html`, `./html/node` and `./react`
|
|
18
|
+
surfaces, including HTML parser/storage ports, session/history, discoverable tool registrations,
|
|
19
|
+
direct tool runner, React hook and preview components. Export metadata and optional React peers
|
|
20
|
+
were already present and are retained. No version change or new dependency is required.
|
|
21
|
+
- BREAKING: `createVibecodingSession({ target }, { historyOptions })` separates required ports
|
|
22
|
+
from optional tuning. `VibecodingSessionOptions` now contains only history tuning; the required
|
|
23
|
+
target lives in the new `VibecodingSessionArgs` type. `useVibecodingSession`,
|
|
24
|
+
`createVibecodingToolRegistrations` and `createVibecodingToolRunner` receive `{ session }`.
|
|
25
|
+
- BREAKING: session subscriptions, part reads, edit batches and snapshot rewinds take named
|
|
26
|
+
objects; labels are in the optional second object. Hook actions share these method types.
|
|
27
|
+
The runner uses `run({ toolId, input })`. Tool wire schemas and React component props are unchanged.
|
|
28
|
+
- Fixed session caching for repeated part ids and reverse-order undo. Selected previews now
|
|
29
|
+
refresh after history replay. Added regression, composition, export-map and whole-source
|
|
30
|
+
neutrality tests, and migrated the existing React test fixtures and README API examples.
|
|
31
|
+
- Integration status supersedes the earlier React ownership note below: the cleared React
|
|
32
|
+
directory and its dependent calls are now migrated. Tests, typechecks, builds and pack checks
|
|
33
|
+
remain **not run (owner directive)**; no runtime correctness or release readiness is claimed.
|
|
34
|
+
|
|
35
|
+
- BREAKING: `applyEdit`, `applyEdits`, `correctionsFor`, `createEditHistory`, and
|
|
36
|
+
`isValidRegionHandle` accept named required arguments. `createHtmlRegionTarget`
|
|
37
|
+
receives optional settings as its second object, separate from storage and parser ports.
|
|
38
|
+
- BREAKING: `EditTarget.readPart`, `replacePart`, and `restore`, `EditHistory.transaction`
|
|
39
|
+
and `restore`, `HtmlRegionParser.findRegions` and `checkWellFormed`, and
|
|
40
|
+
`HtmlDocumentStore.write` accept named object arguments. Transaction labels belong
|
|
41
|
+
to the optional second object. Export names and edit/history behavior are preserved.
|
|
42
|
+
- Updated core/HTML behavior tests and added a package neutrality guard. Verification
|
|
43
|
+
is deferred by owner directive. The pre-existing React worktree remains unchanged;
|
|
44
|
+
its dependent calls require migration before package verification or release.
|
package/README.md
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# @jini-ai/vibecoding
|
|
2
|
+
|
|
3
|
+
Conversational artifact authoring — the loop that turns "make the hero blue" into a validated,
|
|
4
|
+
undoable change, without knowing what kind of artifact it is editing.
|
|
5
|
+
|
|
6
|
+
## The idea
|
|
7
|
+
|
|
8
|
+
Every AI app-builder differs at exactly one place: **what is being edited**. One edits a file tree.
|
|
9
|
+
Another edits a single markup document. The conversation, the streaming, the undo and the preview
|
|
10
|
+
are the same in all of them.
|
|
11
|
+
|
|
12
|
+
So this package owns the loop, and the artifact reaches it through one small port:
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
interface EditTarget {
|
|
16
|
+
listParts(): Promise<readonly PartRef[]>; // the allowlist
|
|
17
|
+
readPart({ id }): Promise<string>;
|
|
18
|
+
replacePart({ id, content }): Promise<void>; // upsert
|
|
19
|
+
snapshot(): Promise<Snapshot>;
|
|
20
|
+
restore({ snapshot }): Promise<void>;
|
|
21
|
+
validate(candidate): Promise<ValidationResult>;
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A file-tree host maps parts to files. A single-document host maps them to addressable regions.
|
|
26
|
+
Neither reimplements the loop.
|
|
27
|
+
|
|
28
|
+
## Layout
|
|
29
|
+
|
|
30
|
+
| entry | runtime | contents |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `.` | universal | re-exports `./core` |
|
|
33
|
+
| `./core` | universal | the loop and the `EditTarget` port — no React, no Node, no DOM |
|
|
34
|
+
| `./html` | universal | the tagged-region, single-HTML-document `EditTarget` (parser injected) |
|
|
35
|
+
| `./html/node` | node | a parse5-backed `HtmlRegionParser` for `./html`, for a Node host |
|
|
36
|
+
| `./react` | browser | `VibecodingSession`, the `@jini-ai/core`-registrable tool set, and the file-viewer/renderer components — see `src/react/README` section below |
|
|
37
|
+
|
|
38
|
+
`./node` (filesystem targets) is planned and deliberately not present yet — building it is a
|
|
39
|
+
separate `EditTarget` implementation, not a `./react` concern. React is an **optional** peer of
|
|
40
|
+
`./react`: keeping the framework-free half importable without a UI dependency is the point of the
|
|
41
|
+
split, not a stylistic preference, and importing `.`/`./core`/`./html` never pulls React in.
|
|
42
|
+
|
|
43
|
+
### `./react`
|
|
44
|
+
|
|
45
|
+
The chat + preview surface over any `EditTarget`: `createVibecodingSession` (a subscribable
|
|
46
|
+
wrapper over `./core`'s loop, built for `useSyncExternalStore`), `createVibecodingToolRegistrations`
|
|
47
|
+
/`createVibecodingToolRunner` (the same session exposed as `@jini-ai/core` `ToolDescriptor`s a chat
|
|
48
|
+
surface discovers through a registry rather than a hardcoded list), and three React pieces —
|
|
49
|
+
`PartsViewer` (the file viewer), `DocumentPreview` (the live renderer, a sandboxed iframe — not an
|
|
50
|
+
editor, and not `@jini-ai/sandbox`'s job; see that component's own doc), and `VibecodingWorkbench`
|
|
51
|
+
(both composed). See `src/react/index.ts` and each module's own doc comment for the design
|
|
52
|
+
rationale behind every one of those pieces.
|
|
53
|
+
|
|
54
|
+
## Three decisions worth knowing before changing anything
|
|
55
|
+
|
|
56
|
+
**1. `listParts` is an allowlist, and that is where scope discipline comes from.**
|
|
57
|
+
A part not listed is structurally unaddressable rather than merely discouraged. Prompt severity is
|
|
58
|
+
not a substitute for this — one shipped builder ships three prompt variants that enforce identical
|
|
59
|
+
scope rules with wildly different tone, over an identical mechanism. The constraint lives in the
|
|
60
|
+
addressing.
|
|
61
|
+
|
|
62
|
+
**2. `validate` receives the whole prospective artifact, and it is the one piece with no upstream.**
|
|
63
|
+
Neither reference implementation validates writes at all, because their unit is a whole file and a
|
|
64
|
+
malformed file cannot corrupt another file's syntax. Sub-document parts lose that property: one
|
|
65
|
+
unbalanced tag corrupts everything after it. It takes the whole prospective artifact rather than the
|
|
66
|
+
part alone because a fragment that is well-formed in isolation can still break the document it lands
|
|
67
|
+
in. A rejection's `reason` is written for the model and fed back as its next turn — that is what
|
|
68
|
+
closes the loop between validation and correction.
|
|
69
|
+
|
|
70
|
+
**3. `snapshot` restores data, never an execution environment.**
|
|
71
|
+
The reference implementation this shape came from also replays setup commands on rewind to restart
|
|
72
|
+
its dev server. That is execution resync, and it is the host's job, after `restore` returns. Folding
|
|
73
|
+
it in here would promise something this package cannot deliver for any host but the simplest.
|
|
74
|
+
|
|
75
|
+
There are deliberately **no verbs for running processes, installing dependencies, or building**.
|
|
76
|
+
Those are real needs for a file-tree host and belong to a separate execution layer. Their absence is
|
|
77
|
+
a decision, not a gap.
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { applyEdits, correctionsFor } from "@jini-ai/vibecoding/core";
|
|
83
|
+
|
|
84
|
+
const outcomes = await applyEdits({ target, edits: proposals });
|
|
85
|
+
const corrections = correctionsFor({ outcomes });
|
|
86
|
+
if (corrections.length > 0) {
|
|
87
|
+
// Feed these back as the model's next turn.
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Every proposal gets an outcome — a rejection or a write failure does not abandon the batch, so a
|
|
92
|
+
host can report precisely which parts landed. Write failures surface as `failed` rather than being
|
|
93
|
+
logged and forgotten.
|
|
94
|
+
|
|
95
|
+
## Object API
|
|
96
|
+
|
|
97
|
+
Functions with inputs take a required object followed, when needed, by an optional object.
|
|
98
|
+
Dependencies are explicit ports in the required object. Methods with no inputs stay zero-argument;
|
|
99
|
+
React components keep their props and framework callbacks keep the framework's signature.
|
|
100
|
+
|
|
101
|
+
| API | Call shape |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| Single edit | `applyEdit({ target, edit })` |
|
|
104
|
+
| Batch edits and feedback | `applyEdits({ target, edits })`, `correctionsFor({ outcomes })` |
|
|
105
|
+
| History | `createEditHistory({ target }, { limit })` |
|
|
106
|
+
| Transaction / snapshot rewind | `history.transaction({ work }, { label })`, `history.restore({ snapshot })` |
|
|
107
|
+
| HTML target | `createHtmlRegionTarget({ store, parser }, { maxPartLength })` |
|
|
108
|
+
| Handle check / parser | `isValidRegionHandle({ handle })`, `createParse5RegionParser()` |
|
|
109
|
+
| Parser port | `parser.findRegions({ html })`, `parser.checkWellFormed?.({ html })` |
|
|
110
|
+
| Document store port | `store.read()`, `store.write({ html })` |
|
|
111
|
+
| Session | `createVibecodingSession({ target }, { historyOptions })` |
|
|
112
|
+
| Session methods | `session.subscribe({ listener })`, `session.readPart({ id })`, `session.applyEdits({ edits }, { label })`, `session.restoreSnapshot({ snapshot })` |
|
|
113
|
+
| React hook | `useVibecodingSession({ session })`; its `readPart` and `applyEdits` actions use the session method shapes |
|
|
114
|
+
| Registered tools | `createVibecodingToolRegistrations({ session }, { policy })` |
|
|
115
|
+
| Direct tool runner | `createVibecodingToolRunner({ session })`, `runner.run({ toolId, input })` |
|
|
116
|
+
|
|
117
|
+
`createParse5RegionParser()` always provides `checkWellFormed({ html })`; optional chaining
|
|
118
|
+
is only needed when working with the generic `HtmlRegionParser` port.
|
|
119
|
+
|
|
120
|
+
`VibecodingSessionArgs` holds the required target; `VibecodingSessionOptions` now holds only
|
|
121
|
+
optional `historyOptions`. `UseVibecodingSessionArgs` and `VibecodingToolArgs` describe the required
|
|
122
|
+
session port. All are exported from `./react`. Tool input JSON remains unchanged: `edits` and
|
|
123
|
+
`label` still belong to the same tool input object.
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import { createHtmlRegionTarget, type HtmlDocumentStore } from '@jini-ai/vibecoding/html';
|
|
127
|
+
import { createParse5RegionParser } from '@jini-ai/vibecoding/html/node';
|
|
128
|
+
import {
|
|
129
|
+
createVibecodingSession, createVibecodingToolRunner, VIBECODING_TOOL_IDS,
|
|
130
|
+
} from '@jini-ai/vibecoding/react';
|
|
131
|
+
|
|
132
|
+
let html = '<section data-agent-element="hero">Hello</section>';
|
|
133
|
+
const store: HtmlDocumentStore = {
|
|
134
|
+
read: async () => html,
|
|
135
|
+
write: async ({ html: next }) => { html = next; },
|
|
136
|
+
};
|
|
137
|
+
const target = createHtmlRegionTarget({ store, parser: createParse5RegionParser() });
|
|
138
|
+
const session = createVibecodingSession({ target }, { historyOptions: { limit: 25 } });
|
|
139
|
+
const runner = createVibecodingToolRunner({ session });
|
|
140
|
+
await runner.run({
|
|
141
|
+
toolId: VIBECODING_TOOL_IDS.PROPOSE_EDITS,
|
|
142
|
+
input: { edits: [{ id: 'hero', content: 'Hello again' }], label: 'Greeting' },
|
|
143
|
+
});
|
|
144
|
+
await session.undo();
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
The parser import above is for a Node host. Browser hosts inject a browser-compatible
|
|
148
|
+
`HtmlRegionParser` through the same port. Storage paths, persistence and product configuration
|
|
149
|
+
belong to the host. Integration verification is deferred by owner directive; see
|
|
150
|
+
[INTEGRATION-REPORT.md](./INTEGRATION-REPORT.md) for the source review and pending commands.
|
|
151
|
+
|
|
152
|
+
## Design decisions
|
|
153
|
+
|
|
154
|
+
- [Ambiguous region detection refuses rather than dropping a handle](docs/decisions/DR-001-conservative-region-detection.md).
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/apply
|
|
3
|
+
*
|
|
4
|
+
* Validate-then-commit: the only sanctioned path from a model's proposed content to a real write.
|
|
5
|
+
*
|
|
6
|
+
* Two failure behaviours here are deliberate corrections of what the reference implementations do,
|
|
7
|
+
* and both were observed in their source rather than inferred:
|
|
8
|
+
*
|
|
9
|
+
* - **A write failure is never swallowed.** One builder wraps both its directory-create and its
|
|
10
|
+
* file-write in `try/catch` blocks that only log, then returns normally — so the action is
|
|
11
|
+
* marked complete whether or not anything reached disk, while its *shell* actions do surface
|
|
12
|
+
* failures. That asymmetry looks accidental. Here a failed write becomes a `failed` outcome the
|
|
13
|
+
* caller must handle.
|
|
14
|
+
* - **Per-part outcomes are collected and returned, not aggregated into a single boolean.** This is
|
|
15
|
+
* the one thing the second reference implementation does better than the first, and it is what
|
|
16
|
+
* lets a host stream partial progress and tell the model precisely which parts landed.
|
|
17
|
+
*/
|
|
18
|
+
import type { EditTarget } from "./target.js";
|
|
19
|
+
import type { ApplyOutcome, PartId } from "./types.js";
|
|
20
|
+
/** One proposed change: replace `id`'s content with `content`. */
|
|
21
|
+
export interface ProposedEdit {
|
|
22
|
+
readonly id: PartId;
|
|
23
|
+
readonly content: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Validate one proposed edit and, if the host accepts it, commit it.
|
|
27
|
+
*
|
|
28
|
+
* @param requiredArgs - the host's artifact port and the model's proposed replacement.
|
|
29
|
+
* @returns what happened — applied, rejected by `validate`, or failed while validating or writing.
|
|
30
|
+
* @complexity 3
|
|
31
|
+
*/
|
|
32
|
+
export declare function applyEdit({ target, edit }: {
|
|
33
|
+
readonly target: EditTarget;
|
|
34
|
+
readonly edit: ProposedEdit;
|
|
35
|
+
}): Promise<ApplyOutcome>;
|
|
36
|
+
/**
|
|
37
|
+
* Apply several proposed edits in order, one at a time.
|
|
38
|
+
*
|
|
39
|
+
* Sequential on purpose: `validate` is defined over the whole prospective artifact, so two edits
|
|
40
|
+
* validated concurrently would each be judged against a state that never existed. A rejected or
|
|
41
|
+
* failed edit does **not** halt the rest — every proposal gets an outcome, so the caller can report
|
|
42
|
+
* precisely which parts landed rather than abandoning the batch at the first problem.
|
|
43
|
+
*
|
|
44
|
+
* @param requiredArgs - the host's artifact port and proposals, applied in the given order.
|
|
45
|
+
* @returns one outcome per proposal, positionally aligned with `edits`.
|
|
46
|
+
* @complexity 2
|
|
47
|
+
*/
|
|
48
|
+
export declare function applyEdits({ target, edits }: {
|
|
49
|
+
readonly target: EditTarget;
|
|
50
|
+
readonly edits: readonly ProposedEdit[];
|
|
51
|
+
}): Promise<readonly ApplyOutcome[]>;
|
|
52
|
+
/**
|
|
53
|
+
* The subset of outcomes a host should feed back to the model as its next turn.
|
|
54
|
+
*
|
|
55
|
+
* Rejections carry a reason written for the model; write failures carry an error message. Both are
|
|
56
|
+
* things the model can act on, and neither reaches it unless the host asks for them here — which
|
|
57
|
+
* is what closes the loop between validation and correction.
|
|
58
|
+
*
|
|
59
|
+
* @param requiredArgs - results from `applyEdits`.
|
|
60
|
+
* @returns one `{ id, reason }` per non-applied outcome, in input order.
|
|
61
|
+
* @complexity 3
|
|
62
|
+
*/
|
|
63
|
+
export declare function correctionsFor({ outcomes }: {
|
|
64
|
+
readonly outcomes: readonly ApplyOutcome[];
|
|
65
|
+
}): readonly {
|
|
66
|
+
readonly id: PartId;
|
|
67
|
+
readonly reason: string;
|
|
68
|
+
}[];
|
|
69
|
+
//# sourceMappingURL=apply.d.ts.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Normalizes an unknown thrown value into an `Error`, preserving a non-Error as its message. */
|
|
2
|
+
function toError(thrown) {
|
|
3
|
+
return thrown instanceof Error ? thrown : new Error(String(thrown));
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Validate one proposed edit and, if the host accepts it, commit it.
|
|
7
|
+
*
|
|
8
|
+
* @param requiredArgs - the host's artifact port and the model's proposed replacement.
|
|
9
|
+
* @returns what happened — applied, rejected by `validate`, or failed while validating or writing.
|
|
10
|
+
* @complexity 3
|
|
11
|
+
*/
|
|
12
|
+
export async function applyEdit({ target, edit }) {
|
|
13
|
+
try {
|
|
14
|
+
// Validation is a host port too: a failed parser/read must produce an outcome so the batch
|
|
15
|
+
// can continue, and must never reach the write for this proposal.
|
|
16
|
+
const verdict = await target.validate({ id: edit.id, content: edit.content });
|
|
17
|
+
if (!verdict.ok) {
|
|
18
|
+
return { status: "rejected", id: edit.id, reason: verdict.reason };
|
|
19
|
+
}
|
|
20
|
+
await target.replacePart({ id: edit.id, content: edit.content });
|
|
21
|
+
}
|
|
22
|
+
catch (thrown) {
|
|
23
|
+
return { status: "failed", id: edit.id, error: toError(thrown) };
|
|
24
|
+
}
|
|
25
|
+
return { status: "applied", id: edit.id };
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Apply several proposed edits in order, one at a time.
|
|
29
|
+
*
|
|
30
|
+
* Sequential on purpose: `validate` is defined over the whole prospective artifact, so two edits
|
|
31
|
+
* validated concurrently would each be judged against a state that never existed. A rejected or
|
|
32
|
+
* failed edit does **not** halt the rest — every proposal gets an outcome, so the caller can report
|
|
33
|
+
* precisely which parts landed rather than abandoning the batch at the first problem.
|
|
34
|
+
*
|
|
35
|
+
* @param requiredArgs - the host's artifact port and proposals, applied in the given order.
|
|
36
|
+
* @returns one outcome per proposal, positionally aligned with `edits`.
|
|
37
|
+
* @complexity 2
|
|
38
|
+
*/
|
|
39
|
+
export async function applyEdits({ target, edits }) {
|
|
40
|
+
const outcomes = [];
|
|
41
|
+
for (const edit of edits) {
|
|
42
|
+
outcomes.push(await applyEdit({ target, edit }));
|
|
43
|
+
}
|
|
44
|
+
return outcomes;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The subset of outcomes a host should feed back to the model as its next turn.
|
|
48
|
+
*
|
|
49
|
+
* Rejections carry a reason written for the model; write failures carry an error message. Both are
|
|
50
|
+
* things the model can act on, and neither reaches it unless the host asks for them here — which
|
|
51
|
+
* is what closes the loop between validation and correction.
|
|
52
|
+
*
|
|
53
|
+
* @param requiredArgs - results from `applyEdits`.
|
|
54
|
+
* @returns one `{ id, reason }` per non-applied outcome, in input order.
|
|
55
|
+
* @complexity 3
|
|
56
|
+
*/
|
|
57
|
+
export function correctionsFor({ outcomes }) {
|
|
58
|
+
const corrections = [];
|
|
59
|
+
for (const outcome of outcomes) {
|
|
60
|
+
if (outcome.status === "rejected") {
|
|
61
|
+
corrections.push({ id: outcome.id, reason: outcome.reason });
|
|
62
|
+
}
|
|
63
|
+
else if (outcome.status === "failed") {
|
|
64
|
+
corrections.push({ id: outcome.id, reason: outcome.error.message });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return corrections;
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=apply.js.map
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core/history
|
|
3
|
+
*
|
|
4
|
+
* Operation-level undo and redo, layered strictly **on top of** `EditTarget` — it adds no verb to
|
|
5
|
+
* that port. A history records the before/after content of every part a transaction touched, which
|
|
6
|
+
* it learns by calling `readPart` immediately before each `replacePart`. Nothing here needs the
|
|
7
|
+
* host to implement anything new.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this tier exists at all, given `snapshot`/`restore`
|
|
10
|
+
*
|
|
11
|
+
* `snapshot`/`restore` is a whole-artifact rewind: it answers "put everything back the way it was."
|
|
12
|
+
* It cannot answer "undo the last thing," which is the operation a person actually reaches for, and
|
|
13
|
+
* it has no notion of redo. One shipped builder has exactly this gap — a rewind-to-snapshot feature
|
|
14
|
+
* and no undo stack at all, so stepping back one edit means stepping back every edit since the last
|
|
15
|
+
* capture. A second builder has both tiers, with transaction batching, and is the better model.
|
|
16
|
+
*
|
|
17
|
+
* ## Three decisions worth stating, because each could reasonably have gone the other way
|
|
18
|
+
*
|
|
19
|
+
* 1. **Undo and redo bypass `validate`.** `validate` judges a candidate against the whole
|
|
20
|
+
* prospective artifact (see `./target.ts`). Rewinding a transaction that touched three parts
|
|
21
|
+
* necessarily passes through two intermediate states that never existed as committed states, and
|
|
22
|
+
* validating those would reject a legitimate rewind — a half-restored document is exactly the
|
|
23
|
+
* kind of thing `validate` is built to refuse. The state being restored was already committed
|
|
24
|
+
* once, so it needs no second opinion. Applying a *new* edit still goes through `applyEdit`, and
|
|
25
|
+
* that path is unchanged.
|
|
26
|
+
*
|
|
27
|
+
* 2. **A rewind is itself undoable, and so is a snapshot restore.** `restore` below diffs the
|
|
28
|
+
* target snapshot against current content and records the result as an ordinary transaction, so
|
|
29
|
+
* the two tiers share one stack and a mis-aimed rewind is recoverable. Borrowed deliberately:
|
|
30
|
+
* the alternative — a restore that silently discards whatever it overwrote — turns the safety
|
|
31
|
+
* feature into the most destructive button in the product.
|
|
32
|
+
*
|
|
33
|
+
* 3. **A transaction that throws still commits its partial entry, then rethrows.** Whatever reached
|
|
34
|
+
* the artifact before the throw is real, and the only thing worse than a partial write is a
|
|
35
|
+
* partial write nobody can undo. Note that `applyEdits` does not throw — it returns `failed`
|
|
36
|
+
* outcomes — so this path is genuinely exceptional rather than routine.
|
|
37
|
+
*
|
|
38
|
+
* ## The one thing this tier cannot do
|
|
39
|
+
*
|
|
40
|
+
* `replacePart` is an upsert, and `EditTarget` deliberately has no delete verb, so **undoing the
|
|
41
|
+
* creation of a part cannot remove it** — the best available inverse is writing empty content back.
|
|
42
|
+
* Each change records `existedBefore` so a host that *can* delete may consult the entry and finish
|
|
43
|
+
* the job properly. Pretending otherwise would be the more dangerous choice: a caller would believe
|
|
44
|
+
* a part had gone away while it was still listed and still readable.
|
|
45
|
+
*/
|
|
46
|
+
import type { EditTarget } from "./target.js";
|
|
47
|
+
import type { PartId, Snapshot } from "./types.js";
|
|
48
|
+
/** What one part looked like before and after a single transaction touched it. */
|
|
49
|
+
export interface PartChange {
|
|
50
|
+
readonly id: PartId;
|
|
51
|
+
/** Content prior to the change. Empty when the part did not exist — see `existedBefore`. */
|
|
52
|
+
readonly before: string;
|
|
53
|
+
/** Content written by the change. */
|
|
54
|
+
readonly after: string;
|
|
55
|
+
/**
|
|
56
|
+
* Whether the part existed before this change.
|
|
57
|
+
*
|
|
58
|
+
* `false` means the transaction created it, and undo therefore cannot fully invert it — see this
|
|
59
|
+
* module's doc. A host with its own delete capability can use this flag to do better.
|
|
60
|
+
*/
|
|
61
|
+
readonly existedBefore: boolean;
|
|
62
|
+
}
|
|
63
|
+
/** One undoable step: every part change a single transaction produced, in the order applied. */
|
|
64
|
+
export interface HistoryEntry {
|
|
65
|
+
/** Optional caller-supplied description, e.g. the model turn that produced it. Untrusted text. */
|
|
66
|
+
readonly label?: string;
|
|
67
|
+
/** Part changes in application order. Undo replays these in reverse. */
|
|
68
|
+
readonly changes: readonly PartChange[];
|
|
69
|
+
}
|
|
70
|
+
/** Tuning for `createEditHistory`. */
|
|
71
|
+
export interface EditHistoryOptions {
|
|
72
|
+
/**
|
|
73
|
+
* Maximum number of undoable entries retained. Oldest are dropped first.
|
|
74
|
+
*
|
|
75
|
+
* Bounded because every entry holds full copies of the content it touched, so an unbounded stack
|
|
76
|
+
* in a long authoring session grows with total bytes written rather than with artifact size.
|
|
77
|
+
* Defaults to 50.
|
|
78
|
+
*/
|
|
79
|
+
readonly limit?: number;
|
|
80
|
+
}
|
|
81
|
+
/** An operation-level undo/redo stack over one `EditTarget`. */
|
|
82
|
+
export interface EditHistory {
|
|
83
|
+
/**
|
|
84
|
+
* Run `work` against a recording view of the target, committing everything it changed as one
|
|
85
|
+
* undoable entry.
|
|
86
|
+
*
|
|
87
|
+
* The target handed to `work` is a transparent wrapper: pass it to `applyEdit`/`applyEdits`
|
|
88
|
+
* exactly as you would the real one. Grouping is what makes a multi-part model turn undo as a
|
|
89
|
+
* single step rather than as N unrelated ones.
|
|
90
|
+
*
|
|
91
|
+
* A transaction that changes nothing records nothing, so a rejected model turn leaves no empty
|
|
92
|
+
* step for a user to click past. Committing also clears the redo stack, which is the standard
|
|
93
|
+
* rule: once you branch off the redo path, the abandoned future is no longer reachable.
|
|
94
|
+
*/
|
|
95
|
+
transaction<T>(requiredArgs: {
|
|
96
|
+
readonly work: (recording: EditTarget) => Promise<T>;
|
|
97
|
+
}, optionalArgs?: {
|
|
98
|
+
readonly label?: string;
|
|
99
|
+
}): Promise<T>;
|
|
100
|
+
/** Whether `undo` would do anything. */
|
|
101
|
+
canUndo(): boolean;
|
|
102
|
+
/** Whether `redo` would do anything. */
|
|
103
|
+
canRedo(): boolean;
|
|
104
|
+
/**
|
|
105
|
+
* Revert the most recent entry, writing each part's `before` content in reverse order.
|
|
106
|
+
*
|
|
107
|
+
* @returns the entry that was reverted, or `null` when there is nothing to undo.
|
|
108
|
+
*/
|
|
109
|
+
undo(): Promise<HistoryEntry | null>;
|
|
110
|
+
/**
|
|
111
|
+
* Re-apply the most recently undone entry, writing each part's `after` content in order.
|
|
112
|
+
*
|
|
113
|
+
* @returns the entry that was re-applied, or `null` when there is nothing to redo.
|
|
114
|
+
*/
|
|
115
|
+
redo(): Promise<HistoryEntry | null>;
|
|
116
|
+
/**
|
|
117
|
+
* Restore a snapshot as an **undoable** step rather than a destructive one.
|
|
118
|
+
*
|
|
119
|
+
* Current content is captured first and recorded as the `before` side, so the rewind can itself
|
|
120
|
+
* be undone. Parts present now but absent from the snapshot are left alone — there is no delete
|
|
121
|
+
* verb to remove them with (see this module's doc).
|
|
122
|
+
*
|
|
123
|
+
* @returns the entry recorded, or `null` when the snapshot already matches current content.
|
|
124
|
+
*/
|
|
125
|
+
restore(args: {
|
|
126
|
+
readonly snapshot: Snapshot;
|
|
127
|
+
}): Promise<HistoryEntry | null>;
|
|
128
|
+
/** Undoable entries, oldest first. For rendering a history UI; do not mutate. */
|
|
129
|
+
entries(): readonly HistoryEntry[];
|
|
130
|
+
/** Discard both stacks. */
|
|
131
|
+
clear(): void;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Create an undo/redo stack over one target.
|
|
135
|
+
*
|
|
136
|
+
* @param requiredArgs - the host's artifact port; used for reads and replaying changes.
|
|
137
|
+
* @param options - see `EditHistoryOptions`.
|
|
138
|
+
* @returns a history whose `transaction` wrapper is the only thing callers need to adopt.
|
|
139
|
+
* @complexity 4
|
|
140
|
+
*/
|
|
141
|
+
export declare function createEditHistory({ target }: {
|
|
142
|
+
readonly target: EditTarget;
|
|
143
|
+
}, options?: EditHistoryOptions): EditHistory;
|
|
144
|
+
//# sourceMappingURL=history.d.ts.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/** Reads a part's current content, reporting absence rather than throwing. */
|
|
2
|
+
async function readCurrent(target, id) {
|
|
3
|
+
try {
|
|
4
|
+
return { content: await target.readPart({ id }), existed: true };
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return { content: "", existed: false };
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Create an undo/redo stack over one target.
|
|
12
|
+
*
|
|
13
|
+
* @param requiredArgs - the host's artifact port; used for reads and replaying changes.
|
|
14
|
+
* @param options - see `EditHistoryOptions`.
|
|
15
|
+
* @returns a history whose `transaction` wrapper is the only thing callers need to adopt.
|
|
16
|
+
* @complexity 4
|
|
17
|
+
*/
|
|
18
|
+
export function createEditHistory({ target }, options = {}) {
|
|
19
|
+
const limit = options.limit ?? 50;
|
|
20
|
+
const undoStack = [];
|
|
21
|
+
const redoStack = [];
|
|
22
|
+
let transactionOpen = false;
|
|
23
|
+
function push(entry) {
|
|
24
|
+
undoStack.push(entry);
|
|
25
|
+
while (undoStack.length > limit)
|
|
26
|
+
undoStack.shift();
|
|
27
|
+
redoStack.length = 0;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Replay part contents directly, deliberately skipping `validate` — see this module's doc for
|
|
31
|
+
* why an intermediate state of a multi-part rewind must not be judged.
|
|
32
|
+
*/
|
|
33
|
+
async function replay(changes, side) {
|
|
34
|
+
for (const change of changes) {
|
|
35
|
+
await target.replacePart({ id: change.id, content: side === "before" ? change.before : change.after });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
async transaction({ work }, { label } = {}) {
|
|
40
|
+
if (transactionOpen) {
|
|
41
|
+
throw new Error("a transaction is already open — overlapping transactions would interleave their changes into one another's entries");
|
|
42
|
+
}
|
|
43
|
+
transactionOpen = true;
|
|
44
|
+
const changes = [];
|
|
45
|
+
const recording = {
|
|
46
|
+
listParts: () => target.listParts(),
|
|
47
|
+
readPart: (args) => target.readPart(args),
|
|
48
|
+
snapshot: () => target.snapshot(),
|
|
49
|
+
restore: (args) => target.restore(args),
|
|
50
|
+
validate: (candidate) => target.validate(candidate),
|
|
51
|
+
async replacePart({ id, content }) {
|
|
52
|
+
const current = await readCurrent(target, id);
|
|
53
|
+
await target.replacePart({ id, content });
|
|
54
|
+
// A write that changed nothing is not a step a person can meaningfully undo.
|
|
55
|
+
if (current.existed && current.content === content)
|
|
56
|
+
return;
|
|
57
|
+
changes.push({
|
|
58
|
+
id,
|
|
59
|
+
before: current.content,
|
|
60
|
+
after: content,
|
|
61
|
+
existedBefore: current.existed,
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
return await work(recording);
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
transactionOpen = false;
|
|
70
|
+
// Committed even when `work` threw: partial writes already reached the artifact, and the
|
|
71
|
+
// caller needs them to be undoable.
|
|
72
|
+
if (changes.length > 0)
|
|
73
|
+
push(label === undefined ? { changes } : { label, changes });
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
canUndo: () => undoStack.length > 0,
|
|
77
|
+
canRedo: () => redoStack.length > 0,
|
|
78
|
+
async undo() {
|
|
79
|
+
// Peeked, not popped: a `replacePart` that throws partway through leaves the earlier parts
|
|
80
|
+
// already rewound, and popping first would strand that half-applied entry in NEITHER stack —
|
|
81
|
+
// the artifact changed and the history API could no longer reach it. Same principle decision
|
|
82
|
+
// 3 in this module's doc states for `transaction`. Moving between stacks only after a clean
|
|
83
|
+
// replay means a failed undo is simply retryable: replaying `before` again rewrites the
|
|
84
|
+
// parts that already landed with the content they already hold (a no-op) and retries the one
|
|
85
|
+
// that failed.
|
|
86
|
+
const entry = undoStack[undoStack.length - 1];
|
|
87
|
+
if (!entry)
|
|
88
|
+
return null;
|
|
89
|
+
await replay([...entry.changes].reverse(), "before");
|
|
90
|
+
undoStack.pop();
|
|
91
|
+
redoStack.push(entry);
|
|
92
|
+
return entry;
|
|
93
|
+
},
|
|
94
|
+
async redo() {
|
|
95
|
+
// Peeked for the same reason as `undo` above, in the other direction.
|
|
96
|
+
const entry = redoStack[redoStack.length - 1];
|
|
97
|
+
if (!entry)
|
|
98
|
+
return null;
|
|
99
|
+
await replay(entry.changes, "after");
|
|
100
|
+
redoStack.pop();
|
|
101
|
+
undoStack.push(entry);
|
|
102
|
+
return entry;
|
|
103
|
+
},
|
|
104
|
+
async restore({ snapshot }) {
|
|
105
|
+
const changes = [];
|
|
106
|
+
for (const [id, after] of Object.entries(snapshot.parts)) {
|
|
107
|
+
const current = await readCurrent(target, id);
|
|
108
|
+
if (current.existed && current.content === after)
|
|
109
|
+
continue;
|
|
110
|
+
changes.push({ id, before: current.content, after, existedBefore: current.existed });
|
|
111
|
+
}
|
|
112
|
+
if (changes.length === 0)
|
|
113
|
+
return null;
|
|
114
|
+
// Recorded BEFORE the replay, not after. A restore that throws on its second part has still
|
|
115
|
+
// changed the first, and pushing afterwards left those writes with no undo record at all —
|
|
116
|
+
// the destructive-restore failure mode decision 2 in this module's doc exists to prevent,
|
|
117
|
+
// reached by a different route. The `before` sides were captured above and are correct
|
|
118
|
+
// regardless of how far the replay got: undoing a partially-applied restore rewrites every
|
|
119
|
+
// listed part with the content it had before, which is a no-op for the ones never reached.
|
|
120
|
+
const entry = { label: `restore ${snapshot.id}`, changes };
|
|
121
|
+
push(entry);
|
|
122
|
+
await replay(changes, "after");
|
|
123
|
+
return entry;
|
|
124
|
+
},
|
|
125
|
+
entries: () => [...undoStack],
|
|
126
|
+
clear() {
|
|
127
|
+
undoStack.length = 0;
|
|
128
|
+
redoStack.length = 0;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=history.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module core
|
|
3
|
+
*
|
|
4
|
+
* The framework-free half of `@jini-ai/vibecoding`: the authoring loop and the artifact port it
|
|
5
|
+
* drives. No React, no Node built-ins, no DOM — so a server, a worker or a CLI can import this
|
|
6
|
+
* without acquiring a UI dependency.
|
|
7
|
+
*
|
|
8
|
+
* That separation is enforced by the same rule that governs the rest of this workspace, and it is
|
|
9
|
+
* not theoretical: a sibling consolidation was measured and abandoned precisely because a Node
|
|
10
|
+
* HTTP server imports a framework-free chat package directly, and folding a React layer into it
|
|
11
|
+
* would have forced React onto that server.
|
|
12
|
+
*/
|
|
13
|
+
export type { ApplyOutcome, PartId, PartRef, Snapshot, ValidationResult, } from "./types.js";
|
|
14
|
+
export type { EditTarget } from "./target.js";
|
|
15
|
+
export type { ProposedEdit } from "./apply.js";
|
|
16
|
+
export { applyEdit, applyEdits, correctionsFor } from "./apply.js";
|
|
17
|
+
export type { EditHistory, EditHistoryOptions, HistoryEntry, PartChange } from "./history.js";
|
|
18
|
+
export { createEditHistory } from "./history.js";
|
|
19
|
+
//# sourceMappingURL=index.d.ts.map
|