@usecortex_ai/openclaw-cortex-ai 0.0.1 → 0.1.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.
@@ -0,0 +1,40 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+
8
+ jobs:
9
+ publish:
10
+ name: Publish package to npm
11
+ runs-on: ubuntu-latest
12
+
13
+ steps:
14
+ - name: Checkout code
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Set up Node.js
18
+ uses: actions/setup-node@v4
19
+ with:
20
+ node-version: '20'
21
+ registry-url: 'https://registry.npmjs.org'
22
+
23
+ - name: Install dependencies
24
+ run: npm ci
25
+
26
+ - name: Build package
27
+ # If you have a build step, otherwise remove this
28
+ run: npm run build
29
+ continue-on-error: true
30
+
31
+ - name: Publish to npm
32
+ env:
33
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
34
+ run: |
35
+ # Only publish if this is not a pre-release and version has changed
36
+ if [ "$(npm view . version)" != "$(node -p "require('./package.json').version")" ]; then
37
+ npm publish --access public
38
+ else
39
+ echo "Version already published, skipping."
40
+ fi
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Cortex AI — OpenClaw Plugin
2
2
 
3
- Long-term memory for OpenClaw powered by [Cortex AI](https://usecortex.ai). Automatically captures conversations, recalls relevant context with knowledge-graph connections, and injects them before every AI turn.
3
+ State-of-the-art agentic memory for OpenClaw powered by [Cortex AI](https://usecortex.ai). Automatically captures conversations, recalls relevant context with knowledge-graph connections, and injects them before every AI turn.
4
4
 
5
5
  ## Install
6
6
 
@@ -39,44 +39,43 @@ Or configure directly in `openclaw.json`:
39
39
 
40
40
  ### Options
41
41
 
42
- | Key | Type | Default | Description |
43
- | ------------------ | --------- | ------------------ | -------------------------------------------------------------------------- |
44
- | `subTenantId` | `string` | `"cortex-openclaw"`| Sub-tenant for data partitioning within your tenant |
45
- | `autoRecall` | `boolean` | `true` | Inject relevant memories before every AI turn |
46
- | `autoCapture` | `boolean` | `true` | Store conversation exchanges after every AI turn |
47
- | `maxRecallResults` | `number` | `10` | Max memory chunks injected into context per turn |
48
- | `recallMode` | `string` | `"fast"` | `"fast"` or `"thinking"` (deeper personalised recall with graph traversal) |
49
- | `graphContext` | `boolean` | `true` | Include knowledge graph relations in recalled context |
50
- | `debug` | `boolean` | `false` | Verbose debug logs |
42
+ | Key | Type | Default | Description |
43
+ | -------------------- | ----------- | --------------------- | ------------------------------------------------------------------------------ |
44
+ | `subTenantId` | `string` | `"cortex-openclaw"` | Sub-tenant for data partitioning within your tenant |
45
+ | `autoRecall` | `boolean` | `true` | Inject relevant memories before every AI turn |
46
+ | `autoCapture` | `boolean` | `true` | Store conversation exchanges after every AI turn |
47
+ | `maxRecallResults` | `number` | `10` | Max memory chunks injected into context per turn |
48
+ | `recallMode` | `string` | `"fast"` | `"fast"` or `"thinking"` (deeper personalised recall with graph traversal) |
49
+ | `graphContext` | `boolean` | `true` | Include knowledge graph relations in recalled context |
50
+ | `debug` | `boolean` | `false` | Verbose debug logs |
51
51
 
52
52
  ## How It Works
53
53
 
54
54
  - **Auto-Recall** — Before every AI turn, queries Cortex (`/recall/recall_preferences`) for relevant memories and injects graph-enriched context (entity paths, chunk relations, extra context).
55
-
56
55
  - **Auto-Capture** — After every AI turn, the last user/assistant exchange is sent to Cortex (`/memories/add_memory`) as conversation pairs with `infer: true` and `upsert: true`. The session ID is used as `source_id` so Cortex groups exchanges per session and builds a knowledge graph automatically.
57
56
 
58
57
  ## Slash Commands
59
58
 
60
- | Command | Description |
61
- | -------------------------- | ------------------------------------------------- |
62
- | `/cortex-remember <text>` | Save something to Cortex memory |
63
- | `/cortex-recall <query>` | Search memories with relevance scores |
64
- | `/cortex-list` | List all stored user memories |
65
- | `/cortex-delete <id>` | Delete a specific memory by its ID |
66
- | `/cortex-get <source_id>` | Fetch the full content of a source |
59
+ | Command | Description |
60
+ | --------------------------- | ------------------------------------- |
61
+ | `/cortex-remember <text>` | Save something to Cortex memory |
62
+ | `/cortex-recall <query>` | Search memories with relevance scores |
63
+ | `/cortex-list` | List all stored user memories |
64
+ | `/cortex-delete <id>` | Delete a specific memory by its ID |
65
+ | `/cortex-get <source_id>` | Fetch the full content of a source |
67
66
 
68
67
  ## AI Tools
69
68
 
70
- | Tool | Description |
71
- | ---------------- | --------------------------------------------------------- |
72
- | `cortex_store` | Save information to long-term memory |
73
- | `cortex_search` | Search memories with graph-enriched results |
69
+ | Tool | Description |
70
+ | ----------------- | ------------------------------------------- |
71
+ | `cortex_store` | Save information to memory |
72
+ | `cortex_search` | Search memories with graph-enriched results |
74
73
 
75
74
  ## CLI
76
75
 
77
76
  ```bash
78
77
  openclaw cortex search <query> # Search memories
79
- openclaw cortex list # List all user memories
78
+ # List all user memories
80
79
  openclaw cortex delete <id> # Delete a memory
81
80
  openclaw cortex get <source_id> # Fetch source content
82
81
  openclaw cortex status # Show plugin configuration
@@ -87,4 +86,5 @@ openclaw cortex status # Show plugin configuration
87
86
  Recalled context is injected inside `<cortex-context>` tags containing:
88
87
 
89
88
  - **Entity Paths** — Knowledge graph paths connecting entities relevant to the query
90
- - **Context Chunks** — Retrieved memory chunks with source titles, graph relations, and linked extra context
89
+ - **
90
+ Context Chunks** — Retrieved memory chunks with source titles, graph relations, and linked extra context
package/client.ts CHANGED
@@ -18,7 +18,9 @@ const API_BASE = "https://api.usecortex.ai"
18
18
  const INGEST_INSTRUCTIONS =
19
19
  "Focus on extracting user preferences, habits, opinions, likes, dislikes, " +
20
20
  "goals, and recurring themes. Capture any stated or implied personal context " +
21
- "that would help personalise future interactions."
21
+ "that would help personalise future interactions. Capture important personal details like " +
22
+ "name, age, email ids, phone numbers, etc. along with the original name and context " +
23
+ "so that it can be used to personalise future interactions."
22
24
 
23
25
  export class CortexClient {
24
26
  private apiKey: string
@@ -74,7 +76,10 @@ export class CortexClient {
74
76
  async ingestConversation(
75
77
  turns: ConversationTurn[],
76
78
  sourceId: string,
77
- userName?: string,
79
+ opts?: {
80
+ userName?: string
81
+ metadata?: Record<string, unknown>
82
+ },
78
83
  ): Promise<AddMemoryResponse> {
79
84
  const payload: AddMemoryRequest = {
80
85
  memories: [
@@ -82,8 +87,11 @@ export class CortexClient {
82
87
  user_assistant_pairs: turns,
83
88
  infer: true,
84
89
  source_id: sourceId,
85
- user_name: userName ?? "User",
90
+ user_name: opts?.userName ?? "User",
86
91
  custom_instructions: INGEST_INSTRUCTIONS,
92
+ ...(opts?.metadata && {
93
+ document_metadata: JSON.stringify(opts.metadata),
94
+ }),
87
95
  },
88
96
  ],
89
97
  tenant_id: this.tenantId,
package/commands/slash.ts CHANGED
@@ -2,7 +2,7 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
2
  import type { CortexClient } from "../client.ts"
3
3
  import type { CortexPluginConfig } from "../config.ts"
4
4
  import { log } from "../log.ts"
5
- import { toSourceId } from "../session.ts"
5
+ import { toToolSourceId } from "../session.ts"
6
6
 
7
7
  function preview(text: string, max = 80): string {
8
8
  return text.length > max ? `${text.slice(0, max)}…` : text
@@ -12,7 +12,7 @@ export function registerSlashCommands(
12
12
  api: OpenClawPluginApi,
13
13
  client: CortexClient,
14
14
  cfg: CortexPluginConfig,
15
- getSessionKey: () => string | undefined,
15
+ getSessionId: () => string | undefined,
16
16
  ): void {
17
17
  api.registerCommand({
18
18
  name: "cortex-remember",
@@ -24,8 +24,8 @@ export function registerSlashCommands(
24
24
  if (!text) return { text: "Usage: /cortex-remember <text to store>" }
25
25
 
26
26
  try {
27
- const sk = getSessionKey()
28
- const sourceId = sk ? toSourceId(sk) : undefined
27
+ const sid = getSessionId()
28
+ const sourceId = sid ? toToolSourceId(sid) : undefined
29
29
  await client.ingestText(text, { sourceId, title: "Manual Memory", infer: true })
30
30
  return { text: `Saved: "${preview(text, 60)}"` }
31
31
  } catch (err) {
package/config.ts CHANGED
@@ -22,7 +22,7 @@ const KNOWN_KEYS = new Set([
22
22
  "debug",
23
23
  ])
24
24
 
25
- const DEFAULT_SUB_TENANT = "cortex-openclaw"
25
+ const DEFAULT_SUB_TENANT = "cortex-openclaw-plugin"
26
26
 
27
27
  function envOrNull(name: string): string | undefined {
28
28
  return typeof process !== "undefined" ? process.env[name] : undefined
package/hooks/capture.ts CHANGED
@@ -1,49 +1,11 @@
1
1
  import type { CortexClient } from "../client.ts"
2
2
  import type { CortexPluginConfig } from "../config.ts"
3
3
  import { log } from "../log.ts"
4
- import { toSourceId } from "../session.ts"
4
+ import { extractAllTurns } from "../messages.ts"
5
+ import { toHookSourceId } from "../session.ts"
5
6
  import type { ConversationTurn } from "../types/cortex.ts"
6
7
 
7
- function textFromMessage(msg: Record<string, unknown>): string {
8
- const content = msg.content
9
- if (typeof content === "string") return content
10
- if (Array.isArray(content)) {
11
- return content
12
- .filter(
13
- (b) =>
14
- b &&
15
- typeof b === "object" &&
16
- (b as Record<string, unknown>).type === "text",
17
- )
18
- .map((b) => (b as Record<string, unknown>).text as string)
19
- .join("\n")
20
- }
21
- return ""
22
- }
23
-
24
- function getLatestTurn(messages: unknown[]): ConversationTurn | null {
25
- let userIdx = -1
26
- for (let i = messages.length - 1; i >= 0; i--) {
27
- const m = messages[i]
28
- if (m && typeof m === "object" && (m as Record<string, unknown>).role === "user") {
29
- userIdx = i
30
- break
31
- }
32
- }
33
- if (userIdx < 0) return null
34
-
35
- const userText = textFromMessage(messages[userIdx] as Record<string, unknown>)
36
- if (!userText) return null
37
-
38
- for (let i = userIdx + 1; i < messages.length; i++) {
39
- const m = messages[i]
40
- if (m && typeof m === "object" && (m as Record<string, unknown>).role === "assistant") {
41
- const aText = textFromMessage(m as Record<string, unknown>)
42
- if (aText) return { user: userText, assistant: aText }
43
- }
44
- }
45
- return null
46
- }
8
+ const MAX_HOOK_TURNS = -1
47
9
 
48
10
  function removeInjectedBlocks(text: string): string {
49
11
  return text.replace(/<cortex-context>[\s\S]*?<\/cortex-context>\s*/g, "").trim()
@@ -52,34 +14,83 @@ function removeInjectedBlocks(text: string): string {
52
14
  export function createIngestionHook(
53
15
  client: CortexClient,
54
16
  _cfg: CortexPluginConfig,
55
- getSessionKey: () => string | undefined,
56
17
  ) {
57
- return async (event: Record<string, unknown>) => {
58
- if (!event.success || !Array.isArray(event.messages) || event.messages.length === 0) return
18
+ return async (event: Record<string, unknown>, sessionId: string | undefined) => {
19
+ try {
20
+ log.debug(`[capture] hook fired — success=${event.success} msgs=${Array.isArray(event.messages) ? event.messages.length : "N/A"} sid=${sessionId ?? "none"}`)
59
21
 
60
- const turn = getLatestTurn(event.messages)
61
- if (!turn) return
22
+ if (!event.success) {
23
+ log.debug("[capture] skipped — event.success is falsy")
24
+ return
25
+ }
26
+ if (!Array.isArray(event.messages) || event.messages.length === 0) {
27
+ log.debug("[capture] skipped — no messages in event")
28
+ return
29
+ }
62
30
 
63
- const userClean = removeInjectedBlocks(turn.user)
64
- const assistantClean = removeInjectedBlocks(turn.assistant)
65
- if (userClean.length < 5 || assistantClean.length < 5) return
31
+ if (!sessionId) {
32
+ log.debug("[capture] skipped no session id available")
33
+ return
34
+ }
66
35
 
67
- const sk = getSessionKey()
68
- const sourceId = sk ? toSourceId(sk) : undefined
69
- if (!sourceId) {
70
- log.debug("ingestion skipped — no session key")
71
- return
72
- }
36
+ const allTurns = extractAllTurns(event.messages)
73
37
 
74
- log.debug(`ingesting turn (u=${userClean.length}c, a=${assistantClean.length}c) → ${sourceId}`)
38
+ if (allTurns.length === 0) {
39
+ log.debug(`[capture] skipped — no user-assistant turns found in ${event.messages.length} messages`)
40
+ const roles = event.messages
41
+ .slice(-5)
42
+ .map((m) => (m && typeof m === "object" ? (m as Record<string, unknown>).role : "?"))
43
+ log.debug(`[capture] last 5 message roles: ${JSON.stringify(roles)}`)
44
+ return
45
+ }
46
+
47
+ const recentTurns = MAX_HOOK_TURNS === -1 ? allTurns : allTurns.slice(-MAX_HOOK_TURNS)
48
+ const turns: ConversationTurn[] = recentTurns.map((t) => ({
49
+ user: removeInjectedBlocks(t.user),
50
+ assistant: removeInjectedBlocks(t.assistant),
51
+ })).filter((t) => t.user.length >= 5 && t.assistant.length >= 5)
52
+
53
+ if (turns.length === 0) {
54
+ log.debug("[capture] skipped — all turns too short after cleaning")
55
+ return
56
+ }
57
+
58
+ const sourceId = toHookSourceId(sessionId)
59
+
60
+ const now = new Date()
61
+ const timestamp = now.toISOString()
62
+ const readableTime = now.toLocaleString("en-US", {
63
+ weekday: "short",
64
+ year: "numeric",
65
+ month: "short",
66
+ day: "numeric",
67
+ hour: "2-digit",
68
+ minute: "2-digit",
69
+ timeZoneName: "short",
70
+ })
71
+
72
+ const annotatedTurns = turns.map((t, i) => ({
73
+ user: i === 0 ? `[Temporal details: ${readableTime}]\n\n${t.user}` : t.user,
74
+ assistant: t.assistant,
75
+ }))
76
+
77
+ log.debug(`[capture] ingesting ${annotatedTurns.length} turns (of ${allTurns.length} total) @ ${timestamp} -> ${sourceId}`)
75
78
 
76
- try {
77
79
  await client.ingestConversation(
78
- [{ user: userClean, assistant: assistantClean }],
80
+ annotatedTurns,
79
81
  sourceId,
82
+ {
83
+ metadata: {
84
+ captured_at: timestamp,
85
+ source: "openclaw_hook",
86
+ turn_count: annotatedTurns.length,
87
+ },
88
+ },
80
89
  )
90
+
91
+ log.debug("[capture] ingestion succeeded")
81
92
  } catch (err) {
82
- log.error("ingestion failed", err)
93
+ log.error("[capture] hook error", err)
83
94
  }
84
95
  }
85
96
  }
package/index.ts CHANGED
@@ -6,6 +6,9 @@ import { cortexConfigSchema, parseConfig } from "./config.ts"
6
6
  import { createIngestionHook } from "./hooks/capture.ts"
7
7
  import { createRecallHook } from "./hooks/recall.ts"
8
8
  import { log } from "./log.ts"
9
+ import { registerDeleteTool } from "./tools/delete.ts"
10
+ import { registerGetTool } from "./tools/get.ts"
11
+ import { registerListTool } from "./tools/list.ts"
9
12
  import { registerSearchTool } from "./tools/search.ts"
10
13
  import { registerStoreTool } from "./tools/store.ts"
11
14
 
@@ -20,32 +23,48 @@ export default {
20
23
  register(api: OpenClawPluginApi) {
21
24
  const cfg = parseConfig(api.pluginConfig)
22
25
 
23
- log.setDebug(cfg.debug)
26
+ log.init(api.logger, cfg.debug)
24
27
 
25
28
  const client = new CortexClient(cfg.apiKey, cfg.tenantId, cfg.subTenantId)
26
29
 
27
- let activeSessionKey: string | undefined
28
- const getSessionKey = () => activeSessionKey
30
+ let activeSessionId: string | undefined
31
+ let conversationMessages: unknown[] = []
32
+ const getSessionId = () => activeSessionId
33
+ const getMessages = () => conversationMessages
29
34
 
30
35
  registerSearchTool(api, client, cfg)
31
- registerStoreTool(api, client, cfg, getSessionKey)
36
+ registerStoreTool(api, client, cfg, getSessionId, getMessages)
37
+ registerListTool(api, client, cfg)
38
+ registerDeleteTool(api, client, cfg)
39
+ registerGetTool(api, client, cfg)
32
40
 
33
41
  if (cfg.autoRecall) {
34
42
  const onRecall = createRecallHook(client, cfg)
35
43
  api.on(
36
44
  "before_agent_start",
37
45
  (event: Record<string, unknown>, ctx: Record<string, unknown>) => {
38
- if (ctx.sessionKey) activeSessionKey = ctx.sessionKey as string
46
+ if (ctx.sessionId) activeSessionId = ctx.sessionId as string
47
+ if (Array.isArray(event.messages)) conversationMessages = event.messages
48
+ log.debug(`[session] before_agent_start — sid=${activeSessionId ?? "none"} msgs=${conversationMessages.length}`)
39
49
  return onRecall(event)
40
50
  },
41
51
  )
42
52
  }
43
53
 
44
54
  if (cfg.autoCapture) {
45
- api.on("agent_end", createIngestionHook(client, cfg, getSessionKey))
55
+ const captureHandler = createIngestionHook(client, cfg)
56
+ api.on(
57
+ "agent_end",
58
+ (event: Record<string, unknown>, ctx: Record<string, unknown>) => {
59
+ if (ctx.sessionId) activeSessionId = ctx.sessionId as string
60
+ if (Array.isArray(event.messages)) conversationMessages = event.messages
61
+ log.debug(`[session] agent_end — sid=${activeSessionId ?? "none"} msgs=${conversationMessages.length} ctxKeys=${Object.keys(ctx).join(",")}`)
62
+ return captureHandler(event, activeSessionId)
63
+ },
64
+ )
46
65
  }
47
66
 
48
- registerSlashCommands(api, client, cfg, getSessionKey)
67
+ registerSlashCommands(api, client, cfg, getSessionId)
49
68
  registerCliCommands(api, client, cfg)
50
69
 
51
70
  api.registerService({
package/log.ts CHANGED
@@ -1,25 +1,48 @@
1
+ export type LoggerBackend = {
2
+ info(msg: string): void
3
+ warn(msg: string): void
4
+ error(msg: string): void
5
+ debug?(msg: string): void
6
+ }
7
+
1
8
  const TAG = "[cortex-ai]"
2
9
 
10
+ let _backend: LoggerBackend | null = null
3
11
  let _debug = false
4
12
 
5
13
  export const log = {
14
+ init(backend: LoggerBackend, debug: boolean) {
15
+ _backend = backend
16
+ _debug = debug
17
+ },
18
+
6
19
  setDebug(enabled: boolean) {
7
20
  _debug = enabled
8
21
  },
9
22
 
10
23
  info(...args: unknown[]) {
11
- console.log(TAG, ...args)
24
+ const msg = `${TAG} ${args.map(String).join(" ")}`
25
+ if (_backend) _backend.info(msg)
26
+ else console.log(msg)
12
27
  },
13
28
 
14
29
  warn(...args: unknown[]) {
15
- console.warn(TAG, ...args)
30
+ const msg = `${TAG} ${args.map(String).join(" ")}`
31
+ if (_backend) _backend.warn(msg)
32
+ else console.warn(msg)
16
33
  },
17
34
 
18
35
  error(...args: unknown[]) {
19
- console.error(TAG, ...args)
36
+ const msg = `${TAG} ${args.map(String).join(" ")}`
37
+ if (_backend) _backend.error(msg)
38
+ else console.error(msg)
20
39
  },
21
40
 
22
41
  debug(...args: unknown[]) {
23
- if (_debug) console.debug(TAG, ...args)
42
+ if (!_debug) return
43
+ const msg = `${TAG} ${args.map(String).join(" ")}`
44
+ if (_backend?.debug) _backend.debug(msg)
45
+ else if (_backend) _backend.info(msg)
46
+ else console.debug(msg)
24
47
  },
25
48
  }
package/messages.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { ConversationTurn } from "./types/cortex.ts"
2
+
3
+ export function textFromMessage(msg: Record<string, unknown>): string {
4
+ const content = msg.content
5
+ if (typeof content === "string") return content
6
+ if (Array.isArray(content)) {
7
+ return content
8
+ .filter(
9
+ (b) =>
10
+ b &&
11
+ typeof b === "object" &&
12
+ (b as Record<string, unknown>).type === "text",
13
+ )
14
+ .map((b) => (b as Record<string, unknown>).text as string)
15
+ .filter(Boolean)
16
+ .join("\n")
17
+ }
18
+ return ""
19
+ }
20
+
21
+ export function extractAllTurns(messages: unknown[]): ConversationTurn[] {
22
+ const turns: ConversationTurn[] = []
23
+ let currentUserText: string | null = null
24
+ let currentAssistantText: string | null = null
25
+
26
+ for (const msg of messages) {
27
+ if (!msg || typeof msg !== "object") continue
28
+ const m = msg as Record<string, unknown>
29
+ const text = textFromMessage(m)
30
+
31
+ if (m.role === "user") {
32
+ if (!text) continue
33
+ if (currentUserText && currentAssistantText) {
34
+ turns.push({ user: currentUserText, assistant: currentAssistantText })
35
+ }
36
+ currentUserText = text
37
+ currentAssistantText = "no-message"
38
+ } else if (m.role === "assistant") {
39
+ if (!text) continue
40
+ currentAssistantText = text
41
+ }
42
+ }
43
+
44
+ if (currentUserText && currentAssistantText) {
45
+ turns.push({ user: currentUserText, assistant: currentAssistantText })
46
+ }
47
+
48
+ return turns
49
+ }
50
+
51
+ export function getLatestTurn(messages: unknown[]): ConversationTurn | null {
52
+ let userIdx = -1
53
+ for (let i = messages.length - 1; i >= 0; i--) {
54
+ const m = messages[i]
55
+ if (m && typeof m === "object" && (m as Record<string, unknown>).role === "user") {
56
+ userIdx = i
57
+ break
58
+ }
59
+ }
60
+ if (userIdx < 0) return null
61
+
62
+ const userText = textFromMessage(messages[userIdx] as Record<string, unknown>)
63
+ if (!userText) return null
64
+
65
+ for (let i = userIdx + 1; i < messages.length; i++) {
66
+ const m = messages[i]
67
+ if (m && typeof m === "object" && (m as Record<string, unknown>).role === "assistant") {
68
+ const aText = textFromMessage(m as Record<string, unknown>)
69
+ if (aText) return { user: userText, assistant: aText }
70
+ }
71
+ }
72
+ return null
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usecortex_ai/openclaw-cortex-ai",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "description": "OpenClaw plugin for Cortex AI — the State-of-the-art agentic memory system with auto-capture, recall, and knowledge graph context for open-claw",
6
6
  "license": "MIT",
package/session.ts CHANGED
@@ -1,3 +1,11 @@
1
- export function toSourceId(sessionKey: string): string {
2
- return `openclaw_cortex_sess_${sessionKey.replace(/\W+/g, "_")}`
1
+ export function toSourceId(sessionId: string): string {
2
+ return `sess_${sessionId}`
3
+ }
4
+
5
+ export function toHookSourceId(sessionId: string): string {
6
+ return `hook_${sessionId}`
7
+ }
8
+
9
+ export function toToolSourceId(sessionId: string): string {
10
+ return `tool_${sessionId}`
3
11
  }
@@ -0,0 +1,54 @@
1
+ import { Type } from "@sinclair/typebox"
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
3
+ import type { CortexClient } from "../client.ts"
4
+ import type { CortexPluginConfig } from "../config.ts"
5
+ import { log } from "../log.ts"
6
+
7
+ export function registerDeleteTool(
8
+ api: OpenClawPluginApi,
9
+ client: CortexClient,
10
+ _cfg: CortexPluginConfig,
11
+ ): void {
12
+ api.registerTool(
13
+ {
14
+ name: "cortex_delete_memory",
15
+ label: "Cortex Delete Memory",
16
+ description:
17
+ "Delete a specific memory from Cortex by its memory ID. Use this when the user explicitly asks you to forget something or remove a specific piece of stored information. Always confirm the memory ID before deleting.",
18
+ parameters: Type.Object({
19
+ memory_id: Type.String({
20
+ description: "The unique ID of the memory to delete",
21
+ }),
22
+ }),
23
+ async execute(
24
+ _toolCallId: string,
25
+ params: { memory_id: string },
26
+ ) {
27
+ log.debug(`delete tool: memory_id=${params.memory_id}`)
28
+
29
+ const res = await client.deleteMemory(params.memory_id)
30
+
31
+ if (res.user_memory_deleted) {
32
+ return {
33
+ content: [
34
+ {
35
+ type: "text" as const,
36
+ text: `Successfully deleted memory: ${params.memory_id}`,
37
+ },
38
+ ],
39
+ }
40
+ }
41
+
42
+ return {
43
+ content: [
44
+ {
45
+ type: "text" as const,
46
+ text: `Memory ${params.memory_id} was not found or has already been deleted.`,
47
+ },
48
+ ],
49
+ }
50
+ },
51
+ },
52
+ { name: "cortex_delete_memory" },
53
+ )
54
+ }
package/tools/get.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { Type } from "@sinclair/typebox"
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
3
+ import type { CortexClient } from "../client.ts"
4
+ import type { CortexPluginConfig } from "../config.ts"
5
+ import { log } from "../log.ts"
6
+
7
+ export function registerGetTool(
8
+ api: OpenClawPluginApi,
9
+ client: CortexClient,
10
+ _cfg: CortexPluginConfig,
11
+ ): void {
12
+ api.registerTool(
13
+ {
14
+ name: "cortex_get_content",
15
+ label: "Cortex Get Content",
16
+ description:
17
+ "Fetch the full content of a specific source from Cortex by its source ID. Use this to retrieve the complete text of a memory source when you need more details than what's shown in search results.",
18
+ parameters: Type.Object({
19
+ source_id: Type.String({
20
+ description: "The unique source ID to fetch content for",
21
+ }),
22
+ }),
23
+ async execute(
24
+ _toolCallId: string,
25
+ params: { source_id: string },
26
+ ) {
27
+ log.debug(`get tool: source_id=${params.source_id}`)
28
+
29
+ const res = await client.fetchContent(params.source_id)
30
+
31
+ if (!res.success || res.error) {
32
+ return {
33
+ content: [
34
+ {
35
+ type: "text" as const,
36
+ text: `Failed to fetch source ${params.source_id}: ${res.error ?? "unknown error"}`,
37
+ },
38
+ ],
39
+ }
40
+ }
41
+
42
+ const content = res.content ?? res.content_base64 ?? "(no text content available)"
43
+ const preview = content.length > 3000 ? `${content.slice(0, 3000)}…\n\n[Content truncated, showing first 3000 characters]` : content
44
+
45
+ return {
46
+ content: [
47
+ {
48
+ type: "text" as const,
49
+ text: `Source: ${params.source_id}\n\n${preview}`,
50
+ },
51
+ ],
52
+ }
53
+ },
54
+ },
55
+ { name: "cortex_get_content" },
56
+ )
57
+ }
package/tools/list.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { Type } from "@sinclair/typebox"
2
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
3
+ import type { CortexClient } from "../client.ts"
4
+ import type { CortexPluginConfig } from "../config.ts"
5
+ import { log } from "../log.ts"
6
+
7
+ export function registerListTool(
8
+ api: OpenClawPluginApi,
9
+ client: CortexClient,
10
+ _cfg: CortexPluginConfig,
11
+ ): void {
12
+ api.registerTool(
13
+ {
14
+ name: "cortex_list_memories",
15
+ label: "Cortex List Memories",
16
+ description:
17
+ "List all user memories stored in Cortex. Returns memory IDs and content summaries. Use this when the user asks what you remember about them or wants to see their stored information.",
18
+ parameters: Type.Object({}),
19
+ async execute(_toolCallId: string, _params: Record<string, never>) {
20
+ log.debug("list tool: fetching all memories")
21
+
22
+ const res = await client.listMemories()
23
+ const memories = res.user_memories ?? []
24
+
25
+ if (memories.length === 0) {
26
+ return {
27
+ content: [
28
+ {
29
+ type: "text" as const,
30
+ text: "No memories stored yet.",
31
+ },
32
+ ],
33
+ }
34
+ }
35
+
36
+ const lines = memories.map((m, i) => {
37
+ const preview =
38
+ m.memory_content.length > 100
39
+ ? `${m.memory_content.slice(0, 100)}…`
40
+ : m.memory_content
41
+ return `${i + 1}. [ID: ${m.memory_id}]\n ${preview}`
42
+ })
43
+
44
+ return {
45
+ content: [
46
+ {
47
+ type: "text" as const,
48
+ text: `Found ${memories.length} memories:\n\n${lines.join("\n\n")}`,
49
+ },
50
+ ],
51
+ }
52
+ },
53
+ },
54
+ { name: "cortex_list_memories" },
55
+ )
56
+ }
package/tools/store.ts CHANGED
@@ -3,23 +3,32 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
3
3
  import type { CortexClient } from "../client.ts"
4
4
  import type { CortexPluginConfig } from "../config.ts"
5
5
  import { log } from "../log.ts"
6
- import { toSourceId } from "../session.ts"
6
+ import { extractAllTurns } from "../messages.ts"
7
+ import { toToolSourceId } from "../session.ts"
8
+ import type { ConversationTurn } from "../types/cortex.ts"
9
+
10
+ const MAX_STORE_TURNS = 10
11
+
12
+ function removeInjectedBlocks(text: string): string {
13
+ return text.replace(/<cortex-context>[\s\S]*?<\/cortex-context>\s*/g, "").trim()
14
+ }
7
15
 
8
16
  export function registerStoreTool(
9
17
  api: OpenClawPluginApi,
10
18
  client: CortexClient,
11
19
  _cfg: CortexPluginConfig,
12
- getSessionKey: () => string | undefined,
20
+ getSessionId: () => string | undefined,
21
+ getMessages: () => unknown[],
13
22
  ): void {
14
23
  api.registerTool(
15
24
  {
16
25
  name: "cortex_store",
17
26
  label: "Cortex Store",
18
27
  description:
19
- "Save important information to Cortex long-term memory. Use this to persist facts, preferences, or decisions the user wants remembered.",
28
+ "Save the full conversation history to Cortex long-term memory. Use this to persist facts, preferences, or decisions the user wants remembered. The complete chat history will be sent for context-rich storage.",
20
29
  parameters: Type.Object({
21
30
  text: Type.String({
22
- description: "The information to store in memory",
31
+ description: "A brief summary or note about what is being saved",
23
32
  }),
24
33
  title: Type.Optional(
25
34
  Type.String({
@@ -31,10 +40,59 @@ export function registerStoreTool(
31
40
  _toolCallId: string,
32
41
  params: { text: string; title?: string },
33
42
  ) {
34
- const sk = getSessionKey()
35
- const sourceId = sk ? toSourceId(sk) : undefined
43
+ const sid = getSessionId()
44
+ const sourceId = sid ? toToolSourceId(sid) : undefined
45
+ const messages = getMessages()
46
+
47
+ log.debug(`[store] tool called — sid=${sid ?? "none"} msgs=${messages.length} text="${params.text.slice(0, 50)}"`)
48
+
49
+ const allTurns = extractAllTurns(messages)
50
+ const recentTurns = allTurns.slice(-MAX_STORE_TURNS)
51
+ const turns: ConversationTurn[] = recentTurns.map((t) => ({
52
+ user: removeInjectedBlocks(t.user),
53
+ assistant: removeInjectedBlocks(t.assistant),
54
+ }))
55
+
56
+ log.debug(`[store] extracted ${allTurns.length} total turns, using last ${turns.length} (MAX_STORE_TURNS=${MAX_STORE_TURNS})`)
57
+
58
+ if (turns.length > 0 && sourceId) {
59
+ const now = new Date()
60
+ const readableTime = now.toLocaleString("en-US", {
61
+ weekday: "short",
62
+ year: "numeric",
63
+ month: "short",
64
+ day: "numeric",
65
+ hour: "2-digit",
66
+ minute: "2-digit",
67
+ timeZoneName: "short",
68
+ })
36
69
 
37
- log.debug(`store tool: "${params.text.slice(0, 50)}" - \nsourceId: ${sourceId}`)
70
+ const annotatedTurns = turns.map((t, i) => ({
71
+ user: i === 0 ? `[Temporal details: ${readableTime}]\n\n${t.user}` : t.user,
72
+ assistant: t.assistant,
73
+ }))
74
+
75
+ log.debug(`[store] ingesting ${annotatedTurns.length} conversation turns -> ${sourceId}`)
76
+
77
+ await client.ingestConversation(annotatedTurns, sourceId, {
78
+ metadata: {
79
+ captured_at: now.toISOString(),
80
+ source: "openclaw_tool",
81
+ note: params.text,
82
+ },
83
+ })
84
+
85
+ return {
86
+ content: [
87
+ {
88
+ type: "text" as const,
89
+ text: `Saved ${annotatedTurns.length} conversation turns to Cortex (${sourceId}). Note: "${params.text.length > 80 ? `${params.text.slice(0, 80)}…` : params.text}"`,
90
+ },
91
+ ],
92
+ }
93
+ }
94
+
95
+ log.debug("[store] no conversation turns found, falling back to text ingestion")
38
96
 
39
97
  await client.ingestText(params.text, {
40
98
  sourceId,
@@ -42,16 +100,11 @@ export function registerStoreTool(
42
100
  infer: true,
43
101
  })
44
102
 
45
- const preview =
46
- params.text.length > 80
47
- ? `${params.text.slice(0, 80)}…`
48
- : params.text
49
-
50
103
  return {
51
104
  content: [
52
105
  {
53
106
  type: "text" as const,
54
- text: `Saved to Cortex: "${preview}"`,
107
+ text: `Saved to Cortex: "${params.text.length > 80 ? `${params.text.slice(0, 80)}…` : params.text}"`,
55
108
  },
56
109
  ],
57
110
  }
package/types/cortex.ts CHANGED
@@ -13,6 +13,8 @@ export type MemoryPayload = {
13
13
  source_id?: string
14
14
  title?: string
15
15
  expiry_time?: number
16
+ document_metadata?: string
17
+ tenant_metadata?: string
16
18
  }
17
19
 
18
20
  export type AddMemoryRequest = {