@jkwd/inbase 0.1.17 → 0.1.19
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/README.md +2 -2
- package/apps/explorer/scripts/patch-lib.d.ts +1 -0
- package/apps/explorer/scripts/patch-lib.mjs +1 -0
- package/apps/explorer/scripts/session-store.d.ts +42 -0
- package/apps/explorer/scripts/session-store.mjs +227 -3
- package/apps/explorer/src/App.tsx +2 -1
- package/apps/explorer/src/agentIntent.ts +66 -0
- package/apps/explorer/src/index.css +65 -0
- package/apps/explorer/src/scene/MapView.tsx +1 -11
- package/apps/explorer/src/scene/World.tsx +0 -1
- package/apps/explorer/src/types.ts +6 -0
- package/apps/explorer/src/ui/HUD.tsx +198 -3
- package/apps/explorer/vite.config.ts +20 -13
- package/bin/project.mjs +9 -0
- package/bin/session.mjs +41 -11
- package/package.json +1 -1
- package/skill/commands/inbase.md +1 -1
- package/skill/inbase/SKILL.md +7 -3
package/README.md
CHANGED
|
@@ -49,9 +49,9 @@ inbase run --target /path/to/your/project
|
|
|
49
49
|
|
|
50
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
|
-
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. The blueprint is shared across sessions and stays on the map even after those files and folders exist; use **Hide/Show blueprint**, **Clear blueprint**, or **Cleanup blueprint** (drops planned items that already exist). When a session finishes it is discarded; the shared blueprint remains. Restarting the visualizer also starts with no LLM session; leftover session files are not restored.
|
|
52
|
+
Sessions start in the map: click **Setup LLM session**, type an **initial instruction**, optionally drop **context files** for the chat to read, 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. Dropped context files stay with that session until it ends. The blueprint is shared across sessions and stays on the map even after those files and folders exist; use **Hide/Show blueprint**, **Clear blueprint**, or **Cleanup blueprint** (drops planned items that already exist). When a session finishes it is discarded; the shared blueprint remains. Restarting the visualizer also starts with no LLM session; leftover session files are not restored.
|
|
53
53
|
|
|
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.
|
|
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, instruction, and attached files.
|
|
55
55
|
|
|
56
56
|
If several sessions are open, `/inbase` attaches the oldest session that is still waiting. Sessions that already have an LLM are skipped, and the map window does not need to be focused.
|
|
57
57
|
|
|
@@ -112,6 +112,7 @@ export const emptyIntent: {
|
|
|
112
112
|
listening: boolean
|
|
113
113
|
lastAck: { kind: string; detail: string; at: string | null } | null
|
|
114
114
|
initialInstruction: string | null
|
|
115
|
+
contextFiles: unknown[]
|
|
115
116
|
creationMode: boolean
|
|
116
117
|
canEnterBlueprint: boolean
|
|
117
118
|
blueprintHidden?: boolean
|
|
@@ -17,6 +17,22 @@ export type DiffEntry = {
|
|
|
17
17
|
decidedAt: string | null
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
export type SessionContextFile = {
|
|
21
|
+
id: string
|
|
22
|
+
name: string
|
|
23
|
+
storedName: string
|
|
24
|
+
mimeType: string
|
|
25
|
+
size: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SessionContextFileInfo = {
|
|
29
|
+
id: string
|
|
30
|
+
name: string
|
|
31
|
+
mimeType: string
|
|
32
|
+
size: number
|
|
33
|
+
path?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
20
36
|
export type DiffManifest = {
|
|
21
37
|
version: number
|
|
22
38
|
sessionId: string
|
|
@@ -39,6 +55,7 @@ export type DiffManifest = {
|
|
|
39
55
|
activeDiffId: string | null
|
|
40
56
|
pendingInstruction: string | null
|
|
41
57
|
initialInstruction?: string | null
|
|
58
|
+
contextFiles?: SessionContextFile[]
|
|
42
59
|
workStartedAt: string | null
|
|
43
60
|
stepByStep: boolean
|
|
44
61
|
createdAt: string
|
|
@@ -61,6 +78,7 @@ export function sessionPaths(
|
|
|
61
78
|
baseline: string
|
|
62
79
|
baselineFiles: string
|
|
63
80
|
preStep: string
|
|
81
|
+
context: string
|
|
64
82
|
stopped: string
|
|
65
83
|
}
|
|
66
84
|
export function touchSessionConnection(dataDir: string, sessionId: string): void
|
|
@@ -129,6 +147,30 @@ export function setInitialInstruction(
|
|
|
129
147
|
sessionId: string,
|
|
130
148
|
instruction: string | null | undefined,
|
|
131
149
|
): DiffManifest
|
|
150
|
+
export const MAX_CONTEXT_FILES: number
|
|
151
|
+
export const MAX_CONTEXT_FILE_BYTES: number
|
|
152
|
+
export const MAX_CONTEXT_TOTAL_BYTES: number
|
|
153
|
+
export function listContextFiles(
|
|
154
|
+
dataDir: string,
|
|
155
|
+
sessionId: string,
|
|
156
|
+
): Array<SessionContextFile & { path: string }>
|
|
157
|
+
export function contextFileHandshake(
|
|
158
|
+
dataDir: string,
|
|
159
|
+
sessionId: string,
|
|
160
|
+
): {
|
|
161
|
+
files: Array<{ name: string; path: string; mimeType: string; size: number }>
|
|
162
|
+
texts: Array<{ name: string; content: string }>
|
|
163
|
+
}
|
|
164
|
+
export function addContextFiles(
|
|
165
|
+
dataDir: string,
|
|
166
|
+
sessionId: string,
|
|
167
|
+
files: unknown,
|
|
168
|
+
): DiffManifest
|
|
169
|
+
export function removeContextFile(
|
|
170
|
+
dataDir: string,
|
|
171
|
+
sessionId: string,
|
|
172
|
+
fileId: string,
|
|
173
|
+
): DiffManifest
|
|
132
174
|
export function readAttachedSession(dataDir: string): string | null
|
|
133
175
|
export function listAttachQueue(dataDir: string): string[]
|
|
134
176
|
export function nextAttachSessionId(dataDir: string): string | null
|
|
@@ -16,6 +16,10 @@ import { diffSourceTrees, snapshotSourceTree } from './tree-diff.mjs'
|
|
|
16
16
|
const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
17
17
|
const CONNECTED_TTL_MS = 15_000
|
|
18
18
|
const STALLED_WAIT_MS = 2_000
|
|
19
|
+
export const MAX_CONTEXT_FILES = 16
|
|
20
|
+
export const MAX_CONTEXT_FILE_BYTES = 8 * 1024 * 1024
|
|
21
|
+
export const MAX_CONTEXT_TOTAL_BYTES = 24 * 1024 * 1024
|
|
22
|
+
const CONTEXT_TEXT_INLINE_BYTES = 100_000
|
|
19
23
|
|
|
20
24
|
export function assertSessionId(value) {
|
|
21
25
|
if (typeof value !== 'string' || !SESSION_ID.test(value) || value === '.' || value === '..') {
|
|
@@ -65,6 +69,7 @@ export function sessionPaths(dataDir, sessionId) {
|
|
|
65
69
|
baseline: path.join(root, 'baseline.json'),
|
|
66
70
|
baselineFiles: path.join(root, 'baseline'),
|
|
67
71
|
preStep: path.join(root, 'pre-step'),
|
|
72
|
+
context: path.join(root, 'context'),
|
|
68
73
|
stopped: path.join(dataDir, 'diff-sessions', `${safeId}.stopped`),
|
|
69
74
|
}
|
|
70
75
|
}
|
|
@@ -423,6 +428,129 @@ function blueprintHasContent(blueprint) {
|
|
|
423
428
|
)
|
|
424
429
|
}
|
|
425
430
|
|
|
431
|
+
function normalizeContextFiles(value) {
|
|
432
|
+
if (!Array.isArray(value)) return []
|
|
433
|
+
return value.flatMap((item) => {
|
|
434
|
+
if (!item || typeof item !== 'object') return []
|
|
435
|
+
const id = typeof item.id === 'string' ? item.id.trim() : ''
|
|
436
|
+
const name = typeof item.name === 'string' ? item.name.trim() : ''
|
|
437
|
+
const storedName =
|
|
438
|
+
typeof item.storedName === 'string' ? item.storedName.trim() : ''
|
|
439
|
+
const size = item.size
|
|
440
|
+
if (!id || !name || !storedName || !Number.isFinite(size) || size < 0) {
|
|
441
|
+
return []
|
|
442
|
+
}
|
|
443
|
+
if (storedName.includes('..') || path.basename(storedName) !== storedName) {
|
|
444
|
+
return []
|
|
445
|
+
}
|
|
446
|
+
return [
|
|
447
|
+
{
|
|
448
|
+
id,
|
|
449
|
+
name,
|
|
450
|
+
storedName,
|
|
451
|
+
mimeType:
|
|
452
|
+
typeof item.mimeType === 'string' && item.mimeType.trim()
|
|
453
|
+
? item.mimeType.trim()
|
|
454
|
+
: 'application/octet-stream',
|
|
455
|
+
size,
|
|
456
|
+
},
|
|
457
|
+
]
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function safeContextFileName(name) {
|
|
462
|
+
const base = path.basename(String(name || 'file')).replace(/[\u0000-\u001f]/g, '')
|
|
463
|
+
const cleaned = base
|
|
464
|
+
.replace(/[^\w.\- ()[\]]+/g, '_')
|
|
465
|
+
.replace(/^\.+/, '')
|
|
466
|
+
.trim()
|
|
467
|
+
return (cleaned || 'file').slice(0, 120)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function decodeContextFileBytes(file) {
|
|
471
|
+
if (Buffer.isBuffer(file?.bytes)) return file.bytes
|
|
472
|
+
if (typeof file?.contentBase64 === 'string' && file.contentBase64.trim()) {
|
|
473
|
+
if (!/^[A-Za-z0-9+/=\s]+$/.test(file.contentBase64)) {
|
|
474
|
+
throw new Error('context file content must be base64')
|
|
475
|
+
}
|
|
476
|
+
return Buffer.from(file.contentBase64, 'base64')
|
|
477
|
+
}
|
|
478
|
+
throw new Error('context file bytes are required')
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function contextFileAbsolute(dir, storedName) {
|
|
482
|
+
const absolute = path.resolve(dir, storedName)
|
|
483
|
+
const prefix = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`
|
|
484
|
+
if (absolute !== dir && !absolute.startsWith(prefix)) {
|
|
485
|
+
throw new Error(`Invalid context file path ${storedName}`)
|
|
486
|
+
}
|
|
487
|
+
return absolute
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function publicContextFile(item) {
|
|
491
|
+
return {
|
|
492
|
+
id: item.id,
|
|
493
|
+
name: item.name,
|
|
494
|
+
mimeType: item.mimeType,
|
|
495
|
+
size: item.size,
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function isInlineContextText(mimeType, bytes) {
|
|
500
|
+
if (bytes.length === 0 || bytes.length > CONTEXT_TEXT_INLINE_BYTES) return false
|
|
501
|
+
if (bytes.includes(0)) return false
|
|
502
|
+
const mime = typeof mimeType === 'string' ? mimeType.toLowerCase() : ''
|
|
503
|
+
if (mime.startsWith('audio/') || mime.startsWith('video/')) return false
|
|
504
|
+
if (mime === 'application/pdf' || mime === 'application/zip') return false
|
|
505
|
+
if (mime.startsWith('image/') && mime !== 'image/svg+xml') return false
|
|
506
|
+
if (
|
|
507
|
+
mime.startsWith('text/') ||
|
|
508
|
+
mime === 'application/json' ||
|
|
509
|
+
mime === 'application/javascript' ||
|
|
510
|
+
mime === 'application/xml' ||
|
|
511
|
+
mime === 'image/svg+xml' ||
|
|
512
|
+
mime === 'application/octet-stream' ||
|
|
513
|
+
mime === ''
|
|
514
|
+
) {
|
|
515
|
+
return true
|
|
516
|
+
}
|
|
517
|
+
return !mime.startsWith('image/')
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export function listContextFiles(dataDir, sessionId) {
|
|
521
|
+
const manifest = requireManifest(dataDir, sessionId)
|
|
522
|
+
const dir = sessionPaths(dataDir, sessionId).context
|
|
523
|
+
return normalizeContextFiles(manifest.contextFiles).flatMap((item) => {
|
|
524
|
+
let absolute
|
|
525
|
+
try {
|
|
526
|
+
absolute = contextFileAbsolute(dir, item.storedName)
|
|
527
|
+
} catch {
|
|
528
|
+
return []
|
|
529
|
+
}
|
|
530
|
+
if (!fs.existsSync(absolute)) return []
|
|
531
|
+
return [{ ...item, path: absolute }]
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function contextFileHandshake(dataDir, sessionId) {
|
|
536
|
+
const files = listContextFiles(dataDir, sessionId)
|
|
537
|
+
const listed = []
|
|
538
|
+
const texts = []
|
|
539
|
+
for (const file of files) {
|
|
540
|
+
listed.push({
|
|
541
|
+
name: file.name,
|
|
542
|
+
path: file.path,
|
|
543
|
+
mimeType: file.mimeType,
|
|
544
|
+
size: file.size,
|
|
545
|
+
})
|
|
546
|
+
const bytes = fs.readFileSync(file.path)
|
|
547
|
+
if (isInlineContextText(file.mimeType, bytes)) {
|
|
548
|
+
texts.push({ name: file.name, content: bytes.toString('utf8') })
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return { files: listed, texts }
|
|
552
|
+
}
|
|
553
|
+
|
|
426
554
|
export function readManifest(dataDir, sessionId) {
|
|
427
555
|
const { manifest } = sessionPaths(dataDir, sessionId)
|
|
428
556
|
const value = readJson(manifest, null)
|
|
@@ -448,6 +576,7 @@ export function readManifest(dataDir, sessionId) {
|
|
|
448
576
|
if (typeof value.stepByStep !== 'boolean') value.stepByStep = true
|
|
449
577
|
value.initialInstruction =
|
|
450
578
|
typeof value.initialInstruction === 'string' ? value.initialInstruction : null
|
|
579
|
+
value.contextFiles = normalizeContextFiles(value.contextFiles)
|
|
451
580
|
return value
|
|
452
581
|
}
|
|
453
582
|
|
|
@@ -492,9 +621,12 @@ function liveEntries(manifest, diffId) {
|
|
|
492
621
|
const browsingHistory = selected.id !== manifest.activeDiffId
|
|
493
622
|
return chain.filter((entry) => {
|
|
494
623
|
if (entry.status === 'rejected') return false
|
|
495
|
-
if (entry.status === 'extended'
|
|
624
|
+
if (entry.status === 'extended') {
|
|
496
625
|
return browsingHistory && entry.id === selected.id
|
|
497
626
|
}
|
|
627
|
+
if (entry.status === 'extend') {
|
|
628
|
+
return entry.id === selected.id
|
|
629
|
+
}
|
|
498
630
|
return true
|
|
499
631
|
})
|
|
500
632
|
}
|
|
@@ -627,6 +759,7 @@ export function sessionIntent(
|
|
|
627
759
|
typeof manifest.initialInstruction === 'string'
|
|
628
760
|
? manifest.initialInstruction
|
|
629
761
|
: null,
|
|
762
|
+
contextFiles: listContextFiles(dataDir, sessionId).map(publicContextFile),
|
|
630
763
|
steps: manifest.steps,
|
|
631
764
|
step: activeView ? manifest.currentStep : selected?.step ?? manifest.currentStep,
|
|
632
765
|
stepByStep: isStepByStep(manifest),
|
|
@@ -928,6 +1061,7 @@ export function startSession(dataDir, input) {
|
|
|
928
1061
|
activeDiffId: null,
|
|
929
1062
|
pendingInstruction: null,
|
|
930
1063
|
initialInstruction: null,
|
|
1064
|
+
contextFiles: [],
|
|
931
1065
|
workStartedAt: null,
|
|
932
1066
|
createdAt: now,
|
|
933
1067
|
updatedAt: now,
|
|
@@ -966,6 +1100,7 @@ export function setupSession(dataDir, input = {}) {
|
|
|
966
1100
|
activeDiffId: null,
|
|
967
1101
|
pendingInstruction: null,
|
|
968
1102
|
initialInstruction: null,
|
|
1103
|
+
contextFiles: [],
|
|
969
1104
|
workStartedAt: null,
|
|
970
1105
|
createdAt: now,
|
|
971
1106
|
updatedAt: now,
|
|
@@ -993,6 +1128,92 @@ export function setInitialInstruction(dataDir, sessionId, instruction) {
|
|
|
993
1128
|
return manifest
|
|
994
1129
|
}
|
|
995
1130
|
|
|
1131
|
+
export function addContextFiles(dataDir, sessionId, files) {
|
|
1132
|
+
const manifest = requireManifest(dataDir, sessionId)
|
|
1133
|
+
if (isTerminalSession(manifest)) {
|
|
1134
|
+
throw sessionStoppedError(sessionId)
|
|
1135
|
+
}
|
|
1136
|
+
const incoming = Array.isArray(files) ? files : files ? [files] : []
|
|
1137
|
+
if (incoming.length === 0) {
|
|
1138
|
+
throw new Error('at least one context file is required')
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
const existing = listContextFiles(dataDir, sessionId)
|
|
1142
|
+
if (existing.length + incoming.length > MAX_CONTEXT_FILES) {
|
|
1143
|
+
throw new Error(`at most ${MAX_CONTEXT_FILES} context files can be attached`)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
const dir = sessionPaths(dataDir, sessionId).context
|
|
1147
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
1148
|
+
const existingBytes = existing.reduce((sum, item) => sum + item.size, 0)
|
|
1149
|
+
const next = [...existing.map((item) => ({
|
|
1150
|
+
id: item.id,
|
|
1151
|
+
name: item.name,
|
|
1152
|
+
storedName: item.storedName,
|
|
1153
|
+
mimeType: item.mimeType,
|
|
1154
|
+
size: item.size,
|
|
1155
|
+
}))]
|
|
1156
|
+
let addedBytes = 0
|
|
1157
|
+
|
|
1158
|
+
for (const file of incoming) {
|
|
1159
|
+
const bytes = decodeContextFileBytes(file)
|
|
1160
|
+
if (bytes.length === 0) {
|
|
1161
|
+
throw new Error('context file is empty')
|
|
1162
|
+
}
|
|
1163
|
+
if (bytes.length > MAX_CONTEXT_FILE_BYTES) {
|
|
1164
|
+
throw new Error(
|
|
1165
|
+
`context file must be ${MAX_CONTEXT_FILE_BYTES} bytes or smaller`,
|
|
1166
|
+
)
|
|
1167
|
+
}
|
|
1168
|
+
addedBytes += bytes.length
|
|
1169
|
+
if (existingBytes + addedBytes > MAX_CONTEXT_TOTAL_BYTES) {
|
|
1170
|
+
throw new Error(
|
|
1171
|
+
`attached files must total ${MAX_CONTEXT_TOTAL_BYTES} bytes or less`,
|
|
1172
|
+
)
|
|
1173
|
+
}
|
|
1174
|
+
const id = crypto.randomBytes(4).toString('hex')
|
|
1175
|
+
const originalName =
|
|
1176
|
+
typeof file?.name === 'string' ? path.basename(file.name.trim()) : ''
|
|
1177
|
+
const storedName = `${id}-${safeContextFileName(originalName || 'file')}`
|
|
1178
|
+
fs.writeFileSync(contextFileAbsolute(dir, storedName), bytes)
|
|
1179
|
+
next.push({
|
|
1180
|
+
id,
|
|
1181
|
+
name: originalName || storedName,
|
|
1182
|
+
storedName,
|
|
1183
|
+
mimeType:
|
|
1184
|
+
typeof file?.mimeType === 'string' && file.mimeType.trim()
|
|
1185
|
+
? file.mimeType.trim()
|
|
1186
|
+
: 'application/octet-stream',
|
|
1187
|
+
size: bytes.length,
|
|
1188
|
+
})
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
manifest.contextFiles = next
|
|
1192
|
+
writeManifest(dataDir, manifest)
|
|
1193
|
+
return manifest
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
export function removeContextFile(dataDir, sessionId, fileId) {
|
|
1197
|
+
const manifest = requireManifest(dataDir, sessionId)
|
|
1198
|
+
if (isTerminalSession(manifest)) {
|
|
1199
|
+
throw sessionStoppedError(sessionId)
|
|
1200
|
+
}
|
|
1201
|
+
const id = typeof fileId === 'string' ? fileId.trim() : ''
|
|
1202
|
+
if (!id) throw new Error('fileId is required')
|
|
1203
|
+
const existing = normalizeContextFiles(manifest.contextFiles)
|
|
1204
|
+
const item = existing.find((file) => file.id === id)
|
|
1205
|
+
if (!item) return manifest
|
|
1206
|
+
const dir = sessionPaths(dataDir, sessionId).context
|
|
1207
|
+
try {
|
|
1208
|
+
fs.unlinkSync(contextFileAbsolute(dir, item.storedName))
|
|
1209
|
+
} catch {
|
|
1210
|
+
// Drop the manifest entry even if the file is already gone.
|
|
1211
|
+
}
|
|
1212
|
+
manifest.contextFiles = existing.filter((file) => file.id !== id)
|
|
1213
|
+
writeManifest(dataDir, manifest)
|
|
1214
|
+
return manifest
|
|
1215
|
+
}
|
|
1216
|
+
|
|
996
1217
|
function generateVisualizerSessionId(dataDir) {
|
|
997
1218
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
998
1219
|
const sessionId = `viz-${crypto.randomBytes(6).toString('hex')}`
|
|
@@ -1124,6 +1345,7 @@ export function reportPlan(dataDir, input) {
|
|
|
1124
1345
|
activeDiffId: null,
|
|
1125
1346
|
pendingInstruction: null,
|
|
1126
1347
|
initialInstruction: null,
|
|
1348
|
+
contextFiles: [],
|
|
1127
1349
|
workStartedAt: null,
|
|
1128
1350
|
createdAt: now,
|
|
1129
1351
|
updatedAt: now,
|
|
@@ -1299,6 +1521,7 @@ export function appendDiff(dataDir, targetRoot, input) {
|
|
|
1299
1521
|
)
|
|
1300
1522
|
manifest.activeDiffId = id
|
|
1301
1523
|
manifest.phase = 'review'
|
|
1524
|
+
manifest.pendingInstruction = null
|
|
1302
1525
|
manifest.workStartedAt = null
|
|
1303
1526
|
manifest.diffs.push(entry)
|
|
1304
1527
|
writeManifest(dataDir, manifest)
|
|
@@ -1370,11 +1593,12 @@ export function requestReplan(
|
|
|
1370
1593
|
active.status = 'extend'
|
|
1371
1594
|
active.instruction = guidance
|
|
1372
1595
|
active.decidedAt = new Date().toISOString()
|
|
1373
|
-
manifest.phase = '
|
|
1596
|
+
manifest.phase = 'working'
|
|
1374
1597
|
manifest.pendingInstruction = guidance
|
|
1375
1598
|
manifest.workStartedAt = new Date().toISOString()
|
|
1376
1599
|
writeManifest(dataDir, manifest)
|
|
1377
|
-
|
|
1600
|
+
// Keep the current proposal on disk. The instruction updates that latest state.
|
|
1601
|
+
void targetRoot
|
|
1378
1602
|
return manifest
|
|
1379
1603
|
}
|
|
1380
1604
|
|
|
@@ -475,10 +475,11 @@ function Explorer({
|
|
|
475
475
|
|
|
476
476
|
const flyAlongRelation = useCallback(
|
|
477
477
|
(fromId: string, toId: string) => {
|
|
478
|
+
if (mode !== 'walk') return
|
|
478
479
|
const [x, z] = walkPos.current
|
|
479
480
|
travelToFile(relationTravelTarget(fromId, toId, x, z, layout.files), true)
|
|
480
481
|
},
|
|
481
|
-
[layout.files, travelToFile],
|
|
482
|
+
[layout.files, mode, travelToFile],
|
|
482
483
|
)
|
|
483
484
|
|
|
484
485
|
const runWorkflowAction = useCallback(
|
|
@@ -65,6 +65,26 @@ function normalizeAck(
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
function normalizeContextFiles(value: unknown): NonNullable<AgentIntent['contextFiles']> {
|
|
69
|
+
if (!Array.isArray(value)) return []
|
|
70
|
+
return value.flatMap((item) => {
|
|
71
|
+
if (!item || typeof item !== 'object') return []
|
|
72
|
+
const id = (item as { id?: unknown }).id
|
|
73
|
+
const name = (item as { name?: unknown }).name
|
|
74
|
+
const mimeType = (item as { mimeType?: unknown }).mimeType
|
|
75
|
+
const size = (item as { size?: unknown }).size
|
|
76
|
+
if (
|
|
77
|
+
typeof id !== 'string' ||
|
|
78
|
+
typeof name !== 'string' ||
|
|
79
|
+
typeof mimeType !== 'string' ||
|
|
80
|
+
typeof size !== 'number'
|
|
81
|
+
) {
|
|
82
|
+
return []
|
|
83
|
+
}
|
|
84
|
+
return [{ id, name, mimeType, size }]
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
68
88
|
export const emptyIntent: AgentIntent = {
|
|
69
89
|
updatedAt: null,
|
|
70
90
|
showMap: false,
|
|
@@ -101,6 +121,7 @@ export const emptyIntent: AgentIntent = {
|
|
|
101
121
|
listening: false,
|
|
102
122
|
lastAck: null,
|
|
103
123
|
initialInstruction: null,
|
|
124
|
+
contextFiles: [],
|
|
104
125
|
creationMode: false,
|
|
105
126
|
canEnterBlueprint: false,
|
|
106
127
|
blueprintHidden: false,
|
|
@@ -155,6 +176,7 @@ function normalize(data: Partial<AgentIntent> | null | undefined): AgentIntent {
|
|
|
155
176
|
lastAck: normalizeAck(data?.lastAck),
|
|
156
177
|
initialInstruction:
|
|
157
178
|
typeof data?.initialInstruction === 'string' ? data.initialInstruction : null,
|
|
179
|
+
contextFiles: normalizeContextFiles(data?.contextFiles),
|
|
158
180
|
creationMode: Boolean(data?.creationMode),
|
|
159
181
|
canEnterBlueprint: Boolean(data?.canEnterBlueprint),
|
|
160
182
|
blueprintHidden: Boolean(data?.blueprintHidden),
|
|
@@ -352,6 +374,50 @@ export function persistInitialInstruction(sessionId: string, instruction: string
|
|
|
352
374
|
})
|
|
353
375
|
}
|
|
354
376
|
|
|
377
|
+
export async function persistAddContextFiles(
|
|
378
|
+
sessionId: string,
|
|
379
|
+
files: Array<{ name: string; mimeType: string; contentBase64: string }>,
|
|
380
|
+
) {
|
|
381
|
+
const response = await fetch('/api/agent-intent', {
|
|
382
|
+
method: 'POST',
|
|
383
|
+
headers: { 'Content-Type': 'application/json' },
|
|
384
|
+
body: JSON.stringify({
|
|
385
|
+
action: 'add_context_files',
|
|
386
|
+
sessionId,
|
|
387
|
+
files,
|
|
388
|
+
}),
|
|
389
|
+
})
|
|
390
|
+
if (!response.ok) {
|
|
391
|
+
const detail = await response.text()
|
|
392
|
+
let message = detail || 'Could not attach files'
|
|
393
|
+
try {
|
|
394
|
+
const parsed = JSON.parse(detail) as { error?: string }
|
|
395
|
+
if (parsed?.error) message = parsed.error
|
|
396
|
+
} catch {
|
|
397
|
+
// Use the raw body when it is not JSON.
|
|
398
|
+
}
|
|
399
|
+
throw new Error(message)
|
|
400
|
+
}
|
|
401
|
+
return normalize((await response.json()) as AgentIntent)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export async function persistRemoveContextFile(sessionId: string, fileId: string) {
|
|
405
|
+
const response = await fetch('/api/agent-intent', {
|
|
406
|
+
method: 'POST',
|
|
407
|
+
headers: { 'Content-Type': 'application/json' },
|
|
408
|
+
body: JSON.stringify({
|
|
409
|
+
action: 'remove_context_file',
|
|
410
|
+
sessionId,
|
|
411
|
+
fileId,
|
|
412
|
+
}),
|
|
413
|
+
})
|
|
414
|
+
if (!response.ok) {
|
|
415
|
+
const detail = await response.text()
|
|
416
|
+
throw new Error(detail || 'Could not remove file')
|
|
417
|
+
}
|
|
418
|
+
return normalize((await response.json()) as AgentIntent)
|
|
419
|
+
}
|
|
420
|
+
|
|
355
421
|
export function persistSessionFocus(sessionId: string) {
|
|
356
422
|
fetch('/api/agent-intent', {
|
|
357
423
|
method: 'POST',
|
|
@@ -1133,6 +1133,71 @@ button {
|
|
|
1133
1133
|
margin-top: 0;
|
|
1134
1134
|
}
|
|
1135
1135
|
|
|
1136
|
+
.hud-context {
|
|
1137
|
+
display: grid;
|
|
1138
|
+
gap: 8px;
|
|
1139
|
+
margin-top: 8px;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
.hud-context-drop {
|
|
1143
|
+
width: 100%;
|
|
1144
|
+
min-height: 52px;
|
|
1145
|
+
padding: 10px 8px;
|
|
1146
|
+
color: #8b95a5;
|
|
1147
|
+
background: var(--vc-surface);
|
|
1148
|
+
border: 1px dashed #3a4250;
|
|
1149
|
+
cursor: pointer;
|
|
1150
|
+
font: 12px/1.4 Inter, ui-sans-serif, system-ui, sans-serif;
|
|
1151
|
+
text-align: center;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
.hud-context-drop:hover,
|
|
1155
|
+
.hud-context-drop:focus-visible,
|
|
1156
|
+
.hud-context-drop[data-over='true'] {
|
|
1157
|
+
color: #e7ebf2;
|
|
1158
|
+
border-color: #d6b56a;
|
|
1159
|
+
outline: none;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
.hud-context-drop[data-busy='true'],
|
|
1163
|
+
.hud-context-drop:disabled {
|
|
1164
|
+
cursor: wait;
|
|
1165
|
+
opacity: 0.7;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
.hud-context-list {
|
|
1169
|
+
display: grid;
|
|
1170
|
+
gap: 4px;
|
|
1171
|
+
margin: 0;
|
|
1172
|
+
padding: 0;
|
|
1173
|
+
list-style: none;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
.hud-context-list li {
|
|
1177
|
+
display: grid;
|
|
1178
|
+
grid-template-columns: minmax(0, 1fr) auto auto;
|
|
1179
|
+
align-items: center;
|
|
1180
|
+
gap: 6px;
|
|
1181
|
+
color: #e7ebf2;
|
|
1182
|
+
font-size: 11px;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
.hud-context-name {
|
|
1186
|
+
overflow: hidden;
|
|
1187
|
+
text-overflow: ellipsis;
|
|
1188
|
+
white-space: nowrap;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
.hud-context-size {
|
|
1192
|
+
color: #8b95a5;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
.hud-context-error {
|
|
1196
|
+
margin: 0;
|
|
1197
|
+
color: #ff8a9b;
|
|
1198
|
+
font-size: 11px;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1136
1201
|
.hud-instruction textarea {
|
|
1137
1202
|
width: 100%;
|
|
1138
1203
|
resize: vertical;
|
|
@@ -22,7 +22,6 @@ type MapViewProps = {
|
|
|
22
22
|
onLand: (x: number, z: number) => void
|
|
23
23
|
onSelect: (fileId: string | null) => void
|
|
24
24
|
onSelectFolder: (folderPath: string | null) => void
|
|
25
|
-
onTravelTo: (fromId: string, toId: string) => void
|
|
26
25
|
onBlueprintMenu?: (menu: MapBlueprintMenu) => void
|
|
27
26
|
}
|
|
28
27
|
|
|
@@ -36,7 +35,6 @@ export function MapView({
|
|
|
36
35
|
onLand,
|
|
37
36
|
onSelect,
|
|
38
37
|
onSelectFolder,
|
|
39
|
-
onTravelTo,
|
|
40
38
|
onBlueprintMenu,
|
|
41
39
|
}: MapViewProps) {
|
|
42
40
|
const size = useThree((state) => state.size)
|
|
@@ -157,18 +155,11 @@ export function MapView({
|
|
|
157
155
|
|
|
158
156
|
const pick = pickAt(event.clientX, event.clientY)
|
|
159
157
|
if (!pick) return
|
|
160
|
-
const {
|
|
158
|
+
const { fileHit } = pick
|
|
161
159
|
if (fileHit) {
|
|
162
160
|
onSelect(fileHit.object.userData.fileId as string)
|
|
163
161
|
return
|
|
164
162
|
}
|
|
165
|
-
if (relationHit) {
|
|
166
|
-
onTravelTo(
|
|
167
|
-
relationHit.object.userData.relationFrom as string,
|
|
168
|
-
relationHit.object.userData.relationTo as string,
|
|
169
|
-
)
|
|
170
|
-
return
|
|
171
|
-
}
|
|
172
163
|
|
|
173
164
|
const hit = new THREE.Vector3()
|
|
174
165
|
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)
|
|
@@ -286,7 +277,6 @@ export function MapView({
|
|
|
286
277
|
onBlueprintMenu,
|
|
287
278
|
onSelect,
|
|
288
279
|
onSelectFolder,
|
|
289
|
-
onTravelTo,
|
|
290
280
|
scene,
|
|
291
281
|
selectedFolder,
|
|
292
282
|
])
|
|
@@ -324,6 +324,12 @@ export type AgentIntent = {
|
|
|
324
324
|
at: string | null
|
|
325
325
|
} | null
|
|
326
326
|
initialInstruction?: string | null
|
|
327
|
+
contextFiles?: Array<{
|
|
328
|
+
id: string
|
|
329
|
+
name: string
|
|
330
|
+
mimeType: string
|
|
331
|
+
size: number
|
|
332
|
+
}>
|
|
327
333
|
creationMode: boolean
|
|
328
334
|
canEnterBlueprint: boolean
|
|
329
335
|
blueprintHidden?: boolean
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
2
|
-
import { persistInitialInstruction } from '../agentIntent'
|
|
2
|
+
import { persistAddContextFiles, persistInitialInstruction, persistRemoveContextFile } from '../agentIntent'
|
|
3
3
|
import { NameInput } from './NameInput'
|
|
4
4
|
import { SelectionThumbnail } from '../scene/SelectionThumbnail'
|
|
5
5
|
import {
|
|
@@ -450,6 +450,119 @@ function InitialInstructionField({
|
|
|
450
450
|
)
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
function formatFileSize(bytes: number) {
|
|
454
|
+
if (bytes < 1024) return `${bytes} B`
|
|
455
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
456
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function fileToBase64(file: File) {
|
|
460
|
+
const bytes = new Uint8Array(await file.arrayBuffer())
|
|
461
|
+
const chunk = 0x8000
|
|
462
|
+
let binary = ''
|
|
463
|
+
for (let offset = 0; offset < bytes.length; offset += chunk) {
|
|
464
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunk))
|
|
465
|
+
}
|
|
466
|
+
return btoa(binary)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
type ContextFileInfo = {
|
|
470
|
+
id: string
|
|
471
|
+
name: string
|
|
472
|
+
mimeType: string
|
|
473
|
+
size: number
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function ContextFileDrop({
|
|
477
|
+
files,
|
|
478
|
+
busy,
|
|
479
|
+
error,
|
|
480
|
+
onAdd,
|
|
481
|
+
onRemove,
|
|
482
|
+
}: {
|
|
483
|
+
files: ContextFileInfo[]
|
|
484
|
+
busy: boolean
|
|
485
|
+
error: string | null
|
|
486
|
+
onAdd: (files: File[]) => void
|
|
487
|
+
onRemove: (fileId: string) => void
|
|
488
|
+
}) {
|
|
489
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
490
|
+
const [over, setOver] = useState(false)
|
|
491
|
+
|
|
492
|
+
const takeFiles = (list: FileList | File[] | null) => {
|
|
493
|
+
if (!list || busy) return
|
|
494
|
+
const next = [...list].filter((file) => file.size > 0)
|
|
495
|
+
if (next.length > 0) onAdd(next)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return (
|
|
499
|
+
<div className="hud-context">
|
|
500
|
+
<button
|
|
501
|
+
className="hud-context-drop"
|
|
502
|
+
type="button"
|
|
503
|
+
data-over={over}
|
|
504
|
+
data-busy={busy}
|
|
505
|
+
disabled={busy}
|
|
506
|
+
aria-label="Attach files for the LLM"
|
|
507
|
+
onDragEnter={(event) => {
|
|
508
|
+
event.preventDefault()
|
|
509
|
+
if (event.dataTransfer.types.includes('Files')) setOver(true)
|
|
510
|
+
}}
|
|
511
|
+
onDragOver={(event) => {
|
|
512
|
+
event.preventDefault()
|
|
513
|
+
event.dataTransfer.dropEffect = 'copy'
|
|
514
|
+
}}
|
|
515
|
+
onDragLeave={(event) => {
|
|
516
|
+
if (event.currentTarget.contains(event.relatedTarget as Node)) return
|
|
517
|
+
setOver(false)
|
|
518
|
+
}}
|
|
519
|
+
onDrop={(event) => {
|
|
520
|
+
event.preventDefault()
|
|
521
|
+
event.stopPropagation()
|
|
522
|
+
setOver(false)
|
|
523
|
+
takeFiles(event.dataTransfer.files)
|
|
524
|
+
}}
|
|
525
|
+
onKeyDown={(event) => event.stopPropagation()}
|
|
526
|
+
onClick={() => inputRef.current?.click()}
|
|
527
|
+
>
|
|
528
|
+
<input
|
|
529
|
+
ref={inputRef}
|
|
530
|
+
type="file"
|
|
531
|
+
multiple
|
|
532
|
+
hidden
|
|
533
|
+
onChange={(event) => {
|
|
534
|
+
takeFiles(event.target.files)
|
|
535
|
+
event.target.value = ''
|
|
536
|
+
}}
|
|
537
|
+
/>
|
|
538
|
+
{busy ? 'Attaching…' : 'Drop files or click to attach'}
|
|
539
|
+
</button>
|
|
540
|
+
{files.length > 0 && (
|
|
541
|
+
<ul className="hud-context-list">
|
|
542
|
+
{files.map((file) => (
|
|
543
|
+
<li key={file.id}>
|
|
544
|
+
<span className="hud-context-name" title={file.name}>
|
|
545
|
+
{file.name}
|
|
546
|
+
</span>
|
|
547
|
+
<span className="hud-context-size">{formatFileSize(file.size)}</span>
|
|
548
|
+
<button
|
|
549
|
+
className="hud-item-remove"
|
|
550
|
+
type="button"
|
|
551
|
+
aria-label={`Remove ${file.name}`}
|
|
552
|
+
disabled={busy}
|
|
553
|
+
onClick={() => onRemove(file.id)}
|
|
554
|
+
>
|
|
555
|
+
×
|
|
556
|
+
</button>
|
|
557
|
+
</li>
|
|
558
|
+
))}
|
|
559
|
+
</ul>
|
|
560
|
+
)}
|
|
561
|
+
{error ? <p className="hud-context-error">{error}</p> : null}
|
|
562
|
+
</div>
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
|
|
453
566
|
function blueprintIsDefined(intent: AgentIntent) {
|
|
454
567
|
return (
|
|
455
568
|
(intent.userCreatedBlocks?.length ?? 0) > 0 ||
|
|
@@ -464,24 +577,53 @@ function blueprintIsDefined(intent: AgentIntent) {
|
|
|
464
577
|
function HandshakeSetup({
|
|
465
578
|
instruction,
|
|
466
579
|
onInstructionChange,
|
|
580
|
+
contextFiles,
|
|
581
|
+
contextBusy,
|
|
582
|
+
contextError,
|
|
583
|
+
onAddContextFiles,
|
|
584
|
+
onRemoveContextFile,
|
|
467
585
|
blueprintDefined,
|
|
468
586
|
awaitingAttach,
|
|
469
587
|
nextAttachLabel,
|
|
470
588
|
}: {
|
|
471
589
|
instruction: string
|
|
472
590
|
onInstructionChange: (value: string) => void
|
|
591
|
+
contextFiles: ContextFileInfo[]
|
|
592
|
+
contextBusy: boolean
|
|
593
|
+
contextError: string | null
|
|
594
|
+
onAddContextFiles: (files: File[]) => void
|
|
595
|
+
onRemoveContextFile: (fileId: string) => void
|
|
473
596
|
blueprintDefined: boolean
|
|
474
597
|
awaitingAttach: boolean
|
|
475
598
|
nextAttachLabel: string | null
|
|
476
599
|
}) {
|
|
477
600
|
return (
|
|
478
601
|
<div className="hud-setup">
|
|
479
|
-
<section
|
|
602
|
+
<section
|
|
603
|
+
className="hud-setup-section"
|
|
604
|
+
onDragOver={(event) => {
|
|
605
|
+
if (event.dataTransfer.types.includes('Files')) event.preventDefault()
|
|
606
|
+
}}
|
|
607
|
+
onDrop={(event) => {
|
|
608
|
+
event.preventDefault()
|
|
609
|
+
const dropped = [...(event.dataTransfer.files ?? [])].filter(
|
|
610
|
+
(file) => file.size > 0,
|
|
611
|
+
)
|
|
612
|
+
if (dropped.length > 0) onAddContextFiles(dropped)
|
|
613
|
+
}}
|
|
614
|
+
>
|
|
480
615
|
<h2 className="hud-setup-heading">Instructions</h2>
|
|
481
616
|
<InitialInstructionField
|
|
482
617
|
value={instruction}
|
|
483
618
|
onChange={onInstructionChange}
|
|
484
619
|
/>
|
|
620
|
+
<ContextFileDrop
|
|
621
|
+
files={contextFiles}
|
|
622
|
+
busy={contextBusy}
|
|
623
|
+
error={contextError}
|
|
624
|
+
onAdd={onAddContextFiles}
|
|
625
|
+
onRemove={onRemoveContextFile}
|
|
626
|
+
/>
|
|
485
627
|
</section>
|
|
486
628
|
<section className="hud-setup-section">
|
|
487
629
|
<h2 className="hud-setup-heading">
|
|
@@ -649,6 +791,11 @@ function SessionPanel({
|
|
|
649
791
|
const [initialInstruction, setInitialInstruction] = useState(
|
|
650
792
|
() => intent.initialInstruction ?? '',
|
|
651
793
|
)
|
|
794
|
+
const [contextFiles, setContextFiles] = useState<ContextFileInfo[]>(
|
|
795
|
+
() => intent.contextFiles ?? [],
|
|
796
|
+
)
|
|
797
|
+
const [contextBusy, setContextBusy] = useState(false)
|
|
798
|
+
const [contextError, setContextError] = useState<string | null>(null)
|
|
652
799
|
const sessionId = intent.sessionId
|
|
653
800
|
const pending = intent.status === 'pending' && intent.isActiveDiff
|
|
654
801
|
const askingBlueprint = intent.status === 'blueprint_ask'
|
|
@@ -710,8 +857,15 @@ function SessionPanel({
|
|
|
710
857
|
|
|
711
858
|
useEffect(() => {
|
|
712
859
|
setInitialInstruction(intent.initialInstruction ?? '')
|
|
860
|
+
setContextFiles(intent.contextFiles ?? [])
|
|
861
|
+
setContextError(null)
|
|
713
862
|
}, [sessionId])
|
|
714
863
|
|
|
864
|
+
useEffect(() => {
|
|
865
|
+
if (contextBusy) return
|
|
866
|
+
setContextFiles(intent.contextFiles ?? [])
|
|
867
|
+
}, [contextBusy, intent.contextFiles])
|
|
868
|
+
|
|
715
869
|
if (!sessionId || !isReviewingIntent(intent.status)) return null
|
|
716
870
|
|
|
717
871
|
const updateInitialInstruction = (value: string) => {
|
|
@@ -719,6 +873,43 @@ function SessionPanel({
|
|
|
719
873
|
persistInitialInstruction(sessionId, value)
|
|
720
874
|
}
|
|
721
875
|
|
|
876
|
+
const addContextFiles = (files: File[]) => {
|
|
877
|
+
setContextBusy(true)
|
|
878
|
+
setContextError(null)
|
|
879
|
+
void Promise.all(
|
|
880
|
+
files.map(async (file) => ({
|
|
881
|
+
name: file.name,
|
|
882
|
+
mimeType: file.type || 'application/octet-stream',
|
|
883
|
+
contentBase64: await fileToBase64(file),
|
|
884
|
+
})),
|
|
885
|
+
)
|
|
886
|
+
.then((payload) => persistAddContextFiles(sessionId, payload))
|
|
887
|
+
.then((next) => {
|
|
888
|
+
setContextFiles(next.contextFiles ?? [])
|
|
889
|
+
})
|
|
890
|
+
.catch((caught) => {
|
|
891
|
+
setContextError(
|
|
892
|
+
caught instanceof Error ? caught.message : 'Could not attach files',
|
|
893
|
+
)
|
|
894
|
+
})
|
|
895
|
+
.finally(() => setContextBusy(false))
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const removeContextFile = (fileId: string) => {
|
|
899
|
+
setContextBusy(true)
|
|
900
|
+
setContextError(null)
|
|
901
|
+
void persistRemoveContextFile(sessionId, fileId)
|
|
902
|
+
.then((next) => {
|
|
903
|
+
setContextFiles(next.contextFiles ?? [])
|
|
904
|
+
})
|
|
905
|
+
.catch((caught) => {
|
|
906
|
+
setContextError(
|
|
907
|
+
caught instanceof Error ? caught.message : 'Could not remove file',
|
|
908
|
+
)
|
|
909
|
+
})
|
|
910
|
+
.finally(() => setContextBusy(false))
|
|
911
|
+
}
|
|
912
|
+
|
|
722
913
|
const showInitialInstruction =
|
|
723
914
|
Boolean(intent.awaitingAttach) &&
|
|
724
915
|
(askingBlueprint || sendingBlueprint || preparing)
|
|
@@ -800,6 +991,11 @@ function SessionPanel({
|
|
|
800
991
|
<HandshakeSetup
|
|
801
992
|
instruction={initialInstruction}
|
|
802
993
|
onInstructionChange={updateInitialInstruction}
|
|
994
|
+
contextFiles={contextFiles}
|
|
995
|
+
contextBusy={contextBusy}
|
|
996
|
+
contextError={contextError}
|
|
997
|
+
onAddContextFiles={addContextFiles}
|
|
998
|
+
onRemoveContextFile={removeContextFile}
|
|
803
999
|
blueprintDefined={blueprintIsDefined(intent)}
|
|
804
1000
|
awaitingAttach={Boolean(intent.awaitingAttach)}
|
|
805
1001
|
nextAttachLabel={queuedBehind}
|
|
@@ -1437,7 +1633,6 @@ function explorerInstructions({
|
|
|
1437
1633
|
},
|
|
1438
1634
|
]
|
|
1439
1635
|
: []),
|
|
1440
|
-
{ id: 'click-line', keys: ['Click'], label: 'A line to fly there' },
|
|
1441
1636
|
{
|
|
1442
1637
|
id: 'option-click-walk',
|
|
1443
1638
|
keys: ['Option', 'Click'],
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { defineConfig, type Plugin } from 'vite'
|
|
2
2
|
import react from '@vitejs/plugin-react'
|
|
3
|
-
import { createRequire } from 'node:module'
|
|
4
3
|
import { spawnSync } from 'node:child_process'
|
|
5
4
|
import fs from 'node:fs'
|
|
6
5
|
import path from 'node:path'
|
|
@@ -8,7 +7,7 @@ import { fileURLToPath } from 'node:url'
|
|
|
8
7
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
9
8
|
import { emptyIntent } from './scripts/patch-lib.mjs'
|
|
10
9
|
import { readBranchChanges } from './scripts/branch-changes.mjs'
|
|
11
|
-
import { writeRunningInstance, isolatedViteConfig,
|
|
10
|
+
import { writeRunningInstance, isolatedViteConfig, packageDirFromPackage } from '../../bin/project.mjs'
|
|
12
11
|
import { dataDir, targetRoot } from './scripts/target-config.mjs'
|
|
13
12
|
import { editorFileUri, openInEditor } from './scripts/open-editor.mjs'
|
|
14
13
|
import {
|
|
@@ -24,6 +23,8 @@ import {
|
|
|
24
23
|
sendBlueprint,
|
|
25
24
|
sessionIntent,
|
|
26
25
|
setInitialInstruction,
|
|
26
|
+
addContextFiles,
|
|
27
|
+
removeContextFile,
|
|
27
28
|
setStepByStep,
|
|
28
29
|
setupSession,
|
|
29
30
|
focusSession,
|
|
@@ -36,12 +37,7 @@ import {
|
|
|
36
37
|
} from './scripts/session-store.mjs'
|
|
37
38
|
|
|
38
39
|
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
39
|
-
const requireFromPackage = createRequire(path.join(packageRoot, 'package.json'))
|
|
40
40
|
const isolation = isolatedViteConfig(dataDir)
|
|
41
|
-
|
|
42
|
-
function pkgDir(name: string) {
|
|
43
|
-
return path.dirname(requireFromPackage.resolve(`${name}/package.json`))
|
|
44
|
-
}
|
|
45
41
|
const userContextFile = path.join(dataDir, 'user-context.json')
|
|
46
42
|
const codebaseFile = path.join(dataDir, 'codebase.json')
|
|
47
43
|
const scanScript = path.resolve(here, 'scripts/scan-target.mjs')
|
|
@@ -271,6 +267,8 @@ async function decideIntent(req: IncomingMessage, res: ServerResponse) {
|
|
|
271
267
|
addedVariables?: unknown[]
|
|
272
268
|
addedImports?: unknown[]
|
|
273
269
|
notes?: unknown[]
|
|
270
|
+
files?: unknown[]
|
|
271
|
+
fileId?: string
|
|
274
272
|
}
|
|
275
273
|
const action = body.action
|
|
276
274
|
const blueprintActions = new Set([
|
|
@@ -294,6 +292,8 @@ async function decideIntent(req: IncomingMessage, res: ServerResponse) {
|
|
|
294
292
|
action !== 'focus' &&
|
|
295
293
|
action !== 'set_step_by_step' &&
|
|
296
294
|
action !== 'set_initial_instruction' &&
|
|
295
|
+
action !== 'add_context_files' &&
|
|
296
|
+
action !== 'remove_context_file' &&
|
|
297
297
|
action !== 'setup_session'
|
|
298
298
|
) {
|
|
299
299
|
sendJson(res, 400, { error: 'invalid workflow action' })
|
|
@@ -348,7 +348,6 @@ async function decideIntent(req: IncomingMessage, res: ServerResponse) {
|
|
|
348
348
|
body.instruction ?? '',
|
|
349
349
|
targetRoot,
|
|
350
350
|
)
|
|
351
|
-
rescanTarget('after withdrawing a patch')
|
|
352
351
|
} else if (action === 'blueprint_yes') {
|
|
353
352
|
answerBlueprint(dataDir, body.sessionId, true)
|
|
354
353
|
} else if (action === 'blueprint_no') {
|
|
@@ -387,6 +386,14 @@ async function decideIntent(req: IncomingMessage, res: ServerResponse) {
|
|
|
387
386
|
return
|
|
388
387
|
} else if (action === 'set_initial_instruction') {
|
|
389
388
|
setInitialInstruction(dataDir, body.sessionId, body.instruction ?? '')
|
|
389
|
+
} else if (action === 'add_context_files') {
|
|
390
|
+
addContextFiles(dataDir, body.sessionId, body.files ?? [])
|
|
391
|
+
} else if (action === 'remove_context_file') {
|
|
392
|
+
if (!body.fileId) {
|
|
393
|
+
sendJson(res, 400, { error: 'fileId is required' })
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
removeContextFile(dataDir, body.sessionId, body.fileId)
|
|
390
397
|
} else if (action === 'focus') {
|
|
391
398
|
focusSession(dataDir, body.sessionId)
|
|
392
399
|
} else if (action === 'set_step_by_step') {
|
|
@@ -489,11 +496,11 @@ export default defineConfig({
|
|
|
489
496
|
plugins: [react(), jsonFilePlugin()],
|
|
490
497
|
resolve: {
|
|
491
498
|
alias: {
|
|
492
|
-
react:
|
|
493
|
-
'react-dom':
|
|
494
|
-
three:
|
|
495
|
-
'@react-three/fiber':
|
|
496
|
-
'@react-three/drei':
|
|
499
|
+
react: packageDirFromPackage('react'),
|
|
500
|
+
'react-dom': packageDirFromPackage('react-dom'),
|
|
501
|
+
three: packageDirFromPackage('three'),
|
|
502
|
+
'@react-three/fiber': packageDirFromPackage('@react-three/fiber'),
|
|
503
|
+
'@react-three/drei': packageDirFromPackage('@react-three/drei'),
|
|
497
504
|
},
|
|
498
505
|
dedupe: ['react', 'react-dom', 'three', '@react-three/fiber', '@react-three/drei'],
|
|
499
506
|
},
|
package/bin/project.mjs
CHANGED
|
@@ -15,6 +15,15 @@ export function resolveFromPackage(specifier) {
|
|
|
15
15
|
return requireFromPackage.resolve(specifier)
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
export function packageDirFromPackage(name) {
|
|
19
|
+
let dir = path.dirname(resolveFromPackage(name))
|
|
20
|
+
while (dir !== path.dirname(dir)) {
|
|
21
|
+
if (fs.existsSync(path.join(dir, 'package.json'))) return dir
|
|
22
|
+
dir = path.dirname(dir)
|
|
23
|
+
}
|
|
24
|
+
return dir
|
|
25
|
+
}
|
|
26
|
+
|
|
18
27
|
/** Vite config that never uses the host project's cache, deps, or browser targets. */
|
|
19
28
|
export function isolatedViteConfig(dataDir) {
|
|
20
29
|
return {
|
package/bin/session.mjs
CHANGED
|
@@ -122,21 +122,37 @@ function emitApprovalHandshake(store, dataDir, sessionId, manifest, initialDiff)
|
|
|
122
122
|
if (manifest.phase === 'working') {
|
|
123
123
|
const next = manifest.steps.find((step) => step.index === manifest.currentStep)
|
|
124
124
|
const title = next?.title
|
|
125
|
+
const guidance =
|
|
126
|
+
typeof manifest.pendingInstruction === 'string'
|
|
127
|
+
? manifest.pendingInstruction.trim()
|
|
128
|
+
: ''
|
|
129
|
+
const updating = Boolean(guidance)
|
|
125
130
|
signalAck(
|
|
126
131
|
store,
|
|
127
132
|
dataDir,
|
|
128
133
|
sessionId,
|
|
129
134
|
'execute',
|
|
130
|
-
|
|
131
|
-
?
|
|
132
|
-
:
|
|
135
|
+
updating
|
|
136
|
+
? 'a new instruction'
|
|
137
|
+
: title
|
|
138
|
+
? `step ${manifest.currentStep} — ${title}`
|
|
139
|
+
: `step ${manifest.currentStep}`,
|
|
133
140
|
)
|
|
141
|
+
const instruction = updating
|
|
142
|
+
? `\nVISUAL_CODER_INSTRUCTION_START\n${guidance}\nVISUAL_CODER_INSTRUCTION_END`
|
|
143
|
+
: ''
|
|
134
144
|
const continuing = (manifest.diffs?.length ?? 0) > 0
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
145
|
+
if (updating) {
|
|
146
|
+
console.log(
|
|
147
|
+
`VISUAL_CODER_EXECUTE Update the current proposal for step ${manifest.currentStep}${title ? `: ${title}` : ''}. Live files already contain that proposal — do not reset them. Follow the instruction between VISUAL_CODER_INSTRUCTION_START and END, edit those live files, then inbase propose-patch --session ${sessionId} with no patch file. Do not report a new plan.${instruction}`,
|
|
148
|
+
)
|
|
149
|
+
} else {
|
|
150
|
+
console.log(
|
|
151
|
+
continuing
|
|
152
|
+
? `VISUAL_CODER_EXECUTE Step ${manifest.currentStep} is invoked${title ? `: ${title}` : ''}. Continue immediately: edit live files for this step only, then inbase propose-patch --session ${sessionId} with no patch file. Do not explore, re-plan, or run wait-for-blueprint.`
|
|
153
|
+
: `VISUAL_CODER_EXECUTE Step ${manifest.currentStep} is invoked${title ? `: ${title}` : ''}. Re-read the shared blueprint.json before implementing; the user can place files and islands at any time. Edit the live project files for this step only (Write, StrReplace, Delete). Then record the step with inbase propose-patch --session ${sessionId} — no patch file. Inbase diffs those edits against the invoke snapshot and stores the patch. Do not write a unified diff yourself.`,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
140
156
|
process.exit(0)
|
|
141
157
|
}
|
|
142
158
|
if (manifest.phase === 'replanning') {
|
|
@@ -145,13 +161,13 @@ function emitApprovalHandshake(store, dataDir, sessionId, manifest, initialDiff)
|
|
|
145
161
|
dataDir,
|
|
146
162
|
sessionId,
|
|
147
163
|
'replan',
|
|
148
|
-
`revise the
|
|
164
|
+
`revise the current proposal for step ${manifest.currentStep}`,
|
|
149
165
|
)
|
|
150
166
|
const instruction = manifest.pendingInstruction
|
|
151
167
|
? `\nVISUAL_CODER_INSTRUCTION_START\n${manifest.pendingInstruction}\nVISUAL_CODER_INSTRUCTION_END`
|
|
152
168
|
: ''
|
|
153
169
|
console.log(
|
|
154
|
-
`VISUAL_CODER_REPLAN
|
|
170
|
+
`VISUAL_CODER_REPLAN Live files still contain the current proposal for step ${manifest.currentStep}. Do not reset them. Follow the instruction between VISUAL_CODER_INSTRUCTION_START and END, edit those live files, then inbase propose-patch. Do not report a new plan.${instruction}`,
|
|
155
171
|
)
|
|
156
172
|
process.exit(4)
|
|
157
173
|
}
|
|
@@ -179,7 +195,7 @@ export async function attachSession(args) {
|
|
|
179
195
|
console.log(`VISUAL_CODER_SESSION ${manifest.sessionId}`)
|
|
180
196
|
printAck('attached', manifest.name || manifest.sessionId)
|
|
181
197
|
console.log(
|
|
182
|
-
`VISUAL_CODER_ATTACHED Attached to the next waiting visualizer session ${manifest.name || manifest.sessionId} (${manifest.phase}). Use --session ${manifest.sessionId} for every later command. Run inbase wait-for-blueprint --session ${manifest.sessionId} to read the optional blueprint and
|
|
198
|
+
`VISUAL_CODER_ATTACHED Attached to the next waiting visualizer session ${manifest.name || manifest.sessionId} (${manifest.phase}). Use --session ${manifest.sessionId} for every later command. Run inbase wait-for-blueprint --session ${manifest.sessionId} to read the optional blueprint, instruction, and attached files; it does not wait.`,
|
|
183
199
|
)
|
|
184
200
|
}
|
|
185
201
|
|
|
@@ -243,6 +259,20 @@ export async function waitForBlueprint(args) {
|
|
|
243
259
|
console.log(instruction)
|
|
244
260
|
console.log('VISUAL_CODER_INSTRUCTION_END')
|
|
245
261
|
}
|
|
262
|
+
const attached = store.contextFileHandshake(config.dataDir, sessionId)
|
|
263
|
+
if (attached.files.length > 0) {
|
|
264
|
+
console.log(
|
|
265
|
+
'Honor the user\'s attached context files. Read each path with your file tools before planning. They are session-only attachments, not project files to create. Use any printed VISUAL_CODER_CONTEXT_FILE contents directly.',
|
|
266
|
+
)
|
|
267
|
+
console.log('VISUAL_CODER_CONTEXT_FILES_START')
|
|
268
|
+
console.log(JSON.stringify(attached.files, null, 2))
|
|
269
|
+
console.log('VISUAL_CODER_CONTEXT_FILES_END')
|
|
270
|
+
for (const file of attached.texts) {
|
|
271
|
+
console.log(`VISUAL_CODER_CONTEXT_FILE_START ${file.name}`)
|
|
272
|
+
console.log(file.content)
|
|
273
|
+
console.log('VISUAL_CODER_CONTEXT_FILE_END')
|
|
274
|
+
}
|
|
275
|
+
}
|
|
246
276
|
process.exit(0)
|
|
247
277
|
}
|
|
248
278
|
|
package/package.json
CHANGED
package/skill/commands/inbase.md
CHANGED
|
@@ -14,4 +14,4 @@ npx inbase attach
|
|
|
14
14
|
|
|
15
15
|
2. Read `VISUAL_CODER_SESSION` from the output. That id is the session to use for every later `inbase` command.
|
|
16
16
|
|
|
17
|
-
3. Continue the Inbase visual edits skill from `wait-for-blueprint` onward with that `--session` id. `/inbase` already started the session. `wait-for-blueprint` only reads the optional blueprint and
|
|
17
|
+
3. Continue the Inbase visual edits skill from `wait-for-blueprint` onward with that `--session` id. `/inbase` already started the session. `wait-for-blueprint` only reads the optional blueprint, instruction, and attached files; it does not wait.
|
package/skill/inbase/SKILL.md
CHANGED
|
@@ -85,7 +85,7 @@ new plan.
|
|
|
85
85
|
## Required sequence
|
|
86
86
|
|
|
87
87
|
1. **Read the current layout**. `/inbase` already started the session. Run
|
|
88
|
-
this once to load the optional blueprint and
|
|
88
|
+
this once to load the optional blueprint, instruction, and attached files — it returns
|
|
89
89
|
immediately. Do not wait for the user to send a blueprint. Then report a
|
|
90
90
|
plan.
|
|
91
91
|
|
|
@@ -98,7 +98,11 @@ npx inbase wait-for-blueprint --session "<session-id>"
|
|
|
98
98
|
keep placing at any time.
|
|
99
99
|
If `wait-for-blueprint` prints `VISUAL_CODER_INSTRUCTION_START` /
|
|
100
100
|
`VISUAL_CODER_INSTRUCTION_END`, that text is the user's request for this
|
|
101
|
-
session.
|
|
101
|
+
session. If it prints `VISUAL_CODER_CONTEXT_FILES_START` /
|
|
102
|
+
`VISUAL_CODER_CONTEXT_FILES_END`, those are session-only files the user
|
|
103
|
+
dropped as initial context. Read each `path` (and any printed
|
|
104
|
+
`VISUAL_CODER_CONTEXT_FILE` contents). They are not project files to create.
|
|
105
|
+
Plan from that instruction, attached files, and the blueprint together. The
|
|
102
106
|
instruction does not override an enabled blueprint; if they conflict,
|
|
103
107
|
ask the user.
|
|
104
108
|
3. Read the handshake output between `VISUAL_CODER_BLUEPRINT_START` and
|
|
@@ -220,7 +224,7 @@ npx inbase propose-patch --session "<session-id>" --clear
|
|
|
220
224
|
from **Setup LLM session** in the map
|
|
221
225
|
- Edit files on a direct chat request; reply with the `/skipinbase` line and stop
|
|
222
226
|
- Invent a session id for `/inbase`; run `npx inbase attach` with no `--session`
|
|
223
|
-
- Skip `inbase wait-for-blueprint`; it returns immediately and provides the optional blueprint and
|
|
227
|
+
- Skip `inbase wait-for-blueprint`; it returns immediately and provides the optional blueprint, instruction, and attached files
|
|
224
228
|
- Treat the chat request, viewpoint, or your own plan as overriding an enabled blueprint
|
|
225
229
|
- Skip, rename, relocate, or replace the shared `blueprint.json` files, islands, functions, variables, or imports when `enabled` is true
|
|
226
230
|
- Silently differ from the blueprint; ask the user first
|