Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions hermes-plugin/memory/memory_tencentdb/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1020,6 +1020,7 @@ def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> st
query=query,
limit=_coerce_limit(args.get("limit")),
type_filter=args.get("type", ""),
user_id=self._user_id,
)
self._record_success()
return json.dumps(result)
Expand All @@ -1031,6 +1032,7 @@ def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> st
result = self._client.search_conversations(
query=query,
limit=_coerce_limit(args.get("limit")),
user_id=self._user_id,
)
self._record_success()
return json.dumps(result)
Expand Down
24 changes: 22 additions & 2 deletions hermes-plugin/memory/memory_tencentdb/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,18 +139,35 @@ def capture(
body["user_id"] = user_id
return self._post("/capture", body)

def search_memories(self, query: str, limit: int = 5, type_filter: str = "", scene: str = "") -> Dict[str, Any]:
def search_memories(
self,
query: str,
limit: int = 5,
type_filter: str = "",
scene: str = "",
user_id: str = "",
) -> Dict[str, Any]:
"""Search L1 structured memories."""
body: Dict[str, Any] = {"query": query, "limit": limit}
if user_id:
body["user_id"] = user_id
if type_filter:
body["type"] = type_filter
if scene:
body["scene"] = scene
return self._post("/search/memories", body)

def search_conversations(self, query: str, limit: int = 5, session_key: str = "") -> Dict[str, Any]:
def search_conversations(
self,
query: str,
limit: int = 5,
session_key: str = "",
user_id: str = "",
) -> Dict[str, Any]:
"""Search L0 raw conversations."""
body: Dict[str, Any] = {"query": query, "limit": limit}
if user_id:
body["user_id"] = user_id
if session_key:
body["session_key"] = session_key
return self._post("/search/conversations", body)
Expand All @@ -166,6 +183,7 @@ def seed(
self,
data: Any,
session_key: str = "",
user_id: str = "",
strict_round_role: bool = False,
auto_fill_timestamps: bool = True,
config_override: Optional[Dict[str, Any]] = None,
Expand All @@ -187,6 +205,8 @@ def seed(
body: Dict[str, Any] = {"data": data}
if session_key:
body["session_key"] = session_key
if user_id:
body["user_id"] = user_id
if strict_round_role:
body["strict_round_role"] = True
if not auto_fill_timestamps:
Expand Down
145 changes: 145 additions & 0 deletions src/gateway/server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { afterEach, describe, expect, it } from "vitest";
import { parseConfig } from "../config.js";
import { TdaiGateway } from "./server.js";

async function pickFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
server.close(() => reject(new Error("failed to allocate port")));
return;
}
const port = address.port;
server.close(() => resolve(port));
});
});
}

async function postJson<T>(port: number, pathName: string, body: unknown): Promise<T> {
const response = await fetch(`http://127.0.0.1:${port}${pathName}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await response.text();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${text}`);
}
return JSON.parse(text) as T;
}

async function listJsonlFiles(root: string): Promise<string[]> {
try {
const entries = await readdir(root, { recursive: true, withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
.map((entry) => path.join(entry.parentPath, entry.name))
.sort();
} catch {
return [];
}
}

describe("TdaiGateway user scoping", () => {
let gateway: TdaiGateway | undefined;
let dataDir: string | undefined;

afterEach(async () => {
if (gateway) {
await gateway.stop();
gateway = undefined;
}
if (dataDir) {
await rm(dataDir, { recursive: true, force: true });
dataDir = undefined;
}
});

it("does not expose legacy persona data to a non-default user_id", async () => {
dataDir = await mkdtemp(path.join(os.tmpdir(), "tdai-gateway-user-scope-"));
await writeFile(
path.join(dataDir, "persona.md"),
"Alice legacy private persona marker",
"utf-8",
);
const port = await pickFreePort();
const memory = parseConfig({
extraction: { enabled: false },
embedding: { provider: "none" },
recall: { strategy: "keyword" },
});

gateway = new TdaiGateway({
server: { host: "127.0.0.1", port, corsOrigins: [] },
data: { baseDir: dataDir },
memory,
});
await gateway.start();

const bobRecall = await postJson<{ context: string; memory_count: number }>(port, "/recall", {
user_id: "bob",
session_key: "shared-session",
query: "private persona marker",
});
expect(bobRecall.context).toBe("");
expect(bobRecall.memory_count).toBe(0);

const defaultRecall = await postJson<{ context: string; memory_count: number }>(port, "/recall", {
session_key: "shared-session",
query: "private persona marker",
});
expect(defaultRecall.context).toContain("Alice legacy private persona marker");

const defaultAliasRecall = await postJson<{ context: string; memory_count: number }>(port, "/recall", {
user_id: "default",
session_key: "shared-session",
query: "private persona marker",
});
expect(defaultAliasRecall.context).toContain("Alice legacy private persona marker");
});

it("captures non-default user_id data outside the legacy base directory", async () => {
dataDir = await mkdtemp(path.join(os.tmpdir(), "tdai-gateway-user-scope-"));
const port = await pickFreePort();
const memory = parseConfig({
extraction: { enabled: false },
embedding: { provider: "none" },
recall: { strategy: "keyword" },
});

gateway = new TdaiGateway({
server: { host: "127.0.0.1", port, corsOrigins: [] },
data: { baseDir: dataDir },
memory,
});
await gateway.start();

const now = Date.now() + 10_000;
const capture = await postJson<{ l0_recorded: number }>(port, "/capture", {
user_id: "alice",
session_key: "shared-session",
user_content: "alice private sentinel project alpha",
assistant_content: "acknowledged alice private sentinel project alpha",
messages: [
{ role: "user", content: "alice private sentinel project alpha", timestamp: now },
{ role: "assistant", content: "acknowledged alice private sentinel project alpha", timestamp: now + 1 },
],
});
expect(capture.l0_recorded).toBeGreaterThan(0);

const legacyFiles = await listJsonlFiles(path.join(dataDir, "conversations"));
expect(legacyFiles).toEqual([]);

const scopedFiles = await listJsonlFiles(path.join(dataDir, "users"));
expect(scopedFiles.length).toBeGreaterThan(0);
const scopedText = (await Promise.all(scopedFiles.map((file) => readFile(file, "utf-8")))).join("\n");
expect(scopedText).toContain("alice private sentinel project alpha");
});
});
Loading
Loading