@octamem/octamem-openclaw 1.0.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/LICENSE +21 -0
- package/README.md +54 -0
- package/biome.json +77 -0
- package/octamem-octamem-openclaw-1.0.0.tgz +0 -0
- package/openclaw.plugin.json +50 -0
- package/package.json +37 -0
- package/src/client.ts +152 -0
- package/src/config.ts +79 -0
- package/src/context.ts +145 -0
- package/src/index.ts +284 -0
- package/src/logger.ts +46 -0
- package/src/openclaw-config.ts +85 -0
- package/src/types.ts +35 -0
- package/tsconfig.json +17 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OctaMem
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @octamem/octamem-openclaw
|
|
2
|
+
|
|
3
|
+
OctaMem personal memory plugin for [OpenClaw](https://openclaw.ai). Adds search/add tools and automatic memory recall and capture so the assistant can use and update your personal memory.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- OpenClaw `>=2026.1.29`
|
|
8
|
+
- OctaMem API key (personal memory)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install @octamem/octamem-openclaw
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or from the OpenClaw plugins list:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
openclaw plugins install @octamem/octamem-openclaw
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Configure
|
|
23
|
+
|
|
24
|
+
Get your API key from [platform.octamem.com](https://platform.octamem.com). Then set it:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
openclaw octamem configure <your-api-key>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or set `OCTAMEM_PERSONAL_KEY` and run:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
openclaw octamem configure
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Restart OpenClaw after configuring.
|
|
37
|
+
|
|
38
|
+
## What it does
|
|
39
|
+
|
|
40
|
+
- **Auto-recall**: Before each turn, relevant personal memory is fetched and injected so the LLM can answer from your notes.
|
|
41
|
+
- **Auto-capture**: After each turn, the assistant’s reply is stored in your personal memory (plain text).
|
|
42
|
+
- **Tools**: `octamem_get` (search) and `octamem_add` (add) for the agent to query or store memory when needed.
|
|
43
|
+
- **CLI**: `openclaw octamem status | search <query> | details | add <content>`.
|
|
44
|
+
|
|
45
|
+
## Config (optional)
|
|
46
|
+
|
|
47
|
+
In OpenClaw plugin config you can set:
|
|
48
|
+
|
|
49
|
+
- `memories.personal.apiKey` — API key (supports `${OCTAMEM_PERSONAL_KEY}`).
|
|
50
|
+
- `toolDescription` — Override the description for the get-memory tool.
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
package/biome.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://biomejs.dev/schemas/2.4.4/schema.json",
|
|
3
|
+
"assist": {
|
|
4
|
+
"actions": {
|
|
5
|
+
"source": {
|
|
6
|
+
"organizeImports": "on",
|
|
7
|
+
"useSortedAttributes": "on",
|
|
8
|
+
"useSortedKeys": "off"
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"enabled": true
|
|
12
|
+
},
|
|
13
|
+
"files": {
|
|
14
|
+
"includes": [
|
|
15
|
+
"**",
|
|
16
|
+
"!**/node_modules",
|
|
17
|
+
"!**/dist",
|
|
18
|
+
"!**/bun.lock",
|
|
19
|
+
"!**/*.lock"
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
"formatter": {
|
|
23
|
+
"enabled": true,
|
|
24
|
+
"indentStyle": "tab"
|
|
25
|
+
},
|
|
26
|
+
"javascript": {
|
|
27
|
+
"formatter": {
|
|
28
|
+
"quoteStyle": "double",
|
|
29
|
+
"semicolons": "asNeeded"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"linter": {
|
|
33
|
+
"domains": {
|
|
34
|
+
"project": "none"
|
|
35
|
+
},
|
|
36
|
+
"enabled": true,
|
|
37
|
+
"rules": {
|
|
38
|
+
"correctness": {
|
|
39
|
+
"useYield": "warn",
|
|
40
|
+
"noUnusedVariables": {
|
|
41
|
+
"level": "warn",
|
|
42
|
+
"options": {
|
|
43
|
+
"ignoreRestSiblings": true
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"noUnusedImports": "warn",
|
|
47
|
+
"useParseIntRadix": "off"
|
|
48
|
+
},
|
|
49
|
+
"recommended": true,
|
|
50
|
+
"style": {
|
|
51
|
+
"noDefaultExport": "off",
|
|
52
|
+
"noInferrableTypes": "error",
|
|
53
|
+
"noNonNullAssertion": "warn",
|
|
54
|
+
"noParameterAssign": "error",
|
|
55
|
+
"noUnusedTemplateLiteral": "error",
|
|
56
|
+
"noUselessElse": "error",
|
|
57
|
+
"useAsConstAssertion": "error",
|
|
58
|
+
"useDefaultParameterLast": "error",
|
|
59
|
+
"useEnumInitializers": "error",
|
|
60
|
+
"useNamingConvention": {
|
|
61
|
+
"level": "off",
|
|
62
|
+
"options": {
|
|
63
|
+
"strictCase": false
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"useNumberNamespace": "error",
|
|
67
|
+
"useSelfClosingElements": "error",
|
|
68
|
+
"useSingleVarDeclarator": "error"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"vcs": {
|
|
73
|
+
"clientKind": "git",
|
|
74
|
+
"enabled": true,
|
|
75
|
+
"useIgnoreFile": true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "octamem-openclaw",
|
|
3
|
+
"kind": "memory",
|
|
4
|
+
"uiHints": {
|
|
5
|
+
"memories": {
|
|
6
|
+
"label": "Personal Memory",
|
|
7
|
+
"help": "Configure API key for personal memory"
|
|
8
|
+
},
|
|
9
|
+
"memories.personal": {
|
|
10
|
+
"label": "Personal Memory",
|
|
11
|
+
"help": "Your personal memory API key (read/write access)"
|
|
12
|
+
},
|
|
13
|
+
"memories.personal.apiKey": {
|
|
14
|
+
"label": "Personal API Key",
|
|
15
|
+
"sensitive": true,
|
|
16
|
+
"placeholder": "pk_personal_...",
|
|
17
|
+
"help": "API key for personal memory (or use ${OCTAMEM_PERSONAL_KEY})"
|
|
18
|
+
},
|
|
19
|
+
"toolDescription": {
|
|
20
|
+
"label": "Get tool description",
|
|
21
|
+
"help": "Custom description for the get/search memory tool (how the LLM sees it)"
|
|
22
|
+
},
|
|
23
|
+
"addToolDescription": {
|
|
24
|
+
"label": "Add tool description",
|
|
25
|
+
"help": "Custom description for the add/store memory tool (how the LLM sees it)"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"configSchema": {
|
|
29
|
+
"type": "object",
|
|
30
|
+
"additionalProperties": false,
|
|
31
|
+
"properties": {
|
|
32
|
+
"memories": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"properties": {
|
|
36
|
+
"personal": {
|
|
37
|
+
"type": "object",
|
|
38
|
+
"properties": {
|
|
39
|
+
"apiKey": { "type": "string" }
|
|
40
|
+
},
|
|
41
|
+
"required": ["apiKey"]
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"toolDescription": { "type": "string" },
|
|
46
|
+
"addToolDescription": { "type": "string" }
|
|
47
|
+
},
|
|
48
|
+
"required": []
|
|
49
|
+
}
|
|
50
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octamem/octamem-openclaw",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "OctaMem personal memory plugin for OpenClaw",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "OctaMem",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"openclaw",
|
|
10
|
+
"plugin",
|
|
11
|
+
"memory",
|
|
12
|
+
"ai",
|
|
13
|
+
"octamem"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@sinclair/typebox": "0.34.47"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc --noEmit",
|
|
20
|
+
"check-types": "tsc --noEmit",
|
|
21
|
+
"lint": "bunx @biomejs/biome ci .",
|
|
22
|
+
"lint:fix": "bunx @biomejs/biome check --write ."
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"openclaw": ">=2026.1.29"
|
|
26
|
+
},
|
|
27
|
+
"openclaw": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./src/index.ts"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.0.0",
|
|
34
|
+
"openclaw": "^2026.3.2",
|
|
35
|
+
"typescript": "^5.9.3"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { logRequest } from "./logger.ts"
|
|
2
|
+
import type {
|
|
3
|
+
AddResult,
|
|
4
|
+
ChatContext,
|
|
5
|
+
LastQa,
|
|
6
|
+
OctaMemConfig,
|
|
7
|
+
SearchResult,
|
|
8
|
+
} from "./types.ts"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* OctaMem Memory API base URL.
|
|
12
|
+
* API key must be sent in a header (Authorization: Bearer <key> or X-API-Key), not in the body.
|
|
13
|
+
* - POST /search — body: { query, previousContext? }
|
|
14
|
+
* - POST /add — body: { content, previousContext? }
|
|
15
|
+
* - POST /details — body: {} or omitted
|
|
16
|
+
*/
|
|
17
|
+
const API_BASE = "http://platform.octamem.com/api/memory"
|
|
18
|
+
|
|
19
|
+
type ApiBody = { success?: boolean; error?: string; message?: string; [k: string]: unknown }
|
|
20
|
+
|
|
21
|
+
/** Prefer API message for user-facing error; no hard-coded error codes. */
|
|
22
|
+
function apiErrorMessage(data: ApiBody | null): string | null {
|
|
23
|
+
if (!data || typeof data !== "object") return null
|
|
24
|
+
if (typeof data.message === "string" && data.message.length > 0) return data.message
|
|
25
|
+
if (typeof data.error === "string" && data.error.length > 0) return data.error
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Client for the OctaMem personal-memory API (search, add, details).
|
|
31
|
+
* Requires config.memories.personal.apiKey to be set.
|
|
32
|
+
*/
|
|
33
|
+
export class OctaMemClient {
|
|
34
|
+
private readonly apiKey: string
|
|
35
|
+
|
|
36
|
+
constructor(config: OctaMemConfig) {
|
|
37
|
+
const key = config.memories.personal?.apiKey
|
|
38
|
+
if (!key) throw new Error("Personal memory API key not configured")
|
|
39
|
+
this.apiKey = key
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Headers for Memory API: key in Authorization Bearer (recommended). */
|
|
43
|
+
private authHeaders(): Record<string, string> {
|
|
44
|
+
return {
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Returns true if config has a valid personal memory API key. */
|
|
51
|
+
static isConfigured(config: OctaMemConfig): boolean {
|
|
52
|
+
return Boolean(config.memories.personal?.apiKey)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Serializes chat context for API previousContext field. */
|
|
56
|
+
private contextStr(ctx: ChatContext): string {
|
|
57
|
+
return ctx
|
|
58
|
+
.flatMap((t) => [
|
|
59
|
+
`user: ${t.user}`,
|
|
60
|
+
t.assistant ? `assistant: ${t.assistant}` : null,
|
|
61
|
+
])
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join(" | ")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Searches personal memory; returns the raw API response for the LLM.
|
|
68
|
+
* @param query - Search query text.
|
|
69
|
+
* @param context - Recent user/assistant turns (previousContext sent to API).
|
|
70
|
+
*/
|
|
71
|
+
async search(query: string, context: ChatContext = []): Promise<SearchResult> {
|
|
72
|
+
try {
|
|
73
|
+
const previousContext = this.contextStr(context)
|
|
74
|
+
const payload = { query, previousContext }
|
|
75
|
+
logRequest("POST /api/memory/search", payload)
|
|
76
|
+
const res = await fetch(`${API_BASE}/search`, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: this.authHeaders(),
|
|
79
|
+
body: JSON.stringify(payload),
|
|
80
|
+
})
|
|
81
|
+
const data = (await res.json().catch(() => null)) as ApiBody | null
|
|
82
|
+
const apiMsg = apiErrorMessage(data)
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
return { success: false, query, error: apiMsg ?? `API ${res.status}` }
|
|
85
|
+
}
|
|
86
|
+
if (data && data.success === false) {
|
|
87
|
+
return { success: false, query, error: apiMsg ?? "Request failed" }
|
|
88
|
+
}
|
|
89
|
+
return { success: true, data: data ?? undefined, query }
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return { success: false, query, error: e instanceof Error ? e.message : String(e) }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Stores content in personal memory. If lastQa is provided, stores a Q&A pair instead of raw content.
|
|
97
|
+
* @param content - Text to store (or ignored when lastQa is set).
|
|
98
|
+
* @param metadata - Optional metadata (e.g. category, source).
|
|
99
|
+
* @param context - Recent chat turns for API previousContext.
|
|
100
|
+
* @param lastQa - If set, stores "User: ... Assistant: ..." instead of content.
|
|
101
|
+
*/
|
|
102
|
+
async add(
|
|
103
|
+
content: string,
|
|
104
|
+
metadata: Record<string, unknown> = {},
|
|
105
|
+
context: ChatContext = [],
|
|
106
|
+
lastQa?: LastQa,
|
|
107
|
+
): Promise<AddResult> {
|
|
108
|
+
try {
|
|
109
|
+
const body = lastQa
|
|
110
|
+
? `User: ${lastQa.query}\nAssistant: ${lastQa.response}`
|
|
111
|
+
: content
|
|
112
|
+
const previousContext = this.contextStr(context)
|
|
113
|
+
const payload = { content: body, previousContext }
|
|
114
|
+
logRequest("POST /api/memory/add", payload)
|
|
115
|
+
const res = await fetch(`${API_BASE}/add`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: this.authHeaders(),
|
|
118
|
+
body: JSON.stringify(payload),
|
|
119
|
+
})
|
|
120
|
+
const data = (await res.json().catch(() => null)) as ApiBody | null
|
|
121
|
+
const apiMsg = apiErrorMessage(data)
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
return { success: false, id: "", message: apiMsg ?? `API ${res.status}` }
|
|
124
|
+
}
|
|
125
|
+
if (data && data.success === false) {
|
|
126
|
+
return { success: false, id: "", message: apiMsg ?? "Request failed" }
|
|
127
|
+
}
|
|
128
|
+
return { success: true, id: `mem_${Date.now()}`, message: "Memory stored successfully" }
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return { success: false, id: "", message: e instanceof Error ? e.message : "Unknown error" }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Fetches account/details for the configured personal memory. */
|
|
135
|
+
async details(): Promise<{ success: boolean; data?: unknown; error?: string }> {
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(`${API_BASE}/details`, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: this.authHeaders(),
|
|
140
|
+
body: "{}",
|
|
141
|
+
})
|
|
142
|
+
const data = (await res.json().catch(() => null)) as ApiBody | null
|
|
143
|
+
const apiMsg = apiErrorMessage(data)
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
return { success: false, error: apiMsg ?? `API ${res.status}` }
|
|
146
|
+
}
|
|
147
|
+
return { success: true, data: data ?? undefined }
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return { success: false, error: e instanceof Error ? e.message : String(e) }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { OctaMemConfig } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
const ALLOWED_KEYS = ["memories", "toolDescription", "addToolDescription", "autoRecall", "autoCapture"]
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Replaces ${VAR} placeholders in a string with process.env[VAR].
|
|
7
|
+
* @throws If a referenced env var is not set.
|
|
8
|
+
*/
|
|
9
|
+
function resolveEnv(s: string): string {
|
|
10
|
+
return s.replace(/\$\{([^}]+)\}/g, (_, key: string) => {
|
|
11
|
+
const v = process.env[key]
|
|
12
|
+
if (!v) throw new Error(`Env ${key} not set`)
|
|
13
|
+
return v
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parses and validates raw plugin config into OctaMemConfig.
|
|
19
|
+
* Only known keys are allowed; personal memory apiKey supports ${ENV} substitution.
|
|
20
|
+
* @returns Normalized config with memories.personal set only if apiKey is valid.
|
|
21
|
+
*/
|
|
22
|
+
export function parseConfig(raw: unknown): OctaMemConfig {
|
|
23
|
+
const o = raw && typeof raw === "object" && !Array.isArray(raw)
|
|
24
|
+
? (raw as Record<string, unknown>)
|
|
25
|
+
: {}
|
|
26
|
+
|
|
27
|
+
if (Object.keys(o).length > 0) {
|
|
28
|
+
const bad = Object.keys(o).filter((k) => !ALLOWED_KEYS.includes(k))
|
|
29
|
+
if (bad.length) throw new Error(`Unknown config: ${bad.join(", ")}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const memories: OctaMemConfig["memories"] = {}
|
|
33
|
+
const personalRaw = o.memories && typeof o.memories === "object"
|
|
34
|
+
? (o.memories as Record<string, unknown>).personal
|
|
35
|
+
: undefined
|
|
36
|
+
|
|
37
|
+
if (personalRaw && typeof personalRaw === "object") {
|
|
38
|
+
const p = personalRaw as Record<string, unknown>
|
|
39
|
+
if (typeof p.apiKey === "string" && p.apiKey.length > 0) {
|
|
40
|
+
try {
|
|
41
|
+
memories.personal = {
|
|
42
|
+
apiKey: resolveEnv(p.apiKey),
|
|
43
|
+
description: typeof p.description === "string" ? p.description : undefined,
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// skip invalid personal config
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
memories,
|
|
53
|
+
toolDescription: typeof o.toolDescription === "string" ? o.toolDescription : undefined,
|
|
54
|
+
addToolDescription: typeof o.addToolDescription === "string" ? o.addToolDescription : undefined,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** OpenClaw config schema and parser for the OctaMem plugin. */
|
|
59
|
+
export const configSchema = {
|
|
60
|
+
jsonSchema: {
|
|
61
|
+
type: "object",
|
|
62
|
+
additionalProperties: false,
|
|
63
|
+
properties: {
|
|
64
|
+
memories: {
|
|
65
|
+
type: "object",
|
|
66
|
+
properties: {
|
|
67
|
+
personal: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: { apiKey: { type: "string" }, description: { type: "string" } },
|
|
70
|
+
required: ["apiKey"],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
toolDescription: { type: "string" },
|
|
75
|
+
addToolDescription: { type: "string" },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
parse: parseConfig,
|
|
79
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import type { ChatContext } from "./types.ts"
|
|
4
|
+
|
|
5
|
+
/** Max number of user/assistant pairs sent as previousContext to the OctaMem API (search/add). */
|
|
6
|
+
export const PREVIOUS_CONTEXT_PAIRS = 3
|
|
7
|
+
|
|
8
|
+
const OCTAMEM_TAG = /<octamem-context>[\s\S]*?<\/octamem-context>\s*/g
|
|
9
|
+
/** Sender (untrusted metadata) block: "Sender (untrusted metadata):" plus fenced block. */
|
|
10
|
+
const SENDER_METADATA = /Sender\s*\(untrusted\s+metadata\)\s*:\s*```[\s\S]*?```\s*/gi
|
|
11
|
+
/** Leading [timestamp] or [anything] prefix. */
|
|
12
|
+
const BRACKET_PREFIX = /^\[[^\]]*\]\s*/
|
|
13
|
+
/** Leading stray ] (e.g. from some assistant message formats). */
|
|
14
|
+
const LEADING_BRACKET = /^\s*\]\s*/
|
|
15
|
+
|
|
16
|
+
/** Session startup / system instruction text; exclude from previousContext and skip memory search. */
|
|
17
|
+
export function isSessionStartup(text: string): boolean {
|
|
18
|
+
const t = text.trim().toLowerCase()
|
|
19
|
+
return (
|
|
20
|
+
t.startsWith("a new session was started") ||
|
|
21
|
+
t.includes("session startup sequence") ||
|
|
22
|
+
t.includes("run your session startup")
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Extracts plain text from an OpenClaw message (string content or array of text parts).
|
|
28
|
+
*/
|
|
29
|
+
export function getMessageText(msg: Record<string, unknown>): string {
|
|
30
|
+
if (typeof msg.content === "string") return msg.content
|
|
31
|
+
if (Array.isArray(msg.content)) {
|
|
32
|
+
return (msg.content as Array<{ type?: string; text?: string }>)
|
|
33
|
+
.filter((p) => p.type === "text" && typeof p.text === "string")
|
|
34
|
+
.map((p) => p.text as string)
|
|
35
|
+
.join(" ")
|
|
36
|
+
}
|
|
37
|
+
return ""
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Strips OpenClaw metadata from a raw string (e.g. prompt or message content).
|
|
42
|
+
*/
|
|
43
|
+
function stripMetadata(s: string): string {
|
|
44
|
+
return s
|
|
45
|
+
.replace(OCTAMEM_TAG, "")
|
|
46
|
+
.replace(SENDER_METADATA, "")
|
|
47
|
+
.replace(BRACKET_PREFIX, "")
|
|
48
|
+
.replace(LEADING_BRACKET, "")
|
|
49
|
+
.trim()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Returns only the user or assistant utterance: no metadata, no timestamps.
|
|
54
|
+
* Use for previousContext and for stored content (add).
|
|
55
|
+
*/
|
|
56
|
+
export function getCleanMessageText(msg: Record<string, unknown>): string {
|
|
57
|
+
return stripMetadata(getMessageText(msg))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Cleans a raw prompt string (e.g. for search query). */
|
|
61
|
+
export function getCleanPrompt(prompt: string): string {
|
|
62
|
+
return stripMetadata(prompt)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Returns user/assistant turns (oldest first) with clean text only. Used to build previousContext.
|
|
67
|
+
*/
|
|
68
|
+
function getTurns(messages: unknown[]): { role: string; text: string }[] {
|
|
69
|
+
const out: { role: string; text: string }[] = []
|
|
70
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
71
|
+
const m = messages[i] as Record<string, unknown>
|
|
72
|
+
const role = m?.role as string
|
|
73
|
+
if (role !== "user" && role !== "assistant") continue
|
|
74
|
+
const text = getCleanMessageText(m)
|
|
75
|
+
if (text.length < 2) continue
|
|
76
|
+
out.push({ role, text })
|
|
77
|
+
}
|
|
78
|
+
return out.reverse()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Loads message objects from the session JSONL file; returns [] on missing file or parse error.
|
|
83
|
+
*/
|
|
84
|
+
function loadSession(stateDir: string, sessionId: string): unknown[] {
|
|
85
|
+
try {
|
|
86
|
+
const file = path.join(stateDir, "agents", "main", "sessions", `${sessionId}.jsonl`)
|
|
87
|
+
return fs
|
|
88
|
+
.readFileSync(file, "utf-8")
|
|
89
|
+
.split("\n")
|
|
90
|
+
.filter((l) => l.trim())
|
|
91
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>)
|
|
92
|
+
.filter((r) => r.type === "message" && r.message)
|
|
93
|
+
.map((r) => r.message)
|
|
94
|
+
} catch {
|
|
95
|
+
return []
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Builds ChatContext (user/assistant pairs) from in-memory messages or by loading the session file.
|
|
101
|
+
* Used for auto-recall and post-response save to provide conversation context to the API.
|
|
102
|
+
* @param messages - Event messages if available.
|
|
103
|
+
* @param stateDir - OpenClaw state dir (for session file path).
|
|
104
|
+
* @param sessionId - Session id (for session file name).
|
|
105
|
+
*/
|
|
106
|
+
export function getRecentContext(
|
|
107
|
+
messages: unknown[] | undefined,
|
|
108
|
+
stateDir: string | undefined,
|
|
109
|
+
sessionId: string | undefined,
|
|
110
|
+
): ChatContext {
|
|
111
|
+
const msgs =
|
|
112
|
+
messages?.length
|
|
113
|
+
? messages
|
|
114
|
+
: stateDir && sessionId
|
|
115
|
+
? loadSession(stateDir, sessionId)
|
|
116
|
+
: []
|
|
117
|
+
const turns = getTurns(msgs)
|
|
118
|
+
const ctx: ChatContext = []
|
|
119
|
+
for (let i = 0; i < turns.length; i++) {
|
|
120
|
+
const t = turns[i]
|
|
121
|
+
if (t.role !== "user") continue
|
|
122
|
+
if (isSessionStartup(t.text)) {
|
|
123
|
+
if (turns[i + 1]?.role === "assistant") i++
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
const next = turns[i + 1]
|
|
127
|
+
if (next?.role === "assistant") {
|
|
128
|
+
ctx.push({ user: t.text, assistant: next.text })
|
|
129
|
+
i++
|
|
130
|
+
} else {
|
|
131
|
+
ctx.push({ user: t.text, assistant: null })
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return ctx
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns at most PREVIOUS_CONTEXT_PAIRS for the API previousContext field.
|
|
139
|
+
* @param ctx - Full chat context (oldest first).
|
|
140
|
+
* @param excludeLast - If true, omit the last pair (use when storing that pair as content).
|
|
141
|
+
*/
|
|
142
|
+
export function getPreviousContextForApi(ctx: ChatContext, excludeLast: boolean): ChatContext {
|
|
143
|
+
const base = excludeLast ? ctx.slice(0, -1) : ctx
|
|
144
|
+
return base.slice(-PREVIOUS_CONTEXT_PAIRS)
|
|
145
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox"
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
3
|
+
import { OctaMemClient } from "./client.ts"
|
|
4
|
+
import { configSchema, parseConfig } from "./config.ts"
|
|
5
|
+
import {
|
|
6
|
+
resolveOpenClawConfigPath,
|
|
7
|
+
writeOctaMemPluginConfig,
|
|
8
|
+
type OctaMemPluginConfig,
|
|
9
|
+
} from "./openclaw-config.ts"
|
|
10
|
+
import { getCleanMessageText, getCleanPrompt, isSessionStartup, getPreviousContextForApi, getRecentContext } from "./context.ts"
|
|
11
|
+
|
|
12
|
+
const TOOL_DESC =
|
|
13
|
+
"Get memories from OctaMem (personal memory)."
|
|
14
|
+
const ADD_DESC =
|
|
15
|
+
"Save information to personal memory: preferences, facts, names, interests, context. Be proactive when the user shares something worth remembering."
|
|
16
|
+
|
|
17
|
+
/** Builds the content array for an OpenClaw tool response (single text part). */
|
|
18
|
+
function textContent(text: string) {
|
|
19
|
+
return [{ type: "text" as const, text }]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Message shown when memory API is unavailable. Explicit instruction so the LLM tells the user. */
|
|
23
|
+
function memoryUnavailable(reason: string) {
|
|
24
|
+
return `Inform the user in your reply: Personal memory is temporarily unavailable. ${reason}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MAX_MEMORY_SECTION_LEN = 1200
|
|
28
|
+
|
|
29
|
+
/** Formats API memory response as readable text for prependContext (no raw JSON). */
|
|
30
|
+
function formatMemoryBlock(raw: unknown): string {
|
|
31
|
+
const data = raw && typeof raw === "object" && "data" in (raw as object)
|
|
32
|
+
? (raw as { data?: Record<string, unknown> }).data
|
|
33
|
+
: (raw as Record<string, unknown> | null)
|
|
34
|
+
if (!data || typeof data !== "object") return "No memory data."
|
|
35
|
+
const lines: string[] = []
|
|
36
|
+
const add = (label: string, value: unknown) => {
|
|
37
|
+
if (value == null || value === "") return
|
|
38
|
+
const s = typeof value === "string" ? value : String(value)
|
|
39
|
+
const trimmed = s.length > MAX_MEMORY_SECTION_LEN ? s.slice(0, MAX_MEMORY_SECTION_LEN) + "…" : s
|
|
40
|
+
lines.push(`${label}: ${trimmed}`)
|
|
41
|
+
}
|
|
42
|
+
add("Semantic", (data as Record<string, unknown>).semantic_memory)
|
|
43
|
+
add("Episodic", (data as Record<string, unknown>).episodic_memory)
|
|
44
|
+
add("Procedural", (data as Record<string, unknown>).procedural_memory)
|
|
45
|
+
return lines.length ? lines.join("\n\n") : "No memory data."
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* OpenClaw plugin: OctaMem personal memory (search, add, auto-recall, auto-capture).
|
|
50
|
+
* Registers tools octamem_get / octamem_add, CLI (octamem status/search/details/add), and lifecycle hooks.
|
|
51
|
+
*/
|
|
52
|
+
export default {
|
|
53
|
+
id: "octamem-openclaw",
|
|
54
|
+
name: "OctaMem",
|
|
55
|
+
description: "Personal memory for OpenClaw",
|
|
56
|
+
kind: "memory" as const,
|
|
57
|
+
configSchema,
|
|
58
|
+
|
|
59
|
+
/** Registers CLI (always), then tools and hooks when personal memory is configured. */
|
|
60
|
+
register(api: OpenClawPluginApi) {
|
|
61
|
+
const config = parseConfig(api.pluginConfig)
|
|
62
|
+
const configured = OctaMemClient.isConfigured(config)
|
|
63
|
+
const client = configured ? new OctaMemClient(config) : null
|
|
64
|
+
const stateDir = api.runtime.state.resolveStateDir()
|
|
65
|
+
const toolDesc = config.toolDescription ?? TOOL_DESC
|
|
66
|
+
const addToolDesc = config.addToolDescription ?? ADD_DESC
|
|
67
|
+
|
|
68
|
+
// CLI is always registered so users can run `openclaw octamem configure` without editing config.
|
|
69
|
+
api.registerCli(
|
|
70
|
+
({ program }) => {
|
|
71
|
+
const octamem = program.command("octamem").description("OctaMem memory commands")
|
|
72
|
+
|
|
73
|
+
octamem
|
|
74
|
+
.command("configure")
|
|
75
|
+
.description("Write OctaMem plugin config into OpenClaw config file")
|
|
76
|
+
.argument("[apiKey]", "Personal API key (or set OCTAMEM_PERSONAL_KEY)")
|
|
77
|
+
.option("--tool-desc-get <description>", "Custom description for the get/search tool")
|
|
78
|
+
.option("--tool-desc-add <description>", "Custom description for the add/store tool")
|
|
79
|
+
.action(async (apiKeyArg: string, opts: { toolDescGet?: string; toolDescAdd?: string }) => {
|
|
80
|
+
const apiKey =
|
|
81
|
+
(apiKeyArg && apiKeyArg.trim()) || process.env.OCTAMEM_PERSONAL_KEY || ""
|
|
82
|
+
if (!apiKey) {
|
|
83
|
+
console.log(
|
|
84
|
+
"Usage: openclaw octamem configure <apiKey>\n Or set OCTAMEM_PERSONAL_KEY and run: openclaw octamem configure",
|
|
85
|
+
)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
const configPath = resolveOpenClawConfigPath()
|
|
89
|
+
const written = writeOctaMemPluginConfig(configPath, {
|
|
90
|
+
apiKey: apiKey.trim(),
|
|
91
|
+
...(opts.toolDescGet !== undefined && { toolDescription: opts.toolDescGet }),
|
|
92
|
+
...(opts.toolDescAdd !== undefined && { addToolDescription: opts.toolDescAdd }),
|
|
93
|
+
})
|
|
94
|
+
console.log(`Config written to ${written}\nRestart OpenClaw to apply.`)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
octamem.command("status").description("Show status").action(() => {
|
|
98
|
+
if (configured) {
|
|
99
|
+
console.log("\n# OctaMem Status\n\nPersonal memory: configured (read/write)\n")
|
|
100
|
+
} else {
|
|
101
|
+
console.log(
|
|
102
|
+
"\n# OctaMem Status\n\nNot configured. Run: openclaw octamem configure <apiKey>\n",
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if (client) {
|
|
108
|
+
octamem
|
|
109
|
+
.command("search")
|
|
110
|
+
.description("Search personal memory")
|
|
111
|
+
.argument("<query>", "Search query")
|
|
112
|
+
.action(async (query: string) => {
|
|
113
|
+
const r = await client.search(query)
|
|
114
|
+
if (!r.success) return console.log("Error:", r.error ?? "Search failed")
|
|
115
|
+
const raw = r.data
|
|
116
|
+
console.log("\n# Search Results (personal)\n")
|
|
117
|
+
console.log(typeof raw === "string" ? raw : JSON.stringify(raw ?? {}, null, 2))
|
|
118
|
+
})
|
|
119
|
+
octamem.command("details").description("Memory details").action(async () => {
|
|
120
|
+
const r = await client.details()
|
|
121
|
+
if (!r.success) return console.log("Error:", r.error)
|
|
122
|
+
console.log("\n# Memory Details (personal)\n\n", JSON.stringify(r.data, null, 2))
|
|
123
|
+
})
|
|
124
|
+
octamem
|
|
125
|
+
.command("add")
|
|
126
|
+
.description("Add to personal memory")
|
|
127
|
+
.argument("<content>", "Content")
|
|
128
|
+
.action(async (content: string) => {
|
|
129
|
+
const r = await client.add(content)
|
|
130
|
+
if (!r.success) return console.log("Error:", r.message)
|
|
131
|
+
console.log("Stored (ID:", r.id + ")")
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{ commands: ["octamem"] },
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if (!client) return
|
|
139
|
+
|
|
140
|
+
api.registerTool(
|
|
141
|
+
{
|
|
142
|
+
name: "octamem_get",
|
|
143
|
+
label: "OctaMem Get",
|
|
144
|
+
description: toolDesc,
|
|
145
|
+
parameters: Type.Object({
|
|
146
|
+
query: Type.String({ description: "Search query" }),
|
|
147
|
+
}),
|
|
148
|
+
async execute(_, params) {
|
|
149
|
+
const result = await client.search(params.query)
|
|
150
|
+
if (!result.success) {
|
|
151
|
+
return { content: textContent(memoryUnavailable(result.error ?? "Search failed.")), details: undefined }
|
|
152
|
+
}
|
|
153
|
+
const raw = result.data
|
|
154
|
+
const text =
|
|
155
|
+
typeof raw === "string"
|
|
156
|
+
? raw
|
|
157
|
+
: JSON.stringify(raw === undefined || raw === null ? {} : raw, null, 2)
|
|
158
|
+
return {
|
|
159
|
+
content: textContent(text),
|
|
160
|
+
details: { memoryType: "personal", data: raw },
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{ name: "octamem_get" },
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
api.registerTool(
|
|
168
|
+
{
|
|
169
|
+
name: "octamem_add",
|
|
170
|
+
label: "OctaMem Add",
|
|
171
|
+
description: addToolDesc,
|
|
172
|
+
parameters: Type.Object({
|
|
173
|
+
content: Type.String({ description: "Information to store" }),
|
|
174
|
+
category: Type.Optional(Type.String({ description: "Category: preference, fact, name, context, interest, reminder" })),
|
|
175
|
+
}),
|
|
176
|
+
async execute(_, params) {
|
|
177
|
+
const meta = params.category ? { category: params.category } : {}
|
|
178
|
+
const result = await client.add(params.content, meta)
|
|
179
|
+
if (!result.success) {
|
|
180
|
+
return { content: textContent(memoryUnavailable(result.message)), details: undefined }
|
|
181
|
+
}
|
|
182
|
+
const preview = params.content.length > 80 ? `${params.content.slice(0, 80)}...` : params.content
|
|
183
|
+
return {
|
|
184
|
+
content: textContent(`Stored to personal memory: "${preview}"`),
|
|
185
|
+
details: { id: result.id, memoryType: "personal" },
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
{ name: "octamem_add" },
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
let lastPrompt = ""
|
|
193
|
+
let lastTime = 0
|
|
194
|
+
|
|
195
|
+
/** Current turn prompt from event (before_prompt_build or before_agent_start). */
|
|
196
|
+
function getEventPrompt(event: { prompt?: string; messages?: unknown[] }): string {
|
|
197
|
+
if (typeof event.prompt === "string" && event.prompt.length > 0) return event.prompt
|
|
198
|
+
const msgs = event.messages ?? []
|
|
199
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
200
|
+
const m = msgs[i] as Record<string, unknown>
|
|
201
|
+
if (m?.role === "user") return getCleanMessageText(m)
|
|
202
|
+
}
|
|
203
|
+
return ""
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Auto-recall and auto-capture are always on (part of the memory system).
|
|
207
|
+
{
|
|
208
|
+
const injectMemory = async (
|
|
209
|
+
event: { prompt?: string; messages?: unknown[] },
|
|
210
|
+
ctx: { sessionId?: string } | undefined,
|
|
211
|
+
) => {
|
|
212
|
+
const prompt = getEventPrompt(event)
|
|
213
|
+
if (!prompt || prompt.length < 5) return
|
|
214
|
+
const now = Date.now()
|
|
215
|
+
if (prompt === lastPrompt && now - lastTime < 2000) return
|
|
216
|
+
lastPrompt = prompt
|
|
217
|
+
lastTime = now
|
|
218
|
+
try {
|
|
219
|
+
const clean = getCleanPrompt(prompt)
|
|
220
|
+
if (isSessionStartup(clean)) return
|
|
221
|
+
// Use session file for context (same source as add) so previousContext matches add's flow.
|
|
222
|
+
const chatCtx =
|
|
223
|
+
stateDir && ctx?.sessionId
|
|
224
|
+
? getRecentContext(undefined, stateDir, ctx.sessionId)
|
|
225
|
+
: getRecentContext(event.messages, stateDir, ctx?.sessionId)
|
|
226
|
+
const lastPair = chatCtx[chatCtx.length - 1]
|
|
227
|
+
const excludeLast = lastPair?.user === clean && lastPair?.assistant === null
|
|
228
|
+
const prevCtx = getPreviousContextForApi(chatCtx, excludeLast)
|
|
229
|
+
const r = await client.search(clean, prevCtx)
|
|
230
|
+
if (!r.success) {
|
|
231
|
+
const msg = r.error ?? "Search failed."
|
|
232
|
+
// appendSystemContext: inject into system prompt so it is not stored with the user message (docs.openclaw.ai/plugin).
|
|
233
|
+
return { appendSystemContext: `\n\n### Personal Memory\nIn your response, tell the user: Personal memory is temporarily unavailable. ${msg}` } as { prependContext?: string }
|
|
234
|
+
}
|
|
235
|
+
const raw = r.data
|
|
236
|
+
const block =
|
|
237
|
+
typeof raw === "string"
|
|
238
|
+
? raw
|
|
239
|
+
: formatMemoryBlock(raw)
|
|
240
|
+
return { appendSystemContext: `\n\n### Personal Memory\n${block}` } as { prependContext?: string }
|
|
241
|
+
} catch {
|
|
242
|
+
// skip
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
api.on("before_prompt_build", injectMemory)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Memory of OpenClaw: save every user query + agent/LLM response to personal memory.
|
|
250
|
+
api.on("agent_end", async (event, ctx) => {
|
|
251
|
+
if (!event.success) return
|
|
252
|
+
const msgs = event.messages ?? []
|
|
253
|
+
let userText = ""
|
|
254
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
255
|
+
const m = msgs[i] as Record<string, unknown>
|
|
256
|
+
if (m.role === "user") {
|
|
257
|
+
userText = getCleanMessageText(m)
|
|
258
|
+
break
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!userText || userText.length < 5) return
|
|
262
|
+
if (isSessionStartup(userText)) return
|
|
263
|
+
let assistantText = ""
|
|
264
|
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
265
|
+
const m = msgs[i] as Record<string, unknown>
|
|
266
|
+
if (m.role === "assistant") {
|
|
267
|
+
assistantText = getCleanMessageText(m)
|
|
268
|
+
break
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (!assistantText) return
|
|
272
|
+
const chatCtx = getRecentContext(event.messages, stateDir, ctx?.sessionId)
|
|
273
|
+
const prevCtx = getPreviousContextForApi(chatCtx, true)
|
|
274
|
+
// Store only the assistant reply as plain text (no "User: ... Assistant: ..." wrapper).
|
|
275
|
+
try {
|
|
276
|
+
await client.add(assistantText, { source: "post_response" }, prevCtx)
|
|
277
|
+
} catch {
|
|
278
|
+
// skip
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
api.registerService({ id: "octamem-openclaw", start: () => {}, stop: () => {} })
|
|
283
|
+
},
|
|
284
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
|
|
5
|
+
const LOG_PATH = path.join(os.homedir(), ".openclaw", "octamem-api.log")
|
|
6
|
+
|
|
7
|
+
/** Redact apiKey in payload for safe logging. */
|
|
8
|
+
function redactPayload(obj: Record<string, unknown>): Record<string, unknown> {
|
|
9
|
+
const out = { ...obj }
|
|
10
|
+
if (typeof out.apiKey === "string" && out.apiKey.length > 0) {
|
|
11
|
+
out.apiKey = "<redacted>"
|
|
12
|
+
}
|
|
13
|
+
return out
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Appends a formatted log line for an API request to ~/.openclaw/octamem-api.log.
|
|
18
|
+
* Payload is JSON-formatted; apiKey is redacted.
|
|
19
|
+
*/
|
|
20
|
+
export function logRequest(endpoint: string, payload: Record<string, unknown>): void {
|
|
21
|
+
try {
|
|
22
|
+
const ts = new Date().toISOString()
|
|
23
|
+
const safe = redactPayload(payload)
|
|
24
|
+
const body = JSON.stringify(safe, null, 2)
|
|
25
|
+
const block = `\n=== ${ts} ${endpoint} ===\n${body}\n`
|
|
26
|
+
fs.appendFileSync(LOG_PATH, block)
|
|
27
|
+
} catch {
|
|
28
|
+
// ignore log errors
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Appends the API response to ~/.openclaw/octamem-api.log (same file as request log).
|
|
34
|
+
*/
|
|
35
|
+
export function logResponse(endpoint: string, response: unknown): void {
|
|
36
|
+
try {
|
|
37
|
+
const ts = new Date().toISOString()
|
|
38
|
+
const body = typeof response === "object" && response !== null
|
|
39
|
+
? JSON.stringify(response, null, 2)
|
|
40
|
+
: String(response)
|
|
41
|
+
const block = `--- ${ts} ${endpoint} response ---\n${body}\n`
|
|
42
|
+
fs.appendFileSync(LOG_PATH, block)
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore log errors
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import path from "node:path"
|
|
3
|
+
import os from "node:os"
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = ".openclaw"
|
|
6
|
+
const CONFIG_FILES = ["openclaw.json", "config.json"]
|
|
7
|
+
|
|
8
|
+
export type OctaMemPluginConfig = {
|
|
9
|
+
apiKey: string
|
|
10
|
+
/** Override description for the get (search) tool. */
|
|
11
|
+
toolDescription?: string
|
|
12
|
+
/** Override description for the add (store) tool. */
|
|
13
|
+
addToolDescription?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolves the path to the OpenClaw config file.
|
|
18
|
+
* Uses OPENCLAW_CONFIG env if set, otherwise ~/.openclaw/openclaw.json or config.json.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveOpenClawConfigPath(): string {
|
|
21
|
+
const fromEnv = process.env.OPENCLAW_CONFIG
|
|
22
|
+
if (fromEnv) return fromEnv
|
|
23
|
+
const home = os.homedir()
|
|
24
|
+
const dir = path.join(home, CONFIG_DIR)
|
|
25
|
+
for (const name of CONFIG_FILES) {
|
|
26
|
+
const p = path.join(dir, name)
|
|
27
|
+
if (fs.existsSync(p)) return p
|
|
28
|
+
}
|
|
29
|
+
return path.join(dir, CONFIG_FILES[0])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Reads and parses the OpenClaw config file. Returns empty object if missing or invalid.
|
|
34
|
+
*/
|
|
35
|
+
export function readOpenClawConfig(configPath: string): Record<string, unknown> {
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(configPath, "utf-8")
|
|
38
|
+
const data = JSON.parse(raw)
|
|
39
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : {}
|
|
40
|
+
} catch {
|
|
41
|
+
return {}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Writes OctaMem plugin config into the OpenClaw config file (merge then write).
|
|
47
|
+
* Ensures plugins.enabled, plugins.slots.memory, and plugins.entries["octamem-openclaw"].
|
|
48
|
+
* @returns Path to the config file written.
|
|
49
|
+
*/
|
|
50
|
+
export function writeOctaMemPluginConfig(
|
|
51
|
+
configPath: string,
|
|
52
|
+
options: OctaMemPluginConfig,
|
|
53
|
+
): string {
|
|
54
|
+
const data = readOpenClawConfig(configPath)
|
|
55
|
+
const plugins = (data.plugins && typeof data.plugins === "object"
|
|
56
|
+
? { ...(data.plugins as Record<string, unknown>) }
|
|
57
|
+
: {}) as Record<string, unknown>
|
|
58
|
+
|
|
59
|
+
plugins.enabled = true
|
|
60
|
+
plugins.slots = { ...(plugins.slots as Record<string, unknown>), memory: "octamem-openclaw" }
|
|
61
|
+
const entries = (plugins.entries && typeof plugins.entries === "object"
|
|
62
|
+
? { ...(plugins.entries as Record<string, unknown>) }
|
|
63
|
+
: {}) as Record<string, unknown>
|
|
64
|
+
|
|
65
|
+
const existing = entries["octamem-openclaw"]
|
|
66
|
+
const existingConfig = existing && typeof existing === "object" && (existing as Record<string, unknown>).config && typeof (existing as Record<string, unknown>).config === "object"
|
|
67
|
+
? { ...((existing as Record<string, unknown>).config as Record<string, unknown>) }
|
|
68
|
+
: {}
|
|
69
|
+
entries["octamem-openclaw"] = {
|
|
70
|
+
enabled: true,
|
|
71
|
+
config: {
|
|
72
|
+
...existingConfig,
|
|
73
|
+
memories: { personal: { apiKey: options.apiKey } },
|
|
74
|
+
...(options.toolDescription !== undefined && { toolDescription: options.toolDescription }),
|
|
75
|
+
...(options.addToolDescription !== undefined && { addToolDescription: options.addToolDescription }),
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
plugins.entries = entries
|
|
79
|
+
data.plugins = plugins
|
|
80
|
+
|
|
81
|
+
const dir = path.dirname(configPath)
|
|
82
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
83
|
+
fs.writeFileSync(configPath, JSON.stringify(data, null, 2), "utf-8")
|
|
84
|
+
return configPath
|
|
85
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration shape (from OpenClaw plugin config).
|
|
3
|
+
* Only personal memory is supported; apiKey may use ${ENV_VAR} substitution.
|
|
4
|
+
*/
|
|
5
|
+
export type OctaMemConfig = {
|
|
6
|
+
memories: { personal?: { apiKey: string; description?: string } }
|
|
7
|
+
/** Description for the get/search tool (user-facing). */
|
|
8
|
+
toolDescription?: string
|
|
9
|
+
/** Description for the add/store tool (user-facing). */
|
|
10
|
+
addToolDescription?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A single user/assistant turn in chat history. */
|
|
14
|
+
export type ChatTurn = { user: string; assistant: string | null }
|
|
15
|
+
|
|
16
|
+
/** Ordered list of chat turns, used as context for search/add API calls. */
|
|
17
|
+
export type ChatContext = ChatTurn[]
|
|
18
|
+
|
|
19
|
+
/** Result of a search request; data is the raw API response (passed through to LLM). */
|
|
20
|
+
export type SearchResult = {
|
|
21
|
+
success: boolean
|
|
22
|
+
data?: unknown
|
|
23
|
+
query: string
|
|
24
|
+
error?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Result of an add (store) request; message holds success or error text. */
|
|
28
|
+
export type AddResult = {
|
|
29
|
+
success: boolean
|
|
30
|
+
id: string
|
|
31
|
+
message: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Last user question and assistant reply; used when auto-saving a Q&A pair. */
|
|
35
|
+
export type LastQa = { query: string; response: string }
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"rootDir": "src"
|
|
14
|
+
},
|
|
15
|
+
"include": ["src/**/*.ts"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|