@beremaran/godot-agent-loop 1.0.1 → 1.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/README.md +59 -34
- package/addons/godot_agent_loop/README.md +26 -16
- package/addons/godot_agent_loop/plugin.cfg +3 -2
- package/addons/godot_agent_loop/plugin.gd +840 -25
- package/agent-plugin/.claude-plugin/plugin.json +1 -1
- package/agent-plugin/.codex-plugin/plugin.json +1 -1
- package/agent-plugin/.mcp.json +1 -1
- package/agent-plugin/adapter-manifest.json +3 -3
- package/agent-plugin/pi/extension.ts +1 -1
- package/agent-plugin/skills/build-godot-game/SKILL.md +26 -5
- package/agent-plugin/skills/debug-godot-game/SKILL.md +20 -7
- package/agent-plugin/skills/ship-godot-game/SKILL.md +13 -3
- package/agent-plugin/skills/verify-godot-change/SKILL.md +17 -2
- package/build/authoring-session-manager.js +24 -1
- package/build/domain-tool-registries.js +5 -1
- package/build/editor-authoring-router.js +228 -0
- package/build/editor-bridge-protocol.js +2 -1
- package/build/editor-connection.js +29 -24
- package/build/editor-mutation-guard.js +3 -1
- package/build/editor-plugin-installer.js +2 -1
- package/build/editor-session-registry.js +392 -0
- package/build/editor-sync-queue.js +135 -0
- package/build/index.js +208 -29
- package/build/lifecycle-trace.js +80 -0
- package/build/scripts/mcp_editor_plugin.gd +840 -25
- package/build/scripts/mcp_interaction_server.gd +76 -2
- package/build/scripts/mcp_runtime/core_domain.gd +2 -1
- package/build/scripts/mcp_runtime/system_domain.gd +2 -0
- package/build/server-instructions.js +5 -5
- package/build/session-timing.js +17 -1
- package/build/tool-definitions.js +99 -6
- package/build/tool-handlers/game-tool-handlers.js +21 -4
- package/build/tool-handlers/lifecycle-tool-handlers.js +361 -27
- package/build/tool-handlers/project-handler-services.js +45 -6
- package/build/tool-handlers/project-tool-handlers.js +4 -4
- package/build/tool-manifest.js +29 -1
- package/build/tool-mutation-policy.js +1 -0
- package/build/tool-surface.js +4 -4
- package/build/utils.js +2 -2
- package/package.json +4 -3
- package/product.json +7 -5
package/build/index.js
CHANGED
|
@@ -27,7 +27,9 @@ import { GameToolHandlers } from './tool-handlers/game-tool-handlers.js';
|
|
|
27
27
|
import { ProjectToolHandlers } from './tool-handlers/project-tool-handlers.js';
|
|
28
28
|
import { LifecycleToolHandlers } from './tool-handlers/lifecycle-tool-handlers.js';
|
|
29
29
|
import { ProjectSupport } from './project-support.js';
|
|
30
|
-
import {
|
|
30
|
+
import { canonicalProjectPath, EditorSessionRegistry } from './editor-session-registry.js';
|
|
31
|
+
import { EditorSyncQueue } from './editor-sync-queue.js';
|
|
32
|
+
import { EditorAuthoringRouter } from './editor-authoring-router.js';
|
|
31
33
|
import { EditorPluginInstaller } from './editor-plugin-installer.js';
|
|
32
34
|
import { PRIVILEGED_RUNTIME_GROUPS } from './runtime-protocol.js';
|
|
33
35
|
import { AuthoringSessionManager } from './authoring-session-manager.js';
|
|
@@ -35,6 +37,9 @@ import { EditorMutationGuard } from './editor-mutation-guard.js';
|
|
|
35
37
|
import { SERVER_INSTRUCTIONS } from './server-instructions.js';
|
|
36
38
|
import { advertisedToolDefinitions } from './tool-surface.js';
|
|
37
39
|
import { runOpenCodeSetup } from './opencode-setup.js';
|
|
40
|
+
import { LifecycleTrace } from './lifecycle-trace.js';
|
|
41
|
+
import { toolManifest } from './tool-manifest.js';
|
|
42
|
+
import { isToolCallMutating } from './tool-mutation-policy.js';
|
|
38
43
|
// Check if debug mode is enabled
|
|
39
44
|
const DEBUG_MODE = process.env.DEBUG === 'true';
|
|
40
45
|
const ALLOW_PRIVILEGED_COMMANDS = process.env.GODOT_MCP_ALLOW_PRIVILEGED_COMMANDS === 'true';
|
|
@@ -66,14 +71,16 @@ function resolveRuntimePort() {
|
|
|
66
71
|
}
|
|
67
72
|
return parsed;
|
|
68
73
|
}
|
|
69
|
-
function resolveEditorPort() {
|
|
70
|
-
const configured = process.env.GODOT_MCP_EDITOR_PORT;
|
|
71
|
-
if (!configured)
|
|
72
|
-
return 9091;
|
|
73
|
-
const parsed = Number(configured);
|
|
74
|
-
return Number.isInteger(parsed) && parsed > 0 && parsed < 65536 ? parsed : 9091;
|
|
75
|
-
}
|
|
76
74
|
const pathSecurity = new PathSecurity();
|
|
75
|
+
function firstString(...values) {
|
|
76
|
+
return values.find((value) => typeof value === 'string' && value.length > 0);
|
|
77
|
+
}
|
|
78
|
+
function toResourcePath(path) {
|
|
79
|
+
return path.startsWith('res://') ? path : `res://${path.replace(/^\/+/, '')}`;
|
|
80
|
+
}
|
|
81
|
+
function normalizeFocusPath(path) {
|
|
82
|
+
return path.replace(/^\/?root\/?/, '').replace(/^\.\//, '') || '.';
|
|
83
|
+
}
|
|
77
84
|
// Derive __filename and __dirname in ESM
|
|
78
85
|
const __filename = fileURLToPath(import.meta.url);
|
|
79
86
|
const __dirname = dirname(__filename);
|
|
@@ -113,14 +120,44 @@ export class GodotServer {
|
|
|
113
120
|
authoringSession;
|
|
114
121
|
headlessOperations;
|
|
115
122
|
gameCommands;
|
|
116
|
-
|
|
117
|
-
|
|
123
|
+
attachedEditorProjects = new Set();
|
|
124
|
+
lifecycleTrace = new LifecycleTrace({
|
|
125
|
+
onEvent: (projectPath, event) => {
|
|
126
|
+
if (!this.attachedEditorProjects.has(projectPath))
|
|
127
|
+
return;
|
|
128
|
+
void this.editorSessions.send(projectPath, 'activity', event, 1_000).catch(error => {
|
|
129
|
+
this.logDebug(`Editor trace forwarding failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
editorSessions = new EditorSessionRegistry({
|
|
134
|
+
serverVersion: SERVER_VERSION,
|
|
135
|
+
log: message => { this.logDebug(message); },
|
|
136
|
+
onStateChange: session => {
|
|
137
|
+
if (session.connected) {
|
|
138
|
+
const wasAttached = this.attachedEditorProjects.has(session.project_path);
|
|
139
|
+
this.attachedEditorProjects.add(session.project_path);
|
|
140
|
+
if (!wasAttached)
|
|
141
|
+
void this.replayEditorTrace(session.project_path);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
this.attachedEditorProjects.delete(session.project_path);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
editorSync = new EditorSyncQueue({
|
|
149
|
+
send: (projectPath, params, timeoutMs) => this.editorSessions.send(projectPath, 'filesystem_changed', params, timeoutMs),
|
|
150
|
+
status: projectPath => this.editorSessions.status(projectPath),
|
|
118
151
|
});
|
|
119
|
-
|
|
152
|
+
editorAuthoring = new EditorAuthoringRouter({
|
|
153
|
+
status: projectPath => this.editorSessions.status(projectPath),
|
|
154
|
+
ensure: (projectPath, timeoutMs) => this.editorSessions.ensure(projectPath, timeoutMs),
|
|
155
|
+
send: (projectPath, command, params, timeoutMs) => this.editorSessions.send(projectPath, command, params, timeoutMs),
|
|
156
|
+
});
|
|
157
|
+
editorMutationGuard = new EditorMutationGuard((projectPath, command, params, timeoutMs) => this.editorSessions.send(projectPath, command, params, timeoutMs));
|
|
120
158
|
toolRegistry = null;
|
|
121
159
|
editorPluginInstaller;
|
|
122
|
-
|
|
123
|
-
editorPluginInstallation = null;
|
|
160
|
+
editorPluginInstallations = new Map();
|
|
124
161
|
strictPathValidation = false;
|
|
125
162
|
tcpGameConnection = new GameConnection({
|
|
126
163
|
port: resolveRuntimePort(),
|
|
@@ -190,8 +227,9 @@ export class GodotServer {
|
|
|
190
227
|
canStart: () => this.activeProcess === null,
|
|
191
228
|
onLifecycleEvent: event => { this.forwardEditorActivity(event); },
|
|
192
229
|
onProjectWrite: event => {
|
|
193
|
-
|
|
230
|
+
return this.editorSync.enqueue(event);
|
|
194
231
|
},
|
|
232
|
+
tryEditorOperation: (command, params, projectPath) => this.editorAuthoring.tryExecute(command, params, projectPath),
|
|
195
233
|
});
|
|
196
234
|
this.headlessOperations = new HeadlessOperationService(this.operationRunner, pathSecurity, this.authoringSession);
|
|
197
235
|
this.gameCommands = new GameCommandService(this.processManager, this.gameConnection);
|
|
@@ -238,17 +276,34 @@ export class GodotServer {
|
|
|
238
276
|
getInteractionPort: () => this.gameConnection.interactionPort,
|
|
239
277
|
getRuntimeEnvironment: () => ({ GODOT_MCP_RUNTIME_SECRET: RUNTIME_SECRET }),
|
|
240
278
|
installEditorPlugin: projectPath => {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
279
|
+
const previous = this.editorPluginInstallations.get(projectPath);
|
|
280
|
+
const installation = this.editorPluginInstaller.install(projectPath);
|
|
281
|
+
const cleanupInstallation = previous
|
|
282
|
+
&& previous.pluginName === installation.pluginName
|
|
283
|
+
&& previous.distribution === installation.distribution
|
|
284
|
+
&& previous.enabledByServer
|
|
285
|
+
? {
|
|
286
|
+
...installation,
|
|
287
|
+
enabledByServer: true,
|
|
288
|
+
projectBefore: previous.projectBefore,
|
|
289
|
+
}
|
|
290
|
+
: installation;
|
|
291
|
+
this.editorPluginInstallations.set(projectPath, cleanupInstallation);
|
|
292
|
+
return installation;
|
|
248
293
|
},
|
|
249
|
-
removeEditorPlugin: (projectPath, installation) => {
|
|
250
|
-
|
|
251
|
-
|
|
294
|
+
removeEditorPlugin: (projectPath, installation) => {
|
|
295
|
+
this.editorPluginInstaller.remove(projectPath, installation);
|
|
296
|
+
this.editorPluginInstallations.delete(projectPath);
|
|
297
|
+
},
|
|
298
|
+
getEditorEnvironment: () => ({
|
|
299
|
+
...(process.env.GODOT_MCP_EDITOR_START_PAUSED
|
|
300
|
+
? { GODOT_MCP_EDITOR_START_PAUSED: process.env.GODOT_MCP_EDITOR_START_PAUSED }
|
|
301
|
+
: {}),
|
|
302
|
+
}),
|
|
303
|
+
ensureEditorSession: (projectPath, timeoutMs) => this.editorSessions.ensure(projectPath, timeoutMs),
|
|
304
|
+
getEditorSessionStatus: projectPath => this.editorSessions.status(projectPath),
|
|
305
|
+
disconnectEditorSession: projectPath => this.editorSessions.disconnect(projectPath),
|
|
306
|
+
sendEditorCommand: (projectPath, command, params, timeoutMs) => this.editorSessions.send(projectPath, command, params, timeoutMs),
|
|
252
307
|
isGameConnected: () => this.gameConnection.isConnected,
|
|
253
308
|
sendGameCommand: (command, params, timeoutMs) => this.gameCommands.send(command, params, timeoutMs),
|
|
254
309
|
dispatchTool: (name, args) => {
|
|
@@ -288,7 +343,90 @@ export class GodotServer {
|
|
|
288
343
|
}
|
|
289
344
|
}
|
|
290
345
|
forwardEditorActivity(event) {
|
|
291
|
-
|
|
346
|
+
const activeProjectPath = this.gameConnection.connectedProjectPath ?? this.authoringSession.activeProjectPath;
|
|
347
|
+
if (!activeProjectPath)
|
|
348
|
+
return;
|
|
349
|
+
const projectPath = this.traceProjectPath(activeProjectPath);
|
|
350
|
+
this.lifecycleTrace.record(projectPath, {
|
|
351
|
+
correlation_id: event.correlation_id,
|
|
352
|
+
tool: event.command,
|
|
353
|
+
command: event.command,
|
|
354
|
+
target_backend: event.target,
|
|
355
|
+
phase: event.event === 'request_started' ? 'start' : 'finish',
|
|
356
|
+
outcome: event.event === 'request_started' ? 'running'
|
|
357
|
+
: event.event === 'request_timed_out' ? 'timeout'
|
|
358
|
+
: event.outcome === 'success' ? 'success' : 'failure',
|
|
359
|
+
duration_ms: event.duration_ms ?? 0,
|
|
360
|
+
source: 'automatic',
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
async replayEditorTrace(projectPath) {
|
|
364
|
+
for (const event of this.lifecycleTrace.events(projectPath)) {
|
|
365
|
+
try {
|
|
366
|
+
await this.editorSessions.send(projectPath, 'activity', { ...event, replayed: true }, 1_000);
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
this.logDebug(`Editor trace replay stopped: ${error instanceof Error ? error.message : String(error)}`);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
traceProjectPath(projectPath) {
|
|
375
|
+
try {
|
|
376
|
+
return canonicalProjectPath(projectPath);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return projectPath;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async synchronizePersistentToolMutation(toolName, args, projectPath, response) {
|
|
383
|
+
const nestedTool = toolName === 'godot_tools' && args.action === 'call' && typeof args.toolName === 'string'
|
|
384
|
+
? args.toolName
|
|
385
|
+
: toolName;
|
|
386
|
+
const nestedArgs = nestedTool === toolName
|
|
387
|
+
? args
|
|
388
|
+
: args.arguments && typeof args.arguments === 'object' ? args.arguments : {};
|
|
389
|
+
const manifest = toolManifest[nestedTool];
|
|
390
|
+
if (!manifest || manifest.domain !== 'project' || !isToolCallMutating(nestedTool, nestedArgs)
|
|
391
|
+
|| response.isError === true)
|
|
392
|
+
return response;
|
|
393
|
+
const existingText = response.content
|
|
394
|
+
.filter(item => item.type === 'text' && typeof item.text === 'string')
|
|
395
|
+
.map(item => String(item.text)).join('\n');
|
|
396
|
+
if (/"backend"\s*:\s*"editor"|"sync_status"\s*:/.test(existingText))
|
|
397
|
+
return response;
|
|
398
|
+
const sceneCandidate = firstString(nestedArgs.scenePath, nestedArgs.newPath, nestedArgs.filePath);
|
|
399
|
+
const scenePath = sceneCandidate && /\.tscn$/i.test(sceneCandidate) ? toResourcePath(sceneCandidate) : undefined;
|
|
400
|
+
const resourceCandidate = scenePath ? undefined : firstString(nestedArgs.resourcePath, nestedArgs.scriptPath, nestedArgs.shaderPath, nestedArgs.themePath, nestedArgs.texturePath, nestedArgs.translationPath, nestedArgs.newPath, nestedArgs.filePath);
|
|
401
|
+
const sync = await this.editorSync.enqueue({
|
|
402
|
+
project_path: this.traceProjectPath(projectPath),
|
|
403
|
+
command: nestedTool,
|
|
404
|
+
...(scenePath ? { scene_path: scenePath } : {}),
|
|
405
|
+
...(resourceCandidate ? { resource_path: toResourcePath(resourceCandidate) }
|
|
406
|
+
: { resource_path: 'res://project.godot' }),
|
|
407
|
+
...(typeof nestedArgs.nodePath === 'string' ? { focus_path: normalizeFocusPath(nestedArgs.nodePath) } : {}),
|
|
408
|
+
});
|
|
409
|
+
const backend = manifest.backend.kind === 'authoring-session' ? 'subprocess'
|
|
410
|
+
: manifest.backend.kind === 'local' ? 'file-backed'
|
|
411
|
+
: manifest.backend.kind;
|
|
412
|
+
const metadata = { backend, ...sync };
|
|
413
|
+
const content = response.content.map(item => {
|
|
414
|
+
if (item.type !== 'text' || typeof item.text !== 'string')
|
|
415
|
+
return item;
|
|
416
|
+
try {
|
|
417
|
+
const parsed = JSON.parse(item.text);
|
|
418
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
419
|
+
return { ...item, text: JSON.stringify({ ...parsed, ...metadata }, null, 2) };
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
catch { /* prose responses receive a separate bounded metadata block */ }
|
|
423
|
+
return item;
|
|
424
|
+
});
|
|
425
|
+
const merged = content.some(item => item.type === 'text' && typeof item.text === 'string'
|
|
426
|
+
&& /"sync_status"\s*:/.test(item.text));
|
|
427
|
+
if (!merged)
|
|
428
|
+
content.push({ type: 'text', text: `Mutation metadata: ${JSON.stringify(metadata)}` });
|
|
429
|
+
return { ...response, content };
|
|
292
430
|
}
|
|
293
431
|
/**
|
|
294
432
|
* Log debug messages if debug mode is enabled
|
|
@@ -376,9 +514,11 @@ export class GodotServer {
|
|
|
376
514
|
this.logDebug('Cleaning up resources');
|
|
377
515
|
this.authoringSession.stop();
|
|
378
516
|
this.disconnectFromGame();
|
|
379
|
-
this.
|
|
380
|
-
|
|
381
|
-
this.editorPluginInstaller.remove(
|
|
517
|
+
this.editorSessions.disconnectAll();
|
|
518
|
+
for (const [projectPath, installation] of this.editorPluginInstallations) {
|
|
519
|
+
this.editorPluginInstaller.remove(projectPath, installation);
|
|
520
|
+
}
|
|
521
|
+
this.editorPluginInstallations.clear();
|
|
382
522
|
if (this.gameConnection.connectedProjectPath) {
|
|
383
523
|
this.removeInteractionServer(this.gameConnection.connectedProjectPath);
|
|
384
524
|
this.gameConnection.clearConnectedProject();
|
|
@@ -416,7 +556,46 @@ export class GodotServer {
|
|
|
416
556
|
}));
|
|
417
557
|
this.server.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
418
558
|
this.logDebug(`Handling tool request: ${request.params.name}`);
|
|
419
|
-
|
|
559
|
+
const argumentsValue = request.params.arguments ?? {};
|
|
560
|
+
const nestedArguments = argumentsValue.arguments && typeof argumentsValue.arguments === 'object'
|
|
561
|
+
? argumentsValue.arguments
|
|
562
|
+
: null;
|
|
563
|
+
const explicitProject = typeof argumentsValue.projectPath === 'string'
|
|
564
|
+
? argumentsValue.projectPath
|
|
565
|
+
: typeof nestedArguments?.projectPath === 'string' ? nestedArguments.projectPath : null;
|
|
566
|
+
const projectPath = explicitProject
|
|
567
|
+
?? this.gameConnection.connectedProjectPath
|
|
568
|
+
?? this.authoringSession.activeProjectPath;
|
|
569
|
+
const toolName = request.params.name;
|
|
570
|
+
const manifest = toolManifest[toolName];
|
|
571
|
+
const span = projectPath
|
|
572
|
+
? this.lifecycleTrace.begin(this.traceProjectPath(projectPath), toolName, toolName, manifest.backend.kind)
|
|
573
|
+
: null;
|
|
574
|
+
try {
|
|
575
|
+
let response = await tools.dispatch(toolName, argumentsValue);
|
|
576
|
+
if (projectPath) {
|
|
577
|
+
response = await this.synchronizePersistentToolMutation(toolName, argumentsValue, projectPath, response);
|
|
578
|
+
}
|
|
579
|
+
if (span) {
|
|
580
|
+
const firstText = response.content.find(item => item.type === 'text' && 'text' in item);
|
|
581
|
+
const text = firstText && 'text' in firstText && typeof firstText.text === 'string' ? firstText.text : '';
|
|
582
|
+
let outcome = response.isError === true ? 'failure' : 'success';
|
|
583
|
+
if (/unsaved_conflict|"sync_status"\s*:\s*"conflict"/.test(text))
|
|
584
|
+
outcome = 'conflict';
|
|
585
|
+
else if (/"sync_status"\s*:\s*"(?:detached|failed)"|"fallback_reason"\s*:\s*(?!null\b)/.test(text)) {
|
|
586
|
+
outcome = 'fallback';
|
|
587
|
+
}
|
|
588
|
+
else if (/paused/i.test(text) && response.isError === true)
|
|
589
|
+
outcome = 'paused';
|
|
590
|
+
this.lifecycleTrace.finish(span, outcome, { is_error: response.isError === true });
|
|
591
|
+
}
|
|
592
|
+
return response;
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
if (span)
|
|
596
|
+
this.lifecycleTrace.finish(span, 'failure', { error: error instanceof Error ? error.message : String(error) });
|
|
597
|
+
throw error;
|
|
598
|
+
}
|
|
420
599
|
});
|
|
421
600
|
}
|
|
422
601
|
async run() {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
/** Bounded, correlated, per-project lifecycle evidence with secret-safe payloads. */
|
|
3
|
+
export class LifecycleTrace {
|
|
4
|
+
options;
|
|
5
|
+
buffers = new Map();
|
|
6
|
+
capacity;
|
|
7
|
+
now;
|
|
8
|
+
nextEventId;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
this.capacity = Math.max(200, options.capacity ?? 200);
|
|
12
|
+
this.now = options.now ?? Date.now;
|
|
13
|
+
this.nextEventId = Math.floor(this.now() * 1_000);
|
|
14
|
+
}
|
|
15
|
+
begin(projectPath, tool, command, backend) {
|
|
16
|
+
const span = {
|
|
17
|
+
projectPath, correlationId: randomUUID(), tool, command, backend, startedAt: this.now(),
|
|
18
|
+
};
|
|
19
|
+
this.record(projectPath, {
|
|
20
|
+
correlation_id: span.correlationId, tool, command, target_backend: backend,
|
|
21
|
+
phase: 'start', outcome: 'running', duration_ms: 0, source: 'agent',
|
|
22
|
+
});
|
|
23
|
+
return span;
|
|
24
|
+
}
|
|
25
|
+
finish(span, outcome, details) {
|
|
26
|
+
return this.record(span.projectPath, {
|
|
27
|
+
correlation_id: span.correlationId, tool: span.tool, command: span.command,
|
|
28
|
+
target_backend: span.backend, phase: 'finish', outcome,
|
|
29
|
+
duration_ms: Math.max(0, this.now() - span.startedAt), source: 'agent',
|
|
30
|
+
...(details === undefined ? {} : { details }),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
record(projectPath, event) {
|
|
34
|
+
const safe = redactAndBound(event);
|
|
35
|
+
const full = {
|
|
36
|
+
...safe,
|
|
37
|
+
event_id: this.nextEventId++,
|
|
38
|
+
timestamp: new Date(this.now()).toISOString(),
|
|
39
|
+
};
|
|
40
|
+
const buffer = this.buffers.get(projectPath) ?? [];
|
|
41
|
+
buffer.push(full);
|
|
42
|
+
if (buffer.length > this.capacity)
|
|
43
|
+
buffer.splice(0, buffer.length - this.capacity);
|
|
44
|
+
this.buffers.set(projectPath, buffer);
|
|
45
|
+
this.options.onEvent?.(projectPath, full);
|
|
46
|
+
return full;
|
|
47
|
+
}
|
|
48
|
+
events(projectPath) {
|
|
49
|
+
return (this.buffers.get(projectPath) ?? []).map(event => ({ ...event }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function redactAndBound(value, depth = 0) {
|
|
53
|
+
if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number')
|
|
54
|
+
return value;
|
|
55
|
+
if (typeof value === 'string') {
|
|
56
|
+
if (looksLikeLargeBase64(value))
|
|
57
|
+
return `[binary omitted: ${value.length} characters]`;
|
|
58
|
+
return value.length > 500 ? `${value.slice(0, 497)}...` : value;
|
|
59
|
+
}
|
|
60
|
+
if (depth >= 4)
|
|
61
|
+
return '[depth omitted]';
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
const bounded = value.slice(0, 20).map(item => redactAndBound(item, depth + 1));
|
|
64
|
+
if (value.length > 20)
|
|
65
|
+
bounded.push(`[${value.length - 20} more items]`);
|
|
66
|
+
return bounded;
|
|
67
|
+
}
|
|
68
|
+
if (typeof value === 'object') {
|
|
69
|
+
const result = {};
|
|
70
|
+
for (const [key, item] of Object.entries(value).slice(0, 40)) {
|
|
71
|
+
result[key] = SECRET_KEY.test(key) ? '[redacted]' : redactAndBound(item, depth + 1);
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
return typeof value === 'symbol' ? value.description ?? 'symbol' : typeof value;
|
|
76
|
+
}
|
|
77
|
+
const SECRET_KEY = /(?:^|_)(?:token|secret|password|credential|authorization|api_?key|private_?key)(?:$|_)/i;
|
|
78
|
+
function looksLikeLargeBase64(value) {
|
|
79
|
+
return value.length > 1_024 && /^[A-Za-z0-9+/=_-]+$/.test(value);
|
|
80
|
+
}
|