@hydra_db/openclaw 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yaml +40 -0
- package/README.md +172 -0
- package/client.ts +214 -0
- package/commands/cli.ts +97 -0
- package/commands/onboarding.ts +485 -0
- package/commands/slash.ts +138 -0
- package/config.ts +128 -0
- package/context.ts +191 -0
- package/hooks/capture.ts +101 -0
- package/hooks/recall.ts +46 -0
- package/index.ts +212 -0
- package/log.ts +48 -0
- package/messages.ts +88 -0
- package/openclaw.plugin.json +74 -0
- package/package.json +27 -0
- package/session.ts +11 -0
- package/tools/delete.ts +54 -0
- package/tools/get.ts +57 -0
- package/tools/list.ts +56 -0
- package/tools/search.ts +64 -0
- package/tools/store.ts +116 -0
- package/tsconfig.json +23 -0
- package/types/hydra.ts +166 -0
- package/types/openclaw.d.ts +19 -0
package/config.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export type HydraPluginConfig = {
|
|
2
|
+
apiKey: string
|
|
3
|
+
tenantId: string
|
|
4
|
+
subTenantId: string
|
|
5
|
+
autoRecall: boolean
|
|
6
|
+
autoCapture: boolean
|
|
7
|
+
maxRecallResults: number
|
|
8
|
+
recallMode: "fast" | "thinking"
|
|
9
|
+
graphContext: boolean
|
|
10
|
+
ignoreTerm: string
|
|
11
|
+
debug: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const KNOWN_KEYS = new Set([
|
|
15
|
+
"apiKey",
|
|
16
|
+
"tenantId",
|
|
17
|
+
"subTenantId",
|
|
18
|
+
"autoRecall",
|
|
19
|
+
"autoCapture",
|
|
20
|
+
"maxRecallResults",
|
|
21
|
+
"recallMode",
|
|
22
|
+
"graphContext",
|
|
23
|
+
"ignoreTerm",
|
|
24
|
+
"debug",
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
const DEFAULT_SUB_TENANT = "hydra-openclaw-plugin"
|
|
28
|
+
const DEFAULT_IGNORE_TERM = "hydra-ignore"
|
|
29
|
+
|
|
30
|
+
function envOrNull(name: string): string | undefined {
|
|
31
|
+
return typeof process !== "undefined" ? process.env[name] : undefined
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resolveEnvVars(value: string): string {
|
|
35
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, name: string) => {
|
|
36
|
+
const val = envOrNull(name)
|
|
37
|
+
if (!val) throw new Error(`Environment variable ${name} is not set`)
|
|
38
|
+
return val
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function parseConfig(raw: unknown): HydraPluginConfig {
|
|
43
|
+
const cfg =
|
|
44
|
+
raw && typeof raw === "object" && !Array.isArray(raw)
|
|
45
|
+
? (raw as Record<string, unknown>)
|
|
46
|
+
: {}
|
|
47
|
+
|
|
48
|
+
const unknown = Object.keys(cfg).filter((k) => !KNOWN_KEYS.has(k))
|
|
49
|
+
if (unknown.length > 0) {
|
|
50
|
+
throw new Error(`hydra-db: unrecognized config keys: ${unknown.join(", ")}`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const apiKey =
|
|
54
|
+
typeof cfg.apiKey === "string" && cfg.apiKey.length > 0
|
|
55
|
+
? resolveEnvVars(cfg.apiKey)
|
|
56
|
+
: envOrNull("HYDRA_OPENCLAW_API_KEY")
|
|
57
|
+
|
|
58
|
+
if (!apiKey) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"hydra-db: apiKey is required — set it in plugin config or via HYDRA_OPENCLAW_API_KEY env var",
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const tenantId =
|
|
65
|
+
typeof cfg.tenantId === "string" && cfg.tenantId.length > 0
|
|
66
|
+
? resolveEnvVars(cfg.tenantId)
|
|
67
|
+
: envOrNull("HYDRA_OPENCLAW_TENANT_ID")
|
|
68
|
+
|
|
69
|
+
if (!tenantId) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"hydra-db: tenantId is required — set it in plugin config or via HYDRA_OPENCLAW_TENANT_ID env var",
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const subTenantId =
|
|
76
|
+
typeof cfg.subTenantId === "string" && cfg.subTenantId.length > 0
|
|
77
|
+
? cfg.subTenantId
|
|
78
|
+
: DEFAULT_SUB_TENANT
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
apiKey,
|
|
82
|
+
tenantId,
|
|
83
|
+
subTenantId,
|
|
84
|
+
autoRecall: (cfg.autoRecall as boolean) ?? true,
|
|
85
|
+
autoCapture: (cfg.autoCapture as boolean) ?? true,
|
|
86
|
+
maxRecallResults: (cfg.maxRecallResults as number) ?? 10,
|
|
87
|
+
recallMode:
|
|
88
|
+
cfg.recallMode === "thinking"
|
|
89
|
+
? ("thinking" as const)
|
|
90
|
+
: ("fast" as const),
|
|
91
|
+
graphContext: (cfg.graphContext as boolean) ?? true,
|
|
92
|
+
ignoreTerm:
|
|
93
|
+
typeof cfg.ignoreTerm === "string" && cfg.ignoreTerm.length > 0
|
|
94
|
+
? cfg.ignoreTerm
|
|
95
|
+
: DEFAULT_IGNORE_TERM,
|
|
96
|
+
debug: (cfg.debug as boolean) ?? false,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function tryParseConfig(raw: unknown): HydraPluginConfig | null {
|
|
101
|
+
try {
|
|
102
|
+
return parseConfig(raw)
|
|
103
|
+
} catch {
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Permissive schema parse — validates key names but does NOT require credentials.
|
|
110
|
+
* This lets the plugin load so the onboarding wizard can run.
|
|
111
|
+
*/
|
|
112
|
+
function parseConfigSoft(raw: unknown): Record<string, unknown> {
|
|
113
|
+
const cfg =
|
|
114
|
+
raw && typeof raw === "object" && !Array.isArray(raw)
|
|
115
|
+
? (raw as Record<string, unknown>)
|
|
116
|
+
: {}
|
|
117
|
+
|
|
118
|
+
const unknown = Object.keys(cfg).filter((k) => !KNOWN_KEYS.has(k))
|
|
119
|
+
if (unknown.length > 0) {
|
|
120
|
+
throw new Error(`hydra-db: unrecognized config keys: ${unknown.join(", ")}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return cfg
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export const hydraConfigSchema = {
|
|
127
|
+
parse: parseConfigSoft,
|
|
128
|
+
}
|
package/context.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PathTriplet,
|
|
3
|
+
RecallResponse,
|
|
4
|
+
ScoredPath,
|
|
5
|
+
VectorChunk,
|
|
6
|
+
} from "./types/hydra.ts"
|
|
7
|
+
|
|
8
|
+
function formatTriplet(triplet: PathTriplet): string {
|
|
9
|
+
const src = triplet.source?.name ?? "?"
|
|
10
|
+
const rel = triplet.relation
|
|
11
|
+
const predicate =
|
|
12
|
+
rel?.raw_predicate ?? rel?.canonical_predicate ?? "related to"
|
|
13
|
+
const tgt = triplet.target?.name ?? "?"
|
|
14
|
+
const ctx = rel?.context ? ` [${rel.context}]` : ""
|
|
15
|
+
return ` (${src}) —[${predicate}]→ (${tgt})${ctx}`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildRecalledContext(
|
|
19
|
+
response: RecallResponse,
|
|
20
|
+
opts?: {
|
|
21
|
+
maxGroupOccurrences?: number
|
|
22
|
+
minEvidenceScore?: number
|
|
23
|
+
},
|
|
24
|
+
): string {
|
|
25
|
+
const minScore = opts?.minEvidenceScore ?? 0.4
|
|
26
|
+
|
|
27
|
+
const chunks = response.chunks ?? []
|
|
28
|
+
const graphCtx = response.graph_context ?? {
|
|
29
|
+
query_paths: [],
|
|
30
|
+
chunk_relations: [],
|
|
31
|
+
chunk_id_to_group_ids: {},
|
|
32
|
+
}
|
|
33
|
+
const extraContextMap = response.additional_context ?? {}
|
|
34
|
+
|
|
35
|
+
const rawRelations: ScoredPath[] = graphCtx.chunk_relations ?? []
|
|
36
|
+
const relationIndex: Record<string, ScoredPath> = {}
|
|
37
|
+
|
|
38
|
+
for (let idx = 0; idx < rawRelations.length; idx++) {
|
|
39
|
+
const relation = rawRelations[idx]!
|
|
40
|
+
if ((relation.relevancy_score ?? 0) < minScore) continue
|
|
41
|
+
const groupId = relation.group_id ?? `p_${idx}`
|
|
42
|
+
relationIndex[groupId] = relation
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const chunkToGroupIds = graphCtx.chunk_id_to_group_ids ?? {}
|
|
46
|
+
const consumedExtraIds = new Set<string>()
|
|
47
|
+
const chunkSections: string[] = []
|
|
48
|
+
|
|
49
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
50
|
+
const chunk = chunks[i]!
|
|
51
|
+
const lines: string[] = []
|
|
52
|
+
|
|
53
|
+
lines.push(`Chunk ${i + 1}`)
|
|
54
|
+
|
|
55
|
+
const meta = chunk.document_metadata ?? {}
|
|
56
|
+
const title =
|
|
57
|
+
chunk.source_title || (meta as Record<string, string>).title
|
|
58
|
+
if (title) {
|
|
59
|
+
lines.push(`Source: ${title}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
lines.push(chunk.chunk_content ?? "")
|
|
63
|
+
|
|
64
|
+
const chunkUuid = chunk.chunk_uuid
|
|
65
|
+
const linkedGroupIds = chunkToGroupIds[chunkUuid] ?? []
|
|
66
|
+
|
|
67
|
+
const matchedRelations: ScoredPath[] = []
|
|
68
|
+
|
|
69
|
+
for (const gid of linkedGroupIds) {
|
|
70
|
+
if (relationIndex[gid]) {
|
|
71
|
+
matchedRelations.push(relationIndex[gid]!)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (matchedRelations.length === 0) {
|
|
76
|
+
for (const rel of Object.values(relationIndex)) {
|
|
77
|
+
const triplets = rel.triplets ?? []
|
|
78
|
+
const hasChunk = triplets.some(
|
|
79
|
+
(t) => t.relation?.chunk_id === chunkUuid,
|
|
80
|
+
)
|
|
81
|
+
if (hasChunk) {
|
|
82
|
+
matchedRelations.push(rel)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const relationLines: string[] = []
|
|
88
|
+
for (const rel of matchedRelations) {
|
|
89
|
+
const triplets = rel.triplets ?? []
|
|
90
|
+
if (triplets.length > 0) {
|
|
91
|
+
for (const triplet of triplets) {
|
|
92
|
+
relationLines.push(formatTriplet(triplet))
|
|
93
|
+
}
|
|
94
|
+
} else if (rel.combined_context) {
|
|
95
|
+
relationLines.push(` ${rel.combined_context}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (relationLines.length > 0) {
|
|
100
|
+
lines.push("Graph Relations:")
|
|
101
|
+
lines.push(...relationLines)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const extraIds = chunk.extra_context_ids ?? []
|
|
105
|
+
if (extraIds.length > 0 && Object.keys(extraContextMap).length > 0) {
|
|
106
|
+
const extraLines: string[] = []
|
|
107
|
+
for (const ctxId of extraIds) {
|
|
108
|
+
if (consumedExtraIds.has(ctxId)) continue
|
|
109
|
+
const extraChunk = extraContextMap[ctxId]
|
|
110
|
+
if (extraChunk) {
|
|
111
|
+
consumedExtraIds.add(ctxId)
|
|
112
|
+
const extraContent = extraChunk.chunk_content ?? ""
|
|
113
|
+
const extraTitle = extraChunk.source_title ?? ""
|
|
114
|
+
if (extraTitle) {
|
|
115
|
+
extraLines.push(
|
|
116
|
+
` Related Context (${extraTitle}): ${extraContent}`,
|
|
117
|
+
)
|
|
118
|
+
} else {
|
|
119
|
+
extraLines.push(` Related Context: ${extraContent}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (extraLines.length > 0) {
|
|
124
|
+
lines.push("Extra Context:")
|
|
125
|
+
lines.push(...extraLines)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
chunkSections.push(lines.join("\n"))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const entityPathLines: string[] = []
|
|
133
|
+
const rawPaths: ScoredPath[] = graphCtx.query_paths ?? []
|
|
134
|
+
for (const path of rawPaths) {
|
|
135
|
+
if (path.combined_context) {
|
|
136
|
+
entityPathLines.push(path.combined_context)
|
|
137
|
+
} else {
|
|
138
|
+
const triplets = path.triplets ?? []
|
|
139
|
+
const segments: string[] = []
|
|
140
|
+
for (const pt of triplets) {
|
|
141
|
+
const s = pt.source?.name
|
|
142
|
+
const rel = pt.relation
|
|
143
|
+
const p =
|
|
144
|
+
rel?.raw_predicate ??
|
|
145
|
+
rel?.canonical_predicate ??
|
|
146
|
+
"related to"
|
|
147
|
+
const t = pt.target?.name
|
|
148
|
+
segments.push(`(${s} -> ${p} -> ${t})`)
|
|
149
|
+
}
|
|
150
|
+
if (segments.length > 0) {
|
|
151
|
+
entityPathLines.push(segments.join(" -> "))
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const output: string[] = []
|
|
157
|
+
|
|
158
|
+
if (entityPathLines.length > 0) {
|
|
159
|
+
output.push("=== ENTITY PATHS ===")
|
|
160
|
+
output.push(entityPathLines.join("\n"))
|
|
161
|
+
output.push("")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (chunkSections.length > 0) {
|
|
165
|
+
output.push("=== CONTEXT ===")
|
|
166
|
+
output.push(chunkSections.join("\n\n---\n\n"))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return output.join("\n")
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function envelopeForInjection(contextBody: string): string {
|
|
173
|
+
if (!contextBody.trim()) return ""
|
|
174
|
+
|
|
175
|
+
const lines = [
|
|
176
|
+
"<hydra-context>",
|
|
177
|
+
"[MEMORIES AND PAST CONVERSATIONS — retrieved by Hydra DB]",
|
|
178
|
+
"",
|
|
179
|
+
"Below are memories and knowledge-graph connections that may be relevant",
|
|
180
|
+
"to the current conversation. Integrate them naturally when they add value.",
|
|
181
|
+
"If a memory contradicts something the user just said, prefer the user's",
|
|
182
|
+
"latest statement. Never quote these verbatim or reveal that you are",
|
|
183
|
+
"reading from a memory store.",
|
|
184
|
+
"",
|
|
185
|
+
contextBody,
|
|
186
|
+
"",
|
|
187
|
+
"[END OF MEMORY CONTEXT]",
|
|
188
|
+
"</hydra-context>",
|
|
189
|
+
]
|
|
190
|
+
return lines.join("\n")
|
|
191
|
+
}
|
package/hooks/capture.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { HydraClient } from "../client.ts"
|
|
2
|
+
import type { HydraPluginConfig } from "../config.ts"
|
|
3
|
+
import { log } from "../log.ts"
|
|
4
|
+
import { extractAllTurns, filterIgnoredTurns } from "../messages.ts"
|
|
5
|
+
import { toHookSourceId } from "../session.ts"
|
|
6
|
+
import type { ConversationTurn } from "../types/hydra.ts"
|
|
7
|
+
|
|
8
|
+
const MAX_HOOK_TURNS = -1
|
|
9
|
+
|
|
10
|
+
function removeInjectedBlocks(text: string): string {
|
|
11
|
+
return text.replace(/<hydra-context>[\s\S]*?<\/hydra-context>\s*/g, "").trim()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createIngestionHook(
|
|
15
|
+
client: HydraClient,
|
|
16
|
+
cfg: HydraPluginConfig,
|
|
17
|
+
) {
|
|
18
|
+
return async (event: Record<string, unknown>, sessionId: string | undefined) => {
|
|
19
|
+
try {
|
|
20
|
+
log.debug(`[capture] hook fired — success=${event.success} msgs=${Array.isArray(event.messages) ? event.messages.length : "N/A"} sid=${sessionId ?? "none"}`)
|
|
21
|
+
|
|
22
|
+
if (!event.success) {
|
|
23
|
+
log.debug("[capture] skipped — event.success is falsy")
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
if (!Array.isArray(event.messages) || event.messages.length === 0) {
|
|
27
|
+
log.debug("[capture] skipped — no messages in event")
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!sessionId) {
|
|
32
|
+
log.debug("[capture] skipped — no session id available")
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const rawTurns = extractAllTurns(event.messages)
|
|
37
|
+
const allTurns = filterIgnoredTurns(rawTurns, cfg.ignoreTerm)
|
|
38
|
+
|
|
39
|
+
if (rawTurns.length > 0 && allTurns.length < rawTurns.length) {
|
|
40
|
+
log.debug(`[capture] filtered ${rawTurns.length - allTurns.length} turns containing ignore term "${cfg.ignoreTerm}"`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (allTurns.length === 0) {
|
|
44
|
+
log.debug(`[capture] skipped — no user-assistant turns found in ${event.messages.length} messages`)
|
|
45
|
+
const roles = event.messages
|
|
46
|
+
.slice(-5)
|
|
47
|
+
.map((m) => (m && typeof m === "object" ? (m as Record<string, unknown>).role : "?"))
|
|
48
|
+
log.debug(`[capture] last 5 message roles: ${JSON.stringify(roles)}`)
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const recentTurns = MAX_HOOK_TURNS === -1 ? allTurns : allTurns.slice(-MAX_HOOK_TURNS)
|
|
53
|
+
const turns: ConversationTurn[] = recentTurns.map((t) => ({
|
|
54
|
+
user: removeInjectedBlocks(t.user),
|
|
55
|
+
assistant: removeInjectedBlocks(t.assistant),
|
|
56
|
+
})).filter((t) => t.user.length >= 5 && t.assistant.length >= 5)
|
|
57
|
+
|
|
58
|
+
if (turns.length === 0) {
|
|
59
|
+
log.debug("[capture] skipped — all turns too short after cleaning")
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sourceId = toHookSourceId(sessionId)
|
|
64
|
+
|
|
65
|
+
const now = new Date()
|
|
66
|
+
const timestamp = now.toISOString()
|
|
67
|
+
const readableTime = now.toLocaleString("en-US", {
|
|
68
|
+
weekday: "short",
|
|
69
|
+
year: "numeric",
|
|
70
|
+
month: "short",
|
|
71
|
+
day: "numeric",
|
|
72
|
+
hour: "2-digit",
|
|
73
|
+
minute: "2-digit",
|
|
74
|
+
timeZoneName: "short",
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const annotatedTurns = turns.map((t, i) => ({
|
|
78
|
+
user: i === 0 ? `[Temporal details: ${readableTime}]\n\n${t.user}` : t.user,
|
|
79
|
+
assistant: t.assistant,
|
|
80
|
+
}))
|
|
81
|
+
|
|
82
|
+
log.debug(`[capture] ingesting ${annotatedTurns.length} turns (of ${allTurns.length} total) @ ${timestamp} -> ${sourceId}`)
|
|
83
|
+
|
|
84
|
+
await client.ingestConversation(
|
|
85
|
+
annotatedTurns,
|
|
86
|
+
sourceId,
|
|
87
|
+
{
|
|
88
|
+
metadata: {
|
|
89
|
+
captured_at: timestamp,
|
|
90
|
+
source: "openclaw_hook",
|
|
91
|
+
turn_count: annotatedTurns.length,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
log.debug("[capture] ingestion succeeded")
|
|
97
|
+
} catch (err) {
|
|
98
|
+
log.error("[capture] hook error", err)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
package/hooks/recall.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { HydraClient } from "../client.ts"
|
|
2
|
+
import type { HydraPluginConfig } from "../config.ts"
|
|
3
|
+
import { buildRecalledContext, envelopeForInjection } from "../context.ts"
|
|
4
|
+
import { log } from "../log.ts"
|
|
5
|
+
import { containsIgnoreTerm } from "../messages.ts"
|
|
6
|
+
|
|
7
|
+
export function createRecallHook(
|
|
8
|
+
client: HydraClient,
|
|
9
|
+
cfg: HydraPluginConfig,
|
|
10
|
+
) {
|
|
11
|
+
return async (event: Record<string, unknown>) => {
|
|
12
|
+
const prompt = event.prompt as string | undefined
|
|
13
|
+
if (!prompt || prompt.length < 5) return
|
|
14
|
+
|
|
15
|
+
if (containsIgnoreTerm(prompt, cfg.ignoreTerm)) {
|
|
16
|
+
log.debug(`recall skipped — prompt contains ignore term "${cfg.ignoreTerm}"`)
|
|
17
|
+
return
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
log.debug(`recall query (${prompt.length} chars)`)
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const response = await client.recall(prompt, {
|
|
24
|
+
maxResults: cfg.maxRecallResults,
|
|
25
|
+
mode: cfg.recallMode,
|
|
26
|
+
graphContext: cfg.graphContext,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
if (!response.chunks || response.chunks.length === 0) {
|
|
30
|
+
log.debug("no memories matched")
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const body = buildRecalledContext(response)
|
|
35
|
+
if (!body.trim()) return
|
|
36
|
+
|
|
37
|
+
const envelope = envelopeForInjection(body)
|
|
38
|
+
|
|
39
|
+
log.debug(`injecting ${response.chunks.length} chunks (${envelope.length} chars)`)
|
|
40
|
+
return { prependContext: envelope }
|
|
41
|
+
} catch (err) {
|
|
42
|
+
log.error("recall failed", err)
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
2
|
+
import { HydraClient } from "./client.ts"
|
|
3
|
+
import type { HydraPluginConfig } from "./config.ts"
|
|
4
|
+
import { registerOnboardingCli as createOnboardingCliRegistrar, registerOnboardingSlashCommands } from "./commands/onboarding.ts"
|
|
5
|
+
import { registerSlashCommands } from "./commands/slash.ts"
|
|
6
|
+
import { hydraConfigSchema, tryParseConfig } from "./config.ts"
|
|
7
|
+
import { createIngestionHook } from "./hooks/capture.ts"
|
|
8
|
+
import { createRecallHook } from "./hooks/recall.ts"
|
|
9
|
+
import { log } from "./log.ts"
|
|
10
|
+
import { registerDeleteTool } from "./tools/delete.ts"
|
|
11
|
+
import { registerGetTool } from "./tools/get.ts"
|
|
12
|
+
import { registerListTool } from "./tools/list.ts"
|
|
13
|
+
import { registerSearchTool } from "./tools/search.ts"
|
|
14
|
+
import { registerStoreTool } from "./tools/store.ts"
|
|
15
|
+
|
|
16
|
+
const NOT_CONFIGURED_MSG =
|
|
17
|
+
"[hydra-db] Not configured. Run `openclaw hydra onboard` to set up credentials."
|
|
18
|
+
|
|
19
|
+
export default {
|
|
20
|
+
id: "openclaw-hydra-db",
|
|
21
|
+
name: "Hydra DB",
|
|
22
|
+
description:
|
|
23
|
+
"State-of-the-art agentic memory for OpenClaw powered by Hydra DB — auto-capture, recall, and graph-enriched context",
|
|
24
|
+
kind: "memory" as const,
|
|
25
|
+
configSchema: hydraConfigSchema,
|
|
26
|
+
|
|
27
|
+
register(api: OpenClawPluginApi) {
|
|
28
|
+
const cfg = tryParseConfig(api.pluginConfig)
|
|
29
|
+
const cliClient = cfg ? new HydraClient(cfg.apiKey, cfg.tenantId, cfg.subTenantId) : null
|
|
30
|
+
|
|
31
|
+
// Always register ALL CLI commands so they appear in help text.
|
|
32
|
+
// Non-onboard commands guard on credentials at runtime.
|
|
33
|
+
api.registerCli(
|
|
34
|
+
({ program }: { program: any }) => {
|
|
35
|
+
const root = program
|
|
36
|
+
.command("hydra")
|
|
37
|
+
.description("Hydra DB memory commands")
|
|
38
|
+
|
|
39
|
+
createOnboardingCliRegistrar(cfg ?? undefined)(root)
|
|
40
|
+
registerHydraCliCommands(root, cliClient, cfg)
|
|
41
|
+
},
|
|
42
|
+
{ commands: ["hydra"] },
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if (!cfg) {
|
|
46
|
+
api.registerService({
|
|
47
|
+
id: "openclaw-hydra-db",
|
|
48
|
+
start: () => console.log(NOT_CONFIGURED_MSG),
|
|
49
|
+
stop: () => {},
|
|
50
|
+
})
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Full plugin registration — credentials present
|
|
55
|
+
log.init(api.logger, cfg.debug)
|
|
56
|
+
|
|
57
|
+
const client = new HydraClient(cfg.apiKey, cfg.tenantId, cfg.subTenantId)
|
|
58
|
+
|
|
59
|
+
let activeSessionId: string | undefined
|
|
60
|
+
let conversationMessages: unknown[] = []
|
|
61
|
+
const getSessionId = () => activeSessionId
|
|
62
|
+
const getMessages = () => conversationMessages
|
|
63
|
+
|
|
64
|
+
registerSearchTool(api, client, cfg)
|
|
65
|
+
registerStoreTool(api, client, cfg, getSessionId, getMessages)
|
|
66
|
+
registerListTool(api, client, cfg)
|
|
67
|
+
registerDeleteTool(api, client, cfg)
|
|
68
|
+
registerGetTool(api, client, cfg)
|
|
69
|
+
|
|
70
|
+
if (cfg.autoRecall) {
|
|
71
|
+
const onRecall = createRecallHook(client, cfg)
|
|
72
|
+
api.on(
|
|
73
|
+
"before_agent_start",
|
|
74
|
+
(event: Record<string, unknown>, ctx: Record<string, unknown>) => {
|
|
75
|
+
if (ctx.sessionId) activeSessionId = ctx.sessionId as string
|
|
76
|
+
if (Array.isArray(event.messages)) conversationMessages = event.messages
|
|
77
|
+
log.debug(`[session] before_agent_start — sid=${activeSessionId ?? "none"} msgs=${conversationMessages.length}`)
|
|
78
|
+
return onRecall(event)
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (cfg.autoCapture) {
|
|
84
|
+
const captureHandler = createIngestionHook(client, cfg)
|
|
85
|
+
api.on(
|
|
86
|
+
"agent_end",
|
|
87
|
+
(event: Record<string, unknown>, ctx: Record<string, unknown>) => {
|
|
88
|
+
if (ctx.sessionId) activeSessionId = ctx.sessionId as string
|
|
89
|
+
if (Array.isArray(event.messages)) conversationMessages = event.messages
|
|
90
|
+
log.debug(`[session] agent_end — sid=${activeSessionId ?? "none"} msgs=${conversationMessages.length} ctxKeys=${Object.keys(ctx).join(",")}`)
|
|
91
|
+
return captureHandler(event, activeSessionId)
|
|
92
|
+
},
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
registerSlashCommands(api, client, cfg, getSessionId)
|
|
97
|
+
registerOnboardingSlashCommands(api, client, cfg)
|
|
98
|
+
|
|
99
|
+
api.registerService({
|
|
100
|
+
id: "openclaw-hydra-db",
|
|
101
|
+
start: () => log.info("plugin started"),
|
|
102
|
+
stop: () => log.info("plugin stopped"),
|
|
103
|
+
})
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Register all `hydra *` CLI subcommands.
|
|
109
|
+
* Commands other than `onboard` guard on valid credentials at runtime.
|
|
110
|
+
*/
|
|
111
|
+
function registerHydraCliCommands(
|
|
112
|
+
root: any,
|
|
113
|
+
client: HydraClient | null,
|
|
114
|
+
cfg: HydraPluginConfig | null,
|
|
115
|
+
): void {
|
|
116
|
+
const requireCreds = (): { client: HydraClient; cfg: HydraPluginConfig } | null => {
|
|
117
|
+
if (client && cfg) return { client, cfg }
|
|
118
|
+
console.error(NOT_CONFIGURED_MSG)
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
root
|
|
123
|
+
.command("search")
|
|
124
|
+
.argument("<query>", "Search query")
|
|
125
|
+
.option("--limit <n>", "Max results", "10")
|
|
126
|
+
.action(async (query: string, opts: { limit: string }) => {
|
|
127
|
+
const ctx = requireCreds()
|
|
128
|
+
if (!ctx) return
|
|
129
|
+
|
|
130
|
+
const limit = Number.parseInt(opts.limit, 10) || 10
|
|
131
|
+
const res = await ctx.client.recall(query, {
|
|
132
|
+
maxResults: limit,
|
|
133
|
+
mode: ctx.cfg.recallMode,
|
|
134
|
+
graphContext: ctx.cfg.graphContext,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
if (!res.chunks || res.chunks.length === 0) {
|
|
138
|
+
console.log("No memories found.")
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const chunk of res.chunks) {
|
|
143
|
+
const score = chunk.relevancy_score != null
|
|
144
|
+
? ` (${(chunk.relevancy_score * 100).toFixed(0)}%)`
|
|
145
|
+
: ""
|
|
146
|
+
const title = chunk.source_title ? `[${chunk.source_title}] ` : ""
|
|
147
|
+
console.log(`- ${title}${chunk.chunk_content.slice(0, 200)}${score}`)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
root
|
|
152
|
+
.command("list")
|
|
153
|
+
.description("List all user memories")
|
|
154
|
+
.action(async () => {
|
|
155
|
+
const ctx = requireCreds()
|
|
156
|
+
if (!ctx) return
|
|
157
|
+
|
|
158
|
+
const res = await ctx.client.listMemories()
|
|
159
|
+
const memories = res.user_memories ?? []
|
|
160
|
+
if (memories.length === 0) {
|
|
161
|
+
console.log("No memories stored.")
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
for (const m of memories) {
|
|
165
|
+
console.log(`[${m.memory_id}] ${m.memory_content.slice(0, 150)}`)
|
|
166
|
+
}
|
|
167
|
+
console.log(`\nTotal: ${memories.length}`)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
root
|
|
171
|
+
.command("delete")
|
|
172
|
+
.argument("<memory_id>", "Memory ID to delete")
|
|
173
|
+
.action(async (memoryId: string) => {
|
|
174
|
+
const ctx = requireCreds()
|
|
175
|
+
if (!ctx) return
|
|
176
|
+
|
|
177
|
+
const res = await ctx.client.deleteMemory(memoryId)
|
|
178
|
+
console.log(res.user_memory_deleted ? `Deleted: ${memoryId}` : `Not found: ${memoryId}`)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
root
|
|
182
|
+
.command("get")
|
|
183
|
+
.argument("<source_id>", "Source ID to fetch")
|
|
184
|
+
.action(async (sourceId: string) => {
|
|
185
|
+
const ctx = requireCreds()
|
|
186
|
+
if (!ctx) return
|
|
187
|
+
|
|
188
|
+
const res = await ctx.client.fetchContent(sourceId)
|
|
189
|
+
if (!res.success || res.error) {
|
|
190
|
+
console.error(`Error: ${res.error ?? "unknown"}`)
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
console.log(res.content ?? res.content_base64 ?? "(no text content)")
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
root
|
|
197
|
+
.command("status")
|
|
198
|
+
.description("Show plugin configuration")
|
|
199
|
+
.action(() => {
|
|
200
|
+
const ctx = requireCreds()
|
|
201
|
+
if (!ctx) return
|
|
202
|
+
|
|
203
|
+
console.log(`Tenant: ${ctx.client.getTenantId()}`)
|
|
204
|
+
console.log(`Sub-Tenant: ${ctx.client.getSubTenantId()}`)
|
|
205
|
+
console.log(`Auto-Recall: ${ctx.cfg.autoRecall}`)
|
|
206
|
+
console.log(`Auto-Capture: ${ctx.cfg.autoCapture}`)
|
|
207
|
+
console.log(`Recall Mode: ${ctx.cfg.recallMode}`)
|
|
208
|
+
console.log(`Graph: ${ctx.cfg.graphContext}`)
|
|
209
|
+
console.log(`Max Results: ${ctx.cfg.maxRecallResults}`)
|
|
210
|
+
console.log(`Ignore Term: ${ctx.cfg.ignoreTerm}`)
|
|
211
|
+
})
|
|
212
|
+
}
|