feat: evolve retrieval layer with content_type, memory FTS, forget(), and recap references

Extract schema DDL into scripts/schema.sql. Add content_type and is_meta
  columns to messages for transcript control-plane filtering. Introduce
  FTS5-backed memory recall with safe tokenization, memory soft-delete via
  forget() through the renamed --attune runtime, and anchors on memory
  records. Expand query helpers (includeMeta, thread opts, overview
  project-path awareness). Add per-card recap retrieval and writing
  references under references/recap/.
This commit is contained in:
tommy0103
2026-06-15 02:25:21 +08:00
parent b52f57b538
commit 3822bda89e
24 changed files with 1290 additions and 157 deletions
+199 -27
View File
@@ -3,6 +3,9 @@
Advanced reference for the obelisk database.
Read this when `search()`, `context()`, or `sql()` are not enough.
Executable schema source: `scripts/schema.sql`. This document explains that
contract for agents and humans; it is not the runtime source of truth.
---
## 1. Database Schema
@@ -41,6 +44,8 @@ CREATE TABLE messages (
timestamp TEXT, -- ISO 8601
role TEXT, -- "user" or "assistant" (from message payload)
text TEXT, -- extracted text content (thinking + text blocks, truncated to 10k chars)
content_type TEXT, -- "text", "thinking", "tool_use", "tool_result", or "unknown"
is_meta INTEGER DEFAULT 0, -- 1 for injected/control-plane transcript messages
model TEXT, -- model name (e.g. "claude-opus-4-6-20250529"), NULL for user messages
is_sidechain INTEGER DEFAULT 0, -- 1 if this message is on a sidechain (retry/branch)
agent_id TEXT, -- subagent or workflow agent UUID (NULL for main conversation)
@@ -54,6 +59,22 @@ CREATE TABLE messages (
Indexes: `idx_messages_session(session_id)`, `idx_messages_agent(agent_id)`, `idx_messages_ts(session_id, timestamp)`.
`content_type` preserves the top-level Claude Code content block shape for the
message row. Treat `text` as user/assistant visible language, `thinking` as
trace/debug material, and `tool_use` as a marker that the assistant message
contains tool calls. `tool_result` marks a tool-result message, but the
structured payload remains in `tool_results`. Tool-call details remain in
`tool_calls`. Messages whose top-level content is not one of these four raw
message surfaces are `unknown`. Real user input is represented by `type='user'`
and `content_type='text'`, not by a separate `user_message` content type.
`is_meta` marks transcript control-plane content: injected caveats, command
envelopes such as `<command-name>/exit</command-name>`, and similar messages
that may appear as user-role text but are not ordinary user intent. It is
separate from `type`, `role`, and `content_type`. Default helpers hide meta
messages from ordinary recall; use `includeMeta: true` or explicit SQL when
investigating injected context, command messages, or transcript structure.
### messages_fts
FTS5 virtual table for full-text search over message text.
@@ -68,7 +89,28 @@ CREATE VIRTUAL TABLE messages_fts USING fts5(
);
```
Queried via `MATCH` syntax. Rebuilt on each index pass.
Queried via `MATCH` syntax. The table is kept in sync by the `messages_fts_*`
triggers above; rebuild it manually only when repairing FTS state.
### memories_fts
FTS5 virtual table for ranked memory recall over registered memory summaries
and paths.
```sql
CREATE VIRTUAL TABLE memories_fts USING fts5(
id UNINDEXED, -- memory record ID, carried for inspection
path, -- searchable memory file path
summary, -- searchable compact memory summary
content=memories,
content_rowid=rowid,
tokenize='unicode61 remove_diacritics 1'
);
```
`memories({ query })` queries this table with safe tokenization and joins back to
`memories`, omitting archived rows. It is rebuilt during index finalization;
`remember()` also inserts the new memory row into FTS immediately.
### tool_calls
@@ -177,6 +219,14 @@ CREATE TABLE index_state (
);
```
Sentinel rows use synthetic `jsonl_path` keys:
`__last_build__` stores the last completed build time, `__app_heartbeat__`
stores the optional app indexer's liveness heartbeat,
`__app_last_successful_build__` stores the app's last successful index build,
`__indexer_owner_app__` marks app ownership, and `__last_source_mtime__` records
the newest indexed source mtime. Skill-side lazy builds skip work only while the
app heartbeat and app successful-build marker are both fresh.
### memories
Human-approved markdown memory records registered in Obelisk. The markdown
@@ -191,14 +241,22 @@ CREATE TABLE memories (
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
anchors TEXT, -- optional JSON array of recall anchors
summary TEXT, -- retrieval summary of the memory
created_at TEXT -- ISO 8601 registration time
created_at TEXT, -- ISO 8601 registration time
deleted_at TEXT, -- ISO 8601 archive time, if forgotten
deleted_reason TEXT -- human/agent deletion reason, if forgotten
);
```
Indexes: `idx_memories_project(project)`,
`idx_memories_session(session_id)`, `idx_memories_created(created_at)`.
Active memory means `deleted_at IS NULL`. Recall helpers return active memories
only. Archived memories are management/audit data, not recall data. Query recall
uses `memories_fts` joined back to `memories`; when using raw SQL for memory
recall, include `deleted_at IS NULL`.
### Key Relationships
```
@@ -221,8 +279,8 @@ workflows.run_id <-- workflow_agents.run_id
## 2. Query API Reference
Read helpers are available as globals inside `--query` scripts. Memory write
helpers are available only inside `--remember` scripts. Scripts run in an async
Read helpers are available as globals inside `--query` scripts. Memory mutation
helpers are available only inside `--attune` scripts. Scripts run in an async
IIFE with a 30-second timeout.
### Simple Layer
@@ -240,6 +298,7 @@ Full-text search across all message text using FTS5.
| `opts.after` | `string` | ISO 8601 lower bound on timestamp |
| `opts.before` | `string` | ISO 8601 upper bound on timestamp |
| `opts.cwd` | `string` | Filter by working directory (supports LIKE) |
| `opts.includeMeta` | `boolean` | Include injected/control-plane messages (default `false`) |
**Scope note:** `sessions.project` is the stored Claude Code project slug,
`sessions.project_path` is the absolute session path derived from message `cwd`
@@ -248,15 +307,22 @@ Helper `project` filters are fuzzy `LIKE` filters over `sessions.project`. For
exact project membership, use `sql()` with `s.project = ?` or
`s.project_path = ?`.
**Returns:** `Array<{ message, session, rank, context }>` where `context` is the
6 nearest messages by timestamp in the same session. It is temporal neighbor
context, not a parent chain. `rank` is the FTS5 relevance score used by
`ORDER BY rank`; lower values sort earlier, so treat the returned order as the
relevance order unless you are deliberately using FTS5 ranking details.
**Returns:** `Array<{ message, session, rank, context }>` where `message`
includes `{ uuid, text, content_type, is_meta, role, timestamp, model, cwd }`
and `context` is the 6 nearest non-meta messages by timestamp in the same
session unless `includeMeta: true` is passed. It is temporal neighbor context,
not a parent chain. `rank` is the FTS5 relevance score used by `ORDER BY rank`;
lower values sort earlier, so treat the returned order as the relevance order
unless you are deliberately using FTS5 ranking details.
```js
const hits = search('MCTS exploration');
return hits.map(h => ({ title: h.session.title, text: h.message.text?.slice(0, 200) }));
return hits.map(h => ({
title: h.session.title,
content_type: h.message.content_type,
is_meta: h.message.is_meta,
text: h.message.text?.slice(0, 200),
}));
```
#### `context(uuid)`
@@ -285,8 +351,8 @@ Read-only SQL with parameterized bindings. Returns an array of row objects.
**Returns:** `Array<Object>` -- each row as `{ column: value }`.
Write statements are rejected. Use `--remember` and `remember()` for memory
registration after user approval.
Write statements are rejected. Use `--attune` with `remember()` or `forget()`
for memory mutation 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');
@@ -306,9 +372,11 @@ const chain = trace('some-uuid');
return chain.map(m => ({ role: m.role, text: m.text?.slice(0, 100) }));
```
#### `thread(sessionId)`
#### `thread(sessionId, opts?)`
All messages in a session, ordered by timestamp.
Session messages ordered by timestamp. Meta messages are omitted by default;
pass `{ includeMeta: true }` to include injected caveats, command envelopes, and
other control-plane transcript rows.
**Returns:** `Array<message>`.
@@ -317,6 +385,31 @@ const msgs = thread('session-uuid');
return { count: msgs.length, first: msgs[0]?.text?.slice(0, 100) };
```
#### `raw(uuid, opts?)`
Windowed access to the original JSONL line for a message. Use this when indexed
text, tool inputs, or tool results were truncated and you need the raw source.
It resolves main-session, subagent, and workflow-agent JSONL paths from the
indexed message metadata.
| Param | Type | Description |
|-------|------|-------------|
| `uuid` | `string` | Message UUID |
| `opts.offset` | `number` | Character offset into the JSONL line (default 0) |
| `opts.limit` | `number` | Max characters to return (default 10000) |
**Returns:** `{ text, totalLength, offset, limit, hasMore }` or `null` if the
source line cannot be found.
```js
const line = raw('message-uuid', { offset: 0, limit: 4000 });
return {
preview: line?.text,
has_more: line?.hasMore,
total: line?.totalLength,
};
```
#### `subagents(opts?)`
All subagent spawns, with message counts. For backward compatibility, passing a string is treated as `sessionId`.
@@ -417,6 +510,34 @@ const last5 = recent(5);
return last5.map(s => ({ title: s.title, project: s.project_path, ended: s.ended_at }));
```
#### `summaries(opts?)`
Session summary rows, newest first. For backward compatibility, passing a
string is treated as `sessionId`, and passing a number is treated as `limit`.
| Param | Type | Description |
|-------|------|-------------|
| `opts.sessionId` | `string` | Restrict to one session |
| `opts.sessions` | `string[]` | Restrict to a set of session IDs |
| `opts.project` | `string` | SQL `LIKE` pattern over `sessions.project` |
| `opts.after` | `string` | ISO 8601 lower bound on summary timestamp |
| `opts.before` | `string` | ISO 8601 upper bound on summary timestamp |
| `opts.branch` | `string` | Filter by source session git branch (exact match) |
| `opts.limit` | `number` | Max results (default 100) |
**Returns:** `Array<summary_row & { session_title, project }>` ordered by
`timestamp` descending.
```js
const rows = summaries({ project: '%quiet-zero%', limit: 5 });
return rows.map(s => ({
session: s.session_title,
source: s.source,
summary: s.content?.slice(0, 240),
timestamp: s.timestamp,
}));
```
#### `overview(opts?)`
Compact orientation map for choosing the next retrieval scope. It is not an
@@ -457,7 +578,7 @@ from `process.cwd()` against `sessions.project_path`, then from exact
],
memory_total,
memories: [
{ id, path, summary, session_id, project, created_at }
{ id, path, anchors, summary, session_id, project, created_at }
]
} | null,
projects: [
@@ -491,6 +612,7 @@ return {
memories: map.current_project?.memories.map(m => ({
id: m.id,
path: m.path,
anchors: m.anchors,
summary: m.summary,
})),
};
@@ -522,12 +644,12 @@ 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`.
Active 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` | English term filter over `summary` and `path`; hyphens/underscores are treated as spaces |
| `opts.query` | `string` | English FTS recall query over `summary` and `path`; hyphens/underscores/punctuation are safely tokenized |
| `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 |
@@ -536,9 +658,13 @@ is treated as `sessionId`, and passing a number is treated as `limit`.
| `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.
**Returns:** `Array<memory_row & { rank?: number }>` with archived memories
omitted. Without `query`, results are ordered by `created_at` descending. With
`query`, results are ordered by FTS rank first, then `created_at` descending;
lower rank sorts earlier.
`query` is a lightweight English term filter, not FTS5 ranking. Translate
`query` uses safe FTS5 tokenization rather than raw `MATCH`, so punctuation-only
queries return no rows instead of broadening into all memories. Translate
non-English user requests into concise English query terms before calling
`memories()`. Use it to avoid pulling all recent memories, then read the
markdown file at `path` when a memory looks relevant. The runtime rejects
@@ -553,6 +679,7 @@ const prior = memories({
return prior.map(m => ({
id: m.id,
path: m.path,
anchors: m.anchors,
session_id: m.session_id,
summary: m.summary?.slice(0, 240),
}));
@@ -562,11 +689,11 @@ return prior.map(m => ({
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`.
available only in scripts run with `runtime.mjs --attune`.
`--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.
`--attune` exposes only `remember()` and `forget()`, not `search()`, `sql()`,
`memories()`, or other retrieval helpers. If source IDs or memory IDs are
unknown, find them first with a normal `--query` script.
| Param | Type | Description |
|-------|------|-------------|
@@ -576,12 +703,14 @@ normal `--query` script.
| `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` |
| `record.anchors` | `array` or JSON `string` | Optional recall anchors stored as JSON text. Expected shape is an array of objects, such as `{ kind: 'file', path: 'src/index/builder.ts' }` |
`remember()` validates that `path` exists and is a regular file, and rejects
obvious CJK text in `summary`. It stores the normalized absolute path in
`memories.path`.
`memories.path`. `anchors` is nullable; omit it or pass an empty array when the
memory has no explicit file or object anchors.
**Returns:** `{ id, path, project, created_at }`.
**Returns:** `{ id, path, project, anchors, created_at }`.
```js
return remember({
@@ -589,10 +718,53 @@ return remember({
session_id: 'source-session-id',
message_start: 'first-message-uuid',
message_end: 'last-message-uuid',
anchors: [{ kind: 'file', path: 'src/index/builder.ts' }],
summary: 'Decision: keep Obelisk as one user-facing entry that queries both memory and raw session evidence. Memory is prior notes, not final authority.',
});
```
#### `forget(record)`
Archive a human-approved memory record. Use it when the user says a memory is
outdated, wrong, or should be forgotten. It is available only in scripts run
with `runtime.mjs --attune`.
`forget()` requires a precise memory ID. Do not pass a query string and let the
helper choose. If the ID is unknown, first use a normal `--query` script with
`memories()` to identify candidates. If exactly one candidate clearly matches
the user's request, the request is approval to archive it. If multiple memories
could match, ask the user which one to forget.
| Param | Type | Description |
|-------|------|-------------|
| `record.id` | `string` | Memory record ID to archive |
| `record.reason` | `string` | Required reason for audit and future management views |
`forget()` sets `deleted_at` and `deleted_reason`. It does not delete the
markdown file at `path`. Active recall helpers omit archived memories.
**Returns:** `{ id, deleted_at, deleted_reason }`, or the same fields plus
`already_deleted: true` if the record had already been forgotten.
```js
return forget({
id: 'mem-20260610-example',
reason: 'Outdated by newer project guidance.',
});
```
#### Memory Mutation Approval
Agents may decide whether to use, ignore, or verify memory in a single answer
without approval. Approval is required only for persistent memory mutations.
When the user explicitly says a memory is wrong, outdated, should be forgotten,
or should say something else, that utterance is approval to mutate the exact
matching memory. If multiple memories could match, ask the user to choose.
Updating is not an in-place edit. Archive the old record with `forget()`, then
write and register a replacement markdown file with `remember()` under the same
approval.
---
## 3. Common Query Patterns
@@ -667,7 +839,7 @@ const wfs = workflows();
for (const wf of wfs.slice(0, 3)) {
const tree = workflowTree(wf.run_id);
wf.agent_details = tree?.agents.map(a => ({
type: a.agent_type, desc: a.description, msgs: a.messages.length,
type: a.agent_type, desc: a.description, msgs: a.messageCount,
}));
}
return wfs.slice(0, 3);