@hyperspell/openclaw-hyperspell 0.14.0 → 0.14.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 +16 -0
- package/dist/commands/setup.js +2 -0
- package/dist/config.js +12 -1
- package/dist/hooks/auto-context.js +1 -16
- package/dist/hooks/memory-sync.js +19 -1
- package/dist/index.js +12 -2
- package/dist/lib/filters.js +30 -0
- package/dist/sync/markdown.js +86 -10
- package/dist/tools/search.js +4 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -151,6 +151,22 @@ When `syncMemories: true`, the plugin syncs markdown files from your agent's wor
|
|
|
151
151
|
- The returned `resource_id` is stored in the file's YAML frontmatter as `hyperspell_id`
|
|
152
152
|
- On subsequent syncs, files with an existing `hyperspell_id` are updated rather than duplicated
|
|
153
153
|
- Files are synced automatically on startup and when they change
|
|
154
|
+
- Startup sync runs in the **background** — the agent boots immediately and sync proceeds without blocking it
|
|
155
|
+
- A per-section content hash is tracked in `<workspace>/.hyperspell-sync-hashes.json`, so unchanged sections are skipped on subsequent syncs (no re-ingestion)
|
|
156
|
+
|
|
157
|
+
**Tuning sync (object form):**
|
|
158
|
+
|
|
159
|
+
```jsonc
|
|
160
|
+
"syncMemories": {
|
|
161
|
+
"enabled": true,
|
|
162
|
+
"sectionize": true, // split files on ## headings into separate memories
|
|
163
|
+
"watchPaths": [], // extra files/dirs to sync beyond memory/
|
|
164
|
+
"debounceMs": 2000, // wait for writes to settle before syncing
|
|
165
|
+
"maxAgeDays": 30 // startup skips already-synced files older than this
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`maxAgeDays` bounds steady-state load: on startup, files whose mtime is older than the cutoff **and** already recorded in the sync manifest are skipped without re-reading. Files not yet in the manifest are always synced once regardless of age, and editing an old file bumps its mtime back into the window. Set to `0` to disable the cutoff.
|
|
154
170
|
|
|
155
171
|
**Example frontmatter after sync:**
|
|
156
172
|
|
package/dist/commands/setup.js
CHANGED
package/dist/config.js
CHANGED
|
@@ -249,7 +249,14 @@ export function parseConfig(raw) {
|
|
|
249
249
|
// object form must be too, or a typo (sectionise/debounceMS) is silently
|
|
250
250
|
// ignored and the user gets default behavior they didn't ask for.
|
|
251
251
|
if (typeof smRaw === "object" && smRaw !== null && !Array.isArray(smRaw)) {
|
|
252
|
-
assertAllowedKeys(smObj, [
|
|
252
|
+
assertAllowedKeys(smObj, [
|
|
253
|
+
"enabled",
|
|
254
|
+
"sectionize",
|
|
255
|
+
"watchPaths",
|
|
256
|
+
"debounceMs",
|
|
257
|
+
"maxAgeDays",
|
|
258
|
+
"ignorePaths",
|
|
259
|
+
], "hyperspell.syncMemories");
|
|
253
260
|
}
|
|
254
261
|
return {
|
|
255
262
|
apiKey,
|
|
@@ -280,6 +287,10 @@ export function parseConfig(raw) {
|
|
|
280
287
|
? smObj.watchPaths
|
|
281
288
|
: [],
|
|
282
289
|
debounceMs: smObj.debounceMs ?? 2000,
|
|
290
|
+
maxAgeDays: smObj.maxAgeDays ?? 30,
|
|
291
|
+
ignorePaths: Array.isArray(smObj.ignorePaths)
|
|
292
|
+
? smObj.ignorePaths
|
|
293
|
+
: ["dreaming"],
|
|
283
294
|
},
|
|
284
295
|
sources: parseSources(cfg.sources),
|
|
285
296
|
maxResults: cfg.maxResults ?? 10,
|
|
@@ -1,21 +1,6 @@
|
|
|
1
1
|
import { buildScopeFilter, getCanReadScopes, resolveUser, } from "../lib/sender.js";
|
|
2
|
+
import { EXCLUDE_SESSION_END_FILTER, mergeWithExclude } from "../lib/filters.js";
|
|
2
3
|
import { log } from "../logger.js";
|
|
3
|
-
/**
|
|
4
|
-
* Memories produced by session-end hooks (auto-trace, emotional-state) are
|
|
5
|
-
* tagged `source: "openclaw_agent_end"`. The emotional-state hook already has
|
|
6
|
-
* a dedicated fetch path (`getEmotionalState` + `<hyperspell-emotional-context>`
|
|
7
|
-
* injection), so those memories should NOT also surface via generic retrieval —
|
|
8
|
-
* double-injection dilutes results and replays the conversation verbatim.
|
|
9
|
-
* Exclude them here at the search filter.
|
|
10
|
-
*/
|
|
11
|
-
const EXCLUDE_SESSION_END_FILTER = {
|
|
12
|
-
source: { $ne: "openclaw_agent_end" },
|
|
13
|
-
};
|
|
14
|
-
function mergeWithExclude(base) {
|
|
15
|
-
if (!base)
|
|
16
|
-
return EXCLUDE_SESSION_END_FILTER;
|
|
17
|
-
return { $and: [base, EXCLUDE_SESSION_END_FILTER] };
|
|
18
|
-
}
|
|
19
4
|
function formatRelativeTime(isoTimestamp) {
|
|
20
5
|
try {
|
|
21
6
|
const dt = new Date(isoTimestamp);
|
|
@@ -19,8 +19,22 @@ export function buildFileSyncHandler(client, cfg) {
|
|
|
19
19
|
const sectionize = cfg.syncMemoriesConfig.sectionize;
|
|
20
20
|
const watchPaths = cfg.syncMemoriesConfig.watchPaths;
|
|
21
21
|
const debounceMs = cfg.syncMemoriesConfig.debounceMs;
|
|
22
|
+
const ignoreDirs = new Set(cfg.syncMemoriesConfig.ignorePaths);
|
|
22
23
|
// Resolve additional watch paths to absolute paths for matching
|
|
23
24
|
const resolvedWatchPaths = (watchPaths ?? []).map((wp) => wp.startsWith("/") ? wp : path.join(workspaceDir, wp));
|
|
25
|
+
/**
|
|
26
|
+
* Mirror the bulk walk's exclusions on the live path: a file under a
|
|
27
|
+
* dot-directory (e.g. .dreams) or an ignored directory (e.g. dreaming) must
|
|
28
|
+
* not live-sync either, or an edit would re-ingest exactly what the walk
|
|
29
|
+
* skips.
|
|
30
|
+
*/
|
|
31
|
+
function isIgnoredPath(filePath) {
|
|
32
|
+
const rel = path.relative(memoryDir, filePath);
|
|
33
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
34
|
+
return false;
|
|
35
|
+
const segments = rel.split(path.sep).slice(0, -1); // directory segments only
|
|
36
|
+
return segments.some((seg) => seg.startsWith(".") || ignoreDirs.has(seg));
|
|
37
|
+
}
|
|
24
38
|
// Debounce map: filePath -> timeout handle
|
|
25
39
|
const debounceTimers = new Map();
|
|
26
40
|
/**
|
|
@@ -29,6 +43,8 @@ export function buildFileSyncHandler(client, cfg) {
|
|
|
29
43
|
function isSyncable(filePath) {
|
|
30
44
|
if (!filePath.endsWith(".md"))
|
|
31
45
|
return false;
|
|
46
|
+
if (isIgnoredPath(filePath))
|
|
47
|
+
return false;
|
|
32
48
|
// Always sync memory/ files
|
|
33
49
|
if (filePath.startsWith(memoryDir + path.sep))
|
|
34
50
|
return true;
|
|
@@ -106,8 +122,10 @@ export async function syncMemoriesOnStartup(client, workspaceDir, options) {
|
|
|
106
122
|
const result = await syncAllFilesSectionized(client, workspaceDir, {
|
|
107
123
|
userId: options.userId,
|
|
108
124
|
watchPaths: options.watchPaths,
|
|
125
|
+
maxAgeDays: options.maxAgeDays,
|
|
126
|
+
ignorePaths: options.ignorePaths,
|
|
109
127
|
});
|
|
110
|
-
log.info(`Section sync complete: ${result.synced} synced, ${result.skipped} unchanged, ${result.removed} removed, ${result.failed} failed`);
|
|
128
|
+
log.info(`Section sync complete: ${result.synced} synced, ${result.skipped} unchanged, ${result.agedOut} aged-out, ${result.removed} removed, ${result.failed} failed`);
|
|
111
129
|
if (result.failed > 0) {
|
|
112
130
|
for (const error of result.errors) {
|
|
113
131
|
log.error(` - ${error}`);
|
package/dist/index.js
CHANGED
|
@@ -117,13 +117,23 @@ export default {
|
|
|
117
117
|
id: "openclaw-hyperspell",
|
|
118
118
|
start: async () => {
|
|
119
119
|
api.logger.info("hyperspell: connected");
|
|
120
|
-
// Sync memories on startup if enabled
|
|
120
|
+
// Sync memories on startup if enabled.
|
|
121
|
+
//
|
|
122
|
+
// Deliberately NOT awaited: the bulk sync is sequential and
|
|
123
|
+
// network-bound (one request per changed section), so awaiting
|
|
124
|
+
// it here stalls the agent's startup until the entire corpus
|
|
125
|
+
// has been processed. Run it in the background and let the
|
|
126
|
+
// agent come up immediately; failures are logged, not fatal.
|
|
121
127
|
if (cfg.syncMemories) {
|
|
122
128
|
const workspaceDir = getWorkspaceDir();
|
|
123
|
-
|
|
129
|
+
void syncMemoriesOnStartup(client, workspaceDir, {
|
|
124
130
|
userId: cfg.multiUser?.sharedUserId,
|
|
125
131
|
sectionize: cfg.syncMemoriesConfig.sectionize,
|
|
126
132
|
watchPaths: cfg.syncMemoriesConfig.watchPaths,
|
|
133
|
+
maxAgeDays: cfg.syncMemoriesConfig.maxAgeDays,
|
|
134
|
+
ignorePaths: cfg.syncMemoriesConfig.ignorePaths,
|
|
135
|
+
}).catch((err) => {
|
|
136
|
+
api.logger.error("hyperspell: background memory sync failed", err);
|
|
127
137
|
});
|
|
128
138
|
}
|
|
129
139
|
},
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Hyperspell `options.filter` clauses, used by every retrieval path
|
|
3
|
+
* (auto-context hook + the hyperspell_search tool) so filtering stays
|
|
4
|
+
* consistent across them.
|
|
5
|
+
*
|
|
6
|
+
* Filters match against memory METADATA keys by their bare name — the same
|
|
7
|
+
* convention `buildScopeFilter` uses (`openclaw_scope`, `openclaw_user`).
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Memories produced by session-end hooks (auto-trace, emotional-state) are
|
|
11
|
+
* tagged in metadata as `openclaw_source: "agent_end"` (see `sendTrace` in
|
|
12
|
+
* client.ts). Those should NOT surface via generic retrieval: the
|
|
13
|
+
* emotional-state hook injects them through its own dedicated path, and
|
|
14
|
+
* replaying whole sanitized transcripts back into context creates a
|
|
15
|
+
* self-amplifying pollution loop. Exclude them at the search filter.
|
|
16
|
+
*
|
|
17
|
+
* NOTE: an earlier version of this filter checked the top-level `source` field
|
|
18
|
+
* for the value `"openclaw_agent_end"` — wrong on BOTH counts (the tag lives in
|
|
19
|
+
* metadata under `openclaw_source`, and its value is `"agent_end"`), so it
|
|
20
|
+
* silently matched nothing and excluded no traces.
|
|
21
|
+
*/
|
|
22
|
+
export const EXCLUDE_SESSION_END_FILTER = {
|
|
23
|
+
openclaw_source: { $ne: "agent_end" },
|
|
24
|
+
};
|
|
25
|
+
/** Combine a caller-supplied filter with the session-end exclude via `$and`. */
|
|
26
|
+
export function mergeWithExclude(base) {
|
|
27
|
+
if (!base)
|
|
28
|
+
return EXCLUDE_SESSION_END_FILTER;
|
|
29
|
+
return { $and: [base, EXCLUDE_SESSION_END_FILTER] };
|
|
30
|
+
}
|
package/dist/sync/markdown.js
CHANGED
|
@@ -168,10 +168,14 @@ export function loadManifest(workspaceDir) {
|
|
|
168
168
|
try {
|
|
169
169
|
if (fs.existsSync(p)) {
|
|
170
170
|
const parsed = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
171
|
+
// Forward-migrate rather than discard. Throwing the manifest away on a
|
|
172
|
+
// version bump means every section re-uploads on the next run — the
|
|
173
|
+
// exact full-re-ingest load this guard exists to prevent. As long as the
|
|
174
|
+
// `files` map is structurally a record we keep it: the per-section hash
|
|
175
|
+
// check still gates every upload, so a stale-but-readable manifest is
|
|
176
|
+
// strictly better than an empty one. Only a missing/corrupt `files`
|
|
177
|
+
// (truly unusable) falls back to empty.
|
|
178
|
+
if (parsed && typeof parsed.files === "object" && parsed.files !== null) {
|
|
175
179
|
return { version: MANIFEST_VERSION, files: parsed.files };
|
|
176
180
|
}
|
|
177
181
|
}
|
|
@@ -183,11 +187,24 @@ export function loadManifest(workspaceDir) {
|
|
|
183
187
|
}
|
|
184
188
|
export function saveManifest(workspaceDir, manifest) {
|
|
185
189
|
const p = manifestPath(workspaceDir);
|
|
190
|
+
// Atomic write: a process killed mid-write (deploy/restart) would otherwise
|
|
191
|
+
// leave a truncated JSON file, which loadManifest can only treat as corrupt
|
|
192
|
+
// -> empty -> full re-ingest. Write to a temp sibling, then rename (atomic
|
|
193
|
+
// on the same filesystem) so readers only ever see a complete manifest.
|
|
194
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
186
195
|
try {
|
|
187
|
-
fs.writeFileSync(
|
|
196
|
+
fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2));
|
|
197
|
+
fs.renameSync(tmp, p);
|
|
188
198
|
}
|
|
189
199
|
catch (err) {
|
|
190
200
|
log.error("Failed to write sync manifest", err);
|
|
201
|
+
try {
|
|
202
|
+
if (fs.existsSync(tmp))
|
|
203
|
+
fs.unlinkSync(tmp);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
/* best-effort cleanup */
|
|
207
|
+
}
|
|
191
208
|
}
|
|
192
209
|
}
|
|
193
210
|
/**
|
|
@@ -206,15 +223,38 @@ export function withSyncLock(fn) {
|
|
|
206
223
|
// ---------------------------------------------------------------------------
|
|
207
224
|
// Public API — file-level sync (original, kept for backward compat)
|
|
208
225
|
// ---------------------------------------------------------------------------
|
|
209
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Directory names skipped by default when walking memory/. The dreaming engine
|
|
228
|
+
* writes first-person dream journals under memory/dreaming/ that are not user
|
|
229
|
+
* memories; ingesting them pollutes retrieval (they score highly on emotional/
|
|
230
|
+
* associative queries and crowd out real memories). Configurable via
|
|
231
|
+
* `syncMemories.ignorePaths`.
|
|
232
|
+
*/
|
|
233
|
+
export const DEFAULT_IGNORE_DIRS = ["dreaming"];
|
|
234
|
+
/**
|
|
235
|
+
* Skip a directory entry during the walk when it is a dot-directory/file
|
|
236
|
+
* (e.g. `.dreams`, engine scaffolding) or a directory named in `ignore`.
|
|
237
|
+
* Dot-entries are always skipped regardless of the configured list.
|
|
238
|
+
*/
|
|
239
|
+
function isIgnoredEntry(entry, ignore) {
|
|
240
|
+
if (entry.name.startsWith("."))
|
|
241
|
+
return true;
|
|
242
|
+
if (entry.isDirectory() && ignore.has(entry.name))
|
|
243
|
+
return true;
|
|
244
|
+
return false;
|
|
245
|
+
}
|
|
246
|
+
export function getMemoryFiles(workspaceDir, ignorePaths) {
|
|
210
247
|
const memoryDir = path.join(workspaceDir, "memory");
|
|
211
248
|
if (!fs.existsSync(memoryDir)) {
|
|
212
249
|
return [];
|
|
213
250
|
}
|
|
251
|
+
const ignore = new Set(ignorePaths ?? DEFAULT_IGNORE_DIRS);
|
|
214
252
|
const results = [];
|
|
215
253
|
function walk(dir) {
|
|
216
254
|
try {
|
|
217
255
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
256
|
+
if (isIgnoredEntry(entry, ignore))
|
|
257
|
+
continue;
|
|
218
258
|
const fullPath = path.join(dir, entry.name);
|
|
219
259
|
if (entry.isDirectory()) {
|
|
220
260
|
walk(fullPath);
|
|
@@ -235,8 +275,9 @@ export function getMemoryFiles(workspaceDir) {
|
|
|
235
275
|
* Collect all syncable files based on configuration.
|
|
236
276
|
* Includes memory/*.md by default, plus any additional watchPaths.
|
|
237
277
|
*/
|
|
238
|
-
export function getSyncableFiles(workspaceDir, watchPaths) {
|
|
239
|
-
const
|
|
278
|
+
export function getSyncableFiles(workspaceDir, watchPaths, ignorePaths) {
|
|
279
|
+
const ignore = new Set(ignorePaths ?? DEFAULT_IGNORE_DIRS);
|
|
280
|
+
const files = getMemoryFiles(workspaceDir, ignorePaths);
|
|
240
281
|
if (watchPaths) {
|
|
241
282
|
for (const wp of watchPaths) {
|
|
242
283
|
const resolved = wp.startsWith("/") ? wp : path.join(workspaceDir, wp);
|
|
@@ -253,6 +294,8 @@ export function getSyncableFiles(workspaceDir, watchPaths) {
|
|
|
253
294
|
const walk = (dir) => {
|
|
254
295
|
try {
|
|
255
296
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
297
|
+
if (isIgnoredEntry(entry, ignore))
|
|
298
|
+
continue;
|
|
256
299
|
const fullPath = path.join(dir, entry.name);
|
|
257
300
|
if (entry.isDirectory()) {
|
|
258
301
|
walk(fullPath);
|
|
@@ -480,13 +523,45 @@ export async function syncAllMemoryFiles(client, workspaceDir, options) {
|
|
|
480
523
|
* Unchanged sections (by content hash) are skipped.
|
|
481
524
|
*/
|
|
482
525
|
export async function syncAllFilesSectionized(client, workspaceDir, options) {
|
|
483
|
-
const files = getSyncableFiles(workspaceDir, options?.watchPaths);
|
|
526
|
+
const files = getSyncableFiles(workspaceDir, options?.watchPaths, options?.ignorePaths);
|
|
527
|
+
// Age pre-filter: a file untouched for longer than maxAgeDays that is
|
|
528
|
+
// already recorded in the manifest has been ingested at least once and is
|
|
529
|
+
// not changing — re-parsing/hashing/diffing it every startup is pure churn.
|
|
530
|
+
// Files NOT yet in the manifest are always processed regardless of age, so
|
|
531
|
+
// a genuinely cold start (or a never-synced old file) still ingests once.
|
|
532
|
+
// The live file_changed handler is unaffected: an edit bumps mtime, so
|
|
533
|
+
// edited-but-old files re-enter the window naturally.
|
|
534
|
+
const maxAgeDays = options?.maxAgeDays ?? 0;
|
|
535
|
+
let candidates = files;
|
|
536
|
+
let agedOut = 0;
|
|
537
|
+
if (maxAgeDays > 0) {
|
|
538
|
+
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
539
|
+
const manifest = loadManifest(workspaceDir);
|
|
540
|
+
candidates = files.filter((filePath) => {
|
|
541
|
+
const key = fileKey(workspaceDir, filePath);
|
|
542
|
+
const known = manifest.files[key] !== undefined;
|
|
543
|
+
if (!known)
|
|
544
|
+
return true;
|
|
545
|
+
try {
|
|
546
|
+
if (fs.statSync(filePath).mtimeMs >= cutoff)
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
return true; // stat failed — be safe, process it
|
|
551
|
+
}
|
|
552
|
+
agedOut++;
|
|
553
|
+
return false;
|
|
554
|
+
});
|
|
555
|
+
if (agedOut > 0) {
|
|
556
|
+
log.info(`Skipping ${agedOut} unchanged file(s) older than ${maxAgeDays}d (already synced)`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
484
559
|
let totalSynced = 0;
|
|
485
560
|
let totalSkipped = 0;
|
|
486
561
|
let totalFailed = 0;
|
|
487
562
|
let totalRemoved = 0;
|
|
488
563
|
const allErrors = [];
|
|
489
|
-
for (const filePath of
|
|
564
|
+
for (const filePath of candidates) {
|
|
490
565
|
const result = await syncMarkdownFileSectionized(client, filePath, workspaceDir, {
|
|
491
566
|
userId: options?.userId,
|
|
492
567
|
});
|
|
@@ -501,6 +576,7 @@ export async function syncAllFilesSectionized(client, workspaceDir, options) {
|
|
|
501
576
|
skipped: totalSkipped,
|
|
502
577
|
failed: totalFailed,
|
|
503
578
|
removed: totalRemoved,
|
|
579
|
+
agedOut,
|
|
504
580
|
errors: allErrors,
|
|
505
581
|
};
|
|
506
582
|
}
|
package/dist/tools/search.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import { mergeWithExclude } from "../lib/filters.js";
|
|
2
3
|
import { buildScopeFilter, getCanReadScopes, resolveUser } from "../lib/sender.js";
|
|
3
4
|
import { log } from "../logger.js";
|
|
4
5
|
export function createSearchToolFactory(client, cfg) {
|
|
@@ -38,6 +39,9 @@ export function createSearchToolFactory(client, cfg) {
|
|
|
38
39
|
: canRead;
|
|
39
40
|
filter = buildScopeFilter(allowed, resolved?.userId ?? "");
|
|
40
41
|
}
|
|
42
|
+
// Keep session-end trace memories out of agent-facing search, matching
|
|
43
|
+
// the auto-context hook so both retrieval paths filter identically.
|
|
44
|
+
filter = mergeWithExclude(filter);
|
|
41
45
|
log.debug(`search tool: query="${params.query}" limit=${limit} after=${params.after ?? "none"} before=${params.before ?? "none"} userId=${userId} scope=${params.scope ?? "any"}`);
|
|
42
46
|
try {
|
|
43
47
|
const response = await client.searchRaw(params.query, {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperspell/openclaw-hyperspell",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.2",
|
|
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 lib/sender.test.ts config.test.ts sync/markdown.test.ts hooks/auto-trace.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts"
|
|
41
|
+
"test": "node --test --experimental-strip-types lib/sender.test.ts lib/filters.test.ts config.test.ts sync/markdown.test.ts hooks/auto-trace.test.ts hooks/emotional-state.test.ts hooks/startup-orientation.test.ts"
|
|
42
42
|
},
|
|
43
43
|
"openclaw": {
|
|
44
44
|
"extensions": [
|