@jini-ai/vibecoding 0.6.11 → 0.6.15-rc.1

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 CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.13
4
+
5
+ ### Patch Changes
6
+
7
+ - 506196f: BREAKING (host composition): two native effects are now injected by the host instead of loaded from a sibling
8
+ domain (guard L1 / SI-domain clean-up). No default is provided because a default would reintroduce exactly
9
+ that edge.
10
+
11
+ - `@jini-ai/vibecoding` node: `openFileTreeProject` (and the node store/paths inputs) require
12
+ `filesystem: ProjectFilesystemPort` (`createReader`, `withFileLock`, `writeFileAtomicAsync`,
13
+ `writeJsonFileAtomicAsync`). Bind the existing `@jini-ai/platform` implementations in the host's composition root (see the `ProjectFilesystemPort` doc example).
14
+ Hosts that only use `@jini-ai/vibecoding/html*` are unaffected.
15
+ - `@jini-ai/integrations` saved requests: `SavedRequestDeps` requires `executeCredentialedRequest`;
16
+ pass `makeCredentialedRequest` from `@jini-ai/integrations/credentialed-http` so auditing and redaction stay bound
17
+ to that owner.
18
+
19
+ - Updated dependencies [7479159]
20
+ - Updated dependencies [c31264d]
21
+ - Updated dependencies [0728914]
22
+ - Updated dependencies [6ecbdb2]
23
+ - Updated dependencies [63bacc5]
24
+ - @jini-ai/core@0.6.13
25
+
26
+ ## 0.6.12
27
+
28
+ ### Patch Changes
29
+
30
+ - Updated dependencies
31
+ - @jini-ai/platform@0.6.12
32
+ - @jini-ai/core@0.6.12
33
+
3
34
  ## 0.6.11
4
35
 
5
36
  ### Patch Changes
package/README.md CHANGED
@@ -159,10 +159,20 @@ belong to the host. Integration verification is deferred by owner directive; see
159
159
  ## Local app projects
160
160
 
161
161
  ```ts
162
- import { openFileTreeProject } from '@jini-ai/vibecoding/node';
162
+ import { openFileTreeProject, type ProjectFilesystemPort } from '@jini-ai/vibecoding/node';
163
+ import { createGuardedFileReader, createNodeGuardedReaderFilesystem, withFileLock,
164
+ writeFileAtomicAsync, writeJsonFileAtomicAsync } from '@jini-ai/platform/fs';
163
165
  import { createVibecodingSession } from '@jini-ai/vibecoding/session';
164
166
 
167
+ // The host binds the existing platform owners once at composition.
168
+ const filesystem: ProjectFilesystemPort = {
169
+ createReader: (input, options = {}) => createGuardedFileReader({
170
+ ...input, filesystem: createNodeGuardedReaderFilesystem({}),
171
+ }, options),
172
+ withFileLock, writeFileAtomicAsync, writeJsonFileAtomicAsync,
173
+ };
165
174
  const project = await openFileTreeProject({
175
+ filesystem,
166
176
  root: appDirectory,
167
177
  stateDir: privateHostStateDirectory, // outside appDirectory: dev servers must never serve history
168
178
  policy: { allows: ({ relativePath }) => allowedAppPath(relativePath) },
@@ -17,4 +17,5 @@ export { applyEdit, applyEdits, correctionsFor } from "./apply.js";
17
17
  export type { EditHistory, EditHistoryOptions, HistoryEntry, PartChange } from "./history.js";
18
18
  export { createEditHistory } from "./history.js";
19
19
  export * from './project.js';
20
+ export type { ProjectFilesystemPort } from './project-filesystem.js';
20
21
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,36 @@
1
+ import type { DenyRules, GuardedFileReader, GuardedReaderLimits } from '@jini-ai/core/filesystem';
2
+ /** Filesystem capabilities required by the local project adapter. Composition binds the existing
3
+ * platform implementations so containment, locking and atomic writes keep a single owner.
4
+ * @example const filesystem: ProjectFilesystemPort = { createReader, withFileLock, writeFileAtomicAsync, writeJsonFileAtomicAsync };
5
+ */
6
+ export interface ProjectFilesystemPort {
7
+ createReader(required: {
8
+ rootPath: string;
9
+ denyRules: DenyRules;
10
+ limits: GuardedReaderLimits;
11
+ }, options?: {
12
+ excludedListingDirs?: ReadonlySet<string>;
13
+ }): GuardedFileReader;
14
+ withFileLock<T>(required: {
15
+ lockPath: string;
16
+ run: () => Promise<T>;
17
+ }, options?: {
18
+ staleMs?: number;
19
+ createParent?: boolean;
20
+ }): Promise<T>;
21
+ writeFileAtomicAsync(required: {
22
+ filePath: string;
23
+ content: string | Uint8Array;
24
+ }, options?: {
25
+ createParent?: boolean;
26
+ refuseSymlink?: boolean;
27
+ defaultMode?: number;
28
+ }): Promise<void>;
29
+ writeJsonFileAtomicAsync(required: {
30
+ filePath: string;
31
+ data: unknown;
32
+ }, options?: {
33
+ createParent?: boolean;
34
+ }): Promise<void>;
35
+ }
36
+ //# sourceMappingURL=project-filesystem.d.ts.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=project-filesystem.js.map
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @module session
3
+ *
4
+ * `VibecodingSession` — the framework-free stateful object over
5
+ * `./core`'s `EditTarget` + `EditHistory`. Everything else in `./react` (the hook, the tool
6
+ * registrations, the presentational components) reads and drives an artifact through this seam
7
+ * rather than touching `EditTarget`/`EditHistory` directly, for one reason: a tool call arrives
8
+ * from the chat's execution path, not from a React event handler, so the thing tool handlers call
9
+ * into cannot be React state — it has to be a plain, subscribable object a `useSyncExternalStore`
10
+ * hook can also read. This module is React-free and exported with the tools from `./session`.
11
+ *
12
+ * ## Why part content is cached rather than re-read on every render
13
+ *
14
+ * `EditTarget.readPart` is a host call — for the `html` target it is cheap (a string slice), but
15
+ * the port makes no such promise for every target (a filesystem or sandbox-backed host could hit a
16
+ * network round trip per part). Re-reading every listed part's content on every `refresh()` would
17
+ * turn "the operator opened the file viewer" into an O(part count) fan-out of host calls, and
18
+ * nothing here can assume that count stays small — see `./target.ts`'s own doc on why
19
+ * `listParts` is the allowlist, not a bound on its size. So `refresh()` only calls `listParts()`;
20
+ * content is fetched lazily, per id, through `readPart()`, and cached. Every mutating operation
21
+ * below (`applyEdits`, `undo`, `redo`, `restoreSnapshot`) in a default session knows the exact before/after
22
+ * content it just wrote — `./apply.ts`'s `ProposedEdit`, `./history.ts`'s
23
+ * `PartChange.before`/`.after` — so the cache is updated from that data directly instead of
24
+ * re-reading the part it just wrote back from the host. Project sessions clear caches instead:
25
+ * their history entries hold revision pointers, and external tools may have changed file bytes.
26
+ */
27
+ import type { ApplyOutcome, PartId, PartRef, Snapshot } from './types.js';
28
+ import type { EditTarget } from './target.js';
29
+ import { type ProposedEdit } from './apply.js';
30
+ import { type EditHistory, type EditHistoryOptions, type HistoryEntry } from './history.js';
31
+ export interface VibecodingSessionArgs {
32
+ /** The host's artifact port. See `./target.ts`. */
33
+ readonly target: EditTarget;
34
+ }
35
+ export interface VibecodingSessionOptions {
36
+ /** Forwarded to `createEditHistory`. See `./history.ts`'s `EditHistoryOptions`. */
37
+ readonly historyOptions?: EditHistoryOptions;
38
+ /** A project supplies its guarded pointer history instead of a per-file stack. */
39
+ readonly history?: EditHistory;
40
+ /** Whole-turn mutation; owns its history recording and returns aligned outcomes. */
41
+ readonly applyTurn?: (input: {
42
+ readonly edits: readonly ProposedEdit[];
43
+ }, options?: {
44
+ readonly label?: string;
45
+ }) => Promise<readonly ApplyOutcome[]>;
46
+ }
47
+ /**
48
+ * Immutable, point-in-time read of a session. `getSnapshot()` returns the same object reference
49
+ * until something actually changes — required for `useSyncExternalStore`, which re-renders a
50
+ * subscriber whenever the reference it reads changes, not whenever it is merely called again.
51
+ */
52
+ export interface VibecodingSessionSnapshot {
53
+ /** `'idle'` until the first `refresh()` resolves; `'error'` reflects the most recent failure, not
54
+ * a permanently wedged session — a subsequent successful call returns it to `'ready'`. */
55
+ readonly status: 'idle' | 'loading' | 'ready' | 'error';
56
+ /** Every part the host currently advertises, in `EditTarget.listParts()`'s own order. */
57
+ readonly parts: readonly PartRef[];
58
+ /** Cached content, keyed by part id. A part absent from this map has not been read yet — see
59
+ * this module's doc for why content is lazy rather than eagerly fetched for every part. */
60
+ readonly partContent: ReadonlyMap<PartId, string>;
61
+ readonly canUndo: boolean;
62
+ readonly canRedo: boolean;
63
+ /** The most recent operation's failure message, if any. Cleared by the next successful call. */
64
+ readonly lastError: string | undefined;
65
+ }
66
+ export interface ApplyEditsResult {
67
+ readonly outcomes: readonly ApplyOutcome[];
68
+ /** See `./apply.ts`'s `correctionsFor` — the subset a caller feeds back to the model. */
69
+ readonly corrections: readonly {
70
+ readonly id: PartId;
71
+ readonly reason: string;
72
+ }[];
73
+ }
74
+ export interface VibecodingSession {
75
+ /** The underlying port, for a caller (or test) that needs it directly — e.g. to read the whole
76
+ * document a host-specific target stores, which is outside this generic session's vocabulary. */
77
+ readonly target: EditTarget;
78
+ /** The underlying undo/redo stack, for a caller that needs `.entries()` for a history UI. */
79
+ readonly history: EditHistory;
80
+ getSnapshot(): VibecodingSessionSnapshot;
81
+ /** Returns an unsubscribe function; the React hook adapts this object API for React. */
82
+ subscribe(args: {
83
+ readonly listener: () => void;
84
+ }): () => void;
85
+ /** Re-lists parts from the host. Does not re-read content — see this module's doc. */
86
+ refresh(): Promise<void>;
87
+ /** Cached content for `id`, fetching once from the host on first request. */
88
+ readPart(args: {
89
+ readonly id: PartId;
90
+ }): Promise<string>;
91
+ /** Applies `edits` as one undoable transaction and updates the cache from the edits themselves. */
92
+ applyEdits(requiredArgs: {
93
+ readonly edits: readonly ProposedEdit[];
94
+ }, optionalArgs?: {
95
+ readonly label?: string;
96
+ }): Promise<ApplyEditsResult>;
97
+ undo(): Promise<HistoryEntry | null>;
98
+ redo(): Promise<HistoryEntry | null>;
99
+ takeSnapshot(): Promise<Snapshot>;
100
+ restoreSnapshot(args: {
101
+ readonly snapshot: Snapshot;
102
+ }): Promise<HistoryEntry | null>;
103
+ }
104
+ /**
105
+ * Builds a session over `target`, with private history by default or injected project history.
106
+ * Injected applyTurn owns whole-turn recording; this session owns notifications and cache invalidation.
107
+ *
108
+ * The returned session starts in `'idle'` with no parts listed — call `refresh()` (or let
109
+ * `useVibecodingSession` do it on mount) to populate it. Nothing here triggers I/O eagerly, so
110
+ * constructing a session has no side effect a caller needs to clean up.
111
+ *
112
+ * @complexity Construction is O(1). See each method for its own cost.
113
+ */
114
+ export declare function createVibecodingSession({ target }: VibecodingSessionArgs, options?: VibecodingSessionOptions): VibecodingSession;
115
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1,178 @@
1
+ import { applyEdits as applyEditsToTarget, correctionsFor } from './apply.js';
2
+ import { createEditHistory } from './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 `./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
+ //# sourceMappingURL=session.js.map
@@ -3,4 +3,5 @@ export { openFileTreeProject } from './project.js';
3
3
  export type { OpenFileTreeProjectInput, OpenFileTreeProjectOptions } from './project.js';
4
4
  export type { FileTreePolicy } from './paths.js';
5
5
  export * from '../core/project.js';
6
+ export type { ProjectFilesystemPort } from '../core/project-filesystem.js';
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -1,3 +1,4 @@
1
+ import type { ProjectFilesystemPort } from '../core/project-filesystem.js';
1
2
  import { type ProjectLimits } from '../core/project.js';
2
3
  export interface FileTreePolicy {
3
4
  /** Host authority intersects mandatory exclusions, including for external captures. */
@@ -30,15 +31,16 @@ export declare function canonicalPath({ relativePath, maxDepth }: {
30
31
  export declare function assertCandidatePaths({ paths }: {
31
32
  paths: readonly string[];
32
33
  }, _options?: {}): void;
33
- /** Platform owns physical containment and bounded traversal; this adapter adds project policy.
34
+ /** The injected reader owns physical containment and bounded traversal; this adapter adds project policy.
34
35
  * @complexity Construction O(1); path checks O(depth), listing bounded by configured limits.
35
36
  */
36
- export declare function createProjectPaths({ root, policy, limits }: {
37
+ export declare function createProjectPaths({ root, policy, limits, filesystem }: {
37
38
  root: string;
38
39
  policy: FileTreePolicy;
39
40
  limits: ProjectLimits;
41
+ filesystem: ProjectFilesystemPort;
40
42
  }, _options?: {}): {
41
- reader: import("@jini-ai/platform/fs").GuardedFileReader;
43
+ reader: import("@jini-ai/core/filesystem").GuardedFileReader;
42
44
  check: ({ relativePath }: {
43
45
  relativePath: string;
44
46
  }) => string;
@@ -1,4 +1,3 @@
1
- import { createGuardedFileReader, createNodeGuardedReaderFilesystem } from '@jini-ai/platform/fs';
2
1
  import { ProjectError } from '../core/project.js';
3
2
  export const DEFAULT_PROJECT_LIMITS = {
4
3
  maxFiles: 2_000, maxFileBytes: 2 * 1024 * 1024, maxTreeBytes: 64 * 1024 * 1024,
@@ -65,11 +64,11 @@ export function assertCandidatePaths({ paths }, _options = {}) {
65
64
  }
66
65
  }
67
66
  }
68
- /** Platform owns physical containment and bounded traversal; this adapter adds project policy.
67
+ /** The injected reader owns physical containment and bounded traversal; this adapter adds project policy.
69
68
  * @complexity Construction O(1); path checks O(depth), listing bounded by configured limits.
70
69
  */
71
- export function createProjectPaths({ root, policy, limits }, _options = {}) {
72
- const reader = createGuardedFileReader({ rootPath: root, filesystem: createNodeGuardedReaderFilesystem({}),
70
+ export function createProjectPaths({ root, policy, limits, filesystem }, _options = {}) {
71
+ const reader = filesystem.createReader({ rootPath: root,
73
72
  denyRules: { segments: EXCLUDED, filenamePatterns: [/^\.env/i, ATOMIC_TEMP] },
74
73
  limits: { maxFileBytes: limits.maxFileBytes, binarySniffBytes: 1024,
75
74
  maxListedFiles: limits.maxFiles + 1, maxWalkDepth: limits.maxDepth, maxWalkEntries: limits.maxFiles * (limits.maxDepth + 1) },
@@ -1,6 +1,9 @@
1
+ import type { ProjectFilesystemPort } from '../core/project-filesystem.js';
1
2
  import { type FileTreeProject, type ProjectLimits, type ProjectValidator } from '../core/project.js';
2
3
  import { type FileTreePolicy } from './paths.js';
3
4
  export interface OpenFileTreeProjectInput {
5
+ /** Native effects are supplied by the host composition root. */
6
+ readonly filesystem: ProjectFilesystemPort;
4
7
  readonly root: string;
5
8
  /** Private host-owned directory outside the watched and served application tree. */
6
9
  readonly stateDir: string;
@@ -1,5 +1,5 @@
1
1
  import { createEditHistory } from '../core/history.js';
2
- import { FsFilePathError } from '@jini-ai/platform/fs';
2
+ import { FsFilePathError } from '@jini-ai/core/filesystem';
3
3
  import { ProjectError } from '../core/project.js';
4
4
  import { assertCandidatePaths, createProjectPaths, projectLimits } from './paths.js';
5
5
  import { createProjectStore, prepareDirectories, revisionToken } from './store.js';
@@ -117,8 +117,8 @@ export async function openFileTreeProject(input, options = {}) {
117
117
  throw new TypeError('historyLimit must be positive');
118
118
  try {
119
119
  const directories = await prepareDirectories(input);
120
- const paths = createProjectPaths({ root: directories.root, policy: input.policy, limits });
121
- const store = createProjectStore({ ...directories, paths, limits });
120
+ const paths = createProjectPaths({ root: directories.root, policy: input.policy, limits, filesystem: input.filesystem });
121
+ const store = createProjectStore({ ...directories, paths, limits, filesystem: input.filesystem });
122
122
  const project = assembleProject({ store, paths, limits, historyLimit, validator: input.validator });
123
123
  await project.capture();
124
124
  return project;
@@ -1,3 +1,4 @@
1
+ import type { ProjectFilesystemPort } from '../core/project-filesystem.js';
1
2
  import { type ProjectLimits, type RevisionToken } from '../core/project.js';
2
3
  import { type ProjectPaths } from './paths.js';
3
4
  export interface ManifestFile {
@@ -30,6 +31,7 @@ export interface StoreInput {
30
31
  stateDir: string;
31
32
  paths: ProjectPaths;
32
33
  limits: ProjectLimits;
34
+ filesystem: ProjectFilesystemPort;
33
35
  }
34
36
  export interface StoreOptions {
35
37
  /** Internal deterministic process-crash seam; never needed by the public factory. */
@@ -55,7 +57,7 @@ export declare function prepareDirectories({ root, stateDir }: {
55
57
  /** Content-addressed persistence and idempotent roll-forward. Call mutations inside locked().
56
58
  * @complexity O(bytes + files log files + files × depth²) for capture; memory bounded by maxTreeBytes.
57
59
  */
58
- export declare function createProjectStore({ root, stateDir, paths, limits }: StoreInput, options?: StoreOptions): {
60
+ export declare function createProjectStore({ root, stateDir, paths, limits, filesystem }: StoreInput, options?: StoreOptions): {
59
61
  readCurrent: () => Promise<Current | null>;
60
62
  manifest: ({ revisionId }: {
61
63
  revisionId: string;
@@ -1,7 +1,6 @@
1
1
  import * as fs from 'node:fs/promises';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
- import { withFileLock, writeFileAtomicAsync, writeJsonFileAtomicAsync } from '@jini-ai/platform/fs';
5
4
  import { ProjectError } from '../core/project.js';
6
5
  import { assertCandidatePaths, portablePathKey } from './paths.js';
7
6
  function fingerprint(info) {
@@ -169,7 +168,7 @@ export async function prepareDirectories({ root, stateDir }, _options = {}) {
169
168
  /** Content-addressed persistence and idempotent roll-forward. Call mutations inside locked().
170
169
  * @complexity O(bytes + files log files + files × depth²) for capture; memory bounded by maxTreeBytes.
171
170
  */
172
- export function createProjectStore({ root, stateDir, paths, limits }, options = {}) {
171
+ export function createProjectStore({ root, stateDir, paths, limits, filesystem }, options = {}) {
173
172
  const currentPath = resolve(stateDir, 'current.json');
174
173
  const objectPath = (hash) => { assertHash(hash); return resolve(stateDir, 'objects', hash); };
175
174
  const manifestPath = (hash) => { assertHash(hash); return resolve(stateDir, 'manifests', `${hash}.json`); };
@@ -223,7 +222,7 @@ export function createProjectStore({ root, stateDir, paths, limits }, options =
223
222
  if (existing)
224
223
  await manifest({ revisionId });
225
224
  else
226
- await writeJsonFileAtomicAsync({ filePath: manifestPath(revisionId), data: value }, { createParent: true });
225
+ await filesystem.writeJsonFileAtomicAsync({ filePath: manifestPath(revisionId), data: value }, { createParent: true });
227
226
  await checkpoint('manifest');
228
227
  return revisionId;
229
228
  }
@@ -232,7 +231,7 @@ export function createProjectStore({ root, stateDir, paths, limits }, options =
232
231
  if (exists)
233
232
  await object({ file });
234
233
  else
235
- await writeFileAtomicAsync({ filePath: objectPath(file.hash), content: bytes }, { createParent: true });
234
+ await filesystem.writeFileAtomicAsync({ filePath: objectPath(file.hash), content: bytes }, { createParent: true });
236
235
  }
237
236
  async function observations({ files }) {
238
237
  const result = Object.create(null);
@@ -348,12 +347,12 @@ export function createProjectStore({ root, stateDir, paths, limits }, options =
348
347
  for (const file of writes)
349
348
  await removeBlockingFolder({ path: file.path, before });
350
349
  for (const file of writes) {
351
- await writeFileAtomicAsync({ filePath: await regularPath({ paths, path: file.path }), content: bytes.get(file.path) }, { createParent: true, refuseSymlink: true, defaultMode: 0o644 });
350
+ await filesystem.writeFileAtomicAsync({ filePath: await regularPath({ paths, path: file.path }), content: bytes.get(file.path) }, { createParent: true, refuseSymlink: true, defaultMode: 0o644 });
352
351
  await checkpoint('write');
353
352
  }
354
353
  await prune({ before, after });
355
354
  const settled = { ...current, pending: null, observed: await observations(after) };
356
- await writeJsonFileAtomicAsync({ filePath: currentPath, data: settled });
355
+ await filesystem.writeJsonFileAtomicAsync({ filePath: currentPath, data: settled });
357
356
  await checkpoint('clear');
358
357
  return settled;
359
358
  }
@@ -435,7 +434,7 @@ export function createProjectStore({ root, stateDir, paths, limits }, options =
435
434
  throw new ProjectError({ code: 'LIMIT', message: 'generation exhausted' });
436
435
  const next = { version: 1, root, revision: revisionId, generation: (current?.generation ?? 0) + 1,
437
436
  pending: captured ? null : { from: current?.revision ?? null }, observed: captured ?? {} };
438
- await writeJsonFileAtomicAsync({ filePath: currentPath, data: next });
437
+ await filesystem.writeJsonFileAtomicAsync({ filePath: currentPath, data: next });
439
438
  await checkpoint('journal');
440
439
  return captured ? next : materialize({ current: next });
441
440
  }
@@ -444,7 +443,7 @@ export function createProjectStore({ root, stateDir, paths, limits }, options =
444
443
  if (JSON.stringify(current.observed) === JSON.stringify(observed))
445
444
  return current;
446
445
  const next = { ...current, observed };
447
- await writeJsonFileAtomicAsync({ filePath: currentPath, data: next });
446
+ await filesystem.writeJsonFileAtomicAsync({ filePath: currentPath, data: next });
448
447
  return next;
449
448
  }
450
449
  /** Both stacks pin manifests and blobs. Expired snapshots do not pin storage indefinitely. */
@@ -459,7 +458,7 @@ export function createProjectStore({ root, stateDir, paths, limits }, options =
459
458
  }
460
459
  return {
461
460
  readCurrent, manifest, object, prepare, scan, select, collect, preflight, observe, readWorkingFile,
462
- locked: (run) => withFileLock({ lockPath: resolve(stateDir, 'project.lock'), run }, { staleMs: Infinity, createParent: true }),
461
+ locked: (run) => filesystem.withFileLock({ lockPath: resolve(stateDir, 'project.lock'), run }, { staleMs: Infinity, createParent: true }),
463
462
  recover,
464
463
  };
465
464
  }
@@ -1,3 +1,4 @@
1
1
  /** Compatibility re-export; the implementation is framework-free. */
2
- export * from "../session.js";
2
+ export * from "../core/session.js";
3
+ export * from "../tools.js";
3
4
  //# sourceMappingURL=session.d.ts.map
@@ -1,3 +1,4 @@
1
1
  /** Compatibility re-export; the implementation is framework-free. */
2
- export * from "../session.js";
2
+ export * from "../core/session.js";
3
+ export * from "../tools.js";
3
4
  //# sourceMappingURL=session.js.map
package/dist/session.d.ts CHANGED
@@ -1,116 +1,4 @@
1
- /**
2
- * @module session
3
- *
4
- * `VibecodingSession` — the framework-free stateful object over
5
- * `./core`'s `EditTarget` + `EditHistory`. Everything else in `./react` (the hook, the tool
6
- * registrations, the presentational components) reads and drives an artifact through this seam
7
- * rather than touching `EditTarget`/`EditHistory` directly, for one reason: a tool call arrives
8
- * from the chat's execution path, not from a React event handler, so the thing tool handlers call
9
- * into cannot be React state — it has to be a plain, subscribable object a `useSyncExternalStore`
10
- * hook can also read. This module is React-free and exported with the tools from `./session`.
11
- *
12
- * ## Why part content is cached rather than re-read on every render
13
- *
14
- * `EditTarget.readPart` is a host call — for the `html` target it is cheap (a string slice), but
15
- * the port makes no such promise for every target (a filesystem or sandbox-backed host could hit a
16
- * network round trip per part). Re-reading every listed part's content on every `refresh()` would
17
- * turn "the operator opened the file viewer" into an O(part count) fan-out of host calls, and
18
- * nothing here can assume that count stays small — see `./core/target.ts`'s own doc on why
19
- * `listParts` is the allowlist, not a bound on its size. So `refresh()` only calls `listParts()`;
20
- * content is fetched lazily, per id, through `readPart()`, and cached. Every mutating operation
21
- * below (`applyEdits`, `undo`, `redo`, `restoreSnapshot`) in a default session knows the exact before/after
22
- * content it just wrote — `./core/apply.ts`'s `ProposedEdit`, `./core/history.ts`'s
23
- * `PartChange.before`/`.after` — so the cache is updated from that data directly instead of
24
- * re-reading the part it just wrote back from the host. Project sessions clear caches instead:
25
- * their history entries hold revision pointers, and external tools may have changed file bytes.
26
- */
27
- import type { ApplyOutcome, PartId, PartRef, Snapshot } from './core/types.js';
28
- import type { EditTarget } from './core/target.js';
29
- import { type ProposedEdit } from './core/apply.js';
30
- import { type EditHistory, type EditHistoryOptions, type HistoryEntry } from './core/history.js';
31
- export interface VibecodingSessionArgs {
32
- /** The host's artifact port. See `./core/target.ts`. */
33
- readonly target: EditTarget;
34
- }
35
- export interface VibecodingSessionOptions {
36
- /** Forwarded to `createEditHistory`. See `./core/history.ts`'s `EditHistoryOptions`. */
37
- readonly historyOptions?: EditHistoryOptions;
38
- /** A project supplies its guarded pointer history instead of a per-file stack. */
39
- readonly history?: EditHistory;
40
- /** Whole-turn mutation; owns its history recording and returns aligned outcomes. */
41
- readonly applyTurn?: (input: {
42
- readonly edits: readonly ProposedEdit[];
43
- }, options?: {
44
- readonly label?: string;
45
- }) => Promise<readonly ApplyOutcome[]>;
46
- }
47
- /**
48
- * Immutable, point-in-time read of a session. `getSnapshot()` returns the same object reference
49
- * until something actually changes — required for `useSyncExternalStore`, which re-renders a
50
- * subscriber whenever the reference it reads changes, not whenever it is merely called again.
51
- */
52
- export interface VibecodingSessionSnapshot {
53
- /** `'idle'` until the first `refresh()` resolves; `'error'` reflects the most recent failure, not
54
- * a permanently wedged session — a subsequent successful call returns it to `'ready'`. */
55
- readonly status: 'idle' | 'loading' | 'ready' | 'error';
56
- /** Every part the host currently advertises, in `EditTarget.listParts()`'s own order. */
57
- readonly parts: readonly PartRef[];
58
- /** Cached content, keyed by part id. A part absent from this map has not been read yet — see
59
- * this module's doc for why content is lazy rather than eagerly fetched for every part. */
60
- readonly partContent: ReadonlyMap<PartId, string>;
61
- readonly canUndo: boolean;
62
- readonly canRedo: boolean;
63
- /** The most recent operation's failure message, if any. Cleared by the next successful call. */
64
- readonly lastError: string | undefined;
65
- }
66
- export interface ApplyEditsResult {
67
- readonly outcomes: readonly ApplyOutcome[];
68
- /** See `./core/apply.ts`'s `correctionsFor` — the subset a caller feeds back to the model. */
69
- readonly corrections: readonly {
70
- readonly id: PartId;
71
- readonly reason: string;
72
- }[];
73
- }
74
- export interface VibecodingSession {
75
- /** The underlying port, for a caller (or test) that needs it directly — e.g. to read the whole
76
- * document a host-specific target stores, which is outside this generic session's vocabulary. */
77
- readonly target: EditTarget;
78
- /** The underlying undo/redo stack, for a caller that needs `.entries()` for a history UI. */
79
- readonly history: EditHistory;
80
- getSnapshot(): VibecodingSessionSnapshot;
81
- /** Returns an unsubscribe function; the React hook adapts this object API for React. */
82
- subscribe(args: {
83
- readonly listener: () => void;
84
- }): () => void;
85
- /** Re-lists parts from the host. Does not re-read content — see this module's doc. */
86
- refresh(): Promise<void>;
87
- /** Cached content for `id`, fetching once from the host on first request. */
88
- readPart(args: {
89
- readonly id: PartId;
90
- }): Promise<string>;
91
- /** Applies `edits` as one undoable transaction and updates the cache from the edits themselves. */
92
- applyEdits(requiredArgs: {
93
- readonly edits: readonly ProposedEdit[];
94
- }, optionalArgs?: {
95
- readonly label?: string;
96
- }): Promise<ApplyEditsResult>;
97
- undo(): Promise<HistoryEntry | null>;
98
- redo(): Promise<HistoryEntry | null>;
99
- takeSnapshot(): Promise<Snapshot>;
100
- restoreSnapshot(args: {
101
- readonly snapshot: Snapshot;
102
- }): Promise<HistoryEntry | null>;
103
- }
104
- /**
105
- * Builds a session over `target`, with private history by default or injected project history.
106
- * Injected applyTurn owns whole-turn recording; this session owns notifications and cache invalidation.
107
- *
108
- * The returned session starts in `'idle'` with no parts listed — call `refresh()` (or let
109
- * `useVibecodingSession` do it on mount) to populate it. Nothing here triggers I/O eagerly, so
110
- * constructing a session has no side effect a caller needs to clean up.
111
- *
112
- * @complexity Construction is O(1). See each method for its own cost.
113
- */
114
- export declare function createVibecodingSession({ target }: VibecodingSessionArgs, options?: VibecodingSessionOptions): VibecodingSession;
1
+ /** Compatibility path; the framework-free session is shared with the React integration. */
2
+ export * from "./core/session.js";
115
3
  export * from "./tools.js";
116
4
  //# sourceMappingURL=session.d.ts.map
package/dist/session.js CHANGED
@@ -1,179 +1,4 @@
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
- }
1
+ /** Compatibility path; the framework-free session is shared with the React integration. */
2
+ export * from "./core/session.js";
178
3
  export * from "./tools.js";
179
4
  //# sourceMappingURL=session.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/vibecoding",
3
- "version": "0.6.11",
3
+ "version": "0.6.15-rc.1",
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": {
@@ -87,11 +87,11 @@
87
87
  "typescript": "^5.9.2",
88
88
  "vitest": "^3.2.4",
89
89
  "parse5": "^8.0.1",
90
- "@jini-ai/core": "0.6.11"
90
+ "@jini-ai/core": "0.6.15-rc.1",
91
+ "@jini-ai/platform": "^0.6.15-rc.1"
91
92
  },
92
93
  "dependencies": {
93
- "@jini-ai/platform": "^0.6.11",
94
- "@jini-ai/core": "^0.6.11"
94
+ "@jini-ai/core": "^0.6.15-rc.1"
95
95
  },
96
96
  "peerDependencies": {
97
97
  "react": "^18.3.0 || ^19.0.0",