@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.
- package/.github/workflows/publish.yaml +40 -0
- package/README.md +24 -24
- package/client.ts +11 -3
- package/commands/slash.ts +4 -4
- package/config.ts +1 -1
- package/hooks/capture.ts +70 -59
- package/index.ts +26 -7
- package/log.ts +27 -4
- package/messages.ts +73 -0
- package/package.json +1 -1
- package/session.ts +10 -2
- package/tools/delete.ts +54 -0
- package/tools/get.ts +57 -0
- package/tools/list.ts +56 -0
- package/tools/store.ts +66 -13
- package/types/cortex.ts +2 -0
|
@@ -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
|
-
|
|
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
|
|
43
|
-
|
|
|
44
|
-
| `subTenantId` | `string` | `"cortex-openclaw"
|
|
45
|
-
| `autoRecall` | `boolean` | `true`
|
|
46
|
-
| `autoCapture` | `boolean` | `true`
|
|
47
|
-
| `maxRecallResults` | `number` | `10`
|
|
48
|
-
| `recallMode` | `string` | `"fast"`
|
|
49
|
-
| `graphContext` | `boolean` | `true`
|
|
50
|
-
| `debug` | `boolean` | `false`
|
|
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
|
|
61
|
-
|
|
|
62
|
-
| `/cortex-remember <text>`
|
|
63
|
-
| `/cortex-recall <query>`
|
|
64
|
-
| `/cortex-list`
|
|
65
|
-
| `/cortex-delete <id>`
|
|
66
|
-
| `/cortex-get <source_id>`
|
|
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
|
|
71
|
-
|
|
|
72
|
-
| `cortex_store`
|
|
73
|
-
| `cortex_search`
|
|
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
|
-
|
|
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
|
-
- **
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
28
|
-
const sourceId =
|
|
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 {
|
|
4
|
+
import { extractAllTurns } from "../messages.ts"
|
|
5
|
+
import { toHookSourceId } from "../session.ts"
|
|
5
6
|
import type { ConversationTurn } from "../types/cortex.ts"
|
|
6
7
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
31
|
+
if (!sessionId) {
|
|
32
|
+
log.debug("[capture] skipped — no session id available")
|
|
33
|
+
return
|
|
34
|
+
}
|
|
66
35
|
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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.
|
|
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
|
|
28
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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(
|
|
2
|
-
return `
|
|
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
|
}
|
package/tools/delete.ts
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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
|
|
35
|
-
const sourceId =
|
|
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
|
-
|
|
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: "${
|
|
107
|
+
text: `Saved to Cortex: "${params.text.length > 80 ? `${params.text.slice(0, 80)}…` : params.text}"`,
|
|
55
108
|
},
|
|
56
109
|
],
|
|
57
110
|
}
|