@aexol/spectral 0.6.8 → 0.7.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/dist/cli.js +32 -4
- package/dist/extensions/aexol-mcp.js +16 -1
- package/dist/mcp/tool-registrar.js +18 -2
- package/dist/relay/dispatcher.js +19 -1
- package/dist/server/handlers/projects.js +35 -0
- package/dist/server/session-stream.js +10 -2
- package/dist/studio-binding.js +20 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -136,15 +136,19 @@ function printHeader() {
|
|
|
136
136
|
// ---- Delegate to pi ----------------------------------------------------------
|
|
137
137
|
function delegateToPi(args) {
|
|
138
138
|
const piBin = resolvePiBin();
|
|
139
|
+
// The subprocess inherits a snapshot of process.env (which already has
|
|
140
|
+
// all interactive-editor vars disabled by disableInteractiveGitEditors()),
|
|
141
|
+
// but we pass it explicitly anyway as defense-in-depth in case the user
|
|
142
|
+
// overrides any of these in their shell rc files before launching spectral.
|
|
139
143
|
const child = spawn(process.execPath, [piBin, ...args], {
|
|
140
144
|
stdio: "inherit",
|
|
141
145
|
env: {
|
|
142
146
|
...process.env,
|
|
143
|
-
// Prevent git from opening an interactive editor when the agent
|
|
144
|
-
// runs commands like `git rebase --continue`, `git commit` (without -m),
|
|
145
|
-
// or `git merge`. Without this, the editor hangs forever waiting for
|
|
146
|
-
// TTY input that doesn't exist in the agent's non-interactive shell.
|
|
147
147
|
GIT_EDITOR: "true",
|
|
148
|
+
GIT_SEQUENCE_EDITOR: "true",
|
|
149
|
+
EDITOR: "true",
|
|
150
|
+
VISUAL: "true",
|
|
151
|
+
GIT_PAGER: "cat",
|
|
148
152
|
},
|
|
149
153
|
});
|
|
150
154
|
// Forward common termination signals to pi so its TUI can clean up.
|
|
@@ -177,8 +181,32 @@ function delegateToPi(args) {
|
|
|
177
181
|
// TypeScript needs a `never` terminator.
|
|
178
182
|
return undefined;
|
|
179
183
|
}
|
|
184
|
+
// ---- Disable interactive git editors ----------------------------------------
|
|
185
|
+
// Set at process level so BOTH direct mode (delegateToPi → subprocess inherits)
|
|
186
|
+
// AND serve mode (PiBridge in-process → getShellEnv() returns process.env) are
|
|
187
|
+
// covered. Without this, `git rebase --continue`, `git commit` (without -m),
|
|
188
|
+
// `git merge`, etc. open an editor that hangs forever — the agent's bash tool
|
|
189
|
+
// runs with `stdio: ["ignore", ...]`, so there is no TTY to interact with.
|
|
190
|
+
function disableInteractiveGitEditors() {
|
|
191
|
+
const editorVars = {
|
|
192
|
+
GIT_EDITOR: "true",
|
|
193
|
+
GIT_SEQUENCE_EDITOR: "true",
|
|
194
|
+
EDITOR: "true",
|
|
195
|
+
VISUAL: "true",
|
|
196
|
+
// cat pipes output straight through; prevents git's pager (less) from
|
|
197
|
+
// blocking on a non-existent TTY.
|
|
198
|
+
GIT_PAGER: "cat",
|
|
199
|
+
};
|
|
200
|
+
for (const [key, value] of Object.entries(editorVars)) {
|
|
201
|
+
if (!process.env[key]) {
|
|
202
|
+
process.env[key] = value;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
180
206
|
// ---- Main --------------------------------------------------------------------
|
|
181
207
|
async function main() {
|
|
208
|
+
// Must be called before any pi process is spawned (direct or serve).
|
|
209
|
+
disableInteractiveGitEditors();
|
|
182
210
|
const args = process.argv.slice(2);
|
|
183
211
|
const first = args[0];
|
|
184
212
|
// Branded short-circuits: never require login.
|
|
@@ -45,8 +45,23 @@ function renderContentToString(content) {
|
|
|
45
45
|
}
|
|
46
46
|
if (typeof first.text === "string")
|
|
47
47
|
return first.text;
|
|
48
|
+
// For image/audio/binary content types, return a metadata placeholder
|
|
49
|
+
// instead of serializing the raw base64 data into the model context.
|
|
50
|
+
if (first.type === "image" || first.type === "audio") {
|
|
51
|
+
const mimeType = typeof first.mimeType === "string" ? first.mimeType : first.type;
|
|
52
|
+
const dataLen = typeof first.data === "string" ? first.data.length : 0;
|
|
53
|
+
return `[${first.type}: ${mimeType}${dataLen ? `, ${dataLen.toLocaleString()} bytes base64` : ""}]`;
|
|
54
|
+
}
|
|
48
55
|
try {
|
|
49
|
-
|
|
56
|
+
// Strip large binary fields from serialization to prevent context overflow.
|
|
57
|
+
const safe = content.map((c) => {
|
|
58
|
+
const copy = { ...c };
|
|
59
|
+
if ("data" in copy && typeof copy.data === "string" && copy.data.length > 200) {
|
|
60
|
+
copy.data = `[${copy.data.length.toLocaleString()} bytes base64 omitted]`;
|
|
61
|
+
}
|
|
62
|
+
return copy;
|
|
63
|
+
});
|
|
64
|
+
return JSON.stringify(safe, null, 2);
|
|
50
65
|
}
|
|
51
66
|
catch {
|
|
52
67
|
return String(content);
|
|
@@ -57,7 +57,14 @@ export function transformMcpContent(content) {
|
|
|
57
57
|
}
|
|
58
58
|
if (c.type === "resource") {
|
|
59
59
|
const resourceUri = c.resource?.uri ?? "(no URI)";
|
|
60
|
-
|
|
60
|
+
// Blob resources contain base64 binary data — never serialize the raw
|
|
61
|
+
// blob into the model context (causes context overflow and dead sessions).
|
|
62
|
+
const resourceContent = c.resource?.text ??
|
|
63
|
+
("blob" in (c.resource ?? {}) && c.resource.blob
|
|
64
|
+
? `[Binary blob: ${c.resource.mimeType ?? "application/octet-stream"}]`
|
|
65
|
+
: c.resource
|
|
66
|
+
? JSON.stringify(c.resource)
|
|
67
|
+
: "(no content)");
|
|
61
68
|
return truncateTextBlock({
|
|
62
69
|
type: "text",
|
|
63
70
|
text: `[Resource: ${resourceUri}]\n${resourceContent}`,
|
|
@@ -77,6 +84,15 @@ export function transformMcpContent(content) {
|
|
|
77
84
|
text: `[Audio content: ${c.mimeType ?? "audio/*"}]`,
|
|
78
85
|
};
|
|
79
86
|
}
|
|
80
|
-
|
|
87
|
+
// Unknown content type — serialize as text but strip any potentially
|
|
88
|
+
// large binary fields (data, blob) to avoid context overflow.
|
|
89
|
+
const safe = { ...c };
|
|
90
|
+
if ("data" in safe && typeof safe.data === "string" && safe.data.length > 200) {
|
|
91
|
+
safe.data = `[${safe.data.length.toLocaleString()} bytes base64 omitted]`;
|
|
92
|
+
}
|
|
93
|
+
if ("blob" in safe && typeof safe.blob === "string" && safe.blob.length > 200) {
|
|
94
|
+
safe.blob = `[${safe.blob.length.toLocaleString()} bytes base64 omitted]`;
|
|
95
|
+
}
|
|
96
|
+
return truncateTextBlock({ type: "text", text: JSON.stringify(safe) });
|
|
81
97
|
});
|
|
82
98
|
}
|
package/dist/relay/dispatcher.js
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
*/
|
|
41
41
|
import { BadRequestError, NotFoundError } from "../server/handlers/errors.js";
|
|
42
42
|
import { handlePathAutocomplete } from "../server/handlers/paths-autocomplete.js";
|
|
43
|
-
import { handleCreateProject, handleDeleteProject, handleListProjects, handleListSessionsByProject, handleUpdateProject, } from "../server/handlers/projects.js";
|
|
43
|
+
import { handleBindStudioProject, handleCreateProject, handleDeleteProject, handleListProjects, handleListSessionsByProject, handleUpdateProject, } from "../server/handlers/projects.js";
|
|
44
44
|
import { handleCompactSession, handleCreateSession, handleDeleteSession, handleForkSession, handleGetSessionDetail, handleGetSessionMemoryDetails, handleGetSessionMemoryStatus, handleUpdateSession, } from "../server/handlers/sessions.js";
|
|
45
45
|
import { shutdownState } from "../server/shutdown.js";
|
|
46
46
|
import { handleAutoResearch } from "./auto-research.js";
|
|
@@ -76,6 +76,14 @@ export function matchRoute(method, path) {
|
|
|
76
76
|
return { route: "create_session" };
|
|
77
77
|
return null;
|
|
78
78
|
}
|
|
79
|
+
// /api/projects/:id/bind-studio
|
|
80
|
+
const bindStudioMatch = /^\/api\/projects\/([^/]+)\/bind-studio$/.exec(cleanPath);
|
|
81
|
+
if (bindStudioMatch) {
|
|
82
|
+
const id = decodeURIComponent(bindStudioMatch[1]);
|
|
83
|
+
if (method === "POST")
|
|
84
|
+
return { route: "bind_studio", id };
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
79
87
|
// /api/projects/:id and /api/projects/:id/sessions
|
|
80
88
|
const projectMatch = /^\/api\/projects\/([^/]+)(\/sessions)?$/.exec(cleanPath);
|
|
81
89
|
if (projectMatch) {
|
|
@@ -251,6 +259,16 @@ async function dispatchRoute(match, body, deps) {
|
|
|
251
259
|
});
|
|
252
260
|
return result;
|
|
253
261
|
}
|
|
262
|
+
case "bind_studio": {
|
|
263
|
+
const result = await handleBindStudioProject(store, id, asObject(body));
|
|
264
|
+
safePublish(publishMetaEvent, logger, {
|
|
265
|
+
type: "project_studio_bound",
|
|
266
|
+
projectId: id,
|
|
267
|
+
studioProjectId: result.studioProjectId,
|
|
268
|
+
studioProjectName: result.studioProjectName,
|
|
269
|
+
});
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
254
272
|
case "list_project_sessions": {
|
|
255
273
|
const activeIds = manager.getActiveTurnSessionIds();
|
|
256
274
|
const sessions = handleListSessionsByProject(store, id);
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { validateProjectPath } from "../paths.js";
|
|
23
23
|
import { BadRequestError, NotFoundError } from "./errors.js";
|
|
24
|
+
import { writeStudioBindingAt } from "../../studio-binding.js";
|
|
24
25
|
export function handleListProjects(store) {
|
|
25
26
|
return store.listProjects();
|
|
26
27
|
}
|
|
@@ -78,6 +79,40 @@ export function handleDeleteProject(store, id) {
|
|
|
78
79
|
throw new NotFoundError("Project not found");
|
|
79
80
|
return { sessionIds: result.sessionIds };
|
|
80
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Bind a local project directory to an Aexol Studio project by writing
|
|
84
|
+
* `.aexol/aexol.jsonc` into the project's filesystem path.
|
|
85
|
+
*/
|
|
86
|
+
export async function handleBindStudioProject(store, id, body) {
|
|
87
|
+
const project = store.getProject(id);
|
|
88
|
+
if (!project)
|
|
89
|
+
throw new NotFoundError("Project not found");
|
|
90
|
+
if (typeof body.studioProjectId !== "string" || !body.studioProjectId.trim()) {
|
|
91
|
+
throw new BadRequestError("studioProjectId (non-empty string) is required");
|
|
92
|
+
}
|
|
93
|
+
const studioProjectId = body.studioProjectId.trim();
|
|
94
|
+
const studioTeamId = typeof body.studioTeamId === "string" && body.studioTeamId.trim()
|
|
95
|
+
? body.studioTeamId.trim()
|
|
96
|
+
: undefined;
|
|
97
|
+
const studioProjectName = typeof body.studioProjectName === "string" && body.studioProjectName.trim()
|
|
98
|
+
? body.studioProjectName.trim()
|
|
99
|
+
: undefined;
|
|
100
|
+
const binding = {
|
|
101
|
+
$schema: "https://aexol.ai/schemas/studio-binding.json",
|
|
102
|
+
projectId: studioProjectId,
|
|
103
|
+
...(studioTeamId ? { teamId: studioTeamId } : {}),
|
|
104
|
+
...(studioProjectName ? { name: studioProjectName } : {}),
|
|
105
|
+
};
|
|
106
|
+
await writeStudioBindingAt(project.path, binding);
|
|
107
|
+
// Re-read to pick up the freshly written studio fields
|
|
108
|
+
const updated = store.getProject(id);
|
|
109
|
+
return {
|
|
110
|
+
project: updated,
|
|
111
|
+
studioProjectId,
|
|
112
|
+
studioProjectName: studioProjectName || studioProjectId,
|
|
113
|
+
...(studioTeamId ? { studioTeamId } : {}),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
81
116
|
export function handleListSessionsByProject(store, id) {
|
|
82
117
|
const project = store.getProject(id);
|
|
83
118
|
if (!project)
|
|
@@ -699,6 +699,12 @@ export class SessionStreamManager {
|
|
|
699
699
|
stream.loopOriginalPrompt = null;
|
|
700
700
|
stream.loopGoal = null;
|
|
701
701
|
stream.loopIterationCount = 0;
|
|
702
|
+
// Capture whether a turn is in-flight BEFORE we dispose the bridge.
|
|
703
|
+
// dispose() tears down pi, which can cause the in-flight prompt()
|
|
704
|
+
// promise to reject synchronously/microtask and emit an error event
|
|
705
|
+
// through handleBridgeEvent — which clears currentTurn. We must
|
|
706
|
+
// broadcast agent_end regardless of what dispose() does to currentTurn.
|
|
707
|
+
const hadTurn = stream.currentTurn != null;
|
|
702
708
|
// Dispose the pi bridge immediately — this tears down pi's session and
|
|
703
709
|
// unsubscribe. The bridge's own event handler is detached; no further
|
|
704
710
|
// events will flow. We broadcast agent_end ourselves below.
|
|
@@ -718,8 +724,10 @@ export class SessionStreamManager {
|
|
|
718
724
|
stream.currentMessageId = null;
|
|
719
725
|
stream.lastFlushedEventCount = 0;
|
|
720
726
|
// Broadcast agent_end so all subscribers close their open turn and
|
|
721
|
-
// re-enable their composers.
|
|
722
|
-
|
|
727
|
+
// re-enable their composers. Use the pre-disposal flag — dispose()
|
|
728
|
+
// may have cleared currentTurn via an error event from the torn-down
|
|
729
|
+
// pi session.
|
|
730
|
+
if (hadTurn) {
|
|
723
731
|
this.broadcast(stream, { type: "agent_end" });
|
|
724
732
|
stream.currentTurn = null;
|
|
725
733
|
}
|
package/dist/studio-binding.js
CHANGED
|
@@ -79,6 +79,26 @@ export async function writeStudioBinding(binding) {
|
|
|
79
79
|
"\n";
|
|
80
80
|
await writeFile(bindingPath(), content, "utf8");
|
|
81
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Persist a Studio binding at an arbitrary project path (creates `.aexol/`
|
|
84
|
+
* under that directory). Uses the same format as `writeStudioBinding`.
|
|
85
|
+
*/
|
|
86
|
+
export async function writeStudioBindingAt(projectPath, binding) {
|
|
87
|
+
const dir = join(projectPath, BINDING_DIR);
|
|
88
|
+
try {
|
|
89
|
+
await access(dir, constants.F_OK);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
await mkdir(dir, { recursive: true });
|
|
93
|
+
}
|
|
94
|
+
const content = `// Aexol Studio Binding\n` +
|
|
95
|
+
`// Links this repository to a Studio project.\n` +
|
|
96
|
+
`// Schema: https://aexol.ai/schemas/studio-binding.json\n` +
|
|
97
|
+
`\n` +
|
|
98
|
+
JSON.stringify(binding, null, 2) +
|
|
99
|
+
"\n";
|
|
100
|
+
await writeFile(join(dir, BINDING_FILE), content, "utf8");
|
|
101
|
+
}
|
|
82
102
|
/**
|
|
83
103
|
* Remove the local Studio binding file.
|
|
84
104
|
* Returns `true` when the file existed and was deleted, `false` otherwise (no-op).
|