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:
@@ -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,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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user