@jkwd/inbase 0.1.10 → 0.1.12
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/LICENSE +21 -0
- package/README.md +22 -9
- package/apps/explorer/package.json +1 -0
- package/apps/explorer/scripts/branch-changes.d.ts +24 -0
- package/apps/explorer/scripts/branch-changes.mjs +237 -0
- package/apps/explorer/scripts/js-source.mjs +12 -15
- package/apps/explorer/scripts/patch-lib.d.ts +5 -0
- package/apps/explorer/scripts/patch-lib.mjs +5 -0
- package/apps/explorer/scripts/scan-target.mjs +46 -13
- package/apps/explorer/scripts/session-store.d.ts +58 -1
- package/apps/explorer/scripts/session-store.mjs +276 -52
- package/apps/explorer/scripts/tree-diff.mjs +121 -0
- package/apps/explorer/src/App.tsx +170 -32
- package/apps/explorer/src/agentIntent.ts +63 -0
- package/apps/explorer/src/branchChanges.ts +54 -0
- package/apps/explorer/src/index.css +153 -0
- package/apps/explorer/src/scene/FileBlock.tsx +55 -5
- package/apps/explorer/src/scene/MapView.tsx +32 -1
- package/apps/explorer/src/scene/World.tsx +6 -1
- package/apps/explorer/src/types.ts +44 -0
- package/apps/explorer/src/ui/HUD.tsx +583 -96
- package/apps/explorer/src/ui/MapContextMenu.tsx +89 -0
- package/apps/explorer/src/userContext.ts +11 -0
- package/apps/explorer/src/userCreated.ts +50 -0
- package/apps/explorer/vite.config.ts +61 -7
- package/bin/inbase.mjs +23 -4
- package/bin/project.mjs +62 -4
- package/bin/session.mjs +231 -125
- package/package.json +20 -2
- package/skill/commands/inbase.md +17 -0
- package/skill/commands/skipinbase.md +9 -0
- package/skill/inbase/SKILL.md +143 -112
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joris Kuijper
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
</picture>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
|
-
A first-person 3D map of a
|
|
8
|
+
A first-person 3D map of a codebase. Files become blocks, folders become walkable areas, and imports become lines in the air.
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
11
|
<img src="docs/inbase-1.png" alt="First-person walk view of the codebase map" width="47%" />
|
|
@@ -47,16 +47,21 @@ inbase run --target /path/to/your/project
|
|
|
47
47
|
|
|
48
48
|
<img src="docs/inbase-llm.png" alt="LLM working through a plan in the heads-up display overlay" align="left" width="280" hspace="16" />
|
|
49
49
|
|
|
50
|
-
Inbase does not call a model itself. The visual coding loop currently supports **Cursor**. The installed skill makes the agent work through the map: it reports a plan, waits on the **HUD** (heads-up display — the overlay panel on the 3D map),
|
|
50
|
+
Inbase does not call a model itself. The visual coding loop currently supports **Cursor**. The installed skill makes the agent work through the map: it reports a plan, waits on the **HUD** (heads-up display — the overlay panel on the 3D map), edits live files for each invoked step, and records that step as a patch. Those stored patches are the session record and are applied on every step update.
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
Sessions start in the map: click **Setup LLM session**, type an **initial instruction**, optionally place a **blueprint** (`Space` for files, `B` for folders), then open a Cursor chat and run **`/inbase`**. That command connects the chat and starts the work. The layout is the source of truth for the chat. You can still place files and islands on later steps; they are stored on that chat’s blueprint. When the session finishes, placement stops and the session is discarded. Restarting the visualizer also starts with no LLM session; leftover session files are not restored.
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
A normal chat request does not open a session. Use `/inbase` after Setup LLM session, or `/skipinbase [request]` to work outside the map. `/inbase` starts the session immediately; `wait-for-blueprint` only reads the optional blueprint.
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
If several sessions are open, only one session window is shown at a time. Switch tabs so the focused session is the one `/inbase` connects to.
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
Turn on **Make LLM look where I look** if the agent should prefer the island you are standing on and the blocks you are facing. With **Step by step** on, click **Create proposal** to start a step, then **Accept proposal** when the patch is ready. With it off, the LLM implements the full plan; you can still walk Previous/Next over the diffs, then **Accept proposal**. Send an alternative instruction from the HUD to revise the remaining plan, or **Stop** to end the session.
|
|
59
|
+
|
|
60
|
+
When no LLM is currently making changes, turn on **Show branch changes** (or press **G**) to highlight the current git branch against its base — committed, unstaged, and untracked files — instead of LLM patch files. The control is disabled while an LLM session is writing or reviewing a patch.
|
|
61
|
+
|
|
62
|
+
The LLM edits project files after a step is invoked. Inbase records those edits
|
|
63
|
+
as a patch against the snapshot taken at invoke. Those stored patches are the
|
|
64
|
+
session record and are applied on every step update.
|
|
60
65
|
|
|
61
66
|
<br clear="all" />
|
|
62
67
|
|
|
@@ -68,7 +73,7 @@ stored patches are the source of truth and are applied on every step update.
|
|
|
68
73
|
| `inbase run` | Scan this repo and start the local map |
|
|
69
74
|
| `inbase run --port 5174` | Start on another port |
|
|
70
75
|
|
|
71
|
-
Session commands (`
|
|
76
|
+
Session commands (`attach`, `wait-for-blueprint`, `report-plan`, `wait-for-approval`, `propose-patch`) are used by the Cursor skill. You do not need to run them yourself.
|
|
72
77
|
|
|
73
78
|
## Editor support
|
|
74
79
|
|
|
@@ -76,7 +81,7 @@ The map runs in the browser. The LLM plan and patch loop currently works in **Cu
|
|
|
76
81
|
|
|
77
82
|
## Language support
|
|
78
83
|
|
|
79
|
-
|
|
84
|
+
Every text file in the project becomes a block. JavaScript and TypeScript (`.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`) also get **functions**, **classes**, and **variables**. Relative `import` / `require()` specifiers become edges when they resolve — including `.astro` and other JS-style modules. Package names like `react` or `@angular/core` do not. Binary files are skipped.
|
|
80
85
|
|
|
81
86
|
## How to read the map
|
|
82
87
|
|
|
@@ -106,3 +111,11 @@ To run the example React app itself:
|
|
|
106
111
|
```bash
|
|
107
112
|
npm run dev:target
|
|
108
113
|
```
|
|
114
|
+
|
|
115
|
+
## Contributing
|
|
116
|
+
|
|
117
|
+
Issues and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
Inbase is open source under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PatchImport, PatchImportAddition, PatchSymbolAddition } from './patch-lib.d.ts'
|
|
2
|
+
|
|
3
|
+
export type BranchChanges = {
|
|
4
|
+
available: boolean
|
|
5
|
+
branch: string | null
|
|
6
|
+
base: string | null
|
|
7
|
+
files: string[]
|
|
8
|
+
creates: string[]
|
|
9
|
+
deletes: string[]
|
|
10
|
+
createFolders: string[]
|
|
11
|
+
createLines: Record<string, number>
|
|
12
|
+
imports: PatchImport[]
|
|
13
|
+
addedFunctions: PatchSymbolAddition[]
|
|
14
|
+
addedVariables: PatchSymbolAddition[]
|
|
15
|
+
addedImports: PatchImportAddition[]
|
|
16
|
+
changedFunctions: PatchSymbolAddition[]
|
|
17
|
+
changedVariables: PatchSymbolAddition[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function emptyBranchChanges(): BranchChanges
|
|
21
|
+
export function readBranchChanges(
|
|
22
|
+
targetRoot: string,
|
|
23
|
+
knownFileIds?: string[],
|
|
24
|
+
): BranchChanges
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
collectCreateFolders,
|
|
6
|
+
extractPatchAdditions,
|
|
7
|
+
extractPatchImports,
|
|
8
|
+
foldersFromFileIds,
|
|
9
|
+
parseUnifiedPatch,
|
|
10
|
+
} from './patch-lib.mjs'
|
|
11
|
+
|
|
12
|
+
const SKIP_DIRS = new Set([
|
|
13
|
+
'node_modules',
|
|
14
|
+
'dist',
|
|
15
|
+
'build',
|
|
16
|
+
'out',
|
|
17
|
+
'coverage',
|
|
18
|
+
'.git',
|
|
19
|
+
'.inbase',
|
|
20
|
+
])
|
|
21
|
+
const SKIP_FILES = new Set(['package-lock.json'])
|
|
22
|
+
const BINARY_PROBE_BYTES = 8000
|
|
23
|
+
|
|
24
|
+
function toPosix(filePath) {
|
|
25
|
+
return filePath.split(path.sep).join('/')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function unique(ids) {
|
|
29
|
+
return [...new Set(ids)]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function runGit(cwd, args) {
|
|
33
|
+
return spawnSync('git', args, {
|
|
34
|
+
cwd,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function gitTopLevel(fromDir) {
|
|
41
|
+
try {
|
|
42
|
+
const result = runGit(fromDir, ['rev-parse', '--show-toplevel'])
|
|
43
|
+
if (result.status !== 0) return null
|
|
44
|
+
const root = result.stdout.trim()
|
|
45
|
+
return root ? path.resolve(root) : null
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isBinaryFile(filePath) {
|
|
52
|
+
try {
|
|
53
|
+
const fd = fs.openSync(filePath, 'r')
|
|
54
|
+
try {
|
|
55
|
+
const buf = Buffer.alloc(BINARY_PROBE_BYTES)
|
|
56
|
+
const bytes = fs.readSync(fd, buf, 0, buf.length, 0)
|
|
57
|
+
return buf.subarray(0, bytes).includes(0)
|
|
58
|
+
} finally {
|
|
59
|
+
fs.closeSync(fd)
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function shouldSkipPath(fileId) {
|
|
67
|
+
const parts = fileId.split('/').filter(Boolean)
|
|
68
|
+
if (parts.some((part) => part.startsWith('.') || SKIP_DIRS.has(part))) {
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
return SKIP_FILES.has(parts.at(-1) ?? '')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function currentBranch(cwd) {
|
|
75
|
+
const result = runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
76
|
+
if (result.status !== 0) return null
|
|
77
|
+
const name = result.stdout.trim()
|
|
78
|
+
return name || null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveBaseRef(gitRoot) {
|
|
82
|
+
const originHead = runGit(gitRoot, [
|
|
83
|
+
'symbolic-ref',
|
|
84
|
+
'--quiet',
|
|
85
|
+
'refs/remotes/origin/HEAD',
|
|
86
|
+
])
|
|
87
|
+
if (originHead.status === 0) {
|
|
88
|
+
const ref = originHead.stdout.trim().replace(/^refs\/remotes\//, '')
|
|
89
|
+
if (ref) return ref
|
|
90
|
+
}
|
|
91
|
+
for (const name of ['origin/main', 'origin/master', 'main', 'master']) {
|
|
92
|
+
const check = runGit(gitRoot, ['rev-parse', '--verify', '--quiet', name])
|
|
93
|
+
if (check.status === 0 && check.stdout.trim()) return name
|
|
94
|
+
}
|
|
95
|
+
return 'HEAD'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveMergeBase(gitRoot, baseRef) {
|
|
99
|
+
const result = runGit(gitRoot, ['merge-base', 'HEAD', baseRef])
|
|
100
|
+
if (result.status === 0 && result.stdout.trim()) return result.stdout.trim()
|
|
101
|
+
return 'HEAD'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseNameStatus(text) {
|
|
105
|
+
const files = []
|
|
106
|
+
const creates = []
|
|
107
|
+
const deletes = []
|
|
108
|
+
for (const line of text.split('\n')) {
|
|
109
|
+
if (!line.trim()) continue
|
|
110
|
+
const tab = line.indexOf('\t')
|
|
111
|
+
if (tab < 0) continue
|
|
112
|
+
const status = line.slice(0, tab).trim()
|
|
113
|
+
const filePath = toPosix(line.slice(tab + 1).trim())
|
|
114
|
+
if (!filePath || filePath.includes('\t') || shouldSkipPath(filePath)) continue
|
|
115
|
+
const code = status[0]
|
|
116
|
+
if (code === 'A') creates.push(filePath)
|
|
117
|
+
else if (code === 'D') deletes.push(filePath)
|
|
118
|
+
else if (code === 'M' || code === 'T') files.push(filePath)
|
|
119
|
+
}
|
|
120
|
+
return { files, creates, deletes }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fileAsAddPatch(fileId, contents) {
|
|
124
|
+
const lines = contents.split('\n')
|
|
125
|
+
if (contents.endsWith('\n')) lines.pop()
|
|
126
|
+
const count = lines.length
|
|
127
|
+
const hunk =
|
|
128
|
+
count === 0
|
|
129
|
+
? []
|
|
130
|
+
: [`@@ -0,0 +1,${count} @@`, ...lines.map((line) => `+${line}`)]
|
|
131
|
+
return [
|
|
132
|
+
`diff --git a/${fileId} b/${fileId}`,
|
|
133
|
+
'new file mode 100644',
|
|
134
|
+
'--- /dev/null',
|
|
135
|
+
`+++ b/${fileId}`,
|
|
136
|
+
...hunk,
|
|
137
|
+
'',
|
|
138
|
+
].join('\n')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function collectUntracked(targetRoot) {
|
|
142
|
+
const result = runGit(targetRoot, ['ls-files', '--others', '--exclude-standard'])
|
|
143
|
+
const creates = []
|
|
144
|
+
const patches = []
|
|
145
|
+
const createLines = {}
|
|
146
|
+
if (result.status !== 0) return { creates, patches, createLines }
|
|
147
|
+
|
|
148
|
+
for (const raw of result.stdout.split('\n')) {
|
|
149
|
+
const fileId = toPosix(raw.trim())
|
|
150
|
+
if (!fileId || shouldSkipPath(fileId)) continue
|
|
151
|
+
const absolute = path.join(targetRoot, fileId)
|
|
152
|
+
try {
|
|
153
|
+
if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) continue
|
|
154
|
+
} catch {
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
if (isBinaryFile(absolute)) continue
|
|
158
|
+
const contents = fs.readFileSync(absolute, 'utf8')
|
|
159
|
+
creates.push(fileId)
|
|
160
|
+
patches.push(fileAsAddPatch(fileId, contents))
|
|
161
|
+
const lineCount =
|
|
162
|
+
contents === ''
|
|
163
|
+
? 1
|
|
164
|
+
: contents.split('\n').length - (contents.endsWith('\n') ? 1 : 0)
|
|
165
|
+
createLines[fileId] = Math.max(1, lineCount)
|
|
166
|
+
}
|
|
167
|
+
return { creates, patches, createLines }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function emptyBranchChanges() {
|
|
171
|
+
return {
|
|
172
|
+
available: false,
|
|
173
|
+
branch: null,
|
|
174
|
+
base: null,
|
|
175
|
+
files: [],
|
|
176
|
+
creates: [],
|
|
177
|
+
deletes: [],
|
|
178
|
+
createFolders: [],
|
|
179
|
+
createLines: {},
|
|
180
|
+
imports: [],
|
|
181
|
+
addedFunctions: [],
|
|
182
|
+
addedVariables: [],
|
|
183
|
+
addedImports: [],
|
|
184
|
+
changedFunctions: [],
|
|
185
|
+
changedVariables: [],
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function readBranchChanges(targetRoot, knownFileIds = []) {
|
|
190
|
+
const empty = emptyBranchChanges()
|
|
191
|
+
if (!targetRoot || !fs.existsSync(targetRoot)) return empty
|
|
192
|
+
const gitRoot = gitTopLevel(targetRoot)
|
|
193
|
+
if (!gitRoot) return empty
|
|
194
|
+
|
|
195
|
+
const branch = currentBranch(targetRoot) ?? currentBranch(gitRoot)
|
|
196
|
+
const base = resolveBaseRef(gitRoot)
|
|
197
|
+
const mergeBase = resolveMergeBase(gitRoot, base)
|
|
198
|
+
const diffArgs = [
|
|
199
|
+
'--no-color',
|
|
200
|
+
'--no-ext-diff',
|
|
201
|
+
'--no-renames',
|
|
202
|
+
'--relative',
|
|
203
|
+
mergeBase,
|
|
204
|
+
]
|
|
205
|
+
const statusResult = runGit(targetRoot, ['diff', '--name-status', ...diffArgs])
|
|
206
|
+
const patchResult = runGit(targetRoot, ['diff', ...diffArgs])
|
|
207
|
+
const named = parseNameStatus(
|
|
208
|
+
statusResult.status === 0 ? statusResult.stdout : '',
|
|
209
|
+
)
|
|
210
|
+
const untracked = collectUntracked(targetRoot)
|
|
211
|
+
const creates = unique([...named.creates, ...untracked.creates])
|
|
212
|
+
const deletes = named.deletes.filter((id) => !creates.includes(id))
|
|
213
|
+
const files = named.files.filter(
|
|
214
|
+
(id) => !creates.includes(id) && !deletes.includes(id),
|
|
215
|
+
)
|
|
216
|
+
const patchText = [patchResult.status === 0 ? patchResult.stdout : '', ...untracked.patches]
|
|
217
|
+
.filter((part) => part.trim())
|
|
218
|
+
.join('\n')
|
|
219
|
+
const parsed = parseUnifiedPatch(patchText)
|
|
220
|
+
const known = unique([...knownFileIds, ...creates, ...files])
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
available: true,
|
|
224
|
+
branch,
|
|
225
|
+
base,
|
|
226
|
+
files,
|
|
227
|
+
creates,
|
|
228
|
+
deletes,
|
|
229
|
+
createFolders: collectCreateFolders(
|
|
230
|
+
creates,
|
|
231
|
+
foldersFromFileIds(knownFileIds.filter((id) => !creates.includes(id))),
|
|
232
|
+
),
|
|
233
|
+
createLines: { ...parsed.createLines, ...untracked.createLines },
|
|
234
|
+
imports: extractPatchImports(parsed.entries, known),
|
|
235
|
+
...extractPatchAdditions(parsed.entries),
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
export const FILE_EXTENSIONS = new Set([
|
|
2
|
-
'.ts',
|
|
3
|
-
'.tsx',
|
|
4
|
-
'.js',
|
|
5
|
-
'.jsx',
|
|
6
|
-
'.mjs',
|
|
7
|
-
'.cjs',
|
|
8
|
-
'.css',
|
|
9
|
-
'.scss',
|
|
10
|
-
'.json',
|
|
11
|
-
'.html',
|
|
12
|
-
'.md',
|
|
13
|
-
])
|
|
14
|
-
|
|
15
1
|
export const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'])
|
|
16
2
|
|
|
17
3
|
export const RESOLVE_EXTENSIONS = [
|
|
@@ -147,16 +133,27 @@ export function extractImportBindings(source) {
|
|
|
147
133
|
return bindings
|
|
148
134
|
}
|
|
149
135
|
|
|
136
|
+
function firstKnownWithPrefix(prefix, known) {
|
|
137
|
+
for (const id of known) {
|
|
138
|
+
if (!id.startsWith(prefix)) continue
|
|
139
|
+
if (id.slice(prefix.length).includes('/')) continue
|
|
140
|
+
return id
|
|
141
|
+
}
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
|
|
150
145
|
export function resolveSpecifierAgainst(candidate, known) {
|
|
151
146
|
if (known.has(candidate)) return candidate
|
|
152
147
|
for (const ext of RESOLVE_EXTENSIONS) {
|
|
153
148
|
if (known.has(candidate + ext)) return candidate + ext
|
|
154
149
|
}
|
|
150
|
+
const withExt = firstKnownWithPrefix(`${candidate}.`, known)
|
|
151
|
+
if (withExt) return withExt
|
|
155
152
|
for (const ext of RESOLVE_EXTENSIONS) {
|
|
156
153
|
const indexFile = `${candidate}/index${ext}`
|
|
157
154
|
if (known.has(indexFile)) return indexFile
|
|
158
155
|
}
|
|
159
|
-
return
|
|
156
|
+
return firstKnownWithPrefix(`${candidate}/index.`, known)
|
|
160
157
|
}
|
|
161
158
|
|
|
162
159
|
function destructuredNames(pattern, { importClause = false } = {}) {
|
|
@@ -80,6 +80,7 @@ export const emptyIntent: {
|
|
|
80
80
|
updatedAt: null
|
|
81
81
|
showMap: boolean
|
|
82
82
|
status: string
|
|
83
|
+
name: null
|
|
83
84
|
feature: null
|
|
84
85
|
steps: unknown[]
|
|
85
86
|
step: null
|
|
@@ -107,6 +108,10 @@ export const emptyIntent: {
|
|
|
107
108
|
working: boolean
|
|
108
109
|
stalledWait: boolean
|
|
109
110
|
llmIdle: boolean
|
|
111
|
+
awaitingAttach: boolean
|
|
112
|
+
listening: boolean
|
|
113
|
+
lastAck: { kind: string; detail: string; at: string | null } | null
|
|
114
|
+
initialInstruction: string | null
|
|
110
115
|
creationMode: boolean
|
|
111
116
|
canEnterBlueprint: boolean
|
|
112
117
|
blueprintSessionId: string | null
|
|
@@ -507,6 +507,7 @@ export const emptyIntent = {
|
|
|
507
507
|
updatedAt: null,
|
|
508
508
|
showMap: false,
|
|
509
509
|
status: 'idle',
|
|
510
|
+
name: null,
|
|
510
511
|
feature: null,
|
|
511
512
|
steps: [],
|
|
512
513
|
step: null,
|
|
@@ -534,6 +535,10 @@ export const emptyIntent = {
|
|
|
534
535
|
working: false,
|
|
535
536
|
stalledWait: false,
|
|
536
537
|
llmIdle: false,
|
|
538
|
+
awaitingAttach: false,
|
|
539
|
+
listening: false,
|
|
540
|
+
lastAck: null,
|
|
541
|
+
initialInstruction: null,
|
|
537
542
|
creationMode: false,
|
|
538
543
|
canEnterBlueprint: false,
|
|
539
544
|
blueprintSessionId: null,
|
|
@@ -2,7 +2,6 @@ import fs from 'node:fs'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
import {
|
|
5
|
-
FILE_EXTENSIONS,
|
|
6
5
|
SOURCE_EXTENSIONS,
|
|
7
6
|
RESOLVE_EXTENSIONS,
|
|
8
7
|
collectImportSpecifiers,
|
|
@@ -25,11 +24,23 @@ const IGNORE_DIRS = new Set([
|
|
|
25
24
|
'.inbase',
|
|
26
25
|
])
|
|
27
26
|
const IGNORE_FILES = new Set(['package-lock.json'])
|
|
27
|
+
const BINARY_PROBE_BYTES = 8000
|
|
28
28
|
|
|
29
29
|
function toPosix(filePath) {
|
|
30
30
|
return filePath.split(path.sep).join('/')
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
function isBinaryFile(filePath) {
|
|
34
|
+
const fd = fs.openSync(filePath, 'r')
|
|
35
|
+
try {
|
|
36
|
+
const buf = Buffer.alloc(BINARY_PROBE_BYTES)
|
|
37
|
+
const bytes = fs.readSync(fd, buf, 0, buf.length, 0)
|
|
38
|
+
return buf.subarray(0, bytes).includes(0)
|
|
39
|
+
} finally {
|
|
40
|
+
fs.closeSync(fd)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
33
44
|
function walk(dir, acc = []) {
|
|
34
45
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
35
46
|
if (entry.name.startsWith('.')) continue
|
|
@@ -39,8 +50,9 @@ function walk(dir, acc = []) {
|
|
|
39
50
|
continue
|
|
40
51
|
}
|
|
41
52
|
if (IGNORE_FILES.has(entry.name)) continue
|
|
42
|
-
|
|
43
|
-
|
|
53
|
+
const absolutePath = path.join(dir, entry.name)
|
|
54
|
+
if (isBinaryFile(absolutePath)) continue
|
|
55
|
+
acc.push(absolutePath)
|
|
44
56
|
}
|
|
45
57
|
return acc
|
|
46
58
|
}
|
|
@@ -55,24 +67,40 @@ function extractSymbols(source, ext) {
|
|
|
55
67
|
return extractJsSymbols(source)
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
function
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
function isUsableFile(filePath) {
|
|
71
|
+
return fs.existsSync(filePath) && fs.statSync(filePath).isFile() && !isBinaryFile(filePath)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fileWithStem(dir, stem) {
|
|
75
|
+
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return null
|
|
76
|
+
for (const name of fs.readdirSync(dir)) {
|
|
77
|
+
if (name.startsWith('.')) continue
|
|
78
|
+
const parsed = path.parse(name)
|
|
79
|
+
if (parsed.name !== stem || !parsed.ext) continue
|
|
80
|
+
const full = path.join(dir, name)
|
|
81
|
+
if (isUsableFile(full)) return full
|
|
61
82
|
}
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function resolveExisting(candidate) {
|
|
87
|
+
if (isUsableFile(candidate)) return candidate
|
|
62
88
|
|
|
63
89
|
for (const ext of RESOLVE_EXTENSIONS) {
|
|
64
90
|
const withExt = candidate + ext
|
|
65
|
-
if (
|
|
66
|
-
return withExt
|
|
67
|
-
}
|
|
91
|
+
if (isUsableFile(withExt)) return withExt
|
|
68
92
|
}
|
|
69
93
|
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
94
|
+
const sibling = fileWithStem(path.dirname(candidate), path.basename(candidate))
|
|
95
|
+
if (sibling) return sibling
|
|
96
|
+
|
|
97
|
+
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {
|
|
72
98
|
for (const ext of RESOLVE_EXTENSIONS) {
|
|
73
|
-
const indexFile = path.join(
|
|
74
|
-
if (
|
|
99
|
+
const indexFile = path.join(candidate, `index${ext}`)
|
|
100
|
+
if (isUsableFile(indexFile)) return indexFile
|
|
75
101
|
}
|
|
102
|
+
const indexFile = fileWithStem(candidate, 'index')
|
|
103
|
+
if (indexFile) return indexFile
|
|
76
104
|
}
|
|
77
105
|
|
|
78
106
|
return null
|
|
@@ -110,6 +138,11 @@ function ensureFolder(folders, folderPath, rootName) {
|
|
|
110
138
|
if (parent) ensureFolder(folders, parent, rootName)
|
|
111
139
|
}
|
|
112
140
|
|
|
141
|
+
export function listSourceFiles(root) {
|
|
142
|
+
if (!root || !fs.existsSync(root) || !fs.statSync(root).isDirectory()) return []
|
|
143
|
+
return walk(root).map((absolutePath) => toPosix(path.relative(root, absolutePath)))
|
|
144
|
+
}
|
|
145
|
+
|
|
113
146
|
export function scanTarget({
|
|
114
147
|
root = defaultTargetRoot,
|
|
115
148
|
name = path.basename(root),
|
|
@@ -20,6 +20,7 @@ export type DiffEntry = {
|
|
|
20
20
|
export type DiffManifest = {
|
|
21
21
|
version: number
|
|
22
22
|
sessionId: string
|
|
23
|
+
name: string
|
|
23
24
|
feature: string
|
|
24
25
|
steps: Array<{ index: number; title: string }>
|
|
25
26
|
status: 'active' | 'finished' | 'rejected'
|
|
@@ -33,9 +34,11 @@ export type DiffManifest = {
|
|
|
33
34
|
| 'replanning'
|
|
34
35
|
| 'finished'
|
|
35
36
|
| 'stopped'
|
|
37
|
+
awaitingAttach?: boolean
|
|
36
38
|
currentStep: number
|
|
37
39
|
activeDiffId: string | null
|
|
38
40
|
pendingInstruction: string | null
|
|
41
|
+
initialInstruction?: string | null
|
|
39
42
|
workStartedAt: string | null
|
|
40
43
|
stepByStep: boolean
|
|
41
44
|
createdAt: string
|
|
@@ -47,7 +50,26 @@ export function assertSessionId(value: unknown): string
|
|
|
47
50
|
export function readActiveSession(dataDir: string): string | null
|
|
48
51
|
export function writeActiveSession(dataDir: string, sessionId: string | null): void
|
|
49
52
|
export function focusSession(dataDir: string, sessionId: string): string
|
|
53
|
+
export function sessionPaths(
|
|
54
|
+
dataDir: string,
|
|
55
|
+
sessionId: string,
|
|
56
|
+
): {
|
|
57
|
+
root: string
|
|
58
|
+
diffs: string
|
|
59
|
+
manifest: string
|
|
60
|
+
blueprint: string
|
|
61
|
+
baseline: string
|
|
62
|
+
baselineFiles: string
|
|
63
|
+
preStep: string
|
|
64
|
+
stopped: string
|
|
65
|
+
}
|
|
50
66
|
export function touchSessionConnection(dataDir: string, sessionId: string): void
|
|
67
|
+
export function recordSessionAck(
|
|
68
|
+
dataDir: string,
|
|
69
|
+
sessionId: string,
|
|
70
|
+
kind: string,
|
|
71
|
+
detail?: string,
|
|
72
|
+
): { kind: string; detail: string; at: string } | null
|
|
51
73
|
export function isSessionConnected(
|
|
52
74
|
dataDir: string,
|
|
53
75
|
sessionId: string,
|
|
@@ -86,9 +108,28 @@ export function startSession(
|
|
|
86
108
|
dataDir: string,
|
|
87
109
|
input: {
|
|
88
110
|
sessionId: string
|
|
111
|
+
name?: string
|
|
112
|
+
feature?: string
|
|
113
|
+
},
|
|
114
|
+
): DiffManifest
|
|
115
|
+
export function setupSession(
|
|
116
|
+
dataDir: string,
|
|
117
|
+
input?: {
|
|
118
|
+
sessionId?: string
|
|
119
|
+
name?: string
|
|
89
120
|
feature?: string
|
|
90
121
|
},
|
|
91
122
|
): DiffManifest
|
|
123
|
+
export function attachSession(
|
|
124
|
+
dataDir: string,
|
|
125
|
+
sessionId?: string | null,
|
|
126
|
+
): DiffManifest
|
|
127
|
+
export function setInitialInstruction(
|
|
128
|
+
dataDir: string,
|
|
129
|
+
sessionId: string,
|
|
130
|
+
instruction: string | null | undefined,
|
|
131
|
+
): DiffManifest
|
|
132
|
+
export function readAttachedSession(dataDir: string): string | null
|
|
92
133
|
export type SessionBlueprint = {
|
|
93
134
|
enabled: boolean
|
|
94
135
|
sent: boolean
|
|
@@ -132,12 +173,18 @@ export function sendBlueprint(
|
|
|
132
173
|
addedImports?: unknown[]
|
|
133
174
|
},
|
|
134
175
|
): DiffManifest
|
|
176
|
+
export function maybeStartVisualizerHandshake(
|
|
177
|
+
dataDir: string,
|
|
178
|
+
sessionId: string,
|
|
179
|
+
): DiffManifest
|
|
135
180
|
export function reportPlan(
|
|
136
181
|
dataDir: string,
|
|
137
182
|
input: {
|
|
138
183
|
sessionId: string
|
|
184
|
+
name?: string
|
|
139
185
|
feature: string
|
|
140
186
|
stepTitles: string[]
|
|
187
|
+
targetRoot?: string | null
|
|
141
188
|
},
|
|
142
189
|
): DiffManifest
|
|
143
190
|
export function isStepByStep(manifest: DiffManifest | null | undefined): boolean
|
|
@@ -186,6 +233,16 @@ export function materializeDiff(
|
|
|
186
233
|
sessionId: string,
|
|
187
234
|
diffId?: string | null,
|
|
188
235
|
): DiffManifest
|
|
236
|
+
export function snapshotPreStep(
|
|
237
|
+
dataDir: string,
|
|
238
|
+
sessionId: string,
|
|
239
|
+
targetRoot: string,
|
|
240
|
+
): string
|
|
241
|
+
export function readLiveDiff(
|
|
242
|
+
dataDir: string,
|
|
243
|
+
sessionId: string,
|
|
244
|
+
targetRoot: string,
|
|
245
|
+
): string
|
|
189
246
|
export function inspectTargetFile(
|
|
190
247
|
dataDir: string,
|
|
191
248
|
targetRoot: string,
|
|
@@ -200,7 +257,7 @@ export function appendDiff(
|
|
|
200
257
|
targetRoot: string,
|
|
201
258
|
input: {
|
|
202
259
|
sessionId: string
|
|
203
|
-
patchText
|
|
260
|
+
patchText?: string
|
|
204
261
|
},
|
|
205
262
|
): { manifest: DiffManifest; entry: DiffEntry }
|
|
206
263
|
export function continueDiff(
|