feat(memory): persistent memory layer with remember/recall CodeAct API

Add a memories table (survives index rebuilds) for agent-written
  conclusions with provenance (session, message range, project). The agent
  writes markdown files via Write tool (user-approved), then registers
  them via a --remember CodeAct script with remember(). Recall via
  memories() in --query scripts, filtered by project/session/time.

  Separates query (read-only, assertReadOnlySql) from remember (write)
  execution contexts in runtime.mjs.
This commit is contained in:
tommy0103
2026-06-10 02:05:27 +08:00
parent dff88bbb37
commit 34f3a164ab
9 changed files with 364 additions and 29 deletions
+73 -2
View File
@@ -1,7 +1,8 @@
# Obelisk Query Patterns
These are copyable CodeAct patterns for `runtime.mjs --query` scripts. They are
not new APIs. Adapt them to the user's scope and return compact evidence.
These are copyable CodeAct patterns for `runtime.mjs --query` scripts, plus one
`--remember` registration pattern. They are not new APIs. Adapt them to the
user's scope and return compact evidence.
## Bounded Search To Context
@@ -26,6 +27,76 @@ return hits.slice(0, 5).map(h => {
});
```
## Memory Plus Session Evidence
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
with session evidence in your final answer when correctness matters.
```js
const project = '%quiet-zero%';
const topic = 'markdown memory layer';
const ftsTopic = topic.replace(/[-_]/g, ' ');
const prior_memories = memories({
project,
query: topic,
limit: 5,
}).map(m => ({
id: m.id,
path: m.path,
session_id: m.session_id,
message_start: m.message_start,
message_end: m.message_end,
created_at: m.created_at,
summary: m.summary?.slice(0, 260),
}));
const session_evidence = search(ftsTopic, { project, limit: 8 })
.slice(0, 6)
.map(h => ({
session_id: h.session.id,
session_title: h.session.title,
uuid: h.message.uuid,
timestamp: h.message.timestamp,
snippet: h.message.text?.slice(0, 220),
}));
return {
query_plan: {
project,
topic,
memory_limit: 5,
session_limit: 8,
},
prior_memories,
session_evidence,
};
```
## Register Approved Memory
Use this only after the user approves writing memory and the markdown file
already exists. `remember()` validates the file and stores a normalized absolute
path, so keep the script small and return the registered record.
Run this script with `runtime.mjs --remember <script>`. The `--remember` runtime
exposes only `remember()`, not retrieval helpers.
```js
return remember({
path: '.obelisk/memories/memory-layer-design.md',
session_id: 'source-session-id',
message_start: 'first-message-uuid',
message_end: 'last-message-uuid',
summary: [
'Decision: Obelisk uses one user-facing entry that queries both memory and raw sessions.',
'Memory records are prior notes and must be identified naturally when they influence an answer.',
'New memory writes require human confirmation before the markdown file is written and registered.',
].join(' '),
});
```
## One-Shot Retrieval For Synthesis
Use this for conclusion, broad history, failure investigation, or file evolution
+13 -8
View File
@@ -13,7 +13,7 @@ Classify the user's request before choosing tools.
|-------------|--------------|------------|-------------|
| project name/path, session, cwd, file, time range | scope | `sessions()`, exact SQL on `project_path`, `sessionId`, `fileHistory()` | broad FTS |
| workflow, subagent, tool call, summary, edit | artifact | `workflows()`, `subagents()`, `summaries()`, `tool_calls`, `tool_results` | all-session search |
| concept, conclusion, design history, vague memory | semantic | `search()`, summaries, bounded facet sweep | session dumps |
| concept, conclusion, design history, vague memory | semantic | `memories({ query })`, `search()`, summaries, bounded facet sweep | session dumps |
One-shot retrieval is not all-shot retrieval. A query script may perform
multiple steps, but the first locator should be the narrowest semantic fit. If a
@@ -23,6 +23,7 @@ unless scoped evidence is insufficient and `query_plan` says why.
Project-like fields are distinct:
- `sessions.project`: stored Claude Code project slug.
- `memories.project`: stored project slug copied onto registered memory records.
- `sessions.project_path`: reconstructed absolute project path.
- `messages.cwd`: working directory at message time.
- helper `project`: SQL `LIKE` over `sessions.project`, not exact membership.
@@ -62,23 +63,27 @@ Use the database shape before asking the model to read text.
Ordering and context are semantic:
- `sessions()`, `summaries()`, `workflows()`, and `failures()` are newest first.
- `sessions()`, `memories()`, `summaries()`, `workflows()`, and `failures()` are newest first.
- `fileHistory()` is oldest first.
- `search().context` is temporal neighbors in one session, not causal context.
- `context(uuid)` and `trace(uuid)` are for parent-chain/causal expansion.
### Evidence Before Conclusion
Obelisk stores original structure, not precompiled claims. It has sessions,
messages, summaries, tool calls/results, files, subagents, workflows, parent
chains, and raw JSONL windows. It does not store "claim", "stance",
"contradiction", or "conclusion" entities.
Obelisk's raw session layer stores original structure, not precompiled claims:
sessions, messages, summaries, tool calls/results, files, subagents, workflows,
parent chains, and raw JSONL windows. The memory layer can store
human-approved markdown conclusions, but treat them as prior notes to compare
against raw evidence when correctness matters.
For semantic questions, build a task-local evidence view:
```js
{
query_plan: { mode, scope, facets, limits },
prior_memories: [
{ id, path, session_id, created_at, summary }
],
evidence: [
{ type, id, session_id, timestamp, facet, snippet }
],
@@ -86,8 +91,8 @@ For semantic questions, build a task-local evidence view:
}
```
Then synthesize the conclusion in the final answer. Do not pretend the evidence
view is a stored Obelisk entity.
Then synthesize the conclusion in the final answer. Do not pretend the raw
evidence view is itself a stored Obelisk entity.
## Text Search Semantics
+102 -4
View File
@@ -177,6 +177,28 @@ CREATE TABLE index_state (
);
```
### memories
Human-approved markdown memory records registered in Obelisk. The markdown
file at `path` is the durable memory content; `summary` is the compact retrieval
surface.
```sql
CREATE TABLE memories (
id TEXT PRIMARY KEY, -- memory record ID
session_id TEXT, -- FK -> sessions.id where the memory was drawn, if known
project TEXT, -- project slug used for scoped recall
message_start TEXT, -- first relevant message UUID, if known
message_end TEXT, -- last relevant message UUID, if known
path TEXT, -- normalized absolute markdown memory file path
summary TEXT, -- retrieval summary of the memory
created_at TEXT -- ISO 8601 registration time
);
```
Indexes: `idx_memories_project(project)`,
`idx_memories_session(session_id)`, `idx_memories_created(created_at)`.
### Key Relationships
```
@@ -185,8 +207,10 @@ sessions.id <-- tool_calls.session_id
sessions.id <-- tool_results.session_id
sessions.id <-- subagents.session_id
sessions.id <-- workflows.session_id
sessions.id <-- memories.session_id
messages.uuid <-- tool_calls.message_uuid
messages.uuid <-- tool_results.message_uuid
messages.uuid <-- memories.message_start / memories.message_end
messages.agent_id --> subagents.agent_id (for subagent messages)
messages.agent_id --> workflow_agents.agent_id (for workflow agent messages)
tool_calls.id <-- tool_results.tool_use_id
@@ -197,8 +221,9 @@ workflows.run_id <-- workflow_agents.run_id
## 2. Query API Reference
All functions are available as globals inside `--query` scripts.
Scripts run in an async IIFE with a 30-second timeout.
Read helpers are available as globals inside `--query` scripts. Memory write
helpers are available only inside `--remember` scripts. Scripts run in an async
IIFE with a 30-second timeout.
### Simple Layer
@@ -250,15 +275,18 @@ return { chain_length: c.parentChain.length, session_title: c.session?.title };
#### `sql(query, ...params)`
Raw SQL with parameterized bindings. Returns an array of row objects.
Read-only SQL with parameterized bindings. Returns an array of row objects.
| Param | Type | Description |
|-------|------|-------------|
| `query` | `string` | SQL SELECT statement |
| `query` | `string` | SQL SELECT/WITH statement |
| `...params` | `any` | Bind parameters (positional `?`) |
**Returns:** `Array<Object>` -- each row as `{ column: value }`.
Write statements are rejected. Use `--remember` and `remember()` for memory
registration after user approval.
```js
const rows = sql('SELECT id, title FROM sessions WHERE project = ? ORDER BY ended_at DESC LIMIT 5', 'Users-tomiya-Code-quiet-zero');
return rows;
@@ -412,6 +440,76 @@ const qz = sessions({ project: '%quiet-zero%', limit: 5 });
return qz.map(s => ({ title: s.title, branch: s.git_branch, ended: s.ended_at }));
```
#### `memories(opts?)`
Registered markdown memory records. Like other list helpers, passing a string
is treated as `sessionId`, and passing a number is treated as `limit`.
| Param | Type | Description |
|-------|------|-------------|
| `opts.query` | `string` | Term filter over `summary` and `path`; hyphens/underscores are treated as spaces |
| `opts.project` | `string` | SQL `LIKE` pattern over `memories.project` |
| `opts.sessionId` | `string` | Restrict to one source session |
| `opts.sessions` | `string[]` | Restrict to a set of source session IDs |
| `opts.after` | `string` | ISO 8601 lower bound on `created_at` |
| `opts.before` | `string` | ISO 8601 upper bound on `created_at` |
| `opts.branch` | `string` | Filter by source session git branch (exact match) |
| `opts.limit` | `number` | Max results (default 50) |
**Returns:** `Array<memory_row>` ordered by `created_at` descending.
`query` is a lightweight term filter, not FTS5 ranking. Use it to avoid pulling
all recent memories, then read the markdown file at `path` when a memory looks
relevant.
```js
const prior = memories({
project: '%quiet-zero%',
query: 'memory layer markdown',
limit: 5,
});
return prior.map(m => ({
id: m.id,
path: m.path,
session_id: m.session_id,
summary: m.summary?.slice(0, 240),
}));
```
#### `remember(record)`
Register a human-approved markdown memory file. This is a write helper, not a
recall helper; use it only after the user has approved writing memory. It is
available only in scripts run with `runtime.mjs --remember`.
`--remember` exposes only `remember()`, not `search()`, `sql()`, `memories()`,
or other retrieval helpers. If source IDs are unknown, find them first with a
normal `--query` script.
| 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.summary` | `string` | Required retrieval summary: decision, reasoning, constraints |
| `record.session_id` | `string` | Source session ID, 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.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
normalized absolute path in `memories.path`.
**Returns:** `{ id, path, project, created_at }`.
```js
return remember({
path: '.obelisk/memories/memory-layer-design.md',
session_id: 'source-session-id',
message_start: 'first-message-uuid',
message_end: 'last-message-uuid',
summary: 'Decision: keep Obelisk as one user-facing entry that queries both memory and raw session evidence. Memory is prior notes, not final authority.',
});
```
---
## 3. Common Query Patterns