@octamem/octamem-openclaw 1.0.3 → 1.0.4
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/package.json +1 -1
- package/src/client.ts +54 -104
- package/src/config.ts +7 -56
- package/src/context.ts +141 -106
- package/src/index.ts +140 -286
- package/src/logger.ts +9 -15
- package/src/openclaw-config.ts +42 -18
- package/src/types.ts +11 -25
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -1,152 +1,102 @@
|
|
|
1
1
|
import { logRequest } from "./logger.ts"
|
|
2
2
|
import type {
|
|
3
3
|
AddResult,
|
|
4
|
-
|
|
5
|
-
LastQa,
|
|
4
|
+
MemoryData,
|
|
6
5
|
OctaMemConfig,
|
|
7
6
|
SearchResult,
|
|
8
7
|
} from "./types.ts"
|
|
9
8
|
|
|
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
9
|
const API_BASE = "http://platform.octamem.com/api/memory"
|
|
18
10
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
11
|
+
/** Safely coerce any value to a string — handles objects, arrays, numbers, null, etc. */
|
|
12
|
+
function asString(val: unknown): string {
|
|
13
|
+
if (val === null || val === undefined || val === "") return ""
|
|
14
|
+
if (typeof val === "string") return val
|
|
15
|
+
return JSON.stringify(val)
|
|
27
16
|
}
|
|
28
17
|
|
|
29
|
-
/**
|
|
30
|
-
* Client for the OctaMem personal-memory API (search, add, details).
|
|
31
|
-
* Requires config.memories.personal.apiKey to be set.
|
|
32
|
-
*/
|
|
33
18
|
export class OctaMemClient {
|
|
34
19
|
private readonly apiKey: string
|
|
35
20
|
|
|
36
21
|
constructor(config: OctaMemConfig) {
|
|
37
22
|
const key = config.memories.personal?.apiKey
|
|
38
|
-
if (!key) throw new Error("
|
|
23
|
+
if (!key) throw new Error("OctaMem API key missing")
|
|
39
24
|
this.apiKey = key
|
|
40
25
|
}
|
|
41
26
|
|
|
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
27
|
static isConfigured(config: OctaMemConfig): boolean {
|
|
52
28
|
return Boolean(config.memories.personal?.apiKey)
|
|
53
29
|
}
|
|
54
30
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
t.assistant ? `assistant: ${t.assistant}` : null,
|
|
61
|
-
])
|
|
62
|
-
.filter(Boolean)
|
|
63
|
-
.join(" | ")
|
|
31
|
+
private headers() {
|
|
32
|
+
return {
|
|
33
|
+
"Content-Type": "application/json",
|
|
34
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
35
|
+
}
|
|
64
36
|
}
|
|
65
37
|
|
|
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> {
|
|
38
|
+
async search(query: string, previousContext: string): Promise<SearchResult> {
|
|
72
39
|
try {
|
|
73
|
-
const previousContext = this.contextStr(context)
|
|
74
40
|
const payload = { query, previousContext }
|
|
75
|
-
logRequest("
|
|
41
|
+
logRequest("search", payload)
|
|
42
|
+
|
|
76
43
|
const res = await fetch(`${API_BASE}/search`, {
|
|
77
44
|
method: "POST",
|
|
78
|
-
headers: this.
|
|
45
|
+
headers: this.headers(),
|
|
79
46
|
body: JSON.stringify(payload),
|
|
80
47
|
})
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
48
|
+
|
|
49
|
+
const json = (await res.json().catch(() => null)) as Record<
|
|
50
|
+
string,
|
|
51
|
+
unknown
|
|
52
|
+
> | null
|
|
53
|
+
|
|
54
|
+
if (!res.ok || json?.success === false) {
|
|
55
|
+
const error =
|
|
56
|
+
(json?.message as string) ||
|
|
57
|
+
(json?.error as string) ||
|
|
58
|
+
"Search failed"
|
|
59
|
+
return { success: false, query, error }
|
|
85
60
|
}
|
|
86
|
-
|
|
87
|
-
|
|
61
|
+
|
|
62
|
+
const raw = (json?.data ?? {}) as Record<string, unknown>
|
|
63
|
+
const data: MemoryData = {
|
|
64
|
+
semantic_memory: asString(raw.semantic_memory),
|
|
65
|
+
episodic_memory: asString(raw.episodic_memory),
|
|
66
|
+
procedural_memory: asString(raw.procedural_memory),
|
|
88
67
|
}
|
|
89
|
-
|
|
68
|
+
|
|
69
|
+
return { success: true, data, query }
|
|
90
70
|
} catch (e) {
|
|
91
|
-
return { success: false, query, error:
|
|
71
|
+
return { success: false, query, error: String(e) }
|
|
92
72
|
}
|
|
93
73
|
}
|
|
94
74
|
|
|
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> {
|
|
75
|
+
async add(content: string, previousContext: string): Promise<AddResult> {
|
|
108
76
|
try {
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const previousContext = this.contextStr(context)
|
|
113
|
-
const payload = { content: body, previousContext }
|
|
114
|
-
logRequest("POST /api/memory/add", payload)
|
|
77
|
+
const payload = { content, previousContext }
|
|
78
|
+
logRequest("add", payload)
|
|
79
|
+
|
|
115
80
|
const res = await fetch(`${API_BASE}/add`, {
|
|
116
81
|
method: "POST",
|
|
117
|
-
headers: this.
|
|
82
|
+
headers: this.headers(),
|
|
118
83
|
body: JSON.stringify(payload),
|
|
119
84
|
})
|
|
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
85
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const apiMsg = apiErrorMessage(data)
|
|
144
|
-
if (!res.ok) {
|
|
145
|
-
return { success: false, error: apiMsg ?? `API ${res.status}` }
|
|
86
|
+
const json = (await res.json().catch(() => null)) as Record<
|
|
87
|
+
string,
|
|
88
|
+
unknown
|
|
89
|
+
> | null
|
|
90
|
+
|
|
91
|
+
if (!res.ok || json?.success === false) {
|
|
92
|
+
const message =
|
|
93
|
+
(json?.message as string) || (json?.error as string) || "Add failed"
|
|
94
|
+
return { success: false, message }
|
|
146
95
|
}
|
|
147
|
-
|
|
96
|
+
|
|
97
|
+
return { success: true, message: (json?.message as string) || "Stored" }
|
|
148
98
|
} catch (e) {
|
|
149
|
-
return { success: false,
|
|
99
|
+
return { success: false, message: String(e) }
|
|
150
100
|
}
|
|
151
101
|
}
|
|
152
102
|
}
|
package/src/config.ts
CHANGED
|
@@ -1,78 +1,29 @@
|
|
|
1
1
|
import type { OctaMemConfig } from "./types.ts"
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
}
|
|
3
|
+
export function parseConfig(raw: any): OctaMemConfig {
|
|
4
|
+
const apiKey =
|
|
5
|
+
raw?.memories?.personal?.apiKey || process.env.OCTAMEM_PERSONAL_KEY || ""
|
|
50
6
|
|
|
51
7
|
return {
|
|
52
|
-
memories,
|
|
53
|
-
toolDescription: typeof o.toolDescription === "string" ? o.toolDescription : undefined,
|
|
54
|
-
addToolDescription: typeof o.addToolDescription === "string" ? o.addToolDescription : undefined,
|
|
8
|
+
memories: apiKey ? { personal: { apiKey } } : {},
|
|
55
9
|
}
|
|
56
10
|
}
|
|
57
11
|
|
|
58
|
-
/** OpenClaw config schema and parser for the OctaMem plugin. */
|
|
59
12
|
export const configSchema = {
|
|
60
13
|
jsonSchema: {
|
|
61
14
|
type: "object",
|
|
62
|
-
additionalProperties: false,
|
|
63
15
|
properties: {
|
|
64
16
|
memories: {
|
|
65
17
|
type: "object",
|
|
66
18
|
properties: {
|
|
67
19
|
personal: {
|
|
68
20
|
type: "object",
|
|
69
|
-
properties: {
|
|
70
|
-
|
|
21
|
+
properties: {
|
|
22
|
+
apiKey: { type: "string" },
|
|
23
|
+
},
|
|
71
24
|
},
|
|
72
25
|
},
|
|
73
26
|
},
|
|
74
|
-
toolDescription: { type: "string" },
|
|
75
|
-
addToolDescription: { type: "string" },
|
|
76
27
|
},
|
|
77
28
|
},
|
|
78
29
|
parse: parseConfig,
|
package/src/context.ts
CHANGED
|
@@ -1,145 +1,180 @@
|
|
|
1
|
-
import fs from "node:fs"
|
|
2
|
-
import path from "node:path"
|
|
3
1
|
import type { ChatContext } from "./types.ts"
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
export const PREVIOUS_CONTEXT_PAIRS = 3
|
|
3
|
+
export const MAX_CONTEXT_PAIRS = 3
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Strips OpenClaw-injected wrappers from message text.
|
|
7
|
+
*
|
|
8
|
+
* OpenClaw wraps user prompts with:
|
|
9
|
+
* 1. A "Sender (untrusted metadata):" block containing a fenced ```json ... ``` section
|
|
10
|
+
* 2. A "[timestamp]" prefix like [Thu 2026-03-19 18:01 GMT+5:30]
|
|
11
|
+
*
|
|
12
|
+
* OpenClaw wraps assistant messages with:
|
|
13
|
+
* 3. [[tag]] markers like [[reply_to_current]]
|
|
14
|
+
*
|
|
15
|
+
* This function removes all three using plain string operations.
|
|
16
|
+
*/
|
|
17
|
+
export function stripOpenClawWrapper(text: string): string {
|
|
18
|
+
let s = text.trim()
|
|
19
|
+
|
|
20
|
+
// 1. Strip "Sender (untrusted metadata):\n```json\n{...}\n```" block
|
|
21
|
+
const senderMarker = "Sender (untrusted metadata):"
|
|
22
|
+
const senderIdx = s.indexOf(senderMarker)
|
|
23
|
+
if (senderIdx !== -1) {
|
|
24
|
+
const fenceOpen = s.indexOf("```", senderIdx + senderMarker.length)
|
|
25
|
+
if (fenceOpen !== -1) {
|
|
26
|
+
const fenceClose = s.indexOf("```", fenceOpen + 3)
|
|
27
|
+
if (fenceClose !== -1) {
|
|
28
|
+
s = s.slice(fenceClose + 3).trim()
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 2. Strip [timestamp] prefix — only if it starts with [ and closes within 80 chars
|
|
34
|
+
if (s.startsWith("[")) {
|
|
35
|
+
const close = s.indexOf("]")
|
|
36
|
+
if (close !== -1 && close < 80) {
|
|
37
|
+
s = s.slice(close + 1).trim()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 3. Strip [[tag]] markers anywhere in text (e.g. [[reply_to_current]])
|
|
42
|
+
let safety = 10
|
|
43
|
+
while (s.includes("[[") && s.includes("]]") && safety-- > 0) {
|
|
44
|
+
const start = s.indexOf("[[")
|
|
45
|
+
const end = s.indexOf("]]", start)
|
|
46
|
+
if (end === -1) break
|
|
47
|
+
s = (s.slice(0, start) + s.slice(end + 2)).trim()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 4. Strip leading stray ] that can remain after other stripping
|
|
51
|
+
if (s.startsWith("]")) {
|
|
52
|
+
s = s.slice(1).trim()
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return s
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const GREETINGS = new Set([
|
|
59
|
+
"hi",
|
|
60
|
+
"hello",
|
|
61
|
+
"hey",
|
|
62
|
+
"hola",
|
|
63
|
+
"howdy",
|
|
64
|
+
"yo",
|
|
65
|
+
"sup",
|
|
66
|
+
"greetings",
|
|
67
|
+
"good morning",
|
|
68
|
+
"good afternoon",
|
|
69
|
+
"good evening",
|
|
70
|
+
"good night",
|
|
71
|
+
"whats up",
|
|
72
|
+
"what's up",
|
|
73
|
+
"how are you",
|
|
74
|
+
"how's it going",
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
const SESSION_STARTUP_MARKERS = [
|
|
78
|
+
"a new session was started",
|
|
79
|
+
"session startup sequence",
|
|
80
|
+
"run your session startup",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
function stripTrailingPunctuation(s: string): string {
|
|
84
|
+
let end = s.length
|
|
85
|
+
while (end > 0 && "!.,?;:' \t\n\"".includes(s[end - 1])) end--
|
|
86
|
+
return s.slice(0, end)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isGreeting(text: string): boolean {
|
|
90
|
+
const normalized = stripTrailingPunctuation(text.toLowerCase().trim())
|
|
91
|
+
if (!normalized) return true
|
|
92
|
+
if (GREETINGS.has(normalized)) return true
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
15
95
|
|
|
16
|
-
/** Session startup / system instruction text; exclude from previousContext and skip memory search. */
|
|
17
96
|
export function isSessionStartup(text: string): boolean {
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
)
|
|
97
|
+
const lower = text.trim().toLowerCase()
|
|
98
|
+
for (const marker of SESSION_STARTUP_MARKERS) {
|
|
99
|
+
if (lower.includes(marker)) return true
|
|
100
|
+
}
|
|
101
|
+
return false
|
|
24
102
|
}
|
|
25
103
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
104
|
+
/** Returns true if this text should NOT be sent to OctaMem (greeting, startup, or empty). */
|
|
105
|
+
export function shouldSkip(text: string): boolean {
|
|
106
|
+
if (!text || !text.trim()) return true
|
|
107
|
+
return isGreeting(text) || isSessionStartup(text)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Extracts raw text from an OpenClaw message object. */
|
|
111
|
+
function getRawMessageText(msg: Record<string, unknown>): string {
|
|
112
|
+
if (typeof msg.content === "string") return msg.content.trim()
|
|
31
113
|
if (Array.isArray(msg.content)) {
|
|
32
114
|
return (msg.content as Array<{ type?: string; text?: string }>)
|
|
33
115
|
.filter((p) => p.type === "text" && typeof p.text === "string")
|
|
34
116
|
.map((p) => p.text as string)
|
|
35
117
|
.join(" ")
|
|
118
|
+
.trim()
|
|
36
119
|
}
|
|
37
120
|
return ""
|
|
38
121
|
}
|
|
39
122
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
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)
|
|
123
|
+
/** Extracts clean text from an OpenClaw message, with metadata wrappers stripped. */
|
|
124
|
+
export function getMessageText(msg: Record<string, unknown>): string {
|
|
125
|
+
return stripOpenClawWrapper(getRawMessageText(msg))
|
|
63
126
|
}
|
|
64
127
|
|
|
65
128
|
/**
|
|
66
|
-
*
|
|
129
|
+
* Collects user/assistant Q&A pairs from messages, skipping greetings and startup.
|
|
130
|
+
* Returns pairs in chronological order.
|
|
67
131
|
*/
|
|
68
|
-
function
|
|
69
|
-
const
|
|
70
|
-
for (
|
|
71
|
-
const m =
|
|
132
|
+
function collectPairs(messages: unknown[]): ChatContext {
|
|
133
|
+
const turns: { role: string; text: string }[] = []
|
|
134
|
+
for (const raw of messages) {
|
|
135
|
+
const m = raw as Record<string, unknown>
|
|
72
136
|
const role = m?.role as string
|
|
73
137
|
if (role !== "user" && role !== "assistant") continue
|
|
74
|
-
const text =
|
|
75
|
-
if (text
|
|
76
|
-
|
|
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 []
|
|
138
|
+
const text = getMessageText(m)
|
|
139
|
+
if (!text) continue
|
|
140
|
+
turns.push({ role, text })
|
|
96
141
|
}
|
|
97
|
-
}
|
|
98
142
|
|
|
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 = []
|
|
143
|
+
const pairs: ChatContext = []
|
|
119
144
|
for (let i = 0; i < turns.length; i++) {
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
if (isSessionStartup(t.text)) {
|
|
145
|
+
if (turns[i].role !== "user") continue
|
|
146
|
+
if (shouldSkip(turns[i].text)) {
|
|
123
147
|
if (turns[i + 1]?.role === "assistant") i++
|
|
124
148
|
continue
|
|
125
149
|
}
|
|
126
150
|
const next = turns[i + 1]
|
|
127
151
|
if (next?.role === "assistant") {
|
|
128
|
-
|
|
152
|
+
pairs.push({ user: turns[i].text, assistant: next.text })
|
|
129
153
|
i++
|
|
130
154
|
} else {
|
|
131
|
-
|
|
155
|
+
pairs.push({ user: turns[i].text, assistant: null })
|
|
132
156
|
}
|
|
133
157
|
}
|
|
134
|
-
|
|
158
|
+
|
|
159
|
+
return pairs
|
|
135
160
|
}
|
|
136
161
|
|
|
137
162
|
/**
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
163
|
+
* Builds the previousContext string from conversation messages.
|
|
164
|
+
* Format: "user: <query> | assistant: <answer> | user: <query2> | assistant: <answer2>"
|
|
165
|
+
* Max 3 pairs. Pass excludeLast=true when storing (to exclude the pair being stored).
|
|
141
166
|
*/
|
|
142
|
-
export function
|
|
143
|
-
|
|
144
|
-
|
|
167
|
+
export function buildPreviousContext(
|
|
168
|
+
messages: unknown[],
|
|
169
|
+
excludeLast: boolean,
|
|
170
|
+
): string {
|
|
171
|
+
const pairs = collectPairs(messages)
|
|
172
|
+
const base = excludeLast ? pairs.slice(0, -1) : pairs
|
|
173
|
+
const recent = base.slice(-MAX_CONTEXT_PAIRS)
|
|
174
|
+
|
|
175
|
+
if (recent.length === 0) return ""
|
|
176
|
+
|
|
177
|
+
return recent
|
|
178
|
+
.map((t) => `user: ${t.user} | assistant: ${t.assistant ?? ""}`)
|
|
179
|
+
.join(" | ")
|
|
145
180
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,327 +3,181 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
|
3
3
|
import { OctaMemClient } from "./client.ts"
|
|
4
4
|
import { configSchema, parseConfig } from "./config.ts"
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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). */
|
|
6
|
+
buildPreviousContext,
|
|
7
|
+
getMessageText,
|
|
8
|
+
shouldSkip,
|
|
9
|
+
stripOpenClawWrapper,
|
|
10
|
+
} from "./context.ts"
|
|
11
|
+
import { logLLMResponse } from "./logger.ts"
|
|
12
|
+
import type { MemoryData } from "./types.ts"
|
|
13
|
+
|
|
18
14
|
function textContent(text: string) {
|
|
19
15
|
return [{ type: "text" as const, text }]
|
|
20
16
|
}
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
function hasMemory(data: MemoryData): boolean {
|
|
19
|
+
return Boolean(
|
|
20
|
+
data.semantic_memory || data.episodic_memory || data.procedural_memory,
|
|
21
|
+
)
|
|
25
22
|
}
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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."
|
|
24
|
+
function formatMemoryForLLM(data: MemoryData): string {
|
|
25
|
+
return [
|
|
26
|
+
`semantic_memory: ${data.semantic_memory || "(empty)"}`,
|
|
27
|
+
`episodic_memory: ${data.episodic_memory || "(empty)"}`,
|
|
28
|
+
`procedural_memory: ${data.procedural_memory || "(empty)"}`,
|
|
29
|
+
].join("\n\n")
|
|
46
30
|
}
|
|
47
31
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
32
|
+
const MEMORY_INSTRUCTION = `[OctaMem Memory System — STRICT]
|
|
33
|
+
You are connected to OctaMem, a personal memory system.
|
|
34
|
+
You MUST answer the user's question ONLY based on the memory data provided below.
|
|
35
|
+
Do NOT use your internal training data or general knowledge.
|
|
36
|
+
If the memory data does not contain relevant information, clearly tell the user that you don't have that information in your memory.
|
|
37
|
+
Be conversational and natural, but NEVER fabricate information that is not present in the memory data.`
|
|
38
|
+
|
|
39
|
+
const NO_MEMORY_INSTRUCTION = `[OctaMem Memory System — STRICT]
|
|
40
|
+
No relevant memory was found for this query.
|
|
41
|
+
You MUST inform the user that you don't have any information about this in your memory.
|
|
42
|
+
Do NOT answer from your internal knowledge — say you don't know.`
|
|
57
43
|
|
|
58
|
-
/**
|
|
59
|
-
* OpenClaw plugin: OctaMem personal memory (search, add, auto-recall, auto-capture).
|
|
60
|
-
* Registers tools octamem_get / octamem_add, CLI (octamem status/search/details/add), and lifecycle hooks.
|
|
61
|
-
*/
|
|
62
44
|
export default {
|
|
63
45
|
id: "octamem-openclaw",
|
|
64
46
|
name: "OctaMem",
|
|
65
|
-
description: "Personal memory
|
|
47
|
+
description: "Personal memory using OctaMem",
|
|
66
48
|
kind: "memory" as const,
|
|
67
49
|
configSchema,
|
|
68
50
|
|
|
69
|
-
/** Registers CLI (always), then tools and hooks when personal memory is configured. */
|
|
70
51
|
register(api: OpenClawPluginApi) {
|
|
71
52
|
const config = parseConfig(api.pluginConfig)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
const configPath = resolveOpenClawConfigPath()
|
|
99
|
-
const written = writeOctaMemPluginConfig(configPath, {
|
|
100
|
-
apiKey: apiKey.trim(),
|
|
101
|
-
...(opts.toolDescGet !== undefined && { toolDescription: opts.toolDescGet }),
|
|
102
|
-
...(opts.toolDescAdd !== undefined && { addToolDescription: opts.toolDescAdd }),
|
|
103
|
-
})
|
|
104
|
-
console.log(`Config written to ${written}\nRestart OpenClaw to apply.`)
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
octamem.command("status").description("Show status").action(() => {
|
|
108
|
-
if (configured) {
|
|
109
|
-
console.log("\n# OctaMem Status\n\nPersonal memory: configured (read/write)\n")
|
|
110
|
-
} else {
|
|
111
|
-
console.log(
|
|
112
|
-
"\n# OctaMem Status\n\nNot configured. Run: openclaw octamem configure <apiKey>\n",
|
|
113
|
-
)
|
|
53
|
+
|
|
54
|
+
if (!OctaMemClient.isConfigured(config)) {
|
|
55
|
+
console.log("[OctaMem] Not configured — missing API key")
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const client = new OctaMemClient(config)
|
|
60
|
+
|
|
61
|
+
// ── Manual Tool: Search ──
|
|
62
|
+
api.registerTool({
|
|
63
|
+
name: "octamem_search",
|
|
64
|
+
label: "OctaMem Search",
|
|
65
|
+
description:
|
|
66
|
+
"Search OctaMem memory for relevant information about a query.",
|
|
67
|
+
parameters: Type.Object({
|
|
68
|
+
query: Type.String(),
|
|
69
|
+
}),
|
|
70
|
+
|
|
71
|
+
async execute(_, { query }) {
|
|
72
|
+
const result = await client.search(query, "")
|
|
73
|
+
|
|
74
|
+
if (!result.success) {
|
|
75
|
+
return {
|
|
76
|
+
content: textContent(`Memory search failed: ${result.error}`),
|
|
77
|
+
details: { success: false },
|
|
114
78
|
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
.description("Search personal memory")
|
|
121
|
-
.argument("<query>", "Search query")
|
|
122
|
-
.action(async (query: string) => {
|
|
123
|
-
const r = await client.search(query)
|
|
124
|
-
if (!r.success) return console.log("Error:", r.error ?? "Search failed")
|
|
125
|
-
const raw = r.data
|
|
126
|
-
console.log("\n# Search Results (personal)\n")
|
|
127
|
-
console.log(typeof raw === "string" ? raw : JSON.stringify(raw ?? {}, null, 2))
|
|
128
|
-
})
|
|
129
|
-
octamem.command("details").description("Memory details").action(async () => {
|
|
130
|
-
const r = await client.details()
|
|
131
|
-
if (!r.success) return console.log("Error:", r.error)
|
|
132
|
-
console.log("\n# Memory Details (personal)\n\n", JSON.stringify(r.data, null, 2))
|
|
133
|
-
})
|
|
134
|
-
octamem
|
|
135
|
-
.command("add")
|
|
136
|
-
.description("Add to personal memory")
|
|
137
|
-
.argument("<content>", "Content")
|
|
138
|
-
.action(async (content: string) => {
|
|
139
|
-
const r = await client.add(content)
|
|
140
|
-
if (!r.success) return console.log("Error:", r.message)
|
|
141
|
-
console.log("Stored (ID:", r.id + ")")
|
|
142
|
-
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
content: textContent(formatMemoryForLLM(result.data)),
|
|
83
|
+
details: { success: true, data: result.data },
|
|
143
84
|
}
|
|
144
85
|
},
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
if (!result.success) {
|
|
161
|
-
return { content: textContent(memoryUnavailable(result.error ?? "Search failed.")), details: undefined }
|
|
162
|
-
}
|
|
163
|
-
const raw = result.data
|
|
164
|
-
const text =
|
|
165
|
-
typeof raw === "string"
|
|
166
|
-
? raw
|
|
167
|
-
: JSON.stringify(raw === undefined || raw === null ? {} : raw, null, 2)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// ── Manual Tool: Add ──
|
|
89
|
+
api.registerTool({
|
|
90
|
+
name: "octamem_add",
|
|
91
|
+
label: "OctaMem Add",
|
|
92
|
+
description: "Store information into OctaMem memory.",
|
|
93
|
+
parameters: Type.Object({
|
|
94
|
+
content: Type.String(),
|
|
95
|
+
}),
|
|
96
|
+
|
|
97
|
+
async execute(_, { content }) {
|
|
98
|
+
const result = await client.add(content, "")
|
|
99
|
+
|
|
100
|
+
if (!result.success) {
|
|
168
101
|
return {
|
|
169
|
-
content: textContent(
|
|
170
|
-
details: {
|
|
102
|
+
content: textContent(`Failed to store: ${result.message}`),
|
|
103
|
+
details: { success: false },
|
|
171
104
|
}
|
|
172
|
-
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
content: textContent("Stored successfully"),
|
|
109
|
+
details: { success: true },
|
|
110
|
+
}
|
|
173
111
|
},
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
api.
|
|
178
|
-
{
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
const preview = params.content.length > 80 ? `${params.content.slice(0, 80)}...` : params.content
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// ── Auto Search: runs before the LLM sees the user's message ──
|
|
115
|
+
api.on("before_prompt_build", async (event) => {
|
|
116
|
+
try {
|
|
117
|
+
const prompt = stripOpenClawWrapper(event.prompt ?? "")
|
|
118
|
+
if (shouldSkip(prompt)) return
|
|
119
|
+
|
|
120
|
+
const messages = event.messages ?? []
|
|
121
|
+
const previousContext = buildPreviousContext(messages, false)
|
|
122
|
+
|
|
123
|
+
const result = await client.search(prompt, previousContext)
|
|
124
|
+
|
|
125
|
+
if (!result.success) return
|
|
126
|
+
|
|
127
|
+
if (hasMemory(result.data)) {
|
|
128
|
+
const memory = formatMemoryForLLM(result.data)
|
|
193
129
|
return {
|
|
194
|
-
|
|
195
|
-
details: { id: result.id, memoryType: "personal" },
|
|
130
|
+
appendSystemContext: `${MEMORY_INSTRUCTION}\n\n${memory}`,
|
|
196
131
|
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
/** Current turn prompt from event (before_prompt_build or before_agent_start). */
|
|
206
|
-
function getEventPrompt(event: { prompt?: string; messages?: unknown[] }): string {
|
|
207
|
-
if (typeof event.prompt === "string" && event.prompt.length > 0) return event.prompt
|
|
208
|
-
const msgs = event.messages ?? []
|
|
209
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
210
|
-
const m = msgs[i] as Record<string, unknown>
|
|
211
|
-
if (m?.role === "user") return getCleanMessageText(m)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
appendSystemContext: NO_MEMORY_INSTRUCTION,
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// Don't block the LLM if memory search fails
|
|
212
139
|
}
|
|
213
|
-
|
|
214
|
-
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
// ── Auto Save: runs after the LLM has responded ──
|
|
143
|
+
api.on("agent_end", async (event) => {
|
|
144
|
+
try {
|
|
145
|
+
if (!event.success) return
|
|
215
146
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (prompt === lastPrompt && now - lastTime < 2000) return
|
|
226
|
-
lastPrompt = prompt
|
|
227
|
-
lastTime = now
|
|
228
|
-
try {
|
|
229
|
-
const clean = getCleanPrompt(prompt)
|
|
230
|
-
if (isSessionStartup(clean)) return
|
|
231
|
-
// Use session file for context (same source as add) so previousContext matches add's flow.
|
|
232
|
-
const chatCtx =
|
|
233
|
-
stateDir && ctx?.sessionId
|
|
234
|
-
? getRecentContext(undefined, stateDir, ctx.sessionId)
|
|
235
|
-
: getRecentContext(event.messages, stateDir, ctx?.sessionId)
|
|
236
|
-
const lastPair = chatCtx[chatCtx.length - 1]
|
|
237
|
-
const excludeLast = lastPair?.user === clean && lastPair?.assistant === null
|
|
238
|
-
const prevCtx = getPreviousContextForApi(chatCtx, excludeLast)
|
|
239
|
-
const r = await client.search(clean, prevCtx)
|
|
240
|
-
if (!r.success) {
|
|
241
|
-
const msg = r.error ?? "Search failed."
|
|
242
|
-
return {
|
|
243
|
-
appendSystemContext: `\n\n### Personal Memory\nIn your response, tell the user: Personal memory is temporarily unavailable. ${msg}`,
|
|
244
|
-
}
|
|
147
|
+
const messages = event.messages ?? []
|
|
148
|
+
|
|
149
|
+
let userText = ""
|
|
150
|
+
let assistantText = ""
|
|
151
|
+
|
|
152
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
153
|
+
const m = messages[i] as Record<string, unknown>
|
|
154
|
+
if (!userText && m.role === "user") {
|
|
155
|
+
userText = getMessageText(m)
|
|
245
156
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const dataPayload =
|
|
249
|
-
raw && typeof raw === "object" && "data" in (raw as object)
|
|
250
|
-
? (raw as { data?: unknown }).data
|
|
251
|
-
: raw
|
|
252
|
-
const hasPayload = dataPayload != null && typeof dataPayload === "object"
|
|
253
|
-
const isEmpty =
|
|
254
|
-
!hasPayload ||
|
|
255
|
-
(hasPayload && isMemoryDataEmpty(dataPayload as Record<string, unknown>))
|
|
256
|
-
const block =
|
|
257
|
-
hasPayload && !isEmpty
|
|
258
|
-
? JSON.stringify(dataPayload, null, 2)
|
|
259
|
-
: isEmpty
|
|
260
|
-
? "No memory data."
|
|
261
|
-
: typeof raw === "string"
|
|
262
|
-
? raw
|
|
263
|
-
: JSON.stringify(raw ?? {}, null, 2)
|
|
264
|
-
const sourceRule =
|
|
265
|
-
"Personal memory is OctaMem only. If the user asks where memory/data comes from, you MUST answer: from OctaMem. Never say local, workspace, or not Octamem."
|
|
266
|
-
const summarizeInstruction =
|
|
267
|
-
"The following is the raw memory search result from OctaMem. Summarize it in a clear, natural response for the user; do not dump the raw structure. "
|
|
268
|
-
const memoryOnlyInstruction =
|
|
269
|
-
"[SOURCE: All memory below is from OctaMem only. When asked where memory comes from, always answer: from OctaMem. Never say 'local', 'workspace', or 'not Octamem'.] " +
|
|
270
|
-
summarizeInstruction +
|
|
271
|
-
"For 'who is X?', 'what do you know about X?', or any fact about people/places/things the user may have told you: base your answer ONLY on the memory data below. Do not use general knowledge or invent details. " +
|
|
272
|
-
(isEmpty
|
|
273
|
-
? "[CRITICAL: The section below is EMPTY (No memory data). You MUST reply only that you don't have that in your personal memory (OctaMem). Do not guess, invent, or use your training knowledge.] "
|
|
274
|
-
: "") +
|
|
275
|
-
"When asked the source of memory data, reply: It comes from OctaMem. Never say local or workspace.\n\n"
|
|
276
|
-
const memoryBlockContent = isEmpty
|
|
277
|
-
? "No memory data.\n[If you answer from general knowledge here, you are wrong. Reply only: I don't have that in my personal memory.]"
|
|
278
|
-
: block
|
|
279
|
-
const prepend = isEmpty
|
|
280
|
-
? `\n\n[Memory source rule] ${sourceRule}\n[When Personal Memory is empty below, you MUST NOT use general knowledge. Say only: I don't have that in my personal memory.]\n`
|
|
281
|
-
: `\n\n[Memory source rule] ${sourceRule}\n`
|
|
282
|
-
return {
|
|
283
|
-
prependSystemContext: prepend,
|
|
284
|
-
appendSystemContext: `\n\n### Personal Memory (OctaMem)\n${memoryOnlyInstruction}${memoryBlockContent}`,
|
|
157
|
+
if (!assistantText && m.role === "assistant") {
|
|
158
|
+
assistantText = getMessageText(m)
|
|
285
159
|
}
|
|
286
|
-
|
|
287
|
-
// skip
|
|
160
|
+
if (userText && assistantText) break
|
|
288
161
|
}
|
|
289
|
-
}
|
|
290
162
|
|
|
291
|
-
|
|
292
|
-
|
|
163
|
+
if (!userText || !assistantText) return
|
|
164
|
+
if (shouldSkip(userText)) return
|
|
293
165
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const m = msgs[i] as Record<string, unknown>
|
|
301
|
-
if (m.role === "user") {
|
|
302
|
-
userText = getCleanMessageText(m)
|
|
303
|
-
break
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
if (!userText || userText.length < 5) return
|
|
307
|
-
if (isSessionStartup(userText)) return
|
|
308
|
-
let assistantText = ""
|
|
309
|
-
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
310
|
-
const m = msgs[i] as Record<string, unknown>
|
|
311
|
-
if (m.role === "assistant") {
|
|
312
|
-
assistantText = getCleanMessageText(m)
|
|
313
|
-
break
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
if (!assistantText) return
|
|
317
|
-
const chatCtx = getRecentContext(event.messages, stateDir, ctx?.sessionId)
|
|
318
|
-
const prevCtx = getPreviousContextForApi(chatCtx, true)
|
|
319
|
-
// Store only the assistant reply as plain text (no "User: ... Assistant: ..." wrapper).
|
|
320
|
-
try {
|
|
321
|
-
await client.add(assistantText, { source: "post_response" }, prevCtx)
|
|
166
|
+
logLLMResponse(assistantText)
|
|
167
|
+
|
|
168
|
+
const content = `user: ${userText} | assistant: ${assistantText}`
|
|
169
|
+
const previousContext = buildPreviousContext(messages, true)
|
|
170
|
+
|
|
171
|
+
await client.add(content, previousContext)
|
|
322
172
|
} catch {
|
|
323
|
-
//
|
|
173
|
+
// Don't crash if memory save fails
|
|
324
174
|
}
|
|
325
175
|
})
|
|
326
176
|
|
|
327
|
-
api.registerService({
|
|
177
|
+
api.registerService({
|
|
178
|
+
id: "octamem-openclaw",
|
|
179
|
+
start: () => {},
|
|
180
|
+
stop: () => {},
|
|
181
|
+
})
|
|
328
182
|
},
|
|
329
183
|
}
|
package/src/logger.ts
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs"
|
|
2
|
-
import path from "node:path"
|
|
3
2
|
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
4
|
|
|
5
5
|
const LOG_PATH = path.join(os.homedir(), ".openclaw", "octamem-api.log")
|
|
6
6
|
|
|
7
|
-
/** Redact apiKey in payload for safe logging. */
|
|
8
7
|
function redactPayload(obj: Record<string, unknown>): Record<string, unknown> {
|
|
9
8
|
const out = { ...obj }
|
|
10
9
|
if (typeof out.apiKey === "string" && out.apiKey.length > 0) {
|
|
@@ -13,11 +12,11 @@ function redactPayload(obj: Record<string, unknown>): Record<string, unknown> {
|
|
|
13
12
|
return out
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
/** Log an outgoing API request (endpoint + payload). */
|
|
16
|
+
export function logRequest(
|
|
17
|
+
endpoint: string,
|
|
18
|
+
payload: Record<string, unknown>,
|
|
19
|
+
): void {
|
|
21
20
|
try {
|
|
22
21
|
const ts = new Date().toISOString()
|
|
23
22
|
const safe = redactPayload(payload)
|
|
@@ -29,16 +28,11 @@ export function logRequest(endpoint: string, payload: Record<string, unknown>):
|
|
|
29
28
|
}
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
*/
|
|
35
|
-
export function logResponse(endpoint: string, response: unknown): void {
|
|
31
|
+
/** Log the LLM's response shown to the user. */
|
|
32
|
+
export function logLLMResponse(assistantText: string): void {
|
|
36
33
|
try {
|
|
37
34
|
const ts = new Date().toISOString()
|
|
38
|
-
const
|
|
39
|
-
? JSON.stringify(response, null, 2)
|
|
40
|
-
: String(response)
|
|
41
|
-
const block = `--- ${ts} ${endpoint} response ---\n${body}\n`
|
|
35
|
+
const block = `--- ${ts} llm-response ---\n${assistantText}\n`
|
|
42
36
|
fs.appendFileSync(LOG_PATH, block)
|
|
43
37
|
} catch {
|
|
44
38
|
// ignore log errors
|
package/src/openclaw-config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs"
|
|
2
|
-
import path from "node:path"
|
|
3
2
|
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
4
|
|
|
5
5
|
const CONFIG_DIR = ".openclaw"
|
|
6
6
|
const CONFIG_FILES = ["openclaw.json", "config.json"]
|
|
@@ -32,7 +32,9 @@ export function resolveOpenClawConfigPath(): string {
|
|
|
32
32
|
/**
|
|
33
33
|
* Reads and parses the OpenClaw config file. Returns empty object if missing or invalid.
|
|
34
34
|
*/
|
|
35
|
-
export function readOpenClawConfig(
|
|
35
|
+
export function readOpenClawConfig(
|
|
36
|
+
configPath: string,
|
|
37
|
+
): Record<string, unknown> {
|
|
36
38
|
try {
|
|
37
39
|
const raw = fs.readFileSync(configPath, "utf-8")
|
|
38
40
|
const data = JSON.parse(raw)
|
|
@@ -52,27 +54,47 @@ export function writeOctaMemPluginConfig(
|
|
|
52
54
|
options: OctaMemPluginConfig,
|
|
53
55
|
): string {
|
|
54
56
|
const data = readOpenClawConfig(configPath)
|
|
55
|
-
const plugins = (
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
const plugins = (
|
|
58
|
+
data.plugins && typeof data.plugins === "object"
|
|
59
|
+
? { ...(data.plugins as Record<string, unknown>) }
|
|
60
|
+
: {}
|
|
61
|
+
) as Record<string, unknown>
|
|
58
62
|
|
|
59
63
|
plugins.enabled = true
|
|
60
|
-
plugins.slots = {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
plugins.slots = {
|
|
65
|
+
...(plugins.slots as Record<string, unknown>),
|
|
66
|
+
memory: "octamem-openclaw",
|
|
67
|
+
}
|
|
68
|
+
const entries = (
|
|
69
|
+
plugins.entries && typeof plugins.entries === "object"
|
|
70
|
+
? { ...(plugins.entries as Record<string, unknown>) }
|
|
71
|
+
: {}
|
|
72
|
+
) as Record<string, unknown>
|
|
64
73
|
|
|
65
74
|
const existing = entries["octamem-openclaw"]
|
|
66
|
-
const existingConfig =
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
const existingConfig =
|
|
76
|
+
existing &&
|
|
77
|
+
typeof existing === "object" &&
|
|
78
|
+
(existing as Record<string, unknown>).config &&
|
|
79
|
+
typeof (existing as Record<string, unknown>).config === "object"
|
|
80
|
+
? {
|
|
81
|
+
...((existing as Record<string, unknown>).config as Record<
|
|
82
|
+
string,
|
|
83
|
+
unknown
|
|
84
|
+
>),
|
|
85
|
+
}
|
|
86
|
+
: {}
|
|
69
87
|
entries["octamem-openclaw"] = {
|
|
70
88
|
enabled: true,
|
|
71
89
|
config: {
|
|
72
90
|
...existingConfig,
|
|
73
91
|
memories: { personal: { apiKey: options.apiKey } },
|
|
74
|
-
...(options.toolDescription !== undefined && {
|
|
75
|
-
|
|
92
|
+
...(options.toolDescription !== undefined && {
|
|
93
|
+
toolDescription: options.toolDescription,
|
|
94
|
+
}),
|
|
95
|
+
...(options.addToolDescription !== undefined && {
|
|
96
|
+
addToolDescription: options.addToolDescription,
|
|
97
|
+
}),
|
|
76
98
|
},
|
|
77
99
|
}
|
|
78
100
|
plugins.entries = entries
|
|
@@ -80,10 +102,12 @@ export function writeOctaMemPluginConfig(
|
|
|
80
102
|
|
|
81
103
|
// So only OctaMem is used for memory: disable core memory tools (file-based).
|
|
82
104
|
const coreMemoryToolsToDeny = ["memory_search", "memory_get"]
|
|
83
|
-
const tools = (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
const tools = (
|
|
106
|
+
data.tools && typeof data.tools === "object"
|
|
107
|
+
? { ...(data.tools as Record<string, unknown>) }
|
|
108
|
+
: {}
|
|
109
|
+
) as Record<string, unknown>
|
|
110
|
+
const deny = Array.isArray(tools.deny) ? [...(tools.deny as string[])] : []
|
|
87
111
|
for (const id of coreMemoryToolsToDeny) {
|
|
88
112
|
if (!deny.includes(id)) deny.push(id)
|
|
89
113
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,35 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plugin configuration shape (from OpenClaw plugin config).
|
|
3
|
-
* Only personal memory is supported; apiKey may use ${ENV_VAR} substitution.
|
|
4
|
-
*/
|
|
5
1
|
export type OctaMemConfig = {
|
|
6
|
-
memories: { personal?: { apiKey: 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
|
|
2
|
+
memories: { personal?: { apiKey: string } }
|
|
11
3
|
}
|
|
12
4
|
|
|
13
|
-
/** A single user/assistant turn in chat history. */
|
|
14
5
|
export type ChatTurn = { user: string; assistant: string | null }
|
|
15
6
|
|
|
16
|
-
/** Ordered list of chat turns, used as context for search/add API calls. */
|
|
17
7
|
export type ChatContext = ChatTurn[]
|
|
18
8
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
query: string
|
|
24
|
-
error?: string
|
|
9
|
+
export type MemoryData = {
|
|
10
|
+
semantic_memory: string
|
|
11
|
+
episodic_memory: string
|
|
12
|
+
procedural_memory: string
|
|
25
13
|
}
|
|
26
14
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
success:
|
|
30
|
-
id: string
|
|
31
|
-
message: string
|
|
32
|
-
}
|
|
15
|
+
export type SearchResult =
|
|
16
|
+
| { success: true; data: MemoryData; query: string }
|
|
17
|
+
| { success: false; error: string; query: string }
|
|
33
18
|
|
|
34
|
-
|
|
35
|
-
|
|
19
|
+
export type AddResult =
|
|
20
|
+
| { success: true; message: string }
|
|
21
|
+
| { success: false; message: string }
|