gyoshu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1039 -0
- package/README.ja.md +390 -0
- package/README.ko.md +385 -0
- package/README.md +459 -0
- package/README.zh.md +383 -0
- package/bin/gyoshu.js +295 -0
- package/install.sh +241 -0
- package/package.json +65 -0
- package/src/agent/baksa.md +494 -0
- package/src/agent/executor.md +1851 -0
- package/src/agent/gyoshu.md +2351 -0
- package/src/agent/jogyo-feedback.md +137 -0
- package/src/agent/jogyo-insight.md +359 -0
- package/src/agent/jogyo-paper-writer.md +370 -0
- package/src/agent/jogyo.md +1445 -0
- package/src/agent/plan-reviewer.md +1862 -0
- package/src/agent/plan.md +97 -0
- package/src/agent/task-orchestrator.md +1121 -0
- package/src/bridge/gyoshu_bridge.py +782 -0
- package/src/command/analyze-knowledge.md +840 -0
- package/src/command/analyze-plans.md +513 -0
- package/src/command/execute.md +893 -0
- package/src/command/generate-policy.md +924 -0
- package/src/command/generate-suggestions.md +1111 -0
- package/src/command/gyoshu-auto.md +258 -0
- package/src/command/gyoshu.md +1352 -0
- package/src/command/learn.md +1181 -0
- package/src/command/planner.md +630 -0
- package/src/lib/artifact-security.ts +159 -0
- package/src/lib/atomic-write.ts +107 -0
- package/src/lib/cell-identity.ts +176 -0
- package/src/lib/checkpoint-schema.ts +455 -0
- package/src/lib/environment-capture.ts +181 -0
- package/src/lib/filesystem-check.ts +84 -0
- package/src/lib/literature-client.ts +1048 -0
- package/src/lib/marker-parser.ts +474 -0
- package/src/lib/notebook-frontmatter.ts +835 -0
- package/src/lib/paths.ts +799 -0
- package/src/lib/pdf-export.ts +340 -0
- package/src/lib/quality-gates.ts +369 -0
- package/src/lib/readme-index.ts +462 -0
- package/src/lib/report-markdown.ts +870 -0
- package/src/lib/session-lock.ts +411 -0
- package/src/plugin/gyoshu-hooks.ts +140 -0
- package/src/skill/data-analysis/SKILL.md +369 -0
- package/src/skill/experiment-design/SKILL.md +374 -0
- package/src/skill/ml-rigor/SKILL.md +672 -0
- package/src/skill/scientific-method/SKILL.md +331 -0
- package/src/tool/checkpoint-manager.ts +1387 -0
- package/src/tool/gyoshu-completion.ts +493 -0
- package/src/tool/gyoshu-snapshot.ts +745 -0
- package/src/tool/literature-search.ts +389 -0
- package/src/tool/migration-tool.ts +1404 -0
- package/src/tool/notebook-search.ts +794 -0
- package/src/tool/notebook-writer.ts +391 -0
- package/src/tool/python-repl.ts +1038 -0
- package/src/tool/research-manager.ts +1494 -0
- package/src/tool/retrospective-store.ts +347 -0
- package/src/tool/session-manager.ts +565 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Manager - OpenCode tool for managing Gyoshu runtime sessions
|
|
3
|
+
*
|
|
4
|
+
* Provides runtime-only session management with:
|
|
5
|
+
* - Session locking (acquire/release)
|
|
6
|
+
* - Bridge socket paths
|
|
7
|
+
* - Bridge metadata storage (runtime state only)
|
|
8
|
+
*
|
|
9
|
+
* Note: Durable research data is now stored in notebook frontmatter.
|
|
10
|
+
* This tool only manages ephemeral runtime state in OS temp directories
|
|
11
|
+
* (see paths.ts getRuntimeDir() for resolution order).
|
|
12
|
+
*
|
|
13
|
+
* @module session-manager
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { tool } from "@opencode-ai/plugin";
|
|
17
|
+
import * as fs from "fs/promises";
|
|
18
|
+
import * as fsSync from "fs";
|
|
19
|
+
import * as path from "path";
|
|
20
|
+
import { durableAtomicWrite, fileExists, readFile } from "../lib/atomic-write";
|
|
21
|
+
import {
|
|
22
|
+
getRuntimeDir,
|
|
23
|
+
getSessionDir,
|
|
24
|
+
ensureDirSync,
|
|
25
|
+
existsSync,
|
|
26
|
+
} from "../lib/paths";
|
|
27
|
+
|
|
28
|
+
// ===== CONSTANTS =====
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Name of the bridge metadata file (runtime state only)
|
|
32
|
+
*/
|
|
33
|
+
const BRIDGE_META_FILE = "bridge_meta.json";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Name of the session lock file
|
|
37
|
+
*/
|
|
38
|
+
const SESSION_LOCK_FILE = "session.lock";
|
|
39
|
+
|
|
40
|
+
// ===== INTERFACES =====
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Python environment info for runtime tracking
|
|
44
|
+
*/
|
|
45
|
+
interface PythonEnvInfo {
|
|
46
|
+
/** Environment type (venv, uv, poetry, conda, etc.) */
|
|
47
|
+
type: string;
|
|
48
|
+
/** Path to Python interpreter */
|
|
49
|
+
pythonPath: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A single verification round in the adversarial challenge loop.
|
|
54
|
+
* Tracks the outcome of each verification attempt by Baksa (critic agent).
|
|
55
|
+
*/
|
|
56
|
+
interface VerificationRound {
|
|
57
|
+
/** Round number (1, 2, 3, ...) */
|
|
58
|
+
round: number;
|
|
59
|
+
/** ISO 8601 timestamp of verification attempt */
|
|
60
|
+
timestamp: string;
|
|
61
|
+
/** Trust score from 0-100 calculated by Baksa (critic agent) */
|
|
62
|
+
trustScore: number;
|
|
63
|
+
/** Outcome of this verification round */
|
|
64
|
+
outcome: "passed" | "failed" | "rework_requested";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Verification state for adversarial challenge loops.
|
|
69
|
+
* Tracks the current verification round and history of all attempts.
|
|
70
|
+
*/
|
|
71
|
+
interface VerificationState {
|
|
72
|
+
/** Current verification round. 0 = not started, 1+ = active rounds */
|
|
73
|
+
currentRound: number;
|
|
74
|
+
/** Maximum allowed verification rounds before escalation (default: 3) */
|
|
75
|
+
maxRounds: number;
|
|
76
|
+
/** History of verification rounds (rounds are 1-indexed) */
|
|
77
|
+
history: VerificationRound[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Bridge metadata - lightweight runtime state only.
|
|
82
|
+
* This is NOT durable research data - just ephemeral session state.
|
|
83
|
+
*/
|
|
84
|
+
interface BridgeMeta {
|
|
85
|
+
/** Unique session identifier */
|
|
86
|
+
sessionId: string;
|
|
87
|
+
/** ISO 8601 timestamp when bridge was started */
|
|
88
|
+
bridgeStarted: string;
|
|
89
|
+
/** Python environment information */
|
|
90
|
+
pythonEnv: PythonEnvInfo;
|
|
91
|
+
/** Path to the notebook being edited */
|
|
92
|
+
notebookPath: string;
|
|
93
|
+
/** Human-readable report title for display purposes */
|
|
94
|
+
reportTitle?: string;
|
|
95
|
+
/** Adversarial verification state for challenge loops (optional, runtime only) */
|
|
96
|
+
verification?: VerificationState;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ===== RUNTIME INITIALIZATION =====
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Ensures the runtime directory exists.
|
|
103
|
+
* Runtime is now in OS temp directories, no .gitignore needed.
|
|
104
|
+
*/
|
|
105
|
+
async function ensureGyoshuRuntime(): Promise<void> {
|
|
106
|
+
const runtimeDir = getRuntimeDir();
|
|
107
|
+
await fs.mkdir(runtimeDir, { recursive: true });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Synchronous version for initialization in execute()
|
|
112
|
+
*/
|
|
113
|
+
function ensureGyoshuRuntimeSync(): void {
|
|
114
|
+
const runtimeDir = getRuntimeDir();
|
|
115
|
+
ensureDirSync(runtimeDir);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ===== PATH HELPERS =====
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Gets the path to a session's bridge metadata file.
|
|
122
|
+
*
|
|
123
|
+
* @param sessionId - The session identifier
|
|
124
|
+
* @returns Full path to bridge_meta.json
|
|
125
|
+
*/
|
|
126
|
+
function getBridgeMetaPath(sessionId: string): string {
|
|
127
|
+
return path.join(getSessionDir(sessionId), BRIDGE_META_FILE);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Gets the path to a session's lock file.
|
|
132
|
+
*
|
|
133
|
+
* @param sessionId - The session identifier
|
|
134
|
+
* @returns Full path to session.lock
|
|
135
|
+
*/
|
|
136
|
+
function getSessionLockFilePath(sessionId: string): string {
|
|
137
|
+
return path.join(getSessionDir(sessionId), SESSION_LOCK_FILE);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ===== VALIDATION =====
|
|
141
|
+
|
|
142
|
+
/** Maximum number of verification history entries to keep */
|
|
143
|
+
const MAX_VERIFICATION_HISTORY = 10;
|
|
144
|
+
|
|
145
|
+
/** Valid outcomes for verification rounds */
|
|
146
|
+
const VALID_OUTCOMES = ["passed", "failed", "rework_requested"] as const;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Validates a VerificationRound object.
|
|
150
|
+
*
|
|
151
|
+
* @param round - The verification round to validate
|
|
152
|
+
* @param index - Index in history array (for error messages)
|
|
153
|
+
* @returns Error message if invalid, null if valid
|
|
154
|
+
*/
|
|
155
|
+
function validateVerificationRound(
|
|
156
|
+
round: unknown,
|
|
157
|
+
index: number
|
|
158
|
+
): string | null {
|
|
159
|
+
if (!round || typeof round !== "object") {
|
|
160
|
+
return `history[${index}] is not an object`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const r = round as Record<string, unknown>;
|
|
164
|
+
|
|
165
|
+
// Validate round number
|
|
166
|
+
if (typeof r.round !== "number" || !Number.isInteger(r.round) || r.round < 1) {
|
|
167
|
+
return `history[${index}].round must be a positive integer`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Validate timestamp
|
|
171
|
+
if (typeof r.timestamp !== "string" || r.timestamp.trim() === "") {
|
|
172
|
+
return `history[${index}].timestamp must be a non-empty string`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Validate trustScore (0-100)
|
|
176
|
+
if (
|
|
177
|
+
typeof r.trustScore !== "number" ||
|
|
178
|
+
r.trustScore < 0 ||
|
|
179
|
+
r.trustScore > 100
|
|
180
|
+
) {
|
|
181
|
+
return `history[${index}].trustScore must be a number between 0 and 100`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Validate outcome
|
|
185
|
+
if (!VALID_OUTCOMES.includes(r.outcome as typeof VALID_OUTCOMES[number])) {
|
|
186
|
+
return `history[${index}].outcome must be one of: ${VALID_OUTCOMES.join(", ")}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Validates a VerificationState object.
|
|
194
|
+
*
|
|
195
|
+
* @param state - The verification state to validate
|
|
196
|
+
* @returns Error message if invalid, null if valid
|
|
197
|
+
*/
|
|
198
|
+
function validateVerificationState(state: unknown): string | null {
|
|
199
|
+
if (!state || typeof state !== "object") {
|
|
200
|
+
return "verification must be an object";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const s = state as Record<string, unknown>;
|
|
204
|
+
|
|
205
|
+
// Validate currentRound (non-negative integer)
|
|
206
|
+
if (
|
|
207
|
+
typeof s.currentRound !== "number" ||
|
|
208
|
+
!Number.isInteger(s.currentRound) ||
|
|
209
|
+
s.currentRound < 0
|
|
210
|
+
) {
|
|
211
|
+
return "currentRound must be a non-negative integer";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Validate maxRounds (positive integer >= 1)
|
|
215
|
+
if (
|
|
216
|
+
typeof s.maxRounds !== "number" ||
|
|
217
|
+
!Number.isInteger(s.maxRounds) ||
|
|
218
|
+
s.maxRounds < 1
|
|
219
|
+
) {
|
|
220
|
+
return "maxRounds must be a positive integer >= 1";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Validate currentRound <= maxRounds
|
|
224
|
+
if (s.currentRound > s.maxRounds) {
|
|
225
|
+
return `currentRound (${s.currentRound}) cannot exceed maxRounds (${s.maxRounds})`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Validate history is an array
|
|
229
|
+
if (!Array.isArray(s.history)) {
|
|
230
|
+
return "history must be an array";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Validate each history entry
|
|
234
|
+
for (let i = 0; i < s.history.length; i++) {
|
|
235
|
+
const error = validateVerificationRound(s.history[i], i);
|
|
236
|
+
if (error) {
|
|
237
|
+
return error;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Validates that a session ID is safe to use in file paths.
|
|
246
|
+
* Prevents directory traversal and other path injection attacks.
|
|
247
|
+
*
|
|
248
|
+
* @param sessionId - The session ID to validate
|
|
249
|
+
* @throws Error if session ID is invalid
|
|
250
|
+
*/
|
|
251
|
+
function validateSessionId(sessionId: string): void {
|
|
252
|
+
if (!sessionId || typeof sessionId !== "string") {
|
|
253
|
+
throw new Error("researchSessionID is required and must be a string");
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (sessionId.includes("..") || sessionId.includes("/") || sessionId.includes("\\")) {
|
|
257
|
+
throw new Error("Invalid researchSessionID: contains path traversal characters");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (sessionId.trim().length === 0) {
|
|
261
|
+
throw new Error("Invalid researchSessionID: cannot be empty or whitespace");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (sessionId.length > 255) {
|
|
265
|
+
throw new Error("Invalid researchSessionID: exceeds maximum length of 255 characters");
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ===== DEFAULT BRIDGE META =====
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Creates a new bridge metadata object with default values.
|
|
273
|
+
*
|
|
274
|
+
* @param sessionId - The session identifier
|
|
275
|
+
* @param data - Optional initial data to merge
|
|
276
|
+
* @returns A new BridgeMeta object
|
|
277
|
+
*/
|
|
278
|
+
function createDefaultBridgeMeta(
|
|
279
|
+
sessionId: string,
|
|
280
|
+
data?: Partial<BridgeMeta>
|
|
281
|
+
): BridgeMeta {
|
|
282
|
+
const now = new Date().toISOString();
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
sessionId,
|
|
286
|
+
bridgeStarted: now,
|
|
287
|
+
pythonEnv: {
|
|
288
|
+
type: "unknown",
|
|
289
|
+
pythonPath: "",
|
|
290
|
+
},
|
|
291
|
+
notebookPath: "",
|
|
292
|
+
reportTitle: "",
|
|
293
|
+
...data,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ===== TOOL DEFINITION =====
|
|
298
|
+
|
|
299
|
+
export default tool({
|
|
300
|
+
description:
|
|
301
|
+
"Manage Gyoshu runtime sessions - create, read, update, delete session state. " +
|
|
302
|
+
"Sessions track bridge metadata, notebook paths, and runtime state. " +
|
|
303
|
+
"Research data is now stored in notebook frontmatter (not session manifests).",
|
|
304
|
+
args: {
|
|
305
|
+
action: tool.schema
|
|
306
|
+
.enum(["create", "get", "list", "update", "delete"])
|
|
307
|
+
.describe("Operation to perform on runtime sessions"),
|
|
308
|
+
researchSessionID: tool.schema
|
|
309
|
+
.string()
|
|
310
|
+
.optional()
|
|
311
|
+
.describe("Unique session identifier (required for create/get/update/delete)"),
|
|
312
|
+
data: tool.schema
|
|
313
|
+
.any()
|
|
314
|
+
.optional()
|
|
315
|
+
.describe(
|
|
316
|
+
"Bridge metadata for create/update operations. Can include: " +
|
|
317
|
+
"pythonEnv (type, pythonPath), notebookPath, reportTitle, " +
|
|
318
|
+
"verification (currentRound, maxRounds, history)"
|
|
319
|
+
),
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
async execute(args) {
|
|
323
|
+
ensureGyoshuRuntimeSync();
|
|
324
|
+
|
|
325
|
+
switch (args.action) {
|
|
326
|
+
// ===== CREATE =====
|
|
327
|
+
case "create": {
|
|
328
|
+
if (!args.researchSessionID) {
|
|
329
|
+
throw new Error("researchSessionID is required for create action");
|
|
330
|
+
}
|
|
331
|
+
validateSessionId(args.researchSessionID);
|
|
332
|
+
|
|
333
|
+
const sessionDir = getSessionDir(args.researchSessionID);
|
|
334
|
+
const bridgeMetaPath = getBridgeMetaPath(args.researchSessionID);
|
|
335
|
+
|
|
336
|
+
if (await fileExists(bridgeMetaPath)) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
`Session '${args.researchSessionID}' already exists. Use 'update' to modify existing sessions.`
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
343
|
+
|
|
344
|
+
const bridgeMeta = createDefaultBridgeMeta(
|
|
345
|
+
args.researchSessionID,
|
|
346
|
+
args.data as Partial<BridgeMeta>
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
await durableAtomicWrite(bridgeMetaPath, JSON.stringify(bridgeMeta, null, 2));
|
|
350
|
+
|
|
351
|
+
return JSON.stringify(
|
|
352
|
+
{
|
|
353
|
+
success: true,
|
|
354
|
+
action: "create",
|
|
355
|
+
researchSessionID: args.researchSessionID,
|
|
356
|
+
bridgeMeta,
|
|
357
|
+
sessionDir,
|
|
358
|
+
},
|
|
359
|
+
null,
|
|
360
|
+
2
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ===== GET =====
|
|
365
|
+
case "get": {
|
|
366
|
+
if (!args.researchSessionID) {
|
|
367
|
+
throw new Error("researchSessionID is required for get action");
|
|
368
|
+
}
|
|
369
|
+
validateSessionId(args.researchSessionID);
|
|
370
|
+
|
|
371
|
+
const bridgeMetaPath = getBridgeMetaPath(args.researchSessionID);
|
|
372
|
+
const sessionDir = getSessionDir(args.researchSessionID);
|
|
373
|
+
const lockPath = getSessionLockFilePath(args.researchSessionID);
|
|
374
|
+
|
|
375
|
+
if (!(await fileExists(bridgeMetaPath))) {
|
|
376
|
+
throw new Error(`Session '${args.researchSessionID}' not found`);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const bridgeMeta = await readFile<BridgeMeta>(bridgeMetaPath, true);
|
|
380
|
+
const isLocked = await fileExists(lockPath);
|
|
381
|
+
|
|
382
|
+
return JSON.stringify(
|
|
383
|
+
{
|
|
384
|
+
success: true,
|
|
385
|
+
action: "get",
|
|
386
|
+
researchSessionID: args.researchSessionID,
|
|
387
|
+
bridgeMeta,
|
|
388
|
+
sessionDir,
|
|
389
|
+
isLocked,
|
|
390
|
+
},
|
|
391
|
+
null,
|
|
392
|
+
2
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ===== LIST =====
|
|
397
|
+
case "list": {
|
|
398
|
+
const sessions: Array<{
|
|
399
|
+
researchSessionID: string;
|
|
400
|
+
bridgeStarted: string;
|
|
401
|
+
notebookPath: string;
|
|
402
|
+
reportTitle: string;
|
|
403
|
+
isLocked: boolean;
|
|
404
|
+
}> = [];
|
|
405
|
+
|
|
406
|
+
const runtimeDir = getRuntimeDir();
|
|
407
|
+
|
|
408
|
+
let entries: Array<{ name: string; isDirectory: () => boolean }>;
|
|
409
|
+
try {
|
|
410
|
+
entries = await fs.readdir(runtimeDir, { withFileTypes: true });
|
|
411
|
+
} catch (err: any) {
|
|
412
|
+
if (err.code === "ENOENT") {
|
|
413
|
+
return JSON.stringify(
|
|
414
|
+
{
|
|
415
|
+
success: true,
|
|
416
|
+
action: "list",
|
|
417
|
+
sessions: [],
|
|
418
|
+
count: 0,
|
|
419
|
+
},
|
|
420
|
+
null,
|
|
421
|
+
2
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
throw err;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
for (const entry of entries) {
|
|
428
|
+
if (!entry.isDirectory()) continue;
|
|
429
|
+
|
|
430
|
+
const bridgeMetaPath = path.join(runtimeDir, entry.name, BRIDGE_META_FILE);
|
|
431
|
+
const lockPath = path.join(runtimeDir, entry.name, SESSION_LOCK_FILE);
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const bridgeMeta = await readFile<BridgeMeta>(bridgeMetaPath, true);
|
|
435
|
+
const isLocked = existsSync(lockPath);
|
|
436
|
+
|
|
437
|
+
sessions.push({
|
|
438
|
+
researchSessionID: bridgeMeta.sessionId,
|
|
439
|
+
bridgeStarted: bridgeMeta.bridgeStarted,
|
|
440
|
+
notebookPath: bridgeMeta.notebookPath,
|
|
441
|
+
reportTitle: bridgeMeta.reportTitle || "",
|
|
442
|
+
isLocked,
|
|
443
|
+
});
|
|
444
|
+
} catch (error) {
|
|
445
|
+
// Log error but continue listing other sessions
|
|
446
|
+
console.error(`[session-manager] Failed to read session ${entry.name}:`, error);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
sessions.sort(
|
|
451
|
+
(a, b) =>
|
|
452
|
+
new Date(b.bridgeStarted).getTime() - new Date(a.bridgeStarted).getTime()
|
|
453
|
+
);
|
|
454
|
+
|
|
455
|
+
return JSON.stringify(
|
|
456
|
+
{
|
|
457
|
+
success: true,
|
|
458
|
+
action: "list",
|
|
459
|
+
sessions,
|
|
460
|
+
count: sessions.length,
|
|
461
|
+
},
|
|
462
|
+
null,
|
|
463
|
+
2
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ===== UPDATE =====
|
|
468
|
+
case "update": {
|
|
469
|
+
if (!args.researchSessionID) {
|
|
470
|
+
throw new Error("researchSessionID is required for update action");
|
|
471
|
+
}
|
|
472
|
+
validateSessionId(args.researchSessionID);
|
|
473
|
+
|
|
474
|
+
const bridgeMetaPath = getBridgeMetaPath(args.researchSessionID);
|
|
475
|
+
|
|
476
|
+
if (!(await fileExists(bridgeMetaPath))) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`Session '${args.researchSessionID}' not found. Use 'create' first.`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const existing = await readFile<BridgeMeta>(bridgeMetaPath, true);
|
|
483
|
+
const updateData = args.data as Partial<BridgeMeta> | undefined;
|
|
484
|
+
|
|
485
|
+
let sanitizedVerification: VerificationState | undefined = undefined;
|
|
486
|
+
if (updateData?.verification !== undefined) {
|
|
487
|
+
const validationError = validateVerificationState(updateData.verification);
|
|
488
|
+
if (validationError) {
|
|
489
|
+
throw new Error(`Invalid verification state: ${validationError}`);
|
|
490
|
+
}
|
|
491
|
+
const verif = updateData.verification as VerificationState;
|
|
492
|
+
sanitizedVerification = {
|
|
493
|
+
...verif,
|
|
494
|
+
history: verif.history.slice(-MAX_VERIFICATION_HISTORY),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const updated: BridgeMeta = {
|
|
499
|
+
...existing,
|
|
500
|
+
...(updateData?.notebookPath !== undefined && {
|
|
501
|
+
notebookPath: updateData.notebookPath,
|
|
502
|
+
}),
|
|
503
|
+
...(updateData?.reportTitle !== undefined && {
|
|
504
|
+
reportTitle: updateData.reportTitle,
|
|
505
|
+
}),
|
|
506
|
+
...(sanitizedVerification !== undefined && {
|
|
507
|
+
verification: sanitizedVerification,
|
|
508
|
+
}),
|
|
509
|
+
sessionId: existing.sessionId,
|
|
510
|
+
bridgeStarted: existing.bridgeStarted,
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
if (updateData?.pythonEnv) {
|
|
514
|
+
updated.pythonEnv = {
|
|
515
|
+
...existing.pythonEnv,
|
|
516
|
+
...updateData.pythonEnv,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
await durableAtomicWrite(bridgeMetaPath, JSON.stringify(updated, null, 2));
|
|
521
|
+
|
|
522
|
+
return JSON.stringify(
|
|
523
|
+
{
|
|
524
|
+
success: true,
|
|
525
|
+
action: "update",
|
|
526
|
+
researchSessionID: args.researchSessionID,
|
|
527
|
+
bridgeMeta: updated,
|
|
528
|
+
},
|
|
529
|
+
null,
|
|
530
|
+
2
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ===== DELETE =====
|
|
535
|
+
case "delete": {
|
|
536
|
+
if (!args.researchSessionID) {
|
|
537
|
+
throw new Error("researchSessionID is required for delete action");
|
|
538
|
+
}
|
|
539
|
+
validateSessionId(args.researchSessionID);
|
|
540
|
+
|
|
541
|
+
const sessionDir = getSessionDir(args.researchSessionID);
|
|
542
|
+
|
|
543
|
+
if (!(await fileExists(sessionDir))) {
|
|
544
|
+
throw new Error(`Session '${args.researchSessionID}' not found`);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
await fs.rm(sessionDir, { recursive: true, force: true });
|
|
548
|
+
|
|
549
|
+
return JSON.stringify(
|
|
550
|
+
{
|
|
551
|
+
success: true,
|
|
552
|
+
action: "delete",
|
|
553
|
+
researchSessionID: args.researchSessionID,
|
|
554
|
+
message: `Session '${args.researchSessionID}' and all runtime data deleted`,
|
|
555
|
+
},
|
|
556
|
+
null,
|
|
557
|
+
2
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
default:
|
|
562
|
+
throw new Error(`Unknown action: ${args.action}`);
|
|
563
|
+
}
|
|
564
|
+
},
|
|
565
|
+
});
|