@unblocklabs/unblock-memory 0.3.1 → 0.3.3

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 CHANGED
@@ -127,7 +127,9 @@ The `skills` corpus shares the existing QMD store and warm embedding model but
127
127
  is private to Skill Whisperer: it is excluded from ordinary `memory_search`
128
128
  (including `corpora: ["all"]`), `memory_get`, clustering, and memory-maintenance
129
129
  tasks. Paths are explicit by design; the plugin does not reconstruct
130
- OpenClaw's effective skill inventory from `openclaw.json`.
130
+ OpenClaw's effective skill inventory from `openclaw.json`. Configured skill
131
+ globs follow symlinked directories, including OpenClaw's `plugin-skills`
132
+ directory.
131
133
 
132
134
  Use `sessionFilter` to restrict session results by metadata while leaving file
133
135
  corpora searchable. Supported fields are `startedFrom` and `startedTo`
@@ -1,4 +1,5 @@
1
1
  import type { MemoryPluginCapability } from "openclaw/plugin-sdk/memory-host-core";
2
+ import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry";
2
3
  import type { SessionMetadata } from "./session-projector.js";
3
4
  import type { ChatType } from "./config.js";
4
5
  export type MemoryPluginRuntimeContract = NonNullable<MemoryPluginCapability["runtime"]>;
@@ -18,9 +19,11 @@ export type SessionSearchFilter = {
18
19
  accountId?: string;
19
20
  conversationId?: string;
20
21
  };
22
+ export type MemoryRequestContext = Pick<OpenClawPluginToolContext, "sessionKey" | "sessionId" | "messageChannel" | "agentAccountId" | "nativeChannelId" | "deliveryContext">;
21
23
  export type CorpusSearchOptions = NonNullable<Parameters<MemorySearchManagerContract["search"]>[1]> & {
22
24
  corpora?: readonly string[];
23
25
  sessionFilter?: SessionSearchFilter;
26
+ requestContext?: MemoryRequestContext;
24
27
  };
25
28
  export type MemoryEmbeddingProbeResult = Awaited<ReturnType<MemorySearchManagerContract["probeEmbeddingAvailability"]>>;
26
29
  export type MemorySyncParams = Parameters<NonNullable<MemorySearchManagerContract["sync"]>>[0];
@@ -1,6 +1,6 @@
1
1
  import type { QMDStore } from "@unblocklabs/qmd";
2
2
  import { type AnalysisRunner, type MemoryAnalysisSummary, type MemoryClusterDetail, type MemoryClusterList, type MemoryClusterSort, type MemoryReclusterOptions } from "./analysis.js";
3
- import type { CorpusMemorySearchResult, CorpusSearchOptions, MemoryEmbeddingProbeResult, MemoryProviderStatus, MemoryReadResult, MemorySearchManagerContract, MemorySyncParams } from "./contracts.js";
3
+ import type { CorpusMemorySearchResult, CorpusSearchOptions, MemoryEmbeddingProbeResult, MemoryProviderStatus, MemoryReadResult, MemoryRequestContext, MemorySearchManagerContract, MemorySyncParams } from "./contracts.js";
4
4
  import type { ChatType } from "./config.js";
5
5
  import { type MaintenanceStatus, type TemporalBasis } from "./curation.js";
6
6
  import { type SessionSyncResult } from "./session-sync.js";
@@ -75,6 +75,7 @@ export declare class QmdMemoryManager implements MemorySearchManagerContract {
75
75
  relPath: string;
76
76
  from?: number;
77
77
  lines?: number;
78
+ requestContext?: MemoryRequestContext;
78
79
  }): Promise<MemoryReadResult>;
79
80
  status(): MemoryProviderStatus;
80
81
  probeEmbeddingAvailability(): Promise<MemoryEmbeddingProbeResult>;
@@ -1,7 +1,8 @@
1
- import { realpathSync } from "node:fs";
1
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { mkdir, stat } from "node:fs/promises";
3
- import { basename, dirname, resolve } from "node:path";
3
+ import { basename, dirname, relative, resolve, sep } from "node:path";
4
4
  import chokidar from "chokidar";
5
+ import picomatch from "picomatch";
5
6
  import { ensureMemoryAnalysisSchema, latestAnalysisCollections, latestAnalysisRunId, markMemoryAnalysisStale, readAnalysisSummary, readCluster, readClusters, runAnalysisWorker, } from "./analysis.js";
6
7
  import { CurationStore, chunkFingerprint, } from "./curation.js";
7
8
  import { readSessionManifest, sessionMetadataByPath, syncSessionProjections, } from "./session-sync.js";
@@ -10,6 +11,50 @@ const DEFAULT_READ_LINES = 120;
10
11
  const MAX_READ_CHARS = 12_000;
11
12
  const WATCH_DEBOUNCE_MS = 250;
12
13
  const qmdModule = import("@unblocklabs/qmd");
14
+ function readSkillDocuments(source) {
15
+ const documents = [];
16
+ const visitedDirectories = new Set();
17
+ const visit = (directory) => {
18
+ let canonicalDirectory;
19
+ try {
20
+ canonicalDirectory = realpathSync(directory);
21
+ }
22
+ catch {
23
+ return;
24
+ }
25
+ if (visitedDirectories.has(canonicalDirectory))
26
+ return;
27
+ visitedDirectories.add(canonicalDirectory);
28
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
29
+ const path = resolve(directory, entry.name);
30
+ let kind = entry.isDirectory()
31
+ ? "directory"
32
+ : entry.isFile()
33
+ ? "file"
34
+ : undefined;
35
+ if (entry.isSymbolicLink()) {
36
+ try {
37
+ const target = statSync(path);
38
+ kind = target.isDirectory() ? "directory" : target.isFile() ? "file" : undefined;
39
+ }
40
+ catch {
41
+ continue;
42
+ }
43
+ }
44
+ if (kind === "directory") {
45
+ visit(path);
46
+ continue;
47
+ }
48
+ const relativePath = relative(source.root, path).split(sep).join("/");
49
+ if (kind !== "file" || basename(path).toLowerCase() !== "skill.md" ||
50
+ !picomatch.isMatch(relativePath, source.pattern, { dot: true }))
51
+ continue;
52
+ documents.push({ path, body: readFileSync(path, "utf8") });
53
+ }
54
+ };
55
+ visit(source.root);
56
+ return documents;
57
+ }
13
58
  function frontmatterValue(body, key) {
14
59
  const frontmatter = /^---\s*\n([\s\S]*?)\n---(?:\n|$)/u.exec(body)?.[1];
15
60
  const raw = frontmatter?.split("\n")
@@ -264,9 +309,11 @@ export class QmdMemoryManager {
264
309
  this.#watcher.on("error", (error) => {
265
310
  this.#watchError = error instanceof Error ? error.message : String(error);
266
311
  });
267
- this.#watcher.on("all", () => {
312
+ this.#watcher.on("all", (_event, path) => {
268
313
  if (this.#closed)
269
314
  return;
315
+ if (basename(path).toLowerCase() === "skill.md")
316
+ this.#skillIndex = undefined;
270
317
  this.#dirty = true;
271
318
  if (this.#watchTimer)
272
319
  clearTimeout(this.#watchTimer);
@@ -690,31 +737,24 @@ export class QmdMemoryManager {
690
737
  throw new Error("Skill Whisperer requires the QMD embedding model");
691
738
  const llm = store.internal.llm;
692
739
  this.#skillIndex ??= (async () => {
693
- const placeholders = collections.map(() => "?").join(", ");
694
- const rows = store.internal.db.prepare(`
695
- SELECT document.collection, document.path, content.doc AS body
696
- FROM documents document
697
- JOIN content ON content.hash = document.hash
698
- WHERE document.active = 1 AND document.collection IN (${placeholders})
699
- ORDER BY document.collection, document.path
700
- `).all(...collections);
701
740
  const sourceOrder = new Map([...this.#sources.keys()].map((collection, index) => [collection, index]));
702
741
  const metadata = new Map();
703
- for (const row of rows) {
704
- const file = `qmd://${row.collection}/${row.path}`;
705
- const safe = parseSafeVirtualPath(file, this.#sources);
706
- if (!safe || safe.source.kind !== "skills")
742
+ for (const source of this.#sources.values()) {
743
+ if (source.kind !== "skills")
707
744
  continue;
708
- const path = realpathSync(resolve(safe.source.root, safe.relativePath));
709
- if (basename(path).toLowerCase() !== "skill.md")
710
- continue;
711
- const name = frontmatterValue(row.body, "name") || basename(dirname(path));
712
- const description = frontmatterValue(row.body, "description") ?? "";
713
- const key = name.toLowerCase();
714
- const order = sourceOrder.get(row.collection) ?? Number.MAX_SAFE_INTEGER;
715
- const current = metadata.get(key);
716
- if (!current || order < current.sourceOrder) {
717
- metadata.set(key, { candidate: { name, path }, description, sourceOrder: order });
745
+ for (const document of readSkillDocuments(source)) {
746
+ const name = frontmatterValue(document.body, "name") || basename(dirname(document.path));
747
+ const description = frontmatterValue(document.body, "description") ?? "";
748
+ const key = name.toLowerCase();
749
+ const order = sourceOrder.get(source.collection) ?? Number.MAX_SAFE_INTEGER;
750
+ const current = metadata.get(key);
751
+ if (!current || order < current.sourceOrder) {
752
+ metadata.set(key, {
753
+ candidate: { name, path: document.path },
754
+ description,
755
+ sourceOrder: order,
756
+ });
757
+ }
718
758
  }
719
759
  }
720
760
  const skills = [...metadata.values()];
@@ -8,7 +8,18 @@ function getContext(ctx) {
8
8
  const cfg = ctx.getRuntimeConfig?.() ?? ctx.runtimeConfig ?? ctx.config;
9
9
  if (!cfg || !ctx.agentId)
10
10
  return undefined;
11
- return { cfg, agentId: ctx.agentId };
11
+ return {
12
+ cfg,
13
+ agentId: ctx.agentId,
14
+ requestContext: {
15
+ sessionKey: ctx.sessionKey,
16
+ sessionId: ctx.sessionId,
17
+ messageChannel: ctx.messageChannel,
18
+ agentAccountId: ctx.agentAccountId,
19
+ nativeChannelId: ctx.nativeChannelId,
20
+ deliveryContext: ctx.deliveryContext,
21
+ },
22
+ };
12
23
  }
13
24
  const searchParameters = Type.Object({
14
25
  query: Type.String({ pattern: "\\S" }),
@@ -58,6 +69,7 @@ function createSearchTool(runtime, ctx) {
58
69
  maxResults,
59
70
  minScore,
60
71
  signal,
72
+ requestContext: active.requestContext,
61
73
  });
62
74
  return jsonResult({
63
75
  results: results.map((result) => result.session
@@ -93,6 +105,7 @@ function createGetTool(runtime, ctx) {
93
105
  relPath: path,
94
106
  from,
95
107
  lines,
108
+ requestContext: active.requestContext,
96
109
  }));
97
110
  },
98
111
  };
@@ -113,16 +113,10 @@ export function resolveConfiguredSkillPath(workspaceDir, inputPath, sources) {
113
113
  for (const source of sources) {
114
114
  if (source.kind !== "skills")
115
115
  continue;
116
- let canonicalRoot;
117
- try {
118
- canonicalRoot = realpathSync(source.root);
119
- }
120
- catch {
116
+ const relativePath = relative(source.root, target);
117
+ if (relativePath === ".." || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath))
121
118
  continue;
122
- }
123
- const relativePath = relative(canonicalRoot, canonicalTarget);
124
- const safe = parseSafeVirtualPath(`qmd://${source.collection}/${relativePath.split(sep).join("/")}`, new Map([[source.collection, source]]));
125
- if (safe)
119
+ if (picomatch.isMatch(relativePath.split(sep).join("/"), source.pattern, { dot: true }))
126
120
  return canonicalTarget;
127
121
  }
128
122
  return undefined;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "unblock-memory",
3
3
  "name": "Unblock Memory",
4
- "version": "0.3.1",
4
+ "version": "0.3.3",
5
5
  "description": "Indexes, retrieves, and analyzes configured workspace memory with existing QMD vectors.",
6
6
  "kind": "memory",
7
7
  "activation": { "onStartup": false },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unblocklabs/unblock-memory",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Workspace-native memory for OpenClaw, powered by QMD",
5
5
  "type": "module",
6
6
  "license": "MIT",