@jkwd/inbase 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +76 -0
  2. package/apps/explorer/index.html +12 -0
  3. package/apps/explorer/package.json +28 -0
  4. package/apps/explorer/scripts/js-source.mjs +188 -0
  5. package/apps/explorer/scripts/patch-lib.d.ts +115 -0
  6. package/apps/explorer/scripts/patch-lib.mjs +472 -0
  7. package/apps/explorer/scripts/scan-target.mjs +188 -0
  8. package/apps/explorer/scripts/session-store.d.ts +156 -0
  9. package/apps/explorer/scripts/session-store.mjs +809 -0
  10. package/apps/explorer/scripts/target-config.d.ts +8 -0
  11. package/apps/explorer/scripts/target-config.mjs +42 -0
  12. package/apps/explorer/src/App.tsx +941 -0
  13. package/apps/explorer/src/agentIntent.ts +182 -0
  14. package/apps/explorer/src/codebase.ts +15 -0
  15. package/apps/explorer/src/index.css +632 -0
  16. package/apps/explorer/src/layout.ts +508 -0
  17. package/apps/explorer/src/main.tsx +16 -0
  18. package/apps/explorer/src/scene/BlockPlacer.tsx +87 -0
  19. package/apps/explorer/src/scene/Bridge.tsx +290 -0
  20. package/apps/explorer/src/scene/FileBlock.tsx +256 -0
  21. package/apps/explorer/src/scene/FolderArea.tsx +96 -0
  22. package/apps/explorer/src/scene/IslandPlacer.tsx +40 -0
  23. package/apps/explorer/src/scene/MapSelectBorder.tsx +57 -0
  24. package/apps/explorer/src/scene/MapView.tsx +247 -0
  25. package/apps/explorer/src/scene/Player.tsx +245 -0
  26. package/apps/explorer/src/scene/RelationLines.tsx +223 -0
  27. package/apps/explorer/src/scene/SelectionController.tsx +89 -0
  28. package/apps/explorer/src/scene/UserContextTracker.tsx +152 -0
  29. package/apps/explorer/src/scene/World.tsx +323 -0
  30. package/apps/explorer/src/theme.ts +111 -0
  31. package/apps/explorer/src/types.ts +245 -0
  32. package/apps/explorer/src/ui/CanvasErrorBoundary.tsx +38 -0
  33. package/apps/explorer/src/ui/HUD.tsx +1090 -0
  34. package/apps/explorer/src/ui/NameInput.tsx +45 -0
  35. package/apps/explorer/src/userContext.ts +73 -0
  36. package/apps/explorer/src/userCreated.ts +354 -0
  37. package/apps/explorer/src/vite-env.d.ts +1 -0
  38. package/apps/explorer/tsconfig.json +21 -0
  39. package/apps/explorer/vite.config.ts +295 -0
  40. package/bin/inbase.mjs +170 -0
  41. package/bin/project.mjs +94 -0
  42. package/bin/session.mjs +241 -0
  43. package/package.json +63 -0
  44. package/skill/inbase/SKILL.md +167 -0
@@ -0,0 +1,295 @@
1
+ import { defineConfig, type Plugin } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import { spawnSync } from 'node:child_process'
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import type { IncomingMessage, ServerResponse } from 'node:http'
8
+ import { emptyIntent } from './scripts/patch-lib.mjs'
9
+ import { dataDir, targetRoot } from './scripts/target-config.mjs'
10
+ import {
11
+ answerBlueprint,
12
+ continueDiff,
13
+ invokeStep,
14
+ readActiveSession,
15
+ requestReplan,
16
+ sendBlueprint,
17
+ sessionIntent,
18
+ stopSession,
19
+ updateBlueprint,
20
+ } from './scripts/session-store.mjs'
21
+
22
+ const here = path.dirname(fileURLToPath(import.meta.url))
23
+ const userContextFile = path.join(dataDir, 'user-context.json')
24
+ const codebaseFile = path.join(dataDir, 'codebase.json')
25
+ const scanScript = path.resolve(here, 'scripts/scan-target.mjs')
26
+
27
+ // The rescan runs with cwd set to the explorer, so hand it the already-resolved
28
+ // root and data dir instead of letting relative env values resolve differently.
29
+ const scanEnv = {
30
+ ...process.env,
31
+ VISUAL_CODER_TARGET: targetRoot,
32
+ INBASE_DATA_DIR: dataDir,
33
+ }
34
+
35
+ function readBody(req: IncomingMessage) {
36
+ return new Promise<string>((resolve, reject) => {
37
+ const chunks: Buffer[] = []
38
+ req.on('data', (chunk) => {
39
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
40
+ })
41
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
42
+ req.on('error', reject)
43
+ })
44
+ }
45
+
46
+ function knownFileIds() {
47
+ try {
48
+ const graph = JSON.parse(fs.readFileSync(codebaseFile, 'utf8')) as {
49
+ files?: Array<{ id?: string }>
50
+ }
51
+ return Array.isArray(graph.files)
52
+ ? graph.files.map((file) => file.id).filter((id): id is string => Boolean(id))
53
+ : []
54
+ } catch {
55
+ return []
56
+ }
57
+ }
58
+
59
+ function sendJson(res: ServerResponse, status: number, body: unknown) {
60
+ res.statusCode = status
61
+ res.setHeader('Content-Type', 'application/json')
62
+ res.end(JSON.stringify(body))
63
+ }
64
+
65
+ function jsonFilePlugin(): Plugin {
66
+ return {
67
+ name: 'visual-coder-json-files',
68
+ configureServer(server) {
69
+ server.middlewares.use('/api/user-context', (req, res, next) => {
70
+ if (req.method === 'GET') {
71
+ sendJson(res, 200, readUserContext())
72
+ return
73
+ }
74
+ if (req.method === 'POST') {
75
+ void writeUserContext(req, res)
76
+ return
77
+ }
78
+ next()
79
+ })
80
+
81
+ server.middlewares.use('/api/codebase', (req, res, next) => {
82
+ if (req.method === 'GET') {
83
+ sendJson(res, 200, readCodebase())
84
+ return
85
+ }
86
+ next()
87
+ })
88
+
89
+ server.middlewares.use('/api/agent-intent', (req, res, next) => {
90
+ if (req.method === 'GET') {
91
+ const url = new URL(req.url ?? '/', 'http://visual-coder.local')
92
+ const sessionId = url.searchParams.get('sessionId') ?? readActiveSession(dataDir)
93
+ const diffId = url.searchParams.get('diffId') ?? undefined
94
+ const intent = sessionId
95
+ ? sessionIntent(dataDir, sessionId, knownFileIds(), diffId)
96
+ : null
97
+ sendJson(res, 200, intent ?? { ...emptyIntent })
98
+ return
99
+ }
100
+
101
+ if (req.method === 'POST') {
102
+ void decideIntent(req, res)
103
+ return
104
+ }
105
+
106
+ next()
107
+ })
108
+ },
109
+ }
110
+ }
111
+
112
+ async function decideIntent(req: IncomingMessage, res: ServerResponse) {
113
+ try {
114
+ const body = JSON.parse(await readBody(req)) as {
115
+ action?: string
116
+ sessionId?: string
117
+ diffId?: string
118
+ instruction?: string
119
+ step?: number
120
+ userCreatedBlocks?: unknown[]
121
+ userCreatedIslands?: unknown[]
122
+ addedFunctions?: unknown[]
123
+ addedVariables?: unknown[]
124
+ addedImports?: unknown[]
125
+ }
126
+ const action = body.action
127
+ if (
128
+ action !== 'invoke' &&
129
+ action !== 'continue' &&
130
+ action !== 'instruct' &&
131
+ action !== 'stop' &&
132
+ action !== 'blueprint_yes' &&
133
+ action !== 'blueprint_no' &&
134
+ action !== 'blueprint_send' &&
135
+ action !== 'blueprint_update'
136
+ ) {
137
+ sendJson(res, 400, { error: 'invalid workflow action' })
138
+ return
139
+ }
140
+ if (!body.sessionId) {
141
+ sendJson(res, 400, { error: 'sessionId is required' })
142
+ return
143
+ }
144
+ if (
145
+ body.instruction !== undefined &&
146
+ (typeof body.instruction !== 'string' || body.instruction.length > 4000)
147
+ ) {
148
+ sendJson(res, 400, { error: 'instruction must be a string up to 4000 characters' })
149
+ return
150
+ }
151
+
152
+ if (action === 'invoke') {
153
+ if (!Number.isInteger(body.step)) {
154
+ sendJson(res, 400, { error: 'step is required for invoke' })
155
+ return
156
+ }
157
+ invokeStep(dataDir, body.sessionId, body.step as number, targetRoot)
158
+ const scan = spawnSync(process.execPath, [scanScript], {
159
+ cwd: here,
160
+ encoding: 'utf8',
161
+ env: scanEnv,
162
+ })
163
+ if (scan.status !== 0) {
164
+ console.error(scan.stderr || scan.stdout || 'scan failed after invoking step')
165
+ }
166
+ } else if (action === 'continue') {
167
+ if (!body.diffId) {
168
+ sendJson(res, 400, { error: 'diffId is required for continue' })
169
+ return
170
+ }
171
+ continueDiff(dataDir, targetRoot, body.sessionId, body.diffId)
172
+ const scan = spawnSync(process.execPath, [scanScript], {
173
+ cwd: here,
174
+ encoding: 'utf8',
175
+ env: scanEnv,
176
+ })
177
+ if (scan.status !== 0) {
178
+ console.error(scan.stderr || scan.stdout || 'scan failed after applying patch')
179
+ }
180
+ } else if (action === 'instruct') {
181
+ if (!body.diffId) {
182
+ sendJson(res, 400, { error: 'diffId is required for instruct' })
183
+ return
184
+ }
185
+ requestReplan(
186
+ dataDir,
187
+ body.sessionId,
188
+ body.diffId,
189
+ body.instruction ?? '',
190
+ )
191
+ } else if (action === 'blueprint_yes') {
192
+ answerBlueprint(dataDir, body.sessionId, true)
193
+ } else if (action === 'blueprint_no') {
194
+ answerBlueprint(dataDir, body.sessionId, false)
195
+ } else if (action === 'blueprint_update') {
196
+ updateBlueprint(dataDir, body.sessionId, {
197
+ userCreatedBlocks: body.userCreatedBlocks,
198
+ userCreatedIslands: body.userCreatedIslands,
199
+ addedFunctions: body.addedFunctions,
200
+ addedVariables: body.addedVariables,
201
+ addedImports: body.addedImports,
202
+ })
203
+ } else if (action === 'blueprint_send') {
204
+ sendBlueprint(dataDir, body.sessionId, {
205
+ userCreatedBlocks: body.userCreatedBlocks,
206
+ userCreatedIslands: body.userCreatedIslands,
207
+ addedFunctions: body.addedFunctions,
208
+ addedVariables: body.addedVariables,
209
+ addedImports: body.addedImports,
210
+ })
211
+ } else {
212
+ stopSession(dataDir, body.sessionId, body.diffId)
213
+ }
214
+ const next = sessionIntent(dataDir, body.sessionId, knownFileIds())
215
+ sendJson(res, 200, next ?? { ...emptyIntent })
216
+ } catch (error) {
217
+ const message = error instanceof Error ? error.message : 'invalid request'
218
+ sendJson(res, 400, { error: message })
219
+ }
220
+ }
221
+
222
+ function readCodebase() {
223
+ try {
224
+ return JSON.parse(fs.readFileSync(codebaseFile, 'utf8')) as {
225
+ root?: string
226
+ targetName?: string
227
+ files?: unknown[]
228
+ folders?: unknown[]
229
+ }
230
+ } catch {
231
+ return {
232
+ root: '.',
233
+ targetName: path.basename(targetRoot),
234
+ files: [],
235
+ folders: [
236
+ {
237
+ path: '.',
238
+ name: path.basename(targetRoot),
239
+ parent: null,
240
+ files: [],
241
+ children: [],
242
+ },
243
+ ],
244
+ }
245
+ }
246
+ }
247
+
248
+ function readUserContext() {
249
+ try {
250
+ const parsed = JSON.parse(fs.readFileSync(userContextFile, 'utf8')) as Record<
251
+ string,
252
+ unknown
253
+ >
254
+ return {
255
+ ...parsed,
256
+ followLook: Boolean(parsed.followLook),
257
+ }
258
+ } catch {
259
+ return { followLook: false }
260
+ }
261
+ }
262
+
263
+ async function writeUserContext(req: IncomingMessage, res: ServerResponse) {
264
+ try {
265
+ const incoming = JSON.parse(await readBody(req)) as Record<string, unknown>
266
+ const existing = readUserContext()
267
+ const next = {
268
+ ...existing,
269
+ ...incoming,
270
+ followLook:
271
+ typeof incoming.followLook === 'boolean'
272
+ ? incoming.followLook
273
+ : Boolean(existing.followLook),
274
+ }
275
+ delete next.userCreatedBlocks
276
+ delete next.userCreatedIslands
277
+ fs.mkdirSync(path.dirname(userContextFile), { recursive: true })
278
+ fs.writeFileSync(userContextFile, `${JSON.stringify(next, null, 2)}\n`)
279
+ res.statusCode = 204
280
+ res.end()
281
+ } catch {
282
+ res.statusCode = 400
283
+ res.end('invalid json')
284
+ }
285
+ }
286
+
287
+ export default defineConfig({
288
+ plugins: [react(), jsonFilePlugin()],
289
+ server: {
290
+ port: 5173,
291
+ fs: {
292
+ allow: [here, dataDir],
293
+ },
294
+ },
295
+ })
package/bin/inbase.mjs ADDED
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath, pathToFileURL } from 'node:url'
6
+ import {
7
+ applyHostEnv,
8
+ copyDir,
9
+ ensureDataDir,
10
+ ensureGitignoreEntry,
11
+ explorerRoot,
12
+ skillTemplateDir,
13
+ takeFlagValue,
14
+ } from './project.mjs'
15
+ import {
16
+ proposePatch,
17
+ reportPlan,
18
+ startSession,
19
+ waitForApproval,
20
+ waitForBlueprint,
21
+ } from './session.mjs'
22
+
23
+ const HELP = `inbase — a first-person 3D map of a codebase
24
+
25
+ Usage:
26
+ inbase init Install the Cursor skill in this repo
27
+ inbase run Scan this repo and start the local map
28
+ inbase help Show this help
29
+
30
+ Agent commands (used by the Cursor skill):
31
+ inbase start-session --session <id> [--feature "name"]
32
+ inbase wait-for-blueprint --session <id>
33
+ inbase report-plan --session <id> --feature "name" --steps "one" [--steps "two"]
34
+ inbase wait-for-approval --session <id>
35
+ inbase propose-patch --session <id> <file.patch|->
36
+ inbase propose-patch --session <id> --clear
37
+
38
+ Options for run:
39
+ --target <dir> Project to map (default: current directory)
40
+ --port <number> Dev server port (default: 5173)
41
+ `
42
+
43
+ function printHelp() {
44
+ console.log(HELP.trim())
45
+ }
46
+
47
+ export function initProject(projectRoot = process.cwd()) {
48
+ if (!fs.existsSync(skillTemplateDir)) {
49
+ throw new Error(`Inbase skill template missing at ${skillTemplateDir}`)
50
+ }
51
+ const skillDir = path.join(projectRoot, '.cursor/skills/inbase')
52
+ copyDir(skillTemplateDir, skillDir)
53
+ const { dataDir } = applyHostEnv({
54
+ cwd: projectRoot,
55
+ target: projectRoot,
56
+ dataDir: path.join(projectRoot, '.inbase'),
57
+ })
58
+ ensureDataDir(dataDir)
59
+ const gitignoreAdded = ensureGitignoreEntry(projectRoot)
60
+ return { skillDir, dataDir, gitignoreAdded }
61
+ }
62
+
63
+ function explorerHref(relative) {
64
+ return pathToFileURL(path.join(explorerRoot, relative)).href
65
+ }
66
+
67
+ async function runServer(args) {
68
+ const target = takeFlagValue(args, '--target')
69
+ const portValue = takeFlagValue(args, '--port')
70
+ const port = portValue ? Number(portValue) : 5173
71
+ if (portValue && !Number.isInteger(port)) {
72
+ console.error('inbase run --port must be an integer')
73
+ process.exit(1)
74
+ }
75
+
76
+ const { targetRoot, dataDir } = applyHostEnv({ target })
77
+ if (!fs.existsSync(targetRoot)) {
78
+ console.error(`Target not found at ${targetRoot}`)
79
+ process.exit(1)
80
+ }
81
+ ensureDataDir(dataDir)
82
+
83
+ const { scanTarget } = await import(explorerHref('scripts/scan-target.mjs'))
84
+ const { targetName } = await import(explorerHref('scripts/target-config.mjs'))
85
+ scanTarget({
86
+ root: targetRoot,
87
+ name: targetName,
88
+ dest: path.join(dataDir, 'codebase.json'),
89
+ })
90
+
91
+ const { createServer } = await import('vite')
92
+ const server = await createServer({
93
+ configFile: path.join(explorerRoot, 'vite.config.ts'),
94
+ root: explorerRoot,
95
+ server: {
96
+ port,
97
+ host: '127.0.0.1',
98
+ fs: {
99
+ allow: [explorerRoot, targetRoot, dataDir],
100
+ },
101
+ },
102
+ })
103
+ await server.listen()
104
+ const local = server.resolvedUrls?.local?.[0] ?? `http://localhost:${port}/`
105
+ console.log(`Inbase is mapping ${targetRoot}`)
106
+ console.log(`Open ${local}`)
107
+ console.log('Leave this running. In Cursor, the inbase skill talks to this server.')
108
+ }
109
+
110
+ export async function main(argv = process.argv.slice(2)) {
111
+ const [command, ...args] = argv
112
+ if (
113
+ !command ||
114
+ command === 'help' ||
115
+ command === '-h' ||
116
+ command === '--help'
117
+ ) {
118
+ printHelp()
119
+ return
120
+ }
121
+
122
+ if (command === 'init') {
123
+ const result = initProject()
124
+ console.log(`Installed Cursor skill at ${result.skillDir}`)
125
+ if (result.gitignoreAdded) console.log('Added .inbase/ to .gitignore')
126
+ console.log('Next: run `inbase run`, then ask Cursor to change source files.')
127
+ return
128
+ }
129
+
130
+ if (command === 'run') {
131
+ await runServer(args)
132
+ return
133
+ }
134
+
135
+ applyHostEnv()
136
+ ensureDataDir(process.env.INBASE_DATA_DIR)
137
+
138
+ if (command === 'start-session') {
139
+ await startSession(args)
140
+ return
141
+ }
142
+ if (command === 'wait-for-blueprint') {
143
+ await waitForBlueprint(args)
144
+ return
145
+ }
146
+ if (command === 'report-plan') {
147
+ await reportPlan(args)
148
+ return
149
+ }
150
+ if (command === 'wait-for-approval') {
151
+ await waitForApproval(args)
152
+ return
153
+ }
154
+ if (command === 'propose-patch') {
155
+ await proposePatch(args)
156
+ return
157
+ }
158
+
159
+ console.error(`Unknown command: ${command}\n`)
160
+ printHelp()
161
+ process.exitCode = 1
162
+ }
163
+
164
+ const invoked = process.argv[1] ? path.resolve(process.argv[1]) : ''
165
+ if (invoked === fileURLToPath(import.meta.url)) {
166
+ main().catch((error) => {
167
+ console.error(error instanceof Error ? error.message : error)
168
+ process.exit(1)
169
+ })
170
+ }
@@ -0,0 +1,94 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ const here = path.dirname(fileURLToPath(import.meta.url))
6
+ export const packageRoot = path.resolve(here, '..')
7
+ export const explorerRoot = path.join(packageRoot, 'apps/explorer')
8
+ export const skillTemplateDir = path.join(packageRoot, 'skill/inbase')
9
+
10
+ export function resolveOptionalPath(value, fallback) {
11
+ const raw = value?.trim()
12
+ if (!raw) return fallback
13
+ return path.isAbsolute(raw) ? path.normalize(raw) : path.resolve(process.cwd(), raw)
14
+ }
15
+
16
+ export function applyHostEnv({
17
+ cwd = process.cwd(),
18
+ target = process.env.VISUAL_CODER_TARGET,
19
+ dataDir = process.env.INBASE_DATA_DIR,
20
+ } = {}) {
21
+ const targetRoot = resolveOptionalPath(target, cwd)
22
+ const resolvedDataDir = resolveOptionalPath(
23
+ dataDir,
24
+ path.join(targetRoot, '.inbase'),
25
+ )
26
+ process.env.VISUAL_CODER_TARGET = targetRoot
27
+ process.env.INBASE_DATA_DIR = resolvedDataDir
28
+ return { cwd, targetRoot, dataDir: resolvedDataDir }
29
+ }
30
+
31
+ export function ensureDataDir(dataDir) {
32
+ fs.mkdirSync(dataDir, { recursive: true })
33
+ const userContextFile = path.join(dataDir, 'user-context.json')
34
+ if (!fs.existsSync(userContextFile)) {
35
+ fs.writeFileSync(
36
+ userContextFile,
37
+ `${JSON.stringify({ followLook: false }, null, 2)}\n`,
38
+ )
39
+ }
40
+ return dataDir
41
+ }
42
+
43
+ export function takeFlagValue(args, flag) {
44
+ const index = args.indexOf(flag)
45
+ return index >= 0 ? args[index + 1] : null
46
+ }
47
+
48
+ export function takeFlagValues(args, flag) {
49
+ const values = []
50
+ const rest = []
51
+ for (let index = 0; index < args.length; index += 1) {
52
+ if (args[index] === flag && args[index + 1]) {
53
+ values.push(args[index + 1])
54
+ index += 1
55
+ } else {
56
+ rest.push(args[index])
57
+ }
58
+ }
59
+ return { values, rest }
60
+ }
61
+
62
+ export function withoutFlag(args, flag) {
63
+ return args.filter((arg) => arg !== flag)
64
+ }
65
+
66
+ export function copyDir(from, to) {
67
+ fs.mkdirSync(to, { recursive: true })
68
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
69
+ const source = path.join(from, entry.name)
70
+ const dest = path.join(to, entry.name)
71
+ if (entry.isDirectory()) {
72
+ copyDir(source, dest)
73
+ continue
74
+ }
75
+ fs.copyFileSync(source, dest)
76
+ }
77
+ }
78
+
79
+ export function ensureGitignoreEntry(projectRoot, entry = '.inbase/') {
80
+ const gitignore = path.join(projectRoot, '.gitignore')
81
+ const line = entry.endsWith('\n') ? entry : `${entry}\n`
82
+ if (!fs.existsSync(gitignore)) {
83
+ fs.writeFileSync(gitignore, line)
84
+ return true
85
+ }
86
+ const current = fs.readFileSync(gitignore, 'utf8')
87
+ const hasEntry = current
88
+ .split(/\r?\n/)
89
+ .some((row) => row.trim() === entry || row.trim() === entry.replace(/\/$/, ''))
90
+ if (hasEntry) return false
91
+ const prefix = current.endsWith('\n') || current === '' ? '' : '\n'
92
+ fs.appendFileSync(gitignore, `${prefix}${line}`)
93
+ return true
94
+ }