@hyperspell/openclaw-hyperspell 0.18.1 → 0.20.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 +226 -10
- package/dist/client.js +14 -0
- package/dist/commands/preview.js +93 -0
- package/dist/commands/purge-channel.js +54 -0
- package/dist/commands/setup.js +64 -6
- package/dist/commands/slash.js +58 -0
- package/dist/config.js +43 -4
- package/dist/graph/ops.js +36 -3
- package/dist/hooks/auto-context.js +75 -4
- package/dist/hooks/auto-trace.js +20 -2
- package/dist/hooks/emotional-state.js +90 -22
- package/dist/hooks/hot-buffer.js +76 -6
- package/dist/hooks/memory-sync.js +23 -7
- package/dist/hooks/mood-weather.js +45 -0
- package/dist/hooks/startup-orientation.js +86 -55
- package/dist/index.js +37 -0
- package/dist/lib/eval-matchers.js +49 -0
- package/dist/lib/exclude-channels.js +17 -6
- package/dist/lib/filters.js +46 -16
- package/dist/lib/loops-audit.js +438 -0
- package/dist/lib/ranking.js +79 -15
- package/dist/logger.js +16 -0
- package/dist/sync/markdown.js +57 -2
- package/dist/tools/emotional-arc.js +60 -0
- package/dist/tools/remember.js +9 -1
- package/openclaw.plugin.json +8 -2
- package/package.json +2 -2
package/dist/sync/markdown.js
CHANGED
|
@@ -40,6 +40,7 @@ function readMarkdownFile(filePath) {
|
|
|
40
40
|
title,
|
|
41
41
|
content: body.trim(),
|
|
42
42
|
hyperspellId: frontmatter.hyperspell_id || null,
|
|
43
|
+
graphEntity: frontmatter.graph_entity === "true",
|
|
43
44
|
};
|
|
44
45
|
}
|
|
45
46
|
catch (err) {
|
|
@@ -271,6 +272,39 @@ export function getMemoryFiles(workspaceDir, ignorePaths) {
|
|
|
271
272
|
walk(memoryDir);
|
|
272
273
|
return results;
|
|
273
274
|
}
|
|
275
|
+
/** Resolve a watchPath (workspace-relative or absolute) to an absolute path. */
|
|
276
|
+
export function resolveWatchPath(workspaceDir, p) {
|
|
277
|
+
return path.isAbsolute(p) ? p : path.join(workspaceDir, p);
|
|
278
|
+
}
|
|
279
|
+
/** Slug for unlabeled watchPaths: "notes/brainstem" -> "notes_brainstem". */
|
|
280
|
+
function watchPathSlug(workspaceDir, resolved) {
|
|
281
|
+
const rel = path.relative(workspaceDir, resolved);
|
|
282
|
+
const base = rel.startsWith("..") ? path.basename(resolved) : rel;
|
|
283
|
+
return base.split(path.sep).join("_").replace(/[^a-zA-Z0-9_]/g, "_");
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Provenance for a syncable file: the longest-prefix-matching watchPath's
|
|
287
|
+
* label (explicit `source` or derived slug), else "memory" for memory/ files.
|
|
288
|
+
* Longest prefix wins so a watchPath nested inside another (or inside memory/)
|
|
289
|
+
* keeps its more specific label.
|
|
290
|
+
*/
|
|
291
|
+
export function resolveSyncSource(filePath, workspaceDir, watchPaths) {
|
|
292
|
+
let best;
|
|
293
|
+
for (const wp of watchPaths) {
|
|
294
|
+
const resolved = resolveWatchPath(workspaceDir, wp.path);
|
|
295
|
+
if (filePath === resolved || filePath.startsWith(resolved + path.sep)) {
|
|
296
|
+
if (!best || resolved.length > best.len) {
|
|
297
|
+
best = { len: resolved.length, label: wp.source ?? watchPathSlug(workspaceDir, resolved) };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (best)
|
|
302
|
+
return best.label;
|
|
303
|
+
const memoryDir = path.join(workspaceDir, "memory");
|
|
304
|
+
if (filePath.startsWith(memoryDir + path.sep))
|
|
305
|
+
return "memory";
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
274
308
|
/**
|
|
275
309
|
* Collect all syncable files based on configuration.
|
|
276
310
|
* Includes memory/*.md by default, plus any additional watchPaths.
|
|
@@ -280,7 +314,7 @@ export function getSyncableFiles(workspaceDir, watchPaths, ignorePaths) {
|
|
|
280
314
|
const files = getMemoryFiles(workspaceDir, ignorePaths);
|
|
281
315
|
if (watchPaths) {
|
|
282
316
|
for (const wp of watchPaths) {
|
|
283
|
-
const resolved =
|
|
317
|
+
const resolved = resolveWatchPath(workspaceDir, wp.path);
|
|
284
318
|
if (!fs.existsSync(resolved))
|
|
285
319
|
continue;
|
|
286
320
|
const stat = fs.statSync(resolved);
|
|
@@ -333,6 +367,13 @@ export async function syncMarkdownFile(client, filePath, options) {
|
|
|
333
367
|
metadata: {
|
|
334
368
|
openclaw_source: "memory_sync",
|
|
335
369
|
file_path: filePath,
|
|
370
|
+
// Content origin (memory/ vs a labeled watchPath). Additive key —
|
|
371
|
+
// openclaw_source stays the pipeline discriminator that retrieval
|
|
372
|
+
// filters and startup-orientation dedupe depend on.
|
|
373
|
+
...(options?.syncSource ? { openclaw_sync_source: options.syncSource } : {}),
|
|
374
|
+
// Honor the extraction prompt's contract: entity files synced back
|
|
375
|
+
// to Hyperspell must be skippable by the Memory Network scan.
|
|
376
|
+
...(file.graphEntity ? { graph_entity: "true" } : {}),
|
|
336
377
|
},
|
|
337
378
|
userId: options?.userId,
|
|
338
379
|
});
|
|
@@ -426,6 +467,14 @@ export async function syncMarkdownFileSectionized(client, filePath, workspaceDir
|
|
|
426
467
|
file_name: fileName,
|
|
427
468
|
section_title: section.title,
|
|
428
469
|
content_hash: section.contentHash,
|
|
470
|
+
// Content origin (memory/ vs a labeled watchPath). Additive key —
|
|
471
|
+
// openclaw_source stays the pipeline discriminator that retrieval
|
|
472
|
+
// filters and startup-orientation dedupe depend on.
|
|
473
|
+
...(options?.syncSource ? { openclaw_sync_source: options.syncSource } : {}),
|
|
474
|
+
// Honor the extraction prompt's contract: entity files synced
|
|
475
|
+
// back to Hyperspell must be skippable by the Memory Network
|
|
476
|
+
// scan, or the extractor is re-fed its own output every cycle.
|
|
477
|
+
...(file.graphEntity ? { graph_entity: "true" } : {}),
|
|
429
478
|
},
|
|
430
479
|
userId: options?.userId,
|
|
431
480
|
});
|
|
@@ -503,7 +552,12 @@ export async function syncAllMemoryFiles(client, workspaceDir, options) {
|
|
|
503
552
|
let failed = 0;
|
|
504
553
|
const errors = [];
|
|
505
554
|
for (const filePath of files) {
|
|
506
|
-
|
|
555
|
+
// Legacy bulk mode only walks memory/, so everything it uploads is
|
|
556
|
+
// memory-origin by construction.
|
|
557
|
+
const result = await syncMarkdownFile(client, filePath, {
|
|
558
|
+
userId: options?.userId,
|
|
559
|
+
syncSource: "memory",
|
|
560
|
+
});
|
|
507
561
|
if (result.success) {
|
|
508
562
|
synced++;
|
|
509
563
|
log.info(`Synced: ${path.basename(filePath)} -> ${result.resourceId}`);
|
|
@@ -564,6 +618,7 @@ export async function syncAllFilesSectionized(client, workspaceDir, options) {
|
|
|
564
618
|
for (const filePath of candidates) {
|
|
565
619
|
const result = await syncMarkdownFileSectionized(client, filePath, workspaceDir, {
|
|
566
620
|
userId: options?.userId,
|
|
621
|
+
syncSource: resolveSyncSource(filePath, workspaceDir, options?.watchPaths ?? []),
|
|
567
622
|
});
|
|
568
623
|
totalSynced += result.synced;
|
|
569
624
|
totalSkipped += result.skipped;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { buildEmotionalContext, EMOTIONAL_ARC_LIMIT, fetchRecentOrLatest, looksLikeRawTranscript, } from "../hooks/emotional-state.js";
|
|
3
|
+
import { log } from "../logger.js";
|
|
4
|
+
/** Hard cap so the agent can't ask the backend for an unbounded history. */
|
|
5
|
+
const MAX_ARC_LIMIT = 10;
|
|
6
|
+
export function createEmotionalArcToolFactory(client, cfg) {
|
|
7
|
+
// No sender context needed: the register is keyed to cfg.relationshipId, not
|
|
8
|
+
// the current speaker — but keep the factory signature so toolUnlessQuarantined
|
|
9
|
+
// can wrap it like search/remember.
|
|
10
|
+
return (_ctx) => ({
|
|
11
|
+
name: "hyperspell_emotional_arc",
|
|
12
|
+
label: "Emotional Arc",
|
|
13
|
+
description: "Fetch the recent emotional arc of your relationship with this user — the same emotional-context block that is injected at session start. Use it when that block is no longer in your history (e.g. after the conversation was compacted) or when you genuinely want to reflect on how the relationship has been feeling before responding. Returns the most recent emotional registers, newest first. Let the trajectory inform your tone — don't recite it back to the user.",
|
|
14
|
+
parameters: Type.Object({
|
|
15
|
+
limit: Type.Optional(Type.Number({
|
|
16
|
+
description: `How many recent registers to fetch (default ${EMOTIONAL_ARC_LIMIT}, max ${MAX_ARC_LIMIT})`,
|
|
17
|
+
})),
|
|
18
|
+
}),
|
|
19
|
+
async execute(_toolCallId, params) {
|
|
20
|
+
const limit = Math.min(Math.max(Math.floor(params.limit ?? EMOTIONAL_ARC_LIMIT), 1), MAX_ARC_LIMIT);
|
|
21
|
+
log.debug(`emotional-arc tool: limit=${limit} relationshipId=${cfg.relationshipId ?? "(default)"}`);
|
|
22
|
+
try {
|
|
23
|
+
const states = await fetchRecentOrLatest(client, cfg, limit);
|
|
24
|
+
// Same placeholder filter as the injection path: for ~10s after a
|
|
25
|
+
// store, the register can be the RAW input transcript (status=pending),
|
|
26
|
+
// not a distilled feeling. Returning that would pollute tone.
|
|
27
|
+
const usable = states.filter((s) => s.summary && !looksLikeRawTranscript(s.summary));
|
|
28
|
+
if (usable.length === 0) {
|
|
29
|
+
const text = states.length > 0
|
|
30
|
+
? "The latest emotional register is still being extracted — try again in a few seconds."
|
|
31
|
+
: "No emotional arc recorded yet for this relationship. It builds up as real conversations end.";
|
|
32
|
+
return { content: [{ type: "text", text }] };
|
|
33
|
+
}
|
|
34
|
+
// Deliberately identical to the session-start injection block
|
|
35
|
+
// (buildEmotionalContext), so a post-compaction re-fetch restores
|
|
36
|
+
// exactly what a fresh session would have received. Mood weather is
|
|
37
|
+
// intentionally NOT included — it is an injection-only session
|
|
38
|
+
// override, rolled at most once per session; a tool call must never
|
|
39
|
+
// re-roll or reveal it (do not import from mood-weather.ts here).
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{ type: "text", text: buildEmotionalContext(usable) },
|
|
43
|
+
],
|
|
44
|
+
details: { count: usable.length },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
log.error("emotional-arc tool failed", err);
|
|
49
|
+
return {
|
|
50
|
+
content: [
|
|
51
|
+
{
|
|
52
|
+
type: "text",
|
|
53
|
+
text: `Failed to fetch emotional arc: ${err instanceof Error ? err.message : String(err)}`,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
package/dist/tools/remember.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { channelIdFromCtx } from "../lib/exclude-channels.js";
|
|
2
3
|
import { getDefaultWriteScope, resolveRole, resolveUser, routeWrite, } from "../lib/sender.js";
|
|
3
4
|
import { resolveCurrentSessionId } from "../lib/session.js";
|
|
4
5
|
import { isMultiSpeaker } from "../lib/speaker-tracker.js";
|
|
@@ -85,12 +86,19 @@ export function createRememberToolFactory(client, cfg) {
|
|
|
85
86
|
userId = resolved?.userId;
|
|
86
87
|
}
|
|
87
88
|
log.debug(`remember tool: "${params.text.slice(0, 50)}..." date=${params.date ?? "now"} userId=${userId} scope=${scope}`);
|
|
89
|
+
// Tag with the originating conversation so purge-channel can find this
|
|
90
|
+
// memory if the channel is quarantined later (the tool is already
|
|
91
|
+
// suppressed in channels that are quarantined NOW).
|
|
92
|
+
const channelId = channelIdFromCtx(ctx);
|
|
88
93
|
try {
|
|
89
94
|
await client.addMemory(params.text, {
|
|
90
95
|
title: params.title,
|
|
91
96
|
date: params.date,
|
|
92
97
|
collection,
|
|
93
|
-
metadata: {
|
|
98
|
+
metadata: {
|
|
99
|
+
source: "openclaw_tool",
|
|
100
|
+
...(channelId ? { openclaw_channel_id: channelId } : {}),
|
|
101
|
+
},
|
|
94
102
|
userId,
|
|
95
103
|
scope: scopingEnabled ? scope : undefined,
|
|
96
104
|
});
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
"id": "openclaw-hyperspell",
|
|
3
3
|
"name": "Hyperspell",
|
|
4
4
|
"description": "Context and memory for your AI agents — search across Notion, Slack, Google Drive, and more",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.20.0",
|
|
6
6
|
"kind": "memory",
|
|
7
7
|
"contracts": {
|
|
8
8
|
"tools": [
|
|
9
9
|
"hyperspell_search",
|
|
10
10
|
"hyperspell_remember",
|
|
11
|
+
"hyperspell_emotional_arc",
|
|
11
12
|
"hyperspell_network_scan",
|
|
12
13
|
"hyperspell_network_write",
|
|
13
14
|
"hyperspell_network_complete"
|
|
@@ -48,7 +49,7 @@
|
|
|
48
49
|
},
|
|
49
50
|
"excludeChannels": {
|
|
50
51
|
"label": "Excluded Channels",
|
|
51
|
-
"help": "Conversation/channel ids fully quarantined from memory: no context injection, no memory writes, no memory tools in those sessions. Threads inside an excluded channel inherit the quarantine.",
|
|
52
|
+
"help": "Conversation/channel ids fully quarantined from memory: no context injection, no memory writes, no memory tools in those sessions. Threads inside an excluded channel inherit the quarantine. Forward-only; already-synced content is not removed (see purge-channel CLI command).",
|
|
52
53
|
"advanced": true
|
|
53
54
|
},
|
|
54
55
|
"startupOrientation": {
|
|
@@ -74,6 +75,11 @@
|
|
|
74
75
|
"help": "Maximum memories injected into context per turn",
|
|
75
76
|
"advanced": true
|
|
76
77
|
},
|
|
78
|
+
"ranking": {
|
|
79
|
+
"label": "Composite Ranking",
|
|
80
|
+
"help": "Rank memories by more than raw relevance: boost curated memory and the active story, penalize auto-saved conversation fragments. Set storyTerms to 3-15 distinctive names/terms of the active thread (characters, codenames, invented words; matched case-insensitively at word boundaries) so it outranks chatter; update the list when the active story changes. { enabled, storyTerms, curationBoost, storyBoost, chatterPenalty, candidateMultiplier, chatterQuota }",
|
|
81
|
+
"advanced": true
|
|
82
|
+
},
|
|
77
83
|
"debug": {
|
|
78
84
|
"label": "Debug Logging",
|
|
79
85
|
"help": "Enable verbose debug logs for API calls and responses",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenClaw Hyperspell memory plugin",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"check-types": "tsc --noEmit",
|
|
39
39
|
"lint": "bunx @biomejs/biome ci .",
|
|
40
40
|
"lint:fix": "bunx @biomejs/biome check --write .",
|
|
41
|
-
"test": "node --test --experimental-strip-types client.test.ts lib/sender.test.ts lib/filters.test.ts lib/search-error.test.ts lib/session.test.ts lib/exclude-channels.test.ts lib/ranking.test.ts tools/search.test.ts config.test.ts sync/markdown.test.ts hooks/auto-trace.test.ts hooks/auto-context.test.ts hooks/hot-buffer.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts hooks/mood-weather.test.ts"
|
|
41
|
+
"test": "node --test --experimental-strip-types client.test.ts logger.test.ts lib/sender.test.ts lib/filters.test.ts lib/search-error.test.ts lib/session.test.ts lib/exclude-channels.test.ts lib/ranking.test.ts lib/eval-matchers.test.ts lib/loops-audit.test.ts tools/search.test.ts tools/emotional-arc.test.ts commands/preview.test.ts commands/purge-channel.test.ts config.test.ts sync/markdown.test.ts graph/ops.test.ts hooks/auto-trace.test.ts hooks/auto-context.test.ts hooks/hot-buffer.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts hooks/mood-weather.test.ts eval/eval.test.ts"
|
|
42
42
|
},
|
|
43
43
|
"openclaw": {
|
|
44
44
|
"extensions": [
|