@jkwd/inbase 0.1.10 → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +22 -9
- package/apps/explorer/package.json +1 -0
- package/apps/explorer/scripts/branch-changes.d.ts +24 -0
- package/apps/explorer/scripts/branch-changes.mjs +237 -0
- package/apps/explorer/scripts/js-source.mjs +12 -15
- package/apps/explorer/scripts/patch-lib.d.ts +5 -0
- package/apps/explorer/scripts/patch-lib.mjs +5 -0
- package/apps/explorer/scripts/scan-target.mjs +46 -13
- package/apps/explorer/scripts/session-store.d.ts +58 -1
- package/apps/explorer/scripts/session-store.mjs +276 -52
- package/apps/explorer/scripts/tree-diff.mjs +121 -0
- package/apps/explorer/src/App.tsx +170 -32
- package/apps/explorer/src/agentIntent.ts +63 -0
- package/apps/explorer/src/branchChanges.ts +54 -0
- package/apps/explorer/src/index.css +153 -0
- package/apps/explorer/src/scene/FileBlock.tsx +55 -5
- package/apps/explorer/src/scene/MapView.tsx +32 -1
- package/apps/explorer/src/scene/World.tsx +6 -1
- package/apps/explorer/src/types.ts +44 -0
- package/apps/explorer/src/ui/HUD.tsx +583 -96
- package/apps/explorer/src/ui/MapContextMenu.tsx +89 -0
- package/apps/explorer/src/userContext.ts +11 -0
- package/apps/explorer/src/userCreated.ts +50 -0
- package/apps/explorer/vite.config.ts +61 -7
- package/bin/inbase.mjs +23 -4
- package/bin/project.mjs +62 -4
- package/bin/session.mjs +231 -125
- package/package.json +20 -2
- package/skill/commands/inbase.md +17 -0
- package/skill/commands/skipinbase.md +9 -0
- package/skill/inbase/SKILL.md +143 -112
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
3
4
|
import { spawnSync } from 'node:child_process'
|
|
4
5
|
import {
|
|
5
6
|
accumulatePatchAdditions,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
foldersFromFileIds,
|
|
11
12
|
parseUnifiedPatch,
|
|
12
13
|
} from './patch-lib.mjs'
|
|
14
|
+
import { diffSourceTrees, snapshotSourceTree } from './tree-diff.mjs'
|
|
13
15
|
|
|
14
16
|
const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
|
|
15
17
|
const CONNECTED_TTL_MS = 15_000
|
|
@@ -39,6 +41,19 @@ function atomicWrite(file, contents) {
|
|
|
39
41
|
fs.renameSync(temporary, file)
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
function featureName(value) {
|
|
45
|
+
const trimmed = typeof value === 'string' ? value.trim() : ''
|
|
46
|
+
return trimmed
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sessionName(value) {
|
|
50
|
+
return featureName(value)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function resolvedSessionName(manifest) {
|
|
54
|
+
return sessionName(manifest?.name) || sessionName(manifest?.feature)
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
export function sessionPaths(dataDir, sessionId) {
|
|
43
58
|
const safeId = assertSessionId(sessionId)
|
|
44
59
|
const root = path.join(dataDir, 'diff-sessions', safeId)
|
|
@@ -49,6 +64,7 @@ export function sessionPaths(dataDir, sessionId) {
|
|
|
49
64
|
blueprint: path.join(root, 'blueprint.json'),
|
|
50
65
|
baseline: path.join(root, 'baseline.json'),
|
|
51
66
|
baselineFiles: path.join(root, 'baseline'),
|
|
67
|
+
preStep: path.join(root, 'pre-step'),
|
|
52
68
|
stopped: path.join(dataDir, 'diff-sessions', `${safeId}.stopped`),
|
|
53
69
|
}
|
|
54
70
|
}
|
|
@@ -124,6 +140,38 @@ function connectionFile(dataDir, sessionId) {
|
|
|
124
140
|
return path.join(sessionPaths(dataDir, sessionId).root, 'connected.json')
|
|
125
141
|
}
|
|
126
142
|
|
|
143
|
+
function ackFile(dataDir, sessionId) {
|
|
144
|
+
return path.join(sessionPaths(dataDir, sessionId).root, 'ack.json')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function recordSessionAck(dataDir, sessionId, kind, detail = '') {
|
|
148
|
+
const safeId = assertSessionId(sessionId)
|
|
149
|
+
if (isSessionStopped(dataDir, safeId) && kind !== 'stopped' && kind !== 'finished') {
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
const payload = {
|
|
153
|
+
kind: String(kind),
|
|
154
|
+
detail: String(detail ?? ''),
|
|
155
|
+
at: new Date().toISOString(),
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
atomicWrite(ackFile(dataDir, safeId), `${JSON.stringify(payload, null, 2)}\n`)
|
|
159
|
+
} catch {
|
|
160
|
+
return null
|
|
161
|
+
}
|
|
162
|
+
return payload
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function readSessionAck(dataDir, sessionId) {
|
|
166
|
+
const value = readJson(ackFile(dataDir, sessionId), null)
|
|
167
|
+
if (!value || typeof value.kind !== 'string' || value.kind.trim() === '') return null
|
|
168
|
+
return {
|
|
169
|
+
kind: value.kind,
|
|
170
|
+
detail: typeof value.detail === 'string' ? value.detail : '',
|
|
171
|
+
at: typeof value.at === 'string' ? value.at : null,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
127
175
|
function isFreshTimestamp(value, now = Date.now()) {
|
|
128
176
|
if (typeof value !== 'string') return false
|
|
129
177
|
const at = Date.parse(value)
|
|
@@ -137,6 +185,11 @@ export function touchSessionConnection(dataDir, sessionId) {
|
|
|
137
185
|
connectionFile(dataDir, safeId),
|
|
138
186
|
`${JSON.stringify({ sessionId: safeId, connectedAt: new Date().toISOString() }, null, 2)}\n`,
|
|
139
187
|
)
|
|
188
|
+
const manifest = readManifest(dataDir, safeId)
|
|
189
|
+
if (manifest?.awaitingAttach) {
|
|
190
|
+
manifest.awaitingAttach = false
|
|
191
|
+
writeManifest(dataDir, manifest)
|
|
192
|
+
}
|
|
140
193
|
}
|
|
141
194
|
|
|
142
195
|
function waiterSessionIds() {
|
|
@@ -196,10 +249,12 @@ export function isSessionConnected(
|
|
|
196
249
|
const safeId = assertSessionId(sessionId)
|
|
197
250
|
const manifest = readManifest(dataDir, safeId)
|
|
198
251
|
if (isTerminalSession(manifest)) return false
|
|
199
|
-
if (isGeneratingPhase(manifest.phase)) return true
|
|
200
|
-
if (waiterIds.has(safeId)) return true
|
|
201
252
|
const connected = readJson(connectionFile(dataDir, safeId), null)
|
|
202
|
-
|
|
253
|
+
const heartbeat =
|
|
254
|
+
waiterIds.has(safeId) || isFreshTimestamp(connected?.connectedAt)
|
|
255
|
+
if (manifest.awaitingAttach) return heartbeat
|
|
256
|
+
if (isGeneratingPhase(manifest.phase)) return true
|
|
257
|
+
if (heartbeat) return true
|
|
203
258
|
return isFreshTimestamp(manifest.updatedAt) || isFreshTimestamp(manifest.createdAt)
|
|
204
259
|
}
|
|
205
260
|
|
|
@@ -327,6 +382,8 @@ export function readManifest(dataDir, sessionId) {
|
|
|
327
382
|
value.workStartedAt ??= null
|
|
328
383
|
}
|
|
329
384
|
if (typeof value.stepByStep !== 'boolean') value.stepByStep = true
|
|
385
|
+
value.initialInstruction =
|
|
386
|
+
typeof value.initialInstruction === 'string' ? value.initialInstruction : null
|
|
330
387
|
return value
|
|
331
388
|
}
|
|
332
389
|
|
|
@@ -500,7 +557,12 @@ export function sessionIntent(
|
|
|
500
557
|
showMap: previewVisible,
|
|
501
558
|
status: activeView ? phaseStatus ?? historicalStatus : historicalStatus,
|
|
502
559
|
phase: manifest.phase,
|
|
560
|
+
name: resolvedSessionName(manifest) || null,
|
|
503
561
|
feature: manifest.feature,
|
|
562
|
+
initialInstruction:
|
|
563
|
+
typeof manifest.initialInstruction === 'string'
|
|
564
|
+
? manifest.initialInstruction
|
|
565
|
+
: null,
|
|
504
566
|
steps: manifest.steps,
|
|
505
567
|
step: activeView ? manifest.currentStep : selected?.step ?? manifest.currentStep,
|
|
506
568
|
stepByStep: isStepByStep(manifest),
|
|
@@ -519,11 +581,17 @@ export function sessionIntent(
|
|
|
519
581
|
isActiveDiff: Boolean(selected && selected.id === manifest.activeDiffId),
|
|
520
582
|
preview: previewVisible,
|
|
521
583
|
working:
|
|
522
|
-
manifest.
|
|
523
|
-
manifest.phase === '
|
|
524
|
-
|
|
584
|
+
!manifest.awaitingAttach &&
|
|
585
|
+
(manifest.phase === 'preparing' ||
|
|
586
|
+
manifest.phase === 'working' ||
|
|
587
|
+
manifest.phase === 'replanning'),
|
|
525
588
|
stalledWait: isStalledWorking(manifest, waiterIds, sessionId),
|
|
526
589
|
llmIdle: !isSessionConnected(dataDir, sessionId, waiterIds),
|
|
590
|
+
awaitingAttach:
|
|
591
|
+
Boolean(manifest.awaitingAttach) &&
|
|
592
|
+
!isSessionConnected(dataDir, sessionId, waiterIds),
|
|
593
|
+
listening: waiterIds.has(sessionId),
|
|
594
|
+
lastAck: readSessionAck(dataDir, sessionId),
|
|
527
595
|
creationMode: sessionAllowsPlacement(manifest),
|
|
528
596
|
canEnterBlueprint,
|
|
529
597
|
blueprintSessionId: null,
|
|
@@ -738,11 +806,6 @@ function planSteps(titles, startAt = 1) {
|
|
|
738
806
|
})
|
|
739
807
|
}
|
|
740
808
|
|
|
741
|
-
function featureName(value) {
|
|
742
|
-
const trimmed = typeof value === 'string' ? value.trim() : ''
|
|
743
|
-
return trimmed
|
|
744
|
-
}
|
|
745
|
-
|
|
746
809
|
export function isStepByStep(manifest) {
|
|
747
810
|
return manifest?.stepByStep !== false
|
|
748
811
|
}
|
|
@@ -774,7 +837,12 @@ export function startSession(dataDir, input) {
|
|
|
774
837
|
const sessionId = assertSessionId(input.sessionId)
|
|
775
838
|
clearStoppedMarker(dataDir, sessionId)
|
|
776
839
|
const existing = readManifest(dataDir, sessionId)
|
|
840
|
+
const name = sessionName(input.name) || sessionName(input.feature)
|
|
777
841
|
if (existing) {
|
|
842
|
+
if (name && existing.name !== name) {
|
|
843
|
+
existing.name = name
|
|
844
|
+
writeManifest(dataDir, existing)
|
|
845
|
+
}
|
|
778
846
|
focusSession(dataDir, sessionId)
|
|
779
847
|
return existing
|
|
780
848
|
}
|
|
@@ -783,7 +851,8 @@ export function startSession(dataDir, input) {
|
|
|
783
851
|
const manifest = {
|
|
784
852
|
version: 2,
|
|
785
853
|
sessionId,
|
|
786
|
-
|
|
854
|
+
name,
|
|
855
|
+
feature: featureName(input.feature) || name,
|
|
787
856
|
steps: [],
|
|
788
857
|
status: 'active',
|
|
789
858
|
phase: 'blueprint_ask',
|
|
@@ -791,6 +860,7 @@ export function startSession(dataDir, input) {
|
|
|
791
860
|
currentStep: 1,
|
|
792
861
|
activeDiffId: null,
|
|
793
862
|
pendingInstruction: null,
|
|
863
|
+
initialInstruction: null,
|
|
794
864
|
workStartedAt: null,
|
|
795
865
|
createdAt: now,
|
|
796
866
|
updatedAt: now,
|
|
@@ -802,6 +872,115 @@ export function startSession(dataDir, input) {
|
|
|
802
872
|
return manifest
|
|
803
873
|
}
|
|
804
874
|
|
|
875
|
+
export function setupSession(dataDir, input = {}) {
|
|
876
|
+
const sessionId = input.sessionId
|
|
877
|
+
? assertSessionId(input.sessionId)
|
|
878
|
+
: generateVisualizerSessionId(dataDir)
|
|
879
|
+
const existing = readManifest(dataDir, sessionId)
|
|
880
|
+
if (existing && !isTerminalSession(existing)) {
|
|
881
|
+
throw new Error(`Session ${sessionId} already exists`)
|
|
882
|
+
}
|
|
883
|
+
if (existing) {
|
|
884
|
+
discardStoredSession(dataDir, sessionId, null, { restore: false })
|
|
885
|
+
}
|
|
886
|
+
clearStoppedMarker(dataDir, sessionId)
|
|
887
|
+
const now = new Date().toISOString()
|
|
888
|
+
const name = sessionName(input.name)
|
|
889
|
+
const manifest = {
|
|
890
|
+
version: 2,
|
|
891
|
+
sessionId,
|
|
892
|
+
name,
|
|
893
|
+
feature: featureName(input.feature) || name,
|
|
894
|
+
steps: [],
|
|
895
|
+
status: 'active',
|
|
896
|
+
phase: 'blueprint',
|
|
897
|
+
awaitingAttach: true,
|
|
898
|
+
stepByStep: true,
|
|
899
|
+
currentStep: 1,
|
|
900
|
+
activeDiffId: null,
|
|
901
|
+
pendingInstruction: null,
|
|
902
|
+
initialInstruction: null,
|
|
903
|
+
workStartedAt: null,
|
|
904
|
+
createdAt: now,
|
|
905
|
+
updatedAt: now,
|
|
906
|
+
diffs: [],
|
|
907
|
+
}
|
|
908
|
+
writeManifest(dataDir, manifest)
|
|
909
|
+
writeBlueprint(dataDir, sessionId, {
|
|
910
|
+
...emptyBlueprint(),
|
|
911
|
+
enabled: true,
|
|
912
|
+
sent: false,
|
|
913
|
+
})
|
|
914
|
+
focusSession(dataDir, sessionId)
|
|
915
|
+
return manifest
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
export function setInitialInstruction(dataDir, sessionId, instruction) {
|
|
919
|
+
const manifest = requireManifest(dataDir, sessionId)
|
|
920
|
+
if (isTerminalSession(manifest)) {
|
|
921
|
+
throw sessionStoppedError(sessionId)
|
|
922
|
+
}
|
|
923
|
+
const text = typeof instruction === 'string' ? instruction : ''
|
|
924
|
+
if (text.length > 4000) {
|
|
925
|
+
throw new Error('instruction must be a string up to 4000 characters')
|
|
926
|
+
}
|
|
927
|
+
const next = text.trim() === '' ? null : text
|
|
928
|
+
if ((manifest.initialInstruction ?? null) === next) return manifest
|
|
929
|
+
manifest.initialInstruction = next
|
|
930
|
+
writeManifest(dataDir, manifest)
|
|
931
|
+
return manifest
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function generateVisualizerSessionId(dataDir) {
|
|
935
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
936
|
+
const sessionId = `viz-${crypto.randomBytes(6).toString('hex')}`
|
|
937
|
+
if (!readManifest(dataDir, sessionId) && !isSessionStopped(dataDir, sessionId)) {
|
|
938
|
+
return sessionId
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
throw new Error('Could not allocate a visualizer session id')
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
export function readAttachedSession(dataDir) {
|
|
945
|
+
for (const sessionId of listOpenSessionIds(dataDir)) {
|
|
946
|
+
const manifest = readManifest(dataDir, sessionId)
|
|
947
|
+
if (manifest?.awaitingAttach === false) return sessionId
|
|
948
|
+
}
|
|
949
|
+
return null
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
export function attachSession(dataDir, sessionId) {
|
|
953
|
+
const safeId = sessionId
|
|
954
|
+
? assertSessionId(sessionId)
|
|
955
|
+
: readActiveSession(dataDir)
|
|
956
|
+
if (!safeId) {
|
|
957
|
+
throw new Error(
|
|
958
|
+
'No visualizer session is focused. Click Setup LLM session in the map, then /inbase.',
|
|
959
|
+
)
|
|
960
|
+
}
|
|
961
|
+
const manifest = requireManifest(
|
|
962
|
+
dataDir,
|
|
963
|
+
safeId,
|
|
964
|
+
`No visualizer session ${safeId}. Click Setup LLM session in the map, then /inbase.`,
|
|
965
|
+
)
|
|
966
|
+
if (isTerminalSession(manifest)) {
|
|
967
|
+
throw sessionStoppedError(safeId)
|
|
968
|
+
}
|
|
969
|
+
const attached = readAttachedSession(dataDir)
|
|
970
|
+
if (attached && attached !== safeId) {
|
|
971
|
+
const other = readManifest(dataDir, attached)
|
|
972
|
+
const label = resolvedSessionName(other) || attached
|
|
973
|
+
throw new Error(
|
|
974
|
+
`An LLM is already attached to ${label}. Stop that session before attaching another.`,
|
|
975
|
+
)
|
|
976
|
+
}
|
|
977
|
+
focusSession(dataDir, safeId)
|
|
978
|
+
touchSessionConnection(dataDir, safeId)
|
|
979
|
+
recordSessionAck(dataDir, safeId, 'attached', resolvedSessionName(manifest) || safeId)
|
|
980
|
+
maybeStartVisualizerHandshake(dataDir, safeId)
|
|
981
|
+
return readManifest(dataDir, safeId) ?? manifest
|
|
982
|
+
}
|
|
983
|
+
|
|
805
984
|
export function answerBlueprint(dataDir, sessionId, enabled) {
|
|
806
985
|
const manifest = requireManifest(dataDir, sessionId)
|
|
807
986
|
if (manifest.phase !== 'blueprint_ask') {
|
|
@@ -849,14 +1028,36 @@ export function sendBlueprint(dataDir, sessionId, input = {}) {
|
|
|
849
1028
|
throw new Error(`Session ${safeId} is not in blueprint mode`)
|
|
850
1029
|
}
|
|
851
1030
|
const current = readBlueprint(dataDir, safeId)
|
|
852
|
-
|
|
853
|
-
enabled: true,
|
|
854
|
-
sent: true,
|
|
1031
|
+
const next = {
|
|
855
1032
|
userCreatedBlocks: input.userCreatedBlocks ?? current.userCreatedBlocks,
|
|
856
1033
|
userCreatedIslands: input.userCreatedIslands ?? current.userCreatedIslands,
|
|
857
1034
|
addedFunctions: input.addedFunctions ?? current.addedFunctions,
|
|
858
1035
|
addedVariables: input.addedVariables ?? current.addedVariables,
|
|
859
1036
|
addedImports: input.addedImports ?? current.addedImports,
|
|
1037
|
+
}
|
|
1038
|
+
writeBlueprint(dataDir, safeId, {
|
|
1039
|
+
...next,
|
|
1040
|
+
enabled: blueprintHasContent(next),
|
|
1041
|
+
sent: true,
|
|
1042
|
+
})
|
|
1043
|
+
manifest.phase = 'preparing'
|
|
1044
|
+
manifest.workStartedAt = new Date().toISOString()
|
|
1045
|
+
writeManifest(dataDir, manifest)
|
|
1046
|
+
releaseBlueprintSession(dataDir, safeId)
|
|
1047
|
+
return manifest
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
export function maybeStartVisualizerHandshake(dataDir, sessionId) {
|
|
1051
|
+
const safeId = assertSessionId(sessionId)
|
|
1052
|
+
const manifest = requireManifest(dataDir, safeId)
|
|
1053
|
+
if (manifest.phase !== 'blueprint_ask' && manifest.phase !== 'blueprint') {
|
|
1054
|
+
return manifest
|
|
1055
|
+
}
|
|
1056
|
+
const current = readBlueprint(dataDir, safeId)
|
|
1057
|
+
writeBlueprint(dataDir, safeId, {
|
|
1058
|
+
...current,
|
|
1059
|
+
enabled: blueprintHasContent(current),
|
|
1060
|
+
sent: true,
|
|
860
1061
|
})
|
|
861
1062
|
manifest.phase = 'preparing'
|
|
862
1063
|
manifest.workStartedAt = new Date().toISOString()
|
|
@@ -883,6 +1084,7 @@ export function reportPlan(dataDir, input) {
|
|
|
883
1084
|
const manifest = existing ?? {
|
|
884
1085
|
version: 2,
|
|
885
1086
|
sessionId,
|
|
1087
|
+
name: sessionName(input.name) || sessionName(input.feature),
|
|
886
1088
|
feature: input.feature,
|
|
887
1089
|
steps: [],
|
|
888
1090
|
status: 'active',
|
|
@@ -891,11 +1093,15 @@ export function reportPlan(dataDir, input) {
|
|
|
891
1093
|
currentStep: 1,
|
|
892
1094
|
activeDiffId: null,
|
|
893
1095
|
pendingInstruction: null,
|
|
1096
|
+
initialInstruction: null,
|
|
894
1097
|
workStartedAt: null,
|
|
895
1098
|
createdAt: now,
|
|
896
1099
|
updatedAt: now,
|
|
897
1100
|
diffs: [],
|
|
898
1101
|
}
|
|
1102
|
+
if (!sessionName(manifest.name)) {
|
|
1103
|
+
manifest.name = sessionName(input.feature)
|
|
1104
|
+
}
|
|
899
1105
|
manifest.feature = input.feature
|
|
900
1106
|
manifest.steps = planSteps(input.stepTitles)
|
|
901
1107
|
manifest.status = 'active'
|
|
@@ -904,7 +1110,13 @@ export function reportPlan(dataDir, input) {
|
|
|
904
1110
|
manifest.workStartedAt = null
|
|
905
1111
|
writeManifest(dataDir, manifest)
|
|
906
1112
|
focusSession(dataDir, sessionId)
|
|
907
|
-
|
|
1113
|
+
recordSessionAck(
|
|
1114
|
+
dataDir,
|
|
1115
|
+
sessionId,
|
|
1116
|
+
'plan',
|
|
1117
|
+
`${manifest.steps.length} step(s)`,
|
|
1118
|
+
)
|
|
1119
|
+
return autoAdvance(dataDir, sessionId, input.targetRoot)
|
|
908
1120
|
}
|
|
909
1121
|
|
|
910
1122
|
if (existing.phase !== 'replanning') {
|
|
@@ -921,7 +1133,13 @@ export function reportPlan(dataDir, input) {
|
|
|
921
1133
|
existing.workStartedAt = null
|
|
922
1134
|
writeManifest(dataDir, existing)
|
|
923
1135
|
focusSession(dataDir, sessionId)
|
|
924
|
-
|
|
1136
|
+
recordSessionAck(
|
|
1137
|
+
dataDir,
|
|
1138
|
+
sessionId,
|
|
1139
|
+
'plan',
|
|
1140
|
+
`${existing.steps.filter((step) => step.index >= startAt).length} step(s)`,
|
|
1141
|
+
)
|
|
1142
|
+
return autoAdvance(dataDir, sessionId, input.targetRoot)
|
|
925
1143
|
}
|
|
926
1144
|
|
|
927
1145
|
export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
|
|
@@ -961,9 +1179,33 @@ export function invokeStep(dataDir, sessionId, step, targetRoot = null) {
|
|
|
961
1179
|
manifest.phase = 'working'
|
|
962
1180
|
manifest.workStartedAt = new Date().toISOString()
|
|
963
1181
|
writeManifest(dataDir, manifest)
|
|
1182
|
+
const title = manifest.steps.find((item) => item.index === step)?.title
|
|
1183
|
+
recordSessionAck(
|
|
1184
|
+
dataDir,
|
|
1185
|
+
sessionId,
|
|
1186
|
+
'invoke',
|
|
1187
|
+
title ? `step ${step} — ${title}` : `step ${step}`,
|
|
1188
|
+
)
|
|
1189
|
+
if (targetRoot) snapshotPreStep(dataDir, sessionId, targetRoot)
|
|
964
1190
|
return manifest
|
|
965
1191
|
}
|
|
966
1192
|
|
|
1193
|
+
export function snapshotPreStep(dataDir, sessionId, targetRoot) {
|
|
1194
|
+
const { preStep } = sessionPaths(dataDir, sessionId)
|
|
1195
|
+
snapshotSourceTree(targetRoot, preStep)
|
|
1196
|
+
return preStep
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
export function readLiveDiff(dataDir, sessionId, targetRoot) {
|
|
1200
|
+
const { preStep } = sessionPaths(dataDir, sessionId)
|
|
1201
|
+
if (!fs.existsSync(preStep)) {
|
|
1202
|
+
throw new Error(
|
|
1203
|
+
`Step ${sessionId} has no invoke snapshot. Wait for VISUAL_CODER_EXECUTE before recording file changes.`,
|
|
1204
|
+
)
|
|
1205
|
+
}
|
|
1206
|
+
return diffSourceTrees(preStep, targetRoot)
|
|
1207
|
+
}
|
|
1208
|
+
|
|
967
1209
|
export function appendDiff(dataDir, targetRoot, input) {
|
|
968
1210
|
const sessionId = assertSessionId(input.sessionId)
|
|
969
1211
|
const manifest = requireManifest(
|
|
@@ -990,12 +1232,19 @@ export function appendDiff(dataDir, targetRoot, input) {
|
|
|
990
1232
|
throw new Error(`The next diff must implement step ${parent.step + 1}`)
|
|
991
1233
|
}
|
|
992
1234
|
|
|
993
|
-
|
|
1235
|
+
const patchText = input.patchText ?? readLiveDiff(dataDir, sessionId, targetRoot)
|
|
1236
|
+
if (!patchText.trim()) {
|
|
1237
|
+
throw new Error('No file changes to record for this step')
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const snapshotRoot = sessionPaths(dataDir, sessionId).preStep
|
|
1241
|
+
const originRoot = fs.existsSync(snapshotRoot) ? snapshotRoot : targetRoot
|
|
1242
|
+
validateContinuation(dataDir, manifest, originRoot, patchText)
|
|
994
1243
|
captureBaseline(
|
|
995
1244
|
dataDir,
|
|
996
1245
|
sessionId,
|
|
997
|
-
|
|
998
|
-
parseUnifiedPatch(
|
|
1246
|
+
originRoot,
|
|
1247
|
+
parseUnifiedPatch(patchText).entries.map((entry) => entry.id),
|
|
999
1248
|
)
|
|
1000
1249
|
if (parent?.status === 'extend') parent.status = 'extended'
|
|
1001
1250
|
|
|
@@ -1016,7 +1265,7 @@ export function appendDiff(dataDir, targetRoot, input) {
|
|
|
1016
1265
|
fs.mkdirSync(paths.diffs, { recursive: true })
|
|
1017
1266
|
atomicWrite(
|
|
1018
1267
|
path.join(paths.root, file),
|
|
1019
|
-
|
|
1268
|
+
patchText.endsWith('\n') ? patchText : `${patchText}\n`,
|
|
1020
1269
|
)
|
|
1021
1270
|
manifest.activeDiffId = id
|
|
1022
1271
|
manifest.phase = 'review'
|
|
@@ -1180,37 +1429,6 @@ export function discardInactiveDiffSessions(
|
|
|
1180
1429
|
return liveIds
|
|
1181
1430
|
}
|
|
1182
1431
|
|
|
1183
|
-
export function recoverOpenDiffSessions(dataDir, targetRoot = null) {
|
|
1184
|
-
const kept = []
|
|
1185
|
-
for (const sessionId of listStoredSessionIds(dataDir)) {
|
|
1186
|
-
const manifest = readManifest(dataDir, sessionId)
|
|
1187
|
-
if (!isTerminalSession(manifest)) {
|
|
1188
|
-
if (targetRoot && manifest.activeDiffId) {
|
|
1189
|
-
try {
|
|
1190
|
-
materializeDiff(dataDir, targetRoot, sessionId, manifest.activeDiffId)
|
|
1191
|
-
} catch {
|
|
1192
|
-
// Keep the stored diffs visible even if disk replay fails.
|
|
1193
|
-
}
|
|
1194
|
-
}
|
|
1195
|
-
kept.push(sessionId)
|
|
1196
|
-
continue
|
|
1197
|
-
}
|
|
1198
|
-
discardStoredSession(dataDir, sessionId, targetRoot, {
|
|
1199
|
-
restore:
|
|
1200
|
-
manifest?.phase === 'stopped' ||
|
|
1201
|
-
manifest?.status === 'rejected' ||
|
|
1202
|
-
!manifest,
|
|
1203
|
-
})
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
const active = readActiveSession(dataDir)
|
|
1207
|
-
if (active && !kept.includes(active)) writeActiveSession(dataDir, null)
|
|
1208
|
-
const locked = readBlueprintSession(dataDir)
|
|
1209
|
-
if (locked && !kept.includes(locked)) writeBlueprintSession(dataDir, null)
|
|
1210
|
-
unstageDiffSessionArtifacts(dataDir, targetRoot)
|
|
1211
|
-
return kept
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
1432
|
export function clearDiffSessions(dataDir, targetRoot = null) {
|
|
1215
1433
|
for (const sessionId of listStoredSessionIds(dataDir)) {
|
|
1216
1434
|
discardStoredSession(dataDir, sessionId, targetRoot)
|
|
@@ -1227,6 +1445,12 @@ export function clearDiffSessions(dataDir, targetRoot = null) {
|
|
|
1227
1445
|
unstageDiffSessionArtifacts(dataDir, targetRoot)
|
|
1228
1446
|
}
|
|
1229
1447
|
|
|
1448
|
+
export function recoverOpenDiffSessions(dataDir, targetRoot = null) {
|
|
1449
|
+
// Visualizer startup never reopens an LLM session.
|
|
1450
|
+
clearDiffSessions(dataDir, targetRoot)
|
|
1451
|
+
return []
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1230
1454
|
export function stopSession(dataDir, sessionId, targetRoot = null) {
|
|
1231
1455
|
const safeId = assertSessionId(sessionId)
|
|
1232
1456
|
writeStoppedMarker(dataDir, safeId)
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { listSourceFiles } from './scan-target.mjs'
|
|
5
|
+
|
|
6
|
+
function splitLines(text) {
|
|
7
|
+
if (text === '') return []
|
|
8
|
+
const lines = text.split('\n')
|
|
9
|
+
if (text.endsWith('\n')) lines.pop()
|
|
10
|
+
return lines
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function snapshotSourceTree(fromRoot, toRoot) {
|
|
14
|
+
if (fs.existsSync(toRoot)) fs.rmSync(toRoot, { recursive: true, force: true })
|
|
15
|
+
fs.mkdirSync(toRoot, { recursive: true })
|
|
16
|
+
for (const fileId of listSourceFiles(fromRoot)) {
|
|
17
|
+
const from = path.join(fromRoot, fileId)
|
|
18
|
+
const to = path.join(toRoot, fileId)
|
|
19
|
+
fs.mkdirSync(path.dirname(to), { recursive: true })
|
|
20
|
+
fs.copyFileSync(from, to)
|
|
21
|
+
}
|
|
22
|
+
return toRoot
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function fileAsAddPatch(fileId, contents) {
|
|
26
|
+
const lines = splitLines(contents)
|
|
27
|
+
const count = lines.length
|
|
28
|
+
const hunk =
|
|
29
|
+
count === 0
|
|
30
|
+
? []
|
|
31
|
+
: [`@@ -0,0 +1,${count} @@`, ...lines.map((line) => `+${line}`)]
|
|
32
|
+
return [
|
|
33
|
+
`diff --git a/${fileId} b/${fileId}`,
|
|
34
|
+
'new file mode 100644',
|
|
35
|
+
'--- /dev/null',
|
|
36
|
+
`+++ b/${fileId}`,
|
|
37
|
+
...hunk,
|
|
38
|
+
'',
|
|
39
|
+
].join('\n')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function fileAsDeletePatch(fileId, contents) {
|
|
43
|
+
const lines = splitLines(contents)
|
|
44
|
+
const count = lines.length
|
|
45
|
+
const hunk =
|
|
46
|
+
count === 0
|
|
47
|
+
? []
|
|
48
|
+
: [`@@ -1,${count} +0,0 @@`, ...lines.map((line) => `-${line}`)]
|
|
49
|
+
return [
|
|
50
|
+
`diff --git a/${fileId} b/${fileId}`,
|
|
51
|
+
'deleted file mode 100644',
|
|
52
|
+
`--- a/${fileId}`,
|
|
53
|
+
'+++ /dev/null',
|
|
54
|
+
...hunk,
|
|
55
|
+
'',
|
|
56
|
+
].join('\n')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rewriteGitPaths(patch, fileId) {
|
|
60
|
+
return patch
|
|
61
|
+
.split('\n')
|
|
62
|
+
.map((line) => {
|
|
63
|
+
if (line.startsWith('diff --git ')) {
|
|
64
|
+
return `diff --git a/${fileId} b/${fileId}`
|
|
65
|
+
}
|
|
66
|
+
if (line.startsWith('--- ')) {
|
|
67
|
+
return line.includes('/dev/null') ? '--- /dev/null' : `--- a/${fileId}`
|
|
68
|
+
}
|
|
69
|
+
if (line.startsWith('+++ ')) {
|
|
70
|
+
return line.includes('/dev/null') ? '+++ /dev/null' : `+++ b/${fileId}`
|
|
71
|
+
}
|
|
72
|
+
return line
|
|
73
|
+
})
|
|
74
|
+
.join('\n')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function gitFileDiff(beforePath, afterPath, fileId) {
|
|
78
|
+
const result = spawnSync(
|
|
79
|
+
'git',
|
|
80
|
+
['diff', '--no-index', '--no-color', '--no-ext-diff', '--', beforePath, afterPath],
|
|
81
|
+
{
|
|
82
|
+
encoding: 'utf8',
|
|
83
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
if (result.status !== 0 && result.status !== 1) {
|
|
87
|
+
throw new Error(result.stderr?.trim() || `git diff failed for ${fileId}`)
|
|
88
|
+
}
|
|
89
|
+
const stdout = result.stdout?.trimEnd()
|
|
90
|
+
if (!stdout) return ''
|
|
91
|
+
return `${rewriteGitPaths(stdout, fileId).trimEnd()}\n`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function diffSourceTrees(beforeRoot, afterRoot) {
|
|
95
|
+
const before = new Set(listSourceFiles(beforeRoot))
|
|
96
|
+
const after = new Set(listSourceFiles(afterRoot))
|
|
97
|
+
const ids = [...new Set([...before, ...after])].sort((left, right) =>
|
|
98
|
+
left.localeCompare(right),
|
|
99
|
+
)
|
|
100
|
+
const parts = []
|
|
101
|
+
for (const fileId of ids) {
|
|
102
|
+
const beforePath = path.join(beforeRoot, fileId)
|
|
103
|
+
const afterPath = path.join(afterRoot, fileId)
|
|
104
|
+
const had = before.has(fileId)
|
|
105
|
+
const has = after.has(fileId)
|
|
106
|
+
if (!had && has) {
|
|
107
|
+
parts.push(fileAsAddPatch(fileId, fs.readFileSync(afterPath, 'utf8')))
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
if (had && !has) {
|
|
111
|
+
parts.push(fileAsDeletePatch(fileId, fs.readFileSync(beforePath, 'utf8')))
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
const beforeText = fs.readFileSync(beforePath, 'utf8')
|
|
115
|
+
const afterText = fs.readFileSync(afterPath, 'utf8')
|
|
116
|
+
if (beforeText === afterText) continue
|
|
117
|
+
const patch = gitFileDiff(beforePath, afterPath, fileId)
|
|
118
|
+
if (patch) parts.push(patch)
|
|
119
|
+
}
|
|
120
|
+
return parts.join('\n')
|
|
121
|
+
}
|