@jkwd/inbase 0.1.18 → 0.1.20
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 +3 -3
- package/apps/explorer/scripts/patch-lib.d.ts +2 -0
- package/apps/explorer/scripts/patch-lib.mjs +2 -0
- package/apps/explorer/scripts/session-store.d.ts +45 -0
- package/apps/explorer/scripts/session-store.mjs +281 -5
- package/apps/explorer/src/App.tsx +114 -4
- package/apps/explorer/src/agentIntent.ts +76 -1
- package/apps/explorer/src/index.css +65 -0
- package/apps/explorer/src/scene/FileBlock.tsx +27 -0
- package/apps/explorer/src/scene/FolderArea.tsx +21 -0
- package/apps/explorer/src/scene/MapView.tsx +1 -11
- package/apps/explorer/src/scene/SelectionThumbnail.tsx +16 -3
- package/apps/explorer/src/scene/World.tsx +22 -4
- package/apps/explorer/src/types.ts +16 -0
- package/apps/explorer/src/ui/EyeIcon.tsx +26 -0
- package/apps/explorer/src/ui/HUD.tsx +377 -26
- package/apps/explorer/src/ui/MapContextMenu.tsx +18 -1
- package/apps/explorer/src/userCreated.ts +97 -0
- package/apps/explorer/vite.config.ts +18 -1
- package/bin/session.mjs +41 -11
- package/package.json +1 -1
- package/skill/commands/inbase.md +1 -1
- package/skill/inbase/SKILL.md +24 -23
package/README.md
CHANGED
|
@@ -49,13 +49,13 @@ 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
|
|
|
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
|
|
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 update the current proposal, or **Stop** to end the session.
|
|
59
59
|
|
|
60
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
61
|
|
|
@@ -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
|
|
@@ -123,6 +124,7 @@ export const emptyIntent: {
|
|
|
123
124
|
blueprintVariables: unknown[]
|
|
124
125
|
blueprintImports: unknown[]
|
|
125
126
|
blueprintNotes: unknown[]
|
|
127
|
+
blueprintPointers: unknown[]
|
|
126
128
|
}
|
|
127
129
|
|
|
128
130
|
export function isLastStep(intent: {
|
|
@@ -539,6 +539,7 @@ export const emptyIntent = {
|
|
|
539
539
|
listening: false,
|
|
540
540
|
lastAck: null,
|
|
541
541
|
initialInstruction: null,
|
|
542
|
+
contextFiles: [],
|
|
542
543
|
creationMode: false,
|
|
543
544
|
canEnterBlueprint: false,
|
|
544
545
|
blueprintHidden: false,
|
|
@@ -550,6 +551,7 @@ export const emptyIntent = {
|
|
|
550
551
|
blueprintVariables: [],
|
|
551
552
|
blueprintImports: [],
|
|
552
553
|
blueprintNotes: [],
|
|
554
|
+
blueprintPointers: [],
|
|
553
555
|
}
|
|
554
556
|
|
|
555
557
|
export function isLastStep(intent) {
|
|
@@ -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
|
|
@@ -143,6 +185,7 @@ export type SessionBlueprint = {
|
|
|
143
185
|
addedVariables: unknown[]
|
|
144
186
|
addedImports: unknown[]
|
|
145
187
|
notes: unknown[]
|
|
188
|
+
pointers: unknown[]
|
|
146
189
|
}
|
|
147
190
|
export function emptyBlueprint(): SessionBlueprint
|
|
148
191
|
export function readBlueprint(dataDir: string, sessionId?: string): SessionBlueprint
|
|
@@ -182,6 +225,7 @@ export function updateBlueprint(
|
|
|
182
225
|
addedVariables?: unknown[]
|
|
183
226
|
addedImports?: unknown[]
|
|
184
227
|
notes?: unknown[]
|
|
228
|
+
pointers?: unknown[]
|
|
185
229
|
},
|
|
186
230
|
): SessionBlueprint
|
|
187
231
|
export function sendBlueprint(
|
|
@@ -194,6 +238,7 @@ export function sendBlueprint(
|
|
|
194
238
|
addedVariables?: unknown[]
|
|
195
239
|
addedImports?: unknown[]
|
|
196
240
|
notes?: unknown[]
|
|
241
|
+
pointers?: unknown[]
|
|
197
242
|
},
|
|
198
243
|
): DiffManifest
|
|
199
244
|
export function maybeStartVisualizerHandshake(
|
|
@@ -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
|
}
|
|
@@ -419,10 +424,134 @@ function blueprintHasContent(blueprint) {
|
|
|
419
424
|
(blueprint.addedFunctions?.length ?? 0) > 0 ||
|
|
420
425
|
(blueprint.addedVariables?.length ?? 0) > 0 ||
|
|
421
426
|
(blueprint.addedImports?.length ?? 0) > 0 ||
|
|
422
|
-
(blueprint.notes?.length ?? 0) > 0
|
|
427
|
+
(blueprint.notes?.length ?? 0) > 0 ||
|
|
428
|
+
(blueprint.pointers?.length ?? 0) > 0
|
|
423
429
|
)
|
|
424
430
|
}
|
|
425
431
|
|
|
432
|
+
function normalizeContextFiles(value) {
|
|
433
|
+
if (!Array.isArray(value)) return []
|
|
434
|
+
return value.flatMap((item) => {
|
|
435
|
+
if (!item || typeof item !== 'object') return []
|
|
436
|
+
const id = typeof item.id === 'string' ? item.id.trim() : ''
|
|
437
|
+
const name = typeof item.name === 'string' ? item.name.trim() : ''
|
|
438
|
+
const storedName =
|
|
439
|
+
typeof item.storedName === 'string' ? item.storedName.trim() : ''
|
|
440
|
+
const size = item.size
|
|
441
|
+
if (!id || !name || !storedName || !Number.isFinite(size) || size < 0) {
|
|
442
|
+
return []
|
|
443
|
+
}
|
|
444
|
+
if (storedName.includes('..') || path.basename(storedName) !== storedName) {
|
|
445
|
+
return []
|
|
446
|
+
}
|
|
447
|
+
return [
|
|
448
|
+
{
|
|
449
|
+
id,
|
|
450
|
+
name,
|
|
451
|
+
storedName,
|
|
452
|
+
mimeType:
|
|
453
|
+
typeof item.mimeType === 'string' && item.mimeType.trim()
|
|
454
|
+
? item.mimeType.trim()
|
|
455
|
+
: 'application/octet-stream',
|
|
456
|
+
size,
|
|
457
|
+
},
|
|
458
|
+
]
|
|
459
|
+
})
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function safeContextFileName(name) {
|
|
463
|
+
const base = path.basename(String(name || 'file')).replace(/[\u0000-\u001f]/g, '')
|
|
464
|
+
const cleaned = base
|
|
465
|
+
.replace(/[^\w.\- ()[\]]+/g, '_')
|
|
466
|
+
.replace(/^\.+/, '')
|
|
467
|
+
.trim()
|
|
468
|
+
return (cleaned || 'file').slice(0, 120)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function decodeContextFileBytes(file) {
|
|
472
|
+
if (Buffer.isBuffer(file?.bytes)) return file.bytes
|
|
473
|
+
if (typeof file?.contentBase64 === 'string' && file.contentBase64.trim()) {
|
|
474
|
+
if (!/^[A-Za-z0-9+/=\s]+$/.test(file.contentBase64)) {
|
|
475
|
+
throw new Error('context file content must be base64')
|
|
476
|
+
}
|
|
477
|
+
return Buffer.from(file.contentBase64, 'base64')
|
|
478
|
+
}
|
|
479
|
+
throw new Error('context file bytes are required')
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function contextFileAbsolute(dir, storedName) {
|
|
483
|
+
const absolute = path.resolve(dir, storedName)
|
|
484
|
+
const prefix = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`
|
|
485
|
+
if (absolute !== dir && !absolute.startsWith(prefix)) {
|
|
486
|
+
throw new Error(`Invalid context file path ${storedName}`)
|
|
487
|
+
}
|
|
488
|
+
return absolute
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function publicContextFile(item) {
|
|
492
|
+
return {
|
|
493
|
+
id: item.id,
|
|
494
|
+
name: item.name,
|
|
495
|
+
mimeType: item.mimeType,
|
|
496
|
+
size: item.size,
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function isInlineContextText(mimeType, bytes) {
|
|
501
|
+
if (bytes.length === 0 || bytes.length > CONTEXT_TEXT_INLINE_BYTES) return false
|
|
502
|
+
if (bytes.includes(0)) return false
|
|
503
|
+
const mime = typeof mimeType === 'string' ? mimeType.toLowerCase() : ''
|
|
504
|
+
if (mime.startsWith('audio/') || mime.startsWith('video/')) return false
|
|
505
|
+
if (mime === 'application/pdf' || mime === 'application/zip') return false
|
|
506
|
+
if (mime.startsWith('image/') && mime !== 'image/svg+xml') return false
|
|
507
|
+
if (
|
|
508
|
+
mime.startsWith('text/') ||
|
|
509
|
+
mime === 'application/json' ||
|
|
510
|
+
mime === 'application/javascript' ||
|
|
511
|
+
mime === 'application/xml' ||
|
|
512
|
+
mime === 'image/svg+xml' ||
|
|
513
|
+
mime === 'application/octet-stream' ||
|
|
514
|
+
mime === ''
|
|
515
|
+
) {
|
|
516
|
+
return true
|
|
517
|
+
}
|
|
518
|
+
return !mime.startsWith('image/')
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
export function listContextFiles(dataDir, sessionId) {
|
|
522
|
+
const manifest = requireManifest(dataDir, sessionId)
|
|
523
|
+
const dir = sessionPaths(dataDir, sessionId).context
|
|
524
|
+
return normalizeContextFiles(manifest.contextFiles).flatMap((item) => {
|
|
525
|
+
let absolute
|
|
526
|
+
try {
|
|
527
|
+
absolute = contextFileAbsolute(dir, item.storedName)
|
|
528
|
+
} catch {
|
|
529
|
+
return []
|
|
530
|
+
}
|
|
531
|
+
if (!fs.existsSync(absolute)) return []
|
|
532
|
+
return [{ ...item, path: absolute }]
|
|
533
|
+
})
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export function contextFileHandshake(dataDir, sessionId) {
|
|
537
|
+
const files = listContextFiles(dataDir, sessionId)
|
|
538
|
+
const listed = []
|
|
539
|
+
const texts = []
|
|
540
|
+
for (const file of files) {
|
|
541
|
+
listed.push({
|
|
542
|
+
name: file.name,
|
|
543
|
+
path: file.path,
|
|
544
|
+
mimeType: file.mimeType,
|
|
545
|
+
size: file.size,
|
|
546
|
+
})
|
|
547
|
+
const bytes = fs.readFileSync(file.path)
|
|
548
|
+
if (isInlineContextText(file.mimeType, bytes)) {
|
|
549
|
+
texts.push({ name: file.name, content: bytes.toString('utf8') })
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
return { files: listed, texts }
|
|
553
|
+
}
|
|
554
|
+
|
|
426
555
|
export function readManifest(dataDir, sessionId) {
|
|
427
556
|
const { manifest } = sessionPaths(dataDir, sessionId)
|
|
428
557
|
const value = readJson(manifest, null)
|
|
@@ -448,6 +577,7 @@ export function readManifest(dataDir, sessionId) {
|
|
|
448
577
|
if (typeof value.stepByStep !== 'boolean') value.stepByStep = true
|
|
449
578
|
value.initialInstruction =
|
|
450
579
|
typeof value.initialInstruction === 'string' ? value.initialInstruction : null
|
|
580
|
+
value.contextFiles = normalizeContextFiles(value.contextFiles)
|
|
451
581
|
return value
|
|
452
582
|
}
|
|
453
583
|
|
|
@@ -492,9 +622,12 @@ function liveEntries(manifest, diffId) {
|
|
|
492
622
|
const browsingHistory = selected.id !== manifest.activeDiffId
|
|
493
623
|
return chain.filter((entry) => {
|
|
494
624
|
if (entry.status === 'rejected') return false
|
|
495
|
-
if (entry.status === 'extended'
|
|
625
|
+
if (entry.status === 'extended') {
|
|
496
626
|
return browsingHistory && entry.id === selected.id
|
|
497
627
|
}
|
|
628
|
+
if (entry.status === 'extend') {
|
|
629
|
+
return entry.id === selected.id
|
|
630
|
+
}
|
|
498
631
|
return true
|
|
499
632
|
})
|
|
500
633
|
}
|
|
@@ -627,6 +760,7 @@ export function sessionIntent(
|
|
|
627
760
|
typeof manifest.initialInstruction === 'string'
|
|
628
761
|
? manifest.initialInstruction
|
|
629
762
|
: null,
|
|
763
|
+
contextFiles: listContextFiles(dataDir, sessionId).map(publicContextFile),
|
|
630
764
|
steps: manifest.steps,
|
|
631
765
|
step: activeView ? manifest.currentStep : selected?.step ?? manifest.currentStep,
|
|
632
766
|
stepByStep: isStepByStep(manifest),
|
|
@@ -668,6 +802,7 @@ export function sessionIntent(
|
|
|
668
802
|
blueprintVariables: blueprint.addedVariables,
|
|
669
803
|
blueprintImports: blueprint.addedImports,
|
|
670
804
|
blueprintNotes: blueprint.notes,
|
|
805
|
+
blueprintPointers: blueprint.pointers,
|
|
671
806
|
}
|
|
672
807
|
}
|
|
673
808
|
|
|
@@ -887,11 +1022,21 @@ export function autoAdvance(dataDir, sessionId, targetRoot = null) {
|
|
|
887
1022
|
const active = manifest.diffs.at(-1)
|
|
888
1023
|
if (!active || active.status !== 'pending') return manifest
|
|
889
1024
|
if (active.step >= manifest.steps.length) return manifest
|
|
1025
|
+
if (isRevisedProposal(manifest, active)) return manifest
|
|
890
1026
|
return invokeStep(dataDir, sessionId, active.step + 1, targetRoot)
|
|
891
1027
|
}
|
|
892
1028
|
return manifest
|
|
893
1029
|
}
|
|
894
1030
|
|
|
1031
|
+
function isRevisedProposal(manifest, active) {
|
|
1032
|
+
return manifest.diffs.some(
|
|
1033
|
+
(entry) =>
|
|
1034
|
+
entry.id !== active.id &&
|
|
1035
|
+
entry.step === active.step &&
|
|
1036
|
+
(entry.status === 'extended' || entry.status === 'extend'),
|
|
1037
|
+
)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
895
1040
|
export function setStepByStep(dataDir, sessionId, enabled, targetRoot = null) {
|
|
896
1041
|
const manifest = requireManifest(dataDir, sessionId)
|
|
897
1042
|
manifest.stepByStep = Boolean(enabled)
|
|
@@ -928,6 +1073,7 @@ export function startSession(dataDir, input) {
|
|
|
928
1073
|
activeDiffId: null,
|
|
929
1074
|
pendingInstruction: null,
|
|
930
1075
|
initialInstruction: null,
|
|
1076
|
+
contextFiles: [],
|
|
931
1077
|
workStartedAt: null,
|
|
932
1078
|
createdAt: now,
|
|
933
1079
|
updatedAt: now,
|
|
@@ -966,6 +1112,7 @@ export function setupSession(dataDir, input = {}) {
|
|
|
966
1112
|
activeDiffId: null,
|
|
967
1113
|
pendingInstruction: null,
|
|
968
1114
|
initialInstruction: null,
|
|
1115
|
+
contextFiles: [],
|
|
969
1116
|
workStartedAt: null,
|
|
970
1117
|
createdAt: now,
|
|
971
1118
|
updatedAt: now,
|
|
@@ -993,6 +1140,92 @@ export function setInitialInstruction(dataDir, sessionId, instruction) {
|
|
|
993
1140
|
return manifest
|
|
994
1141
|
}
|
|
995
1142
|
|
|
1143
|
+
export function addContextFiles(dataDir, sessionId, files) {
|
|
1144
|
+
const manifest = requireManifest(dataDir, sessionId)
|
|
1145
|
+
if (isTerminalSession(manifest)) {
|
|
1146
|
+
throw sessionStoppedError(sessionId)
|
|
1147
|
+
}
|
|
1148
|
+
const incoming = Array.isArray(files) ? files : files ? [files] : []
|
|
1149
|
+
if (incoming.length === 0) {
|
|
1150
|
+
throw new Error('at least one context file is required')
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
const existing = listContextFiles(dataDir, sessionId)
|
|
1154
|
+
if (existing.length + incoming.length > MAX_CONTEXT_FILES) {
|
|
1155
|
+
throw new Error(`at most ${MAX_CONTEXT_FILES} context files can be attached`)
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const dir = sessionPaths(dataDir, sessionId).context
|
|
1159
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
1160
|
+
const existingBytes = existing.reduce((sum, item) => sum + item.size, 0)
|
|
1161
|
+
const next = [...existing.map((item) => ({
|
|
1162
|
+
id: item.id,
|
|
1163
|
+
name: item.name,
|
|
1164
|
+
storedName: item.storedName,
|
|
1165
|
+
mimeType: item.mimeType,
|
|
1166
|
+
size: item.size,
|
|
1167
|
+
}))]
|
|
1168
|
+
let addedBytes = 0
|
|
1169
|
+
|
|
1170
|
+
for (const file of incoming) {
|
|
1171
|
+
const bytes = decodeContextFileBytes(file)
|
|
1172
|
+
if (bytes.length === 0) {
|
|
1173
|
+
throw new Error('context file is empty')
|
|
1174
|
+
}
|
|
1175
|
+
if (bytes.length > MAX_CONTEXT_FILE_BYTES) {
|
|
1176
|
+
throw new Error(
|
|
1177
|
+
`context file must be ${MAX_CONTEXT_FILE_BYTES} bytes or smaller`,
|
|
1178
|
+
)
|
|
1179
|
+
}
|
|
1180
|
+
addedBytes += bytes.length
|
|
1181
|
+
if (existingBytes + addedBytes > MAX_CONTEXT_TOTAL_BYTES) {
|
|
1182
|
+
throw new Error(
|
|
1183
|
+
`attached files must total ${MAX_CONTEXT_TOTAL_BYTES} bytes or less`,
|
|
1184
|
+
)
|
|
1185
|
+
}
|
|
1186
|
+
const id = crypto.randomBytes(4).toString('hex')
|
|
1187
|
+
const originalName =
|
|
1188
|
+
typeof file?.name === 'string' ? path.basename(file.name.trim()) : ''
|
|
1189
|
+
const storedName = `${id}-${safeContextFileName(originalName || 'file')}`
|
|
1190
|
+
fs.writeFileSync(contextFileAbsolute(dir, storedName), bytes)
|
|
1191
|
+
next.push({
|
|
1192
|
+
id,
|
|
1193
|
+
name: originalName || storedName,
|
|
1194
|
+
storedName,
|
|
1195
|
+
mimeType:
|
|
1196
|
+
typeof file?.mimeType === 'string' && file.mimeType.trim()
|
|
1197
|
+
? file.mimeType.trim()
|
|
1198
|
+
: 'application/octet-stream',
|
|
1199
|
+
size: bytes.length,
|
|
1200
|
+
})
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
manifest.contextFiles = next
|
|
1204
|
+
writeManifest(dataDir, manifest)
|
|
1205
|
+
return manifest
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
export function removeContextFile(dataDir, sessionId, fileId) {
|
|
1209
|
+
const manifest = requireManifest(dataDir, sessionId)
|
|
1210
|
+
if (isTerminalSession(manifest)) {
|
|
1211
|
+
throw sessionStoppedError(sessionId)
|
|
1212
|
+
}
|
|
1213
|
+
const id = typeof fileId === 'string' ? fileId.trim() : ''
|
|
1214
|
+
if (!id) throw new Error('fileId is required')
|
|
1215
|
+
const existing = normalizeContextFiles(manifest.contextFiles)
|
|
1216
|
+
const item = existing.find((file) => file.id === id)
|
|
1217
|
+
if (!item) return manifest
|
|
1218
|
+
const dir = sessionPaths(dataDir, sessionId).context
|
|
1219
|
+
try {
|
|
1220
|
+
fs.unlinkSync(contextFileAbsolute(dir, item.storedName))
|
|
1221
|
+
} catch {
|
|
1222
|
+
// Drop the manifest entry even if the file is already gone.
|
|
1223
|
+
}
|
|
1224
|
+
manifest.contextFiles = existing.filter((file) => file.id !== id)
|
|
1225
|
+
writeManifest(dataDir, manifest)
|
|
1226
|
+
return manifest
|
|
1227
|
+
}
|
|
1228
|
+
|
|
996
1229
|
function generateVisualizerSessionId(dataDir) {
|
|
997
1230
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
998
1231
|
const sessionId = `viz-${crypto.randomBytes(6).toString('hex')}`
|
|
@@ -1056,6 +1289,7 @@ export function updateBlueprint(dataDir, _sessionId, input = {}) {
|
|
|
1056
1289
|
addedVariables: input.addedVariables ?? current.addedVariables,
|
|
1057
1290
|
addedImports: input.addedImports ?? current.addedImports,
|
|
1058
1291
|
notes: input.notes ?? current.notes,
|
|
1292
|
+
pointers: input.pointers ?? current.pointers,
|
|
1059
1293
|
}
|
|
1060
1294
|
return writeBlueprint(dataDir, next)
|
|
1061
1295
|
}
|
|
@@ -1072,7 +1306,8 @@ export function sendBlueprint(dataDir, sessionId, input = {}) {
|
|
|
1072
1306
|
input.addedFunctions ||
|
|
1073
1307
|
input.addedVariables ||
|
|
1074
1308
|
input.addedImports ||
|
|
1075
|
-
input.notes
|
|
1309
|
+
input.notes ||
|
|
1310
|
+
input.pointers
|
|
1076
1311
|
) {
|
|
1077
1312
|
updateBlueprint(dataDir, safeId, input)
|
|
1078
1313
|
}
|
|
@@ -1124,6 +1359,7 @@ export function reportPlan(dataDir, input) {
|
|
|
1124
1359
|
activeDiffId: null,
|
|
1125
1360
|
pendingInstruction: null,
|
|
1126
1361
|
initialInstruction: null,
|
|
1362
|
+
contextFiles: [],
|
|
1127
1363
|
workStartedAt: null,
|
|
1128
1364
|
createdAt: now,
|
|
1129
1365
|
updatedAt: now,
|
|
@@ -1299,6 +1535,7 @@ export function appendDiff(dataDir, targetRoot, input) {
|
|
|
1299
1535
|
)
|
|
1300
1536
|
manifest.activeDiffId = id
|
|
1301
1537
|
manifest.phase = 'review'
|
|
1538
|
+
manifest.pendingInstruction = null
|
|
1302
1539
|
manifest.workStartedAt = null
|
|
1303
1540
|
manifest.diffs.push(entry)
|
|
1304
1541
|
writeManifest(dataDir, manifest)
|
|
@@ -1370,11 +1607,12 @@ export function requestReplan(
|
|
|
1370
1607
|
active.status = 'extend'
|
|
1371
1608
|
active.instruction = guidance
|
|
1372
1609
|
active.decidedAt = new Date().toISOString()
|
|
1373
|
-
manifest.phase = '
|
|
1610
|
+
manifest.phase = 'working'
|
|
1374
1611
|
manifest.pendingInstruction = guidance
|
|
1375
1612
|
manifest.workStartedAt = new Date().toISOString()
|
|
1376
1613
|
writeManifest(dataDir, manifest)
|
|
1377
|
-
|
|
1614
|
+
// Keep the current proposal on disk. The instruction updates that latest state.
|
|
1615
|
+
void targetRoot
|
|
1378
1616
|
return manifest
|
|
1379
1617
|
}
|
|
1380
1618
|
|
|
@@ -1531,6 +1769,7 @@ export function emptyBlueprint() {
|
|
|
1531
1769
|
addedVariables: [],
|
|
1532
1770
|
addedImports: [],
|
|
1533
1771
|
notes: [],
|
|
1772
|
+
pointers: [],
|
|
1534
1773
|
}
|
|
1535
1774
|
}
|
|
1536
1775
|
|
|
@@ -1625,6 +1864,36 @@ function namedBlueprintNotes(value) {
|
|
|
1625
1864
|
return notes
|
|
1626
1865
|
}
|
|
1627
1866
|
|
|
1867
|
+
function namedBlueprintPointers(value) {
|
|
1868
|
+
if (!Array.isArray(value)) return []
|
|
1869
|
+
const seen = new Set()
|
|
1870
|
+
const pointers = []
|
|
1871
|
+
for (const item of value) {
|
|
1872
|
+
if (!item || typeof item !== 'object') continue
|
|
1873
|
+
const path = typeof item.path === 'string' ? item.path.trim() : ''
|
|
1874
|
+
if (!path) continue
|
|
1875
|
+
let stored = null
|
|
1876
|
+
if (item.kind === 'file' || item.kind === 'folder') {
|
|
1877
|
+
stored = { kind: item.kind, path }
|
|
1878
|
+
} else if (
|
|
1879
|
+
(item.kind === 'function' || item.kind === 'variable') &&
|
|
1880
|
+
typeof item.name === 'string' &&
|
|
1881
|
+
item.name.trim() !== ''
|
|
1882
|
+
) {
|
|
1883
|
+
stored = { kind: item.kind, path, name: item.name.trim() }
|
|
1884
|
+
}
|
|
1885
|
+
if (!stored) continue
|
|
1886
|
+
const key =
|
|
1887
|
+
stored.kind === 'file' || stored.kind === 'folder'
|
|
1888
|
+
? `${stored.kind}:${stored.path}`
|
|
1889
|
+
: `${stored.kind}:${stored.path}:${stored.name}`
|
|
1890
|
+
if (seen.has(key)) continue
|
|
1891
|
+
seen.add(key)
|
|
1892
|
+
pointers.push(stored)
|
|
1893
|
+
}
|
|
1894
|
+
return pointers
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1628
1897
|
function blueprintContentEqual(left, right) {
|
|
1629
1898
|
return (
|
|
1630
1899
|
JSON.stringify({
|
|
@@ -1634,6 +1903,7 @@ function blueprintContentEqual(left, right) {
|
|
|
1634
1903
|
addedVariables: left.addedVariables,
|
|
1635
1904
|
addedImports: left.addedImports,
|
|
1636
1905
|
notes: left.notes,
|
|
1906
|
+
pointers: left.pointers,
|
|
1637
1907
|
}) ===
|
|
1638
1908
|
JSON.stringify({
|
|
1639
1909
|
userCreatedBlocks: right.userCreatedBlocks,
|
|
@@ -1642,6 +1912,7 @@ function blueprintContentEqual(left, right) {
|
|
|
1642
1912
|
addedVariables: right.addedVariables,
|
|
1643
1913
|
addedImports: right.addedImports,
|
|
1644
1914
|
notes: right.notes,
|
|
1915
|
+
pointers: right.pointers,
|
|
1645
1916
|
})
|
|
1646
1917
|
)
|
|
1647
1918
|
}
|
|
@@ -1653,6 +1924,7 @@ function normalizeBlueprint(value) {
|
|
|
1653
1924
|
const addedVariables = namedBlueprintSymbols(value?.addedVariables)
|
|
1654
1925
|
const addedImports = namedBlueprintImportAdditions(value?.addedImports)
|
|
1655
1926
|
const notes = namedBlueprintNotes(value?.notes)
|
|
1927
|
+
const pointers = namedBlueprintPointers(value?.pointers)
|
|
1656
1928
|
const revision =
|
|
1657
1929
|
Number.isInteger(value?.revision) && value.revision >= 0 ? value.revision : 0
|
|
1658
1930
|
return {
|
|
@@ -1665,6 +1937,7 @@ function normalizeBlueprint(value) {
|
|
|
1665
1937
|
addedVariables,
|
|
1666
1938
|
addedImports,
|
|
1667
1939
|
notes,
|
|
1940
|
+
pointers,
|
|
1668
1941
|
}),
|
|
1669
1942
|
sent: true,
|
|
1670
1943
|
userCreatedBlocks,
|
|
@@ -1673,6 +1946,7 @@ function normalizeBlueprint(value) {
|
|
|
1673
1946
|
addedVariables,
|
|
1674
1947
|
addedImports,
|
|
1675
1948
|
notes,
|
|
1949
|
+
pointers,
|
|
1676
1950
|
}
|
|
1677
1951
|
}
|
|
1678
1952
|
|
|
@@ -1711,6 +1985,7 @@ export function writeBlueprint(dataDir, sessionIdOrBlueprint, maybeBlueprint) {
|
|
|
1711
1985
|
addedVariables: namedBlueprintSymbols(next.addedVariables),
|
|
1712
1986
|
addedImports: namedBlueprintImportAdditions(next.addedImports),
|
|
1713
1987
|
notes: namedBlueprintNotes(next.notes),
|
|
1988
|
+
pointers: namedBlueprintPointers(next.pointers),
|
|
1714
1989
|
},
|
|
1715
1990
|
null,
|
|
1716
1991
|
2,
|
|
@@ -1749,6 +2024,7 @@ export function cleanupBlueprint(dataDir, knownFileIds = [], knownFolderPaths =
|
|
|
1749
2024
|
addedVariables: current.addedVariables.filter((item) => !removedFiles.has(item.file)),
|
|
1750
2025
|
addedImports: current.addedImports.filter((item) => !removedFiles.has(item.file)),
|
|
1751
2026
|
notes: current.notes.filter((item) => !removedFiles.has(item.file)),
|
|
2027
|
+
pointers: current.pointers,
|
|
1752
2028
|
}
|
|
1753
2029
|
return writeBlueprint(dataDir, next)
|
|
1754
2030
|
}
|