@jini-ai/vibecoding 0.4.3 → 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 +44 -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
package/dist/session.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { applyEdits as applyEditsToTarget, correctionsFor } from './core/apply.js';
|
|
2
|
+
import { createEditHistory } from './core/history.js';
|
|
3
|
+
const READY_ERROR = undefined;
|
|
4
|
+
/**
|
|
5
|
+
* Builds a session over `target`, with private history by default or injected project history.
|
|
6
|
+
* Injected applyTurn owns whole-turn recording; this session owns notifications and cache invalidation.
|
|
7
|
+
*
|
|
8
|
+
* The returned session starts in `'idle'` with no parts listed — call `refresh()` (or let
|
|
9
|
+
* `useVibecodingSession` do it on mount) to populate it. Nothing here triggers I/O eagerly, so
|
|
10
|
+
* constructing a session has no side effect a caller needs to clean up.
|
|
11
|
+
*
|
|
12
|
+
* @complexity Construction is O(1). See each method for its own cost.
|
|
13
|
+
*/
|
|
14
|
+
export function createVibecodingSession({ target }, options = {}) {
|
|
15
|
+
const history = options.history ?? createEditHistory({ target }, options.historyOptions);
|
|
16
|
+
// Project turns write through revision pointers, so cached part content is never reusable after them.
|
|
17
|
+
const applyTurn = options.applyTurn;
|
|
18
|
+
const listeners = new Set();
|
|
19
|
+
const partContent = new Map();
|
|
20
|
+
let current = {
|
|
21
|
+
status: 'idle',
|
|
22
|
+
parts: [],
|
|
23
|
+
partContent,
|
|
24
|
+
canUndo: history.canUndo(),
|
|
25
|
+
canRedo: history.canRedo(),
|
|
26
|
+
lastError: READY_ERROR,
|
|
27
|
+
};
|
|
28
|
+
/** Replaces `current` with a fresh object carrying `patch`, then notifies subscribers. A fresh
|
|
29
|
+
* `partContent` Map reference is handed out only when the map's contents actually changed
|
|
30
|
+
* (callers pass a new Map in `patch` for that case); reusing the same Map reference otherwise
|
|
31
|
+
* is what keeps an unrelated status change (e.g. `'loading'` -> `'ready'`) from invalidating a
|
|
32
|
+
* component's memoized read of `partContent`. */
|
|
33
|
+
function commit(patch) {
|
|
34
|
+
current = { ...current, ...patch };
|
|
35
|
+
for (const listener of listeners)
|
|
36
|
+
listener();
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Runs `work`, tagging the session `'loading'` -> `'ready'`/`'error'` and always returning
|
|
40
|
+
* control to the caller — a rejection is recorded as `lastError` AND rethrown, so a component
|
|
41
|
+
* can render `lastError` passively while a tool handler that needs to fail loudly still can.
|
|
42
|
+
*
|
|
43
|
+
* `work` returns its data `patch` alongside its `result` rather than calling `commit` itself, so
|
|
44
|
+
* the success path here can fold that patch into the SAME commit as the `'ready'` status
|
|
45
|
+
* transition — one notification per operation instead of two, which matters to a
|
|
46
|
+
* `useSyncExternalStore` subscriber re-rendering on every commit.
|
|
47
|
+
*/
|
|
48
|
+
async function run(work) {
|
|
49
|
+
commit({ status: 'loading' });
|
|
50
|
+
try {
|
|
51
|
+
const { result, patch } = await work();
|
|
52
|
+
commit({ ...patch, status: 'ready', lastError: READY_ERROR });
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
catch (thrown) {
|
|
56
|
+
const message = thrown instanceof Error ? thrown.message : String(thrown);
|
|
57
|
+
if (applyTurn)
|
|
58
|
+
partContent.clear();
|
|
59
|
+
commit({ status: 'error', lastError: message, partContent: new Map(partContent), canUndo: history.canUndo(), canRedo: history.canRedo() });
|
|
60
|
+
throw thrown;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Mirrors a replayed change in the cache. Undoing creation keeps empty content because the
|
|
64
|
+
* target's upsert port has no delete operation; see `./core/history.ts`. */
|
|
65
|
+
function cacheChange(change, side) {
|
|
66
|
+
partContent.set(change.id, side === 'after' ? change.after : change.before);
|
|
67
|
+
}
|
|
68
|
+
/** Mirrors a recorded snapshot restore in the cache and re-reads the part list it may have grown. */
|
|
69
|
+
async function restoredPatch(entry) {
|
|
70
|
+
if (applyTurn)
|
|
71
|
+
partContent.clear();
|
|
72
|
+
else
|
|
73
|
+
for (const change of entry.changes)
|
|
74
|
+
cacheChange(change, 'after');
|
|
75
|
+
const parts = await target.listParts();
|
|
76
|
+
return { parts, partContent: new Map(partContent), canUndo: history.canUndo(), canRedo: history.canRedo() };
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
target,
|
|
80
|
+
history,
|
|
81
|
+
getSnapshot: () => current,
|
|
82
|
+
subscribe({ listener }) {
|
|
83
|
+
listeners.add(listener);
|
|
84
|
+
return () => listeners.delete(listener);
|
|
85
|
+
},
|
|
86
|
+
refresh: () => run(async () => {
|
|
87
|
+
const parts = await target.listParts();
|
|
88
|
+
if (applyTurn)
|
|
89
|
+
partContent.clear();
|
|
90
|
+
const known = new Set(parts.map((p) => p.id));
|
|
91
|
+
// Drop cached content for parts the host no longer lists — stale content for an
|
|
92
|
+
// unaddressable id would otherwise linger forever with nothing to invalidate it.
|
|
93
|
+
for (const id of partContent.keys())
|
|
94
|
+
if (!known.has(id))
|
|
95
|
+
partContent.delete(id);
|
|
96
|
+
return {
|
|
97
|
+
result: undefined,
|
|
98
|
+
patch: { parts, partContent: new Map(partContent), canUndo: history.canUndo(), canRedo: history.canRedo() },
|
|
99
|
+
};
|
|
100
|
+
}),
|
|
101
|
+
readPart: ({ id }) => run(async () => {
|
|
102
|
+
const cached = applyTurn ? undefined : partContent.get(id);
|
|
103
|
+
if (cached !== undefined)
|
|
104
|
+
return { result: cached, patch: {} };
|
|
105
|
+
if (applyTurn)
|
|
106
|
+
partContent.clear();
|
|
107
|
+
const content = await target.readPart({ id });
|
|
108
|
+
partContent.set(id, content);
|
|
109
|
+
return { result: content, patch: { partContent: new Map(partContent), canUndo: history.canUndo(), canRedo: history.canRedo() } };
|
|
110
|
+
}),
|
|
111
|
+
applyEdits: ({ edits }, options = {}) => run(async () => {
|
|
112
|
+
const outcomes = await (applyTurn
|
|
113
|
+
? applyTurn({ edits }, options)
|
|
114
|
+
: history.transaction({ work: (recording) => applyEditsToTarget({ target: recording, edits }) }, options));
|
|
115
|
+
if (applyTurn)
|
|
116
|
+
partContent.clear();
|
|
117
|
+
// Outcomes are aligned with proposals, including repeated ids. Matching only by id
|
|
118
|
+
// would cache the first proposal instead of the last successful write for that part.
|
|
119
|
+
// This pass is O(edits.length), with no additional reads through the target port.
|
|
120
|
+
for (const [index, outcome] of outcomes.entries()) {
|
|
121
|
+
if (outcome.status !== 'applied')
|
|
122
|
+
continue;
|
|
123
|
+
const edit = edits[index];
|
|
124
|
+
if (edit && !applyTurn)
|
|
125
|
+
partContent.set(edit.id, edit.content);
|
|
126
|
+
}
|
|
127
|
+
const parts = await target.listParts();
|
|
128
|
+
return {
|
|
129
|
+
result: { outcomes, corrections: correctionsFor({ outcomes }) },
|
|
130
|
+
patch: {
|
|
131
|
+
parts,
|
|
132
|
+
partContent: new Map(partContent),
|
|
133
|
+
canUndo: history.canUndo(),
|
|
134
|
+
canRedo: history.canRedo(),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}),
|
|
138
|
+
undo: () => run(async () => {
|
|
139
|
+
const entry = await history.undo();
|
|
140
|
+
if (!entry)
|
|
141
|
+
return { result: entry, patch: {} };
|
|
142
|
+
// Undo replays in reverse order. Repeated ids must leave the earliest before value
|
|
143
|
+
// cached, matching what history wrote to the target.
|
|
144
|
+
if (applyTurn)
|
|
145
|
+
partContent.clear();
|
|
146
|
+
else
|
|
147
|
+
for (const change of [...entry.changes].reverse())
|
|
148
|
+
cacheChange(change, 'before');
|
|
149
|
+
const parts = applyTurn ? await target.listParts() : current.parts;
|
|
150
|
+
return {
|
|
151
|
+
result: entry,
|
|
152
|
+
patch: { parts, partContent: new Map(partContent), canUndo: history.canUndo(), canRedo: history.canRedo() },
|
|
153
|
+
};
|
|
154
|
+
}),
|
|
155
|
+
redo: () => run(async () => {
|
|
156
|
+
const entry = await history.redo();
|
|
157
|
+
if (!entry)
|
|
158
|
+
return { result: entry, patch: {} };
|
|
159
|
+
if (applyTurn)
|
|
160
|
+
partContent.clear();
|
|
161
|
+
else
|
|
162
|
+
for (const change of entry.changes)
|
|
163
|
+
cacheChange(change, 'after');
|
|
164
|
+
const parts = applyTurn ? await target.listParts() : current.parts;
|
|
165
|
+
return {
|
|
166
|
+
result: entry,
|
|
167
|
+
patch: { parts, partContent: new Map(partContent), canUndo: history.canUndo(), canRedo: history.canRedo() },
|
|
168
|
+
};
|
|
169
|
+
}),
|
|
170
|
+
takeSnapshot: () => run(async () => ({ result: await target.snapshot(), patch: {} })),
|
|
171
|
+
restoreSnapshot: ({ snapshot }) => run(async () => {
|
|
172
|
+
const entry = await history.restore({ snapshot });
|
|
173
|
+
// A restore that already matched records no entry and changes nothing to mirror.
|
|
174
|
+
return { result: entry, patch: entry ? await restoredPatch(entry) : {} };
|
|
175
|
+
}),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
export * from "./tools.js";
|
|
179
|
+
//# sourceMappingURL=session.js.map
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tools
|
|
3
|
+
*
|
|
4
|
+
* Turns one `VibecodingSession` (`./session.js`) into the chat-callable control surface the user's
|
|
5
|
+
* vision describes: "tools in the vibecoding package that let us control everything from chat."
|
|
6
|
+
* Every tool here is declared as an `@jini-ai/core` `ToolDescriptor` — the kernel's own
|
|
7
|
+
* discoverable-tool shape (`id`, `description`, `inputSchema`, `readOnly`, ...) — so a host wires
|
|
8
|
+
* these into a real `ToolRegistry` and a chat surface enumerates them via `registry.list({})` rather
|
|
9
|
+
* than importing a hardcoded list of vibecoding-specific function names.
|
|
10
|
+
*
|
|
11
|
+
* ## Two ways to run these tools, because `@jini-ai/core`'s registry deliberately hides handlers
|
|
12
|
+
*
|
|
13
|
+
* `ToolRegistry` (`@jini-ai/core`) never lets a caller retrieve a registered handler directly —
|
|
14
|
+
* only `authorizeToolInvocation` can, and that function is exported from `@jini-ai/core/composition`
|
|
15
|
+
* for `@jini-ai/daemon`'s `ToolExecutor` alone (see that file's own module doc: "the one and only
|
|
16
|
+
* intended caller"). That is the right boundary for a real backend, but it means a browser host
|
|
17
|
+
* with no daemon in front of it — the common case for `./react`, a chat surface running client-side
|
|
18
|
+
* — has no way to actually invoke a tool it just registered.
|
|
19
|
+
*
|
|
20
|
+
* So this module exposes two views over the same underlying definitions:
|
|
21
|
+
*
|
|
22
|
+
* - {@link createVibecodingToolRegistrations} — real `ToolRegistration`s, ready for
|
|
23
|
+
* `registry.register(...)` on any `@jini-ai/core` `ToolRegistry`. This is what makes the tools
|
|
24
|
+
* genuinely discoverable and, once a real daemon sits in front of this session, genuinely
|
|
25
|
+
* executable through `ToolExecutor`'s authorized path.
|
|
26
|
+
* - {@link createVibecodingToolRunner} — the same tool ids and descriptors, plus a `run({ toolId, input })`
|
|
27
|
+
* a host without a daemon calls directly. This is not a bypass of the registry's authorization
|
|
28
|
+
* design: these tools have no principal/run/authorization concept of their own to bypass — every
|
|
29
|
+
* one of them acts on a single in-process `VibecodingSession` the host itself already holds, and
|
|
30
|
+
* the actual security boundary (which parts are addressable, and whether a proposed edit may
|
|
31
|
+
* land) is enforced by `EditTarget.validate` inside `session.applyEdits` regardless of which of
|
|
32
|
+
* these two paths reached it. A host that DOES have a daemon and wants centralized authorization
|
|
33
|
+
* registers through the first path instead and never calls `run` at all.
|
|
34
|
+
*/
|
|
35
|
+
import { type ToolDescriptor, type ToolPolicy, type ToolRegistration } from '@jini-ai/core';
|
|
36
|
+
import type { VibecodingSession } from './session.js';
|
|
37
|
+
/** Stable tool ids, exported so a host building a catalog (composer menu, MCP tool list, ...) from
|
|
38
|
+
* `ToolRegistry.list({})` can still branch on a known id where it needs to (e.g. to route
|
|
39
|
+
* `propose_edits`'s result into a "corrections" UI) without restating the string literal. */
|
|
40
|
+
export declare const VIBECODING_TOOL_IDS: {
|
|
41
|
+
readonly LIST_PARTS: "vibecoding.list_parts";
|
|
42
|
+
readonly READ_PART: "vibecoding.read_part";
|
|
43
|
+
readonly PROPOSE_EDITS: "vibecoding.propose_edits";
|
|
44
|
+
readonly UNDO: "vibecoding.undo";
|
|
45
|
+
readonly REDO: "vibecoding.redo";
|
|
46
|
+
};
|
|
47
|
+
export interface CreateVibecodingToolRegistrationsOptions {
|
|
48
|
+
/** Overrides {@link ALLOW_ALWAYS} for every registration this call produces. */
|
|
49
|
+
readonly policy?: ToolPolicy;
|
|
50
|
+
}
|
|
51
|
+
/** Required port shared by the registration and direct-runner factories. */
|
|
52
|
+
export interface VibecodingToolArgs {
|
|
53
|
+
readonly session: VibecodingSession;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Builds `ToolRegistration`s ready for `registry.register(...)` on any `@jini-ai/core`
|
|
57
|
+
* `ToolRegistry`. This is the "must register there, not be bespoke" seam: a chat surface backed by
|
|
58
|
+
* a real daemon enumerates these through `registry.list({})` and runs them through
|
|
59
|
+
* `ToolExecutor`/`authorizeToolInvocation`, never through a hardcoded switch on tool name.
|
|
60
|
+
*
|
|
61
|
+
* @param requiredArgs - the artifact session port every tool call acts on.
|
|
62
|
+
* @param options - see {@link CreateVibecodingToolRegistrationsOptions}.
|
|
63
|
+
* @returns one `ToolRegistration` per tool in `VIBECODING_TOOL_IDS`, in that constant's order.
|
|
64
|
+
* @complexity O(1) — five fixed registrations.
|
|
65
|
+
*/
|
|
66
|
+
export declare function createVibecodingToolRegistrations({ session }: VibecodingToolArgs, options?: CreateVibecodingToolRegistrationsOptions): ToolRegistration[];
|
|
67
|
+
export interface VibecodingToolRunner {
|
|
68
|
+
/** The same descriptors {@link createVibecodingToolRegistrations} would register — a host with no
|
|
69
|
+
* `ToolRegistry` in play yet (e.g. building a composer catalog directly) reads this instead of
|
|
70
|
+
* standing up a registry purely to call `.list({})` once. */
|
|
71
|
+
readonly descriptors: readonly ToolDescriptor[];
|
|
72
|
+
/** Runs one tool by id directly against the bound session. Rejects with `ToolInputError` for a
|
|
73
|
+
* malformed `input`, with `RangeError` for an unknown `toolId`, or with whatever `session`'s own
|
|
74
|
+
* operation rejects with (see `./session.js`). */
|
|
75
|
+
run(args: {
|
|
76
|
+
readonly toolId: string;
|
|
77
|
+
readonly input: unknown;
|
|
78
|
+
}): Promise<unknown>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The no-daemon path — see this module's doc for why one is needed at all. Builds one runner over
|
|
82
|
+
* `session` that a browser-only host (no `@jini-ai/daemon` in front of it) can call directly from,
|
|
83
|
+
* e.g., a composer's `onDiscoverySelect` or a mock chat transport.
|
|
84
|
+
*
|
|
85
|
+
* @param requiredArgs - the artifact session port every tool call acts on.
|
|
86
|
+
* @returns descriptors plus a `run` dispatcher — see {@link VibecodingToolRunner}.
|
|
87
|
+
* @complexity `run` is O(n) in tool count (currently 5) to resolve `toolId`, then whatever the
|
|
88
|
+
* resolved handler costs.
|
|
89
|
+
*/
|
|
90
|
+
export declare function createVibecodingToolRunner({ session }: VibecodingToolArgs): VibecodingToolRunner;
|
|
91
|
+
//# sourceMappingURL=tools.d.ts.map
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module tools
|
|
3
|
+
*
|
|
4
|
+
* Turns one `VibecodingSession` (`./session.js`) into the chat-callable control surface the user's
|
|
5
|
+
* vision describes: "tools in the vibecoding package that let us control everything from chat."
|
|
6
|
+
* Every tool here is declared as an `@jini-ai/core` `ToolDescriptor` — the kernel's own
|
|
7
|
+
* discoverable-tool shape (`id`, `description`, `inputSchema`, `readOnly`, ...) — so a host wires
|
|
8
|
+
* these into a real `ToolRegistry` and a chat surface enumerates them via `registry.list({})` rather
|
|
9
|
+
* than importing a hardcoded list of vibecoding-specific function names.
|
|
10
|
+
*
|
|
11
|
+
* ## Two ways to run these tools, because `@jini-ai/core`'s registry deliberately hides handlers
|
|
12
|
+
*
|
|
13
|
+
* `ToolRegistry` (`@jini-ai/core`) never lets a caller retrieve a registered handler directly —
|
|
14
|
+
* only `authorizeToolInvocation` can, and that function is exported from `@jini-ai/core/composition`
|
|
15
|
+
* for `@jini-ai/daemon`'s `ToolExecutor` alone (see that file's own module doc: "the one and only
|
|
16
|
+
* intended caller"). That is the right boundary for a real backend, but it means a browser host
|
|
17
|
+
* with no daemon in front of it — the common case for `./react`, a chat surface running client-side
|
|
18
|
+
* — has no way to actually invoke a tool it just registered.
|
|
19
|
+
*
|
|
20
|
+
* So this module exposes two views over the same underlying definitions:
|
|
21
|
+
*
|
|
22
|
+
* - {@link createVibecodingToolRegistrations} — real `ToolRegistration`s, ready for
|
|
23
|
+
* `registry.register(...)` on any `@jini-ai/core` `ToolRegistry`. This is what makes the tools
|
|
24
|
+
* genuinely discoverable and, once a real daemon sits in front of this session, genuinely
|
|
25
|
+
* executable through `ToolExecutor`'s authorized path.
|
|
26
|
+
* - {@link createVibecodingToolRunner} — the same tool ids and descriptors, plus a `run({ toolId, input })`
|
|
27
|
+
* a host without a daemon calls directly. This is not a bypass of the registry's authorization
|
|
28
|
+
* design: these tools have no principal/run/authorization concept of their own to bypass — every
|
|
29
|
+
* one of them acts on a single in-process `VibecodingSession` the host itself already holds, and
|
|
30
|
+
* the actual security boundary (which parts are addressable, and whether a proposed edit may
|
|
31
|
+
* land) is enforced by `EditTarget.validate` inside `session.applyEdits` regardless of which of
|
|
32
|
+
* these two paths reached it. A host that DOES have a daemon and wants centralized authorization
|
|
33
|
+
* registers through the first path instead and never calls `run` at all.
|
|
34
|
+
*/
|
|
35
|
+
import { ToolInputError, } from '@jini-ai/core';
|
|
36
|
+
/** Stable tool ids, exported so a host building a catalog (composer menu, MCP tool list, ...) from
|
|
37
|
+
* `ToolRegistry.list({})` can still branch on a known id where it needs to (e.g. to route
|
|
38
|
+
* `propose_edits`'s result into a "corrections" UI) without restating the string literal. */
|
|
39
|
+
export const VIBECODING_TOOL_IDS = {
|
|
40
|
+
LIST_PARTS: 'vibecoding.list_parts',
|
|
41
|
+
READ_PART: 'vibecoding.read_part',
|
|
42
|
+
PROPOSE_EDITS: 'vibecoding.propose_edits',
|
|
43
|
+
UNDO: 'vibecoding.undo',
|
|
44
|
+
REDO: 'vibecoding.redo',
|
|
45
|
+
};
|
|
46
|
+
/** A proposed edit in one `vibecoding.propose_edits` call is capped so a single model turn cannot
|
|
47
|
+
* ask this session to rewrite an unbounded number of parts in one transaction — see the
|
|
48
|
+
* Programmer pre-check on service-backed collections this module was written against. Chosen
|
|
49
|
+
* generously above any realistic single-turn edit count rather than tuned to one; a host needing
|
|
50
|
+
* fewer or more can still bound its own model prompt independently. */
|
|
51
|
+
const MAX_EDITS_PER_INVOCATION = 50;
|
|
52
|
+
/** No-argument tools still declare an (empty) object schema rather than omitting `inputSchema`, so
|
|
53
|
+
* a model sees explicitly that `{}` is the correct call shape instead of guessing. */
|
|
54
|
+
const EMPTY_INPUT_SCHEMA = { type: 'object', additionalProperties: false };
|
|
55
|
+
const READ_PART_INPUT_SCHEMA = {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
id: { type: 'string', description: 'A part id from a prior vibecoding.list_parts call.' },
|
|
59
|
+
},
|
|
60
|
+
required: ['id'],
|
|
61
|
+
additionalProperties: false,
|
|
62
|
+
};
|
|
63
|
+
const PROPOSE_EDITS_INPUT_SCHEMA = {
|
|
64
|
+
type: 'object',
|
|
65
|
+
properties: {
|
|
66
|
+
edits: {
|
|
67
|
+
type: 'array',
|
|
68
|
+
minItems: 1,
|
|
69
|
+
maxItems: MAX_EDITS_PER_INVOCATION,
|
|
70
|
+
items: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
id: { type: 'string', description: 'A part id from a prior vibecoding.list_parts call.' },
|
|
74
|
+
content: { type: 'string', description: "The part's full replacement content." },
|
|
75
|
+
},
|
|
76
|
+
required: ['id', 'content'],
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
label: {
|
|
81
|
+
type: 'string',
|
|
82
|
+
description: 'Optional human-facing description of this turn, shown in undo history.',
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
required: ['edits'],
|
|
86
|
+
additionalProperties: false,
|
|
87
|
+
};
|
|
88
|
+
/** Narrows `input` to a plain record, the same first check every handler below needs before
|
|
89
|
+
* reading a named field out of a value that arrived as `unknown` model-authored JSON. */
|
|
90
|
+
function requireInputRecord(input, toolId) {
|
|
91
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
92
|
+
throw new ToolInputError({ message: `${toolId}: expected an input object` });
|
|
93
|
+
}
|
|
94
|
+
return input;
|
|
95
|
+
}
|
|
96
|
+
function requireString(record, key, toolId) {
|
|
97
|
+
const value = record[key];
|
|
98
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
99
|
+
throw new ToolInputError({ message: `${toolId}: "${key}" must be a non-empty string` });
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
/** Validates the `edits` array's shape without trusting any of it — `content` may legitimately be
|
|
104
|
+
* an empty string (clearing a part), so only `edits` itself and each entry's `id` are non-empty
|
|
105
|
+
* checked. */
|
|
106
|
+
function requireProposedEdits(record, toolId) {
|
|
107
|
+
const raw = record.edits;
|
|
108
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
109
|
+
throw new ToolInputError({ message: `${toolId}: "edits" must be a non-empty array` });
|
|
110
|
+
}
|
|
111
|
+
if (raw.length > MAX_EDITS_PER_INVOCATION) {
|
|
112
|
+
throw new ToolInputError({ message: `${toolId}: "edits" carries ${raw.length} entries, over the ${MAX_EDITS_PER_INVOCATION}-entry limit per call` });
|
|
113
|
+
}
|
|
114
|
+
return raw.map((entry, index) => {
|
|
115
|
+
const entryRecord = requireInputRecord(entry, `${toolId}.edits[${index}]`);
|
|
116
|
+
const id = requireString(entryRecord, 'id', `${toolId}.edits[${index}]`);
|
|
117
|
+
const content = entryRecord.content;
|
|
118
|
+
if (typeof content !== 'string') {
|
|
119
|
+
throw new ToolInputError({ message: `${toolId}.edits[${index}]: "content" must be a string` });
|
|
120
|
+
}
|
|
121
|
+
return { id, content };
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Builds the five tool definitions bound to `session`. Package-private: hosts reach these only
|
|
126
|
+
* through {@link createVibecodingToolRegistrations} or {@link createVibecodingToolRunner}, which is
|
|
127
|
+
* what keeps "how a definition is invoked" a single decision made in one of those two places rather
|
|
128
|
+
* than duplicated per tool.
|
|
129
|
+
*
|
|
130
|
+
* @complexity O(1) to build the array; each handler's own cost is documented on its descriptor.
|
|
131
|
+
*/
|
|
132
|
+
function buildVibecodingToolDefinitions(session) {
|
|
133
|
+
return [
|
|
134
|
+
{
|
|
135
|
+
descriptor: {
|
|
136
|
+
id: VIBECODING_TOOL_IDS.LIST_PARTS,
|
|
137
|
+
description: 'List every editable part of the artifact currently being authored.',
|
|
138
|
+
inputSchema: EMPTY_INPUT_SCHEMA,
|
|
139
|
+
readOnly: true,
|
|
140
|
+
timeoutMs: 10_000,
|
|
141
|
+
},
|
|
142
|
+
handler: async () => {
|
|
143
|
+
await session.refresh();
|
|
144
|
+
return session.getSnapshot().parts;
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
descriptor: {
|
|
149
|
+
id: VIBECODING_TOOL_IDS.READ_PART,
|
|
150
|
+
description: "Read one part's current content by id.",
|
|
151
|
+
inputSchema: READ_PART_INPUT_SCHEMA,
|
|
152
|
+
readOnly: true,
|
|
153
|
+
timeoutMs: 10_000,
|
|
154
|
+
},
|
|
155
|
+
handler: async (input) => {
|
|
156
|
+
const record = requireInputRecord(input, VIBECODING_TOOL_IDS.READ_PART);
|
|
157
|
+
const id = requireString(record, 'id', VIBECODING_TOOL_IDS.READ_PART);
|
|
158
|
+
return { id, content: await session.readPart({ id }) };
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
descriptor: {
|
|
163
|
+
id: VIBECODING_TOOL_IDS.PROPOSE_EDITS,
|
|
164
|
+
description: 'Propose replacement content for one or more parts, as a single undoable turn. Each edit ' +
|
|
165
|
+
'is validated before anything is written; a rejected edit leaves the artifact untouched ' +
|
|
166
|
+
'and its reason is returned for the next turn.',
|
|
167
|
+
inputSchema: PROPOSE_EDITS_INPUT_SCHEMA,
|
|
168
|
+
readOnly: false,
|
|
169
|
+
timeoutMs: 15_000,
|
|
170
|
+
},
|
|
171
|
+
handler: async (input) => {
|
|
172
|
+
const record = requireInputRecord(input, VIBECODING_TOOL_IDS.PROPOSE_EDITS);
|
|
173
|
+
const edits = requireProposedEdits(record, VIBECODING_TOOL_IDS.PROPOSE_EDITS);
|
|
174
|
+
const label = record.label === undefined ? undefined : requireString(record, 'label', VIBECODING_TOOL_IDS.PROPOSE_EDITS);
|
|
175
|
+
return session.applyEdits({ edits }, label === undefined ? {} : { label });
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
descriptor: {
|
|
180
|
+
id: VIBECODING_TOOL_IDS.UNDO,
|
|
181
|
+
description: 'Revert the most recent proposed-edits turn.',
|
|
182
|
+
inputSchema: EMPTY_INPUT_SCHEMA,
|
|
183
|
+
readOnly: false,
|
|
184
|
+
timeoutMs: 10_000,
|
|
185
|
+
},
|
|
186
|
+
handler: async () => ({ entry: await session.undo() }),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
descriptor: {
|
|
190
|
+
id: VIBECODING_TOOL_IDS.REDO,
|
|
191
|
+
description: 'Re-apply the most recently undone turn.',
|
|
192
|
+
inputSchema: EMPTY_INPUT_SCHEMA,
|
|
193
|
+
readOnly: false,
|
|
194
|
+
timeoutMs: 10_000,
|
|
195
|
+
},
|
|
196
|
+
handler: async () => ({ entry: await session.redo() }),
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
}
|
|
200
|
+
/** Grants every call — see this module's doc for why tool-level authorization has nothing to add
|
|
201
|
+
* beyond `EditTarget.validate`'s own allowlist check for a session with no principal concept of
|
|
202
|
+
* its own. A host with per-principal restrictions passes its own `ToolPolicy` via
|
|
203
|
+
* `createVibecodingToolRegistrations`'s `options.policy` instead of relying on this default. */
|
|
204
|
+
const ALLOW_ALWAYS = { authorize: () => 'allow' };
|
|
205
|
+
/**
|
|
206
|
+
* Builds `ToolRegistration`s ready for `registry.register(...)` on any `@jini-ai/core`
|
|
207
|
+
* `ToolRegistry`. This is the "must register there, not be bespoke" seam: a chat surface backed by
|
|
208
|
+
* a real daemon enumerates these through `registry.list({})` and runs them through
|
|
209
|
+
* `ToolExecutor`/`authorizeToolInvocation`, never through a hardcoded switch on tool name.
|
|
210
|
+
*
|
|
211
|
+
* @param requiredArgs - the artifact session port every tool call acts on.
|
|
212
|
+
* @param options - see {@link CreateVibecodingToolRegistrationsOptions}.
|
|
213
|
+
* @returns one `ToolRegistration` per tool in `VIBECODING_TOOL_IDS`, in that constant's order.
|
|
214
|
+
* @complexity O(1) — five fixed registrations.
|
|
215
|
+
*/
|
|
216
|
+
export function createVibecodingToolRegistrations({ session }, options = {}) {
|
|
217
|
+
const policy = options.policy ?? ALLOW_ALWAYS;
|
|
218
|
+
return buildVibecodingToolDefinitions(session).map(({ descriptor, handler }) => ({
|
|
219
|
+
descriptor,
|
|
220
|
+
policy,
|
|
221
|
+
handler: (ctx) => handler(ctx.input),
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* The no-daemon path — see this module's doc for why one is needed at all. Builds one runner over
|
|
226
|
+
* `session` that a browser-only host (no `@jini-ai/daemon` in front of it) can call directly from,
|
|
227
|
+
* e.g., a composer's `onDiscoverySelect` or a mock chat transport.
|
|
228
|
+
*
|
|
229
|
+
* @param requiredArgs - the artifact session port every tool call acts on.
|
|
230
|
+
* @returns descriptors plus a `run` dispatcher — see {@link VibecodingToolRunner}.
|
|
231
|
+
* @complexity `run` is O(n) in tool count (currently 5) to resolve `toolId`, then whatever the
|
|
232
|
+
* resolved handler costs.
|
|
233
|
+
*/
|
|
234
|
+
export function createVibecodingToolRunner({ session }) {
|
|
235
|
+
const definitions = buildVibecodingToolDefinitions(session);
|
|
236
|
+
return {
|
|
237
|
+
descriptors: definitions.map((d) => d.descriptor),
|
|
238
|
+
async run({ toolId, input }) {
|
|
239
|
+
const definition = definitions.find((d) => d.descriptor.id === toolId);
|
|
240
|
+
if (!definition)
|
|
241
|
+
throw new RangeError(`no vibecoding tool registered with id "${toolId}"`);
|
|
242
|
+
return definition.handler(input);
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=tools.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/vibecoding",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.11",
|
|
4
4
|
"description": "Conversational artifact authoring: a target-agnostic edit loop — addressable parts, validated replace, operation-level undo/redo, and per-turn snapshot/rewind. Framework-free in `/core`; tagged-region HTML documents behind ./html; filesystem targets behind ./node and the chat/preview surface behind ./react.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"./core": "universal",
|
|
18
18
|
"./html": "universal",
|
|
19
19
|
"./html/node": "node",
|
|
20
|
-
"./react": "browser"
|
|
20
|
+
"./react": "browser",
|
|
21
|
+
"./session": "universal",
|
|
22
|
+
"./node": "node"
|
|
21
23
|
}
|
|
22
24
|
},
|
|
23
25
|
"type": "module",
|
|
@@ -49,6 +51,16 @@
|
|
|
49
51
|
"types": "./dist/react/index.d.ts",
|
|
50
52
|
"import": "./dist/react/index.js",
|
|
51
53
|
"default": "./dist/react/index.js"
|
|
54
|
+
},
|
|
55
|
+
"./session": {
|
|
56
|
+
"types": "./dist/session.d.ts",
|
|
57
|
+
"import": "./dist/session.js",
|
|
58
|
+
"default": "./dist/session.js"
|
|
59
|
+
},
|
|
60
|
+
"./node": {
|
|
61
|
+
"types": "./dist/node/index.d.ts",
|
|
62
|
+
"import": "./dist/node/index.js",
|
|
63
|
+
"default": "./dist/node/index.js"
|
|
52
64
|
}
|
|
53
65
|
},
|
|
54
66
|
"files": [
|
|
@@ -75,10 +87,11 @@
|
|
|
75
87
|
"typescript": "^5.9.2",
|
|
76
88
|
"vitest": "^3.2.4",
|
|
77
89
|
"parse5": "^8.0.1",
|
|
78
|
-
"@jini-ai/core": "0.
|
|
90
|
+
"@jini-ai/core": "0.6.11"
|
|
79
91
|
},
|
|
80
92
|
"dependencies": {
|
|
81
|
-
"@jini-ai/
|
|
93
|
+
"@jini-ai/platform": "^0.6.11",
|
|
94
|
+
"@jini-ai/core": "^0.6.11"
|
|
82
95
|
},
|
|
83
96
|
"peerDependencies": {
|
|
84
97
|
"react": "^18.3.0 || ^19.0.0",
|