@aexol/spectral 0.0.1
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/CHANGELOG.md +106 -0
- package/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/cli.js +206 -0
- package/dist/commands/bind.js +96 -0
- package/dist/commands/login.js +109 -0
- package/dist/commands/logout.js +24 -0
- package/dist/commands/serve.js +374 -0
- package/dist/commands/unbind.js +36 -0
- package/dist/config.js +92 -0
- package/dist/extensions/aexol-mcp.js +117 -0
- package/dist/mcp-client.js +116 -0
- package/dist/preflight.js +36 -0
- package/dist/relay/client.js +240 -0
- package/dist/relay/dispatcher.js +504 -0
- package/dist/relay/machine-store.js +116 -0
- package/dist/relay/models-fetch.js +108 -0
- package/dist/relay/registration.js +135 -0
- package/dist/server/handlers/errors.js +34 -0
- package/dist/server/handlers/projects.js +86 -0
- package/dist/server/handlers/sessions.js +42 -0
- package/dist/server/paths.js +78 -0
- package/dist/server/pi-bridge.js +572 -0
- package/dist/server/session-stream.js +579 -0
- package/dist/server/shutdown.js +180 -0
- package/dist/server/storage.js +491 -0
- package/dist/server/title-generator.js +196 -0
- package/dist/server/wire.js +12 -0
- package/dist/studio-binding.js +97 -0
- package/package.json +67 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-titler for sessions whose title is still the default
|
|
3
|
+
* ("New conversation" / null / empty).
|
|
4
|
+
*
|
|
5
|
+
* Strategy:
|
|
6
|
+
* - After the first assistant turn completes (agent_end), the session-stream
|
|
7
|
+
* manager fires `generateAndPersistTitle` once per session per server
|
|
8
|
+
* lifetime. We track attempted ids in `SessionStreamManager` so a second
|
|
9
|
+
* message in the same session never retriggers.
|
|
10
|
+
* - Title generation itself is a one-shot LLM call. To keep the prod path
|
|
11
|
+
* decoupled from tests, the call is injectable via the `llmCall`
|
|
12
|
+
* dependency. The default implementation spawns a one-off pi
|
|
13
|
+
* `AgentSession` with `noTools: "all"`, sends the prompt, and accumulates
|
|
14
|
+
* `text_delta` events until `agent_end`. This reuses every bit of pi's
|
|
15
|
+
* auth + model selection without introducing a new SDK code path.
|
|
16
|
+
* - All failures are swallowed: log a warning and leave the title alone.
|
|
17
|
+
* The caller's stream is never affected — title gen runs fire-and-forget.
|
|
18
|
+
*
|
|
19
|
+
* Sanitization rules (applied after the LLM responds):
|
|
20
|
+
* - Trim whitespace
|
|
21
|
+
* - Strip surrounding quotes (`"…"`, `'…'`, `“…”`)
|
|
22
|
+
* - Take only the first non-empty line
|
|
23
|
+
* - Drop trailing punctuation (`.`, `,`, `!`, `?`, `;`, `:`)
|
|
24
|
+
* - Truncate to 60 characters
|
|
25
|
+
*
|
|
26
|
+
* Edge cases:
|
|
27
|
+
* - Empty assistant message (e.g. only tool calls) → fall back to using the
|
|
28
|
+
* user message alone for the prompt; if that's also empty, skip.
|
|
29
|
+
* - LLM returns empty string after sanitization → return null, skip rename.
|
|
30
|
+
*/
|
|
31
|
+
import { createAgentSession, SessionManager, } from "@mariozechner/pi-coding-agent";
|
|
32
|
+
const DEFAULT_TITLES = new Set(["New conversation", "", "Untitled"]);
|
|
33
|
+
export const SESSION_TITLE_DEFAULT = "New conversation";
|
|
34
|
+
/** True if `title` looks like the auto-generated default and is fair game. */
|
|
35
|
+
export function isDefaultTitle(title) {
|
|
36
|
+
if (title == null)
|
|
37
|
+
return true;
|
|
38
|
+
return DEFAULT_TITLES.has(title.trim());
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Sanitize raw LLM output into a usable session title.
|
|
42
|
+
* Returns `null` if nothing usable remains.
|
|
43
|
+
*/
|
|
44
|
+
export function sanitizeTitle(raw) {
|
|
45
|
+
if (!raw)
|
|
46
|
+
return null;
|
|
47
|
+
// First non-empty line.
|
|
48
|
+
const firstLine = raw
|
|
49
|
+
.split(/\r?\n/)
|
|
50
|
+
.map((l) => l.trim())
|
|
51
|
+
.find((l) => l.length > 0);
|
|
52
|
+
if (!firstLine)
|
|
53
|
+
return null;
|
|
54
|
+
let out = firstLine;
|
|
55
|
+
// Strip surrounding quote pairs.
|
|
56
|
+
const quotePairs = [
|
|
57
|
+
['"', '"'],
|
|
58
|
+
["'", "'"],
|
|
59
|
+
["\u201C", "\u201D"], // “ ”
|
|
60
|
+
["\u2018", "\u2019"], // ‘ ’
|
|
61
|
+
["`", "`"],
|
|
62
|
+
];
|
|
63
|
+
for (const [open, close] of quotePairs) {
|
|
64
|
+
if (out.startsWith(open) && out.endsWith(close) && out.length >= 2) {
|
|
65
|
+
out = out.slice(open.length, out.length - close.length).trim();
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Drop trailing punctuation.
|
|
70
|
+
out = out.replace(/[.,!?;:]+$/u, "").trim();
|
|
71
|
+
// Truncate.
|
|
72
|
+
if (out.length > 60)
|
|
73
|
+
out = out.slice(0, 60).trim();
|
|
74
|
+
if (!out)
|
|
75
|
+
return null;
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build the prompt sent to the LLM. Truncates the assistant message body so
|
|
80
|
+
* a long answer doesn't blow out the context window of a tiny title model.
|
|
81
|
+
*/
|
|
82
|
+
export function buildTitlePrompt(firstUserMessage, firstAssistantMessage) {
|
|
83
|
+
const userText = firstUserMessage.trim();
|
|
84
|
+
const assistantText = firstAssistantMessage.trim().slice(0, 500);
|
|
85
|
+
const lines = [
|
|
86
|
+
"Generate a short title (4-6 words, no quotes, no trailing punctuation) for this conversation.",
|
|
87
|
+
"Respond with ONLY the title — no preamble, no explanation, no quotes.",
|
|
88
|
+
"",
|
|
89
|
+
`User: ${userText}`,
|
|
90
|
+
];
|
|
91
|
+
if (assistantText)
|
|
92
|
+
lines.push("", `Assistant: ${assistantText}`);
|
|
93
|
+
lines.push("", "Title:");
|
|
94
|
+
return lines.join("\n");
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Generate a sanitized title for a conversation, or `null` if generation
|
|
98
|
+
* failed / produced nothing usable.
|
|
99
|
+
*/
|
|
100
|
+
export async function generateSessionTitle(firstUserMessage, firstAssistantMessage, opts) {
|
|
101
|
+
const trimmedUser = firstUserMessage.trim();
|
|
102
|
+
if (!trimmedUser) {
|
|
103
|
+
// Nothing to summarize — pi was driven by an empty user turn (shouldn't
|
|
104
|
+
// happen via the wire path, but be defensive).
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const prompt = buildTitlePrompt(trimmedUser, firstAssistantMessage);
|
|
108
|
+
const llmCall = opts.llmCall ?? defaultLlmCall(opts);
|
|
109
|
+
try {
|
|
110
|
+
const raw = await llmCall(prompt);
|
|
111
|
+
return sanitizeTitle(raw);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
115
|
+
console.warn(`[spectral] warn: title generation failed: ${msg}`);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Default LLM call: spawn a one-off pi `AgentSession` with no tools, send the
|
|
121
|
+
* prompt, and accumulate `text_delta`s until `agent_end`. Disposes the session
|
|
122
|
+
* on completion (or failure).
|
|
123
|
+
*
|
|
124
|
+
* This deliberately reuses pi's existing model/auth resolution so the user
|
|
125
|
+
* doesn't have to configure an extra API key just for title generation. The
|
|
126
|
+
* downside is a slightly heavier code path than calling an LLM SDK directly;
|
|
127
|
+
* the upside is zero new auth surface.
|
|
128
|
+
*/
|
|
129
|
+
function defaultLlmCall(opts) {
|
|
130
|
+
return async (prompt) => {
|
|
131
|
+
const { session } = await createAgentSession({
|
|
132
|
+
cwd: opts.cwd,
|
|
133
|
+
agentDir: opts.agentDir,
|
|
134
|
+
sessionManager: SessionManager.inMemory(opts.cwd),
|
|
135
|
+
noTools: "all",
|
|
136
|
+
});
|
|
137
|
+
let text = "";
|
|
138
|
+
let resolveDone;
|
|
139
|
+
let rejectDone;
|
|
140
|
+
const done = new Promise((resolve, reject) => {
|
|
141
|
+
resolveDone = resolve;
|
|
142
|
+
rejectDone = reject;
|
|
143
|
+
});
|
|
144
|
+
const unsubscribe = session.subscribe((ev) => {
|
|
145
|
+
if (ev.type === "message_update") {
|
|
146
|
+
const inner = ev.assistantMessageEvent;
|
|
147
|
+
if (inner.type === "text_delta")
|
|
148
|
+
text += inner.delta;
|
|
149
|
+
}
|
|
150
|
+
else if (ev.type === "agent_end") {
|
|
151
|
+
resolveDone();
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
try {
|
|
155
|
+
// `prompt` resolves when the turn ends; `agent_end` should already
|
|
156
|
+
// have fired by then but we double-await for safety.
|
|
157
|
+
await session.prompt(prompt);
|
|
158
|
+
// In the event prompt() resolves before agent_end (defensive), wait a
|
|
159
|
+
// microtask to let the listener flush.
|
|
160
|
+
await Promise.race([
|
|
161
|
+
done,
|
|
162
|
+
new Promise((r) => setTimeout(r, 0)).then(() => undefined),
|
|
163
|
+
]);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
167
|
+
try {
|
|
168
|
+
unsubscribe();
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
/* ignore */
|
|
172
|
+
}
|
|
173
|
+
try {
|
|
174
|
+
session.dispose();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* ignore */
|
|
178
|
+
}
|
|
179
|
+
rejectDone(e);
|
|
180
|
+
throw e;
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
unsubscribe();
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* ignore */
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
session.dispose();
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
/* ignore */
|
|
193
|
+
}
|
|
194
|
+
return text;
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket wire protocol types shared between the spectral local server and
|
|
3
|
+
* the (future) browser client in `landing/`.
|
|
4
|
+
*
|
|
5
|
+
* Keep this file dependency-free. It will be copied or imported by the
|
|
6
|
+
* frontend in Day 3, so we don't want it to drag in `better-sqlite3`,
|
|
7
|
+
* `ws`, or any pi SDK types.
|
|
8
|
+
*
|
|
9
|
+
* Naming: client → server messages are `ClientMessage`, server → client
|
|
10
|
+
* frames are `ServerEvent`. JSON-encoded one-per-frame on the wire.
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Studio Binding utilities.
|
|
3
|
+
*
|
|
4
|
+
* Read/write/delete `.aexol/aexol.jsonc` — the local file that links a
|
|
5
|
+
* repository directory to an Aexol Studio project.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile, writeFile, mkdir, unlink, access } from "node:fs/promises";
|
|
8
|
+
import { constants } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
const BINDING_DIR = ".aexol";
|
|
11
|
+
const BINDING_FILE = "aexol.jsonc";
|
|
12
|
+
function bindingDir() {
|
|
13
|
+
return join(process.cwd(), BINDING_DIR);
|
|
14
|
+
}
|
|
15
|
+
function bindingPath() {
|
|
16
|
+
return join(bindingDir(), BINDING_FILE);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Strip JSONC comments (// line and /* block */) from raw source text.
|
|
20
|
+
* Kept simple — the binding file shape is well-known.
|
|
21
|
+
*/
|
|
22
|
+
export function stripJsoncComments(raw) {
|
|
23
|
+
return raw
|
|
24
|
+
.replace(/\/\*[\s\S]*?\*\//g, "") // block comments
|
|
25
|
+
.replace(/^\s*\/\/.*$/gm, ""); // line comments (standalone lines only)
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read and parse the local Studio binding file.
|
|
29
|
+
* Returns `null` when no binding file exists (file missing, or directory
|
|
30
|
+
* absent — treated the same way: not bound).
|
|
31
|
+
*/
|
|
32
|
+
export async function readStudioBinding() {
|
|
33
|
+
try {
|
|
34
|
+
const raw = await readFile(bindingPath(), "utf8");
|
|
35
|
+
return JSON.parse(stripJsoncComments(raw));
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
const code = err.code;
|
|
39
|
+
// Missing file or missing directory → not bound.
|
|
40
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
41
|
+
return null;
|
|
42
|
+
throw err;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Read and parse the Studio binding file at an arbitrary project path.
|
|
47
|
+
* Works like `readStudioBinding()` but reads from `projectPath/.aexol/aexol.jsonc`
|
|
48
|
+
* instead of `process.cwd()`.
|
|
49
|
+
*/
|
|
50
|
+
export async function readStudioBindingAt(projectPath) {
|
|
51
|
+
try {
|
|
52
|
+
const raw = await readFile(join(projectPath, BINDING_DIR, BINDING_FILE), "utf8");
|
|
53
|
+
return JSON.parse(stripJsoncComments(raw));
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const code = err.code;
|
|
57
|
+
// Missing file or missing directory → not bound.
|
|
58
|
+
if (code === "ENOENT" || code === "ENOTDIR")
|
|
59
|
+
return null;
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Persist a Studio binding. Creates `.aexol/` if it does not exist.
|
|
65
|
+
*/
|
|
66
|
+
export async function writeStudioBinding(binding) {
|
|
67
|
+
const dir = bindingDir();
|
|
68
|
+
try {
|
|
69
|
+
await access(dir, constants.F_OK);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
await mkdir(dir, { recursive: true });
|
|
73
|
+
}
|
|
74
|
+
const content = `// Aexol Studio Binding\n` +
|
|
75
|
+
`// Links this repository to a Studio project.\n` +
|
|
76
|
+
`// Schema: https://aexol.ai/schemas/studio-binding.json\n` +
|
|
77
|
+
`\n` +
|
|
78
|
+
JSON.stringify(binding, null, 2) +
|
|
79
|
+
"\n";
|
|
80
|
+
await writeFile(bindingPath(), content, "utf8");
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Remove the local Studio binding file.
|
|
84
|
+
* Returns `true` when the file existed and was deleted, `false` otherwise (no-op).
|
|
85
|
+
*/
|
|
86
|
+
export async function deleteStudioBinding() {
|
|
87
|
+
try {
|
|
88
|
+
await unlink(bindingPath());
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
const code = err.code;
|
|
93
|
+
if (code === "ENOENT")
|
|
94
|
+
return false;
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aexol/spectral",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"bin": {
|
|
8
|
+
"spectral": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc && node scripts/postbuild.mjs",
|
|
18
|
+
"dev": "tsx src/cli.ts",
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest",
|
|
21
|
+
"prepublishOnly": "npm run build && npm test"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"aexol",
|
|
28
|
+
"spectral",
|
|
29
|
+
"coding-agent",
|
|
30
|
+
"ai",
|
|
31
|
+
"cli",
|
|
32
|
+
"pi",
|
|
33
|
+
"claude",
|
|
34
|
+
"openai",
|
|
35
|
+
"agent",
|
|
36
|
+
"relay"
|
|
37
|
+
],
|
|
38
|
+
"homepage": "https://aexol.com",
|
|
39
|
+
"repository": {
|
|
40
|
+
"type": "git",
|
|
41
|
+
"url": "https://gitlab.aexol.com/aexol/spectral.git",
|
|
42
|
+
"directory": "cli-node"
|
|
43
|
+
},
|
|
44
|
+
"bugs": {
|
|
45
|
+
"url": "https://gitlab.aexol.com/aexol/spectral/-/issues"
|
|
46
|
+
},
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@inquirer/prompts": "^7.2.0",
|
|
53
|
+
"@mariozechner/pi-coding-agent": "^0.70.2",
|
|
54
|
+
"better-sqlite3": "^12.9.0",
|
|
55
|
+
"picocolors": "^1.1.1",
|
|
56
|
+
"ws": "^8.20.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@aexol/relay-protocol": "file:../packages/relay-protocol",
|
|
60
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
61
|
+
"@types/node": "^20.11.0",
|
|
62
|
+
"@types/ws": "^8.18.1",
|
|
63
|
+
"tsx": "^4.7.0",
|
|
64
|
+
"typescript": "^5.4.0",
|
|
65
|
+
"vitest": "^2.1.9"
|
|
66
|
+
}
|
|
67
|
+
}
|