feat(cli): extract Obelisk runtime into npm package

Add @obelisk-apps/cli with the existing build, search, query, and attune contract plus official skill installation.

Separate the docs-only skill artifact, bootstrap installer, release layout, cross-platform CI, and package-level regression coverage.
This commit is contained in:
tommy0103
2026-07-16 17:26:23 +08:00
parent 49158e9b9c
commit 90191e4604
64 changed files with 1112 additions and 745 deletions
+469
View File
@@ -0,0 +1,469 @@
# Obelisk -- Helper API Reference
Detailed reference for globals available inside `obelisk --query` and
`obelisk --attune` scripts.
- Use `references/schema.md` for raw SQL table/field/join checks.
- Use `references/query-patterns.md` for copyable retrieval plans.
- Use `references/retrieval-semantics.md` for query design and scope choices.
- Use `references/pitfalls.md` after runtime errors or confusing row shapes.
Query scripts run inside an async IIFE with a 30-second timeout. Use `return` to
emit JSON. `--query` scripts are read-only. `--attune` scripts expose only
memory mutation helpers.
## Query API Reference
### Read Helpers
These globals are available only in `obelisk --query` scripts:
```js
sql, search, context, trace, thread, raw,
overview, sessions, recent, summaries, memories,
subagents, workflows, workflowTree, fileHistory, failures
```
All list helpers accept bounded `limit` options. Many helpers also accept
`project`, `sessionId`, `sessions`, `after`, `before`, `branch`, and `source`
when the underlying table can express that scope. Passing a string to many list
helpers is treated as `sessionId`; passing a number is treated as `limit`.
### Mutation Helpers
These globals are available only in `obelisk --attune` scripts:
```js
remember, forget
```
`--attune` does not expose `search()`, `sql()`, `memories()`, or other read
helpers. If you need IDs, discover them first with a normal `--query` script.
---
## Core Helpers
#### `search(text, opts?)`
Full-text search across all indexed message text using FTS5.
| Param | Type | Description |
| --- | --- | --- |
| `text` | `string` | FTS5 query string |
| `opts.limit` | `number` | Max results, default 20 |
| `opts.sessionId` | `string` | Restrict to one session |
| `opts.project` | `string` | SQL `LIKE` pattern over `sessions.project` |
| `opts.after` | `string` | ISO lower bound on message timestamp |
| `opts.before` | `string` | ISO upper bound on message timestamp |
| `opts.cwd` | `string` | SQL `LIKE` filter over `messages.cwd` |
| `opts.source` | `string` | `"claude"`, `"codex"`, or omitted/all |
| `opts.includeMeta` | `boolean` | Include `is_meta=1` rows, default false |
Returns:
```js
Array<{
message: { uuid, text, content_type, is_meta, role, timestamp, model, cwd, source },
session: { id, title, project, started_at, source },
rank,
context
}>
```
`context` is temporal neighbor context in the same session, not a parent chain.
Use `context(uuid)` or `trace(uuid)` for causal/parent-chain expansion. Lower
FTS rank sorts earlier; prefer returned order unless deliberately inspecting
FTS ranking.
Valid FTS5 syntax in `text` is honored. Input that FTS5 would reject as
malformed (for example a hyphenated term like `foo-bar`) does not error: it
falls back to safe per-token quoting — the same tokenization `memories()` uses —
so ordinary text never crashes the query.
#### `context(uuid)`
Full indexed context around one message.
| Param | Type | Description |
| --- | --- | --- |
| `uuid` | `string` | Message UUID |
Returns:
```js
{ message, parentChain, session, subagent, workflow } | null
```
`parentChain` contains ancestors, not temporal neighbors. If the message belongs
to a subagent or workflow agent, `subagent` or `workflow` is populated when the
metadata exists.
#### `sql(query, ...params)`
Read-only SQL helper with positional `?` bindings.
| Param | Type | Description |
| --- | --- | --- |
| `query` | `string` | `SELECT` or `WITH` statement |
| `...params` | `any` | Bind values |
Returns `Array<object>`.
Write statements are rejected. Use `references/schema.md` before non-trivial SQL
joins, and use `--attune` with `remember()` / `forget()` for memory mutation.
---
## Orientation And Lists
#### `overview(opts?)`
Compact orientation map for choosing retrieval scope. It is not evidence: it
does not return snippets, full messages, or markdown memory contents.
Passing a string is treated as `project`. Passing a number is treated as
`limit`.
| Param | Type | Description |
| --- | --- | --- |
| `opts.project` | `string` | Project slug or SQL `LIKE` pattern to use as current scope |
| `opts.limit` | `number` | Max recent sessions in `current_project.sessions`, default 8 |
| `opts.projectLimit` | `number` | Max global project rows, default 20 |
| `opts.memoryLimit` | `number` | Max memories in `current_project.memories`, default 100 |
If `opts.project` is absent, `overview()` tries to identify the current project
from `process.cwd()` against `sessions.project_path`, then from exact
`messages.cwd` matches. It does not guess the current session.
Returns:
```js
{
current: {
cwd,
project: {
project,
project_path,
source: 'opts' | 'cwd_project_path' | 'cwd_messages',
confidence: 'exact' | 'inferred' | 'unknown'
} | null
},
current_project: {
project,
project_path,
session_total,
sessions: [
{ id, title, project, project_path, started_at, ended_at, git_branch, message_count, source }
],
memory_total,
memories: [
{ id, path, anchors, summary, session_id, project, created_at }
]
} | null,
projects: [
{
project,
project_path,
session_count,
memory_count,
last_session_at,
last_memory_at,
recent_branches
}
],
totals: {
projects,
sessions,
memories,
sources: [{ source: 'claude' | 'codex', session_count, last_session_at }]
}
}
```
Confirm facts with `memories()`, `search()`, other helpers, or `sql()`.
#### `sessions(opts?)`
Session rows ordered by `ended_at` descending. Passing a number is treated as
`limit`.
| Param | Type | Description |
| --- | --- | --- |
| `opts.project` | `string` | SQL `LIKE` pattern over `sessions.project` |
| `opts.after` | `string` | ISO lower bound on `started_at` |
| `opts.before` | `string` | ISO upper bound on `started_at` |
| `opts.limit` | `number` | Max rows, default 50 |
| `opts.branch` | `string` | Exact git branch |
| `opts.source` | `string` | `"claude"`, `"codex"`, or omitted/all |
| `opts.sessionId` | `string` | Exact session ID |
| `opts.sessions` | `string[]` | Restrict to session IDs |
Returns `Array<session_row>`.
#### `recent(n?)`
Shorthand for `sessions({ limit: n })`. Default `n` is 10.
Returns `Array<session_row>`.
#### `summaries(opts?)`
Session summary rows ordered by summary `timestamp` descending. Passing a string
is treated as `sessionId`; passing a number is treated as `limit`.
| Param | Type | Description |
| --- | --- | --- |
| `opts.sessionId` | `string` | Restrict to one session |
| `opts.sessions` | `string[]` | Restrict to session IDs |
| `opts.project` | `string` | SQL `LIKE` pattern over source session project |
| `opts.after` | `string` | ISO lower bound on summary timestamp |
| `opts.before` | `string` | ISO upper bound on summary timestamp |
| `opts.branch` | `string` | Exact source session branch |
| `opts.source` | `string` | Provider filter through joined session |
| `opts.limit` | `number` | Max rows, default 100 |
Returns:
```js
Array<summary_row & { session_title, project }>
```
`summaries.source` is the summary kind, such as `away_summary`; it is not the
provider source.
#### `memories(opts?)`
Active registered markdown memory records. Passing a string is treated as
`sessionId`; passing a number is treated as `limit`.
| Param | Type | Description |
| --- | --- | --- |
| `opts.query` | `string` | English recall query over `summary` and `path` |
| `opts.project` | `string` | SQL `LIKE` pattern over `memories.project` |
| `opts.sessionId` | `string` | Restrict to one source session |
| `opts.sessions` | `string[]` | Restrict to source session IDs |
| `opts.after` | `string` | ISO lower bound on `created_at` |
| `opts.before` | `string` | ISO upper bound on `created_at` |
| `opts.branch` | `string` | Exact source session branch |
| `opts.source` | `string` | Provider filter through the source session |
| `opts.limit` | `number` | Max rows, default 50 |
Returns:
```js
Array<memory_row & { rank?: number }>
```
Archived memories are omitted. Without `query`, rows are newest first. With
`query`, rows are ordered by safe FTS rank first, then `created_at` descending.
Lower rank sorts earlier. Translate non-English requests into concise English
query terms before calling `memories()`. Read the markdown file at `path` for
full content.
---
## Structural Expansion Helpers
#### `trace(uuid)`
Walk the `parent_uuid` chain from a message to the conversation root.
Returns `Array<message>` ordered root-first.
#### `thread(sessionId, opts?)`
Messages in a session ordered by timestamp.
| Param | Type | Description |
| --- | --- | --- |
| `sessionId` | `string` | Session ID |
| `opts.includeMeta` | `boolean` | Include injected/control-plane rows, default false |
Returns `Array<message>`. Use `thread()` as a last resort; prefer targeted
search/context or compact SQL projections.
#### `raw(uuid, opts?)`
Windowed access to the original JSONL line for one indexed message. Use this
when indexed text, tool inputs, or tool results were truncated and you need the
raw source.
| Param | Type | Description |
| --- | --- | --- |
| `uuid` | `string` | Message UUID |
| `opts.offset` | `number` | Character offset into the JSONL line, default 0 |
| `opts.limit` | `number` | Max characters, default 10000 |
Returns:
```js
{ text, totalLength, offset, limit, hasMore } | null
```
`raw()` resolves main-session, subagent, workflow-agent, and Codex JSONL paths
from indexed metadata.
---
## Agent And Workflow Helpers
#### `subagents(opts?)`
Subagent metadata plus message counts. Passing a string is treated as
`sessionId`.
| Param | Type | Description |
| --- | --- | --- |
| `opts.sessionId` | `string` | Restrict to one session |
| `opts.project` | `string` | SQL `LIKE` pattern over source session project |
| `opts.source` | `string` | Provider filter |
| `opts.limit` | `number` | Max rows, default 100 |
Returns:
```js
Array<{ ...subagent_row, messageCount }>
```
#### `workflows(opts?)`
Workflow run rows ordered newest first. Passing a string is treated as
`sessionId`.
| Param | Type | Description |
| --- | --- | --- |
| `opts.sessionId` | `string` | Restrict to one session |
| `opts.project` | `string` | SQL `LIKE` pattern over source session project |
| `opts.after` | `string` | ISO lower bound on workflow timestamp |
| `opts.before` | `string` | ISO upper bound on workflow timestamp |
| `opts.source` | `string` | Provider filter |
| `opts.limit` | `number` | Max rows, default 100 |
Returns `Array<workflow_row>`.
#### `workflowTree(runId)`
Lightweight execution tree for one workflow run. It parses `result_json` and
adds per-agent message counts. It does not load agent messages.
Returns:
```js
{ ...workflow_row, result: object | null, agents: Array<{ ...workflow_agent_row, messageCount }> } | null
```
---
## Evidence Helpers
#### `fileHistory(filePath, opts?)`
Tool calls that touched one file, ordered oldest first. Includes `Read` rows as
well as `Edit`/`Write`.
| Param | Type | Description |
| --- | --- | --- |
| `filePath` | `string` | Absolute file path |
| `opts.after` | `string` | ISO lower bound |
| `opts.before` | `string` | ISO upper bound |
| `opts.source` | `string` | Provider filter |
| `opts.limit` | `number` | Max rows, default 200 |
Returns:
```js
Array<{
toolCall: { id, message_uuid, name, input_json },
session: { id, title, project },
timestamp
}>
```
Use raw SQL with `ORDER BY m.timestamp DESC` when you need newest-first file
history.
#### `failures(opts?)`
Failed tool results with tool/session context and the next three messages after
the failure. Passing a string is treated as `sessionId`.
| Param | Type | Description |
| --- | --- | --- |
| `opts.sessionId` | `string` | Restrict to one session |
| `opts.project` | `string` | SQL `LIKE` pattern over source session project |
| `opts.after` | `string` | ISO lower bound on result message timestamp |
| `opts.before` | `string` | ISO upper bound on result message timestamp |
| `opts.source` | `string` | Provider filter |
| `opts.limit` | `number` | Max rows, default 50 |
Returns:
```js
Array<{ toolCall, result, session, nextMessages }>
```
Use SQL for precise counts and grouping; treat `failures()` as compact evidence,
not a counting primitive.
---
## Memory Mutation Helpers
#### `remember(record)`
Register a human-approved markdown memory file. Available only in
`obelisk --attune` scripts.
| Param | Type | Description |
| --- | --- | --- |
| `record.path` | `string` | Existing markdown file path |
| `record.summary` | `string` | Required English retrieval summary |
| `record.session_id` | `string` | Source session ID, if known |
| `record.message_start` | `string` | First relevant source message UUID |
| `record.message_end` | `string` | Last relevant source message UUID |
| `record.project` | `string` | Project slug override |
| `record.anchors` | `array` or JSON `string` | Optional recall anchors |
Relative paths resolve against the source session `project_path` when
`session_id` is provided, otherwise against the runtime cwd. `remember()`
validates that `path` exists and is a regular file, rejects obvious CJK text in
`summary`, stores the normalized absolute path, and accepts nullable `anchors`.
Returns:
```js
{ id, path, project, anchors, created_at }
```
#### `forget(record)`
Archive a human-approved memory record. Available only in
`obelisk --attune` scripts.
| Param | Type | Description |
| --- | --- | --- |
| `record.id` | `string` | Exact memory ID |
| `record.reason` | `string` | Required archive reason |
`forget()` sets `deleted_at` and `deleted_reason`. It does not delete the
markdown file at `path`. Active recall helpers omit archived rows.
Returns:
```js
{ id, deleted_at, deleted_reason } |
{ id, deleted_at, deleted_reason, already_deleted: true }
```
### 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 in-place: archive the old record with `forget()`, then write and
register a replacement markdown file with `remember()` under the same approval.
+84
View File
@@ -0,0 +1,84 @@
# Obelisk Pitfalls
Use this after a query error, suspicious empty result, over-large output, or
unclear helper row shape. For query design, read `retrieval-semantics.md` first.
## Missing Columns And Wrong Aliases
Common wrong guesses:
- Summaries: use `source` and `content`; do not use `summary_type` or `text`.
- Tool call name: use `tool_calls.name`. `tool_name` is only an alias in `SELECT tc.name AS tool_name`; `tc.tool_name` is not a column.
- Tool call timestamps: `tool_calls` has no timestamp. Join `messages m ON m.uuid = tc.message_uuid`.
- Tool result timestamps: `tool_results` has no timestamp. Join `messages m ON m.uuid = tr.message_uuid`.
- Workflow agent message counts: `workflowTree()` returns `messageCount` for agents.
When uncertain, inspect a tiny sample instead of guessing:
```js
const rows = summaries({ limit: 1 });
return rows.length ? Object.keys(rows[0]) : [];
```
## FTS5 Syntax Errors
`search(text)` uses raw FTS5 `MATCH`. Hyphenated terms and punctuation can be
parsed as syntax.
```js
// tokenized phrase for FTS
search('"workflow script"', { limit: 10 })
```
For literal punctuation, use SQL `LIKE` under the same scope:
```js
sql(`
SELECT m.uuid, s.id AS session_id, s.title, substr(m.text,1,180) AS snippet
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE s.project LIKE ?
AND m.text LIKE ?
ORDER BY m.timestamp DESC
LIMIT 10
`, '%quiet-zero%', '%workflow-script%')
```
## Over-Large Runtime JSON
If runtime stdout is large, fix the query instead of reading it in chunks.
- Lower `LIMIT`.
- Shorten snippets to 160-240 chars.
- Group in SQL/JS and return counts plus sparse examples.
- For `fileHistory()`, filter to `Edit`/`Write` before projecting evidence.
- For `workflowTree()`, omit `script`, `result_json`, and full agent messages unless explicitly requested.
- Use `raw(uuid, { offset, limit })` only after identifying one specific message UUID.
## Empty Results
An empty array can be the correct answer for exact scopes or sentinels.
When the user asks for a scoped project/file/session or exact term:
1. run the scoped query;
2. return `[]` or compact counts;
3. say no matching prior result was found;
4. do not call `recent()`, all-project `summaries()`, or `thread()` as fallback unless the user asks.
## Counting From Snippets
If the user asks "how many", "counts", "top N", or "group by", compute it in
SQL or in the query script. Do not infer counts from visible snippets.
```js
sql(`
SELECT tc.name AS tool_name, COUNT(*) AS n
FROM tool_results tr
JOIN tool_calls tc ON tc.id = tr.tool_use_id
WHERE tr.is_error = 1
GROUP BY tc.name
ORDER BY n DESC
LIMIT 10
`)
```
+708
View File
@@ -0,0 +1,708 @@
# Obelisk Query Patterns
These are copyable CodeAct patterns for `obelisk --query` scripts plus
`--attune` memory mutation patterns. They are not new APIs. Adapt them to the
user's scope and return compact evidence.
Read this before the first query for broad synthesis, progress summaries,
design history, weekly/monthly reviews, or questions that ask what the user did,
learned, decided, tried, or abandoned. Start with a helper-first pass; use raw
`sql()` only when the helper surface cannot express the needed join or
aggregation.
## First Pass: Overview + Recall + Evidence
Use this for broad synthesis before writing custom SQL. It gives the agent a
map, prior notes, and raw session evidence in one bounded result. Then run a
faceted detail pass if the first pass reveals useful projects, sessions, files,
or terms.
```js
const topic = 'English topic terms translated from the user request';
const map = overview({ limit: 6 });
const project = map.current.project?.project;
const scoped = project ? { project } : {};
return {
query_plan: {
mode: 'first_pass',
topic,
project: project || null,
limits: { sessions: 6, memories: 5, search: 8 },
},
orientation: map.current_project && {
project: map.current_project.project,
session_total: map.current_project.session_total,
sessions: map.current_project.sessions.map(s => ({
id: s.id,
title: s.title,
branch: s.git_branch,
ended_at: s.ended_at,
})),
memory_total: map.current_project.memory_total,
memories: map.current_project.memories.map(m => ({
id: m.id,
path: m.path,
anchors: m.anchors,
summary: m.summary?.slice(0, 240),
})),
},
prior_memories: memories({ ...scoped, query: topic, limit: 5 }).map(m => ({
id: m.id,
path: m.path,
anchors: m.anchors,
session_id: m.session_id,
created_at: m.created_at,
rank: m.rank,
summary: m.summary?.slice(0, 260),
})),
session_evidence: search(topic.replace(/[-_]/g, ' '), { ...scoped, 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),
})),
};
```
## Orient Before Retrieval
Use `overview()` when the current project or available scopes are unclear. Treat
the result as a map, not evidence; follow up with `memories()`, `search()`,
helpers, or `sql()` for facts.
```js
const map = overview({ limit: 6 });
return {
current: map.current,
current_project: map.current_project && {
project: map.current_project.project,
session_total: map.current_project.session_total,
sessions: map.current_project.sessions.map(s => ({
id: s.id,
title: s.title,
branch: s.git_branch,
ended_at: s.ended_at,
})),
memory_total: map.current_project.memory_total,
memories: map.current_project.memories.map(m => ({
id: m.id,
path: m.path,
anchors: m.anchors,
summary: m.summary?.slice(0, 240),
})),
},
projects: map.projects.slice(0, 8),
totals: map.totals,
};
```
## Bounded Search To Context
Use `search()` to locate candidates, then expand only the strongest hits.
```js
const hits = search('"runtime query"', { project: '%quiet-zero%', limit: 8 });
return hits.slice(0, 5).map(h => {
const c = context(h.message.uuid);
return {
session_id: h.session.id,
session_title: h.session.title,
uuid: h.message.uuid,
timestamp: h.message.timestamp,
snippet: h.message.text?.slice(0, 240),
parentChain: (c?.parentChain || []).slice(-3).map(m => ({
uuid: m.uuid,
role: m.role,
snippet: m.text?.slice(0, 120),
})),
};
});
```
## 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.
Memory query terms are English even when the user asks in another language.
```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,
anchors: m.anchors,
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),
rank: m.rank,
}));
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,
};
```
## Attune 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 `obelisk --attune <script>`. The `--attune` runtime
exposes only `remember()` and `forget()`, 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',
anchors: [{ kind: 'file', path: 'SKILL.md' }],
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(' '),
});
```
## Forget Approved Memory
Use this only after the user asks to archive an outdated or wrong memory. Identify
the exact memory ID in a normal `--query` script first. If one candidate clearly
matches the user's request, that request is approval to archive it; if several
candidates match, ask which one to forget.
Run the mutation with `obelisk --attune <script>`:
```js
return forget({
id: 'mem-id-to-delete',
reason: 'Outdated by newer project guidance.',
});
```
`forget()` archives the record. Active recall through `memories()` will omit it,
and the markdown file at `path` is left in place.
## Update Approved Memory
Use this when the user explicitly corrects an existing memory, or after the
agent proposes a replacement and the user approves. An update is one combined
operation: archive the old record and register the replacement markdown file.
The new markdown file must already exist before running `--attune`.
```js
const archived = forget({
id: 'old-memory-id',
reason: 'Replaced by updated memory from the current session.',
});
const created = remember({
path: '.obelisk/memories/updated-memory.md',
session_id: 'current-session-id',
message_start: 'first-message-uuid',
message_end: 'last-message-uuid',
anchors: [{ kind: 'file', path: 'src/path/to/file.ts' }],
summary: 'Updated summary: concise English retrieval surface for the replacement memory.',
});
return { archived, created };
```
If the agent only suspects a memory is stale, do not run this pattern yet.
Answer from current evidence and ask whether to archive or replace the memory.
## One-Shot Retrieval For Synthesis
Use this for conclusion, broad history, failure investigation, or file evolution
questions. The goal is to reduce conversation turns: keep intermediate search
results inside the query script, then return only a compact task-local evidence
view. This does not create stored semantic entities; the agent still reads the
evidence and forms the conclusion. Expect 1-2 runtime queries: one broad compact
evidence pass, and optionally one targeted detail pass by stable IDs.
```js
const project = '%quiet-zero%';
const topic = 'obelisk retrieval semantics';
const ftsTopic = topic.replace(/[-_]/g, ' ');
const facets = [
'summary conclusion',
'runtime query script',
'failure problem',
'file change',
];
const candidates = [];
for (const facet of facets) {
for (const h of search(`${ftsTopic} ${facet}`, { project, limit: 4 })) {
candidates.push({
kind: 'message',
facet,
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),
});
}
}
for (const s of summaries({ project, limit: 8 })) {
if (/obelisk|retrieval|context|summary/i.test(`${s.content || ''} ${s.session_title || ''}`)) {
candidates.push({
kind: 'summary',
facet: 'summary',
summary_id: s.id,
session_id: s.session_id,
session_title: s.session_title,
timestamp: s.timestamp,
snippet: s.content?.slice(0, 240),
});
}
}
const seen = new Set();
const evidence = [];
for (const row of candidates.sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp)))) {
const key = row.uuid || row.summary_id || `${row.session_id}:${row.timestamp}:${row.facet}`;
if (seen.has(key)) continue;
seen.add(key);
evidence.push(row);
if (evidence.length >= 16) break;
}
return {
query_plan: { project, topic, facets, per_facet_limit: 4, max_evidence: 16 },
evidence,
omitted: Math.max(0, candidates.length - evidence.length),
};
```
## Learned Faceted Detail Pass
Use this after a broad sweep has identified candidate sessions and vocabulary.
Prefer detail facets learned from the first pass over pulling large session
windows. Fall back to small filtered windows only when the vocabulary is still
unclear, and record that reason in `query_plan`.
```js
const sessionIds = [
'first-pass-session-id-a',
'first-pass-session-id-b',
];
const learnedFacets = [
{ facet: 'architecture comparison', terms: ['ultrawork', 'TaskTree', 'parallel'] },
{ facet: 'key judgment', terms: ['ridiculous', 'serial', 'parallel'] },
{ facet: 'merge direction', terms: ['replan', 'merge', 'workflow'] },
{ facet: 'prompt observation', terms: ['prompt', 'guideline', 'skill'] },
];
const rows = [];
for (const { facet, terms } of learnedFacets) {
const clauses = terms.map(() => 'm.text LIKE ?').join(' OR ');
const params = [
...sessionIds,
...terms.map(t => `%${t}%`),
];
rows.push(...sql(`
SELECT
? AS facet,
m.uuid,
m.session_id,
s.title AS session_title,
m.timestamp,
substr(m.text, 1, 220) AS snippet
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE m.session_id IN (${sessionIds.map(() => '?').join(',')})
AND m.text IS NOT NULL
AND (${clauses})
ORDER BY m.timestamp
LIMIT 3
`, facet, ...params));
}
const seen = new Set();
const evidence = [];
for (const row of rows) {
if (seen.has(row.uuid)) continue;
seen.add(row.uuid);
evidence.push(row);
if (evidence.length >= 12) break;
}
return {
query_plan: {
mode: 'learned_faceted_detail',
source: 'terms discovered in first pass',
session_count: sessionIds.length,
facets: learnedFacets.map(f => f.facet),
per_facet_limit: 3,
},
evidence,
};
```
## Facet Sweep For Broad History
Use this only for broad synthesis questions such as "how did X evolve", "what
did we do on X", or "what problems happened". Do not use it for concept recall,
exact session lookup, exact term recall, or tasks that ask for compact search
hits.
Keep the sweep small: 3-4 facets, `limit: 3` per facet, and at most 12 compact
evidence rows.
```js
const name = 'obelisk';
const facets = [
'runtime CLI script',
'schema SQLite FTS',
'skill API helper docs',
'test failure problem',
];
const rows = [];
for (const facet of facets) {
for (const h of search(`${name} ${facet}`, { project: '%quiet-zero%', limit: 3 })) {
rows.push({ facet, h });
}
}
const seen = new Set();
return rows
.filter(({ h }) => {
const key = h.message.uuid || `${h.session.id}:${h.message.timestamp}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.slice(0, 12)
.map(({ facet, h }) => ({
facet,
session_id: h.session.id,
session_title: h.session.title,
project: h.session.project,
uuid: h.message.uuid,
timestamp: h.message.timestamp,
snippet: h.message.text?.slice(0, 180),
}));
```
## Summary Rows And Neighbors
Use `source`, `content`, `session_id`, `project`, and `session_title`.
```js
const rows = summaries({ project: '%quiet-zero%', limit: 8 });
return rows.map(s => ({
id: s.id,
session_id: s.session_id,
session_title: s.session_title,
project: s.project,
source: s.source,
timestamp: s.timestamp,
snippet: s.content?.slice(0, 240),
}));
```
To inspect messages around one summary:
```js
const s = summaries({ project: '%quiet-zero%', limit: 1 })[0];
if (!s) return { results: [] };
const before = sql(
`SELECT uuid, role, timestamp, substr(text,1,200) AS snippet
FROM messages
WHERE session_id=? AND timestamp<?
ORDER BY timestamp DESC LIMIT 3`,
s.session_id,
s.timestamp
);
const after = sql(
`SELECT uuid, role, timestamp, substr(text,1,200) AS snippet
FROM messages
WHERE session_id=? AND timestamp>?
ORDER BY timestamp ASC LIMIT 3`,
s.session_id,
s.timestamp
);
return { summary: s, before, after };
```
## File History Synthesis
`fileHistory()` contains reads as well as writes and old-to-new rows. For
"why/how did this file change", scan a bounded `Edit`/`Write` set first, then
return only compact evidence. Do not return 20 long snippets; keep runtime JSON
small enough that the final answer, not the query output, carries the prose.
```js
const rows = fileHistory('/absolute/path/to/file', { limit: 80 });
const writes = rows.filter(r => ['Edit', 'Write'].includes(r.toolCall?.name));
const reads = rows.filter(r => r.toolCall?.name === 'Read');
const targetTerms = ['summaries', 'failures', 'raw'];
const bySession = new Map();
for (const r of writes) {
let input = {};
try { input = JSON.parse(r.toolCall.input_json || '{}'); } catch {}
const delta = String(input.new_string || input.content || input.old_string || '');
const snippet = delta.slice(0, 220);
const sid = r.session.id;
const group = bySession.get(sid) || {
session_id: sid,
session_title: r.session.title,
project: r.session.project,
write_edit_count: 0,
first_timestamp: r.timestamp,
last_timestamp: r.timestamp,
evidence: [],
};
group.write_edit_count++;
group.first_timestamp = group.first_timestamp < r.timestamp ? group.first_timestamp : r.timestamp;
group.last_timestamp = group.last_timestamp > r.timestamp ? group.last_timestamp : r.timestamp;
if (group.evidence.length < 2) {
group.evidence.push({
tool: r.toolCall.name,
tool_id: r.toolCall.id,
timestamp: r.timestamp,
mentions: targetTerms.filter(k => delta.toLowerCase().includes(k)),
snippet,
});
}
bySession.set(sid, group);
}
const sessions = [...bySession.values()].slice(0, 6);
const returnedEvidence = sessions.reduce((n, s) => n + s.evidence.length, 0);
return {
counts: { reads: reads.length, writes_edits: writes.length },
sessions,
omitted_write_edit_rows: Math.max(0, writes.length - returnedEvidence),
};
```
## Failed Tool Counts
For precise counts, aggregate in SQL. Do not hand-count long result rows in the
final answer.
```js
const counts = sql(`
SELECT
tc.name AS tool_name,
COUNT(*) AS failure_count,
MAX(m.timestamp) AS last_failure_at
FROM tool_results tr
JOIN tool_calls tc ON tc.id = tr.tool_use_id
JOIN messages m ON m.uuid = tr.message_uuid
JOIN sessions s ON s.id = tr.session_id
WHERE tr.is_error = 1
AND s.project LIKE ?
GROUP BY tc.name
ORDER BY failure_count DESC, last_failure_at DESC
LIMIT 20
`, '%quiet-zero%');
const examples = sql(`
SELECT
tr.tool_use_id,
tc.name AS tool_name,
m.timestamp,
s.id AS session_id,
s.title AS session_title,
substr(tr.content, 1, 180) AS error_snippet
FROM tool_results tr
JOIN tool_calls tc ON tc.id = tr.tool_use_id
JOIN messages m ON m.uuid = tr.message_uuid
JOIN sessions s ON s.id = tr.session_id
WHERE tr.is_error = 1
AND s.project LIKE ?
ORDER BY m.timestamp DESC
LIMIT 8
`, '%quiet-zero%');
return { counts, examples };
```
## Failure Investigation Groups
For questions like "recent failed tool calls", "which tasks failed", or "group
failures by task/session", group structurally and return sparse examples. Use
SQL for counts; treat `failures()` as an evidence helper, not a precise counter.
```js
const project = '%quiet-zero%';
const groups = sql(`
SELECT
s.id AS session_id,
s.title AS session_title,
s.project,
COUNT(*) AS failure_count,
MAX(m.timestamp) AS last_failure_at
FROM tool_results tr
JOIN tool_calls tc ON tc.id = tr.tool_use_id
JOIN messages m ON m.uuid = tr.message_uuid
JOIN sessions s ON s.id = tr.session_id
WHERE tr.is_error = 1
AND s.project LIKE ?
GROUP BY s.id
ORDER BY last_failure_at DESC
LIMIT 10
`, project);
const examples = sql(`
SELECT
tr.tool_use_id AS tool_call_id,
tc.name AS tool_name,
s.id AS session_id,
m.timestamp,
substr(tr.content, 1, 180) AS error_snippet
FROM tool_results tr
JOIN tool_calls tc ON tc.id = tr.tool_use_id
JOIN messages m ON m.uuid = tr.message_uuid
JOIN sessions s ON s.id = tr.session_id
WHERE tr.is_error = 1
AND s.project LIKE ?
ORDER BY m.timestamp DESC
LIMIT 12
`, project);
return { groups, examples };
```
## Workflow Tree Compact View
Find the run with `workflows()` under scope, then project `workflowTree()` into
compact fields. Do not return raw `script`, `result_json`, or the full tree.
```js
const runs = workflows({ project: '%quiet-zero%', limit: 30 });
const target = runs.find(w =>
/session[-_ ]journal/i.test(`${w.workflow_name || ''} ${w.task_id || ''} ${w.run_id || ''}`)
);
if (!target) {
return {
found: false,
candidates: runs.slice(0, 8).map(w => ({
run_id: w.run_id,
workflow_name: w.workflow_name,
timestamp: w.timestamp,
agent_count: w.agent_count,
})),
};
}
const tree = workflowTree(target.run_id);
return {
run_id: target.run_id,
workflow_name: target.workflow_name,
status: tree?.status ?? target.status,
timestamp: tree?.timestamp ?? target.timestamp,
agent_count: tree?.agent_count ?? tree?.agents?.length ?? target.agent_count,
agents: (tree?.agents || []).map(a => ({
agent_id: a.agent_id,
phase: a.phase,
label: a.label,
state: a.state,
tokens: a.tokens,
messageCount: a.messageCount,
})),
};
```
## Subagent Metadata Recall
Use `subagents()` for metadata. Do not expand transcripts unless the user asks.
```js
const rows = subagents({ project: '%quiet-zero%', limit: 50 });
return rows
.filter(r => /obelisk/i.test(`${r.description || ''} ${r.agent_type || ''}`))
.map(r => ({
agent_id: r.agent_id,
agent_type: r.agent_type,
description: r.description,
session_id: r.session_id,
messageCount: r.messageCount,
total_tokens: r.total_tokens,
}));
```
## Empty Result Without Fallback
If the user asks for an exact sentinel, scoped project, or exact file, an empty
result is valid. Report it; do not broaden automatically.
```js
const needle = 'obelisk-impossible-sentinel-20260602';
const hits = search(`"${needle.replace(/-/g, ' ')}"`, { limit: 10 });
const real = hits.filter(h => {
const scope = `${h.session?.project || ''} ${h.message?.cwd || ''}`;
return !/SkillOpt[-/. ]outputs|obelisk_train|obelisk-eval/i.test(scope);
});
return real.map(h => ({
session_id: h.session.id,
session_title: h.session.title,
project: h.session.project,
uuid: h.message.uuid,
snippet: h.message.text?.slice(0, 200),
}));
```
## Raw Window
Use `raw()` only after identifying a specific message UUID.
```js
const row = sql(`
SELECT uuid, length(text) AS indexed_len
FROM messages
WHERE length(text) >= 10000
LIMIT 1
`)[0];
if (!row) return null;
const first = raw(row.uuid, { offset: 0, limit: 4000 });
return {
uuid: row.uuid,
indexed_len: row.indexed_len,
totalLength: first?.totalLength,
hasMore: first?.hasMore,
text: first?.text?.slice(0, 500),
};
```
+9
View File
@@ -0,0 +1,9 @@
# Obelisk Recap Retrieval Patterns
Compatibility pointer.
The `/obelisk recap` flow now starts at `references/recap/overview.md`.
Do not use this as an all-in-one retrieval document. The current flow is
card-by-card: read the overview, then for each card read its `patternN-*.md`,
retrieve that card's evidence, read its `writingN-*.md`, and update the JSON
before moving on.
+8
View File
@@ -0,0 +1,8 @@
# Obelisk Recap Writing
Compatibility pointer.
The `/obelisk recap` writing contract now lives in the per-card writing files
under `references/recap/`, coordinated by `references/recap/overview.md`.
Read the overview first. Then use each per-card writing file immediately after
that card's retrieval pattern, rather than loading one large writing prompt.
+74
View File
@@ -0,0 +1,74 @@
# Obelisk Recap Overview
Use this only when the first word after `/obelisk` is `recap`. Everything after
`recap` is the target period or style hint.
## Highest Priority: Phase Loop
This workflow is sequential. Do not preload all recap files. Do not gather all
evidence first and write all cards at the end.
Follow this loop exactly:
1. Resolve the target period from the user's phrase.
2. Run only a tiny orientation pass such as `overview({ limit: 6 })`.
3. For Card 1, read `pattern1-cover.md`.
4. Retrieve only Card 1 evidence.
5. Read `writing1-cover.md`.
6. Update/write the JSON for Card 1 now.
7. Only after the JSON is updated, move to Card 2 and repeat.
Card order:
| card | retrieve | write |
|---|---|---|
| 1 cover | `pattern1-cover.md` | `writing1-cover.md` |
| 2 thinking | `pattern2-thinking.md` | `writing2-thinking.md` |
| 3 vibe | `pattern3-vibe.md` | `writing3-vibe.md` |
| 4 workflow | `pattern4-workflow.md` | `writing4-workflow.md` |
| 5 closing | `pattern5-closing.md` | `writing5-closing.md` |
The per-card files own retrieval details, JSON field duties, and card-specific
taste. Do not move those concerns back into this file.
## Period Targets
- `this week`, `last week`: calendar week in the user's runtime timezone.
- `this month`, `last month`: calendar month in the user's runtime timezone.
Do not infer timezone from examples, UTC suffixes, or file timestamps when
runtime/session timezone is available.
## Overall Deck Taste
This is a Spotify Wrapped-like set of personal share cards: concise, designed,
slightly playful, and built to make the user's work feel seen.
Do not criticize the user. Do not scold, diagnose, rank their personality, or
turn friction into a performance review.
Use designed English chrome where it feels like card UI: week/month labels,
archetype labels, compact stats, verdict seals, and signoffs. Preserve the
user's own language for prompts, quotes, catchphrases, and reactions. This is
not a translation task.
The deck should feel like a small artifact from someone who noticed the week,
not a report generated from a database.
## Archetypes
Choose one dominant archetype from the period's dominant attention, not from the
current recap-generation session.
| archetype | when it fits | tone baseline |
|---|---|---|
| `architect` | structure, boundaries, schema, systems | matter-of-fact structural pride |
| `debugger` | symptoms, false positives, root-cause loops | wry and bug-comfortable |
| `shipper` | dense implementation cadence | energetic but not breathless |
| `curator` | organization, memory, docs, refinement | reflective and precise |
| `director` | workflows, subagents, orchestration | observant from a slight remove |
| `cartographer` | moving boundaries and redrawing maps | patient and surveyor-like |
| `wanderer` | many projects without one center | gentle, exploratory |
If two fit, pick the one that describes what the user spent more thinking time
on, not what shipped.
@@ -0,0 +1,36 @@
# Card 1 Cover Retrieval
Goal: choose the recap's dominant claim, persona, activity shape, and compact
footer. The cover is not a topic inventory; it is one glanceable claim about
what the period felt like.
Use the period from `overview.md`. Start from `overview({ limit: 6 })`, then
look at in-period sessions, summaries, memories, and any obvious project scope.
If the user asked for a project, keep that scope; otherwise prefer the current
project only when the evidence makes it the clear center.
Prefer helpers first. If you need custom SQL for activity, token/message counts,
or source-session scope, read `references/schema.md` before writing the SQL.
Retrieve:
- dominant claim: one thing that defined the period, supported by raw evidence;
- persona: which archetype best matches the user's attention;
- source sessions and memories used by this cover claim;
- activity: weekly day intensities or monthly day intensities when supported;
- footer: compact public metric such as sessions, messages, or tokens.
Avoid:
- a claim that lists three topics;
- an archetype chosen from the recap-generation session itself;
- footer caveats like excluded projects, exact SQL filters, or long session names;
- making the cover a workflow metric when the week was really about a decision.
Read this card's writing file immediately after the cover evidence is stable:
`references/recap/writing1-cover.md`. Then update the JSON fields
`period`, `source`, `metrics`, `persona`, and the first `cards[]` entry.
Do not read `pattern2-thinking.md` until this JSON update is done.
Stop when the cover has one evidence-backed dominant claim, one chosen persona,
one metric scope, and at least one `evidence` anchor.
@@ -0,0 +1,33 @@
# Card 2 Thinking Retrieval
Goal: find turning points. This card is not a project timeline and not an implementation log. It is the record of what changed in the user's mind.
Retrieve 3-6 turns. A turn needs both sides:
- the user question, friction, doubt, or request that started the turn;
- the later decision, reframing, finding, or constraint that replaced the earlier
state.
Useful searches:
- user questions in the period: "为什么", "是不是", "怎么", "我觉得", "不应该";
- places where the user corrected the direction and then approved a new frame;
- summaries that name decisions, followed by `context()` or `thread()` for the
user's actual words;
- memory records only as hints; raw session evidence must provide the prompt and
turn.
Prefer helpers first. If you need custom SQL for message windows or user-turn
counts, read `references/schema.md` before writing the SQL.
Do not use workflow names, feature names, or agent task labels as prompts when
the user had their own wording. Do not use counts, "5 rounds", "13 agents", or
implementation effort as turns unless that count is the turn itself.
Read this card's writing file immediately after the turns are chosen:
`references/recap/writing2-thinking.md`. Then update the JSON `thinking_path`
card and add evidence for each item.
Do not read `pattern3-vibe.md` until this JSON update is done.
Stop when each item has a source-language prompt label, a short changed-state
prompt, turn, and an `evidence` anchor.
@@ -0,0 +1,30 @@
# Card 3 Vibe Retrieval
Goal: find small human signals in visible user messages. Vibe is not a correction log, not bracketed runtime text, and not a psychological profile.
Look for:
- catchphrases and repeated tiny reactions;
- unusually blunt praise or rejection;
- late-night disbelief, jokes, or rituals;
- one quotable sentence that captures the period's character.
Only count visible user messages. Helper APIs omit meta by default, but custom
SQL for phrase counts must filter user text with `COALESCE(m.is_meta,0)=0` and
`m.content_type='text'`. Do not count tool results, injected command envelopes,
UI labels, or bracketed runtime strings.
Useful retrieval:
- targeted phrase counts after you notice a likely catchphrase;
- `thread(sessionId)` around high-energy moments;
- `search()` for exact phrases, then `context()` for timing;
- a bounded SQL count only after reading `references/schema.md`.
Read this card's writing file immediately after you have the small user signals:
`references/recap/writing3-vibe.md`. Then update the JSON `vibe` card and add
evidence for every quote, count, and timestamp.
Do not read `pattern4-workflow.md` until this JSON update is done.
Stop when every observation is either exact user words or a tiny label backed by
exact user words.
@@ -0,0 +1,41 @@
# Card 4 Workflow Retrieval
Goal: find actual workflow runs and how the user received them. Card 4 is about
orchestration as experienced by the user, not an agent performance table.
Workflow rows have their own `workflows.timestamp`. During this card's retrieval,
call `workflows({ project: projectLike, after, before })` before concluding the
period had zero workflows.
Prefer helpers first. If you need custom SQL for workflow joins, timestamps, or
message reactions, read `references/schema.md` before writing the SQL.
Do not derive workflow counts only from `sessions({ after, before })`: long
sessions can start before the period and still contain workflow runs inside the
period. Do not scope workflow lookup by exact `project_path`; nested cwd values
can belong to the same Claude project slug.
For each candidate workflow:
- get the actual workflow_name from `workflows()` or `workflowTree()`;
- collect run id, timestamp, project, agent count, and compact result for stats
and evidence only;
- search the parent session for the user message immediately following the workflow completion;
- use that user reaction as `items[].reaction`.
Rank rows by the strength of the user reaction, not by agent count, workflow
size, duration, or implementation importance. A small workflow with "完美" is a
better row than a large workflow with no visible response.
Do not use architecture topics, memory-system milestones, app modules, or recap
feature work as workflow rows unless they are actual workflow_name values.
Do not make a row for a workflow with no visible user reaction; keep it only in
`stats`, `metrics`, or `evidence`.
Read this card's writing file immediately after workflow evidence is stable:
`references/recap/writing4-workflow.md`. Then update the JSON `workflow` card,
top-level workflow metrics, and source session ids for workflow evidence.
Do not read `pattern5-closing.md` until this JSON update is done.
Stop when every displayed row has an actual workflow name and a visible user
reaction.
@@ -0,0 +1,35 @@
# Card 5 Closing Retrieval
Goal: close with a small personal receipt. Use the same period and source scope
as the recap, or explicitly record a wider metric in `evidence`.
Retrieve:
- one consistent metric that can stand alone, such as streak, active days,
sessions, messages, or workflows;
- one or two compact receipts;
- most said phrase, only if a real repeated user phrase is supported;
- signoff material from the period's mood, not a second summary.
For phrase counts, count only non-meta visible user text. For streaks and active
days, define whether the scope is all Obelisk data, the current project, or the
selected evidence sessions. Keep the scope consistent with the cover footer
unless the evidence explicitly says otherwise.
Prefer helpers first. If you need custom SQL for phrase counts, active days, or
streaks, read `references/schema.md` before writing the SQL.
Avoid:
- naked numbers without units;
- project report bullets;
- internal session names;
- token audits;
- slogans, advice, or next-action commands.
Read this card's writing file immediately after the closing receipt is chosen:
`references/recap/writing5-closing.md`. Then update the JSON `closing` card and
add evidence for counts and phrases.
This is the final card; save the completed JSON before replying.
Stop when the closing can end the deck without explaining the whole week again.
@@ -0,0 +1,83 @@
# Card 1 Cover Writing
The cover should be readable in one glance: badge, persona, one plain claim,
activity, footer. Before writing, say the claim to the user in a chat bubble.
If it sounds like a topic list or report heading, shrink it.
## Mock taste anchor
```json
{
"type": "cover",
"badge": "Week 24",
"title": "The Architect",
"claim": "从零设计了一个完整的 memory 系统。",
"activity": [0.85, 0.92, 0.72, 0.45, 0.88, 0.30, 0],
"footer": "12 sessions · 2.4M tokens"
}
```
This works because `从零设计了一个完整的 memory 系统。` is one plain claim, in
the user's language, and can be read in one breath. `The Architect` is English
chrome; it gives the card a designed surface without translating the user's
actual work.
## JSON Shape
```ts
type CoverCard = {
type: "cover";
badge: string;
title: string;
claim: string;
activity: number[];
footer: string;
evidence_refs?: string[];
};
```
Field duties:
- `badge`: compact period chrome, such as `Week 24`.
- `title`: persona label, usually `The Architect`, `The Debugger`, etc.
- `claim`: one plain claim; not a topic list, project inventory, colon-led
tagline, or clever English that hides the user's language.
- `activity`: period intensity values from retrieval.
- `footer`: public metric line with no internal filter notes.
After writing, check that `persona.claim` and `cover.claim` tell the same
story, and attach `evidence_refs` to the claim or metric if it is surprisingly
specific.
## First JSON Write
After Card 1, create or update the recap JSON file. Do this before reading
`pattern2-thinking.md`.
Use this top-level shape:
```ts
type Recap = {
schema_version: "obelisk.recap.v1";
kind: "weekly" | "monthly";
generated_at: string;
period: { label: string; start: string; end: string; timezone: string };
source: { project?: string; session_ids: string[]; memory_ids?: string[] };
metrics: {
sessions?: number;
messages?: number;
tokens?: number;
active_days?: number[];
streak_days?: number;
workflows?: number;
workflow_agents?: number;
corrections?: number;
};
persona: { archetype: string; title: string; claim: string; tone: string };
cards: [CoverCard, { type: "thinking_path" }, { type: "vibe" }, { type: "workflow" }, { type: "closing" }];
evidence?: Array<{ id: string; session_id?: string; message_uuid?: string; memory_id?: string; summary?: string }>;
};
```
For app handoff, write JSON under `~/.obelisk/recap/`. Weekly filenames are
`recap-{YYYY}-W{WW}.json`; monthly filenames are `recap-{YYYY}-{MM}.json`.
@@ -0,0 +1,49 @@
# Card 2 Thinking Writing
Thinking Path should feel like a few bends in the user's reasoning, not a
weekly changelog. Before writing, test each row by asking: "what changed here?"
## Mock taste anchor
```json
{
"type": "thinking_path",
"title": "Five questions, five turns.",
"items": [
{ "day": "Mon", "prompt": "为什么要把 session 编译成 wiki", "turn": "raw SQLite, no wiki" },
{ "day": "Tue", "prompt": "buildWhere 是什么", "turn": "unified filter opts, not DSL" },
{ "day": "Wed", "prompt": "failures() 90% 误报", "turn": "is_error in JSONL" },
{ "day": "Thu", "prompt": "memory 层需要清理机制吗", "turn": "soft-delete, human-only" },
{ "day": "Fri", "prompt": "热力图不选中默认显示本月", "turn": "GitHub-style activity timeline" }
]
}
```
The prompts stay close to the user's words. Each turn is a short decision
fragment, not a full explanation.
## JSON Shape
```ts
type ThinkingPathCard = {
type: "thinking_path";
title: string;
items: Array<{
day: string;
prompt: string;
turn: string;
evidence_refs?: string[];
}>;
};
```
Field duties:
- `title`: designed deck line, not `本周路径`, not a research-paper heading.
- `prompt`: user's compact question, friction, or task. Use source language.
- `turn`: short decision fragment, finding, or shift; usually under 10 words.
Compact English fragments are allowed when they work as designed chrome.
After writing, remove any row whose prompt is a workflow name or whose turn
describes implementation rather than changed thinking.
Update the JSON now before reading `pattern3-vibe.md`.
@@ -0,0 +1,73 @@
# Card 3 Vibe Writing
Vibe is affectionate observation. It should make the user recognize themselves
without feeling evaluated. Before writing, remove anything that reads like a
correction audit, behavior label, diagnosis, or complaint ledger.
## Mock taste anchor
```json
{
"type": "vibe",
"title": "A short character study.",
"voice_lines": [
{ "label": "catchphrase", "text": "这太丑了", "count": 4 },
{ "label": "highest praise", "text": "可以" },
{ "label": "late night", "text": "你在干什么", "time": "02:47 AM" }
],
"meter": {
"label": "patience",
"value": 0.78,
"caption": "saint"
},
"quote": {
"text": "若无必要,勿增实体。",
"caption": "your most philosophical moment"
}
}
```
The humor comes from exact small lines. `可以` is funnier and truer than
"approval signal".
## JSON Shape
```ts
type VibeCard = {
type: "vibe";
title: string;
voice_lines: Array<{
label: string;
text: string;
count?: number;
time?: string;
evidence_refs?: string[];
}>;
meter?: {
label: string;
value: number;
caption: string;
};
quote?: {
text: string;
caption?: string;
evidence_refs?: string[];
};
};
```
Field duties:
- `title`: light character-study line, not a scorecard.
- `voice_lines[].text`: exact user words; no paraphrase, translation,
ellipsized half-quote, meta text, or correction log.
- `voice_lines[].label`: designed chrome can be English; the quoted user text
stays in source language.
- `meter`: meter is not a diagnosis. Keep the caption one or two words and
affectionate, never punitive.
- `quote.text`: one exact user phrase or sentence.
Do not use `[Request interrupted by user]`, tool output, injected context, or
UI status text as vibe. After writing, verify every `voice_lines[].text` and
`quote.text` can be traced to a non-meta user message.
Update the JSON now before reading `pattern4-workflow.md`.
@@ -0,0 +1,68 @@
# Card 4 Workflow Writing
Workflow is the orchestration card. It should show the strongest few workflow
runs and the user's reaction to them. Before writing, remove any row whose
reaction is not traceable to a visible user reaction.
## Mock taste anchor
```json
{
"type": "workflow",
"title": "Three workflows. Forty-two agents.",
"deck": "你召唤了机器军团。结果各有不同。",
"stats": "3 workflows · 42 agents",
"items": [
{ "name": "hono-plugin-review", "reaction": "完美" },
{ "name": "vue-migration", "reaction": "你这页面完全和之前的不一样…" },
{ "name": "split-render-js", "reaction": "可以" }
],
"verdict": "Mostly tolerated."
}
```
The row reactions are user reactions. The title carries the metric; the verdict
is a small English seal.
## JSON Shape
```ts
type WorkflowCard = {
type: "workflow";
title: string;
deck?: string;
stats?: string;
items: Array<{
name: string;
reaction: string;
evidence_refs?: string[];
}>;
verdict: string;
};
```
Field duties:
- `title`: human story line or compact metric line.
- `deck`: optional second line; do not repeat stats mechanically.
- `stats`: compact count line.
- `items[].name`: actual workflow name, command name, or run-id prefix.
- `items[].reaction`: exact or lightly trimmed user reaction. Preserve source
language. No feature description, implementation summary, agent count,
duration, "framework switch", "modularization", "theming landed", or other
internal progress label.
- `verdict`: compact seal based on the row reactions, often 3-6 words.
Agent counts belong only in `title` or `stats`, never in `items[].reaction`.
These row values are invalid because they are implementation labels, not user
reactions:
- `13 agents, the big build` is invalid.
- `9 agents, framework switch` is invalid.
- `6 agents, modularization` is invalid.
- `theming landed` is invalid.
If no user reaction exists, omit the row rather than write an implementation result.
After writing, check that every row name maps to retrieval evidence and every
reaction can be read as quoted user verdict text.
Update the JSON now before reading `pattern5-closing.md`.
@@ -0,0 +1,54 @@
# Card 5 Closing Writing
Closing is a receipt, not a second summary. Before writing, read the headline
alone. If it does not mean anything without the rest of the card, add the unit
or choose a better line.
## Mock taste anchor
```json
{
"type": "closing",
"headline": "19 days",
"receipts": ["847 messages exchanged", "12 corrections · 47 approvals"],
"most_said_phrase": "好的开始做吧",
"signoff": "See you next week."
}
```
This works because `19 days` has a unit, the receipts feel like a small receipt,
and `See you next week.` is a quiet goodbye instead of a slogan.
## JSON Shape
```ts
type ClosingCard = {
type: "closing";
headline: string;
receipts: string[];
most_said_phrase?: string;
signoff: string;
evidence_refs?: string[];
};
```
Field duties:
- `headline`: compact stat or phrase with its unit; not a naked number.
- `receipts`: at most two `receipts`, compact and personal.
- `most_said_phrase`: complete phrase the user actually said, or omit it.
- `signoff`: short and earned; quiet goodbye, not advice or a brand slogan.
English signoff chrome such as `See you next week.` is allowed.
After writing, remove internal scope notes from visible fields and put them in
`evidence`. The final card should feel like the deck ending, not the report
continuing.
Final save rules:
- The file contains only the JSON object: no Markdown fence, no prose.
- Keep exactly five cards in this order: cover, thinking_path, vibe, workflow,
closing.
- Keep private SQL, raw tool output, secrets, long paths, and source caveats out
of visible card text; put traceability in `evidence`.
- After saving, reply briefly with the saved path and important evidence caveats.
+165
View File
@@ -0,0 +1,165 @@
# Obelisk Retrieval Semantics
Read this before designing a non-trivial query. This is the query design frame;
`pitfalls.md` is only the debug checklist.
## Four Principles
### Scope First
Classify the user's request before choosing tools.
| User signal | Locator mode | Start with | Avoid first |
|-------------|--------------|------------|-------------|
| unclear project/session landscape | orientation | `overview()` | treating overview rows as evidence |
| 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 | `memories({ query })`, `search()`, summaries, bounded facet sweep | session dumps |
`overview()` is a navigation map: current cwd/project if knowable, global
project counts, and recent current-project session/memory entry points. Use it
when scope is unclear, then query the memory or raw session layer for evidence.
It does not guess the current session.
For a new task, the first pass normally starts with `overview({ limit: 6 })`
unless the user gave an exact session ID, message UUID, or absolute file path.
Broad synthesis and progress-summary tasks should start from
`references/query-patterns.md`, not raw SQL.
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
scope locator finds the relevant project/session/file, do not also run broad FTS
unless scoped evidence is insufficient and `query_plan` says why.
Project-like fields are distinct:
- `sessions.project`: provider-normalized project slug.
- `memories.project`: stored project slug copied onto registered memory records.
- `sessions.project_path`: absolute session path derived from message `cwd` when available; slug decoding is only a fallback.
- `messages.cwd`: working directory at message time.
- `sessions.source` / `messages.source`: transcript provider, currently `claude` or `codex`.
- helper `project`: SQL `LIKE` over `sessions.project`, not exact membership.
- helper `source`: optional provider filter. Omit it unless provenance matters.
For exact project membership, prefer helper filters or a scoped first pass when
they are expressive enough; use `sql()` with `s.project = ?` or
`s.project_path = ?` when you need exact membership across a join or
aggregation. Empty or tiny scoped results are valid results; do not broaden
unless the user asks or your `query_plan` explicitly marks a fallback.
### Plan Before Probe
For conclusion, broad history, failure investigation, or file evolution tasks,
prefer a retrieval script over interactive probing.
Good shape:
1. locate candidates with scope/artifact/semantic locators;
2. expand only selected hits;
3. dedupe and group in the script;
4. return compact evidence rows plus counts and limits.
If a second detail pass is needed, derive filters or facets from the first pass:
candidate sessions, discovered vocabulary, files, tools, timestamps, or
decisions. Prefer a learned faceted detail pass over `LIMIT 25` session windows.
If vocabulary is still unclear, use a small filtered window and say so in
`query_plan`.
### Structure Before Text
Use the database shape before asking the model to read text. This means
structured helpers and compact JS shaping first; raw SQL only when it expresses
the needed join, grouping, or exact schema-level check better than helpers.
- Count and aggregate in SQL or JS (`GROUP BY`, `COUNT`, `MAX`, `ORDER BY`, `LIMIT`).
- Join metadata from the owner table instead of inventing fields.
- Project compact rows; do not return whole sessions, complete workflow trees, full raw messages, or entire tool results.
- Keep synthesis runtime JSON around 10k-12k chars when possible.
- For recent failures, aggregate by session/task and return sparse examples.
- For file evolution, filter `fileHistory()` to `Edit`/`Write`, group by session or phase, and return short deltas.
Ordering and context are semantic:
- `sessions()`, `summaries()`, `workflows()`, and `failures()` are newest first.
- `memories()` without `query` is newest first; `memories({ query })` is FTS-ranked over memory `summary`/`path`, with lower rank sorting earlier.
- `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'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, anchors, session_id, created_at, summary }
],
evidence: [
{ type, id, session_id, timestamp, content_type, is_meta, facet, snippet }
],
omitted: 0
}
```
For message evidence, preserve `content_type` when projecting snippets.
`text` can support user-visible claims; `thinking` is only trace/debug context;
`tool_use` means follow `tool_calls` for structured details; `tool_result`
means follow `tool_results` for structured output. Mixed or unfamiliar message
surfaces remain `unknown`.
Preserve `is_meta` separately from `content_type`. Default message evidence
should exclude `is_meta=1` rows because they are transcript control-plane
content, not ordinary user intent or assistant conclusions. Include them only
when investigating injected caveats, command envelopes, or transcript structure.
When writing raw SQL for ordinary conversation evidence, add
`COALESCE(m.is_meta,0)=0` to message filters unless meta rows are the subject of
the investigation.
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.
`memories({ query })` uses safe FTS5 tokenization over memory `summary` and
`path`, so hyphens and punctuation do not need raw `MATCH` escaping.
`memories()` returns active memories only. For raw SQL memory recall, include
`deleted_at IS NULL`; archived memory records are management/audit data.
The agent may decide whether to use, ignore, or verify a recalled memory for the
current answer without user approval because no persistent state changes. If a
user explicitly says a memory is wrong, outdated, should be forgotten, or should
be replaced, that request is approval to mutate the exact matching memory. If
the agent discovers the conflict without an explicit user request, it should
answer from current evidence and ask before archiving or replacing the memory.
Then synthesize the conclusion in the final answer. Do not pretend the raw
evidence view is itself a stored Obelisk entity.
After synthesis, check whether the conclusion should become a memory. Offer to
write one when the result is durable, likely to help future sessions, and not
already covered by `prior_memories`. Good candidates include design decisions,
project conventions, abandoned alternatives, repeated failure causes, workflow
patterns, and conclusions synthesized across multiple raw evidence points. Do
not propose memory for one-off lookups, uncertain findings, or duplicate
coverage. The offer is only a proposal: write the markdown file and run
`--attune` only after user approval.
Memory updates are archive-plus-write, not in-place edits: run `forget()` on the
old record and `remember()` the replacement markdown file under the same user
approval.
## Text Search Semantics
`search(text)` passes text to SQLite FTS5 `MATCH`.
- Hyphens tokenize: for `workflow-script`, use `"workflow script"` or SQL `LIKE` for literal punctuation.
- Special characters may produce FTS syntax errors; simplify or quote the FTS query under the same scope.
- Exact phrase, token search, and literal punctuation are different semantics.
- Results are ordered by `ORDER BY rank`; lower rank sorts earlier. Prefer returned order over "closer to zero" comparisons.
+328
View File
@@ -0,0 +1,328 @@
# Obelisk -- Raw SQL Quick Reference
Read this before writing non-trivial `sql()` queries. It is a compact field and
join map for raw SQL, not the full helper API manual.
- Canonical executable DDL: [`packages/core/src/schema.sql`](https://github.com/tommy0103/obelisk/blob/main/packages/core/src/schema.sql) in the CLI source repository (not duplicated in this docs-only skill)
- Helper signatures and return shapes: `references/api-reference.md`
- Query recipes and synthesis patterns: `references/query-patterns.md`
- FTS, alias, ordering, and compactness traps: `references/pitfalls.md`
Database path: `~/.obelisk/obelisk.sqlite`. Older `~/.claude/obelisk.sqlite`
databases are copied forward on first open when the new database does not
exist.
## Source Model
Obelisk stores Claude Code and Codex transcripts in the same schema.
- Claude rows use `source='claude'`.
- Codex rows use `source='codex'`; root session and message IDs are prefixed
with `codex:`.
- Omit `source` filters unless provider provenance matters.
- Codex child threads are represented through `subagents`; Codex may not have
Claude-style workflow rows.
## Scope Fields
Use the narrowest scope before text search.
| Field | Meaning | Raw SQL note |
| --- | --- | --- |
| `sessions.project` | Provider-normalized project slug | Use `LIKE ?` for fuzzy project filters |
| `sessions.project_path` | Absolute project path inferred from cwd | Use for exact local project identity |
| `messages.cwd` | Working directory at message time | Useful when a session spans directories |
| `sessions.source` / `messages.source` | Transcript provider | Use only when provider matters |
| `messages.is_meta` | Injected/control-plane transcript material | Ordinary evidence should filter it out |
For ordinary conversation evidence in raw SQL, add:
```sql
COALESCE(m.is_meta, 0) = 0
```
Do not add that filter when investigating injected context, command envelopes,
or transcript structure.
## Tables
### `sessions`
One row per root session.
| Column | Meaning |
| --- | --- |
| `id` | Session ID (`codex:<thread-id>` for Codex roots) |
| `title` | AI/session title |
| `project` | Provider-normalized project slug |
| `project_path` | Absolute project path when known |
| `started_at`, `ended_at` | ISO timestamps |
| `git_branch` | Branch at session time |
| `version` | Provider CLI/app version |
| `message_count` | Indexed user + assistant messages |
| `jsonl_path` | Source JSONL path |
| `source` | `claude` or `codex` |
### `messages`
Core evidence table.
| Column | Meaning |
| --- | --- |
| `uuid` | Message ID |
| `session_id` | FK to `sessions.id` |
| `type`, `role` | User/assistant role fields |
| `parent_uuid` | Conversation tree parent |
| `timestamp` | ISO timestamp |
| `text` | Extracted text, truncated to 10k chars |
| `content_type` | `text`, `thinking`, `tool_use`, `tool_result`, or `unknown` |
| `is_meta` | 1 for injected/control-plane messages |
| `model` | Assistant model name |
| `is_sidechain` | Retry/branch marker |
| `agent_id` | Subagent/workflow agent ID |
| `input_tokens`, `output_tokens` | Assistant token usage |
| `cwd` | Working directory at message time |
| `skill` | Skill that generated the response, if known |
| `turn_duration_ms` | Wall-clock duration for the turn |
| `source` | `claude` or `codex` |
`content_type='tool_use'` is only a marker. Tool-call details live in
`tool_calls`. `content_type='tool_result'` marks provider-emitted tool-result
messages; structured tool-result rows live in `tool_results`.
### `tool_calls`
One row per assistant tool invocation.
| Column | Meaning |
| --- | --- |
| `id` | Tool-use ID |
| `message_uuid` | Assistant message containing the call |
| `session_id` | Denormalized session ID |
| `name` | Tool name (`Read`, `Edit`, `Bash`, etc.) |
| `input_json` | JSON-serialized input, truncated to 10k chars |
| `file_path` | Extracted file path for file tools |
`tool_calls` does not have timestamps. Join through `messages`.
### `tool_results`
One row per tool result.
| Column | Meaning |
| --- | --- |
| `tool_use_id` | FK to `tool_calls.id` |
| `message_uuid` | User/tool-result message carrying the result |
| `session_id` | Denormalized session ID |
| `content` | Result text, truncated to 10k chars |
| `file_path` | Tool result file path metadata, if any |
| `is_error` | 1 when the provider marks the result as an error |
`tool_results` does not have timestamps. Join through `messages`.
### `summaries`
Session summary rows.
| Column | Meaning |
| --- | --- |
| `id` | Summary ID |
| `session_id` | FK to `sessions.id` |
| `timestamp` | Summary timestamp |
| `source` | Summary kind, such as `away_summary`; not provider source |
| `content` | Summary text |
### `subagents`
Metadata for non-workflow subagent spawns.
| Column | Meaning |
| --- | --- |
| `agent_id` | Subagent ID |
| `session_id` | Parent session |
| `parent_tool_use_id` | Tool call that spawned the subagent |
| `agent_type` | Agent type label |
| `description` | Assigned task |
| `duration_ms` | Wall-clock duration |
| `total_tokens` | Sum of indexed agent tokens |
### `workflows`
Workflow execution records.
| Column | Meaning |
| --- | --- |
| `run_id` | Workflow run ID |
| `session_id` | Parent session |
| `task_id` | Task identifier |
| `script` | Workflow script content, truncated |
| `result_json` | JSON-serialized workflow result |
| `timestamp` | Execution timestamp |
| `agent_count` | Number of workflow agents |
| `duration_ms`, `total_tokens` | Aggregate run cost |
| `status` | Run status |
| `workflow_name` | Name from workflow metadata |
### `workflow_agents`
Individual agents inside a workflow run.
| Column | Meaning |
| --- | --- |
| `agent_id` | Workflow agent ID |
| `run_id` | FK to `workflows.run_id` |
| `session_id` | Parent session |
| `agent_type`, `description` | Agent task metadata |
| `phase`, `label` | Workflow positioning |
| `model`, `state` | Runtime state |
| `duration_ms`, `tokens`, `tool_calls` | Per-agent cost |
### `memories`
Human-approved markdown memory records. The markdown file at `path` is the
durable memory; `summary` is the compact retrieval surface.
| Column | Meaning |
| --- | --- |
| `id` | Memory ID |
| `session_id` | Source session, if known |
| `project` | Project slug for scoped recall |
| `message_start`, `message_end` | Source message UUID range |
| `path` | Normalized absolute markdown path |
| `anchors` | Optional JSON array of recall anchors |
| `summary` | English retrieval summary |
| `created_at` | Registration timestamp |
| `deleted_at` | Archive timestamp |
| `deleted_reason` | Archive reason |
Active memory means `deleted_at IS NULL`. Recall helpers omit archived rows.
When using raw SQL for memory recall, include `memories.deleted_at IS NULL`.
### `index_state`
Indexer progress and sentinel state.
| Column | Meaning |
| --- | --- |
| `jsonl_path` | Source path or synthetic sentinel key |
| `mtime` | Last indexed mtime |
| `lines_processed` | Incremental line cursor |
Sentinel keys include `__last_build__`, `__app_heartbeat__`,
`__app_last_successful_build__`, `__indexer_owner_app__`, and
`__last_source_mtime__`.
### FTS Tables
| Table | Search surface | Use |
| --- | --- | --- |
| `messages_fts` | `messages.text` | Usually through `search()` |
| `memories_fts` | `memories.path`, `memories.summary` | Usually through `memories({ query })` |
Prefer helpers for FTS. Raw `MATCH` syntax is easy to get wrong; see
`references/pitfalls.md` before debugging FTS behavior.
## Key Relationships
```
sessions.id <-- messages.session_id
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
messages.agent_id --> workflow_agents.agent_id
tool_calls.id <-- tool_results.tool_use_id
workflows.run_id <-- workflow_agents.run_id
```
## Safe SQL Joins
Tool calls with timestamps:
```sql
SELECT tc.id, tc.name, tc.file_path, m.timestamp, s.title
FROM tool_calls tc
JOIN messages m ON m.uuid = tc.message_uuid
JOIN sessions s ON s.id = tc.session_id
WHERE s.project LIKE ?
ORDER BY m.timestamp DESC
LIMIT 20;
```
Tool failures with timestamps:
```sql
SELECT tr.tool_use_id, tc.name, m.timestamp, substr(tr.content, 1, 200) AS error
FROM tool_results tr
JOIN tool_calls tc ON tc.id = tr.tool_use_id
JOIN messages m ON m.uuid = tr.message_uuid
WHERE tr.is_error = 1
ORDER BY m.timestamp DESC
LIMIT 20;
```
Ordinary message evidence:
```sql
SELECT m.uuid, m.role, m.timestamp, substr(m.text, 1, 220) AS snippet
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE s.project LIKE ?
AND COALESCE(m.is_meta, 0) = 0
ORDER BY m.timestamp DESC
LIMIT 20;
```
Active memories:
```sql
SELECT id, path, anchors, summary, session_id, created_at
FROM memories
WHERE project LIKE ?
AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 20;
```
## Indexes
Common indexed filters:
- `messages(session_id)`
- `messages(agent_id)`
- `messages(session_id, timestamp)`
- `sessions(source)`
- `messages(source)`
- `tool_calls(session_id, name)`
- `tool_calls(file_path)`
- `subagents(session_id)`
- `workflows(session_id)`
- `workflow_agents(run_id)`
- `summaries(session_id)`
- `memories(project)`
- `memories(session_id)`
- `memories(created_at)`
## Raw SQL Pitfalls
- Start with helpers. Use raw `sql()` for exact joins, grouping, aggregation, or
fields helpers do not expose.
- `sql()` accepts only read-only `SELECT`/`WITH`; use `--attune` for memory
mutation.
- `tool_calls` and `tool_results` do not have timestamps. Join `messages`.
- For normal user/assistant evidence, filter `COALESCE(m.is_meta, 0) = 0`.
- `summaries.source` is a summary kind, not provider provenance. Provider
source is on `sessions.source` and `messages.source`.
- `sessions.project` is a slug/fuzzy scope; `sessions.project_path` is the
absolute path when known; `messages.cwd` is per-message working directory.
- Memory rows are archived with `deleted_at`; do not recall archived memories.
- Indexed text and JSON fields are truncated to 10k chars. Use `raw()` from
`references/api-reference.md` when a specific message needs the original JSONL
line.
- Prefer SQL-side `COUNT`, `GROUP BY`, `MAX`, `ORDER BY`, and `LIMIT` over
returning large row sets and hand-counting in the final answer.