2026-02-18_signet-setup
This commit is contained in:
parent
acfddae0ff
commit
99b54e164b
@ -130,3 +130,12 @@ In the meantime, you could try bun:sqlite which has a similar API.[0m
|
||||
[2m02:58:37[0m [36mINFO [0m [watcher] File watcher started
|
||||
[2m02:58:37[0m [36mINFO [0m [daemon] Server listening [2m{"address":"::1","port":3850}[0m
|
||||
[2m02:58:37[0m [36mINFO [0m [daemon] Daemon ready
|
||||
[2m07:51:14[0m [36mINFO [0m [daemon] Serving dashboard [2m{"path":"/home/nicholai/signet/signetai/packages/cli/dashboard/build"}[0m
|
||||
[2m07:51:14[0m [36mINFO [0m [daemon] Signet Daemon starting
|
||||
[2m07:51:14[0m [36mINFO [0m [daemon] Agents directory [2m{"path":"/home/nicholai/.agents"}[0m
|
||||
[2m07:51:14[0m [36mINFO [0m [daemon] Port configured [2m{"port":3850}[0m
|
||||
[2m07:51:14[0m [36mINFO [0m [daemon] Memory schema initialized
|
||||
[2m07:51:14[0m [36mINFO [0m [daemon] Process ID [2m{"pid":1076069}[0m
|
||||
[2m07:51:14[0m [36mINFO [0m [watcher] File watcher started
|
||||
[2m07:51:14[0m [36mINFO [0m [daemon] Server listening [2m{"address":"::1","port":3850}[0m
|
||||
[2m07:51:14[0m [36mINFO [0m [daemon] Daemon ready
|
||||
|
||||
@ -1 +1 @@
|
||||
625468
|
||||
1076069
|
||||
@ -1,119 +1,10 @@
|
||||
---
|
||||
name: agent-memory
|
||||
description: "Signet memory integration - hybrid search, auto-embedding, cross-harness persistence"
|
||||
homepage: https://signetai.sh
|
||||
metadata:
|
||||
{
|
||||
"openclaw": {
|
||||
"emoji": "🧠",
|
||||
"events": ["agent:bootstrap", "command:new", "command:remember", "command:recall", "command:context"],
|
||||
"install": [{ "id": "workspace", "kind": "workspace", "label": "Workspace hook" }]
|
||||
}
|
||||
}
|
||||
description: "Signet memory integration"
|
||||
---
|
||||
|
||||
# Agent Memory Hook (Signet)
|
||||
|
||||
Unified memory system for all AI harnesses - OpenClaw, Claude Code, OpenCode, Codex.
|
||||
Uses the Signet daemon API for all operations (no Python dependency).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Signet daemon running: `signet start`
|
||||
- Default daemon URL: `http://localhost:3850`
|
||||
- Override via: `SIGNET_DAEMON_URL` environment variable
|
||||
|
||||
## Features
|
||||
|
||||
- **Hybrid Search**: 70% vector similarity + 30% BM25 keyword matching
|
||||
- **Auto-Embedding**: Memories are vectorized on save via Ollama/OpenAI
|
||||
- **Cross-Harness**: Same memory pool for all agents
|
||||
- **No Python**: Uses native TypeScript daemon
|
||||
|
||||
## Commands
|
||||
|
||||
### `/context`
|
||||
Load memory context (identity + recent memories) into the session.
|
||||
Use at the start of a conversation to get full context.
|
||||
|
||||
### `/remember <content>`
|
||||
Save a memory with auto-embedding.
|
||||
|
||||
Prefixes:
|
||||
- `critical:` - pinned memory (never decays)
|
||||
- `[tag1,tag2]:` - tagged memory
|
||||
|
||||
Examples:
|
||||
```
|
||||
/remember nicholai prefers tabs over spaces
|
||||
/remember critical: never push directly to main
|
||||
/remember [voice,tts]: qwen model needs 12GB VRAM minimum
|
||||
```
|
||||
|
||||
### `/recall <query>`
|
||||
Search memories using hybrid search (semantic + keyword).
|
||||
|
||||
Examples:
|
||||
```
|
||||
/recall voice configuration
|
||||
/recall signet architecture
|
||||
/recall preferences
|
||||
```
|
||||
|
||||
### On `/new`
|
||||
Automatically extracts session context before reset.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
~/.agents/
|
||||
├── agent.yaml # Signet configuration
|
||||
├── MEMORY.md # Synthesized summary
|
||||
└── memory/
|
||||
└── memories.db # SQLite (memories + vectors)
|
||||
```
|
||||
|
||||
Daemon: `http://localhost:3850`
|
||||
|
||||
## Embedding Providers
|
||||
|
||||
Configure in `~/.agents/agent.yaml`:
|
||||
|
||||
**Ollama (local, default):**
|
||||
```yaml
|
||||
embedding:
|
||||
provider: ollama
|
||||
model: nomic-embed-text
|
||||
dimensions: 768
|
||||
base_url: http://localhost:11434
|
||||
```
|
||||
|
||||
**OpenAI-compatible:**
|
||||
```yaml
|
||||
embedding:
|
||||
provider: openai
|
||||
model: text-embedding-3-small
|
||||
dimensions: 1536
|
||||
base_url: https://api.openai.com/v1
|
||||
```
|
||||
|
||||
## Search Tuning
|
||||
|
||||
Adjust in `~/.agents/agent.yaml`:
|
||||
|
||||
```yaml
|
||||
search:
|
||||
alpha: 0.7 # Vector weight (0-1)
|
||||
top_k: 20 # Candidates per source
|
||||
min_score: 0.3 # Minimum result score
|
||||
```
|
||||
|
||||
## Harness Compatibility
|
||||
|
||||
Works with:
|
||||
- **OpenClaw** - via this hook
|
||||
- **Claude Code** - reads ~/.agents/AGENTS.md, MEMORY.md
|
||||
- **OpenCode** - reads ~/.agents/AGENTS.md
|
||||
- **Codex** - reads ~/.agents/AGENTS.md
|
||||
|
||||
All harnesses share the same memory database.
|
||||
- `/context` - Load memory context
|
||||
- `/remember <content>` - Save a memory
|
||||
- `/recall <query>` - Search memories
|
||||
|
||||
@ -1,420 +1,48 @@
|
||||
/**
|
||||
* Signet Agent Memory Hook
|
||||
*
|
||||
* Unified memory system for all AI harnesses (OpenClaw, Claude Code, OpenCode, Codex)
|
||||
* Uses the Signet daemon API for memory operations - no Python dependency.
|
||||
*
|
||||
* Features:
|
||||
* - Hybrid search (vector + BM25)
|
||||
* - Auto-embedding via daemon
|
||||
* - Cross-harness persistence
|
||||
*
|
||||
* Spec: Signet v0.2.1 - https://signetai.sh
|
||||
*/
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
const DAEMON_URL = process.env.SIGNET_DAEMON_URL || "http://localhost:3850";
|
||||
const MEMORY_MD_PATH = path.join(os.homedir(), ".agents/MEMORY.md");
|
||||
const AGENTS_DIR = path.join(os.homedir(), ".agents");
|
||||
const MEMORY_SCRIPT = "/home/nicholai/.agents/memory/scripts/memory.py";
|
||||
|
||||
/**
|
||||
* Check if the Signet daemon is running
|
||||
*/
|
||||
async function isDaemonRunning() {
|
||||
try {
|
||||
const res = await fetch(`${DAEMON_URL}/health`, {
|
||||
signal: AbortSignal.timeout(1000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the Signet daemon remember API
|
||||
*/
|
||||
async function daemonRemember(content, options = {}) {
|
||||
const res = await fetch(`${DAEMON_URL}/api/memory/remember`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
type: options.type,
|
||||
importance: options.importance,
|
||||
tags: options.tags,
|
||||
who: options.who || "openclaw",
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
async function runMemoryScript(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("python3", [MEMORY_SCRIPT, ...args], { timeout: 5000 });
|
||||
let stdout = "", stderr = "";
|
||||
proc.stdout.on("data", (d) => { stdout += d.toString(); });
|
||||
proc.stderr.on("data", (d) => { stderr += d.toString(); });
|
||||
proc.on("close", (code) => {
|
||||
if (code === 0) resolve(stdout.trim());
|
||||
else reject(new Error(stderr || `exit code ${code}`));
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Daemon remember failed: ${res.status} - ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
proc.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the Signet daemon recall API
|
||||
*/
|
||||
async function daemonRecall(query, options = {}) {
|
||||
const res = await fetch(`${DAEMON_URL}/api/memory/recall`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
limit: options.limit || 10,
|
||||
type: options.type,
|
||||
min_score: options.minScore,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const handler = async (event) => {
|
||||
if (event.type !== "command") return;
|
||||
const args = event.context?.args || "";
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Daemon recall failed: ${res.status} - ${text}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the session-start hook to get context
|
||||
*/
|
||||
async function daemonSessionStart(harness, options = {}) {
|
||||
const res = await fetch(`${DAEMON_URL}/api/hooks/session-start`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
harness,
|
||||
sessionKey: options.sessionKey,
|
||||
context: options.context,
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect harness name from session key
|
||||
*/
|
||||
function detectHarness(sessionKey) {
|
||||
if (!sessionKey) return "openclaw";
|
||||
if (sessionKey.includes("claude")) return "claude-code";
|
||||
if (sessionKey.includes("opencode")) return "opencode";
|
||||
return "openclaw";
|
||||
}
|
||||
|
||||
/**
|
||||
* Read recent messages from session file for memory extraction
|
||||
*/
|
||||
async function getRecentSessionContent(sessionFilePath) {
|
||||
try {
|
||||
const content = await fs.readFile(sessionFilePath, "utf-8");
|
||||
const lines = content.trim().split("\n");
|
||||
const recentLines = lines.slice(-20);
|
||||
|
||||
const messages = [];
|
||||
for (const line of recentLines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === "message" && entry.message) {
|
||||
const msg = entry.message;
|
||||
const role = msg.role;
|
||||
if ((role === "user" || role === "assistant") && msg.content) {
|
||||
const text = Array.isArray(msg.content)
|
||||
? msg.content.find((c) => c.type === "text")?.text
|
||||
: msg.content;
|
||||
if (text && !text.startsWith("/")) {
|
||||
messages.push(`${role}: ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
return messages.join("\n");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse remember content for prefixes
|
||||
* - `critical:` for pinned memories
|
||||
* - `[tag1,tag2]:` for tagged memories
|
||||
*/
|
||||
function parseRememberContent(content) {
|
||||
let type = "explicit";
|
||||
let importance = 0.8;
|
||||
let tags = [];
|
||||
let cleanContent = content.trim();
|
||||
|
||||
// Check for critical prefix
|
||||
if (cleanContent.toLowerCase().startsWith("critical:")) {
|
||||
type = "critical";
|
||||
importance = 1.0;
|
||||
cleanContent = cleanContent.slice(9).trim();
|
||||
}
|
||||
// Check for tag prefix [tag1,tag2]:
|
||||
else if (cleanContent.startsWith("[") && cleanContent.includes("]:")) {
|
||||
const tagMatch = cleanContent.match(/^\[([^\]]+)\]:\s*/);
|
||||
if (tagMatch) {
|
||||
tags = tagMatch[1].split(",").map((t) => t.trim());
|
||||
cleanContent = cleanContent.slice(tagMatch[0].length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return { content: cleanContent, type, importance, tags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /remember command
|
||||
*/
|
||||
async function handleRemember(event) {
|
||||
const context = event.context || {};
|
||||
const args = context.args || "";
|
||||
|
||||
if (!args.trim()) {
|
||||
event.messages.push(
|
||||
"🧠 Usage: /remember <content>\n\n" +
|
||||
"Prefixes:\n" +
|
||||
"- `critical:` for pinned memories\n" +
|
||||
"- `[tag1,tag2]:` for tagged memories"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const harness = detectHarness(event.sessionKey);
|
||||
const parsed = parseRememberContent(args);
|
||||
|
||||
try {
|
||||
const result = await daemonRemember(parsed.content, {
|
||||
type: parsed.type,
|
||||
importance: parsed.importance,
|
||||
tags: parsed.tags,
|
||||
who: harness,
|
||||
});
|
||||
|
||||
event.messages.push(`🧠 Memory saved (id: ${result.id || result.memoryId})`);
|
||||
} catch (err) {
|
||||
event.messages.push(`🧠 Error saving memory: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /recall command
|
||||
*/
|
||||
async function handleRecall(event) {
|
||||
const context = event.context || {};
|
||||
const args = context.args || "";
|
||||
|
||||
if (!args.trim()) {
|
||||
event.messages.push("🧠 Usage: /recall <search query>");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await daemonRecall(args.trim(), { limit: 10 });
|
||||
const memories = result.results || [];
|
||||
|
||||
if (memories.length === 0) {
|
||||
event.messages.push("🧠 No memories found matching your query.");
|
||||
return;
|
||||
}
|
||||
|
||||
const formatted = memories
|
||||
.map((m, i) => {
|
||||
const date = new Date(m.created_at).toLocaleDateString();
|
||||
const score = (m.score * 100).toFixed(0);
|
||||
return `${i + 1}. [${date}] (${score}%) ${m.content}`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
event.messages.push(`🧠 Memory search results:\n\n${formatted}`);
|
||||
} catch (err) {
|
||||
event.messages.push(`🧠 Error querying memory: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /context command - load memory context for current session
|
||||
*/
|
||||
async function handleContext(event) {
|
||||
const harness = detectHarness(event.sessionKey);
|
||||
|
||||
try {
|
||||
const result = await daemonSessionStart(harness, {
|
||||
sessionKey: event.sessionKey,
|
||||
});
|
||||
|
||||
if (result && result.inject) {
|
||||
event.messages.push(`🧠 **Memory Context Loaded**\n\n${result.inject}`);
|
||||
} else {
|
||||
// Fallback to reading MEMORY.md directly
|
||||
try {
|
||||
const memoryMd = await fs.readFile(MEMORY_MD_PATH, "utf-8");
|
||||
if (memoryMd.trim()) {
|
||||
event.messages.push(`🧠 **Memory Context**\n\n${memoryMd.trim()}`);
|
||||
} else {
|
||||
event.messages.push("🧠 No memory context available.");
|
||||
}
|
||||
} catch {
|
||||
event.messages.push("🧠 No memory context available.");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback to MEMORY.md
|
||||
try {
|
||||
const memoryMd = await fs.readFile(MEMORY_MD_PATH, "utf-8");
|
||||
if (memoryMd.trim()) {
|
||||
event.messages.push(`🧠 **Memory Context**\n\n${memoryMd.trim()}`);
|
||||
} else {
|
||||
event.messages.push("🧠 No memory context available.");
|
||||
}
|
||||
} catch {
|
||||
event.messages.push(`🧠 Error loading context: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /new command - save session context before reset
|
||||
*/
|
||||
async function handleNew(event) {
|
||||
const context = event.context || {};
|
||||
const sessionEntry = context.previousSessionEntry || context.sessionEntry || {};
|
||||
const sessionFile = sessionEntry.sessionFile;
|
||||
|
||||
if (!sessionFile) {
|
||||
console.log("[agent-memory] No session file found, skipping auto-save");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sessionContent = await getRecentSessionContent(sessionFile);
|
||||
if (!sessionContent || sessionContent.length < 100) {
|
||||
console.log("[agent-memory] Session too short for auto-extraction");
|
||||
return;
|
||||
}
|
||||
|
||||
const harness = detectHarness(event.sessionKey);
|
||||
const now = new Date().toISOString();
|
||||
const summaryContent = `[session-end]: Session ended at ${now}. Recent conversation:\n${sessionContent.slice(0, 500)}`;
|
||||
|
||||
await daemonRemember(summaryContent, {
|
||||
type: "session",
|
||||
importance: 0.6,
|
||||
who: harness,
|
||||
});
|
||||
|
||||
console.log("[agent-memory] Session context saved to Signet");
|
||||
} catch (err) {
|
||||
console.error("[agent-memory] Failed to save session memory:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle agent:bootstrap event - inject memories into bootstrap context
|
||||
*/
|
||||
async function handleBootstrap(event) {
|
||||
const harness = detectHarness(event.sessionKey);
|
||||
|
||||
try {
|
||||
const running = await isDaemonRunning();
|
||||
if (!running) {
|
||||
console.log("[agent-memory] Signet daemon not running, skipping memory injection");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await daemonSessionStart(harness, {
|
||||
sessionKey: event.sessionKey,
|
||||
});
|
||||
|
||||
if (!result || !result.memories || result.memories.length === 0) {
|
||||
console.log("[agent-memory] No memories to inject");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add a SIGNET.md file to bootstrap context with recent memories
|
||||
const context = event.context || {};
|
||||
const bootstrapFiles = context.bootstrapFiles || [];
|
||||
|
||||
// Build memory content
|
||||
const memoryContent = [
|
||||
"# Recent Memories (Signet)",
|
||||
"",
|
||||
...result.memories.slice(0, 10).map((m) => {
|
||||
const date = new Date(m.created_at).toLocaleDateString();
|
||||
return `- [${date}] ${m.content}`;
|
||||
}),
|
||||
].join("\n");
|
||||
|
||||
// Add SIGNET.md to bootstrap files
|
||||
bootstrapFiles.push({
|
||||
name: "SIGNET.md",
|
||||
content: memoryContent,
|
||||
required: false,
|
||||
});
|
||||
|
||||
context.bootstrapFiles = bootstrapFiles;
|
||||
|
||||
console.log(`[agent-memory] Injected ${result.memories.length} memories into bootstrap context`);
|
||||
} catch (err) {
|
||||
console.warn("[agent-memory] Failed to inject memories:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main hook handler
|
||||
*/
|
||||
const agentMemoryHandler = async (event) => {
|
||||
// Handle agent:bootstrap event for memory injection
|
||||
if (event.type === "agent" && event.action === "bootstrap") {
|
||||
await handleBootstrap(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if daemon is running for memory commands
|
||||
if (event.type === "command" && ["remember", "recall", "context"].includes(event.action)) {
|
||||
const running = await isDaemonRunning();
|
||||
if (!running) {
|
||||
console.warn("[agent-memory] Signet daemon not running at", DAEMON_URL);
|
||||
event.messages.push(
|
||||
"🧠 Signet daemon not running. Start it with: `signet start`"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type !== "command") {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.action) {
|
||||
case "remember":
|
||||
await handleRemember(event);
|
||||
break;
|
||||
case "recall":
|
||||
await handleRecall(event);
|
||||
break;
|
||||
case "context":
|
||||
await handleContext(event);
|
||||
break;
|
||||
case "new":
|
||||
await handleNew(event);
|
||||
break;
|
||||
}
|
||||
switch (event.action) {
|
||||
case "remember":
|
||||
if (!args.trim()) { event.messages.push("🧠 Usage: /remember <content>"); return; }
|
||||
try {
|
||||
const result = await runMemoryScript(["save", "--mode", "explicit", "--who", "openclaw", "--content", args.trim()]);
|
||||
event.messages.push(`🧠 ${result}`);
|
||||
} catch (e) { event.messages.push(`🧠 Error: ${e.message}`); }
|
||||
break;
|
||||
case "recall":
|
||||
if (!args.trim()) { event.messages.push("🧠 Usage: /recall <query>"); return; }
|
||||
try {
|
||||
const result = await runMemoryScript(["query", args.trim(), "--limit", "10"]);
|
||||
event.messages.push(result ? `🧠 Results:\n\n${result}` : "🧠 No memories found.");
|
||||
} catch (e) { event.messages.push(`🧠 Error: ${e.message}`); }
|
||||
break;
|
||||
case "context":
|
||||
try {
|
||||
const result = await runMemoryScript(["load", "--mode", "session-start"]);
|
||||
event.messages.push(result ? `🧠 **Context**\n\n${result}` : "🧠 No context.");
|
||||
} catch (e) { event.messages.push(`🧠 Error: ${e.message}`); }
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export default agentMemoryHandler;
|
||||
export default handler;
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
{
|
||||
"name": "agent-memory",
|
||||
"version": "1.0.0",
|
||||
"type": "module"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
9
skills/registry.json
Normal file
9
skills/registry.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"skills": {},
|
||||
"sources": [
|
||||
{
|
||||
"type": "claude-code",
|
||||
"path": "/home/nicholai/.claude/skills"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user