@hyperspell/openclaw-hyperspell 0.5.0 → 0.7.2
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 +127 -3
- package/client.ts +311 -241
- package/commands/setup.ts +8 -54
- package/commands/wine.ts +164 -0
- package/config.ts +242 -175
- package/hooks/auto-context.ts +17 -11
- package/hooks/auto-trace.ts +127 -0
- package/index.ts +122 -100
- package/lib/browser.ts +10 -6
- package/lib/run-script.ts +32 -0
- package/openclaw.plugin.json +110 -87
- package/package.json +8 -4
- package/sommeliagent/references/cross-domain-mappings.md +63 -0
- package/sommeliagent/scripts/auth.py +222 -0
- package/sommeliagent/scripts/history.py +72 -0
- package/sommeliagent/scripts/rate.py +76 -0
- package/sommeliagent/scripts/recommend.py +777 -0
- package/sommeliagent/scripts/test_recommend.py +459 -0
- package/sommeliagent/scripts/wine_db.py +3224 -0
- package/tools/remember.ts +6 -2
- package/tools/search.ts +9 -3
- package/tools/sommelier.ts +254 -0
package/config.ts
CHANGED
|
@@ -1,206 +1,273 @@
|
|
|
1
1
|
export type HyperspellSource =
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
2
|
+
| "collections"
|
|
3
|
+
| "reddit"
|
|
4
|
+
| "notion"
|
|
5
|
+
| "slack"
|
|
6
|
+
| "google_calendar"
|
|
7
|
+
| "google_mail"
|
|
8
|
+
| "box"
|
|
9
|
+
| "google_drive"
|
|
10
|
+
| "vault"
|
|
11
|
+
| "web_crawler";
|
|
12
12
|
|
|
13
13
|
export type KnowledgeGraphConfig = {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
scanIntervalMinutes: number;
|
|
16
|
+
batchSize: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type AutoTraceConfig = {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
extract: Array<"procedure" | "memory" | "mood">;
|
|
22
|
+
metadata?: Record<string, string | number | boolean>;
|
|
23
|
+
};
|
|
18
24
|
|
|
19
25
|
export type HyperspellConfig = {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
apiKey: string;
|
|
27
|
+
userId?: string;
|
|
28
|
+
autoContext: boolean;
|
|
29
|
+
autoTrace: AutoTraceConfig;
|
|
30
|
+
syncMemories: boolean;
|
|
31
|
+
sources: HyperspellSource[];
|
|
32
|
+
maxResults: number;
|
|
33
|
+
relevanceThreshold: number;
|
|
34
|
+
debug: boolean;
|
|
35
|
+
knowledgeGraph: KnowledgeGraphConfig;
|
|
36
|
+
};
|
|
29
37
|
|
|
30
38
|
const ALLOWED_KEYS = [
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
"apiKey",
|
|
40
|
+
"userId",
|
|
41
|
+
"autoContext",
|
|
42
|
+
"autoTrace",
|
|
43
|
+
"syncMemories",
|
|
44
|
+
"sources",
|
|
45
|
+
"maxResults",
|
|
46
|
+
"relevanceThreshold",
|
|
47
|
+
"debug",
|
|
48
|
+
"knowledgeGraph",
|
|
49
|
+
];
|
|
40
50
|
|
|
41
51
|
const VALID_SOURCES: HyperspellSource[] = [
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
]
|
|
52
|
+
"collections",
|
|
53
|
+
"reddit",
|
|
54
|
+
"notion",
|
|
55
|
+
"slack",
|
|
56
|
+
"google_calendar",
|
|
57
|
+
"google_mail",
|
|
58
|
+
"box",
|
|
59
|
+
"google_drive",
|
|
60
|
+
"vault",
|
|
61
|
+
"web_crawler",
|
|
62
|
+
];
|
|
53
63
|
|
|
54
64
|
function assertAllowedKeys(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
value: Record<string, unknown>,
|
|
66
|
+
allowed: string[],
|
|
67
|
+
label: string,
|
|
58
68
|
): void {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
const unknown = Object.keys(value).filter((k) => !allowed.includes(k));
|
|
70
|
+
if (unknown.length > 0) {
|
|
71
|
+
throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
|
|
72
|
+
}
|
|
63
73
|
}
|
|
64
74
|
|
|
65
75
|
function resolveEnvVars(value: string): string {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, envVar: string) => {
|
|
77
|
+
const envValue = process.env[envVar];
|
|
78
|
+
if (!envValue) {
|
|
79
|
+
throw new Error(`Environment variable ${envVar} is not set`);
|
|
80
|
+
}
|
|
81
|
+
return envValue;
|
|
82
|
+
});
|
|
73
83
|
}
|
|
74
84
|
|
|
75
85
|
function parseSources(raw: string | string[] | undefined): HyperspellSource[] {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
86
|
+
if (!raw) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Handle array input
|
|
91
|
+
if (Array.isArray(raw)) {
|
|
92
|
+
const sources = raw
|
|
93
|
+
.map((s) => String(s).trim().toLowerCase())
|
|
94
|
+
.filter((s) => s.length > 0) as HyperspellSource[];
|
|
95
|
+
|
|
96
|
+
for (const source of sources) {
|
|
97
|
+
if (!VALID_SOURCES.includes(source)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Invalid source "${source}". Valid sources: ${VALID_SOURCES.join(", ")}`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return sources;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Handle string input (comma-separated)
|
|
108
|
+
if (typeof raw === "string" && raw.trim() === "") {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const sources = raw
|
|
113
|
+
.split(",")
|
|
114
|
+
.map((s) => s.trim().toLowerCase())
|
|
115
|
+
.filter((s) => s.length > 0) as HyperspellSource[];
|
|
116
|
+
|
|
117
|
+
for (const source of sources) {
|
|
118
|
+
if (!VALID_SOURCES.includes(source)) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Invalid source "${source}". Valid sources: ${VALID_SOURCES.join(", ")}`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return sources;
|
|
116
126
|
}
|
|
117
127
|
|
|
118
128
|
export function parseConfig(raw: unknown): HyperspellConfig {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
129
|
+
const cfg =
|
|
130
|
+
raw && typeof raw === "object" && !Array.isArray(raw)
|
|
131
|
+
? (raw as Record<string, unknown>)
|
|
132
|
+
: {};
|
|
133
|
+
|
|
134
|
+
if (Object.keys(cfg).length > 0) {
|
|
135
|
+
assertAllowedKeys(cfg, ALLOWED_KEYS, "hyperspell config");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const apiKey =
|
|
139
|
+
typeof cfg.apiKey === "string" && cfg.apiKey.length > 0
|
|
140
|
+
? resolveEnvVars(cfg.apiKey)
|
|
141
|
+
: process.env.HYPERSPELL_API_KEY;
|
|
142
|
+
|
|
143
|
+
if (!apiKey) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
"hyperspell: apiKey is required (set in plugin config or HYPERSPELL_API_KEY env var)",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const kgRaw = (cfg.knowledgeGraph ?? {}) as Record<string, unknown>;
|
|
150
|
+
const atRaw = (cfg.autoTrace ?? {}) as Record<string, unknown>;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
apiKey,
|
|
154
|
+
userId: cfg.userId as string | undefined,
|
|
155
|
+
autoContext: (cfg.autoContext as boolean) ?? true,
|
|
156
|
+
autoTrace: {
|
|
157
|
+
enabled: (atRaw.enabled as boolean) ?? false,
|
|
158
|
+
extract: (atRaw.extract as Array<"procedure" | "memory" | "mood">) ?? [
|
|
159
|
+
"procedure",
|
|
160
|
+
],
|
|
161
|
+
metadata: atRaw.metadata as
|
|
162
|
+
| Record<string, string | number | boolean>
|
|
163
|
+
| undefined,
|
|
164
|
+
},
|
|
165
|
+
syncMemories: (cfg.syncMemories as boolean) ?? false,
|
|
166
|
+
sources: parseSources(cfg.sources as string | string[] | undefined),
|
|
167
|
+
maxResults: (cfg.maxResults as number) ?? 10,
|
|
168
|
+
relevanceThreshold: (cfg.relevanceThreshold as number) ?? 0.6,
|
|
169
|
+
debug: (cfg.debug as boolean) ?? false,
|
|
170
|
+
knowledgeGraph: {
|
|
171
|
+
enabled: (kgRaw.enabled as boolean) ?? false,
|
|
172
|
+
scanIntervalMinutes: (kgRaw.scanIntervalMinutes as number) ?? 60,
|
|
173
|
+
batchSize: (kgRaw.batchSize as number) ?? 20,
|
|
174
|
+
},
|
|
175
|
+
};
|
|
155
176
|
}
|
|
156
177
|
|
|
157
178
|
export const hyperspellConfigSchema = {
|
|
158
|
-
|
|
179
|
+
parse: parseConfig,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve OpenClaw state directory (matches OpenClaw's own logic).
|
|
184
|
+
*/
|
|
185
|
+
export function resolveStateDir(): string {
|
|
186
|
+
const { homedir } = require("node:os");
|
|
187
|
+
const path = require("node:path");
|
|
188
|
+
|
|
189
|
+
const override =
|
|
190
|
+
process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
191
|
+
process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
192
|
+
if (override) {
|
|
193
|
+
return override.startsWith("~")
|
|
194
|
+
? override.replace(/^~(?=$|[\\/])/, homedir())
|
|
195
|
+
: path.resolve(override);
|
|
196
|
+
}
|
|
197
|
+
return path.join(homedir(), ".openclaw");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Resolve OpenClaw config file path (matches OpenClaw's own logic).
|
|
202
|
+
*/
|
|
203
|
+
export function resolveConfigPath(): string {
|
|
204
|
+
const path = require("node:path");
|
|
205
|
+
|
|
206
|
+
const override =
|
|
207
|
+
process.env.OPENCLAW_CONFIG_PATH?.trim() ||
|
|
208
|
+
process.env.CLAWDBOT_CONFIG_PATH?.trim();
|
|
209
|
+
if (override) {
|
|
210
|
+
const { homedir } = require("node:os");
|
|
211
|
+
return override.startsWith("~")
|
|
212
|
+
? override.replace(/^~(?=$|[\\/])/, homedir())
|
|
213
|
+
: path.resolve(override);
|
|
214
|
+
}
|
|
215
|
+
return path.join(resolveStateDir(), "openclaw.json");
|
|
159
216
|
}
|
|
160
217
|
|
|
161
218
|
/**
|
|
162
219
|
* Get the workspace directory from OpenClaw config
|
|
163
220
|
*/
|
|
164
221
|
export function getWorkspaceDir(): string {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
222
|
+
const { homedir } = require("node:os");
|
|
223
|
+
const fs = require("node:fs");
|
|
224
|
+
const path = require("node:path");
|
|
225
|
+
|
|
226
|
+
// Resolve config path
|
|
227
|
+
const override =
|
|
228
|
+
process.env.OPENCLAW_CONFIG_PATH?.trim() ||
|
|
229
|
+
process.env.CLAWDBOT_CONFIG_PATH?.trim();
|
|
230
|
+
let configPath: string;
|
|
231
|
+
if (override) {
|
|
232
|
+
configPath = override.startsWith("~")
|
|
233
|
+
? override.replace(/^~(?=$|[\\/])/, homedir())
|
|
234
|
+
: path.resolve(override);
|
|
235
|
+
} else {
|
|
236
|
+
const stateDir =
|
|
237
|
+
process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
238
|
+
process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
239
|
+
const resolvedStateDir = stateDir
|
|
240
|
+
? stateDir.startsWith("~")
|
|
241
|
+
? stateDir.replace(/^~(?=$|[\\/])/, homedir())
|
|
242
|
+
: path.resolve(stateDir)
|
|
243
|
+
: path.join(homedir(), ".openclaw");
|
|
244
|
+
configPath = path.join(resolvedStateDir, "openclaw.json");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Read workspace from config
|
|
248
|
+
if (fs.existsSync(configPath)) {
|
|
249
|
+
try {
|
|
250
|
+
const content = fs.readFileSync(configPath, "utf-8");
|
|
251
|
+
const config = JSON.parse(content);
|
|
252
|
+
const workspace = config?.agents?.defaults?.workspace;
|
|
253
|
+
if (workspace) {
|
|
254
|
+
return workspace.startsWith("~")
|
|
255
|
+
? workspace.replace(/^~(?=$|[\\/])/, homedir())
|
|
256
|
+
: workspace;
|
|
257
|
+
}
|
|
258
|
+
} catch (_e) {
|
|
259
|
+
// Fall back to default
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Default workspace
|
|
264
|
+
const stateDir =
|
|
265
|
+
process.env.OPENCLAW_STATE_DIR?.trim() ||
|
|
266
|
+
process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
267
|
+
const resolvedStateDir = stateDir
|
|
268
|
+
? stateDir.startsWith("~")
|
|
269
|
+
? stateDir.replace(/^~(?=$|[\\/])/, homedir())
|
|
270
|
+
: path.resolve(stateDir)
|
|
271
|
+
: path.join(homedir(), ".openclaw");
|
|
272
|
+
return path.join(resolvedStateDir, "workspace");
|
|
206
273
|
}
|
package/hooks/auto-context.ts
CHANGED
|
@@ -26,25 +26,31 @@ function formatRelativeTime(isoTimestamp: string): string {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function formatContext(results: SearchResult[], maxResults: number): string | null {
|
|
30
|
-
const
|
|
29
|
+
function formatContext(results: SearchResult[], maxResults: number, threshold: number): string | null {
|
|
30
|
+
const sections: string[] = []
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
for (const r of results.slice(0, maxResults)) {
|
|
33
|
+
if ((r.score ?? 0) < threshold) continue
|
|
34
|
+
|
|
35
|
+
const aboveThreshold = r.highlights.filter((h) => h.score >= threshold)
|
|
36
|
+
if (aboveThreshold.length === 0) continue
|
|
33
37
|
|
|
34
|
-
const lines = limited.map((r) => {
|
|
35
38
|
const title = r.title ?? `[${r.source}]`
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const bullets = aboveThreshold
|
|
40
|
+
.map((h) => `- ${h.text.replace(/\n/g, " ")} [${Math.round(h.score * 100)}%]`)
|
|
41
|
+
.join("\n")
|
|
42
|
+
|
|
43
|
+
sections.push(`### ${title} (resource_id: ${r.resourceId}, source: ${r.source})\n\n${bullets}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (sections.length === 0) return null
|
|
41
47
|
|
|
42
48
|
const intro =
|
|
43
49
|
"The following is context from the user's connected sources. Reference it only when relevant to the conversation."
|
|
44
50
|
const disclaimer =
|
|
45
51
|
"Use this context naturally when relevant — including indirect connections — but don't force it into every response or make assumptions beyond what's stated."
|
|
46
52
|
|
|
47
|
-
return `<hyperspell-context>\n${intro}\n\n
|
|
53
|
+
return `<hyperspell-context>\n${intro}\n\n${sections.join("\n\n")}\n\n${disclaimer}\n</hyperspell-context>`
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
export function buildAutoContextHandler(
|
|
@@ -59,7 +65,7 @@ export function buildAutoContextHandler(
|
|
|
59
65
|
|
|
60
66
|
try {
|
|
61
67
|
const results = await client.search(prompt, { limit: cfg.maxResults })
|
|
62
|
-
const context = formatContext(results, cfg.maxResults)
|
|
68
|
+
const context = formatContext(results, cfg.maxResults, cfg.relevanceThreshold)
|
|
63
69
|
|
|
64
70
|
if (!context) {
|
|
65
71
|
log.debug("auto-context: no relevant memories found")
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { HyperspellClient } from "../client.ts";
|
|
2
|
+
import type { HyperspellConfig } from "../config.ts";
|
|
3
|
+
import { log } from "../logger.ts";
|
|
4
|
+
|
|
5
|
+
type Message = { role?: string; content?: string | unknown };
|
|
6
|
+
|
|
7
|
+
const MIN_MESSAGES = 3;
|
|
8
|
+
const MIN_CONVERSATION_LENGTH = 100;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Convert event.messages into OpenClaw JSONL format for the trace API.
|
|
12
|
+
*/
|
|
13
|
+
function messagesToJSONL(messages: unknown[], sessionId: string): string {
|
|
14
|
+
const lines: string[] = [];
|
|
15
|
+
|
|
16
|
+
// Session header
|
|
17
|
+
lines.push(
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
type: "session",
|
|
20
|
+
version: 3,
|
|
21
|
+
id: sessionId,
|
|
22
|
+
timestamp: new Date().toISOString(),
|
|
23
|
+
}),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// Message entries
|
|
27
|
+
for (const raw of messages as Message[]) {
|
|
28
|
+
if (!raw.role || !raw.content) continue;
|
|
29
|
+
|
|
30
|
+
const content =
|
|
31
|
+
typeof raw.content === "string"
|
|
32
|
+
? [{ type: "text", text: raw.content }]
|
|
33
|
+
: Array.isArray(raw.content)
|
|
34
|
+
? raw.content
|
|
35
|
+
: [{ type: "text", text: JSON.stringify(raw.content) }];
|
|
36
|
+
|
|
37
|
+
const id = crypto.randomUUID().slice(0, 8);
|
|
38
|
+
|
|
39
|
+
if (raw.role === "tool" || raw.role === "toolResult") {
|
|
40
|
+
// Tool results use a different structure
|
|
41
|
+
lines.push(
|
|
42
|
+
JSON.stringify({
|
|
43
|
+
type: "message",
|
|
44
|
+
id,
|
|
45
|
+
timestamp: new Date().toISOString(),
|
|
46
|
+
message: {
|
|
47
|
+
role: "toolResult",
|
|
48
|
+
toolCallId: (raw as Record<string, unknown>).toolCallId ?? id,
|
|
49
|
+
toolName: (raw as Record<string, unknown>).toolName ?? "unknown",
|
|
50
|
+
content,
|
|
51
|
+
isError: (raw as Record<string, unknown>).isError ?? false,
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
} else {
|
|
56
|
+
lines.push(
|
|
57
|
+
JSON.stringify({
|
|
58
|
+
type: "message",
|
|
59
|
+
id,
|
|
60
|
+
timestamp: new Date().toISOString(),
|
|
61
|
+
message: { role: raw.role, content },
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Extract and store conversation trace at session end.
|
|
72
|
+
* Runs on `agent_end` — fire-and-forget.
|
|
73
|
+
*/
|
|
74
|
+
export function buildAutoTraceHandler(
|
|
75
|
+
client: HyperspellClient,
|
|
76
|
+
cfg: HyperspellConfig,
|
|
77
|
+
) {
|
|
78
|
+
return async (event: Record<string, unknown>) => {
|
|
79
|
+
if (event.success === false) {
|
|
80
|
+
log.debug("auto-trace: skipping — agent ended with error");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const messages = event.messages as unknown[] | undefined;
|
|
85
|
+
if (!messages || messages.length < MIN_MESSAGES) {
|
|
86
|
+
log.debug(
|
|
87
|
+
`auto-trace: skipping — too few messages (${messages?.length ?? 0})`,
|
|
88
|
+
);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Quick content length check
|
|
93
|
+
const estimate = (messages as Message[])
|
|
94
|
+
.filter((m) => m.content)
|
|
95
|
+
.reduce((acc, m) => acc + String(m.content).length, 0);
|
|
96
|
+
if (estimate < MIN_CONVERSATION_LENGTH) {
|
|
97
|
+
log.debug(
|
|
98
|
+
`auto-trace: skipping — conversation too short (${estimate} chars)`,
|
|
99
|
+
);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const sessionId = (event.sessionId as string) ?? crypto.randomUUID();
|
|
104
|
+
const history = messagesToJSONL(messages, sessionId);
|
|
105
|
+
|
|
106
|
+
// Title from first user message
|
|
107
|
+
const firstUser = (messages as Message[]).find((m) => m.role === "user");
|
|
108
|
+
const title = firstUser?.content
|
|
109
|
+
? String(firstUser.content).slice(0, 80).replace(/\n/g, " ")
|
|
110
|
+
: undefined;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const result = await client.sendTrace(history, {
|
|
114
|
+
sessionId,
|
|
115
|
+
title,
|
|
116
|
+
extract: cfg.autoTrace.extract,
|
|
117
|
+
metadata: cfg.autoTrace.metadata,
|
|
118
|
+
});
|
|
119
|
+
log.info(
|
|
120
|
+
`auto-trace: sent ${result.resourceId} (${messages.length} messages)`,
|
|
121
|
+
);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Fire-and-forget — never break the session
|
|
124
|
+
log.error("auto-trace failed", err);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|