feat(memory): enforce English-only memory indexing with CJK guardrail

Memory layer is now English-indexed: memories() query terms and
  remember() summaries must be English. Adds a runtime assertion that
  rejects CJK text in both paths, guiding the agent to translate
  non-English user requests before querying or writing memories.
  Ensures consistent retrieval regardless of conversation language.
This commit is contained in:
tommy0103
2026-06-10 03:50:38 +08:00
parent 89b53d4570
commit b52f57b538
5 changed files with 45 additions and 17 deletions
+15 -9
View File
@@ -59,7 +59,7 @@ session evidence before deciding whether a detail pass is needed:
```js ```js
const map = overview({ limit: 6 }); const map = overview({ limit: 6 });
const project = map.current.project?.project; const project = map.current.project?.project;
const topic = 'topic terms from the user request'; const topic = 'English topic terms translated from the user request';
return { return {
orientation: map.current_project, orientation: map.current_project,
@@ -161,7 +161,7 @@ tiny sample before relying on less common filters.
- `trace(uuid)` -- parent chain from root to message. - `trace(uuid)` -- parent chain from root to message.
- `thread(sessionId)` -- full session messages; last resort only. - `thread(sessionId)` -- full session messages; last resort only.
- `raw(uuid, opts?)` -- windowed access to the original JSONL line. - `raw(uuid, opts?)` -- windowed access to the original JSONL line.
- `memories(opts?)` -- recall memory layer, newest first. opts: `{ query, project, sessionId, sessions, after, before, branch, limit }`. `query` filters summary/path by terms. Returns registered memory records (id, path, summary, project, session_id, created_at). Read the file at `path` for full content. - `memories(opts?)` -- recall memory layer, newest first. opts: `{ query, project, sessionId, sessions, after, before, branch, limit }`. `query` filters summary/path by English terms. Returns registered memory records (id, path, summary, project, session_id, created_at). Read the file at `path` for full content.
## Retrieval Contract ## Retrieval Contract
@@ -189,10 +189,16 @@ previously recorded, and compare it with raw session evidence when correctness
depends on it. Raw session data is the evidence layer, but one hit is not a depends on it. Raw session data is the evidence layer, but one hit is not a
complete truth; query and cite it compactly. complete truth; query and cite it compactly.
**Recall:** query `memories({ query: 'topic terms', project: '...' })` to find The memory layer is English-indexed. Use English terms in `memories({ query })`
prior conclusions relevant to the current task. Like other list helpers, even when the user asks in another language. Write every `remember().summary`
passing a string is treated as `sessionId`, and passing a number is treated as in English, regardless of the current conversation language. The runtime rejects
`limit`. Read the file at `path` for full content. obvious CJK text in memory queries and summaries as a guardrail.
**Recall:** query `memories({ query: 'English topic terms', project: '...' })`
to find prior conclusions relevant to the current task. Translate non-English
user requests into concise English query terms before calling `memories()`. Like
other list helpers, passing a string is treated as `sessionId`, and passing a
number is treated as `limit`. Read the file at `path` for full content.
Good memory candidates include design decisions, project conventions, abandoned Good memory candidates include design decisions, project conventions, abandoned
alternatives, repeated failure causes, workflow patterns, and conclusions alternatives, repeated failure causes, workflow patterns, and conclusions
@@ -231,9 +237,9 @@ paths are resolved against the source session's `project_path` when
`session_id` is provided, then stored as normalized absolute paths. Prefer `session_id` is provided, then stored as normalized absolute paths. Prefer
project-relative paths such as `.obelisk/memories/...` plus `session_id`. project-relative paths such as `.obelisk/memories/...` plus `session_id`.
`summary` should be detailed enough that `memories()` results alone can judge `summary` must be English and detailed enough that `memories()` results alone
relevance without reading the file. Include the decision, the reasoning, and can judge relevance without reading the file. Include the decision, the
the key constraints — not just a title. reasoning, and the key constraints — not just a title.
The `message_start`/`message_end` range marks where in the conversation this The `message_start`/`message_end` range marks where in the conversation this
conclusion was drawn. Use it later to trace back to the original evidence. conclusion was drawn. Use it later to trace back to the original evidence.
+2 -1
View File
@@ -18,7 +18,7 @@ faceted detail pass if the first pass reveals useful projects, sessions, files,
or terms. or terms.
```js ```js
const topic = 'topic terms from the user request'; const topic = 'English topic terms translated from the user request';
const map = overview({ limit: 6 }); const map = overview({ limit: 6 });
const project = map.current.project?.project; const project = map.current.project?.project;
const scoped = project ? { project } : {}; const scoped = project ? { project } : {};
@@ -124,6 +124,7 @@ return hits.slice(0, 5).map(h => {
Use this when prior conclusions may exist but the answer still depends on raw Use this when prior conclusions may exist but the answer still depends on raw
session evidence. Keep memory as prior notes, not final authority; compare it session evidence. Keep memory as prior notes, not final authority; compare it
with session evidence in your final answer when correctness matters. with session evidence in your final answer when correctness matters.
Memory query terms are English even when the user asks in another language.
```js ```js
const project = '%quiet-zero%'; const project = '%quiet-zero%';
+5
View File
@@ -106,6 +106,11 @@ For semantic questions, build a task-local evidence view:
} }
``` ```
Memory recall is English-indexed: translate non-English user requests into
concise English query terms before calling `memories({ query })`. Memory
summaries registered with `remember()` are also English, regardless of the
conversation language.
Then synthesize the conclusion in the final answer. Do not pretend the raw Then synthesize the conclusion in the final answer. Do not pretend the raw
evidence view is itself a stored Obelisk entity. evidence view is itself a stored Obelisk entity.
+10 -7
View File
@@ -527,7 +527,7 @@ is treated as `sessionId`, and passing a number is treated as `limit`.
| Param | Type | Description | | Param | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `opts.query` | `string` | Term filter over `summary` and `path`; hyphens/underscores are treated as spaces | | `opts.query` | `string` | English term filter over `summary` and `path`; hyphens/underscores are treated as spaces |
| `opts.project` | `string` | SQL `LIKE` pattern over `memories.project` | | `opts.project` | `string` | SQL `LIKE` pattern over `memories.project` |
| `opts.sessionId` | `string` | Restrict to one source session | | `opts.sessionId` | `string` | Restrict to one source session |
| `opts.sessions` | `string[]` | Restrict to a set of source session IDs | | `opts.sessions` | `string[]` | Restrict to a set of source session IDs |
@@ -538,9 +538,11 @@ is treated as `sessionId`, and passing a number is treated as `limit`.
**Returns:** `Array<memory_row>` ordered by `created_at` descending. **Returns:** `Array<memory_row>` ordered by `created_at` descending.
`query` is a lightweight term filter, not FTS5 ranking. Use it to avoid pulling `query` is a lightweight English term filter, not FTS5 ranking. Translate
all recent memories, then read the markdown file at `path` when a memory looks non-English user requests into concise English query terms before calling
relevant. `memories()`. Use it to avoid pulling all recent memories, then read the
markdown file at `path` when a memory looks relevant. The runtime rejects
obvious CJK text in memory queries.
```js ```js
const prior = memories({ const prior = memories({
@@ -569,14 +571,15 @@ normal `--query` script.
| Param | Type | Description | | Param | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `record.path` | `string` | Existing markdown file path. Relative paths resolve against the source session `project_path` when `session_id` is provided, otherwise against the runtime cwd | | `record.path` | `string` | Existing markdown file path. Relative paths resolve against the source session `project_path` when `session_id` is provided, otherwise against the runtime cwd |
| `record.summary` | `string` | Required retrieval summary: decision, reasoning, constraints | | `record.summary` | `string` | Required English retrieval summary: decision, reasoning, constraints |
| `record.session_id` | `string` | Source session ID, if known | | `record.session_id` | `string` | Source session ID, if known |
| `record.message_start` | `string` | First relevant source message UUID, if known | | `record.message_start` | `string` | First relevant source message UUID, if known |
| `record.message_end` | `string` | Last relevant source message UUID, if known | | `record.message_end` | `string` | Last relevant source message UUID, if known |
| `record.project` | `string` | Project slug override. Defaults from `sessions.project` for `session_id` | | `record.project` | `string` | Project slug override. Defaults from `sessions.project` for `session_id` |
`remember()` validates that `path` exists and is a regular file. It stores the `remember()` validates that `path` exists and is a regular file, and rejects
normalized absolute path in `memories.path`. obvious CJK text in `summary`. It stores the normalized absolute path in
`memories.path`.
**Returns:** `{ id, path, project, created_at }`. **Returns:** `{ id, path, project, created_at }`.
+13
View File
@@ -34,6 +34,17 @@ function assertReadOnlySql(sql) {
} }
} }
const CJK_TEXT_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
function assertEnglishMemoryText(value, label) {
const text = String(value || '');
if (!text.trim()) return;
if (CJK_TEXT_RE.test(text)) {
const requirement = label.includes('query') ? 'must use English terms' : 'must be written in English';
throw new Error(`${label} ${requirement}; translate user-language terms before using the memory layer`);
}
}
function createQueryApi(db) { function createQueryApi(db) {
const q = (sql, ...p) => { const q = (sql, ...p) => {
assertReadOnlySql(sql); assertReadOnlySql(sql);
@@ -403,6 +414,7 @@ function createQueryApi(db) {
const memories = (optsOrSid) => { const memories = (optsOrSid) => {
const opts = normalizeOpts(optsOrSid); const opts = normalizeOpts(optsOrSid);
const { limit = 50, query } = opts; const { limit = 50, query } = opts;
assertEnglishMemoryText(query, 'memories() query');
const needsJoin = opts.branch; const needsJoin = opts.branch;
const { where: baseWhere, params } = buildWhere(opts, { const { where: baseWhere, params } = buildWhere(opts, {
sessionId: 'mem.session_id', sessionId: 'mem.session_id',
@@ -449,6 +461,7 @@ function createRememberApi(db) {
const remember = ({ path: memoryPath, session_id, message_start, message_end, summary, project }) => { const remember = ({ path: memoryPath, session_id, message_start, message_end, summary, project }) => {
if (!memoryPath || !summary) throw new Error('remember() requires path and summary'); if (!memoryPath || !summary) throw new Error('remember() requires path and summary');
assertEnglishMemoryText(summary, 'remember() summary');
const normalizedPath = resolveMemoryPath(memoryPath, session_id); const normalizedPath = resolveMemoryPath(memoryPath, session_id);
const id = `mem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const id = `mem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const proj = project || db.prepare('SELECT project FROM sessions WHERE id=?').get(session_id)?.project || null; const proj = project || db.prepare('SELECT project FROM sessions WHERE id=?').get(session_id)?.project || null;