@hyperspell/openclaw-hyperspell 0.19.0 → 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 +180 -10
- package/dist/client.js +14 -0
- package/dist/commands/setup.js +15 -6
- package/dist/commands/slash.js +40 -0
- package/dist/config.js +43 -4
- package/dist/graph/ops.js +36 -3
- package/dist/hooks/auto-context.js +11 -4
- package/dist/hooks/emotional-state.js +19 -8
- package/dist/hooks/memory-sync.js +23 -7
- package/dist/hooks/mood-weather.js +45 -0
- package/dist/hooks/startup-orientation.js +10 -1
- package/dist/index.js +16 -0
- package/dist/lib/filters.js +39 -11
- package/dist/lib/loops-audit.js +438 -0
- package/dist/lib/ranking.js +37 -2
- package/dist/logger.js +16 -0
- package/dist/sync/markdown.js +57 -2
- package/openclaw.plugin.json +6 -1
- package/package.json +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
2
|
import { getWorkspaceDir } from "../config.js";
|
|
3
3
|
import { log } from "../logger.js";
|
|
4
|
-
import { syncMarkdownFile, syncMarkdownFileSectionized, syncAllMemoryFiles, syncAllFilesSectionized, } from "../sync/markdown.js";
|
|
4
|
+
import { resolveSyncSource, resolveWatchPath, syncMarkdownFile, syncMarkdownFileSectionized, syncAllMemoryFiles, syncAllFilesSectionized, } from "../sync/markdown.js";
|
|
5
5
|
/**
|
|
6
6
|
* Build a handler for file change events that syncs markdown files to Hyperspell.
|
|
7
7
|
*
|
|
@@ -21,17 +21,27 @@ export function buildFileSyncHandler(client, cfg) {
|
|
|
21
21
|
const debounceMs = cfg.syncMemoriesConfig.debounceMs;
|
|
22
22
|
const ignoreDirs = new Set(cfg.syncMemoriesConfig.ignorePaths);
|
|
23
23
|
// Resolve additional watch paths to absolute paths for matching
|
|
24
|
-
const resolvedWatchPaths = (watchPaths ?? []).map((wp) =>
|
|
24
|
+
const resolvedWatchPaths = (watchPaths ?? []).map((wp) => resolveWatchPath(workspaceDir, wp.path));
|
|
25
25
|
/**
|
|
26
26
|
* Mirror the bulk walk's exclusions on the live path: a file under a
|
|
27
27
|
* dot-directory (e.g. .dreams) or an ignored directory (e.g. dreaming) must
|
|
28
28
|
* not live-sync either, or an edit would re-ingest exactly what the walk
|
|
29
|
-
* skips.
|
|
29
|
+
* skips. Applies against every watch root (memory/ AND each watchPath), so
|
|
30
|
+
* e.g. notes/brainstem/.drafts/x.md is excluded like the walk excludes it.
|
|
31
|
+
* The longest containing root wins: segments inside an explicitly configured
|
|
32
|
+
* watchPath are judged, the watchPath's own path is not.
|
|
30
33
|
*/
|
|
31
34
|
function isIgnoredPath(filePath) {
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
let root;
|
|
36
|
+
for (const candidate of [memoryDir, ...resolvedWatchPaths]) {
|
|
37
|
+
if (filePath === candidate || filePath.startsWith(candidate + path.sep)) {
|
|
38
|
+
if (!root || candidate.length > root.length)
|
|
39
|
+
root = candidate;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!root)
|
|
34
43
|
return false;
|
|
44
|
+
const rel = path.relative(root, filePath);
|
|
35
45
|
const segments = rel.split(path.sep).slice(0, -1); // directory segments only
|
|
36
46
|
return segments.some((seg) => seg.startsWith(".") || ignoreDirs.has(seg));
|
|
37
47
|
}
|
|
@@ -59,9 +69,12 @@ export function buildFileSyncHandler(client, cfg) {
|
|
|
59
69
|
async function doSync(filePath) {
|
|
60
70
|
const fileName = path.basename(filePath);
|
|
61
71
|
log.info(`Memory file changed: ${fileName}`);
|
|
72
|
+
// Same provenance the startup bulk sync stamps, so a file gets identical
|
|
73
|
+
// openclaw_sync_source metadata whichever path ingests it first.
|
|
74
|
+
const syncSource = resolveSyncSource(filePath, workspaceDir, watchPaths ?? []);
|
|
62
75
|
try {
|
|
63
76
|
if (sectionize) {
|
|
64
|
-
const result = await syncMarkdownFileSectionized(client, filePath, workspaceDir, { userId: syncUserId });
|
|
77
|
+
const result = await syncMarkdownFileSectionized(client, filePath, workspaceDir, { userId: syncUserId, syncSource });
|
|
65
78
|
if (result.synced > 0 || result.removed > 0) {
|
|
66
79
|
log.info(`Section-synced ${fileName}: ${result.synced} synced, ${result.skipped} unchanged, ${result.removed} removed`);
|
|
67
80
|
}
|
|
@@ -77,7 +90,10 @@ export function buildFileSyncHandler(client, cfg) {
|
|
|
77
90
|
}
|
|
78
91
|
else {
|
|
79
92
|
// Legacy whole-file sync
|
|
80
|
-
const result = await syncMarkdownFile(client, filePath, {
|
|
93
|
+
const result = await syncMarkdownFile(client, filePath, {
|
|
94
|
+
userId: syncUserId,
|
|
95
|
+
syncSource,
|
|
96
|
+
});
|
|
81
97
|
if (result.success) {
|
|
82
98
|
log.info(`Synced ${fileName} -> ${result.resourceId}`);
|
|
83
99
|
}
|
|
@@ -22,11 +22,16 @@
|
|
|
22
22
|
* store path. One random cold morning must NOT calcify into "we've been
|
|
23
23
|
* distant lately." One day's weather, then gone. (The store handler in
|
|
24
24
|
* emotional-state.ts is untouched, so this is enforced by construction.)
|
|
25
|
+
* A private, recall-excluded observability record IS written per roll
|
|
26
|
+
* (issue #71) — see recordMoodRoll below; it is invisible to every
|
|
27
|
+
* injection/recall path, so the guarantee holds.
|
|
25
28
|
* - BOUNDED. The dice can make her *difficult* — short, contrary, melancholy,
|
|
26
29
|
* flat. They do NOT get to make her hurtful on purpose. "In a mood" is alive;
|
|
27
30
|
* "mean" is just a bad feature. Mood descriptions below stay on the right side
|
|
28
31
|
* of that line.
|
|
29
32
|
*/
|
|
33
|
+
import { MOOD_WEATHER_SOURCE } from "../lib/filters.js";
|
|
34
|
+
import { log } from "../logger.js";
|
|
30
35
|
/**
|
|
31
36
|
* The weather table. Warmer/lighter moods are weighted a touch heavier than the
|
|
32
37
|
* darker ones — not to defang it, but because a person who woke up cold *every*
|
|
@@ -111,3 +116,43 @@ export function buildMoodWeatherContext(mood) {
|
|
|
111
116
|
"</hyperspell-mood-weather>",
|
|
112
117
|
].join("\n");
|
|
113
118
|
}
|
|
119
|
+
/** Collection the roll records live in, so /moodweather can list them without a search. */
|
|
120
|
+
export const MOOD_WEATHER_COLLECTION = "mood-weather";
|
|
121
|
+
/**
|
|
122
|
+
* Fire-and-forget observability record for a mood roll (issue #71).
|
|
123
|
+
*
|
|
124
|
+
* This does NOT weaken the "does not write forward" contract above: the record
|
|
125
|
+
* goes to the generic vault store tagged openclaw_source="mood_weather", which
|
|
126
|
+
* excludeFilterFor() drops from every recall path (auto-context, the
|
|
127
|
+
* hyperspell_search tool, startup-orientation loops, knowledge graph). The
|
|
128
|
+
* emotional-state arc fetch reads a different endpoint entirely (GET
|
|
129
|
+
* /emotional-state[/recent], written only by storeEmotionalState), so it can
|
|
130
|
+
* never surface there. Queryable only via the dedicated /moodweather command.
|
|
131
|
+
*
|
|
132
|
+
* Written via client.addMemory (memories.add) — NEVER POST /messages: a
|
|
133
|
+
* /messages write carrying metadata renders the row non-retrievable (see the
|
|
134
|
+
* warning in lib/filters.ts), while memories.add metadata is proven to persist
|
|
135
|
+
* and be filterable (canary A in docs/filter-dialect-test.mjs).
|
|
136
|
+
*
|
|
137
|
+
* Deliberately not awaited: this sits on the first-turn injection hot path, and
|
|
138
|
+
* a logging write must never delay or break the session.
|
|
139
|
+
*/
|
|
140
|
+
export function recordMoodRoll(client, mood, opts) {
|
|
141
|
+
const rolledAt = new Date().toISOString();
|
|
142
|
+
void client
|
|
143
|
+
.addMemory(`Mood weather roll: woke up "${mood.id}" (${rolledAt}). Exogenous session mood — uncaused, session-only, never part of the relational register.`, {
|
|
144
|
+
title: `Mood weather — ${mood.id} (${rolledAt.slice(0, 10)})`,
|
|
145
|
+
collection: MOOD_WEATHER_COLLECTION,
|
|
146
|
+
metadata: {
|
|
147
|
+
openclaw_source: MOOD_WEATHER_SOURCE,
|
|
148
|
+
mood: mood.id,
|
|
149
|
+
rolled_at: rolledAt,
|
|
150
|
+
...(opts.sessionKey ? { session: opts.sessionKey } : {}),
|
|
151
|
+
...(opts.relationshipId ? { relationship_id: opts.relationshipId } : {}),
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
.catch((err) => {
|
|
155
|
+
// Fire-and-forget — observability must never break the session.
|
|
156
|
+
log.warn("mood-weather: roll record write failed (non-fatal)", err);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { excludeFilterFor } from "../lib/filters.js";
|
|
1
2
|
import { resolveUser } from "../lib/sender.js";
|
|
2
3
|
import { resolveCurrentSessionId } from "../lib/session.js";
|
|
3
4
|
import { isMultiSpeaker } from "../lib/speaker-tracker.js";
|
|
@@ -219,9 +220,14 @@ export async function gatherOrientation(client, cfg, userId) {
|
|
|
219
220
|
: Promise.resolve([]);
|
|
220
221
|
const [recentSettled, loopsSettled] = await Promise.allSettled([
|
|
221
222
|
recentFetch,
|
|
223
|
+
// Loops feed agent context, so tagged observability rows (agent_end
|
|
224
|
+
// traces, mood_weather rolls) must be dropped like on every other recall
|
|
225
|
+
// path — this also closes a pre-existing gap where agent_end traces
|
|
226
|
+
// could surface in the loops block.
|
|
222
227
|
client.search(so.loopsQuery, {
|
|
223
228
|
limit: so.loopsLimit,
|
|
224
229
|
userId,
|
|
230
|
+
filter: excludeFilterFor(cfg),
|
|
225
231
|
}),
|
|
226
232
|
]);
|
|
227
233
|
const recentOk = recentSettled.status === "fulfilled";
|
|
@@ -310,7 +316,10 @@ export function buildStartupOrientationHandler(client, cfg) {
|
|
|
310
316
|
return;
|
|
311
317
|
}
|
|
312
318
|
const blocks = [gathered.recentBlock, gathered.loopsBlock].filter((b) => b !== null);
|
|
313
|
-
log.debug
|
|
319
|
+
// log.diag, not debug: this one-line injection summary is the operator's
|
|
320
|
+
// only live signal that orientation fired (issue #118 — host drops plugin
|
|
321
|
+
// debug output from gateway.log).
|
|
322
|
+
log.diag(`startup-orientation: injecting recent=${gathered.recentCount} loops=${gathered.loopsCount}`);
|
|
314
323
|
return { prependContext: blocks.join("\n\n") };
|
|
315
324
|
};
|
|
316
325
|
}
|
package/dist/index.js
CHANGED
|
@@ -170,6 +170,22 @@ export default {
|
|
|
170
170
|
if (cfg.knowledgeGraph.enabled) {
|
|
171
171
|
registerNetworkTools(api, client, cfg);
|
|
172
172
|
}
|
|
173
|
+
else {
|
|
174
|
+
// Discoverability (issue #81): the Memory Network ships fully built but
|
|
175
|
+
// default-off. If memories are accumulating (hot buffer / auto-trace /
|
|
176
|
+
// emotional state) and the operator never made a knowledgeGraph decision,
|
|
177
|
+
// say so once at startup — otherwise the feature is undetectable without
|
|
178
|
+
// reading source. Keyed off the RAW config: an explicit `knowledgeGraph`
|
|
179
|
+
// key (even { enabled: false }) is a decision and suppresses this.
|
|
180
|
+
const memoryAccumulating = cfg.hotBuffer.enabled || cfg.autoTrace.enabled || cfg.emotionalContext;
|
|
181
|
+
if (rawConfig?.knowledgeGraph === undefined && memoryAccumulating) {
|
|
182
|
+
log.info("memories are accumulating but the Memory Network (knowledgeGraph) is not configured — " +
|
|
183
|
+
"no entity extraction into memory/people|projects|organizations|topics will run. " +
|
|
184
|
+
"Enable it via 'openclaw openclaw-hyperspell setup' (Memory Network step) or set " +
|
|
185
|
+
"knowledgeGraph.enabled: true, or silence this note with knowledgeGraph: { enabled: false }. " +
|
|
186
|
+
"See README § Memory Network.");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
173
189
|
// Register slash commands
|
|
174
190
|
registerCommands(api, client, cfg);
|
|
175
191
|
// Register service for lifecycle management
|
package/dist/lib/filters.js
CHANGED
|
@@ -6,6 +6,10 @@
|
|
|
6
6
|
* Filters match against memory METADATA keys by their bare name — the same
|
|
7
7
|
* convention `buildScopeFilter` uses (`openclaw_scope`, `openclaw_user`).
|
|
8
8
|
*/
|
|
9
|
+
/** Metadata tag on auto-trace session-end rows (see `sendTrace` in client.ts). */
|
|
10
|
+
export const AGENT_END_SOURCE = "agent_end";
|
|
11
|
+
/** Metadata tag on mood-weather roll records (see `recordMoodRoll` in hooks/mood-weather.ts). */
|
|
12
|
+
export const MOOD_WEATHER_SOURCE = "mood_weather";
|
|
9
13
|
/**
|
|
10
14
|
* Memories produced by the auto-trace session-end hook are tagged in metadata
|
|
11
15
|
* as `openclaw_source: "agent_end"` (see `sendTrace` in client.ts). Those should
|
|
@@ -34,22 +38,46 @@
|
|
|
34
38
|
* "openclaw_agent_end" — wrong on BOTH counts (the tag lives in metadata under
|
|
35
39
|
* `openclaw_source`, value `"agent_end"`), so it silently matched nothing.
|
|
36
40
|
*/
|
|
37
|
-
export const EXCLUDE_SESSION_END_FILTER = {
|
|
38
|
-
openclaw_source: { $ne: "agent_end" },
|
|
39
|
-
};
|
|
40
41
|
/**
|
|
41
42
|
* The exclude clause to apply for a given config — or `undefined` to skip the
|
|
42
|
-
* filter.
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* auto-trace-off agent with zero `agent_end` rows).
|
|
43
|
+
* filter. Each excluded tag is gated on the feature that writes it, for
|
|
44
|
+
* PERFORMANCE, not correctness (see the header comment): with the feature off
|
|
45
|
+
* there are no tagged rows to hide, so the ~1s predicate would be pure latency.
|
|
46
46
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
47
|
+
* - `agent_end` rows are written ONLY by the auto-trace hook → gate on
|
|
48
|
+
* `autoTrace.enabled` (verified live against an auto-trace-off agent with
|
|
49
|
+
* zero `agent_end` rows).
|
|
50
|
+
* - `mood_weather` rolls are recorded ONLY when the emotional-context handler
|
|
51
|
+
* is registered AND the dice are live → gate on both flags.
|
|
52
|
+
*
|
|
53
|
+
* Shape: a single excluded value keeps the proven plain-`$ne` form (byte-
|
|
54
|
+
* identical to the shipped filter, and post-#1921 it drops the tag while
|
|
55
|
+
* KEEPING untagged hot-buffer rows). Two values use `$nin`.
|
|
56
|
+
*
|
|
57
|
+
* ⚠️ The `$nin` two-value shape has NOT been re-verified live post-#1921 (the
|
|
58
|
+
* pre-#1921 truth table showed `$nin` diverging from `$ne`). Owner's post-merge
|
|
59
|
+
* step: run `node docs/filter-dialect-test.mjs` — the `$nin[agent_end,mood_weather]`
|
|
60
|
+
* row must show U=Y, A=N, M=N. Fallback plan if it fails: try the
|
|
61
|
+
* `$and[$ne,$ne]` probe row; if that also fails, gate back to single-value
|
|
62
|
+
* `$ne` for the two common one-feature-on configs, log a warning when both
|
|
63
|
+
* features are enabled, and file a backend dialect follow-up
|
|
64
|
+
* (docs/hyperspell-backend-followups.md style) for the both-on combo.
|
|
50
65
|
*/
|
|
51
66
|
export function excludeFilterFor(cfg) {
|
|
52
|
-
|
|
67
|
+
const excluded = [];
|
|
68
|
+
if (cfg.autoTrace.enabled)
|
|
69
|
+
excluded.push(AGENT_END_SOURCE);
|
|
70
|
+
// Mood rolls are recorded only when the emotional-context handler is
|
|
71
|
+
// registered AND the dice are live — same "no rows to hide → skip the
|
|
72
|
+
// ~1s predicate" gate as auto-trace.
|
|
73
|
+
if (cfg.emotionalContext && cfg.moodWeatherChance > 0) {
|
|
74
|
+
excluded.push(MOOD_WEATHER_SOURCE);
|
|
75
|
+
}
|
|
76
|
+
if (excluded.length === 0)
|
|
77
|
+
return undefined;
|
|
78
|
+
if (excluded.length === 1)
|
|
79
|
+
return { openclaw_source: { $ne: excluded[0] } };
|
|
80
|
+
return { openclaw_source: { $nin: excluded } };
|
|
53
81
|
}
|
|
54
82
|
/**
|
|
55
83
|
* Combine a caller-supplied filter with the session-end exclude via `$and`.
|