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
+406
View File
@@ -0,0 +1,406 @@
---
name: obelisk
description: >
Search and query past Claude Code and Codex session history.
Reactive: when the user asks "how did I fix X", "what did we do last time", "find the session where", "上次怎么修的", "之前的session", "历史记录".
Proactive: when the user references past work you lack context for, when you're about to modify a file with complex edit history, when the user says "继续之前的" or "continue where we left off", or when understanding prior decisions would improve your current response.
Memory: when the user says "记住这个", "remember this", "写入记忆", "save this conclusion", or when you determine a retrieval result contains a conclusion worth persisting.
allowed-tools:
- Read
- Bash(obelisk:*)
- Write
---
# obelisk
Search and query Claude Code and Codex session history stored in `~/.claude/`
and `~/.codex/`.
Obelisk indexes sessions, messages, tool calls, tool results, summaries,
subagents, workflows, workflow agents, parent chains, and raw JSONL lines into
SQLite + FTS5.
Obelisk has two transcript sources. Treat both as ordinary sessions by default:
Claude rows use `source='claude'`; Codex rows use `source='codex'` and IDs
prefixed with `codex:`. Use `source` only when provenance matters or the user
asks to scope to one provider. Codex subagent child threads are mapped to the
same `subagents` table; Codex workflow rows may be absent because Codex does not
emit Claude-style workflow metadata.
Obelisk is a CodeAct memory layer: write a small JS query, run it locally, read
the JSON, then answer. Do not turn history into a flat document or browse entire
sessions by default.
## Quick Start
Fast keyword search:
```bash
obelisk --search "keyword"
```
Custom query:
1. Write a bounded JS query to a temp file, for example `/tmp/q.mjs`.
2. Run:
```bash
obelisk --query /tmp/q.mjs
```
3. Parse JSON stdout and answer with concise evidence.
The query file runs inside `(async () => { ... })()`. Use `return` to emit JSON.
Query scripts are read-only: `remember()` and `forget()` are not available, and
`sql()` only accepts read-only SELECT/WITH queries.
## Default First Pass
Start with helpers, not raw SQL. For the first Obelisk query in a task, normally
call `overview({ limit: 6 })` unless the user already gave an exact
`session_id`, message `uuid`, or absolute file path.
For semantic or synthesis tasks, combine orientation, memory recall, and raw
session evidence before deciding whether a detail pass is needed:
```js
const map = overview({ limit: 6 });
const project = map.current.project?.project;
const topic = 'English topic terms translated from the user request';
return {
orientation: map.current_project,
prior_memories: memories({ project, query: topic, limit: 5 }),
session_evidence: search(topic.replace(/[-_]/g, ' '), { project, limit: 8 }),
};
```
Use `sql()` only as an escalation path for exact joins, aggregations, or schema
questions that helpers cannot express cleanly. Do not use raw SQL as a generic
fallback for broad retrieval.
## Intent Routing
Obelisk supports a small intent prefix layer after `/obelisk`. This is for
output intent, not retrieval architecture.
| Intent | Description | Reference |
|---|---|---|
| `recap [target]` | Generate weekly/monthly recap card content for app handoff or share-style output. | `references/recap/overview.md` |
Routing rules:
1. If the first word is `recap`, read `references/recap/overview.md` before the
first query. Everything after `recap` is the recap target.
Common app-generated prompts include `/obelisk recap this week`,
`/obelisk recap last week`, `/obelisk recap this month`, and
`/obelisk recap last month`; interpret these as natural period targets
relative to the current date and timezone.
2. `recap` does not create a separate retrieval layer. It still uses
`overview()`, `memories()`, helpers, and `sql()` only when needed.
3. Follow the overview's card-by-card sequence. Each card has its own retrieval
pattern and writing file; retrieve that card's evidence, read that card's
writing file, update the JSON, then move to the next card. Do not preload all
recap references before the current card is written.
4. If the first word is not `recap`, do not load
`references/recap/overview.md`. Continue with Query Routing below. Do not
infer recap from broad requests for weekly/monthly summaries, charts,
rankings, shareable cards, or playlist-style metaphors.
## Reference Map
Use references by job, not by habit:
| Reference | Use when |
|---|---|
| `references/query-patterns.md` | Broad synthesis, progress summaries, design history, weekly/monthly reviews, approved memory write/archive/update scripts, or questions about what the user did/learned/decided/tried/abandoned. |
| `references/retrieval-semantics.md` | Multi-step retrieval, scoped project/file/session searches, or when scope/artifact/semantic boundaries affect query design. |
| `references/schema.md` | Raw SQL field and join quick reference before writing non-trivial `sql()`. |
| `references/api-reference.md` | Helper signatures, option names, return fields, or exact `remember()` / `forget()` parameter details are unclear. |
| `references/pitfalls.md` | Error recovery, FTS syntax, aliases, ordering, row-shape surprises, or compact/raw tradeoffs. |
| `references/recap/overview.md` | Explicit `/obelisk recap ...` requests only. |
## Query Routing
Before writing a query, classify the task. Progressive disclosure is useful, but
skipping the relevant reference usually costs extra query rounds.
- Read `references/query-patterns.md` before the first query for broad synthesis, progress summaries, design history, ordinary weekly/monthly reviews, or questions that ask what the user did, learned, decided, tried, or abandoned. Start from the first-pass or one-shot synthesis pattern, then run a faceted detail pass if needed.
- Read `references/retrieval-semantics.md` before multi-step retrieval, scoped project/file/session searches, or synthesis/conclusion/history questions. It defines the query design frame.
- Read `references/schema.md` before raw `sql()` unless the needed table/column relationship is already explicit here. It is intentionally short and SQL-focused. Do this before running the SQL, not after a missing-column error. Do not start with raw SQL for broad synthesis unless helpers cannot express the needed aggregation or join.
- Read `references/api-reference.md` when helper option names, return fields, scalar shorthand behavior, or `remember()`/`forget()` details are unclear.
- Read `references/pitfalls.md` after an error or when FTS syntax, aliases, ordering, row shapes, or compact/raw tradeoffs are unclear.
If a helper row shape is unclear, first run a tiny scoped query and return
`Object.keys(row)` or a compact sample. Do not invent field names.
For approved memory mutations, follow the Memory Layer section below first.
Use `references/query-patterns.md` for copyable `--attune` scripts
(`Attune Approved Memory`, `Forget Approved Memory`, `Update Approved Memory`),
and `references/api-reference.md` only for exact parameter semantics.
## Core API
### `search(text, opts?)`
Full-text search across main messages, subagent messages, and workflow-agent
messages.
Returns:
```js
[{ message: { uuid, text, content_type, is_meta, role, timestamp, model, cwd, source },
session: { id, title, project, started_at, source },
rank,
context }]
```
`context` here means temporal neighbors: nearby messages in the same session by
timestamp. It is not the parent chain. Use `context(uuid)` or `trace(uuid)` for
causal/parent-chain context.
Use `message.content_type` to keep evidence boundaries intact:
`text` is user/assistant visible language, `thinking` is trace/debug material,
`tool_use` marks a tool-call message whose details live in `tool_calls`, and
`tool_result` marks a tool-result message whose details live in `tool_results`.
`unknown` is a conservative fallback. Do not treat `thinking` as a user-visible
assistant conclusion. Real user input is `type='user'` plus `content_type='text'`;
do not invent a separate `user_message` content type.
Use `message.is_meta` to separate transcript control-plane material from
conversation evidence. `is_meta=1` marks injected caveats, command envelopes, or
other messages that entered the transcript as user-role content but should not
be treated as the user's request by default. `search()` and `thread()` omit meta
messages unless `includeMeta: true` is passed; `context()` and `trace()` preserve
the original chain and expose `is_meta` on rows.
Opts: `{ limit, sessionId, project, after, before, cwd, source, includeMeta }`.
`project` is a SQL `LIKE` filter over `sessions.project`, not an exact project
identity. Results are already ordered by FTS5 rank; lower rank sorts earlier.
Prefer returned order over manually interpreting numeric rank unless you are
deliberately using FTS5 semantics.
`source` can be `'claude'`, `'codex'`, or omitted. Omitted means search all
indexed sources.
### `context(uuid)`
Returns the full story around one indexed message:
```js
{ message, parentChain, session, subagent, workflow }
```
Use this after `search()` finds a promising message. It is the usual way to
expand vertically from one evidence point without dumping the whole session.
### `sql(query, ...params)`
Read-only SQL SELECT/WITH with `?` placeholders. Returns array rows. SQL is an
escape hatch for exact structured joins and aggregations after the helper-first
surface is insufficient; it is not the default retrieval entry point.
Before writing non-trivial SQL, read `references/schema.md`. It is the raw SQL
field/join quick reference. The executable DDL is CLI-owned and is deliberately
not duplicated in this docs-only skill. Common safe joins:
- `tool_calls` does not have timestamps. Join `messages m ON m.uuid = tc.message_uuid`.
- `tool_results` does not have timestamps. Join `messages m ON m.uuid = tr.message_uuid`.
- For project/session filters, join `sessions s ON s.id = <table>.session_id`.
- Prefer SQL-side `GROUP BY`, `COUNT`, `MAX`, `ORDER BY`, and `LIMIT` over hand-counting in the final answer.
Tables: `sessions`, `messages`, `tool_calls`, `tool_results`, `summaries`,
`memories`, `subagents`, `workflows`, `workflow_agents`, `messages_fts`.
## Structured Helpers
These helpers are convenience accessors over the same SQLite structure. They do
not replace `sql()`, but they are the default first-pass surface. Use `sql()`
when you need an exact aggregation or a join the helper does not expose.
All list helpers accept a bounded `limit`. Many also accept:
`{ project, after, before, sessionId, sessions, branch, source }`. Check
`references/api-reference.md` or a tiny sample before relying on less common
filters or return fields.
- `overview(opts?)` -- compact orientation map. Returns current cwd/project if knowable, global project/source counts, and current-project recent sessions plus memory records. It is a map, not evidence.
- `sessions(opts?)` -- session rows, newest first. `project` is a SQL `LIKE` pattern.
- `recent(n?)` -- shorthand for recent sessions.
- `summaries(opts?)` -- summary rows, newest first: `{ id, session_id, timestamp, source, content, session_title, project }`; here `source` is the summary kind, not the transcript provider.
- `subagents(opts?)` -- subagent metadata plus `messageCount`.
- `workflows(opts?)` -- workflow runs, newest first.
- `workflowTree(runId)` -- workflow row plus parsed `result` and `agents`; may include bulky `script` and `result_json`, so project compact fields.
- `fileHistory(filePath, opts?)` -- Read/Edit/Write tool calls for a file, oldest first; includes many `Read` rows.
- `failures(opts?)` -- failed tool results with tool/session context, newest first.
- `trace(uuid)` -- parent chain from root to message.
- `thread(sessionId, opts?)` -- session messages ordered by timestamp, omitting meta messages by default. Pass `{ includeMeta: true }` when investigating injected context or command envelopes.
- `raw(uuid, opts?)` -- windowed access to the original JSONL line.
- `memories(opts?)` -- recall memory layer. opts: `{ query, project, sessionId, sessions, after, before, branch, limit }`. Without `query`, returns active memory records newest first. With `query`, searches `summary`/`path` through safe FTS5 tokenization and returns `rank`; lower rank sorts earlier. Records may include nullable JSON `anchors` for explicit recall surfaces such as files. Read the file at `path` for full content.
## Retrieval Contract
Keep queries scoped, bounded, and structural.
- Scope First: classify the locator as scope, artifact, or semantic. Use the narrowest structural locator before FTS; empty scoped results are valid unless the user asks to broaden.
- Orient First: for a new task, normally call `overview({ limit: 6 })` before deeper retrieval unless the user gave an exact session/message/file locator. It is a navigation map; confirm facts with `memories()`, `search()`, helpers, or, only when needed, `sql()`.
- Helper First: prefer `overview()`, `memories()`, `search()`, `sessions()`, `summaries()`, `fileHistory()`, and other helpers for first-pass retrieval. Escalate to raw `sql()` only when helpers cannot express the needed join, grouping, or exact schema-level check.
- Plan Before Probe: for conclusion, broad history, failure investigation, or file evolution, write a bounded retrieval script instead of spending turns on intermediate results.
- Structure Before Text: compute counts, joins, grouping, dedupe, and projection in SQL or JS; keep runtime JSON compact, ideally under 10k-12k chars for synthesis tasks.
- Evidence Before Conclusion: return compact evidence with stable IDs (`session_id`, `uuid`, `tool_call_id`, `run_id`, `agent_id`) and short snippets, then synthesize in the final answer.
- Exclude Meta By Default: `is_meta=1` rows are injected/control-plane transcript material. Helpers hide them by default; raw SQL for ordinary conversation evidence should include `COALESCE(m.is_meta,0)=0` unless meta rows are the investigation target.
- Persist Durable Conclusions: after answering, if retrieval produced a durable conclusion that future sessions are likely to reuse and `memories()` does not already cover it, explicitly offer to write a memory. Keep the offer brief. Do not write the markdown file or run `--attune` until the user approves.
If field, context, ordering, FTS, or helper semantics affect the query, read
`references/retrieval-semantics.md` before coding. If a query errors, read
`references/pitfalls.md` before retrying.
## Memory Layer
Obelisk has a persistent memory layer alongside raw session data. Every
retrieval queries both layers: `memories()` for prior conclusions, `search()`
and helpers for raw session evidence. Use memory as prior notes, not final
authority. If a memory record influences your answer, say naturally that it was
previously recorded, and compare it with raw session evidence when correctness
depends on it. Raw session data is the evidence layer, but one hit is not a
complete truth; query and cite it compactly.
The memory layer is English-indexed. Use English terms in `memories({ query })`
even when the user asks in another language. Write every `remember().summary`
in English, regardless of the current conversation language. The runtime rejects
obvious CJK text in memory queries and summaries as a guardrail.
**Recall:** query `memories({ query: 'English topic terms', project: '...' })`
to find prior conclusions relevant to the current task. Translate non-English
user requests into concise English query terms before calling `memories()`.
Memory recall uses safe FTS5 tokenization over `summary` and `path`, so
hyphens/punctuation are tokenized instead of causing raw `MATCH` syntax errors.
Like other list helpers, passing a string is treated as `sessionId`, and passing
a number is treated as `limit`. Read the file at `path` for full content.
`memories()` returns active memories only. An archived memory is
management/audit data, not recall data.
Good memory 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 conclusions already covered by existing
memories.
**Mutation approvals:** judging whether to use a memory in the current answer is
an agent decision and does not require approval. Persistent memory changes do.
If the user explicitly says a memory is wrong, outdated, should be forgotten, or
should now say something else, that request is the approval to archive or update
the exact matching memory. Do not ask for a second confirmation unless multiple
memories could match. If you notice a possible conflict yourself, explain it
briefly and ask before changing memory state.
**Writing memories:** after a retrieval produces a conclusion worth persisting,
propose writing a memory file. The user must approve. Flow:
1. Write a markdown file using the `Write` tool (user approves).
2. Register it via `remember()` in a narrow memory-registration script:
```js
return remember({
path: '.obelisk/memories/design-decision-x.md',
session_id: 'current-session-id',
message_start: 'uuid-of-first-relevant-msg',
message_end: 'uuid-of-last-relevant-msg',
anchors: [{ kind: 'file', path: 'src/path/to/file.ts' }],
summary: 'Detailed summary: what was decided, why, what alternatives were considered, and what constraints drove the choice.'
})
```
Run the registration script with:
```bash
obelisk --attune /tmp/register-memory.mjs
```
`--attune` exposes only memory mutation helpers: `remember()` and `forget()`.
It does not expose `search()`, `sql()`, `memories()`, or other retrieval
helpers. If you need source IDs or memory IDs, find them first with a normal
`--query` script.
`remember()` validates that `path` already exists and points to a file. Relative
paths are resolved against the source session's `project_path` when
`session_id` is provided, then stored as normalized absolute paths. Prefer
project-relative paths such as `.obelisk/memories/...` plus `session_id`.
Optional `anchors` must be an array of objects and is stored as nullable JSON
text. Use it only for explicit recall surfaces, such as files associated with
the memory.
`summary` must be English and detailed enough that `memories()` results alone
can judge relevance without reading the file. Include the decision, the
reasoning, and the key constraints — not just a title.
The `message_start`/`message_end` range marks where in the conversation this
conclusion was drawn. Use it later to trace back to the original evidence.
**Forgetting memories:** if the user says a memory is outdated, wrong, or should
be forgotten, use normal recall first to identify the exact memory ID. If there
is exactly one clear candidate, the user's request is approval to archive it. If
multiple memories could match, ask which one to forget. Then run an `--attune`
script:
```js
return forget({
id: 'mem-id-to-delete',
reason: 'Outdated by newer project guidance.',
});
```
`forget()` archives the memory record by setting `deleted_at` and
`deleted_reason`. It removes the record from active recall but does not delete
the markdown file. Memory records survive index rebuilds and are never changed
automatically.
**Updating memories:** updating memory is one user-approved operation:
archive the old memory with `forget()`, then write and register a replacement
markdown memory with `remember()`. If the user explicitly corrected the memory,
that correction is approval for the combined archive-plus-write flow. If you
discovered the mismatch yourself, ask first.
## Minimal Patterns
Search, then expand one promising hit:
```js
const hits = search('auth fix', { limit: 5 });
if (!hits.length) return [];
return hits.slice(0, 3).map(h => ({
session_id: h.session.id,
session_title: h.session.title,
uuid: h.message.uuid,
snippet: h.message.text?.slice(0, 240),
}));
```
Check helper fields before assuming names:
```js
const rows = summaries({ project: '%quiet-zero%', limit: 1 });
return rows.length ? Object.keys(rows[0]) : [];
```
Fetch message neighbors without a full thread:
```js
const hit = search('runtime query', { limit: 1 })[0];
return sql(
`SELECT uuid, role, timestamp, substr(text,1,240) AS snippet
FROM messages
WHERE session_id=? AND timestamp>=?
ORDER BY timestamp LIMIT 6`,
hit.session.id,
hit.message.timestamp
);
```
See `references/query-patterns.md` for longer recipes.
## Notes
- First run builds the index. Later runs update incrementally.
- DB location: `~/.obelisk/obelisk.sqlite`; old `~/.claude/obelisk.sqlite` is copied forward if needed.
- Query scripts run in a sandboxed VM with no filesystem or network access from inside the script.
- Indexed text and stored tool inputs/results are truncated to 10k chars. Use `raw(uuid, { offset, limit })` for specific JSONL windows.
+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.