@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 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 revise the remaining plan, or **Stop** to end the session.
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' || entry.status === 'extend') {
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 = 'replanning'
1610
+ manifest.phase = 'working'
1374
1611
  manifest.pendingInstruction = guidance
1375
1612
  manifest.workStartedAt = new Date().toISOString()
1376
1613
  writeManifest(dataDir, manifest)
1377
- if (targetRoot) materializeDiff(dataDir, targetRoot, sessionId, active.id)
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
  }