Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89e8fa265b | ||
|
|
ba158c9cdb | ||
|
|
767a2c0061 | ||
|
|
36334f27a1 | ||
|
|
6a479dddb9 | ||
|
|
7058d1aad7 |
@@ -53,6 +53,53 @@ __all__ = [
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Name of the single forced function tool used to carry structured output when
|
||||
# strict_schema is on. The Codex backend speaks the OpenAI Responses API, so a
|
||||
# forced function call gives us constrained decoding straight into the response
|
||||
# schema — no prompt-injected schema, no raw json.loads on free-form model text,
|
||||
# no invalid-\escape retry storm (issue #2504, same class as #1002 / #2339).
|
||||
_STRUCTURED_TOOL_NAME = "structured_response"
|
||||
|
||||
# Valid JSON string escape characters (the char that may follow a backslash).
|
||||
_VALID_JSON_ESCAPE_CHARS = set('"\\/bfnrtu')
|
||||
|
||||
|
||||
def _repair_invalid_json_escapes(text: str) -> str:
|
||||
"""Best-effort repair of invalid ``\\escape`` sequences in a JSON string.
|
||||
|
||||
Escape-heavy content (code, serial/CLI commands, Windows paths, regexes)
|
||||
makes weaker models emit backslashes that aren't valid JSON escapes (e.g.
|
||||
``\\d``, ``\\s``, ``C:\\Users``), so ``json.loads`` fails deterministically
|
||||
and every retry re-fails the same way (issue #2504). This doubles any
|
||||
backslash that isn't part of a valid escape so the payload parses. It is a
|
||||
lenient fallback only — the strict_schema forced-tool path is the real fix.
|
||||
"""
|
||||
result: list[str] = []
|
||||
i = 0
|
||||
n = len(text)
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
if ch == "\\" and i + 1 < n:
|
||||
nxt = text[i + 1]
|
||||
if nxt in _VALID_JSON_ESCAPE_CHARS:
|
||||
# Preserve the valid escape (both chars) verbatim.
|
||||
result.append(ch)
|
||||
result.append(nxt)
|
||||
i += 2
|
||||
continue
|
||||
# Invalid escape: escape the lone backslash so JSON parses.
|
||||
result.append("\\\\")
|
||||
i += 1
|
||||
continue
|
||||
if ch == "\\" and i + 1 == n:
|
||||
# Trailing lone backslash — escape it.
|
||||
result.append("\\\\")
|
||||
i += 1
|
||||
continue
|
||||
result.append(ch)
|
||||
i += 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
class CodexLLM(LLMInterface):
|
||||
"""
|
||||
@@ -336,7 +383,18 @@ class CodexLLM(LLMInterface):
|
||||
strict_schema: bool = False,
|
||||
return_usage: bool = False,
|
||||
) -> Any:
|
||||
"""Make API call to Codex backend with SSE streaming."""
|
||||
"""Make API call to Codex backend with SSE streaming.
|
||||
|
||||
Args:
|
||||
strict_schema: Route structured output through a single forced
|
||||
function tool (constrained decoding) instead of prompt-injecting
|
||||
the schema and parsing free-form text. The Codex backend speaks
|
||||
the OpenAI Responses API, so the forced function call emits the
|
||||
response schema directly as tool arguments — eliminating the
|
||||
invalid-``\\escape`` retry storm (issue #2504). When False, falls
|
||||
back to schema-in-prompt + JSON parse, now hardened with a lenient
|
||||
invalid-escape repair before giving up.
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
# Proactively refresh the OAuth access_token if it's near expiry.
|
||||
@@ -361,11 +419,22 @@ class CodexLLM(LLMInterface):
|
||||
else:
|
||||
user_messages.append(msg)
|
||||
|
||||
# Add JSON schema instruction if response_format is provided
|
||||
# Structured output: prefer a single forced function tool (constrained
|
||||
# decoding) over text-injecting the schema and parsing the reply. The
|
||||
# forced tool guarantees schema-shaped JSON in the tool arguments,
|
||||
# eliminating the invalid-\escape retry storm (issue #2504). When
|
||||
# strict_schema is off we keep the schema-in-prompt + json.loads
|
||||
# fallback (now hardened with a lenient escape repair) for callers that
|
||||
# can't force tools.
|
||||
schema = None
|
||||
use_forced_tool = False
|
||||
if response_format is not None and hasattr(response_format, "model_json_schema"):
|
||||
schema = response_format.model_json_schema()
|
||||
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
|
||||
system_instruction += schema_msg
|
||||
if strict_schema:
|
||||
use_forced_tool = True
|
||||
else:
|
||||
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2, ensure_ascii=False)}"
|
||||
system_instruction += schema_msg
|
||||
|
||||
# gpt-5.2-codex only supports "detailed" reasoning summary
|
||||
reasoning_summary = "detailed" if "5.2" in self.model else self.reasoning_summary
|
||||
@@ -392,6 +461,20 @@ class CodexLLM(LLMInterface):
|
||||
"prompt_cache_key": str(uuid.uuid4()),
|
||||
}
|
||||
|
||||
if use_forced_tool and schema is not None:
|
||||
# Single function tool whose parameters ARE the response schema;
|
||||
# force it via tool_choice so the backend does constrained decoding.
|
||||
payload["tools"] = [
|
||||
{
|
||||
"type": "function",
|
||||
"name": _STRUCTURED_TOOL_NAME,
|
||||
"description": "Return the structured response.",
|
||||
"parameters": schema,
|
||||
}
|
||||
]
|
||||
payload["tool_choice"] = {"type": "function", "name": _STRUCTURED_TOOL_NAME}
|
||||
payload["parallel_tool_calls"] = False
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.access_token}",
|
||||
"Content-Type": "application/json",
|
||||
@@ -412,8 +495,15 @@ class CodexLLM(LLMInterface):
|
||||
response = await self._client.post(url, json=payload, headers=headers, timeout=120.0)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse SSE stream
|
||||
content = await self._parse_sse_stream(response)
|
||||
# Forced-tool path: read structured output from the function-call
|
||||
# arguments (already a JSON string in a dedicated channel) rather
|
||||
# than from free-form assistant text.
|
||||
if use_forced_tool:
|
||||
text_content, tool_calls = await self._parse_sse_tool_stream(response)
|
||||
content = text_content or ""
|
||||
else:
|
||||
tool_calls = []
|
||||
content = await self._parse_sse_stream(response)
|
||||
|
||||
# Codex SSE carries no usage block; stash the same char/4 estimate
|
||||
# the success path traces so a later parse/validate failure records
|
||||
@@ -426,7 +516,28 @@ class CodexLLM(LLMInterface):
|
||||
)
|
||||
|
||||
# Handle structured output
|
||||
if response_format is not None:
|
||||
if use_forced_tool:
|
||||
tool_input = None
|
||||
for tc in tool_calls:
|
||||
if tc.name == _STRUCTURED_TOOL_NAME:
|
||||
tool_input = tc.arguments if isinstance(tc.arguments, dict) else None
|
||||
break
|
||||
if tool_input is None:
|
||||
# Model ignored the forced tool (rare — e.g. a gateway that
|
||||
# drops tool_choice). Retry so we don't hard-fail.
|
||||
logger.warning(
|
||||
f"Codex forced structured tool missing from response "
|
||||
f"(attempt {attempt + 1}/{max_retries + 1})"
|
||||
)
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
attempt += 1
|
||||
continue
|
||||
raise RuntimeError("Codex did not return the forced structured_response tool call")
|
||||
content = json.dumps(tool_input)
|
||||
result = tool_input if skip_validation else response_format.model_validate(tool_input)
|
||||
elif response_format is not None:
|
||||
# Models may wrap JSON in markdown
|
||||
clean_content = content
|
||||
if "```json" in content:
|
||||
@@ -437,13 +548,20 @@ class CodexLLM(LLMInterface):
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
attempt += 1
|
||||
continue
|
||||
raise
|
||||
# Escape-heavy content deterministically re-fails every
|
||||
# retry (issue #2504). Try a lenient invalid-escape repair
|
||||
# before burning a retry / re-raising.
|
||||
try:
|
||||
json_data = json.loads(_repair_invalid_json_escapes(clean_content))
|
||||
logger.info("Codex JSON parsed after repairing invalid escape sequences")
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Codex JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {e}")
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
attempt += 1
|
||||
continue
|
||||
raise
|
||||
|
||||
if skip_validation:
|
||||
result = json_data
|
||||
@@ -872,8 +990,13 @@ class CodexLLM(LLMInterface):
|
||||
try:
|
||||
arguments = json.loads(arguments_str)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
|
||||
arguments = {}
|
||||
# Escape-heavy content can emit invalid \escape
|
||||
# sequences (issue #2504); repair before giving up.
|
||||
try:
|
||||
arguments = json.loads(_repair_invalid_json_escapes(arguments_str))
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse tool arguments: {arguments_str}")
|
||||
arguments = {}
|
||||
|
||||
tool_calls.append(
|
||||
LLMToolCall(
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Regression tests for Codex structured output (issue #2504).
|
||||
|
||||
Before the fix, ``CodexLLM.call(strict_schema=True)`` was a dead no-op: structured
|
||||
output always went through prompt-injected schema + raw ``json.loads`` on the
|
||||
model's free-form text. Escape-heavy content (code, serial/CLI commands, Windows
|
||||
paths, regexes) makes weaker models emit invalid ``\\escape`` sequences, so every
|
||||
parse attempt fails and retain/consolidation burn all retries and fail.
|
||||
|
||||
The fix:
|
||||
- ``strict_schema=True`` routes structured output through a single forced function
|
||||
tool (constrained decoding into the response schema).
|
||||
- The non-strict fallback now repairs invalid ``\\escape`` sequences before giving up.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from hindsight_api.engine.providers.codex_llm import (
|
||||
CodexLLM,
|
||||
_repair_invalid_json_escapes,
|
||||
)
|
||||
from hindsight_api.engine.response_models import LLMToolCall
|
||||
|
||||
|
||||
class _Fact(BaseModel):
|
||||
fact: str
|
||||
|
||||
|
||||
def build_llm() -> CodexLLM:
|
||||
with patch.object(CodexLLM, "_load_codex_auth", return_value=("token", "account")):
|
||||
return CodexLLM(
|
||||
provider="openai-codex",
|
||||
api_key="ignored",
|
||||
base_url="https://chatgpt.com/backend-api",
|
||||
model="gpt-5.4-mini",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _repair_invalid_json_escapes — pure unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_repair_fixes_invalid_escape_in_json():
|
||||
# `\d` and `\s` are not valid JSON escapes; raw json.loads fails.
|
||||
broken = r'{"fact": "regex \d+\s matches digits"}'
|
||||
import json
|
||||
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
json.loads(broken)
|
||||
repaired = _repair_invalid_json_escapes(broken)
|
||||
assert json.loads(repaired) == {"fact": r"regex \d+\s matches digits"}
|
||||
|
||||
|
||||
def test_repair_preserves_valid_escapes():
|
||||
import json
|
||||
|
||||
valid = r'{"fact": "line1\nline2\ttab \"quoted\" \\backslash é"}'
|
||||
# Already valid — repair must not corrupt it.
|
||||
assert json.loads(_repair_invalid_json_escapes(valid)) == json.loads(valid)
|
||||
|
||||
|
||||
def test_repair_handles_windows_paths():
|
||||
import json
|
||||
|
||||
# Uses path segments whose first char isn't a valid JSON escape letter
|
||||
# (b/f/n/r/t/u), where the repair is unambiguous.
|
||||
broken = r'{"path": "C:\Windows\System32\app.exe"}'
|
||||
assert json.loads(_repair_invalid_json_escapes(broken)) == {"path": r"C:\Windows\System32\app.exe"}
|
||||
|
||||
|
||||
def test_repair_handles_trailing_backslash():
|
||||
# A lone trailing backslash must be escaped, not dropped.
|
||||
assert _repair_invalid_json_escapes("abc\\") == "abc\\\\"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# strict_schema forced-tool path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_schema_uses_forced_function_tool():
|
||||
llm = build_llm()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
tool_call = LLMToolCall(id="call-1", name="structured_response", arguments={"fact": "the sky is blue"})
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = response
|
||||
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
|
||||
mock_parse.return_value = (None, [tool_call])
|
||||
result = await llm.call(
|
||||
messages=[{"role": "user", "content": "The sky is blue"}],
|
||||
response_format=_Fact,
|
||||
strict_schema=True,
|
||||
max_retries=0,
|
||||
)
|
||||
sent_payload = mock_post.call_args.kwargs["json"]
|
||||
|
||||
# Forced tool wired into the request payload.
|
||||
assert sent_payload["tool_choice"] == {"type": "function", "name": "structured_response"}
|
||||
assert len(sent_payload["tools"]) == 1
|
||||
assert sent_payload["tools"][0]["name"] == "structured_response"
|
||||
assert sent_payload["parallel_tool_calls"] is False
|
||||
# No prompt-injected schema in the instructions.
|
||||
assert "You must respond with valid JSON" not in sent_payload["instructions"]
|
||||
|
||||
assert isinstance(result, _Fact)
|
||||
assert result.fact == "the sky is blue"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_schema_skip_validation_returns_dict():
|
||||
llm = build_llm()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
tool_call = LLMToolCall(id="c", name="structured_response", arguments={"fact": "x"})
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = response
|
||||
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
|
||||
mock_parse.return_value = (None, [tool_call])
|
||||
result = await llm.call(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
response_format=_Fact,
|
||||
strict_schema=True,
|
||||
skip_validation=True,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
assert result == {"fact": "x"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strict_schema_retries_when_forced_tool_missing():
|
||||
llm = build_llm()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = response
|
||||
# Model returns no tool call at all — should raise after retries exhausted.
|
||||
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
|
||||
mock_parse.return_value = ("some prose", [])
|
||||
with pytest.raises(RuntimeError, match="structured_response"):
|
||||
await llm.call(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
response_format=_Fact,
|
||||
strict_schema=True,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-strict fallback: escape repair keeps the retry storm from happening
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_strict_repairs_invalid_escapes_without_retrying():
|
||||
llm = build_llm()
|
||||
response = MagicMock()
|
||||
response.raise_for_status.return_value = None
|
||||
# Escape-heavy content the model would emit as invalid JSON.
|
||||
escape_heavy = r'{"fact": "run rig-control \d serial \s command"}'
|
||||
|
||||
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = response
|
||||
with patch.object(llm, "_parse_sse_stream", new_callable=AsyncMock) as mock_parse:
|
||||
mock_parse.return_value = escape_heavy
|
||||
result = await llm.call(
|
||||
messages=[{"role": "user", "content": "coding transcript"}],
|
||||
response_format=_Fact,
|
||||
strict_schema=False,
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
# Parsed on the first attempt (no retry storm): the SSE stream was read once.
|
||||
assert mock_post.await_count == 1
|
||||
assert isinstance(result, _Fact)
|
||||
assert result.fact == r"run rig-control \d serial \s command"
|
||||
@@ -38,14 +38,12 @@
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.5",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
@@ -55,8 +53,6 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cron-parser": "^5.6.1",
|
||||
"cronstrue": "^3.21.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
|
||||
@@ -8,6 +8,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank
|
||||
const { searchParams } = new URL(request.url);
|
||||
const tags = searchParams.getAll("tags");
|
||||
const tagsMatch = searchParams.get("tags_match");
|
||||
const limit = searchParams.get("limit");
|
||||
const offset = searchParams.get("offset");
|
||||
|
||||
if (!bankId) {
|
||||
return NextResponse.json(
|
||||
@@ -26,6 +28,12 @@ export async function GET(request: Request, { params }: { params: Promise<{ bank
|
||||
if (tagsMatch) {
|
||||
queryParams.append("tags_match", tagsMatch);
|
||||
}
|
||||
if (limit) {
|
||||
queryParams.append("limit", limit);
|
||||
}
|
||||
if (offset) {
|
||||
queryParams.append("offset", offset);
|
||||
}
|
||||
|
||||
const url = dataplaneBankUrl(
|
||||
bankId,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useRef, useEffect, useCallback, useMemo, useState } from "react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { prepare, layout, prepareWithSegments, layoutWithLines } from "@chenglou/pretext";
|
||||
import type { GraphData, GraphNode, GraphLink } from "./graph-2d";
|
||||
import type { GraphData, GraphNode, GraphLink } from "./graph-data";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
|
||||
@@ -16,11 +16,9 @@ import {
|
||||
ChevronsRight,
|
||||
Settings2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RefreshCw,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Network,
|
||||
List,
|
||||
Search,
|
||||
Layers,
|
||||
@@ -33,8 +31,6 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
@@ -45,14 +41,14 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import { MemoryDetailPanel } from "./memory-detail-panel";
|
||||
import { MemoryDetailModal } from "./memory-detail-modal";
|
||||
import { Graph2D, convertHindsightGraphData, GraphNode } from "./graph-2d";
|
||||
import { convertHindsightGraphData, GraphNode } from "./graph-data";
|
||||
import { Constellation } from "./constellation";
|
||||
import { TagFilterInput } from "./tag-filter-input";
|
||||
import { ObservationScopeFilter, ObservationScope } from "./observation-scope-filter";
|
||||
import { ScatterChart, Plus, FileText } from "lucide-react";
|
||||
|
||||
type FactType = "world" | "experience" | "observation";
|
||||
type ViewMode = "graph" | "table" | "timeline" | "constellation";
|
||||
type ViewMode = "table" | "timeline" | "constellation";
|
||||
|
||||
// Categorical palette for coloring observation scopes (exact tag sets) when
|
||||
// "Group by scope" clusters the constellation. Distinct, reasonably separable hues.
|
||||
@@ -132,9 +128,7 @@ export function DataView({
|
||||
last_consolidated_at: string | null;
|
||||
} | null>(null);
|
||||
|
||||
// Graph controls state
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [maxNodes, setMaxNodes] = useState<number | undefined>(undefined);
|
||||
// Constellation controls state
|
||||
const [showControlPanel, setShowControlPanel] = useState(true);
|
||||
const [visibleLinkTypes, setVisibleLinkTypes] = useState<Set<string>>(
|
||||
new Set(["semantic", "temporal", "entity", "causal"])
|
||||
@@ -239,7 +233,7 @@ export function DataView({
|
||||
return "semantic";
|
||||
};
|
||||
|
||||
// Convert data for Graph2D (graph data is already filtered server-side)
|
||||
// Convert data for the constellation (graph data is already filtered server-side)
|
||||
const graph2DData = useMemo(() => {
|
||||
if (!data) return { nodes: [], links: [] };
|
||||
const fullData = convertHindsightGraphData(data);
|
||||
@@ -253,34 +247,6 @@ export function DataView({
|
||||
return { nodes: fullData.nodes, links };
|
||||
}, [data, visibleLinkTypes]);
|
||||
|
||||
// Calculate link stats for display
|
||||
const linkStats = useMemo(() => {
|
||||
let semantic = 0,
|
||||
temporal = 0,
|
||||
entity = 0,
|
||||
causal = 0,
|
||||
total = 0;
|
||||
const otherTypes: Record<string, number> = {};
|
||||
graph2DData.links.forEach((l) => {
|
||||
total++;
|
||||
const type = l.type || "unknown";
|
||||
if (type === "semantic") semantic++;
|
||||
else if (type === "temporal") temporal++;
|
||||
else if (type === "entity") entity++;
|
||||
else if (
|
||||
type === "causes" ||
|
||||
type === "caused_by" ||
|
||||
type === "enables" ||
|
||||
type === "prevents"
|
||||
)
|
||||
causal++;
|
||||
else {
|
||||
otherTypes[type] = (otherTypes[type] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return { semantic, temporal, entity, causal, total, otherTypes };
|
||||
}, [graph2DData]);
|
||||
|
||||
// Handle node click in graph - show in panel
|
||||
const handleGraphNodeClick = useCallback(
|
||||
(node: GraphNode) => {
|
||||
@@ -509,19 +475,6 @@ export function DataView({
|
||||
return () => clearInterval(id);
|
||||
}, [isConsolidating, currentBank]);
|
||||
|
||||
// Enforce 50 node limit to prevent UI instability, default to 20 or max whichever is smaller
|
||||
useEffect(() => {
|
||||
if (data && maxNodes === undefined) {
|
||||
if (graph2DData.nodes.length > 50) {
|
||||
// Always set maxNodes to 20 when we have >50 nodes (never leave as undefined)
|
||||
setMaxNodes(20);
|
||||
} else if (graph2DData.nodes.length > 20) {
|
||||
setMaxNodes(20);
|
||||
}
|
||||
// If ≤20 nodes, leave maxNodes undefined to show all
|
||||
}
|
||||
}, [data, graph2DData.nodes.length, maxNodes]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{loading && !data ? (
|
||||
@@ -734,17 +687,6 @@ export function DataView({
|
||||
<ScatterChart className="w-4 h-4" />
|
||||
{t("constellation")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("graph")}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||||
viewMode === "graph"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Network className="w-4 h-4" />
|
||||
{t("graph")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode("table")}
|
||||
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all flex items-center gap-1.5 ${
|
||||
@@ -771,241 +713,6 @@ export function DataView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compactMode && viewMode === "graph" && (
|
||||
<div className="flex gap-0">
|
||||
{/* Graph */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Graph2D
|
||||
data={graph2DData}
|
||||
height={700}
|
||||
showLabels={showLabels}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
maxNodes={maxNodes}
|
||||
nodeColorFn={nodeColorFn}
|
||||
linkColorFn={linkColorFn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Toggle Button */}
|
||||
<button
|
||||
onClick={() => setShowControlPanel(!showControlPanel)}
|
||||
className="flex-shrink-0 w-5 h-[700px] bg-transparent hover:bg-muted/50 flex items-center justify-center transition-colors"
|
||||
title={showControlPanel ? t("hidePanel") : t("showPanel")}
|
||||
>
|
||||
{showControlPanel ? (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground/60" />
|
||||
) : (
|
||||
<ChevronLeft className="w-3 h-3 text-muted-foreground/60" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Right Panel - Legend/Controls OR Memory Details */}
|
||||
<div
|
||||
className={`${showControlPanel ? "w-80" : "w-0"} transition-all duration-300 overflow-hidden flex-shrink-0`}
|
||||
>
|
||||
<div className="w-80 h-[700px] bg-card border-l border-border overflow-y-auto">
|
||||
{selectedGraphNode ? (
|
||||
/* Memory Detail View */
|
||||
<MemoryDetailPanel
|
||||
memory={selectedGraphNode}
|
||||
onClose={() => setSelectedGraphNode(null)}
|
||||
inPanel
|
||||
bankId={currentBank || undefined}
|
||||
/>
|
||||
) : (
|
||||
/* Legend & Controls View */
|
||||
<div className="p-4 space-y-5">
|
||||
{/* Legend & Stats */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-foreground">
|
||||
{t("graphTitle")}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{/* Nodes */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: "#0074d9" }}
|
||||
/>
|
||||
<span className="text-foreground">{t("nodes")}</span>
|
||||
</div>
|
||||
<span className="font-mono text-foreground">
|
||||
{Math.min(
|
||||
maxNodes ?? graph2DData.nodes.length,
|
||||
graph2DData.nodes.length
|
||||
)}
|
||||
/{graph2DData.nodes.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-xs font-medium text-muted-foreground mt-2 mb-1">
|
||||
{t("linksWithCount", { count: linkStats.total })}{" "}
|
||||
<span className="text-muted-foreground/60">{t("clickToFilter")}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleLinkType("semantic")}
|
||||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||||
visibleLinkTypes.has("semantic")
|
||||
? "hover:bg-muted"
|
||||
: "opacity-40 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#0074d9]" />
|
||||
<span className="text-foreground">{t("semantic")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono ${linkStats.semantic === 0 ? "text-destructive" : "text-foreground"}`}
|
||||
>
|
||||
{linkStats.semantic}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleLinkType("temporal")}
|
||||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||||
visibleLinkTypes.has("temporal")
|
||||
? "hover:bg-muted"
|
||||
: "opacity-40 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#009296]" />
|
||||
<span className="text-foreground">{t("temporal")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono ${linkStats.temporal === 0 ? "text-destructive" : "text-foreground"}`}
|
||||
>
|
||||
{linkStats.temporal}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleLinkType("entity")}
|
||||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||||
visibleLinkTypes.has("entity")
|
||||
? "hover:bg-muted"
|
||||
: "opacity-40 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#f59e0b]" />
|
||||
<span className="text-foreground">{t("entity")}</span>
|
||||
</div>
|
||||
<span className="font-mono text-foreground">{linkStats.entity}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleLinkType("causal")}
|
||||
className={`w-full flex items-center justify-between text-sm px-2 py-1 rounded transition-all ${
|
||||
visibleLinkTypes.has("causal")
|
||||
? "hover:bg-muted"
|
||||
: "opacity-40 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-0.5 bg-[#8b5cf6]" />
|
||||
<span className="text-foreground">{t("causal")}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono ${linkStats.causal === 0 ? "text-muted-foreground" : "text-foreground"}`}
|
||||
>
|
||||
{linkStats.causal}
|
||||
</span>
|
||||
</button>
|
||||
{Object.entries(linkStats.otherTypes || {}).map(([type, count]) => (
|
||||
<div key={type} className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground capitalize ml-6">{type}</span>
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{count as number}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Controls Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-foreground">
|
||||
{t("displayTitle")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-labels" className="text-sm text-foreground">
|
||||
{t("showLabels")}
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-labels"
|
||||
checked={showLabels}
|
||||
onCheckedChange={setShowLabels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Limits Section */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3 text-foreground">
|
||||
{t("performanceTitle")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-sm text-foreground">{t("maxNodes")}</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{graph2DData.nodes.length > 50
|
||||
? `${maxNodes ?? 50} / ${graph2DData.nodes.length}`
|
||||
: `${maxNodes ?? "All"} / ${graph2DData.nodes.length}`}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[
|
||||
graph2DData.nodes.length > 50
|
||||
? maxNodes || 20
|
||||
: maxNodes || Math.min(graph2DData.nodes.length, 20),
|
||||
]}
|
||||
min={10}
|
||||
max={Math.min(Math.max(graph2DData.nodes.length, 10), 50)}
|
||||
step={10}
|
||||
onValueChange={([v]) => {
|
||||
const effectiveMax = Math.min(graph2DData.nodes.length, 50);
|
||||
// If we have >50 nodes, never allow "All" (undefined), cap at 50
|
||||
if (graph2DData.nodes.length > 50) {
|
||||
setMaxNodes(v);
|
||||
} else {
|
||||
// Original behavior for ≤50 nodes: allow "All" when slider reaches max
|
||||
setMaxNodes(v >= effectiveMax ? undefined : v);
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("allLinksVisible")}
|
||||
{graph2DData.nodes.length > 50 && (
|
||||
<span className="block text-amber-600 dark:text-amber-400 mt-1">
|
||||
{t("limitedTo50Nodes", { count: graph2DData.nodes.length })}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
{/* Hint */}
|
||||
<div className="text-xs text-muted-foreground/60 text-center pt-2">
|
||||
{t("clickNodeForDetails")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(compactMode || viewMode === "constellation") && (
|
||||
<div className="flex gap-0">
|
||||
<div className="flex-1 min-w-0 border border-border rounded-lg overflow-hidden">
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Constellation } from "./constellation";
|
||||
import { convertHindsightGraphData, GraphNode } from "./graph-2d";
|
||||
import { convertHindsightGraphData, GraphNode } from "./graph-data";
|
||||
|
||||
type EntityGraphResponse = Awaited<ReturnType<typeof client.getEntityGraph>>;
|
||||
|
||||
|
||||
@@ -1,726 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import cytoscape from "cytoscape";
|
||||
|
||||
import fcose from "cytoscape-fcose";
|
||||
|
||||
// Register the fcose extension
|
||||
cytoscape.use(fcose);
|
||||
|
||||
// Hook to detect dark mode
|
||||
function useIsDarkMode() {
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkDark = () => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"));
|
||||
};
|
||||
|
||||
checkDark();
|
||||
|
||||
// Watch for theme changes
|
||||
const observer = new MutationObserver(checkDark);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDark;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Types & Interfaces
|
||||
// ============================================================================
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
group?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
color?: string;
|
||||
width?: number;
|
||||
type?: string;
|
||||
entity?: string;
|
||||
weight?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
export interface Graph2DProps {
|
||||
data: GraphData;
|
||||
height?: number;
|
||||
showLabels?: boolean;
|
||||
onNodeClick?: (node: GraphNode) => void;
|
||||
onNodeHover?: (node: GraphNode | null) => void;
|
||||
nodeColorFn?: (node: GraphNode) => string;
|
||||
nodeSizeFn?: (node: GraphNode) => number;
|
||||
linkColorFn?: (link: GraphLink) => string;
|
||||
linkWidthFn?: (link: GraphLink) => number;
|
||||
maxNodes?: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Default Values
|
||||
// ============================================================================
|
||||
|
||||
// Brand colors
|
||||
const BRAND_PRIMARY = "#0074d9";
|
||||
const LINK_SEMANTIC = "#0074d9"; // Primary blue for semantic
|
||||
|
||||
const DEFAULT_NODE_COLOR = BRAND_PRIMARY;
|
||||
const DEFAULT_LINK_COLOR = LINK_SEMANTIC;
|
||||
const DEFAULT_LINK_WIDTH = 1;
|
||||
|
||||
// ============================================================================
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
export function Graph2D({
|
||||
data,
|
||||
height = 600,
|
||||
showLabels = true,
|
||||
onNodeClick,
|
||||
onNodeHover,
|
||||
nodeColorFn,
|
||||
nodeSizeFn,
|
||||
linkColorFn,
|
||||
linkWidthFn,
|
||||
maxNodes,
|
||||
}: Graph2DProps) {
|
||||
const t = useTranslations("graph2d");
|
||||
const [containerDiv, setContainerDiv] = useState<HTMLDivElement | null>(null);
|
||||
const cyRef = useRef<any>(null);
|
||||
const isInitializingRef = useRef(false);
|
||||
const lastDataSignatureRef = useRef<string>("");
|
||||
const [_hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
|
||||
const [hoveredLink, setHoveredLink] = useState<GraphLink | null>(null);
|
||||
const [linkTooltipPos, setLinkTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isFocusMode, setIsFocusMode] = useState(false);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// Use refs to store callbacks and data to prevent re-renders from resetting the graph
|
||||
const onNodeClickRef = useRef(onNodeClick);
|
||||
const onNodeHoverRef = useRef(onNodeHover);
|
||||
const fullDataRef = useRef(data);
|
||||
const nodeColorFnRef = useRef(nodeColorFn);
|
||||
const linkColorFnRef = useRef(linkColorFn);
|
||||
const isFocusModeRef = useRef(isFocusMode);
|
||||
onNodeClickRef.current = onNodeClick;
|
||||
onNodeHoverRef.current = onNodeHover;
|
||||
fullDataRef.current = data;
|
||||
nodeColorFnRef.current = nodeColorFn;
|
||||
linkColorFnRef.current = linkColorFn;
|
||||
isFocusModeRef.current = isFocusMode;
|
||||
|
||||
// Transform and limit data - only limit nodes, show ALL links between visible nodes
|
||||
const graphData = useMemo(() => {
|
||||
let nodes = [...data.nodes];
|
||||
|
||||
// Limit nodes if needed
|
||||
if (maxNodes && nodes.length > maxNodes) {
|
||||
nodes = nodes.slice(0, maxNodes);
|
||||
}
|
||||
|
||||
// Show ALL links between visible nodes (no random link limiting)
|
||||
const nodeIds = new Set(nodes.map((n) => n.id));
|
||||
const links = data.links.filter((l) => nodeIds.has(l.source) && nodeIds.has(l.target));
|
||||
|
||||
return { nodes, links };
|
||||
}, [data, maxNodes]);
|
||||
|
||||
// Track mounting state
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
return () => setIsMounted(false);
|
||||
}, []);
|
||||
|
||||
// Convert to Cytoscape format
|
||||
const cyElements = useMemo(() => {
|
||||
// Calculate node importance based on connections
|
||||
const nodeConnections = new Map<string, number>();
|
||||
graphData.links.forEach((link) => {
|
||||
nodeConnections.set(link.source, (nodeConnections.get(link.source) || 0) + 1);
|
||||
nodeConnections.set(link.target, (nodeConnections.get(link.target) || 0) + 1);
|
||||
});
|
||||
|
||||
const nodes = graphData.nodes.map((node) => {
|
||||
const connections = nodeConnections.get(node.id) || 0;
|
||||
const dynamicSize = nodeSizeFn
|
||||
? nodeSizeFn(node)
|
||||
: Math.max(16, Math.min(40, 16 + connections * 4)); // Smaller, more subtle sizing
|
||||
|
||||
return {
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.label || node.id.substring(0, 8),
|
||||
color: nodeColorFn ? nodeColorFn(node) : node.color || DEFAULT_NODE_COLOR,
|
||||
size: node.size || dynamicSize,
|
||||
originalNode: node,
|
||||
connections: connections,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const edges = graphData.links.map((link, idx) => ({
|
||||
data: {
|
||||
id: `edge-${idx}`,
|
||||
source: link.source,
|
||||
target: link.target,
|
||||
color: linkColorFn ? linkColorFn(link) : link.color || DEFAULT_LINK_COLOR,
|
||||
width: linkWidthFn ? linkWidthFn(link) : link.width || DEFAULT_LINK_WIDTH,
|
||||
type: link.type,
|
||||
entity: link.entity,
|
||||
weight: link.weight,
|
||||
originalLink: link,
|
||||
},
|
||||
}));
|
||||
|
||||
return [...nodes, ...edges];
|
||||
}, [graphData, nodeColorFn, nodeSizeFn, linkColorFn, linkWidthFn]);
|
||||
|
||||
// Create data signature to prevent double initialization
|
||||
const dataSignature = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
nodeCount: graphData.nodes.length,
|
||||
linkCount: graphData.links.length,
|
||||
nodeIds: graphData.nodes
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join(","),
|
||||
showLabels,
|
||||
isDarkMode,
|
||||
maxNodes,
|
||||
});
|
||||
}, [graphData.nodes, graphData.links, showLabels, isDarkMode, maxNodes]);
|
||||
|
||||
// Initialize Cytoscape
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
// Small delay to ensure container is mounted
|
||||
const timeout = setTimeout(() => {
|
||||
if (isCancelled || !isMounted || !containerDiv || isInitializingRef.current) return;
|
||||
|
||||
// Check if data has actually changed to prevent double initialization
|
||||
if (lastDataSignatureRef.current === dataSignature) {
|
||||
console.log("Data signature unchanged, skipping graph initialization");
|
||||
return;
|
||||
}
|
||||
|
||||
// Additional validation - check if element has dimensions
|
||||
const rect = containerDiv.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) {
|
||||
console.warn("Container has no dimensions, skipping cytoscape initialization");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle empty data case
|
||||
if (cyElements.length === 0) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we already have a graph with the same data
|
||||
if (cyRef.current && !cyRef.current.destroyed()) {
|
||||
const currentNodes = cyRef.current.nodes().length;
|
||||
const currentEdges = cyRef.current.edges().length;
|
||||
const newNodes = cyElements.filter((el) => !(el.data as any).source).length;
|
||||
const newEdges = cyElements.filter((el) => (el.data as any).source).length;
|
||||
|
||||
// If the element counts are the same, just update styles and skip reinitialization
|
||||
if (currentNodes === newNodes && currentEdges === newEdges) {
|
||||
console.log("Graph already initialized with same data, skipping reinitialization");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clean up existing graph before creating new one
|
||||
console.log("Data changed, destroying existing graph");
|
||||
cyRef.current.destroy();
|
||||
cyRef.current = null;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
isInitializingRef.current = true;
|
||||
|
||||
// Theme-aware colors
|
||||
const textColor = isDarkMode ? "#ffffff" : "#1f2937";
|
||||
const textBgColor = isDarkMode ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.9)";
|
||||
|
||||
try {
|
||||
console.log("Initializing cytoscape with container:", containerDiv);
|
||||
console.log("Elements count:", cyElements.length);
|
||||
console.log("Sample elements:", cyElements.slice(0, 2));
|
||||
|
||||
// Try minimal initialization first
|
||||
const cy = cytoscape({
|
||||
container: containerDiv,
|
||||
elements: [],
|
||||
// Disable edge selection to prevent gray border on click
|
||||
selectionType: "single",
|
||||
userZoomingEnabled: true,
|
||||
userPanningEnabled: true,
|
||||
boxSelectionEnabled: false,
|
||||
// Disable automatic layout on initialization
|
||||
layout: { name: "preset" },
|
||||
style: [
|
||||
{
|
||||
selector: "node",
|
||||
style: {
|
||||
"background-color": "data(color)",
|
||||
width: "data(size)",
|
||||
height: "data(size)",
|
||||
label: showLabels ? "data(label)" : "",
|
||||
color: textColor,
|
||||
"text-valign": "bottom",
|
||||
"text-halign": "center",
|
||||
"font-size": "8px",
|
||||
"font-weight": 500,
|
||||
"text-margin-y": 3,
|
||||
"text-wrap": "wrap",
|
||||
"text-max-width": "80px",
|
||||
"text-background-color": textBgColor,
|
||||
"text-background-opacity": 0.9,
|
||||
"text-background-padding": "2px",
|
||||
"text-background-shape": "roundrectangle",
|
||||
"border-width": 1,
|
||||
"border-color": isDarkMode ? "#ffffff20" : "#00000020",
|
||||
"border-opacity": 0.3,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "node:selected",
|
||||
style: {
|
||||
"border-width": 3,
|
||||
"border-color": "#0074d9",
|
||||
"border-opacity": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge",
|
||||
style: {
|
||||
width: "data(width)",
|
||||
"line-color": "data(color)",
|
||||
"target-arrow-color": "data(color)",
|
||||
"target-arrow-shape": "triangle",
|
||||
"target-arrow-size": 6,
|
||||
"curve-style": "bezier",
|
||||
opacity: isDarkMode ? 0.6 : 0.7,
|
||||
},
|
||||
},
|
||||
// Focus mode styles
|
||||
{
|
||||
selector: ".dimmed",
|
||||
style: {
|
||||
opacity: 0.2,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: ".focused",
|
||||
style: {
|
||||
"border-width": 4,
|
||||
"border-color": "#ff6b35",
|
||||
"border-opacity": 1,
|
||||
"z-index": 999,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: ".connected",
|
||||
style: {
|
||||
"border-width": 2,
|
||||
"border-color": "#0074d9",
|
||||
"border-opacity": 0.8,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge.connection",
|
||||
style: {
|
||||
width: 2,
|
||||
opacity: 1,
|
||||
"z-index": 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
selector: "edge.connection:hover",
|
||||
style: {
|
||||
width: 3,
|
||||
opacity: 1,
|
||||
"z-index": 200,
|
||||
},
|
||||
},
|
||||
// Disable edge selection styling
|
||||
{
|
||||
selector: "edge:selected",
|
||||
style: {
|
||||
"overlay-opacity": 0,
|
||||
"overlay-color": "transparent",
|
||||
"overlay-padding": 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
cyRef.current = cy;
|
||||
|
||||
console.log("Cytoscape initialized successfully");
|
||||
|
||||
// Add elements after initialization
|
||||
if (cyElements.length > 0) {
|
||||
console.log("Adding elements to cytoscape");
|
||||
cy.add(cyElements);
|
||||
cy.layout({
|
||||
name: "fcose",
|
||||
quality: "default",
|
||||
randomize: false,
|
||||
animate: true,
|
||||
animationDuration: 1500,
|
||||
// Separation settings - increase to spread nodes more
|
||||
nodeSeparation: 200,
|
||||
idealEdgeLength: () => 250,
|
||||
edgeElasticity: () => 0.05,
|
||||
nestingFactor: 0.05,
|
||||
gravity: 0.05, // Reduced gravity spreads nodes more
|
||||
numIter: 2500,
|
||||
// Overlap prevention
|
||||
nodeOverlap: 30,
|
||||
avoidOverlap: true,
|
||||
nodeDimensionsIncludeLabels: true,
|
||||
// Layout bounds - reduce padding to use more space
|
||||
padding: 20,
|
||||
boundingBox: undefined,
|
||||
// Tiling - increase spacing between disconnected components
|
||||
tile: true,
|
||||
tilingPaddingVertical: 30,
|
||||
tilingPaddingHorizontal: 30,
|
||||
// Force more spread
|
||||
uniformNodeDimensions: false,
|
||||
packComponents: false, // Don't pack components tightly
|
||||
}).run();
|
||||
|
||||
// Fit to viewport
|
||||
cy.fit();
|
||||
}
|
||||
|
||||
// Add basic interactions
|
||||
cy.on("tap", "node", (evt: any) => {
|
||||
const node = evt.target as cytoscape.NodeSingular;
|
||||
const originalNode = node.data("originalNode") as GraphNode;
|
||||
if (onNodeClickRef.current && originalNode) {
|
||||
onNodeClickRef.current(originalNode);
|
||||
}
|
||||
});
|
||||
|
||||
cy.on("mouseover", "node", (evt: any) => {
|
||||
const node = evt.target as cytoscape.NodeSingular;
|
||||
const originalNode = node.data("originalNode") as GraphNode;
|
||||
setHoveredNode(originalNode);
|
||||
if (onNodeHoverRef.current && originalNode) {
|
||||
onNodeHoverRef.current(originalNode);
|
||||
}
|
||||
if (containerDiv) containerDiv.style.cursor = "pointer";
|
||||
});
|
||||
|
||||
cy.on("mouseout", "node", () => {
|
||||
setHoveredNode(null);
|
||||
if (onNodeHoverRef.current) {
|
||||
onNodeHoverRef.current(null);
|
||||
}
|
||||
if (containerDiv) containerDiv.style.cursor = "default";
|
||||
});
|
||||
|
||||
// Edge hover handlers - only work in focus mode and on highlighted edges
|
||||
cy.on("mouseover", "edge", (evt: any) => {
|
||||
const edge = evt.target;
|
||||
|
||||
// Only allow interaction if we're in focus mode and edge is highlighted
|
||||
if (!isFocusModeRef.current || !edge.hasClass("connection")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalLink = edge.data("originalLink") as GraphLink;
|
||||
if (originalLink) {
|
||||
setHoveredLink(originalLink);
|
||||
// Get position for tooltip
|
||||
const renderedPos = edge.renderedMidpoint();
|
||||
setLinkTooltipPos({ x: renderedPos.x, y: renderedPos.y });
|
||||
}
|
||||
});
|
||||
|
||||
cy.on("mouseout", "edge", (evt: any) => {
|
||||
const edge = evt.target;
|
||||
|
||||
// Only clear hover state if we were actually hovering a highlighted edge
|
||||
if (!isFocusModeRef.current || !edge.hasClass("connection")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setHoveredLink(null);
|
||||
setLinkTooltipPos(null);
|
||||
});
|
||||
|
||||
// Prevent edge selection to avoid gray border on click
|
||||
cy.on("select", "edge", (evt: any) => {
|
||||
evt.target.unselect();
|
||||
});
|
||||
|
||||
// Double-click to focus on node and its connections
|
||||
cy.on("dblclick", "node", (evt: any) => {
|
||||
const focusedNode = evt.target as cytoscape.NodeSingular;
|
||||
const focusedNodeId = focusedNode.id();
|
||||
|
||||
console.log("Double-clicked node:", focusedNodeId);
|
||||
|
||||
// Enter focus mode
|
||||
setIsFocusMode(true);
|
||||
|
||||
// Clear any existing focus classes
|
||||
cy.elements().removeClass("dimmed focused connected connection");
|
||||
|
||||
// Get all connected nodes and edges
|
||||
const connectedElements = focusedNode.neighborhood();
|
||||
const connectedNodes = connectedElements.nodes();
|
||||
const connectedEdges = connectedElements.edges();
|
||||
|
||||
// Apply styling classes
|
||||
cy.elements().addClass("dimmed"); // Dim everything first
|
||||
focusedNode.removeClass("dimmed").addClass("focused"); // Highlight the focused node
|
||||
connectedNodes.removeClass("dimmed").addClass("connected"); // Highlight connected nodes
|
||||
connectedEdges.removeClass("dimmed").addClass("connection"); // Highlight connecting edges
|
||||
|
||||
// Create a collection of all relevant elements for positioning
|
||||
const relevantElements = focusedNode.union(connectedElements);
|
||||
|
||||
// Reorient the graph to focus on this subgraph
|
||||
cy.animate(
|
||||
{
|
||||
fit: {
|
||||
eles: relevantElements,
|
||||
padding: 100,
|
||||
},
|
||||
center: {
|
||||
eles: focusedNode,
|
||||
},
|
||||
},
|
||||
{
|
||||
duration: 800,
|
||||
easing: "ease-out-cubic",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Click on background to reset focus
|
||||
cy.on("tap", (evt: any) => {
|
||||
if (evt.target === cy) {
|
||||
console.log("Clicked background - resetting focus");
|
||||
|
||||
// Exit focus mode
|
||||
setIsFocusMode(false);
|
||||
|
||||
// Remove all focus classes
|
||||
cy.elements().removeClass("dimmed focused connected connection");
|
||||
|
||||
// Zoom out to show all elements
|
||||
cy.animate(
|
||||
{
|
||||
fit: {
|
||||
eles: cy.elements(),
|
||||
padding: 50,
|
||||
},
|
||||
},
|
||||
{
|
||||
duration: 600,
|
||||
easing: "ease-out",
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
isInitializingRef.current = false;
|
||||
lastDataSignatureRef.current = dataSignature;
|
||||
} catch (error) {
|
||||
console.error("Error initializing cytoscape:", error);
|
||||
setIsLoading(false);
|
||||
isInitializingRef.current = false;
|
||||
}
|
||||
}, 100); // 100ms delay
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
clearTimeout(timeout);
|
||||
isInitializingRef.current = false;
|
||||
if (cyRef.current) {
|
||||
cyRef.current.destroy();
|
||||
cyRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [dataSignature, isMounted, containerDiv]);
|
||||
|
||||
// Handle resize
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (cyRef.current) {
|
||||
cyRef.current.resize();
|
||||
cyRef.current.fit(undefined, 80);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full rounded-lg overflow-hidden border border-border"
|
||||
style={{ height }}
|
||||
>
|
||||
{/* Loading state */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background z-10">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4" />
|
||||
<p className="text-sm text-muted-foreground">{t("loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cytoscape container */}
|
||||
{isMounted && (
|
||||
<div
|
||||
ref={setContainerDiv}
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: isDarkMode
|
||||
? "radial-gradient(circle at 1px 1px, rgba(255,255,255,0.08) 1px, transparent 0)"
|
||||
: "radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)",
|
||||
backgroundSize: "20px 20px",
|
||||
backgroundColor: isDarkMode ? "#0f1419" : "#f8fafc",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && graphData.nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground">{t("emptyState")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link hover tooltip */}
|
||||
{hoveredLink && linkTooltipPos && (
|
||||
<div
|
||||
className="absolute z-30 pointer-events-none"
|
||||
style={{
|
||||
left: linkTooltipPos.x,
|
||||
top: linkTooltipPos.y,
|
||||
transform: "translate(-50%, -100%) translateY(-8px)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`px-3 py-2 rounded-lg shadow-lg text-sm ${
|
||||
isDarkMode
|
||||
? "bg-gray-800 text-white"
|
||||
: "bg-white text-gray-900 border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium capitalize mb-1">
|
||||
{(() => {
|
||||
const type = hoveredLink.type || "semantic";
|
||||
if (["causes", "caused_by", "enables", "prevents"].includes(type)) {
|
||||
return t("linkTypeCausal", { type: type.replace("_", " ") });
|
||||
}
|
||||
return t("linkTypeGeneric", { type });
|
||||
})()}
|
||||
</div>
|
||||
{hoveredLink.entity && (
|
||||
<div className="text-xs opacity-80">
|
||||
{t("linkTooltipEntity")} <span className="font-medium">{hoveredLink.entity}</span>
|
||||
</div>
|
||||
)}
|
||||
{hoveredLink.weight !== undefined && (
|
||||
<div className="text-xs opacity-80">
|
||||
{t("linkTooltipWeight")}{" "}
|
||||
<span className="font-medium">{hoveredLink.weight.toFixed(3)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls hint */}
|
||||
<div className="absolute bottom-4 right-4 text-xs text-muted-foreground/60 z-20">
|
||||
{t("controlsHint")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
export function convertHindsightGraphData(hindsightData: {
|
||||
nodes?: Array<{ data: { id: string; label?: string; color?: string } }>;
|
||||
edges?: Array<{
|
||||
data: {
|
||||
source: string;
|
||||
target: string;
|
||||
color?: string;
|
||||
lineStyle?: string;
|
||||
linkType?: string;
|
||||
entityName?: string;
|
||||
weight?: number;
|
||||
similarity?: number;
|
||||
};
|
||||
}>;
|
||||
table_rows?: Array<{ id: string; text: string; entities?: string; context?: string }>;
|
||||
}): GraphData {
|
||||
const nodes: GraphNode[] = (hindsightData.nodes || []).map((n) => {
|
||||
const tableRow = hindsightData.table_rows?.find((r) => r.id === n.data.id);
|
||||
// Use memory text as label, truncated to ~40 chars
|
||||
let label = n.data.label;
|
||||
if (!label && tableRow?.text) {
|
||||
label = tableRow.text.length > 40 ? tableRow.text.substring(0, 40) + "..." : tableRow.text;
|
||||
}
|
||||
if (!label) {
|
||||
label = n.data.id.substring(0, 8);
|
||||
}
|
||||
return {
|
||||
id: n.data.id,
|
||||
label,
|
||||
color: n.data.color,
|
||||
metadata: tableRow,
|
||||
};
|
||||
});
|
||||
|
||||
const links: GraphLink[] = (hindsightData.edges || []).map((e) => ({
|
||||
source: e.data.source,
|
||||
target: e.data.target,
|
||||
color: e.data.color,
|
||||
// Use linkType directly from API, fallback to lineStyle check, default to semantic
|
||||
type: e.data.linkType || (e.data.lineStyle === "dashed" ? "temporal" : "semantic"),
|
||||
entity: e.data.entityName, // API returns entityName
|
||||
weight: e.data.weight ?? e.data.similarity,
|
||||
}));
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Shared graph data model + conversion used by the memory visualizations
|
||||
// (Constellation, entities view). The Cytoscape-based "Graph" view that used to
|
||||
// live here was removed; only the framework-agnostic types and the API-response
|
||||
// converter remain, since the constellation and entity views build on them.
|
||||
|
||||
// ============================================================================
|
||||
// Types & Interfaces
|
||||
// ============================================================================
|
||||
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
size?: number;
|
||||
group?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GraphLink {
|
||||
source: string;
|
||||
target: string;
|
||||
color?: string;
|
||||
width?: number;
|
||||
type?: string;
|
||||
entity?: string;
|
||||
weight?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
links: GraphLink[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
export function convertHindsightGraphData(hindsightData: {
|
||||
nodes?: Array<{ data: { id: string; label?: string; color?: string } }>;
|
||||
edges?: Array<{
|
||||
data: {
|
||||
source: string;
|
||||
target: string;
|
||||
color?: string;
|
||||
lineStyle?: string;
|
||||
linkType?: string;
|
||||
entityName?: string;
|
||||
weight?: number;
|
||||
similarity?: number;
|
||||
};
|
||||
}>;
|
||||
table_rows?: Array<{ id: string; text: string; entities?: string; context?: string }>;
|
||||
}): GraphData {
|
||||
const nodes: GraphNode[] = (hindsightData.nodes || []).map((n) => {
|
||||
const tableRow = hindsightData.table_rows?.find((r) => r.id === n.data.id);
|
||||
// Use memory text as label, truncated to ~40 chars
|
||||
let label = n.data.label;
|
||||
if (!label && tableRow?.text) {
|
||||
label = tableRow.text.length > 40 ? tableRow.text.substring(0, 40) + "..." : tableRow.text;
|
||||
}
|
||||
if (!label) {
|
||||
label = n.data.id.substring(0, 8);
|
||||
}
|
||||
return {
|
||||
id: n.data.id,
|
||||
label,
|
||||
color: n.data.color,
|
||||
metadata: tableRow,
|
||||
};
|
||||
});
|
||||
|
||||
const links: GraphLink[] = (hindsightData.edges || []).map((e) => ({
|
||||
source: e.data.source,
|
||||
target: e.data.target,
|
||||
color: e.data.color,
|
||||
// Use linkType directly from API, fallback to lineStyle check, default to semantic
|
||||
type: e.data.linkType || (e.data.lineStyle === "dashed" ? "temporal" : "semantic"),
|
||||
entity: e.data.entityName, // API returns entityName
|
||||
weight: e.data.weight ?? e.data.similarity,
|
||||
}));
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
@@ -154,12 +154,23 @@ export function MentalModelsView() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const mentalModelsData = await client.listMentalModels(
|
||||
currentBank,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
selectedTags.length > 0 ? tagsMatch : undefined
|
||||
);
|
||||
setMentalModels(mentalModelsData.items || []);
|
||||
// The API caps each response at PAGE_SIZE, so page through until a short
|
||||
// page is returned to load every mental model for this bank.
|
||||
const PAGE_SIZE = 100;
|
||||
const all: MentalModel[] = [];
|
||||
for (let offset = 0; ; offset += PAGE_SIZE) {
|
||||
const page = await client.listMentalModels(
|
||||
currentBank,
|
||||
selectedTags.length > 0 ? selectedTags : undefined,
|
||||
selectedTags.length > 0 ? tagsMatch : undefined,
|
||||
PAGE_SIZE,
|
||||
offset
|
||||
);
|
||||
const items = page.items || [];
|
||||
all.push(...items);
|
||||
if (items.length < PAGE_SIZE) break;
|
||||
}
|
||||
setMentalModels(all);
|
||||
} catch (error) {
|
||||
console.error("Error loading mental models:", error);
|
||||
} finally {
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative flex w-full touch-none select-none items-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary/50 border border-border">
|
||||
<SliderPrimitive.Range className="absolute h-full bg-primary" />
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
@@ -1205,7 +1205,13 @@ export class ControlPlaneClient {
|
||||
/**
|
||||
* List mental models for a bank
|
||||
*/
|
||||
async listMentalModels(bankId: string, tags?: string[], tagsMatch?: string) {
|
||||
async listMentalModels(
|
||||
bankId: string,
|
||||
tags?: string[],
|
||||
tagsMatch?: string,
|
||||
limit?: number,
|
||||
offset?: number
|
||||
) {
|
||||
const params = new URLSearchParams();
|
||||
if (tags && tags.length > 0) {
|
||||
tags.forEach((t) => params.append("tags", t));
|
||||
@@ -1213,6 +1219,12 @@ export class ControlPlaneClient {
|
||||
if (tagsMatch) {
|
||||
params.append("tags_match", tagsMatch);
|
||||
}
|
||||
if (limit !== undefined) {
|
||||
params.append("limit", String(limit));
|
||||
}
|
||||
if (offset !== undefined) {
|
||||
params.append("offset", String(offset));
|
||||
}
|
||||
const query = params.toString();
|
||||
return this.fetchApi<{
|
||||
items: Array<{
|
||||
|
||||
@@ -534,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "Alle Erinnerungen konsolidiert (zuletzt: {date})",
|
||||
"pendingConsolidation": "{count} Erinnerungen stehen zur Konsolidierung aus",
|
||||
"constellation": "Konstellation",
|
||||
"graph": "Graph",
|
||||
"table": "Tabelle",
|
||||
"timeline": "Zeitleiste",
|
||||
"hidePanel": "Bereich ausblenden",
|
||||
@@ -548,20 +547,6 @@
|
||||
"linkTypes": "Verknüpfungstypen",
|
||||
"nodes": "Knoten",
|
||||
"links": "Verknüpfungen",
|
||||
"graphTitle": "Graph",
|
||||
"linksWithCount": "Verknüpfungen ({count})",
|
||||
"clickToFilter": "· klicken zum Filtern",
|
||||
"semantic": "Semantisch",
|
||||
"temporal": "Zeitlich",
|
||||
"entity": "Entität",
|
||||
"causal": "Kausal",
|
||||
"displayTitle": "Anzeige",
|
||||
"showLabels": "Beschriftungen anzeigen",
|
||||
"performanceTitle": "Leistung",
|
||||
"maxNodes": "Maximale Knoten",
|
||||
"allLinksVisible": "Alle Verknüpfungen zwischen sichtbaren Knoten werden angezeigt.",
|
||||
"limitedTo50Nodes": "⚠️ Aus Leistungsgründen auf 50 Knoten begrenzt. Gesamt: {count}",
|
||||
"clickNodeForDetails": "Auf einen Knoten klicken, um Details anzuzeigen",
|
||||
"columnObservation": "Beobachtung",
|
||||
"columnMemory": "Erinnerung",
|
||||
"columnSources": "Quellen",
|
||||
@@ -1453,15 +1438,6 @@
|
||||
"factTypeObservation": "Beobachtung",
|
||||
"actionClearContent": "Inhalt löschen"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "Diagramm wird geladen...",
|
||||
"emptyState": "Keine Erinnerungen zum Anzeigen",
|
||||
"linkTypeCausal": "Kausal ({type})",
|
||||
"linkTypeGeneric": "{type}-Verknüpfung",
|
||||
"linkTooltipEntity": "Entität:",
|
||||
"linkTooltipWeight": "Gewicht:",
|
||||
"controlsHint": "Ziehen zum Verschieben • Scrollen zum Zoomen • Doppelklick auf Knoten zum Fokussieren • Klick auf den Hintergrund zum Zurücksetzen"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "Scrollen zum Zoomen · Ziehen zum Verschieben · Hover zum Erkunden · Klicken zum Auswählen",
|
||||
"hudStats": "{memories} Erinnerungen · {visible} sichtbar · {labels} Beschriftungen · {links} Verknüpfungen · Zoom {zoom}x",
|
||||
|
||||
@@ -534,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "All memories consolidated (last: {date})",
|
||||
"pendingConsolidation": "{count} memories pending consolidation",
|
||||
"constellation": "Constellation",
|
||||
"graph": "Graph",
|
||||
"table": "Table",
|
||||
"timeline": "Timeline",
|
||||
"hidePanel": "Hide panel",
|
||||
@@ -548,20 +547,6 @@
|
||||
"linkTypes": "Link types",
|
||||
"nodes": "Nodes",
|
||||
"links": "Links",
|
||||
"graphTitle": "Graph",
|
||||
"linksWithCount": "Links ({count})",
|
||||
"clickToFilter": "· click to filter",
|
||||
"semantic": "Semantic",
|
||||
"temporal": "Temporal",
|
||||
"entity": "Entity",
|
||||
"causal": "Causal",
|
||||
"displayTitle": "Display",
|
||||
"showLabels": "Show labels",
|
||||
"performanceTitle": "Performance",
|
||||
"maxNodes": "Max nodes",
|
||||
"allLinksVisible": "All links between visible nodes are shown.",
|
||||
"limitedTo50Nodes": "⚠️ Limited to 50 nodes for performance. Total: {count}",
|
||||
"clickNodeForDetails": "Click a node to see details",
|
||||
"columnObservation": "Observation",
|
||||
"columnMemory": "Memory",
|
||||
"columnSources": "Sources",
|
||||
@@ -1453,15 +1438,6 @@
|
||||
"factTypeObservation": "observation",
|
||||
"actionClearContent": "Clear Content"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "Loading graph...",
|
||||
"emptyState": "No memories to display",
|
||||
"linkTypeCausal": "Causal ({type})",
|
||||
"linkTypeGeneric": "{type} link",
|
||||
"linkTooltipEntity": "Entity:",
|
||||
"linkTooltipWeight": "Weight:",
|
||||
"controlsHint": "Drag to pan • Scroll to zoom • Double-click node to focus • Click background to reset"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "Scroll to zoom · Drag to pan · Hover to explore · Click to select",
|
||||
"hudStats": "{memories} memories · {visible} visible · {labels} labels · {links} links · zoom {zoom}x",
|
||||
|
||||
@@ -534,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "Todas las memorias consolidadas (última: {date})",
|
||||
"pendingConsolidation": "{count} memorias pendientes de consolidación",
|
||||
"constellation": "Constelación",
|
||||
"graph": "Grafo",
|
||||
"table": "Tabla",
|
||||
"timeline": "Línea de tiempo",
|
||||
"hidePanel": "Ocultar panel",
|
||||
@@ -548,20 +547,6 @@
|
||||
"linkTypes": "Tipos de vínculo",
|
||||
"nodes": "Nodos",
|
||||
"links": "Vínculos",
|
||||
"graphTitle": "Grafo",
|
||||
"linksWithCount": "Vínculos ({count})",
|
||||
"clickToFilter": "· clic para filtrar",
|
||||
"semantic": "Semántico",
|
||||
"temporal": "Temporal",
|
||||
"entity": "Entidad",
|
||||
"causal": "Causal",
|
||||
"displayTitle": "Visualización",
|
||||
"showLabels": "Mostrar etiquetas",
|
||||
"performanceTitle": "Rendimiento",
|
||||
"maxNodes": "Nodos máximos",
|
||||
"allLinksVisible": "Se muestran todos los vínculos entre nodos visibles.",
|
||||
"limitedTo50Nodes": "⚠️ Limitado a 50 nodos por rendimiento. Total: {count}",
|
||||
"clickNodeForDetails": "Haz clic en un nodo para ver detalles",
|
||||
"columnObservation": "Observación",
|
||||
"columnMemory": "Memoria",
|
||||
"columnSources": "Fuentes",
|
||||
@@ -1453,15 +1438,6 @@
|
||||
"factTypeObservation": "observación",
|
||||
"actionClearContent": "Borrar contenido"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "Cargando gráfico...",
|
||||
"emptyState": "No hay memorias que mostrar",
|
||||
"linkTypeCausal": "Causal ({type})",
|
||||
"linkTypeGeneric": "Enlace {type}",
|
||||
"linkTooltipEntity": "Entidad:",
|
||||
"linkTooltipWeight": "Peso:",
|
||||
"controlsHint": "Arrastra para mover • Desplaza para hacer zoom • Doble clic en un nodo para enfocar • Clic en el fondo para restablecer"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "Desplaza para hacer zoom · Arrastra para mover · Pasa el cursor para explorar · Haz clic para seleccionar",
|
||||
"hudStats": "{memories} memorias · {visible} visibles · {labels} etiquetas · {links} enlaces · zoom {zoom}x",
|
||||
|
||||
@@ -534,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "Tous les souvenirs consolidés (dernier : {date})",
|
||||
"pendingConsolidation": "{count} souvenirs en attente de consolidation",
|
||||
"constellation": "Constellation",
|
||||
"graph": "Graphe",
|
||||
"table": "Tableau",
|
||||
"timeline": "Chronologie",
|
||||
"hidePanel": "Masquer le panneau",
|
||||
@@ -548,20 +547,6 @@
|
||||
"linkTypes": "Types de liens",
|
||||
"nodes": "Nœuds",
|
||||
"links": "Liens",
|
||||
"graphTitle": "Graphe",
|
||||
"linksWithCount": "Liens ({count})",
|
||||
"clickToFilter": "· cliquer pour filtrer",
|
||||
"semantic": "Sémantique",
|
||||
"temporal": "Temporel",
|
||||
"entity": "Entité",
|
||||
"causal": "Causal",
|
||||
"displayTitle": "Affichage",
|
||||
"showLabels": "Afficher les étiquettes",
|
||||
"performanceTitle": "Performance",
|
||||
"maxNodes": "Nœuds max",
|
||||
"allLinksVisible": "Tous les liens entre les nœuds visibles sont affichés.",
|
||||
"limitedTo50Nodes": "⚠️ Limité à 50 nœuds pour les performances. Total : {count}",
|
||||
"clickNodeForDetails": "Cliquez sur un nœud pour voir les détails",
|
||||
"columnObservation": "Observation",
|
||||
"columnMemory": "Souvenir",
|
||||
"columnSources": "Sources",
|
||||
@@ -1453,15 +1438,6 @@
|
||||
"factTypeObservation": "observation",
|
||||
"actionClearContent": "Effacer le contenu"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "Chargement du graphe...",
|
||||
"emptyState": "Aucun souvenir à afficher",
|
||||
"linkTypeCausal": "Causal ({type})",
|
||||
"linkTypeGeneric": "Lien {type}",
|
||||
"linkTooltipEntity": "Entité :",
|
||||
"linkTooltipWeight": "Poids :",
|
||||
"controlsHint": "Glisser pour déplacer • Défiler pour zoomer • Double-clic sur un nœud pour zoomer • Clic sur le fond pour réinitialiser"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "Défiler pour zoomer · Glisser pour déplacer · Survoler pour explorer · Cliquer pour sélectionner",
|
||||
"hudStats": "{memories} souvenirs · {visible} visibles · {labels} étiquettes · {links} liens · zoom {zoom}x",
|
||||
|
||||
@@ -534,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "すべてのメモリが統合済み(最終:{date})",
|
||||
"pendingConsolidation": "{count}件のメモリが統合待ち",
|
||||
"constellation": "コンステレーション",
|
||||
"graph": "グラフ",
|
||||
"table": "テーブル",
|
||||
"timeline": "タイムライン",
|
||||
"hidePanel": "パネルを非表示",
|
||||
@@ -548,20 +547,6 @@
|
||||
"linkTypes": "リンクの種類",
|
||||
"nodes": "ノード",
|
||||
"links": "リンク",
|
||||
"graphTitle": "グラフ",
|
||||
"linksWithCount": "リンク({count}件)",
|
||||
"clickToFilter": "・クリックでフィルター",
|
||||
"semantic": "セマンティック",
|
||||
"temporal": "時系列",
|
||||
"entity": "エンティティ",
|
||||
"causal": "因果",
|
||||
"displayTitle": "表示",
|
||||
"showLabels": "ラベルを表示",
|
||||
"performanceTitle": "パフォーマンス",
|
||||
"maxNodes": "最大ノード数",
|
||||
"allLinksVisible": "表示中のノード間のすべてのリンクが表示されています。",
|
||||
"limitedTo50Nodes": "⚠️ パフォーマンスのため50ノードに制限されています。合計:{count}",
|
||||
"clickNodeForDetails": "ノードをクリックして詳細を確認",
|
||||
"columnObservation": "観察",
|
||||
"columnMemory": "メモリ",
|
||||
"columnSources": "ソース",
|
||||
@@ -1453,15 +1438,6 @@
|
||||
"factTypeObservation": "オブザベーション",
|
||||
"actionClearContent": "内容をクリア"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "グラフを読み込み中...",
|
||||
"emptyState": "表示するメモリがありません",
|
||||
"linkTypeCausal": "因果 ({type})",
|
||||
"linkTypeGeneric": "{type} リンク",
|
||||
"linkTooltipEntity": "エンティティ:",
|
||||
"linkTooltipWeight": "ウェイト:",
|
||||
"controlsHint": "ドラッグで移動 • スクロールでズーム • ノードをダブルクリックでフォーカス • 背景をクリックでリセット"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "スクロールでズーム · ドラッグで移動 · ホバーで探索 · クリックで選択",
|
||||
"hudStats": "{memories} 件のメモリ · {visible} 件表示 · {labels} 件のラベル · {links} 件のリンク · ズーム {zoom}x",
|
||||
|
||||
@@ -534,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "모든 메모리 통합됨 (마지막: {date})",
|
||||
"pendingConsolidation": "{count}개 메모리 통합 대기 중",
|
||||
"constellation": "별자리",
|
||||
"graph": "그래프",
|
||||
"table": "표",
|
||||
"timeline": "타임라인",
|
||||
"hidePanel": "패널 숨기기",
|
||||
@@ -548,20 +547,6 @@
|
||||
"linkTypes": "링크 유형",
|
||||
"nodes": "노드",
|
||||
"links": "링크",
|
||||
"graphTitle": "그래프",
|
||||
"linksWithCount": "링크 ({count})",
|
||||
"clickToFilter": "· 클릭하여 필터링",
|
||||
"semantic": "의미적",
|
||||
"temporal": "시간적",
|
||||
"entity": "엔티티",
|
||||
"causal": "인과적",
|
||||
"displayTitle": "표시",
|
||||
"showLabels": "레이블 표시",
|
||||
"performanceTitle": "성능",
|
||||
"maxNodes": "최대 노드",
|
||||
"allLinksVisible": "표시된 노드 간의 모든 링크가 표시됩니다.",
|
||||
"limitedTo50Nodes": "⚠️ 성능을 위해 50개 노드로 제한됩니다. 전체: {count}",
|
||||
"clickNodeForDetails": "노드를 클릭하면 세부 정보를 볼 수 있습니다",
|
||||
"columnObservation": "관찰",
|
||||
"columnMemory": "메모리",
|
||||
"columnSources": "소스",
|
||||
@@ -1453,15 +1438,6 @@
|
||||
"factTypeObservation": "관찰",
|
||||
"actionClearContent": "콘텐츠 지우기"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "그래프 로딩 중...",
|
||||
"emptyState": "표시할 메모리가 없습니다",
|
||||
"linkTypeCausal": "인과 ({type})",
|
||||
"linkTypeGeneric": "{type} 링크",
|
||||
"linkTooltipEntity": "엔티티:",
|
||||
"linkTooltipWeight": "가중치:",
|
||||
"controlsHint": "드래그하여 이동 • 스크롤하여 확대/축소 • 노드 더블클릭으로 포커스 • 배경 클릭으로 초기화"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "스크롤하여 확대/축소 · 드래그하여 이동 · 호버하여 탐색 · 클릭하여 선택",
|
||||
"hudStats": "{memories}개 메모리 · {visible}개 표시 · {labels}개 레이블 · {links}개 링크 · 줌 {zoom}x",
|
||||
|
||||
@@ -534,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "Todas as memórias consolidadas (última: {date})",
|
||||
"pendingConsolidation": "{count} memórias pendentes de consolidação",
|
||||
"constellation": "Constelação",
|
||||
"graph": "Grafo",
|
||||
"table": "Tabela",
|
||||
"timeline": "Linha do Tempo",
|
||||
"hidePanel": "Ocultar painel",
|
||||
@@ -548,20 +547,6 @@
|
||||
"linkTypes": "Tipos de vínculo",
|
||||
"nodes": "Nós",
|
||||
"links": "Vínculos",
|
||||
"graphTitle": "Grafo",
|
||||
"linksWithCount": "Vínculos ({count})",
|
||||
"clickToFilter": "· clique para filtrar",
|
||||
"semantic": "Semântico",
|
||||
"temporal": "Temporal",
|
||||
"entity": "Entidade",
|
||||
"causal": "Causal",
|
||||
"displayTitle": "Exibição",
|
||||
"showLabels": "Mostrar rótulos",
|
||||
"performanceTitle": "Desempenho",
|
||||
"maxNodes": "Máximo de nós",
|
||||
"allLinksVisible": "Todos os vínculos entre os nós visíveis estão sendo exibidos.",
|
||||
"limitedTo50Nodes": "⚠️ Limitado a 50 nós por desempenho. Total: {count}",
|
||||
"clickNodeForDetails": "Clique em um nó para ver detalhes",
|
||||
"columnObservation": "Observação",
|
||||
"columnMemory": "Memória",
|
||||
"columnSources": "Fontes",
|
||||
@@ -1453,15 +1438,6 @@
|
||||
"factTypeObservation": "observação",
|
||||
"actionClearContent": "Limpar conteúdo"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "Carregando grafo...",
|
||||
"emptyState": "Nenhuma memória para exibir",
|
||||
"linkTypeCausal": "Causal ({type})",
|
||||
"linkTypeGeneric": "Link {type}",
|
||||
"linkTooltipEntity": "Entidade:",
|
||||
"linkTooltipWeight": "Peso:",
|
||||
"controlsHint": "Arraste para mover • Scroll para zoom • Duplo clique no nó para focar • Clique no fundo para redefinir"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "Scroll para zoom · Arraste para mover · Passe o mouse para explorar · Clique para selecionar",
|
||||
"hudStats": "{memories} memórias · {visible} visíveis · {labels} rótulos · {links} links · zoom {zoom}x",
|
||||
|
||||
@@ -534,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "所有記憶已整合(最近:{date})",
|
||||
"pendingConsolidation": "{count} 條記憶待整合",
|
||||
"constellation": "星座圖",
|
||||
"graph": "圖譜",
|
||||
"table": "表格",
|
||||
"timeline": "時間軸",
|
||||
"hidePanel": "隱藏面板",
|
||||
@@ -548,20 +547,6 @@
|
||||
"linkTypes": "連結類型",
|
||||
"nodes": "節點",
|
||||
"links": "連結",
|
||||
"graphTitle": "圖譜",
|
||||
"linksWithCount": "連結({count})",
|
||||
"clickToFilter": "· 選取篩選",
|
||||
"semantic": "語義",
|
||||
"temporal": "時間",
|
||||
"entity": "實體",
|
||||
"causal": "因果",
|
||||
"displayTitle": "顯示",
|
||||
"showLabels": "顯示標籤",
|
||||
"performanceTitle": "效能",
|
||||
"maxNodes": "最大節點數",
|
||||
"allLinksVisible": "所有可見節點之間的連結均已顯示。",
|
||||
"limitedTo50Nodes": "⚠️ 出於效能限制,最多顯示 50 個節點。總計:{count}",
|
||||
"clickNodeForDetails": "選取節點檢視詳情",
|
||||
"columnObservation": "觀察",
|
||||
"columnMemory": "記憶",
|
||||
"columnSources": "來源",
|
||||
@@ -1453,15 +1438,6 @@
|
||||
"factTypeObservation": "觀察",
|
||||
"actionClearContent": "清除內容"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "載入中圖譜...",
|
||||
"emptyState": "目前沒有記憶可顯示",
|
||||
"linkTypeCausal": "因果 ({type})",
|
||||
"linkTypeGeneric": "{type} 連結",
|
||||
"linkTooltipEntity": "實體:",
|
||||
"linkTooltipWeight": "權重:",
|
||||
"controlsHint": "拖曳平移 • 滾動縮放 • 雙擊節點聚焦 • 選取背景重設"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "滾動縮放 · 拖曳平移 · 懸停探索 · 選取項目",
|
||||
"hudStats": "{memories} 條記憶 · {visible} 條可見 · {labels} 個標籤 · {links} 條連結 · 縮放 {zoom}x",
|
||||
|
||||
@@ -534,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "所有记忆已整合(最近:{date})",
|
||||
"pendingConsolidation": "{count} 条记忆待整合",
|
||||
"constellation": "星座图",
|
||||
"graph": "图谱",
|
||||
"table": "表格",
|
||||
"timeline": "时间线",
|
||||
"hidePanel": "隐藏面板",
|
||||
@@ -548,20 +547,6 @@
|
||||
"linkTypes": "链接类型",
|
||||
"nodes": "节点",
|
||||
"links": "链接",
|
||||
"graphTitle": "图谱",
|
||||
"linksWithCount": "链接({count})",
|
||||
"clickToFilter": "· 点击筛选",
|
||||
"semantic": "语义",
|
||||
"temporal": "时间",
|
||||
"entity": "实体",
|
||||
"causal": "因果",
|
||||
"displayTitle": "显示",
|
||||
"showLabels": "显示标签",
|
||||
"performanceTitle": "性能",
|
||||
"maxNodes": "最大节点数",
|
||||
"allLinksVisible": "所有可见节点之间的链接均已显示。",
|
||||
"limitedTo50Nodes": "⚠️ 出于性能限制,最多显示 50 个节点。总计:{count}",
|
||||
"clickNodeForDetails": "点击节点查看详情",
|
||||
"columnObservation": "观察",
|
||||
"columnMemory": "记忆",
|
||||
"columnSources": "来源",
|
||||
@@ -1453,15 +1438,6 @@
|
||||
"factTypeObservation": "观察",
|
||||
"actionClearContent": "清除内容"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "正在加载图谱...",
|
||||
"emptyState": "暂无记忆可显示",
|
||||
"linkTypeCausal": "因果 ({type})",
|
||||
"linkTypeGeneric": "{type} 链接",
|
||||
"linkTooltipEntity": "实体:",
|
||||
"linkTooltipWeight": "权重:",
|
||||
"controlsHint": "拖拽平移 • 滚动缩放 • 双击节点聚焦 • 点击背景重置"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "滚动缩放 · 拖拽平移 · 悬停探索 · 点击选择",
|
||||
"hudStats": "{memories} 条记忆 · {visible} 条可见 · {labels} 个标签 · {links} 条链接 · 缩放 {zoom}x",
|
||||
|
||||
@@ -534,7 +534,6 @@
|
||||
"allConsolidatedWithDate": "所有記憶已整合(最近:{date})",
|
||||
"pendingConsolidation": "{count} 條記憶待整合",
|
||||
"constellation": "星座圖",
|
||||
"graph": "圖譜",
|
||||
"table": "表格",
|
||||
"timeline": "時間軸",
|
||||
"hidePanel": "隱藏面板",
|
||||
@@ -548,20 +547,6 @@
|
||||
"linkTypes": "連結型別",
|
||||
"nodes": "節點",
|
||||
"links": "連結",
|
||||
"graphTitle": "圖譜",
|
||||
"linksWithCount": "連結({count})",
|
||||
"clickToFilter": "· 點選篩選",
|
||||
"semantic": "語義",
|
||||
"temporal": "時間",
|
||||
"entity": "實體",
|
||||
"causal": "因果",
|
||||
"displayTitle": "顯示",
|
||||
"showLabels": "顯示標籤",
|
||||
"performanceTitle": "效能",
|
||||
"maxNodes": "最大節點數",
|
||||
"allLinksVisible": "所有可見節點之間的連結均已顯示。",
|
||||
"limitedTo50Nodes": "⚠️ 出於效能限制,最多顯示 50 個節點。總計:{count}",
|
||||
"clickNodeForDetails": "點選節點檢視詳情",
|
||||
"columnObservation": "觀察",
|
||||
"columnMemory": "記憶",
|
||||
"columnSources": "來源",
|
||||
@@ -1453,15 +1438,6 @@
|
||||
"factTypeObservation": "觀察",
|
||||
"actionClearContent": "清除內容"
|
||||
},
|
||||
"graph2d": {
|
||||
"loading": "載入中圖譜...",
|
||||
"emptyState": "尚無記憶可顯示",
|
||||
"linkTypeCausal": "因果 ({type})",
|
||||
"linkTypeGeneric": "{type} 連結",
|
||||
"linkTooltipEntity": "實體:",
|
||||
"linkTooltipWeight": "權重:",
|
||||
"controlsHint": "拖曳平移 • 滾動縮放 • 雙擊節點聚焦 • 按一下背景重設"
|
||||
},
|
||||
"constellation": {
|
||||
"instructions": "滾動縮放 · 拖曳平移 · 懸停探索 · 按一下選取",
|
||||
"hudStats": "{memories} 條記憶 · {visible} 條可見 · {labels} 個標籤 · {links} 條連結 · 縮放 {zoom}x",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
title: "Devin Desktop Persistent Memory (Formerly Windsurf)"
|
||||
authors: [benfrank241]
|
||||
slug: "2026/07/02/devin-desktop-persistent-memory"
|
||||
date: 2026-07-02T13:00
|
||||
tags: [hindsight, devin-desktop, devin, windsurf, codeium, memory, persistent-memory, mcp, tutorial]
|
||||
description: "Add persistent memory to Devin Desktop (formerly Windsurf): a remote MCP server plus one always-on rule that recalls at task start and retains as you work."
|
||||
image: /img/blog/devin-desktop-persistent-memory.png
|
||||
hide_table_of_contents: true
|
||||
---
|
||||
|
||||

|
||||
|
||||
[Devin Desktop](https://devin.ai) is the editor Cognition rebranded from Windsurf (formerly Codeium) in June 2026. The name changed; the gap didn't. Devin reads your codebase and holds a plan within a session, but it carries nothing across sessions. Close the editor, reopen it tomorrow, and the agent is a fresh model again, with no memory of the decision you talked through last week or the convention you set on Tuesday.
|
||||
|
||||
The `hindsight-devin-desktop` integration adds persistent long-term memory to Devin. It's worth understanding *how* it gets there, because Devin Desktop doesn't expose lifecycle hooks to third parties. There's no place to bolt a `sessionStart` recall or a `stop` retain. Instead the integration uses two things the editor *does* support: **remote [Model Context Protocol](https://modelcontextprotocol.io) (MCP) servers** and **always-on workspace rules**.
|
||||
|
||||
<!-- truncate -->
|
||||
|
||||
## TL;DR
|
||||
|
||||
- Devin Desktop (formerly Windsurf) has no third-party lifecycle hooks, so memory is wired through MCP plus a rule, not hook scripts.
|
||||
- `hindsight-devin-desktop init` connects the Hindsight **remote MCP server** (Devin gets `recall` / `retain` / `reflect` tools) and writes one **always-on rule** to `.devin/rules/hindsight.md`.
|
||||
- The rule tells Devin to `recall` at the start of each task and `retain` durable facts as it works.
|
||||
- No local daemon, no plugin scripts, no per-turn hooks. The MCP endpoint connects straight to [Hindsight Cloud](https://hindsight.vectorize.io) or your self-hosted server.
|
||||
- This is **model-driven memory**: the rule rides in every request, but the actual recall/retain calls are Devin's decision. That's the main tradeoff versus deterministic hook-based integrations.
|
||||
|
||||
## Why Devin Desktop Needs Persistent Memory
|
||||
|
||||
A new Devin session starts with whatever it can see: your open files, the workspace, and any rules you've written in `.devin/rules/`. What it can't see is the past. The bug you traced through three files yesterday, the library you chose and why, the naming convention you've been holding the line on. None of that survives the session boundary unless you wrote it down somewhere Devin reads.
|
||||
|
||||
You can pin context by hand with rules files, and for stable facts that works. It doesn't help with the things you didn't know to record in advance. Persistent memory closes that gap: durable facts get retained as you work, and the relevant ones come back on their own next time.
|
||||
|
||||
That matters more for an editor you live in all day. A coding agent that reintroduces itself every morning isn't really an assistant. Memory is what turns a fresh-every-session model into one that builds on yesterday.
|
||||
|
||||
## How Devin Desktop Persistent Memory Works
|
||||
|
||||
Devin Desktop gives third parties two integration points, and `hindsight-devin-desktop` uses both.
|
||||
|
||||
**Remote MCP server.** Devin Desktop reads MCP servers from a single global config and supports *remote* servers via `serverUrl` with custom headers, so the integration points Devin straight at the Hindsight MCP endpoint with no local process to manage:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"hindsight": {
|
||||
"serverUrl": "https://api.hindsight.vectorize.io/mcp/my-project/",
|
||||
"headers": { "Authorization": "Bearer hsk_..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That gives Devin three tools: `recall` (search memory), `retain` (store a durable fact), and `reflect` (a synthesized, memory-grounded answer). The memory bank is encoded in the endpoint path, so one config line scopes the whole connection to a bank.
|
||||
|
||||
**Always-on rule.** Devin Desktop applies any rule file under `.devin/rules/` whose frontmatter says `trigger: always_on` to every request in the workspace. The integration writes one dedicated file, `.devin/rules/hindsight.md`, telling Devin how and when to use those tools:
|
||||
|
||||
```markdown
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
<!-- Managed by hindsight-devin-desktop -->
|
||||
You have persistent long-term memory through the Hindsight MCP server
|
||||
(`recall`, `retain`, and `reflect` tools).
|
||||
|
||||
- At the start of each task, call `recall` with the user's request to load
|
||||
relevant decisions, preferences, and project context before you act.
|
||||
Use what's relevant and ignore the rest.
|
||||
- When you learn a durable fact, such as an architectural decision, a user
|
||||
preference, a convention, or anything worth remembering across sessions,
|
||||
call `retain` to store it.
|
||||
- Do not mention these memory operations unless the user asks about them.
|
||||
```
|
||||
|
||||
The file carries a sentinel comment (`<!-- Managed by hindsight-devin-desktop -->`) so the integration owns it end to end and can update or remove it idempotently without touching any other rule you've authored. Put together: the MCP server makes memory *available* as tools, and the always-on rule makes Devin *use* them.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install hindsight-devin-desktop
|
||||
cd your-project
|
||||
hindsight-devin-desktop init --api-token YOUR_HINDSIGHT_API_KEY --bank-id my-project
|
||||
```
|
||||
|
||||
`init` merges the `mcpServers` entry into Devin Desktop's global MCP config and writes the rule into `./.devin/rules/hindsight.md`. Reload Devin Desktop (or refresh MCP servers) and the `hindsight` tools are live.
|
||||
|
||||
Three commands cover the lifecycle: `hindsight-devin-desktop init` adds the MCP server and the recall/retain rule, `status` shows whether both are configured, and `uninstall` removes them. If your MCP config isn't plain JSON (comments, or some other tool owns it), `init` won't clobber it. It prints the snippet to paste instead, which you can also get anytime with `hindsight-devin-desktop init --print-only`.
|
||||
|
||||
## Cloud or Self-Hosted
|
||||
|
||||
By default the integration points at Hindsight Cloud (`https://api.hindsight.vectorize.io`), which needs an API key from your dashboard. To run against your own server, pass `--api-url`. If it's an open local server, you can skip the token entirely:
|
||||
|
||||
```bash
|
||||
hindsight-devin-desktop init --api-url http://localhost:8888 --bank-id my-project
|
||||
```
|
||||
|
||||
Settings can also come from the environment: `HINDSIGHT_API_URL` (the API endpoint, defaulting to Cloud), `HINDSIGHT_API_TOKEN` (the bearer token, required for Cloud), and `HINDSIGHT_DEVIN_DESKTOP_BANK_ID` (the bank to scope memory to, defaulting to `devin-desktop`). Point two projects at the same bank to share memory, or give each its own bank for isolation.
|
||||
|
||||
## A Rebrand Detail Worth Knowing
|
||||
|
||||
Because Devin Desktop is a rebrand of Windsurf, a couple of on-disk paths still carry the old name, and the integration handles that so you don't have to. The global MCP config still lives under `~/.codeium/windsurf/` (that's Devin Desktop's data directory, unchanged by the rename), while the workspace rule now lives under `.devin/rules/`, with `.windsurf/rules/` kept as a legacy fallback. If you used the integration back when it was the Windsurf package, your existing rule keeps working and the new path takes precedence going forward.
|
||||
|
||||
## The Tradeoff: Model-Driven, Not Hook-Driven
|
||||
|
||||
This is worth being direct about, because it's the real difference between this integration and the hook-based ones for Claude Code or the Cursor CLI.
|
||||
|
||||
Hook-based integrations are **deterministic**. A `sessionStart` hook recalls before the agent ever sees the prompt; a `stop` hook retains after every task, whether or not the model thought to. The recall and retain happen because the harness fires an event, not because the agent decided to.
|
||||
|
||||
Devin Desktop doesn't offer that surface to third parties, so `hindsight-devin-desktop` is **model-driven**. The always-on rule is injected into every request, so the instruction to use memory is always present, but the actual `recall` and `retain` calls are Devin's decision. In practice modern models follow a short, concrete always-on rule reliably. But it's an instruction, not a guarantee: Devin can skip a `retain` on a task it didn't judge memorable, or answer from context without calling `recall` first. If you want memory pulled for a specific task, you can just ask ("check memory for how we handled auth"), and `reflect` is there to consolidate on demand. The honest framing: Devin Desktop trades the guarantees of hooks for the simplicity of a remote MCP server and one rule file, with no local daemon and nothing to keep running.
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
**Is Devin Desktop the same as Windsurf?**
|
||||
Yes. Cognition rebranded the Windsurf editor (formerly Codeium) to Devin Desktop in June 2026. The `hindsight-devin-desktop` package is the maintained integration; it writes its rule to `.devin/rules/` and still reads the MCP config under `~/.codeium/windsurf/`, which is unchanged by the rebrand.
|
||||
|
||||
**Does Devin Desktop have built-in memory across sessions?**
|
||||
No. A new session starts fresh. Persistent memory comes from an integration like `hindsight-devin-desktop` that gives Devin recall and retain over a memory layer.
|
||||
|
||||
**Will memory recall slow Devin down?**
|
||||
Recall is the agent's call, not a per-prompt hook, so there's no fixed overhead on every turn. When Devin does recall, a Hindsight Cloud query is typically well under a second.
|
||||
|
||||
**Does it work with self-hosted Hindsight?**
|
||||
Yes. Pass `--api-url` (or set `HINDSIGHT_API_URL`) to point at your server. For an open local server with no auth, omit the token.
|
||||
|
||||
## Further reading
|
||||
|
||||
- [What is agent memory?](https://vectorize.io/what-is-agent-memory): the foundational concepts behind recall, retention, and memory banks.
|
||||
- [Best AI agent memory systems](https://vectorize.io/articles/best-ai-agent-memory-systems): how the major agent memory frameworks compare.
|
||||
- [Cursor persistent memory](/blog/2026/06/12/cursor-persistent-memory): the hook-based sibling integration for the Cursor editor and CLI.
|
||||
- [One memory for every AI tool](/blog/2026/04/07/one-memory-for-every-ai-tool): point Devin and your other agents at the same bank.
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
sidebar_position: 38
|
||||
title: "Eve Agent Memory with Hindsight | Integration"
|
||||
description: "Add long-term memory to Vercel Eve agents with Hindsight. A one-line MCP connection gives your agent retain, recall, and reflect across sessions."
|
||||
description: "Add automatic long-term memory to Vercel Eve agents with Hindsight. Memory is injected before each turn and retained after — no model tool-calling."
|
||||
---
|
||||
|
||||
# Eve
|
||||
|
||||
Long-term memory for [Vercel Eve](https://github.com/vercel/eve) agents using [Hindsight](https://vectorize.io/hindsight). Eve is filesystem-first — an agent gains a capability by dropping a file under `agent/connections/`. The `@vectorize-io/hindsight-eve` package wraps Eve's `defineMcpClientConnection`, so one file gives your agent `retain`, `recall`, and `reflect` over Hindsight's MCP server and it remembers across sessions and deployments.
|
||||
Automatic long-term memory for [Vercel Eve](https://github.com/vercel/eve) agents using [Hindsight](https://vectorize.io/hindsight). Eve is filesystem-first — an agent gains a capability by dropping a file under `agent/`. The `@vectorize-io/hindsight-eve` package wires two files that call Hindsight's REST API directly, so your agent gets memory that **just works** — relevant memory is injected before every turn and each exchange is retained after — **without the model ever choosing to call a tool.**
|
||||
|
||||
## Install
|
||||
|
||||
@@ -18,67 +18,71 @@ npm install @vectorize-io/hindsight-eve
|
||||
|
||||
## Quick Start
|
||||
|
||||
Create `agent/connections/hindsight.ts`:
|
||||
Create two files:
|
||||
|
||||
```ts
|
||||
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
|
||||
// agent/instructions/hindsight.ts — recall: inject memory before each turn
|
||||
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
|
||||
|
||||
export default defineHindsightConnection();
|
||||
export default hindsightMemory();
|
||||
```
|
||||
|
||||
The connection reads its defaults from the environment:
|
||||
```ts
|
||||
// agent/hooks/hindsight.ts — retain: save each exchange after the turn
|
||||
import { hindsightRetainHook } from "@vectorize-io/hindsight-eve";
|
||||
|
||||
| Env var | Purpose |
|
||||
| ----------------------- | ---------------------------------------------------------------- |
|
||||
| `HINDSIGHT_API_KEY` | Bearer token sent as `Authorization: Bearer <key>` |
|
||||
| `HINDSIGHT_MCP_URL` | MCP endpoint (defaults to Hindsight Cloud) |
|
||||
| `HINDSIGHT_MCP_BANK_ID` | Optional bank to scope memory to, sent as the `X-Bank-Id` header |
|
||||
export default hindsightRetainHook();
|
||||
```
|
||||
|
||||
The model discovers the tools via Eve's `connection__search` and calls them as `connection__hindsight__recall`, `connection__hindsight__retain`, and `connection__hindsight__reflect`. The connection's URL and token never reach the model.
|
||||
Both read their config from the environment:
|
||||
|
||||
| Env var | Purpose |
|
||||
| ------------------- | -------------------------------------------------------------- |
|
||||
| `HINDSIGHT_API_KEY` | Bearer token sent as `Authorization: Bearer <key>` |
|
||||
| `HINDSIGHT_API_URL` | Hindsight REST base (defaults to Hindsight Cloud) |
|
||||
| `HINDSIGHT_BANK_ID` | Bank to scope memory to (defaults to `default`; auto-created) |
|
||||
|
||||
### Hindsight Cloud
|
||||
|
||||
Set `HINDSIGHT_API_KEY` from your [Hindsight Cloud](https://hindsight.vectorize.io) dashboard. The connection defaults to `https://api.hindsight.vectorize.io/mcp`, so no URL is needed.
|
||||
Set `HINDSIGHT_API_KEY` from your [Hindsight Cloud](https://hindsight.vectorize.io) dashboard. `HINDSIGHT_API_URL` defaults to `https://api.hindsight.vectorize.io`, so no URL is needed.
|
||||
|
||||
### Self-hosted
|
||||
|
||||
Point at your own server, optionally scoping to a bank. Use `apiKey: null` for a no-auth local server:
|
||||
|
||||
```ts
|
||||
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
|
||||
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
|
||||
|
||||
export default defineHindsightConnection({
|
||||
url: "http://localhost:8000/mcp",
|
||||
apiKey: null,
|
||||
});
|
||||
// A local server with no auth:
|
||||
export default hindsightMemory({ apiUrl: "http://localhost:8000", apiKey: null });
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
Both factories accept the same options (each falls back to its env var):
|
||||
|
||||
```ts
|
||||
defineHindsightConnection({
|
||||
url, // MCP endpoint; defaults to HINDSIGHT_MCP_URL, then Cloud
|
||||
hindsightMemory({
|
||||
apiUrl, // REST base; defaults to HINDSIGHT_API_URL, then Cloud
|
||||
apiKey, // bearer token; null = no auth (local dev)
|
||||
bankId, // scope memory to a bank (X-Bank-Id header)
|
||||
description, // override the model-facing description
|
||||
tools, // { allow } | { block } — narrow which Hindsight tools the model sees
|
||||
approval, // human-in-the-loop policy, e.g. once() from "eve/tools/approval"
|
||||
bankId, // bank to scope memory to
|
||||
recallQuery, // the broad query used for recall (see below)
|
||||
budget, // "low" | "mid" | "high" — recall result budget (default "mid")
|
||||
maxTokens, // recall token budget (default 1024)
|
||||
context, // `context` tag written on retained items (default "eve")
|
||||
includeAssistantReply, // also retain the assistant's reply (default false — user message only)
|
||||
timeoutMs, // HTTP timeout (default 15000)
|
||||
onError, // (err, phase) => void — failures degrade silently (default console.warn)
|
||||
});
|
||||
```
|
||||
|
||||
Restrict the agent to read-only recall and require approval the first time:
|
||||
## Recall is profile-based, not per-message
|
||||
|
||||
```ts
|
||||
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
|
||||
import { once } from "eve/tools/approval";
|
||||
Eve's instruction resolver runs at the start of a turn and **cannot see the live user message**, so recall uses a fixed broad query (default: `"user preferences, identity, and working context"`) to surface the user's ambient profile/context each turn. This is ideal for "the agent knows you" — preferences, identity, ongoing context — and is fully deterministic. Tune it with `recallQuery`. Per-message, query-specific retrieval inherently needs a tool the model calls and is out of scope here.
|
||||
|
||||
export default defineHindsightConnection({
|
||||
tools: { allow: ["recall", "reflect"] },
|
||||
approval: once(),
|
||||
});
|
||||
```
|
||||
## Verify
|
||||
|
||||
Run your agent. Tell it a durable preference in one chat ("whenever you write me code, use Python with full type hints and no comments"). Start a **fresh** chat and ask for something — the agent applies the remembered preference, because the memory was injected before the model ran, with no tool call.
|
||||
|
||||
## Links
|
||||
|
||||
- [Hindsight docs](https://hindsight.vectorize.io)
|
||||
- [Eve connections](https://github.com/vercel/eve/blob/main/docs/connections.mdx)
|
||||
- [Eve hooks](https://github.com/vercel/eve/blob/main/docs/guides/hooks.md) · [Eve dynamic capabilities](https://github.com/vercel/eve/blob/main/docs/guides/dynamic-capabilities.md)
|
||||
|
||||
@@ -373,7 +373,7 @@
|
||||
{
|
||||
"id": "eve",
|
||||
"name": "Eve",
|
||||
"description": "Long-term memory for Vercel Eve agents. A one-line MCP connection exposing retain, recall, and reflect.",
|
||||
"description": "Automatic long-term memory for Vercel Eve agents. Memory is injected before each turn and retained after, with no model tool-calling.",
|
||||
"type": "official",
|
||||
"by": "hindsight",
|
||||
"category": "framework",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 325 KiB |
@@ -1,18 +1,19 @@
|
||||
# Hindsight for Eve
|
||||
|
||||
Long-term memory for [Vercel Eve](https://github.com/vercel/eve) agents, powered by
|
||||
[Hindsight](https://vectorize.io/hindsight). One file gives your agent `retain`, `recall`,
|
||||
and `reflect` over [Hindsight's MCP server](https://hindsight.vectorize.io) — so it
|
||||
remembers facts across sessions and deployments instead of starting cold every time.
|
||||
Automatic long-term memory for [Vercel Eve](https://github.com/vercel/eve) agents, powered by
|
||||
[Hindsight](https://vectorize.io/hindsight). Two files give your agent memory that **just
|
||||
works** — relevant memory is injected before every turn, and each exchange is saved after —
|
||||
**without the model ever choosing to call a tool.**
|
||||
|
||||
## How it works
|
||||
|
||||
Eve is filesystem-first: an agent gains a capability by dropping a file under
|
||||
`agent/connections/`. This package wraps eve's `defineMcpClientConnection`, pre-filling the
|
||||
Hindsight MCP endpoint, a model-facing description, and bearer auth. The model discovers the
|
||||
tools through `connection__search` and calls them as `connection__hindsight__recall`,
|
||||
`connection__hindsight__retain`, and `connection__hindsight__reflect`. The connection's URL
|
||||
and token never reach the model.
|
||||
Eve is filesystem-first. This package wires two authored files that call Hindsight's REST API
|
||||
directly, so memory never depends on the LLM deciding to call a tool:
|
||||
|
||||
- **`agent/instructions/hindsight.ts`** — a dynamic instructions resolver that, before each
|
||||
turn, recalls the user's stored memory from Hindsight and injects it as a system message.
|
||||
- **`agent/hooks/hindsight.ts`** — a hook that, after each turn, retains the user message and
|
||||
the assistant's answer to Hindsight.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -24,81 +25,97 @@ npm install @vectorize-io/hindsight-eve
|
||||
|
||||
## Quick start
|
||||
|
||||
Create `agent/connections/hindsight.ts`:
|
||||
Create two files:
|
||||
|
||||
```ts
|
||||
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
|
||||
// agent/instructions/hindsight.ts
|
||||
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
|
||||
|
||||
export default defineHindsightConnection();
|
||||
export default hindsightMemory();
|
||||
```
|
||||
|
||||
That's it. By default the connection reads:
|
||||
```ts
|
||||
// agent/hooks/hindsight.ts
|
||||
import { hindsightRetainHook } from "@vectorize-io/hindsight-eve";
|
||||
|
||||
| Env var | Purpose |
|
||||
| ----------------------- | ---------------------------------------------------------------- |
|
||||
| `HINDSIGHT_API_KEY` | Bearer token sent as `Authorization: Bearer <key>` |
|
||||
| `HINDSIGHT_MCP_URL` | MCP endpoint (defaults to Hindsight Cloud) |
|
||||
| `HINDSIGHT_MCP_BANK_ID` | Optional bank to scope memory to, sent as the `X-Bank-Id` header |
|
||||
export default hindsightRetainHook();
|
||||
```
|
||||
|
||||
That's it. Both read their config from the environment:
|
||||
|
||||
| Env var | Purpose |
|
||||
| ------------------- | ------------------------------------------------------------- |
|
||||
| `HINDSIGHT_API_KEY` | Bearer token sent as `Authorization: Bearer <key>` |
|
||||
| `HINDSIGHT_API_URL` | Hindsight REST base (defaults to Hindsight Cloud) |
|
||||
| `HINDSIGHT_BANK_ID` | Bank to scope memory to (defaults to `default`; auto-created) |
|
||||
|
||||
### Hindsight Cloud
|
||||
|
||||
Set `HINDSIGHT_API_KEY` to a key from your [Hindsight Cloud](https://hindsight.vectorize.io)
|
||||
dashboard. The connection defaults to `https://api.hindsight.vectorize.io/mcp`, so no URL is
|
||||
dashboard. `HINDSIGHT_API_URL` defaults to `https://api.hindsight.vectorize.io`, so no URL is
|
||||
needed.
|
||||
|
||||
### Self-hosted
|
||||
|
||||
Point at your own server and (optionally) pick a bank:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_MCP_URL="http://localhost:8000/mcp"
|
||||
export HINDSIGHT_MCP_BANK_ID="my-project"
|
||||
export HINDSIGHT_API_KEY="…" # or omit and pass apiKey: null below for a no-auth server
|
||||
export HINDSIGHT_API_URL="http://localhost:8000"
|
||||
export HINDSIGHT_BANK_ID="my-project"
|
||||
export HINDSIGHT_API_KEY="…" # or pass apiKey: null below for a no-auth server
|
||||
```
|
||||
|
||||
```ts
|
||||
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
|
||||
import { hindsightMemory } from "@vectorize-io/hindsight-eve";
|
||||
|
||||
// A local server with no auth:
|
||||
export default defineHindsightConnection({
|
||||
url: "http://localhost:8000/mcp",
|
||||
apiKey: null,
|
||||
});
|
||||
export default hindsightMemory({ apiUrl: "http://localhost:8000", apiKey: null });
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
Both factories accept the same options (each falls back to its env var):
|
||||
|
||||
```ts
|
||||
defineHindsightConnection({
|
||||
url, // string — MCP endpoint; defaults to HINDSIGHT_MCP_URL, then Cloud
|
||||
hindsightMemory({
|
||||
apiUrl, // string — REST base; defaults to HINDSIGHT_API_URL, then Cloud
|
||||
apiKey, // string | null — bearer token; null = no auth (local dev)
|
||||
bankId, // string — scope memory to a bank (X-Bank-Id header)
|
||||
description, // string — override the model-facing description
|
||||
tools, // { allow } | { block } — narrow which Hindsight tools the model sees
|
||||
approval, // human-in-the-loop policy, e.g. once() from "eve/tools/approval"
|
||||
bankId, // string — bank to scope memory to
|
||||
recallQuery, // string — the broad query used for recall (see below)
|
||||
budget, // "low" | "mid" | "high" — recall result budget (default "mid")
|
||||
maxTokens, // number — recall token budget (default 1024)
|
||||
context, // string — `context` tag written on retained items (default "eve")
|
||||
includeAssistantReply, // boolean — also retain the assistant's reply (default false)
|
||||
timeoutMs, // number — HTTP timeout (default 15000)
|
||||
onError, // (err, phase) => void — failures degrade silently via this (default console.warn)
|
||||
});
|
||||
```
|
||||
|
||||
Restrict the agent to read-only recall, and require approval the first time:
|
||||
## Recall is profile-based, not per-message
|
||||
|
||||
```ts
|
||||
import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
|
||||
import { once } from "eve/tools/approval";
|
||||
Eve's instruction resolver runs at the start of a turn and **cannot see the live user
|
||||
message**, so recall uses a fixed broad query (default:
|
||||
`"user preferences, identity, and working context"`) to surface the user's ambient
|
||||
profile/context each turn. This is ideal for "the agent knows you" — preferences, identity,
|
||||
ongoing context — and is deterministic. Tune it with `recallQuery`. (Per-message, query-
|
||||
specific retrieval inherently needs a tool the model calls; that's out of scope here.)
|
||||
|
||||
export default defineHindsightConnection({
|
||||
tools: { allow: ["recall", "reflect"] },
|
||||
approval: once(),
|
||||
});
|
||||
```
|
||||
## Notes
|
||||
|
||||
- Memory is scoped to a **bank** (one isolated store, e.g. per user). Point both files at the
|
||||
same `HINDSIGHT_BANK_ID`.
|
||||
- By default only the **user's** message is retained (the durable signal) — set
|
||||
`includeAssistantReply: true` to also store the assistant's reply.
|
||||
- Retains run asynchronously and never block a turn; failures degrade via `onError`.
|
||||
- The recall block injected into context is fenced with a sentinel so recalled facts are never
|
||||
re-retained.
|
||||
|
||||
## Verify
|
||||
|
||||
With the connection in place, run your agent and ask it something it would need to look up
|
||||
("what did we decide about X last week?"). Eve's `connection__search` surfaces the Hindsight
|
||||
tools and the model calls `connection__hindsight__recall`. To seed memory, have the agent
|
||||
`retain` a fact in one session and `recall` it in the next.
|
||||
Run your agent. Tell it a durable preference in one chat ("whenever you write me code, use
|
||||
Python with full type hints and no comments"). Start a **fresh** chat and ask for something —
|
||||
the agent applies the remembered preference, because the memory was injected before the model
|
||||
ran, with no tool call.
|
||||
|
||||
## Links
|
||||
|
||||
- [Hindsight docs](https://hindsight.vectorize.io)
|
||||
- [Eve connections](https://github.com/vercel/eve/blob/main/docs/connections.mdx)
|
||||
- [Eve hooks](https://github.com/vercel/eve/blob/main/docs/guides/hooks.md) ·
|
||||
[Eve dynamic capabilities](https://github.com/vercel/eve/blob/main/docs/guides/dynamic-capabilities.md)
|
||||
|
||||
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-eve",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@vectorize-io/hindsight-eve",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
@@ -16,7 +16,7 @@
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=24"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eve": ">=0.11.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-eve",
|
||||
"version": "0.1.0",
|
||||
"description": "Hindsight long-term memory for Vercel Eve agents - a one-line MCP connection exposing retain, recall, and reflect",
|
||||
"version": "0.2.0",
|
||||
"description": "Automatic long-term memory for Vercel Eve agents — Hindsight memory injected before each turn and retained after, with no model tool-calling",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
@@ -15,7 +15,7 @@
|
||||
"eve",
|
||||
"vercel",
|
||||
"agents",
|
||||
"mcp",
|
||||
"hooks",
|
||||
"memory",
|
||||
"hindsight",
|
||||
"llm",
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { hindsightAutoRecall, hindsightRetainHook } from "./auto-memory";
|
||||
import { SENTINEL_OPEN } from "./client";
|
||||
|
||||
const OPTS = { apiUrl: "http://test", apiKey: "k", bankId: "b" };
|
||||
const CTX = { session: { id: "s1" }, channel: { kind: "web" } } as unknown;
|
||||
|
||||
/** Mock fetch, routing by URL; returns recall results or a retain ack. */
|
||||
function mockFetch(recallResults: unknown[] = []): ReturnType<typeof vi.fn> {
|
||||
const fn = vi.fn(async (url: string) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => (url.includes("/recall") ? { results: recallResults } : { success: true }),
|
||||
text: async () => "",
|
||||
}));
|
||||
vi.stubGlobal("fetch", fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handlers = (def: { events: unknown }): any => def.events;
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("hindsightRetainHook", () => {
|
||||
it("retains the user's message on turn.completed (user-only by default)", async () => {
|
||||
const fetchFn = mockFetch();
|
||||
const ev = handlers(hindsightRetainHook(OPTS));
|
||||
|
||||
ev["message.received"]({ data: { turnId: "t1", message: "I prefer tabs" } });
|
||||
ev["message.completed"]({ data: { turnId: "t1", message: "Got it.", finishReason: "stop" } });
|
||||
await ev["turn.completed"]({ data: { turnId: "t1" } }, CTX);
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchFn.mock.calls[0];
|
||||
expect(url).toBe("http://test/v1/default/banks/b/memories");
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.async).toBe(true);
|
||||
expect(body.items[0].content).toBe("User: I prefer tabs");
|
||||
expect(body.items[0].context).toBe("eve");
|
||||
expect(body.items[0].metadata).toMatchObject({ sessionId: "s1", turnId: "t1", channel: "web" });
|
||||
});
|
||||
|
||||
it("includes the assistant reply when includeAssistantReply is set", async () => {
|
||||
const fetchFn = mockFetch();
|
||||
const ev = handlers(hindsightRetainHook({ ...OPTS, includeAssistantReply: true }));
|
||||
ev["message.received"]({ data: { turnId: "t1", message: "I prefer tabs" } });
|
||||
ev["message.completed"]({ data: { turnId: "t1", message: "Got it.", finishReason: "stop" } });
|
||||
await ev["turn.completed"]({ data: { turnId: "t1" } }, CTX);
|
||||
expect(JSON.parse(fetchFn.mock.calls[0][1].body).items[0].content).toBe(
|
||||
"User: I prefer tabs\n\nAssistant: Got it."
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores non-terminal assistant steps (finishReason !== 'stop')", async () => {
|
||||
const fetchFn = mockFetch();
|
||||
const ev = handlers(hindsightRetainHook(OPTS));
|
||||
ev["message.received"]({ data: { turnId: "t1", message: "hi" } });
|
||||
ev["message.completed"]({
|
||||
data: { turnId: "t1", message: "calling tool", finishReason: "tool-calls" },
|
||||
});
|
||||
await ev["turn.completed"]({ data: { turnId: "t1" } }, CTX);
|
||||
// still retains (user text present), but content has no assistant half
|
||||
expect(JSON.parse(fetchFn.mock.calls[0][1].body).items[0].content).toBe("User: hi");
|
||||
});
|
||||
|
||||
it("does not retain a turn with no user message", async () => {
|
||||
const fetchFn = mockFetch();
|
||||
const ev = handlers(hindsightRetainHook(OPTS));
|
||||
ev["message.completed"]({ data: { turnId: "t1", message: "orphan", finishReason: "stop" } });
|
||||
await ev["turn.completed"]({ data: { turnId: "t1" } }, CTX);
|
||||
expect(fetchFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never throws on a retain failure (degrades via onError)", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
text: async () => "boom",
|
||||
}))
|
||||
);
|
||||
const onError = vi.fn();
|
||||
const ev = handlers(hindsightRetainHook({ ...OPTS, onError }));
|
||||
ev["message.received"]({ data: { turnId: "t1", message: "x" } });
|
||||
await expect(ev["turn.completed"]({ data: { turnId: "t1" } }, CTX)).resolves.toBeUndefined();
|
||||
expect(onError).toHaveBeenCalledWith(expect.anything(), "retain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("hindsightAutoRecall", () => {
|
||||
it("recalls and returns injected instructions containing the memories", async () => {
|
||||
const fetchFn = mockFetch([{ id: "1", text: "prefers Python" }]);
|
||||
const ev = handlers(hindsightAutoRecall(OPTS));
|
||||
const result = await ev["turn.started"]({ data: { turnId: "t1" } }, CTX);
|
||||
|
||||
const [url, init] = fetchFn.mock.calls[0];
|
||||
expect(url).toBe("http://test/v1/default/banks/b/memories/recall");
|
||||
expect(JSON.parse(init.body).query).toBe("user preferences, identity, and working context");
|
||||
expect(result.markdown).toContain(SENTINEL_OPEN);
|
||||
expect(result.markdown).toContain("- prefers Python");
|
||||
});
|
||||
|
||||
it("returns undefined when there is nothing to recall", async () => {
|
||||
mockFetch([]);
|
||||
const ev = handlers(hindsightAutoRecall(OPTS));
|
||||
expect(await ev["turn.started"]({ data: { turnId: "t1" } }, CTX)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined and reports onError on a recall failure", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({}),
|
||||
text: async () => "boom",
|
||||
}))
|
||||
);
|
||||
const onError = vi.fn();
|
||||
const ev = handlers(hindsightAutoRecall({ ...OPTS, onError }));
|
||||
expect(await ev["turn.started"]({ data: { turnId: "t1" } }, CTX)).toBeUndefined();
|
||||
expect(onError).toHaveBeenCalledWith(expect.anything(), "recall");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Automatic, no-tool long-term memory for Vercel Eve agents, backed by
|
||||
* Hindsight's REST API. Two authored files give an agent memory that works
|
||||
* without the model ever choosing to call a tool:
|
||||
*
|
||||
* ```ts
|
||||
* // agent/instructions/hindsight.ts — recall: inject memory before each turn
|
||||
* import { hindsightMemory } from "@vectorize-io/hindsight-eve";
|
||||
* export default hindsightMemory();
|
||||
*
|
||||
* // agent/hooks/hindsight.ts — retain: save each exchange after the turn
|
||||
* import { hindsightRetainHook } from "@vectorize-io/hindsight-eve";
|
||||
* export default hindsightRetainHook();
|
||||
* ```
|
||||
*
|
||||
* This module is the only one that imports `eve`. The HTTP client and config
|
||||
* resolution are kept pure (in `./client` and `./config`) so they unit-test
|
||||
* without the framework.
|
||||
*/
|
||||
import { defineHook, type HookDefinition } from "eve/hooks";
|
||||
import { defineDynamic, defineInstructions, type DynamicSentinel } from "eve/instructions";
|
||||
|
||||
import { HindsightRestClient, buildRecallMarkdown } from "./client.js";
|
||||
import {
|
||||
buildRetainContent,
|
||||
recordAssistantMessage,
|
||||
recordUserMessage,
|
||||
resolveAutoMemory,
|
||||
takeTurn,
|
||||
type AutoMemoryOptions,
|
||||
type TurnBuffer,
|
||||
} from "./config.js";
|
||||
|
||||
export type { AutoMemoryOptions } from "./config.js";
|
||||
|
||||
/**
|
||||
* Inject the user's stored memory as a system message before each turn.
|
||||
* Drop the returned value as the default export of `agent/instructions/hindsight.ts`.
|
||||
*
|
||||
* Recall uses a fixed broad query (not the live message — eve's instruction
|
||||
* resolver can't see it), which surfaces the user's ambient profile/context.
|
||||
* Tune it with `recallQuery`.
|
||||
*/
|
||||
export function hindsightAutoRecall(options: AutoMemoryOptions = {}): DynamicSentinel {
|
||||
const cfg = resolveAutoMemory(options);
|
||||
const client = new HindsightRestClient(cfg.apiUrl, cfg.apiKey, cfg.timeoutMs);
|
||||
|
||||
return defineDynamic({
|
||||
events: {
|
||||
"turn.started": async (): Promise<unknown> => {
|
||||
try {
|
||||
const { results } = await client.recall(cfg.bankId, cfg.recallQuery, {
|
||||
budget: cfg.budget,
|
||||
maxTokens: cfg.maxTokens,
|
||||
});
|
||||
if (results.length === 0) return undefined;
|
||||
return defineInstructions({ markdown: buildRecallMarkdown(results) });
|
||||
} catch (error) {
|
||||
cfg.onError(error, "recall");
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Primary name for {@link hindsightAutoRecall} — the memory-injection half. */
|
||||
export const hindsightMemory = hindsightAutoRecall;
|
||||
|
||||
/**
|
||||
* Retain each completed exchange to Hindsight. Drop the returned value as the
|
||||
* default export of `agent/hooks/hindsight.ts`.
|
||||
*
|
||||
* Pairs the user message (`message.received`) with the final assistant answer
|
||||
* (`message.completed` where `finishReason === "stop"`) by `turnId`, then
|
||||
* retains on `turn.completed`. All side effects are guarded — a failure warns
|
||||
* via `onError` and never breaks the turn.
|
||||
*/
|
||||
export function hindsightRetainHook(options: AutoMemoryOptions = {}): HookDefinition {
|
||||
const cfg = resolveAutoMemory(options);
|
||||
const client = new HindsightRestClient(cfg.apiUrl, cfg.apiKey, cfg.timeoutMs);
|
||||
const buffer: TurnBuffer = new Map();
|
||||
|
||||
return defineHook({
|
||||
events: {
|
||||
"message.received": (event) => {
|
||||
recordUserMessage(buffer, event.data.turnId, event.data.message);
|
||||
},
|
||||
"message.completed": (event) => {
|
||||
// Only the terminal assistant text; intermediate steps end in "tool-calls".
|
||||
if (event.data.finishReason === "stop" && event.data.message) {
|
||||
recordAssistantMessage(buffer, event.data.turnId, event.data.message);
|
||||
}
|
||||
},
|
||||
"turn.completed": async (event, ctx) => {
|
||||
try {
|
||||
const content = buildRetainContent(
|
||||
takeTurn(buffer, event.data.turnId),
|
||||
cfg.includeAssistantReply
|
||||
);
|
||||
if (content === null) return;
|
||||
const metadata: Record<string, string> = {
|
||||
sessionId: ctx.session.id,
|
||||
turnId: event.data.turnId,
|
||||
};
|
||||
if (ctx.channel.kind) metadata.channel = ctx.channel.kind;
|
||||
await client.retain(
|
||||
cfg.bankId,
|
||||
[{ content, context: cfg.context, metadata, timestamp: new Date().toISOString() }],
|
||||
{ async: true }
|
||||
);
|
||||
} catch (error) {
|
||||
cfg.onError(error, "retain");
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
HindsightRestClient,
|
||||
SENTINEL_OPEN,
|
||||
SENTINEL_CLOSE,
|
||||
buildRecallMarkdown,
|
||||
stripSentinelBlocks,
|
||||
} from "./client";
|
||||
|
||||
function mockFetchOnce(status: number, json: unknown): ReturnType<typeof vi.fn> {
|
||||
const fn = vi.fn(async () => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => json,
|
||||
text: async () => JSON.stringify(json),
|
||||
}));
|
||||
vi.stubGlobal("fetch", fn);
|
||||
return fn;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("HindsightRestClient.recall", () => {
|
||||
it("POSTs to the recall path with query/budget/max_tokens and bearer auth", async () => {
|
||||
const fetchFn = mockFetchOnce(200, {
|
||||
results: [{ id: "1", text: "likes tabs", type: "world" }],
|
||||
});
|
||||
const client = new HindsightRestClient("https://api.hindsight.vectorize.io", "hsk_k");
|
||||
|
||||
const res = await client.recall("bank-1", "preferences", { budget: "low", maxTokens: 512 });
|
||||
|
||||
expect(res.results[0].text).toBe("likes tabs");
|
||||
const [url, init] = fetchFn.mock.calls[0];
|
||||
expect(url).toBe("https://api.hindsight.vectorize.io/v1/default/banks/bank-1/memories/recall");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers["Authorization"]).toBe("Bearer hsk_k");
|
||||
expect(JSON.parse(init.body)).toEqual({ query: "preferences", budget: "low", max_tokens: 512 });
|
||||
});
|
||||
|
||||
it("defaults budget=mid and max_tokens=1024, omits auth header when no token", async () => {
|
||||
const fetchFn = mockFetchOnce(200, { results: [] });
|
||||
const client = new HindsightRestClient("http://localhost:8000", null);
|
||||
await client.recall("b", "q");
|
||||
const init = fetchFn.mock.calls[0][1];
|
||||
expect(init.headers["Authorization"]).toBeUndefined();
|
||||
expect(JSON.parse(init.body)).toEqual({ query: "q", budget: "mid", max_tokens: 1024 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("HindsightRestClient.retain", () => {
|
||||
it("POSTs items with async=true to the memories path", async () => {
|
||||
const fetchFn = mockFetchOnce(200, { success: true });
|
||||
const client = new HindsightRestClient("https://api.hindsight.vectorize.io/", "hsk_k");
|
||||
await client.retain("my bank", [{ content: "fact", context: "eve" }]);
|
||||
const [url, init] = fetchFn.mock.calls[0];
|
||||
// trailing slash on baseUrl is normalized; bank is URL-encoded
|
||||
expect(url).toBe("https://api.hindsight.vectorize.io/v1/default/banks/my%20bank/memories");
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
items: [{ content: "fact", context: "eve" }],
|
||||
async: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HindsightRestClient error handling", () => {
|
||||
it("throws on a non-2xx response", async () => {
|
||||
mockFetchOnce(401, { detail: "unauthorized" });
|
||||
const client = new HindsightRestClient("https://api.hindsight.vectorize.io", "bad");
|
||||
await expect(client.recall("b", "q")).rejects.toThrow(/HTTP 401/);
|
||||
});
|
||||
|
||||
it("requires a non-empty base URL", () => {
|
||||
expect(() => new HindsightRestClient(" ")).toThrow(/API URL is required/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRecallMarkdown / stripSentinelBlocks", () => {
|
||||
it("returns empty string for no results", () => {
|
||||
expect(buildRecallMarkdown([])).toBe("");
|
||||
});
|
||||
|
||||
it("wraps results in sentinel markers as a bulleted list", () => {
|
||||
const md = buildRecallMarkdown([
|
||||
{ id: "1", text: "prefers Python" },
|
||||
{ id: "2", text: "no comments" },
|
||||
]);
|
||||
expect(md.startsWith(SENTINEL_OPEN)).toBe(true);
|
||||
expect(md.trimEnd().endsWith(SENTINEL_CLOSE)).toBe(true);
|
||||
expect(md).toContain("- prefers Python");
|
||||
expect(md).toContain("- no comments");
|
||||
});
|
||||
|
||||
it("strips a fenced recalled-context block out of text", () => {
|
||||
const md = buildRecallMarkdown([{ id: "1", text: "secret" }]);
|
||||
const polluted = `Here is my answer.\n${md}\nDone.`;
|
||||
const cleaned = stripSentinelBlocks(polluted);
|
||||
expect(cleaned).not.toContain("secret");
|
||||
expect(cleaned).toContain("Here is my answer.");
|
||||
expect(cleaned).toContain("Done.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Minimal Hindsight REST client + memory-formatting helpers. Native `fetch`,
|
||||
* zero dependencies. Pure (no `eve` import) so it can be unit-tested with a
|
||||
* mocked `fetch`.
|
||||
*
|
||||
* Endpoints (tenant is the literal `default`, bank is in the path):
|
||||
* recall: POST /v1/default/banks/{bank}/memories/recall
|
||||
* retain: POST /v1/default/banks/{bank}/memories
|
||||
*/
|
||||
|
||||
export type RecallBudget = "low" | "mid" | "high";
|
||||
|
||||
/** One memory returned by recall. The content lives in `text`. */
|
||||
export interface RecallResult {
|
||||
id: string;
|
||||
text: string;
|
||||
type?: string;
|
||||
context?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface RecallResponse {
|
||||
results: RecallResult[];
|
||||
}
|
||||
|
||||
/** One item to retain. `content` is the only required field. */
|
||||
export interface RetainItem {
|
||||
content: string;
|
||||
context?: string;
|
||||
metadata?: Record<string, string>;
|
||||
/** ISO-8601, `"unset"`, or null (= now). */
|
||||
timestamp?: string | null;
|
||||
}
|
||||
|
||||
export interface RecallOptions {
|
||||
budget?: RecallBudget;
|
||||
maxTokens?: number;
|
||||
types?: Array<"world" | "experience" | "observation">;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markers that fence the recalled-context block injected as a system message.
|
||||
* Used by {@link buildRecallMarkdown} (to wrap) and {@link stripSentinelBlocks}
|
||||
* (to ensure recalled facts are never re-retained).
|
||||
*/
|
||||
export const SENTINEL_OPEN = "<!-- hindsight:recalled-context -->";
|
||||
export const SENTINEL_CLOSE = "<!-- /hindsight:recalled-context -->";
|
||||
|
||||
const SENTINEL_RE = new RegExp(`${SENTINEL_OPEN}[\\s\\S]*?${SENTINEL_CLOSE}`, "g");
|
||||
|
||||
/** Remove any injected recalled-context block from text (defensive de-dup guard). */
|
||||
export function stripSentinelBlocks(text: string): string {
|
||||
return text.replace(SENTINEL_RE, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render recalled memories as a system-message markdown block, fenced with the
|
||||
* sentinel markers so the retain side can recognize and exclude it.
|
||||
*/
|
||||
export function buildRecallMarkdown(results: readonly RecallResult[]): string {
|
||||
if (results.length === 0) return "";
|
||||
const lines = results.map((r) => `- ${r.text}`).join("\n");
|
||||
return [
|
||||
SENTINEL_OPEN,
|
||||
"## What you already know about this user (from long-term memory)",
|
||||
"Use this context to tailor your response. Do not repeat it back verbatim.",
|
||||
"",
|
||||
lines,
|
||||
SENTINEL_CLOSE,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Thin HTTP client for Hindsight's memory REST API. */
|
||||
export class HindsightRestClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly token: string | null;
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(baseUrl: string, token?: string | null, timeoutMs = 15_000) {
|
||||
const url = (baseUrl ?? "").trim();
|
||||
if (!url) throw new Error("Hindsight API URL is required");
|
||||
this.baseUrl = url.replace(/\/$/, "");
|
||||
this.token = token ?? null;
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
private headers(): Record<string, string> {
|
||||
const h: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (this.token) h["Authorization"] = `Bearer ${this.token}`;
|
||||
return h;
|
||||
}
|
||||
|
||||
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
try {
|
||||
const resp = await fetch(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: this.headers(),
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new Error(`Hindsight HTTP ${resp.status} from ${path}: ${text}`);
|
||||
}
|
||||
return (await resp.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recall memories for a bank. `query` is required by the API. */
|
||||
async recall(bankId: string, query: string, opts: RecallOptions = {}): Promise<RecallResponse> {
|
||||
const path = `/v1/default/banks/${encodeURIComponent(bankId)}/memories/recall`;
|
||||
const body: Record<string, unknown> = {
|
||||
query,
|
||||
budget: opts.budget ?? "mid",
|
||||
max_tokens: opts.maxTokens ?? 1024,
|
||||
};
|
||||
if (opts.types) body["types"] = opts.types;
|
||||
return this.request<RecallResponse>("POST", path, body);
|
||||
}
|
||||
|
||||
/** Retain items into a bank. The bank is auto-created on first retain. */
|
||||
async retain(
|
||||
bankId: string,
|
||||
items: readonly RetainItem[],
|
||||
opts: { async?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const path = `/v1/default/banks/${encodeURIComponent(bankId)}/memories`;
|
||||
await this.request("POST", path, { items, async: opts.async ?? true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
HINDSIGHT_CLOUD_API_URL,
|
||||
DEFAULT_RECALL_QUERY,
|
||||
buildRetainContent,
|
||||
recordAssistantMessage,
|
||||
recordUserMessage,
|
||||
resolveAutoMemory,
|
||||
takeTurn,
|
||||
type TurnBuffer,
|
||||
} from "./config";
|
||||
import { buildRecallMarkdown } from "./client";
|
||||
|
||||
const EMPTY_ENV = {} as NodeJS.ProcessEnv;
|
||||
|
||||
describe("resolveAutoMemory", () => {
|
||||
it("defaults to Hindsight Cloud + the broad recall query", () => {
|
||||
const r = resolveAutoMemory({ apiKey: "hsk_k" }, EMPTY_ENV);
|
||||
expect(r.apiUrl).toBe(HINDSIGHT_CLOUD_API_URL);
|
||||
expect(r.bankId).toBe("default");
|
||||
expect(r.recallQuery).toBe(DEFAULT_RECALL_QUERY);
|
||||
expect(r.budget).toBe("mid");
|
||||
});
|
||||
|
||||
it("reads url/key/bank from the environment", () => {
|
||||
const r = resolveAutoMemory({}, {
|
||||
HINDSIGHT_API_URL: "http://localhost:8000",
|
||||
HINDSIGHT_API_KEY: "env_key",
|
||||
HINDSIGHT_BANK_ID: "project-x",
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(r.apiUrl).toBe("http://localhost:8000");
|
||||
expect(r.apiKey).toBe("env_key");
|
||||
expect(r.bankId).toBe("project-x");
|
||||
});
|
||||
|
||||
it("prefers explicit options over the environment", () => {
|
||||
const r = resolveAutoMemory({ apiUrl: "http://opt", apiKey: "opt_key", bankId: "opt_bank" }, {
|
||||
HINDSIGHT_API_URL: "http://env",
|
||||
HINDSIGHT_API_KEY: "env_key",
|
||||
HINDSIGHT_BANK_ID: "env_bank",
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(r.apiUrl).toBe("http://opt");
|
||||
expect(r.apiKey).toBe("opt_key");
|
||||
expect(r.bankId).toBe("opt_bank");
|
||||
});
|
||||
|
||||
it("treats apiKey: null as a no-auth opt-out", () => {
|
||||
const r = resolveAutoMemory({ apiUrl: "http://localhost:8000", apiKey: null }, EMPTY_ENV);
|
||||
expect(r.apiKey).toBeNull();
|
||||
});
|
||||
|
||||
it("throws when targeting Hindsight Cloud without a key", () => {
|
||||
expect(() => resolveAutoMemory({}, EMPTY_ENV)).toThrow(/API key/);
|
||||
});
|
||||
|
||||
it("allows a self-hosted url with no auth", () => {
|
||||
const r = resolveAutoMemory({ apiUrl: "http://localhost:8000", apiKey: null }, EMPTY_ENV);
|
||||
expect(r.apiUrl).toBe("http://localhost:8000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("turn pairing buffer", () => {
|
||||
it("stores only the user message by default (drops the assistant reply)", () => {
|
||||
const buf: TurnBuffer = new Map();
|
||||
recordUserMessage(buf, "t1", "I prefer tabs");
|
||||
recordAssistantMessage(buf, "t1", "Noted.");
|
||||
const pair = takeTurn(buf, "t1");
|
||||
expect(buf.has("t1")).toBe(false); // taken
|
||||
expect(buildRetainContent(pair)).toBe("User: I prefer tabs");
|
||||
});
|
||||
|
||||
it("appends the assistant reply when includeAssistant is set", () => {
|
||||
const buf: TurnBuffer = new Map();
|
||||
recordUserMessage(buf, "t1", "I prefer tabs");
|
||||
recordAssistantMessage(buf, "t1", "Noted.");
|
||||
expect(buildRetainContent(takeTurn(buf, "t1"), true)).toBe(
|
||||
"User: I prefer tabs\n\nAssistant: Noted."
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a turn with no user text", () => {
|
||||
const buf: TurnBuffer = new Map();
|
||||
recordAssistantMessage(buf, "t1", "hello");
|
||||
expect(buildRetainContent(takeTurn(buf, "t1"))).toBeNull();
|
||||
expect(buildRetainContent(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps just the user text when there is no assistant answer", () => {
|
||||
const buf: TurnBuffer = new Map();
|
||||
recordUserMessage(buf, "t1", "remember this");
|
||||
expect(buildRetainContent(takeTurn(buf, "t1"))).toBe("User: remember this");
|
||||
});
|
||||
|
||||
it("strips injected recalled-context from the assistant half when included", () => {
|
||||
const buf: TurnBuffer = new Map();
|
||||
const recalled = buildRecallMarkdown([{ id: "1", text: "user is vegan" }]);
|
||||
recordUserMessage(buf, "t1", "what's for dinner?");
|
||||
recordAssistantMessage(buf, "t1", `${recalled}\nHow about pasta?`);
|
||||
const content = buildRetainContent(takeTurn(buf, "t1"), true);
|
||||
expect(content).toContain("what's for dinner?");
|
||||
expect(content).toContain("How about pasta?");
|
||||
expect(content).not.toContain("user is vegan");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Pure config resolution + turn-pairing helpers for auto-memory. No `eve`
|
||||
* import, so precedence rules and the retain buffer are unit-testable without
|
||||
* the framework.
|
||||
*/
|
||||
import { stripSentinelBlocks, type RecallBudget } from "./client.js";
|
||||
|
||||
/** Hindsight Cloud REST base, used when no API URL is configured. */
|
||||
export const HINDSIGHT_CLOUD_API_URL = "https://api.hindsight.vectorize.io";
|
||||
|
||||
/** Default broad recall query — surfaces the user's ambient profile/context. */
|
||||
export const DEFAULT_RECALL_QUERY = "user preferences, identity, and working context";
|
||||
|
||||
/** Default bank when none is configured (Hindsight auto-creates it). */
|
||||
export const DEFAULT_BANK_ID = "default";
|
||||
|
||||
export interface AutoMemoryOptions {
|
||||
/** Hindsight REST base URL. Defaults to `HINDSIGHT_API_URL`, then Cloud. */
|
||||
apiUrl?: string;
|
||||
/**
|
||||
* API key sent as `Authorization: Bearer <key>`. Defaults to `HINDSIGHT_API_KEY`.
|
||||
* Pass `null` for a no-auth self-hosted server.
|
||||
*/
|
||||
apiKey?: string | null;
|
||||
/** Bank to scope memory to (REST path). Defaults to `HINDSIGHT_BANK_ID`, then `"default"`. */
|
||||
bankId?: string;
|
||||
/** Broad query used for each turn's recall injection. */
|
||||
recallQuery?: string;
|
||||
/** Recall result budget. Defaults to `"mid"`. */
|
||||
budget?: RecallBudget;
|
||||
/** Recall token budget. Defaults to `1024`. */
|
||||
maxTokens?: number;
|
||||
/** `context` tag written on retained items. Defaults to `"eve"`. */
|
||||
context?: string;
|
||||
/**
|
||||
* Also store the assistant's reply (not just the user's message). Off by
|
||||
* default — retaining only what the user says keeps banks clean and avoids
|
||||
* re-storing the agent's own acknowledgments/chatter.
|
||||
*/
|
||||
includeAssistantReply?: boolean;
|
||||
/** HTTP timeout in ms. Defaults to `15000`. */
|
||||
timeoutMs?: number;
|
||||
/** Called when a recall/retain HTTP call fails. Defaults to `console.warn`. */
|
||||
onError?: (error: unknown, phase: "recall" | "retain") => void;
|
||||
}
|
||||
|
||||
export interface ResolvedAutoMemory {
|
||||
apiUrl: string;
|
||||
apiKey: string | null;
|
||||
bankId: string;
|
||||
recallQuery: string;
|
||||
budget: RecallBudget;
|
||||
maxTokens: number;
|
||||
context: string;
|
||||
includeAssistantReply: boolean;
|
||||
timeoutMs: number;
|
||||
onError: (error: unknown, phase: "recall" | "retain") => void;
|
||||
}
|
||||
|
||||
/** First non-empty string among the candidates, or `null`. */
|
||||
function firstNonEmpty(...values: Array<string | null | undefined>): string | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a URL points at Hindsight Cloud, matched on host (so a trailing slash
|
||||
* or regional subdomain still triggers the missing-key guard). The dot boundary
|
||||
* avoids matching look-alikes like `nothindsight.vectorize.io`.
|
||||
*/
|
||||
export function isHindsightCloudUrl(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).hostname;
|
||||
return host === "hindsight.vectorize.io" || host.endsWith(".hindsight.vectorize.io");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve options against env defaults. Pure; throws on Cloud + no key. */
|
||||
export function resolveAutoMemory(
|
||||
options: AutoMemoryOptions = {},
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): ResolvedAutoMemory {
|
||||
const apiUrl = options.apiUrl ?? firstNonEmpty(env.HINDSIGHT_API_URL) ?? HINDSIGHT_CLOUD_API_URL;
|
||||
|
||||
// `apiKey: null` is an explicit no-auth opt-out; `undefined` falls back to the env var.
|
||||
const apiKey =
|
||||
options.apiKey === undefined ? firstNonEmpty(env.HINDSIGHT_API_KEY) : options.apiKey;
|
||||
|
||||
if (isHindsightCloudUrl(apiUrl) && !apiKey) {
|
||||
throw new Error(
|
||||
"Hindsight Cloud requires an API key. Set HINDSIGHT_API_KEY, pass `apiKey`, or point " +
|
||||
"`apiUrl`/HINDSIGHT_API_URL at a self-hosted server (use `apiKey: null` for a no-auth server)."
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
apiUrl,
|
||||
apiKey,
|
||||
bankId: options.bankId ?? firstNonEmpty(env.HINDSIGHT_BANK_ID) ?? DEFAULT_BANK_ID,
|
||||
recallQuery: options.recallQuery ?? DEFAULT_RECALL_QUERY,
|
||||
budget: options.budget ?? "mid",
|
||||
maxTokens: options.maxTokens ?? 1024,
|
||||
context: options.context ?? "eve",
|
||||
includeAssistantReply: options.includeAssistantReply ?? false,
|
||||
timeoutMs: options.timeoutMs ?? 15_000,
|
||||
// eslint-disable-next-line no-console
|
||||
onError: options.onError ?? ((error) => console.warn("[hindsight-eve] memory error:", error)),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Turn pairing buffer — collects the user message and assistant answer for a
|
||||
// turn so they can be retained together once the turn completes.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TurnPair {
|
||||
user?: string;
|
||||
assistant?: string;
|
||||
}
|
||||
export type TurnBuffer = Map<string, TurnPair>;
|
||||
|
||||
/** Hard cap so a long-lived worker never leaks turns whose flush was missed. */
|
||||
const MAX_BUFFERED_TURNS = 256;
|
||||
|
||||
function upsert(buffer: TurnBuffer, turnId: string, patch: TurnPair): void {
|
||||
const existing = buffer.get(turnId) ?? {};
|
||||
buffer.set(turnId, { ...existing, ...patch });
|
||||
if (buffer.size > MAX_BUFFERED_TURNS) {
|
||||
const oldest = buffer.keys().next().value;
|
||||
if (oldest !== undefined) buffer.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function recordUserMessage(buffer: TurnBuffer, turnId: string, text: string): void {
|
||||
upsert(buffer, turnId, { user: text });
|
||||
}
|
||||
|
||||
export function recordAssistantMessage(buffer: TurnBuffer, turnId: string, text: string): void {
|
||||
upsert(buffer, turnId, { assistant: text });
|
||||
}
|
||||
|
||||
/** Remove and return a turn's buffered pair (used at flush time). */
|
||||
export function takeTurn(buffer: TurnBuffer, turnId: string): TurnPair | undefined {
|
||||
const pair = buffer.get(turnId);
|
||||
buffer.delete(turnId);
|
||||
return pair;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the retain `content` for a turn, or `null` to skip. Skips turns with no
|
||||
* user text. By default stores only the user's message (the durable signal);
|
||||
* with `includeAssistant`, appends the assistant reply with any injected
|
||||
* recalled-context block stripped out so recalled facts are never re-retained.
|
||||
*/
|
||||
export function buildRetainContent(
|
||||
pair: TurnPair | undefined,
|
||||
includeAssistant = false
|
||||
): string | null {
|
||||
const user = (pair?.user ?? "").trim();
|
||||
if (!user) return null;
|
||||
if (!includeAssistant) return `User: ${user}`;
|
||||
const assistant = stripSentinelBlocks(pair?.assistant ?? "").trim();
|
||||
return assistant ? `User: ${user}\n\nAssistant: ${assistant}` : `User: ${user}`;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { once } from "eve/tools/approval";
|
||||
import {
|
||||
resolveHindsightConnection,
|
||||
buildHindsightConnectionDefinition,
|
||||
defineHindsightConnection,
|
||||
HINDSIGHT_CLOUD_MCP_URL,
|
||||
DEFAULT_DESCRIPTION,
|
||||
} from "./index";
|
||||
|
||||
const EMPTY_ENV = {} as NodeJS.ProcessEnv;
|
||||
|
||||
describe("resolveHindsightConnection", () => {
|
||||
it("defaults to Hindsight Cloud with the default description", () => {
|
||||
const resolved = resolveHindsightConnection({ apiKey: "hsk_test" }, EMPTY_ENV);
|
||||
expect(resolved.url).toBe(HINDSIGHT_CLOUD_MCP_URL);
|
||||
expect(resolved.description).toBe(DEFAULT_DESCRIPTION);
|
||||
expect(resolved.apiKey).toBe("hsk_test");
|
||||
expect(resolved.bankId).toBeNull();
|
||||
});
|
||||
|
||||
it("reads url, key, and bank from the environment", () => {
|
||||
const resolved = resolveHindsightConnection({}, {
|
||||
HINDSIGHT_MCP_URL: "http://localhost:8000/mcp",
|
||||
HINDSIGHT_API_KEY: "env_key",
|
||||
HINDSIGHT_MCP_BANK_ID: "project-x",
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(resolved.url).toBe("http://localhost:8000/mcp");
|
||||
expect(resolved.apiKey).toBe("env_key");
|
||||
expect(resolved.bankId).toBe("project-x");
|
||||
});
|
||||
|
||||
it("prefers explicit options over the environment", () => {
|
||||
const resolved = resolveHindsightConnection(
|
||||
{ url: "http://opt/mcp", apiKey: "opt_key", bankId: "opt_bank" },
|
||||
{
|
||||
HINDSIGHT_MCP_URL: "http://env/mcp",
|
||||
HINDSIGHT_API_KEY: "env_key",
|
||||
HINDSIGHT_MCP_BANK_ID: "env_bank",
|
||||
} as NodeJS.ProcessEnv
|
||||
);
|
||||
expect(resolved.url).toBe("http://opt/mcp");
|
||||
expect(resolved.apiKey).toBe("opt_key");
|
||||
expect(resolved.bankId).toBe("opt_bank");
|
||||
});
|
||||
|
||||
it("treats apiKey: null as an explicit no-auth opt-out", () => {
|
||||
const resolved = resolveHindsightConnection(
|
||||
{ url: "http://localhost:8000/mcp", apiKey: null },
|
||||
{ HINDSIGHT_API_KEY: "env_key" } as NodeJS.ProcessEnv
|
||||
);
|
||||
expect(resolved.apiKey).toBeNull();
|
||||
});
|
||||
|
||||
it("throws when targeting Hindsight Cloud without a key", () => {
|
||||
expect(() => resolveHindsightConnection({}, EMPTY_ENV)).toThrow(/API key/);
|
||||
});
|
||||
|
||||
it("throws for a Cloud URL with a trailing slash and no key", () => {
|
||||
expect(() =>
|
||||
resolveHindsightConnection({ url: "https://api.hindsight.vectorize.io/mcp/" }, EMPTY_ENV)
|
||||
).toThrow(/API key/);
|
||||
});
|
||||
|
||||
it("throws for a regional Cloud subdomain with no key", () => {
|
||||
expect(() =>
|
||||
resolveHindsightConnection({ url: "https://api.eu.hindsight.vectorize.io/mcp" }, EMPTY_ENV)
|
||||
).toThrow(/API key/);
|
||||
});
|
||||
|
||||
it("does not treat a look-alike host as Cloud", () => {
|
||||
// `nothindsight.vectorize.io` must not match the Cloud guard, so a no-auth
|
||||
// self-hosted server on a similar domain is allowed.
|
||||
const resolved = resolveHindsightConnection(
|
||||
{ url: "https://nothindsight.vectorize.io/mcp", apiKey: null },
|
||||
EMPTY_ENV
|
||||
);
|
||||
expect(resolved.apiKey).toBeNull();
|
||||
});
|
||||
|
||||
it("allows a self-hosted url with no auth", () => {
|
||||
const resolved = resolveHindsightConnection(
|
||||
{ url: "http://localhost:8000/mcp", apiKey: null },
|
||||
EMPTY_ENV
|
||||
);
|
||||
expect(resolved.url).toBe("http://localhost:8000/mcp");
|
||||
expect(resolved.apiKey).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores empty-string environment values", () => {
|
||||
const resolved = resolveHindsightConnection({ apiKey: "k" }, {
|
||||
HINDSIGHT_MCP_URL: "",
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(resolved.url).toBe(HINDSIGHT_CLOUD_MCP_URL);
|
||||
});
|
||||
|
||||
it("passes tool filters through unchanged", () => {
|
||||
const resolved = resolveHindsightConnection(
|
||||
{ apiKey: "k", tools: { allow: ["recall", "retain"] } },
|
||||
EMPTY_ENV
|
||||
);
|
||||
expect(resolved.tools).toEqual({ allow: ["recall", "retain"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHindsightConnectionDefinition", () => {
|
||||
it("wires bearer auth whose getToken returns the configured key", async () => {
|
||||
const definition = buildHindsightConnectionDefinition(
|
||||
resolveHindsightConnection({ url: "http://localhost:8000/mcp", apiKey: "k" }, EMPTY_ENV)
|
||||
);
|
||||
expect(definition.url).toBe("http://localhost:8000/mcp");
|
||||
const auth = definition.auth as { getToken: () => Promise<{ token: string }> };
|
||||
expect(await auth.getToken()).toEqual({ token: "k" });
|
||||
});
|
||||
|
||||
it("emits no auth when the key is null", () => {
|
||||
const definition = buildHindsightConnectionDefinition(
|
||||
resolveHindsightConnection({ url: "http://localhost:8000/mcp", apiKey: null }, EMPTY_ENV)
|
||||
);
|
||||
expect(definition.auth).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sets the X-Bank-Id header when a bank is configured", () => {
|
||||
const definition = buildHindsightConnectionDefinition(
|
||||
resolveHindsightConnection({ apiKey: "k", bankId: "project-x" }, EMPTY_ENV)
|
||||
);
|
||||
expect(definition.headers).toEqual({ "X-Bank-Id": "project-x" });
|
||||
});
|
||||
|
||||
it("passes the approval policy through unchanged", () => {
|
||||
const approval = once();
|
||||
const definition = buildHindsightConnectionDefinition(
|
||||
resolveHindsightConnection({ apiKey: "k", approval }, EMPTY_ENV)
|
||||
);
|
||||
expect(definition.approval).toBe(approval);
|
||||
});
|
||||
|
||||
it("omits approval when none is configured", () => {
|
||||
const definition = buildHindsightConnectionDefinition(
|
||||
resolveHindsightConnection({ apiKey: "k" }, EMPTY_ENV)
|
||||
);
|
||||
expect(definition.approval).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("defineHindsightConnection", () => {
|
||||
it("builds a connection via the real eve framework without throwing", () => {
|
||||
const connection = defineHindsightConnection({
|
||||
url: "http://localhost:8000/mcp",
|
||||
apiKey: "k",
|
||||
});
|
||||
expect(connection).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,149 +1,46 @@
|
||||
/**
|
||||
* Hindsight long-term memory for Vercel Eve agents.
|
||||
* Hindsight long-term memory for Vercel Eve agents — automatic, no-tool memory.
|
||||
*
|
||||
* Wraps eve's `defineMcpClientConnection` so an agent gains persistent memory by
|
||||
* dropping a single file under `agent/connections/`. The helper fills in the
|
||||
* Hindsight MCP endpoint, a model-facing description, and bearer auth, reading
|
||||
* sensible defaults from the environment:
|
||||
* Two authored files give an Eve agent memory that just works, without the model
|
||||
* ever deciding to call a tool: relevant memory is injected before each turn, and
|
||||
* the exchange is retained after.
|
||||
*
|
||||
* ```ts
|
||||
* // agent/connections/hindsight.ts
|
||||
* import { defineHindsightConnection } from "@vectorize-io/hindsight-eve";
|
||||
* export default defineHindsightConnection(); // HINDSIGHT_MCP_URL + HINDSIGHT_API_KEY
|
||||
* // agent/instructions/hindsight.ts
|
||||
* import { hindsightMemory } from "@vectorize-io/hindsight-eve";
|
||||
* export default hindsightMemory();
|
||||
*
|
||||
* // agent/hooks/hindsight.ts
|
||||
* import { hindsightRetainHook } from "@vectorize-io/hindsight-eve";
|
||||
* export default hindsightRetainHook();
|
||||
* ```
|
||||
*
|
||||
* Configure via env: `HINDSIGHT_API_KEY`, `HINDSIGHT_API_URL` (defaults to
|
||||
* Hindsight Cloud), `HINDSIGHT_BANK_ID`.
|
||||
*/
|
||||
import { defineMcpClientConnection } from "eve/connections";
|
||||
export {
|
||||
hindsightMemory,
|
||||
hindsightAutoRecall,
|
||||
hindsightRetainHook,
|
||||
type AutoMemoryOptions,
|
||||
} from "./auto-memory.js";
|
||||
|
||||
/** The argument eve's connection factory accepts; options pass straight through. */
|
||||
type McpConnectionInput = Parameters<typeof defineMcpClientConnection>[0];
|
||||
export {
|
||||
resolveAutoMemory,
|
||||
isHindsightCloudUrl,
|
||||
HINDSIGHT_CLOUD_API_URL,
|
||||
DEFAULT_RECALL_QUERY,
|
||||
DEFAULT_BANK_ID,
|
||||
type ResolvedAutoMemory,
|
||||
} from "./config.js";
|
||||
|
||||
/** Hindsight Cloud MCP endpoint, used when no URL is configured. */
|
||||
export const HINDSIGHT_CLOUD_MCP_URL = "https://api.hindsight.vectorize.io/mcp";
|
||||
|
||||
/**
|
||||
* Default model-facing description written into the generated connection. Eve
|
||||
* surfaces it when the agent discovers this connection's tools
|
||||
* (`connection__hindsight__retain` / `recall` / `reflect`).
|
||||
*/
|
||||
export const DEFAULT_DESCRIPTION =
|
||||
"Hindsight long-term memory: retain facts from this session, recall relevant history " +
|
||||
"from past sessions, and reflect over consolidated mental models.";
|
||||
|
||||
export interface HindsightConnectionOptions {
|
||||
/** Hindsight MCP endpoint. Defaults to `HINDSIGHT_MCP_URL`, then Hindsight Cloud. */
|
||||
url?: string;
|
||||
/**
|
||||
* API key sent as `Authorization: Bearer <key>`. Defaults to `HINDSIGHT_API_KEY`.
|
||||
* Pass `null` to emit a no-auth connection (local/self-hosted dev only).
|
||||
*/
|
||||
apiKey?: string | null;
|
||||
/** Bank to scope memory to; sent as the `X-Bank-Id` header. Defaults to `HINDSIGHT_MCP_BANK_ID`. */
|
||||
bankId?: string;
|
||||
/** Override the model-facing description. */
|
||||
description?: string;
|
||||
/** Restrict which Hindsight tools the model can see. */
|
||||
tools?: McpConnectionInput["tools"];
|
||||
/** Human-in-the-loop approval policy (e.g. `once()` from `eve/tools/approval`). */
|
||||
approval?: McpConnectionInput["approval"];
|
||||
}
|
||||
|
||||
/** Fully-resolved connection settings, after applying options and environment defaults. */
|
||||
export interface ResolvedHindsightConnection {
|
||||
url: string;
|
||||
description: string;
|
||||
apiKey: string | null;
|
||||
bankId: string | null;
|
||||
tools?: McpConnectionInput["tools"];
|
||||
approval?: McpConnectionInput["approval"];
|
||||
}
|
||||
|
||||
/** First non-empty string among the candidates, or `null`. */
|
||||
function firstNonEmpty(...values: Array<string | null | undefined>): string | null {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a URL points at Hindsight Cloud. Matched on host (not exact string)
|
||||
* so a trailing slash, `http`/`https`, or a regional subdomain still triggers
|
||||
* the missing-key guard below instead of letting the request fail with a raw
|
||||
* 401. The dot boundary keeps it from matching look-alike hosts like
|
||||
* `nothindsight.vectorize.io`.
|
||||
*/
|
||||
function isHindsightCloudUrl(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).hostname;
|
||||
return host === "hindsight.vectorize.io" || host.endsWith(".hindsight.vectorize.io");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve options against environment defaults. Pure and side-effect free so the
|
||||
* precedence rules can be unit-tested without constructing a live connection.
|
||||
*/
|
||||
export function resolveHindsightConnection(
|
||||
options: HindsightConnectionOptions = {},
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): ResolvedHindsightConnection {
|
||||
const url = options.url ?? firstNonEmpty(env.HINDSIGHT_MCP_URL) ?? HINDSIGHT_CLOUD_MCP_URL;
|
||||
|
||||
// `apiKey: null` is an explicit no-auth opt-out; `undefined` falls back to the env var.
|
||||
const apiKey =
|
||||
options.apiKey === undefined ? firstNonEmpty(env.HINDSIGHT_API_KEY) : options.apiKey;
|
||||
|
||||
const bankId = options.bankId ?? firstNonEmpty(env.HINDSIGHT_MCP_BANK_ID);
|
||||
|
||||
if (isHindsightCloudUrl(url) && !apiKey) {
|
||||
throw new Error(
|
||||
"Hindsight Cloud requires an API key. Set HINDSIGHT_API_KEY, pass `apiKey`, or point " +
|
||||
"`url`/HINDSIGHT_MCP_URL at a self-hosted server (use `apiKey: null` for a no-auth server)."
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
description: options.description ?? DEFAULT_DESCRIPTION,
|
||||
apiKey,
|
||||
bankId,
|
||||
tools: options.tools,
|
||||
approval: options.approval,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the plain definition object handed to eve. Kept separate from
|
||||
* {@link defineHindsightConnection} so the auth/header wiring is testable without
|
||||
* depending on the shape of eve's returned connection.
|
||||
*/
|
||||
export function buildHindsightConnectionDefinition(
|
||||
resolved: ResolvedHindsightConnection
|
||||
): McpConnectionInput {
|
||||
return {
|
||||
url: resolved.url,
|
||||
description: resolved.description,
|
||||
// `{ token }` is eve's TokenResult shape (sent as `Authorization: Bearer`).
|
||||
// It rides on eve 0.11's auth contract, which the pinned peer/dev dep covers.
|
||||
...(resolved.apiKey
|
||||
? { auth: { getToken: async () => ({ token: resolved.apiKey as string }) } }
|
||||
: {}),
|
||||
...(resolved.bankId ? { headers: { "X-Bank-Id": resolved.bankId } } : {}),
|
||||
...(resolved.tools ? { tools: resolved.tools } : {}),
|
||||
...(resolved.approval ? { approval: resolved.approval } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Define an eve MCP connection to a Hindsight memory server. Export the result as
|
||||
* the default from `agent/connections/hindsight.ts`.
|
||||
*/
|
||||
export function defineHindsightConnection(options: HindsightConnectionOptions = {}) {
|
||||
return defineMcpClientConnection(
|
||||
buildHindsightConnectionDefinition(resolveHindsightConnection(options))
|
||||
);
|
||||
}
|
||||
|
||||
export default defineHindsightConnection;
|
||||
export {
|
||||
HindsightRestClient,
|
||||
buildRecallMarkdown,
|
||||
stripSentinelBlocks,
|
||||
type RecallResult,
|
||||
type RecallResponse,
|
||||
type RetainItem,
|
||||
type RecallBudget,
|
||||
type RecallOptions,
|
||||
} from "./client.js";
|
||||
|
||||
Generated
-43
@@ -369,14 +369,12 @@
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.5",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
@@ -386,8 +384,6 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cron-parser": "^5.6.1",
|
||||
"cronstrue": "^3.21.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.1",
|
||||
"lucide-react": "^0.553.0",
|
||||
@@ -8933,39 +8929,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slider": {
|
||||
"version": "1.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
|
||||
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/number": "1.1.1",
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-collection": "1.1.7",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-direction": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||
"@radix-ui/react-use-previous": "1.1.1",
|
||||
"@radix-ui/react-use-size": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||
@@ -10913,12 +10876,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cytoscape": {
|
||||
"version": "3.21.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
|
||||
"integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3": {
|
||||
"version": "7.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
|
||||
|
||||
Reference in New Issue
Block a user