feat: memory v2, prompt styles, Dream/GAZE integration, Wyoming TTS fix

SQLite + sqlite-vec replaces JSON memory files with semantic search,
follow-up injection, privacy levels, and lifecycle management.

Six prompt styles (quick/standard/creative/roleplayer/game-master/storyteller)
with per-style Claude model tiering (Haiku/Sonnet/Opus), temperature control,
and section stripping. Characters can set default style and per-style overrides.

Dream character import and GAZE character linking in the dashboard editor
with auto-populated fields, cover image resolution, and preset assignment.

Bridge: session isolation (conversation_id / 12h satellite buckets),
model routing refactor, PUT/DELETE support, memory REST endpoints.

Dashboard: mobile-responsive sidebar, retry button, style picker in chat,
follow-up banner, memory lifecycle/privacy UI, cloud model options in editor.

Wyoming TTS: upgraded to v1.8.0 for HA 1.7.2 compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-24 22:31:04 +00:00
parent c3bae6fdc0
commit 56580a2cb2
34 changed files with 2891 additions and 467 deletions

View File

@@ -7,8 +7,13 @@ const SATELLITE_MAP_PATH = '/Users/aodhan/homeai-data/satellite-map.json'
const CONVERSATIONS_DIR = '/Users/aodhan/homeai-data/conversations'
const MEMORIES_DIR = '/Users/aodhan/homeai-data/memories'
const MODE_PATH = '/Users/aodhan/homeai-data/active-mode.json'
const ACTIVE_STYLE_PATH = '/Users/aodhan/homeai-data/active-prompt-style.json'
const PROMPT_STYLES_DIR = new URL('./homeai-agent/prompt-styles', import.meta.url).pathname
const HA_TOKEN = process.env.HA_TOKEN || ''
const GAZE_HOST = 'http://10.0.0.101:5782'
const GAZE_API_KEY = process.env.GAZE_API_KEY || ''
const DREAM_HOST = process.env.DREAM_HOST || 'http://10.0.0.101:3000'
const DREAM_API_KEY = process.env.DREAM_API_KEY || ''
function characterStoragePlugin() {
return {
@@ -272,6 +277,7 @@ function healthCheckPlugin() {
const params = new URL(req.url, 'http://localhost').searchParams;
const url = params.get('url');
const mode = params.get('mode'); // 'tcp' for raw TCP port check
const needsAuth = params.get('auth') === '1'; // use server-side HA_TOKEN
if (!url) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing url param' }));
@@ -298,8 +304,12 @@ function healthCheckPlugin() {
const { default: http } = await import('http');
const client = parsedUrl.protocol === 'https:' ? https : http;
const opts = { rejectUnauthorized: false, timeout: 5000 };
if (needsAuth && HA_TOKEN) {
opts.headers = { 'Authorization': `Bearer ${HA_TOKEN}` };
}
await new Promise((resolve, reject) => {
const reqObj = client.get(url, { rejectUnauthorized: false, timeout: 5000 }, (resp) => {
const reqObj = client.get(url, opts, (resp) => {
resp.resume();
resolve();
});
@@ -387,15 +397,16 @@ function gazeProxyPlugin() {
return {
name: 'gaze-proxy',
configureServer(server) {
server.middlewares.use('/api/gaze/presets', async (req, res) => {
// Helper to proxy a JSON GET to GAZE
const proxyGazeJson = async (apiPath, res, fallback) => {
if (!GAZE_API_KEY) {
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify({ presets: [] }))
res.end(JSON.stringify(fallback))
return
}
try {
const http = await import('http')
const url = new URL(`${GAZE_HOST}/api/v1/presets`)
const url = new URL(`${GAZE_HOST}${apiPath}`)
const proxyRes = await new Promise((resolve, reject) => {
const r = http.default.get(url, { headers: { 'X-API-Key': GAZE_API_KEY }, timeout: 5000 }, resolve)
r.on('error', reject)
@@ -407,8 +418,110 @@ function gazeProxyPlugin() {
res.end(Buffer.concat(chunks))
} catch {
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify({ presets: [] }))
res.end(JSON.stringify(fallback))
}
}
server.middlewares.use('/api/gaze/presets', async (req, res) => {
await proxyGazeJson('/api/v1/presets', res, { presets: [] })
})
server.middlewares.use('/api/gaze/characters', async (req, res) => {
await proxyGazeJson('/api/v1/characters', res, { characters: [] })
})
// Proxy cover image for a GAZE character (binary passthrough)
server.middlewares.use(async (req, res, next) => {
const match = req.url.match(/^\/api\/gaze\/character\/([a-zA-Z0-9_\-]+)\/cover/)
if (!match) return next()
const characterId = match[1]
if (!GAZE_API_KEY) {
res.writeHead(404)
res.end()
return
}
try {
const { default: http } = await import('http')
const url = new URL(`${GAZE_HOST}/api/v1/character/${characterId}/cover`)
const r = http.get(url, { headers: { 'X-API-Key': GAZE_API_KEY }, timeout: 5000 }, (proxyRes) => {
res.writeHead(proxyRes.statusCode, {
'Content-Type': proxyRes.headers['content-type'] || 'image/png',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=3600',
})
proxyRes.pipe(res)
})
r.on('error', () => { if (!res.headersSent) { res.writeHead(502); res.end() } })
r.on('timeout', () => { r.destroy(); if (!res.headersSent) { res.writeHead(504); res.end() } })
} catch {
if (!res.headersSent) { res.writeHead(500); res.end() }
}
})
},
}
}
function dreamProxyPlugin() {
const dreamHeaders = DREAM_API_KEY ? { 'X-API-Key': DREAM_API_KEY } : {}
return {
name: 'dream-proxy',
configureServer(server) {
// Helper: proxy a JSON GET to Dream
const proxyDreamJson = async (apiPath, res) => {
try {
const http = await import('http')
const url = new URL(`${DREAM_HOST}${apiPath}`)
const proxyRes = await new Promise((resolve, reject) => {
const r = http.default.get(url, { headers: dreamHeaders, timeout: 5000 }, resolve)
r.on('error', reject)
r.on('timeout', () => { r.destroy(); reject(new Error('timeout')) })
})
const chunks = []
for await (const chunk of proxyRes) chunks.push(chunk)
res.writeHead(proxyRes.statusCode, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(Buffer.concat(chunks))
} catch {
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify({ characters: [], error: 'Dream unreachable' }))
}
}
// List characters (compact)
server.middlewares.use('/api/dream/characters', async (req, res, next) => {
// Only handle exact path (not sub-paths like /api/dream/characters/abc/image)
if (req.url !== '/' && req.url !== '' && !req.url.startsWith('?')) return next()
const qs = req.url === '/' || req.url === '' ? '' : req.url
await proxyDreamJson(`/api/characters${qs}`, res)
})
// Character image (binary passthrough)
server.middlewares.use(async (req, res, next) => {
const match = req.url.match(/^\/api\/dream\/characters\/([^/]+)\/image/)
if (!match) return next()
const charId = match[1]
try {
const { default: http } = await import('http')
const url = new URL(`${DREAM_HOST}/api/characters/${charId}/image`)
const r = http.get(url, { headers: dreamHeaders, timeout: 5000 }, (proxyRes) => {
res.writeHead(proxyRes.statusCode, {
'Content-Type': proxyRes.headers['content-type'] || 'image/png',
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'public, max-age=3600',
})
proxyRes.pipe(res)
})
r.on('error', () => { if (!res.headersSent) { res.writeHead(502); res.end() } })
r.on('timeout', () => { r.destroy(); if (!res.headersSent) { res.writeHead(504); res.end() } })
} catch {
if (!res.headersSent) { res.writeHead(500); res.end() }
}
})
// Get single character (full details)
server.middlewares.use(async (req, res, next) => {
const match = req.url.match(/^\/api\/dream\/characters\/([^/]+)\/?$/)
if (!match) return next()
await proxyDreamJson(`/api/characters/${match[1]}`, res)
})
},
}
@@ -418,163 +531,67 @@ function memoryStoragePlugin() {
return {
name: 'memory-storage',
configureServer(server) {
const ensureDirs = async () => {
const { mkdir } = await import('fs/promises')
await mkdir(`${MEMORIES_DIR}/personal`, { recursive: true })
}
// Proxy all /api/memories/* requests to the OpenClaw bridge (port 8081)
// The bridge handles SQLite + vector search; dashboard is just a passthrough
const proxyMemoryRequest = async (req, res) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
})
res.end()
return
}
const readJsonFile = async (path, fallback) => {
const { readFile } = await import('fs/promises')
try {
return JSON.parse(await readFile(path, 'utf-8'))
} catch {
return fallback
const { default: http } = await import('http')
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const body = Buffer.concat(chunks)
// Reconstruct full path: /api/memories/... (req.url has the part after /api/memories)
const targetPath = `/api/memories${req.url}`
await new Promise((resolve, reject) => {
const proxyReq = http.request(
`http://localhost:8081${targetPath}`,
{
method: req.method,
headers: {
'Content-Type': req.headers['content-type'] || 'application/json',
...(body.length > 0 ? { 'Content-Length': body.length } : {}),
},
timeout: 30000,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode, {
'Content-Type': proxyRes.headers['content-type'] || 'application/json',
'Access-Control-Allow-Origin': '*',
})
proxyRes.pipe(res)
proxyRes.on('end', resolve)
proxyRes.on('error', resolve)
}
)
proxyReq.on('error', reject)
proxyReq.on('timeout', () => {
proxyReq.destroy()
reject(new Error('timeout'))
})
if (body.length > 0) proxyReq.write(body)
proxyReq.end()
})
} catch (err) {
console.error(`[memory-proxy] failed:`, err?.message || err)
if (!res.headersSent) {
res.writeHead(502, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: `Bridge unreachable: ${err?.message || 'unknown'}` }))
}
}
}
const writeJsonFile = async (path, data) => {
const { writeFile } = await import('fs/promises')
await writeFile(path, JSON.stringify(data, null, 2))
}
// Personal memories: /api/memories/personal/:characterId[/:memoryId]
server.middlewares.use('/api/memories/personal', async (req, res, next) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,DELETE', 'Access-Control-Allow-Headers': 'Content-Type' })
res.end()
return
}
await ensureDirs()
const url = new URL(req.url, 'http://localhost')
const parts = url.pathname.replace(/^\/+/, '').split('/')
const characterId = parts[0] ? parts[0].replace(/[^a-zA-Z0-9_\-\.]/g, '_') : null
const memoryId = parts[1] || null
if (!characterId) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Missing character ID' }))
return
}
const filePath = `${MEMORIES_DIR}/personal/${characterId}.json`
if (req.method === 'GET') {
const data = await readJsonFile(filePath, { characterId, memories: [] })
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify(data))
return
}
if (req.method === 'POST') {
try {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const memory = JSON.parse(Buffer.concat(chunks).toString())
const data = await readJsonFile(filePath, { characterId, memories: [] })
if (memory.id) {
const idx = data.memories.findIndex(m => m.id === memory.id)
if (idx >= 0) {
data.memories[idx] = { ...data.memories[idx], ...memory }
} else {
data.memories.push(memory)
}
} else {
memory.id = 'm_' + Date.now()
memory.createdAt = memory.createdAt || new Date().toISOString()
data.memories.push(memory)
}
await writeJsonFile(filePath, data)
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify({ ok: true, memory }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
return
}
if (req.method === 'DELETE' && memoryId) {
try {
const data = await readJsonFile(filePath, { characterId, memories: [] })
data.memories = data.memories.filter(m => m.id !== memoryId)
await writeJsonFile(filePath, data)
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify({ ok: true }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
return
}
next()
})
// General memories: /api/memories/general[/:memoryId]
server.middlewares.use('/api/memories/general', async (req, res, next) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,DELETE', 'Access-Control-Allow-Headers': 'Content-Type' })
res.end()
return
}
await ensureDirs()
const url = new URL(req.url, 'http://localhost')
const memoryId = url.pathname.replace(/^\/+/, '') || null
const filePath = `${MEMORIES_DIR}/general.json`
if (req.method === 'GET') {
const data = await readJsonFile(filePath, { memories: [] })
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify(data))
return
}
if (req.method === 'POST') {
try {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const memory = JSON.parse(Buffer.concat(chunks).toString())
const data = await readJsonFile(filePath, { memories: [] })
if (memory.id) {
const idx = data.memories.findIndex(m => m.id === memory.id)
if (idx >= 0) {
data.memories[idx] = { ...data.memories[idx], ...memory }
} else {
data.memories.push(memory)
}
} else {
memory.id = 'm_' + Date.now()
memory.createdAt = memory.createdAt || new Date().toISOString()
data.memories.push(memory)
}
await writeJsonFile(filePath, data)
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify({ ok: true, memory }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
return
}
if (req.method === 'DELETE' && memoryId) {
try {
const data = await readJsonFile(filePath, { memories: [] })
data.memories = data.memories.filter(m => m.id !== memoryId)
await writeJsonFile(filePath, data)
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify({ ok: true }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
return
}
next()
})
server.middlewares.use('/api/memories', proxyMemoryRequest)
},
}
}
@@ -698,6 +715,96 @@ function modePlugin() {
}
}
function promptStylePlugin() {
return {
name: 'prompt-style-api',
configureServer(server) {
// GET /api/prompt-styles — list all available styles
server.middlewares.use('/api/prompt-styles', async (req, res, next) => {
if (req.method === 'OPTIONS') {
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET', 'Access-Control-Allow-Headers': 'Content-Type' })
res.end()
return
}
if (req.method !== 'GET') { next(); return }
const { readdir, readFile } = await import('fs/promises')
try {
const files = (await readdir(PROMPT_STYLES_DIR)).filter(f => f.endsWith('.json'))
const styles = []
for (const file of files) {
try {
const raw = await readFile(`${PROMPT_STYLES_DIR}/${file}`, 'utf-8')
styles.push(JSON.parse(raw))
} catch { /* skip corrupt files */ }
}
// Sort: cloud group first, then local
styles.sort((a, b) => {
if (a.group !== b.group) return a.group === 'cloud' ? -1 : 1
return (a.id || '').localeCompare(b.id || '')
})
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify(styles))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
})
// GET/POST /api/prompt-style — active style
server.middlewares.use('/api/prompt-style', async (req, res, next) => {
// Avoid matching /api/prompt-styles (plural)
const url = new URL(req.url, 'http://localhost')
if (url.pathname !== '/') { next(); return }
if (req.method === 'OPTIONS') {
res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST', 'Access-Control-Allow-Headers': 'Content-Type' })
res.end()
return
}
const { readFile, writeFile } = await import('fs/promises')
if (req.method === 'GET') {
try {
const raw = await readFile(ACTIVE_STYLE_PATH, 'utf-8')
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(raw)
} catch {
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify({ style: 'standard', updated_at: '' }))
}
return
}
if (req.method === 'POST') {
try {
const chunks = []
for await (const chunk of req) chunks.push(chunk)
const data = JSON.parse(Buffer.concat(chunks).toString())
const VALID_STYLES = ['quick', 'standard', 'creative', 'roleplayer', 'game-master', 'storyteller']
if (!data.style || !VALID_STYLES.includes(data.style)) {
res.writeHead(400, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: `Invalid style. Valid: ${VALID_STYLES.join(', ')}` }))
return
}
const state = { style: data.style, updated_at: new Date().toISOString() }
await writeFile(ACTIVE_STYLE_PATH, JSON.stringify(state, null, 2))
res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' })
res.end(JSON.stringify({ ok: true, ...state }))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: err.message }))
}
return
}
next()
})
},
}
}
function bridgeProxyPlugin() {
return {
name: 'bridge-proxy',
@@ -771,10 +878,12 @@ export default defineConfig({
satelliteMapPlugin(),
conversationStoragePlugin(),
memoryStoragePlugin(),
dreamProxyPlugin(),
gazeProxyPlugin(),
characterLookupPlugin(),
healthCheckPlugin(),
modePlugin(),
promptStylePlugin(),
bridgeProxyPlugin(),
tailwindcss(),
react(),