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:
1 parent
49158e9b9c
commit
90191e4604
64 files changed
+1113
-746
No files matched your search
@@ -0,0 +1,32 @@
|
||||
name: CLI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Node 22 / ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
|
||||
- run: npm ci
|
||||
- run: npm run build:cli
|
||||
- name: Verify CLI package and runtime contract
|
||||
run: node --experimental-test-module-mocks --test tests/cli-package.test.mjs tests/runtime-cli-envelope.test.mjs tests/runtime.test.mjs
|
||||
|
||||
- name: Verify POSIX bootstrap installer
|
||||
if: runner.os != 'Windows'
|
||||
run: node --test tests/cli-bootstrap-install.test.mjs
|
||||
+7
-6
@@ -10,8 +10,9 @@ not a spec.
|
||||
|
||||
**Runtime interface**:
|
||||
The public contract, expressed as four verbs — `build`, `search(text)`,
|
||||
`query(code)`, `attune(code)`. Skill, CLI, and MCP are transports over this same
|
||||
shape; none of them add their own retrieval surface.
|
||||
`query(code)`, `attune(code)`. CLI and a future MCP server are transports over
|
||||
this same shape; neither adds its own retrieval surface. The agent skill is
|
||||
docs-only guidance that invokes the CLI rather than a transport of its own.
|
||||
_Avoid_: API, tool surface
|
||||
|
||||
**CodeAct**:
|
||||
@@ -44,7 +45,7 @@ persistence happens.
|
||||
**Persist layer**:
|
||||
The single shared, provider- and binding-agnostic writer that consumes records
|
||||
from any adapter and writes them into an injected SQLite handle inside a
|
||||
transaction. The binding is injected — `node:sqlite` (skill/CLI) or
|
||||
transaction. The binding is injected — `node:sqlite` (CLI) or
|
||||
`better-sqlite3` (app) — so there is one persist implementation, not one per
|
||||
binding.
|
||||
_Avoid_: writer, sink, DAO
|
||||
@@ -56,8 +57,8 @@ as files change.
|
||||
_Avoid_: watcher mode, live indexing
|
||||
|
||||
**Passive pull mode**:
|
||||
On-demand incremental indexing performed by the skill when there is no active
|
||||
daemon: an invocation of the runtime brings the index up to date, then answers.
|
||||
On-demand incremental indexing performed by a CLI invocation when there is no
|
||||
active daemon: the command brings the index up to date, then answers.
|
||||
_Avoid_: lazy indexing, on-read indexing
|
||||
|
||||
**index_state**:
|
||||
@@ -68,7 +69,7 @@ arbitration.
|
||||
|
||||
**Daemon arbitration**:
|
||||
The policy by which the passive pull mode detects a fresh daemon from the
|
||||
`__app_heartbeat__` marker and skips every skill-side mutation, including schema
|
||||
`__app_heartbeat__` marker and skips every CLI-side mutation, including schema
|
||||
setup, indexing, checkpointing, and `attune`. The heartbeat alone means “the
|
||||
daemon should write”; `__app_last_successful_build__` records coverage/freshness,
|
||||
not ownership. Both indexing modes use the same persist layer.
|
||||
|
||||
@@ -19,7 +19,9 @@ Every past Claude Code and Codex session -- queryable by your agent, browsable b
|
||||
|
||||
Obelisk has two sides that share one SQLite index:
|
||||
|
||||
**Skill side** — an agent skill that lets coding agents search and query their own session history. The agent writes JS queries, runs them locally, answers in plain language.
|
||||
**Agent side** — the `obelisk` CLI owns the local runtime, while a separate
|
||||
agent skill teaches coding agents how to search and query their session history.
|
||||
The agent writes JS queries, runs them locally, and answers in plain language.
|
||||
|
||||
**App side** — an Electron desktop app for humans to browse sessions, manage memories, view usage stats, and see weekly recap cards.
|
||||
|
||||
@@ -51,11 +53,31 @@ You can use obelisk like:
|
||||
|
||||
### Install
|
||||
|
||||
Obelisk requires Node.js 22.13 or newer. Install the platform-neutral CLI first:
|
||||
|
||||
```bash
|
||||
npx skills add tommy0103/obelisk-skill
|
||||
npm install --global @obelisk-apps/cli
|
||||
obelisk --version
|
||||
```
|
||||
|
||||
Or manually: copy `obelisk-skill/skills/obelisk into your project's `.claude/skills/`
|
||||
On macOS, Linux, or WSL, the CLI-only installer is equivalent:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/tommy0103/obelisk/main/install.sh | sh
|
||||
```
|
||||
|
||||
Then install the agent skill:
|
||||
|
||||
```bash
|
||||
obelisk install
|
||||
```
|
||||
|
||||
`obelisk install` delegates to the standard skills installer for
|
||||
`tommy0103/obelisk-skill`. To let an agent perform the one-time bootstrap, give
|
||||
it the repository's root
|
||||
[`SKILL.md`](https://raw.githubusercontent.com/tommy0103/obelisk/main/SKILL.md).
|
||||
That document installs the CLI and formal skill; it is not the query skill
|
||||
itself.
|
||||
|
||||
Then in any Claude Code session:
|
||||
|
||||
@@ -72,7 +94,7 @@ You ask a question
|
||||
↓
|
||||
Agent writes a JS query against the SQLite index
|
||||
↓
|
||||
Runs it via node $SKILL_DIR/scripts/runtime.js --query <script>
|
||||
Runs it via obelisk --query <script>
|
||||
↓
|
||||
Reads the JSON result, answers in natural language
|
||||
```
|
||||
@@ -81,11 +103,12 @@ Core API: `search()`, `context()`, `sql()`, plus structured helpers (`sessions`,
|
||||
|
||||
### Memory layer
|
||||
|
||||
When a retrieval produces a conclusion worth keeping, the agent proposes a markdown memory file. After user approval, it registers the file with `runtime.js --attune <script>`. Memories are recalled via `memories()` in future sessions — a synthesis cache, not a replacement for raw evidence.
|
||||
When a retrieval produces a conclusion worth keeping, the agent proposes a markdown memory file. After user approval, it registers the file with `obelisk --attune <script>`. Memories are recalled via `memories()` in future sessions — a synthesis cache, not a replacement for raw evidence.
|
||||
|
||||
## App: A surface for humans
|
||||
|
||||
A companion desktop app for browsing what the skill indexes.
|
||||
A companion desktop app for browsing the same index maintained by the CLI or
|
||||
the app daemon.
|
||||
|
||||
<div align="center">
|
||||
<img src=".github/assets/app-screenshot.png" alt="Obelisk App" width="720">
|
||||
@@ -170,23 +193,20 @@ packages/core/ # @obelisk/core npm workspace (TypeScript + ESM)
|
||||
│ ├── parsing.ts # Pure helpers (node:sqlite-free, app-consumable)
|
||||
│ ├── db.ts # node:sqlite lifecycle + migrations
|
||||
│ ├── query.ts # Query/attune sandbox API (helpers)
|
||||
│ ├── runtime.ts # Thin CLI shell (--build/--search/--query/--attune)
|
||||
│ └── schema.sql # SQLite schema (single source of truth)
|
||||
├── package.json
|
||||
└── dist/ # Generated package JS, declarations, and schema
|
||||
|
||||
references/ # Agent-readable docs (progressive disclosure)
|
||||
├── schema.md
|
||||
├── api-reference.md
|
||||
├── query-patterns.md
|
||||
├── retrieval-semantics.md
|
||||
├── pitfalls.md
|
||||
├── recap-patterns.md
|
||||
├── recap-writing.md
|
||||
└── recap/ # Per-card pattern + writing references
|
||||
├── overview.md
|
||||
├── pattern1-cover.md … pattern5-closing.md
|
||||
└── writing1-cover.md … writing5-closing.md
|
||||
packages/cli/ # @obelisk-apps/cli npm workspace
|
||||
├── src/obelisk.ts # CLI shell + skill installer delegation
|
||||
├── scripts/build.mjs # Compiles CLI + readable Core into one package
|
||||
├── package.json
|
||||
└── dist/ # Generated platform-neutral npm payload
|
||||
|
||||
skill-doc/ # Source for the docs-only obelisk agent skill
|
||||
├── SKILL.md # Query and memory workflow
|
||||
└── references/ # Progressive-disclosure API/schema/pattern docs
|
||||
└── recap/ # Per-card recap retrieval + writing references
|
||||
|
||||
app/ # Electron desktop app (electron-vite + Vue)
|
||||
├── src/main/ # TypeScript main process (consumes shared core)
|
||||
@@ -195,32 +215,37 @@ app/ # Electron desktop app (electron-vite + Vue)
|
||||
└── electron.vite.config.ts
|
||||
|
||||
packaging/ # Skill publish infrastructure
|
||||
├── build-skill.mjs # Builds the docs-only skill artifact
|
||||
├── skill-package.json
|
||||
├── skill-README.md
|
||||
├── skill-LICENSE # MIT (relicensed for the skill artifact)
|
||||
└── publish-skill.sh
|
||||
|
||||
SKILL.md # Skill definition (installed with the artifact)
|
||||
SKILL.md # Remote one-time CLI + skill bootstrap guide
|
||||
install.sh # POSIX CLI-only installer
|
||||
CONTEXT.md # Project glossary
|
||||
docs/adr/ # Architecture decision records (0001–0006)
|
||||
```
|
||||
|
||||
The optional `/obelisk recap` flow is loaded only for explicit `/obelisk recap` intent.
|
||||
It starts at `references/recap/overview.md` and proceeds card-by-card:
|
||||
It starts at `skill-doc/references/recap/overview.md` and proceeds card-by-card:
|
||||
|
||||
- `references/recap/pattern1-cover.md` + `references/recap/writing1-cover.md`
|
||||
- `references/recap/pattern2-thinking.md` + `references/recap/writing2-thinking.md`
|
||||
- `references/recap/pattern3-vibe.md` + `references/recap/writing3-vibe.md`
|
||||
- `references/recap/pattern4-workflow.md` + `references/recap/writing4-workflow.md`
|
||||
- `references/recap/pattern5-closing.md` + `references/recap/writing5-closing.md`
|
||||
- `skill-doc/references/recap/pattern1-cover.md` + `skill-doc/references/recap/writing1-cover.md`
|
||||
- `skill-doc/references/recap/pattern2-thinking.md` + `skill-doc/references/recap/writing2-thinking.md`
|
||||
- `skill-doc/references/recap/pattern3-vibe.md` + `skill-doc/references/recap/writing3-vibe.md`
|
||||
- `skill-doc/references/recap/pattern4-workflow.md` + `skill-doc/references/recap/writing4-workflow.md`
|
||||
- `skill-doc/references/recap/pattern5-closing.md` + `skill-doc/references/recap/writing5-closing.md`
|
||||
|
||||
### Generated build outputs
|
||||
|
||||
- `packages/core/dist/` is produced by `npm run build:core`. It is the compiled
|
||||
`@obelisk/core` package: JavaScript, type declarations, and `schema.sql`.
|
||||
internal `@obelisk/core` workspace: JavaScript, type declarations, and
|
||||
`schema.sql`.
|
||||
- `packages/cli/dist/` is produced by `npm run build:cli`. It is the publishable
|
||||
`@obelisk-apps/cli` payload: the thin command shell, readable compiled Core,
|
||||
and `schema.sql`.
|
||||
- `dist/obelisk-skill/` is produced by `npm run build:skill`. It is the
|
||||
install-ready skill artifact: readable plain JavaScript under `scripts/`,
|
||||
`SKILL.md`, references, and the skill package metadata.
|
||||
docs-only skill artifact: `SKILL.md`, references, and skill package metadata.
|
||||
- Skill publishing stages that artifact at `skills/obelisk/` in the
|
||||
`obelisk-skill` repository; only `README.md` and `LICENSE` remain at the
|
||||
repository root for `npx skills` discovery.
|
||||
@@ -233,11 +258,13 @@ app imports `packages/core/src/` directly so electron-vite can bundle Core.
|
||||
The index rebuilds incrementally — only new or modified JSONL files are re-parsed.
|
||||
When the optional app is running, it is the active indexer: it watches Claude
|
||||
project files and builds in a worker thread. A fresh `__app_heartbeat__` alone
|
||||
means the daemon owns writes, so the skill remains read-only; a separate SQLite
|
||||
means the daemon owns writes, so CLI invocations remain read-only; a separate SQLite
|
||||
writer lease prevents cross-process writes from overlapping. The
|
||||
`__app_last_successful_build__` marker records index freshness, not ownership.
|
||||
|
||||
Zero npm dependencies. Uses Node 22's built-in node:sqlite with FTS5. The entire runtime is ~400 lines.
|
||||
The CLI has zero runtime npm dependencies and uses Node 22's built-in
|
||||
`node:sqlite` with FTS5. The formal skill contains instructions and references,
|
||||
not a second executable runtime.
|
||||
|
||||
20K lines of scattered JSONL → something the agent can search() and sql() against in milliseconds.
|
||||
|
||||
|
||||
@@ -1,408 +1,61 @@
|
||||
---
|
||||
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.
|
||||
name: obelisk-installer
|
||||
description: Install the official Obelisk CLI and then install the Obelisk agent skill. Use only for initial setup or repair when the `obelisk` command is missing.
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Bash(node:*)
|
||||
- Write
|
||||
- Bash
|
||||
---
|
||||
|
||||
# obelisk
|
||||
# Install 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.
|
||||
This is a one-time bootstrap guide. It installs the local Obelisk runtime, then
|
||||
uses that runtime to install the official agent skill from
|
||||
`github.com/tommy0103/obelisk-skill`.
|
||||
|
||||
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
|
||||
|
||||
The skill directory is provided as `$SKILL_DIR` at invocation time.
|
||||
|
||||
Fast keyword search:
|
||||
## 1. Check for the CLI
|
||||
|
||||
```bash
|
||||
node $SKILL_DIR/scripts/runtime.js --search "keyword"
|
||||
command -v obelisk >/dev/null 2>&1 && obelisk --version
|
||||
```
|
||||
|
||||
Custom query:
|
||||
If this succeeds, skip to step 3. Do not reinstall a working CLI unless the user
|
||||
asked to upgrade or repair it.
|
||||
|
||||
1. Write a bounded JS query to a temp file, for example `/tmp/q.mjs`.
|
||||
2. Run:
|
||||
## 2. Install the CLI
|
||||
|
||||
Installing a global command changes the user's machine. Show the command and get
|
||||
the user's approval before running one of these official installation methods.
|
||||
|
||||
With npm:
|
||||
|
||||
```bash
|
||||
node $SKILL_DIR/scripts/runtime.js --query /tmp/q.mjs
|
||||
npm install --global @obelisk-apps/cli
|
||||
```
|
||||
|
||||
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 lives in `scripts/schema.sql`;
|
||||
use the SQL file only when checking source alignment. 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:
|
||||
On macOS, Linux, or WSL, the official installer performs the same CLI-only
|
||||
installation and never installs an agent skill:
|
||||
|
||||
```bash
|
||||
node $SKILL_DIR/scripts/runtime.js --attune /tmp/register-memory.mjs
|
||||
curl -fsSL https://raw.githubusercontent.com/tommy0103/obelisk/main/install.sh | sh
|
||||
```
|
||||
|
||||
`--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.
|
||||
Never use `sudo`, install a daemon, or download Obelisk from another source.
|
||||
|
||||
`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.
|
||||
Verify the result:
|
||||
|
||||
`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.',
|
||||
});
|
||||
```bash
|
||||
obelisk --version
|
||||
```
|
||||
|
||||
`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.
|
||||
## 3. Install the official skill
|
||||
|
||||
**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),
|
||||
}));
|
||||
```bash
|
||||
obelisk install
|
||||
```
|
||||
|
||||
Check helper fields before assuming names:
|
||||
Pass through any target or scope options the user requested. The command uses
|
||||
the standard skills installer, so follow its prompts instead of copying skill
|
||||
files by hand.
|
||||
|
||||
```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.
|
||||
After installation, tell the user to reload their agent if the new `/obelisk`
|
||||
skill is not discovered immediately. This bootstrap document is not the query
|
||||
skill and must not answer session-history questions itself.
|
||||
@@ -70,7 +70,7 @@ messages[focusMessageIndex].text = `Truncated preview ${'indexed content '.repea
|
||||
const fullTextSentinel = `FULL TEXT SENTINEL ${'complete content '.repeat(80)}`;
|
||||
const codexExecSource = 'const result = { ok: true };\nreturn result;';
|
||||
const liveBashToolInput = {
|
||||
command: "cat > /tmp/q_jul15b.mjs <<'EOF'\nconst codex = sessions({ source: 'codex', project: '%quiet-zero%', limit: 3 });\n\nconst tail = sql(`\n SELECT substr(text, 1, 500) as snippet, timestamp, role\n FROM messages\n WHERE session_id = ?\n AND timestamp > '2026-07-14T18:20:00'\n AND text IS NOT NULL\n AND COALESCE(is_meta, 0) = 0\n AND length(text) > 30\n ORDER BY timestamp DESC\n LIMIT 5\n`, codex[0]?.id);\n\n// Any new codex sessions for quiet-zero\nconst newer = sql(`\n SELECT id, title, started_at, ended_at, message_count\n FROM sessions\n WHERE COALESCE(source,'claude') = 'codex'\n AND project LIKE '%quiet-zero%'\n AND started_at > '2026-07-14T18:00:00'\n ORDER BY started_at DESC\n LIMIT 5\n`);\n\nreturn {\n main: { id: codex[0]?.id, ended: codex[0]?.ended_at, msgs: codex[0]?.message_count },\n afterLastSync: tail,\n newerSessions: newer,\n};\nEOF\nnode /Users/tomiya/.claude/skills/obelisk/scripts/runtime.js --query /tmp/q_jul15b.mjs",
|
||||
command: "cat > /tmp/q_jul15b.mjs <<'EOF'\nconst codex = sessions({ source: 'codex', project: '%quiet-zero%', limit: 3 });\n\nconst tail = sql(`\n SELECT substr(text, 1, 500) as snippet, timestamp, role\n FROM messages\n WHERE session_id = ?\n AND timestamp > '2026-07-14T18:20:00'\n AND text IS NOT NULL\n AND COALESCE(is_meta, 0) = 0\n AND length(text) > 30\n ORDER BY timestamp DESC\n LIMIT 5\n`, codex[0]?.id);\n\n// Any new codex sessions for quiet-zero\nconst newer = sql(`\n SELECT id, title, started_at, ended_at, message_count\n FROM sessions\n WHERE COALESCE(source,'claude') = 'codex'\n AND project LIKE '%quiet-zero%'\n AND started_at > '2026-07-14T18:00:00'\n ORDER BY started_at DESC\n LIMIT 5\n`);\n\nreturn {\n main: { id: codex[0]?.id, ended: codex[0]?.ended_at, msgs: codex[0]?.message_count },\n afterLastSync: tail,\n newerSessions: newer,\n};\nEOF\nobelisk --query /tmp/q_jul15b.mjs",
|
||||
description: 'Query for activity since last sync',
|
||||
};
|
||||
let codexExecOutput = JSON.stringify([{
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
> persist layer, not one per binding.
|
||||
|
||||
**Context.** Obelisk had two divergent full indexers — the former
|
||||
`scripts/indexer.mjs` (`node:sqlite`, skill/runtime) and `app/indexer.js`
|
||||
`scripts/indexer.mjs` (`node:sqlite`, the former skill-embedded runtime) and
|
||||
`app/indexer.js`
|
||||
(`better-sqlite3`, Electron
|
||||
app) — that duplicated the same Claude and Codex JSONL parsing and had silently
|
||||
diverged in write semantics (`INSERT OR REPLACE` vs `ON CONFLICT DO UPDATE`,
|
||||
@@ -31,14 +32,14 @@ binding-agnostic and does not need a per-binding implementation.
|
||||
incremental `index_state` bookkeeping, FTS maintenance, and the canonical
|
||||
**upsert** (`ON CONFLICT(uuid) DO UPDATE`) write semantics reconciled from the
|
||||
drift on 2026-07-08. The database handle is *injected*, so `node:sqlite`
|
||||
(skill/CLI) and `better-sqlite3` (app) run the same code — there is no
|
||||
(CLI) and `better-sqlite3` (app) run the same code — there is no
|
||||
per-binding persist layer.
|
||||
|
||||
**Two indexing modes** share all of the above and differ only in trigger:
|
||||
**daemon mode** (app/CLI watches and keeps the index fresh) and **passive pull
|
||||
mode** (skill indexes on invocation when no daemon is active). They never write
|
||||
concurrently — passive mode detects a fresh daemon via heartbeat markers in
|
||||
`index_state` (**daemon arbitration**).
|
||||
**daemon mode** (the app, and potentially a future CLI daemon, watches and keeps
|
||||
the index fresh) and **passive pull mode** (a CLI command indexes on invocation
|
||||
when no daemon is active). They never write concurrently — passive mode detects
|
||||
a fresh daemon via heartbeat markers in `index_state` (**daemon arbitration**).
|
||||
|
||||
**Consequences.** Golden tests anchor on each adapter's `parse` output (feed
|
||||
fixture JSONL, assert the yielded record sequence) — independent of binding and
|
||||
|
||||
@@ -5,8 +5,8 @@ pin what "the contract" is so refactoring cannot silently change observable
|
||||
behavior. The four verbs (`build`/`search`/`query`/`attune`) are only the entry
|
||||
surface; agents actually depend on the *return shapes* of the sandbox helpers
|
||||
(`search`, `overview`, `memories`, …), which are already documented in
|
||||
`references/api-reference.md` and relied on by every example in
|
||||
`references/query-patterns.md`. Current behavior is good and there is no reason to
|
||||
`skill-doc/references/api-reference.md` and relied on by every example in
|
||||
`skill-doc/references/query-patterns.md`. Current behavior is good and there is no reason to
|
||||
change it during migration.
|
||||
|
||||
**Decision.** Freeze the contract in two tiers. **Tier 1 (hard freeze, golden
|
||||
@@ -16,8 +16,8 @@ read-only enforcement, `attune` exposing only `remember`/`forget`, the set of
|
||||
globals/helpers available inside `query`/`attune`). **Tier 2 (locked to
|
||||
api-reference.md):** each helper's documented return shape — not frozen forever,
|
||||
but never allowed to drift silently; contract tests assert the live shape matches
|
||||
`references/api-reference.md`, so changing a helper forces a doc change plus a
|
||||
deliberate version bump. `references/api-reference.md` is therefore promoted from
|
||||
`skill-doc/references/api-reference.md`, so changing a helper forces a doc change plus a
|
||||
deliberate version bump. `skill-doc/references/api-reference.md` is therefore promoted from
|
||||
description to authoritative contract, and Phase 1 becomes "make it authoritative
|
||||
and enforce it," not "write a new contract doc."
|
||||
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
# Core is authored in TypeScript, shipped as precompiled ESM JavaScript
|
||||
|
||||
**Context.** The extracted Obelisk Core must serve two consumers — the ESM skill
|
||||
runtime (`node:sqlite`) and the CommonJS Electron app (`better-sqlite3`) — while
|
||||
the skill artifact must install with **zero build step** on the user's machine
|
||||
(the clone-and-run, "low-friction skill" goal). Authoring in TS gives the infra
|
||||
its checkable contracts, but raises how the compiled output is shipped and which
|
||||
module format it targets.
|
||||
**Context.** The extracted Obelisk Core must serve two consumers — the ESM CLI
|
||||
runtime (`node:sqlite`) and the Electron app (`better-sqlite3`) — while the CLI
|
||||
must install with **zero build step** on the user's machine. Authoring in TS
|
||||
gives the infrastructure checkable contracts, but raises how compiled output is
|
||||
shipped and which module format it targets. The formal agent skill is a separate
|
||||
docs-only artifact and must not carry a second runtime.
|
||||
|
||||
**Decision.** Author all of Core in the `@obelisk/core` npm workspace
|
||||
(`packages/core`) in TypeScript and compile it ahead-of-time to
|
||||
**ESM JavaScript plus `.d.ts`**. The skill/CLI runtime ships the *precompiled*
|
||||
ESM JS, so installing the skill never runs a build. Rather than have Core
|
||||
**ESM JavaScript plus `.d.ts`**. `@obelisk-apps/cli` ships the *precompiled*
|
||||
ESM JS, so installing the CLI never runs a build. Rather than have Core
|
||||
dual-publish CJS+ESM, the Electron main process migrates to ESM at Phase 5 so it
|
||||
can `import` the same compiled Core. TypeScript source is the single source of
|
||||
truth; the build step lives in the main repo (`build:skill`), never on the user's
|
||||
machine.
|
||||
truth; the package build lives in the main repo (`build:cli`), never on the
|
||||
user's machine. `build:skill` copies only `skill-doc/SKILL.md`, references, and
|
||||
skill metadata.
|
||||
|
||||
**Consequences.** A one-time ESM migration of the Electron main process (Phase 5),
|
||||
in exchange for no dual-build maintenance and a single module format across skill,
|
||||
CLI, and app. The shipped skill artifact contains compiled JS, not TS. The
|
||||
in exchange for no dual-build maintenance and a single module format across the
|
||||
CLI and app. The shipped CLI package contains compiled JS, not TS. The
|
||||
renderer (Vue) is out of scope and stays JavaScript. Phase 3's TS baseline only
|
||||
adds root tooling (package.json, tsconfig, ESLint); it does not touch the app.
|
||||
The app imports Core source so electron-vite can bundle it, while package and
|
||||
skill builds compile the same workspace source to JavaScript.
|
||||
CLI builds compile the same workspace source to JavaScript.
|
||||
@@ -1,22 +1,21 @@
|
||||
# The skill artifact ships readable compiled JS, deliberately not bundled
|
||||
# The CLI ships readable compiled JS; the skill remains docs-only
|
||||
|
||||
**Context.** Obelisk reads a user's entire local Claude Code and Codex history,
|
||||
so auditability is the foundation of trust: before a user lets the skill loose on
|
||||
their data, they must be able to read what it does. The obvious way to shrink a
|
||||
clone-and-run skill artifact is to bundle/minify Core into a single `runtime.js`,
|
||||
but that ships an opaque blob into `.claude/skills` / `.agents/skills`. The
|
||||
"don't drag the whole repo into the user's skills dir" concern is real but
|
||||
separate — it is solved by shipping *only Core*, not by bundling.
|
||||
so auditability is the foundation of trust. Bundling/minifying Core into one
|
||||
opaque file would make the runtime harder to inspect. Shipping executable Core
|
||||
inside `.claude/skills` / `.agents/skills` would also blur the boundary between
|
||||
the agent's instructions and the local data runtime.
|
||||
|
||||
**Decision.** The skill artifact ships **readable, non-bundled, non-minified**
|
||||
**Decision.** `@obelisk-apps/cli` ships **readable, non-bundled, non-minified**
|
||||
compiled JavaScript emitted straight from `tsc` (module structure and comments
|
||||
preserved, ~1:1 with the TypeScript source), plus `schema.sql`, `SKILL.md`, and
|
||||
`references/`. It excludes `app/`, `release/`, `renderer/`, Electron code, and
|
||||
`tests/`, which is what keeps it small. Bundling into one file is deliberately
|
||||
rejected: it trades auditability for marginal size, the wrong trade for a
|
||||
history-reading tool. The public TS source in the main repo allows cross-checking.
|
||||
preserved, ~1:1 with the TypeScript source), plus `schema.sql`. It excludes the
|
||||
app, renderer, release assets, and tests. The separately published agent skill
|
||||
ships only `SKILL.md`, `references/`, and metadata; every executable action in
|
||||
the skill delegates to the installed `obelisk` command. Bundling into one file
|
||||
is deliberately rejected because it trades auditability for marginal size.
|
||||
|
||||
**Consequences.** The installed skill is a few readable files rather than one
|
||||
blob; a future contributor may be tempted to "optimize" by bundling — this ADR
|
||||
records that the un-bundled form is intentional. Small artifact size comes from
|
||||
scoping the artifact to Core, handled by `build:skill`, not from a bundler.
|
||||
**Consequences.** Runtime ownership is unambiguous: npm installs the CLI, while
|
||||
the skills installer installs only agent guidance. A future contributor may be
|
||||
tempted to re-embed Core in the skill or bundle the CLI — this ADR records that
|
||||
both are intentional boundaries. `build:cli` owns compiled code;
|
||||
`build:skill` owns docs-only packaging.
|
||||
@@ -25,7 +25,7 @@ decisions within this:
|
||||
injecting `better-sqlite3`. This
|
||||
works because the provider→parsing import graph is node:sqlite-free (ADR-0001),
|
||||
so nothing drags `node:sqlite` into the app. The `dist/` from `build:core`
|
||||
(ADR-0003) remains for the skill artifact; the app does not need it.
|
||||
(ADR-0003) remains for the CLI package; the app does not need it.
|
||||
- **better-sqlite3 stays the app's binding**, externalized (not bundled) and
|
||||
unpacked from the asar.
|
||||
- **The app main + preload source is TypeScript with types at its seams**, but
|
||||
|
||||
@@ -7,7 +7,7 @@ threw over the primary exception and turned a skippable per-file failure into a
|
||||
whole-build failure. The masked exception was not preserved, so contention
|
||||
(`SQLITE_BUSY` / `SQLITE_BUSY_SNAPSHOT`) is the leading explanation rather than
|
||||
a proven historical fact. It is plausible because daemon builds, manual
|
||||
rebuilds, skill passive-pull indexing, heartbeat writes, and reads share one WAL
|
||||
rebuilds, CLI passive-pull indexing, heartbeat writes, and reads share one WAL
|
||||
database.
|
||||
|
||||
`busy_timeout` alone is not a correctness fix. In particular,
|
||||
@@ -35,15 +35,15 @@ layers.
|
||||
failures propagate. `affectedSessionIds` is updated only after the relevant
|
||||
commit. Force cleanup is one atomic, retryable transaction, and finalize is
|
||||
likewise retried as a complete idempotent transaction.
|
||||
- A fresh `__app_heartbeat__` is policy ownership: while it is fresh, the skill
|
||||
- A fresh `__app_heartbeat__` is policy ownership: while it is fresh, the CLI
|
||||
opens no write connection and performs no migration, schema setup, checkpoint,
|
||||
index build, or `attune`. `__app_last_successful_build__` remains an
|
||||
observability/freshness marker and is not required for ownership. The skill
|
||||
observability/freshness marker and is not required for ownership. The CLI
|
||||
checks ownership again after acquiring the hard lease to close the TOCTOU
|
||||
window. Search/query connections are read-only.
|
||||
- A dedicated `.obelisk/writer.lock.sqlite` provides the cross-process safety
|
||||
mutex on every platform. Acquisition is `BEGIN IMMEDIATE` with non-blocking or
|
||||
bounded waiting; release is idempotent. App builds and heartbeats, skill builds
|
||||
bounded waiting; release is idempotent. App builds and heartbeats, CLI builds
|
||||
and attune, app schema/legacy migrations and memory mutations, and manual
|
||||
rebuild all participate. Manual rebuild's main process owns the lease across
|
||||
worker build, atomic target replacement, and database reopen; the worker uses
|
||||
@@ -52,7 +52,7 @@ layers.
|
||||
deferral retains changed paths and schedules a short retry without announcing
|
||||
a successful build. Service start publishes the ownership heartbeat
|
||||
immediately, then refreshes it periodically.
|
||||
- Index-writer and skill read connections use an explicit 250 ms SQLite busy
|
||||
- Index-writer and CLI read connections use an explicit 250 ms SQLite busy
|
||||
timeout inside the larger bounded coordination budget. The long-lived app
|
||||
query connection retains a 5 s timeout; heartbeat is deliberately non-blocking
|
||||
(`0 ms`) so it never stalls the Electron main thread. Builds use
|
||||
|
||||
+4
-3
@@ -1,6 +1,7 @@
|
||||
// Flat ESLint config for the Obelisk root (Core + skill runtime + tests).
|
||||
// Scope: the ESM/TS sources under packages/core/src/ and tests/. The Electron app has its
|
||||
// own package and toolchain and is intentionally excluded (see docs/adr/0003).
|
||||
// Flat ESLint config for the Obelisk root (Core + CLI + packaging + tests).
|
||||
// Scope: the root ESM/TS sources, including packages/core/src/ and
|
||||
// packages/cli/src/. The Electron app has its own package and toolchain and is
|
||||
// intentionally excluded (see docs/adr/0003).
|
||||
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PACKAGE='@obelisk-apps/cli'
|
||||
|
||||
if ! command -v node >/dev/null 2>&1; then
|
||||
echo 'Obelisk requires Node.js 22.13 or newer.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! node -e "const [major, minor] = process.versions.node.split('.').map(Number); process.exit(major > 22 || (major === 22 && minor >= 13) ? 0 : 1)"; then
|
||||
echo 'Obelisk requires Node.js 22.13 or newer.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo 'Obelisk installation requires npm.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Installing ${PACKAGE}..."
|
||||
npm install --global "$PACKAGE"
|
||||
|
||||
if ! command -v obelisk >/dev/null 2>&1; then
|
||||
echo 'The CLI was installed, but `obelisk` is not on PATH.' >&2
|
||||
echo 'Add the npm global bin directory to PATH, then run `obelisk --version`.' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
obelisk --version
|
||||
echo 'Obelisk CLI installed. Run `obelisk install` to install the agent skill.'
|
||||
Generated
+15
@@ -214,6 +214,10 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@obelisk-apps/cli": {
|
||||
"resolved": "packages/cli",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@obelisk/core": {
|
||||
"resolved": "packages/core",
|
||||
"link": true
|
||||
@@ -1283,6 +1287,17 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@obelisk-apps/cli",
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0",
|
||||
"bin": {
|
||||
"obelisk": "dist/cli/src/obelisk.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
}
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@obelisk/core",
|
||||
"version": "0.1.0"
|
||||
|
||||
+3
-1
@@ -9,11 +9,13 @@
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"pretest": "npm run build:cli",
|
||||
"test": "node --experimental-test-module-mocks --test tests/*.test.mjs",
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p app/tsconfig.json",
|
||||
"lint": "eslint .",
|
||||
"build:core": "npm run build --workspace @obelisk/core",
|
||||
"build:skill": "rm -rf dist/obelisk-skill && tsc -p tsconfig.skill.json && cp packages/core/src/schema.sql dist/obelisk-skill/scripts/ && cp SKILL.md dist/obelisk-skill/ && cp -R references dist/obelisk-skill/references && cp packaging/skill-package.json dist/obelisk-skill/package.json",
|
||||
"build:cli": "npm run build --workspace @obelisk-apps/cli",
|
||||
"build:skill": "node packaging/build-skill.mjs",
|
||||
"publish:skill": "npm run build:skill && packaging/publish-skill.sh"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Obelisk CLI
|
||||
|
||||
The local Obelisk runtime used by coding agents. It indexes Claude Code and
|
||||
Codex transcripts into `~/.obelisk/obelisk.sqlite` and exposes the stable
|
||||
`build`, `search`, `query`, and `attune` process interface.
|
||||
|
||||
```bash
|
||||
npm install --global @obelisk-apps/cli
|
||||
obelisk --version
|
||||
obelisk install
|
||||
obelisk --query /tmp/query.mjs
|
||||
```
|
||||
|
||||
`obelisk install` installs the separate docs-only agent skill from
|
||||
`tommy0103/obelisk-skill`. The CLI itself remains daemon-free: each command
|
||||
refreshes the local index when write ownership is available, then exits.
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@obelisk-apps/cli",
|
||||
"version": "0.1.0",
|
||||
"description": "Local Obelisk runtime for coding agents.",
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"obelisk": "dist/cli/src/obelisk.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node scripts/build.mjs",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const cliRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repoRoot = resolve(cliRoot, '../..');
|
||||
const outDir = resolve(cliRoot, 'dist');
|
||||
const tsc = resolve(repoRoot, 'node_modules/typescript/bin/tsc');
|
||||
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
execFileSync(process.execPath, [tsc, '-p', resolve(cliRoot, 'tsconfig.build.json')], {
|
||||
cwd: repoRoot,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
const schemaTarget = resolve(outDir, 'core/src/schema.sql');
|
||||
mkdirSync(dirname(schemaTarget), { recursive: true });
|
||||
copyFileSync(resolve(repoRoot, 'packages/core/src/schema.sql'), schemaTarget);
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import {
|
||||
DB_PATH,
|
||||
buildIndex,
|
||||
searchText,
|
||||
executeQuery,
|
||||
executeAttune,
|
||||
} from '../../core/src/core.ts';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const fail = (value: unknown): void => {
|
||||
const error = value instanceof Error ? value : new Error(String(value));
|
||||
process.stdout.write(JSON.stringify({ error: error.message, stack: error.stack }) + '\n');
|
||||
process.exitCode = 1;
|
||||
};
|
||||
const emit = (value: unknown): void => {
|
||||
process.stdout.write(JSON.stringify(value, null, 2) + '\n');
|
||||
};
|
||||
|
||||
if (args[0] === '--version' || args[0] === '-v') {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'),
|
||||
) as { version: string };
|
||||
process.stdout.write(`${packageJson.version}\n`);
|
||||
return;
|
||||
}
|
||||
if (args[0] === '--build') {
|
||||
try {
|
||||
buildIndex({ force: true });
|
||||
process.stdout.write(JSON.stringify({ ok: true, db: DB_PATH }) + '\n');
|
||||
} catch (error) { fail(error); }
|
||||
return;
|
||||
}
|
||||
if (args[0] === '--search' && args[1]) {
|
||||
try { emit(searchText(args.slice(1).join(' '))); } catch (error) { fail(error); }
|
||||
return;
|
||||
}
|
||||
if (args[0] === '--query' && args[1]) {
|
||||
try { emit(await executeQuery(readFileSync(resolve(args[1]), 'utf8'))); } catch (error) { fail(error); }
|
||||
return;
|
||||
}
|
||||
if (args[0] === '--attune' && args[1]) {
|
||||
try { emit(await executeAttune(readFileSync(resolve(args[1]), 'utf8'))); } catch (error) { fail(error); }
|
||||
return;
|
||||
}
|
||||
if (args[0] === 'install') {
|
||||
const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx';
|
||||
const child = spawnSync(
|
||||
npx,
|
||||
['--yes', 'skills', 'add', 'tommy0103/obelisk-skill', ...args.slice(1)],
|
||||
{ stdio: 'inherit', shell: process.platform === 'win32' },
|
||||
);
|
||||
if (child.error) {
|
||||
process.stderr.write(`Unable to run the skills installer: ${child.error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
process.exitCode = child.status ?? 1;
|
||||
}
|
||||
return;
|
||||
}
|
||||
process.stderr.write('Usage:\n obelisk install [skills options]\n obelisk --build\n obelisk --search "text"\n obelisk --query <file.js>\n obelisk --attune <file.js>\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
void main();
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "..",
|
||||
"declaration": false,
|
||||
"rewriteRelativeImportExtensions": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"../core/src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"../core/src/runtime.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// Obelisk Core package (see docs/adr/0003-core-typescript-esm-precompiled.md).
|
||||
//
|
||||
// The single shared implementation behind every transport. runtime.js (skill),
|
||||
// and later the CLI and MCP server, are thin shells over these four functions;
|
||||
// The single shared implementation behind every transport. The CLI and later
|
||||
// the MCP server are thin shells over these four functions;
|
||||
// none of them re-implement retrieval or own the DB lifecycle.
|
||||
//
|
||||
// Authored in TypeScript with erasable-only syntax so Node can run it directly
|
||||
// via type stripping in development, while the skill artifact ships readable,
|
||||
// via type stripping in development, while the CLI package ships readable,
|
||||
// non-bundled tsc output. Core source lives in the @obelisk/core workspace.
|
||||
|
||||
import { createContext, runInNewContext } from 'node:vm';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// Provider-agnostic and binding-agnostic: it consumes the IndexRecord stream
|
||||
// from any adapter's parse() and writes rows into the injected database handle
|
||||
// (node:sqlite for the skill/CLI, better-sqlite3 for the app — they share the
|
||||
// (node:sqlite for the CLI, better-sqlite3 for the app — they share the
|
||||
// prepare/run/get API). It is the ONLY layer that touches the database and the
|
||||
// only place that knows the schema. Adapters stay pure.
|
||||
//
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Skill transport: a typed thin CLI shell over the Obelisk Core package.
|
||||
// It only parses args, reads script files, prints JSON, and owns the uniform
|
||||
// { error, stack } + exit-1 error envelope. All logic lives in Core.
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
import { DB_PATH, buildIndex, searchText, executeQuery, executeAttune } from './core.ts';
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
// Uniform error envelope across all four verbs: a failure is reported as
|
||||
// { error, stack } on stdout with exit code 1, never a raw crash on stderr.
|
||||
const fail = (e: unknown): void => {
|
||||
const error = e instanceof Error ? e : new Error(String(e));
|
||||
process.stdout.write(JSON.stringify({ error: error.message, stack: error.stack }) + '\n');
|
||||
process.exitCode = 1;
|
||||
};
|
||||
const emit = (r: unknown): void => {
|
||||
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
|
||||
};
|
||||
|
||||
if (args[0] === '--build') {
|
||||
try {
|
||||
buildIndex({ force: true });
|
||||
process.stdout.write(JSON.stringify({ ok: true, db: DB_PATH }) + '\n');
|
||||
} catch (e) { fail(e); }
|
||||
return;
|
||||
}
|
||||
if (args[0] === '--search' && args[1]) {
|
||||
try { emit(searchText(args.slice(1).join(' '))); } catch (e) { fail(e); }
|
||||
return;
|
||||
}
|
||||
if (args[0] === '--query' && args[1]) {
|
||||
try { emit(await executeQuery(fs.readFileSync(path.resolve(args[1]), 'utf8'))); } catch (e) { fail(e); }
|
||||
return;
|
||||
}
|
||||
if (args[0] === '--attune' && args[1]) {
|
||||
try { emit(await executeAttune(fs.readFileSync(path.resolve(args[1]), 'utf8'))); } catch (e) { fail(e); }
|
||||
return;
|
||||
}
|
||||
process.stderr.write('Usage:\n node runtime.js --build\n node runtime.js --search "text"\n node runtime.js --query <file.js>\n node runtime.js --attune <file.js>\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,6 +1,6 @@
|
||||
// Binding-agnostic SQLite write plumbing shared from the Core package
|
||||
// (docs/adr/0006). The injected db must expose `exec(sql)`; this works for both
|
||||
// node:sqlite (skill/CLI) and better-sqlite3 (app), same injection model as
|
||||
// node:sqlite (CLI) and better-sqlite3 (app), same injection model as
|
||||
// `persist`.
|
||||
|
||||
export interface WriteTxDb {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cpSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const source = resolve(repoRoot, 'skill-doc');
|
||||
const target = resolve(repoRoot, 'dist/obelisk-skill');
|
||||
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
mkdirSync(target, { recursive: true });
|
||||
cpSync(resolve(source, 'SKILL.md'), resolve(target, 'SKILL.md'));
|
||||
cpSync(resolve(source, 'references'), resolve(target, 'references'), { recursive: true });
|
||||
cpSync(resolve(repoRoot, 'packaging/skill-package.json'), resolve(target, 'package.json'));
|
||||
@@ -5,7 +5,7 @@ SKILL_ARTIFACT="dist/obelisk-skill"
|
||||
SKILL_REPO="dist/obelisk-skill-repo"
|
||||
REMOTE="git@github.com:tommy0103/obelisk-skill.git"
|
||||
|
||||
if [ ! -d "$SKILL_ARTIFACT/scripts" ]; then
|
||||
if [ ! -f "$SKILL_ARTIFACT/SKILL.md" ] || [ ! -d "$SKILL_ARTIFACT/references" ]; then
|
||||
echo "Error: run 'npm run build:skill' first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -6,9 +6,13 @@ layer over local Claude Code and Codex session history.
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npx skills add tommy0103/obelisk-skill
|
||||
npm install --global @obelisk-apps/cli
|
||||
obelisk install
|
||||
```
|
||||
|
||||
The CLI is the executable runtime. This repository contains only the agent
|
||||
instructions and progressive-disclosure references.
|
||||
|
||||
Then in any Claude Code session:
|
||||
|
||||
```
|
||||
@@ -17,7 +21,7 @@ Then in any Claude Code session:
|
||||
|
||||
## Source
|
||||
|
||||
This repository is **auto-published** from the compiled skill artifact of
|
||||
This repository is **auto-published** from the docs-only skill artifact of
|
||||
[tommy0103/obelisk](https://github.com/tommy0103/obelisk). Do not open pull
|
||||
requests here — contribute to the source repo instead.
|
||||
|
||||
@@ -25,5 +29,5 @@ requests here — contribute to the source repo instead.
|
||||
|
||||
MIT — see [LICENSE](LICENSE) in this repository. The
|
||||
[source repository](https://github.com/tommy0103/obelisk) is AGPL-3.0; this
|
||||
compiled skill artifact is explicitly relicensed under MIT by the copyright
|
||||
skill documentation artifact is explicitly relicensed under MIT by the copyright
|
||||
holder.
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"name": "obelisk-skill",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Obelisk skill artifact — readable compiled Core (providers + persist + runtime) over local Claude Code and Codex history. Built by `npm run build:skill`; sources live in the main repo.",
|
||||
"description": "Obelisk agent skill documentation for the @obelisk-apps/cli runtime. Built by `npm run build:skill`; sources live in the main repo.",
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -15,7 +15,7 @@ if [ "$TARGET_DIR" = "/" ] || [ "$TARGET_DIR" = "." ] || [ "$TARGET_DIR" = "$ROO
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for required in SKILL.md package.json references scripts; do
|
||||
for required in SKILL.md package.json references; do
|
||||
if [ ! -e "$ARTIFACT_DIR/$required" ]; then
|
||||
echo "Error: skill artifact missing $required at $ARTIFACT_DIR" >&2
|
||||
exit 1
|
||||
|
||||
@@ -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.
|
||||
@@ -1,7 +1,7 @@
|
||||
# Obelisk -- Helper API Reference
|
||||
|
||||
Detailed reference for globals available inside `runtime.js --query` and
|
||||
`runtime.js --attune` scripts.
|
||||
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.
|
||||
@@ -16,7 +16,7 @@ memory mutation helpers.
|
||||
|
||||
### Read Helpers
|
||||
|
||||
These globals are available only in `runtime.js --query` scripts:
|
||||
These globals are available only in `obelisk --query` scripts:
|
||||
|
||||
```js
|
||||
sql, search, context, trace, thread, raw,
|
||||
@@ -31,7 +31,7 @@ helpers is treated as `sessionId`; passing a number is treated as `limit`.
|
||||
|
||||
### Mutation Helpers
|
||||
|
||||
These globals are available only in `runtime.js --attune` scripts:
|
||||
These globals are available only in `obelisk --attune` scripts:
|
||||
|
||||
```js
|
||||
remember, forget
|
||||
@@ -413,7 +413,7 @@ not a counting primitive.
|
||||
#### `remember(record)`
|
||||
|
||||
Register a human-approved markdown memory file. Available only in
|
||||
`runtime.js --attune` scripts.
|
||||
`obelisk --attune` scripts.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
@@ -439,7 +439,7 @@ Returns:
|
||||
#### `forget(record)`
|
||||
|
||||
Archive a human-approved memory record. Available only in
|
||||
`runtime.js --attune` scripts.
|
||||
`obelisk --attune` scripts.
|
||||
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
File renamed without changes.
@@ -1,6 +1,6 @@
|
||||
# Obelisk Query Patterns
|
||||
|
||||
These are copyable CodeAct patterns for `runtime.js --query` scripts plus
|
||||
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.
|
||||
|
||||
@@ -179,7 +179,7 @@ Use this only after the user approves writing memory and the markdown file
|
||||
already exists. `remember()` validates the file and stores a normalized absolute
|
||||
path, so keep the script small and return the registered record.
|
||||
|
||||
Run this script with `runtime.js --attune <script>`. The `--attune` runtime
|
||||
Run this script with `obelisk --attune <script>`. The `--attune` runtime
|
||||
exposes only `remember()` and `forget()`, not retrieval helpers.
|
||||
|
||||
```js
|
||||
@@ -204,7 +204,7 @@ 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 `runtime.js --attune <script>`:
|
||||
Run the mutation with `obelisk --attune <script>`:
|
||||
|
||||
```js
|
||||
return forget({
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -3,7 +3,7 @@
|
||||
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.
|
||||
|
||||
- Executable DDL: `scripts/schema.sql`
|
||||
- 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`
|
||||
@@ -1,90 +0,0 @@
|
||||
// Phase 6 acceptance: `npm run build:skill` must produce a runnable, readable,
|
||||
// .ts-free skill artifact under dist/obelisk-skill. This guards ADR-0004 (ship
|
||||
// readable non-bundled compiled JS) and catches import-rewriting / config drift
|
||||
// that would only surface when the installed skill runs under plain Node (no
|
||||
// type-stripping, no .ts resolution).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { readFileSync, readdirSync, mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const skillDir = join(repoRoot, 'dist', 'obelisk-skill');
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...walk(full));
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('build:skill produces a runnable, readable, .ts-free skill artifact', () => {
|
||||
execFileSync('npm', ['run', 'build:skill'], { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
|
||||
|
||||
// Structure: compiled Core + schema + docs + package.json.
|
||||
for (const rel of [
|
||||
'package.json', 'SKILL.md', 'references/api-reference.md',
|
||||
'scripts/core.js', 'scripts/persist.js', 'scripts/providers/claude.js',
|
||||
'scripts/providers/codex.js', 'scripts/runtime.js', 'scripts/indexer.js',
|
||||
'scripts/db.js', 'scripts/parsing.js', 'scripts/query.js',
|
||||
'scripts/sqlite-types.js', 'scripts/schema.sql',
|
||||
]) {
|
||||
assert.ok(existsSync(join(skillDir, rel)), `artifact missing ${rel}`);
|
||||
}
|
||||
assert.equal(JSON.parse(readFileSync(join(skillDir, 'package.json'), 'utf8')).type, 'module');
|
||||
|
||||
// Readable, not bundled: emitted files stay ~1:1 with source, and no relative
|
||||
// import may still point at a .ts file (that would break under plain Node).
|
||||
const jsFiles = walk(join(skillDir, 'scripts')).filter(f => f.endsWith('.js') || f.endsWith('.mjs'));
|
||||
assert.ok(jsFiles.length >= 6, 'expected multiple un-bundled script files');
|
||||
for (const file of jsFiles) {
|
||||
const src = readFileSync(file, 'utf8');
|
||||
assert.ok(!/from\s+['"][^'"]*\.ts['"]/.test(src), `${file} still imports a .ts module`);
|
||||
assert.ok(!/import\(['"][^'"]*\.ts['"]\)/.test(src), `${file} still dynamic-imports a .ts module`);
|
||||
}
|
||||
|
||||
// Runs end to end under plain Node against a fresh HOME (no type-stripping).
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-skill-artifact-'));
|
||||
try {
|
||||
const projDir = join(home, '.claude', 'projects', '-tmp-proj');
|
||||
mkdirSync(projDir, { recursive: true });
|
||||
writeFileSync(join(projDir, 'smoke.jsonl'),
|
||||
JSON.stringify({ uuid: 'm1', type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/tmp/proj', message: { role: 'user', content: 'hello artifact' } }) + '\n');
|
||||
const env = { ...process.env, HOME: home };
|
||||
const runtime = join(skillDir, 'scripts', 'runtime.js');
|
||||
|
||||
const build = spawnSync(process.execPath, [runtime, '--build'], { env, encoding: 'utf8' });
|
||||
assert.equal(build.status, 0, build.stderr || build.stdout);
|
||||
|
||||
const search = spawnSync(process.execPath, [runtime, '--search', 'hello artifact'], { env, encoding: 'utf8' });
|
||||
assert.equal(search.status, 0, search.stderr || search.stdout);
|
||||
const hits = JSON.parse(search.stdout);
|
||||
assert.equal(hits[0]?.message?.text, 'hello artifact', 'compiled artifact indexed and found the message');
|
||||
|
||||
const memoryPath = join(home, 'artifact-memory.md');
|
||||
const attunePath = join(home, 'attune.mjs');
|
||||
writeFileSync(memoryPath, '# Artifact memory\n');
|
||||
writeFileSync(attunePath, `return remember(${JSON.stringify({
|
||||
path: memoryPath,
|
||||
session_id: 'smoke',
|
||||
summary: 'Artifact release smoke memory',
|
||||
})});`);
|
||||
const attune = spawnSync(process.execPath, [runtime, '--attune', attunePath], { env, encoding: 'utf8' });
|
||||
assert.equal(attune.status, 0, attune.stderr || attune.stdout);
|
||||
assert.match(JSON.parse(attune.stdout).id, /^mem-/);
|
||||
|
||||
const queryPath = join(home, 'query.mjs');
|
||||
writeFileSync(queryPath, "return memories({ sessionId: 'smoke', query: 'Artifact release smoke' });");
|
||||
const query = spawnSync(process.execPath, [runtime, '--query', queryPath], { env, encoding: 'utf8' });
|
||||
assert.equal(query.status, 0, query.stderr || query.stdout);
|
||||
assert.equal(JSON.parse(query.stdout)[0]?.summary, 'Artifact release smoke memory');
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import {
|
||||
chmodSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { delimiter, dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
test('root SKILL.md bootstraps the CLI before installing the official skill', () => {
|
||||
const source = readFileSync(join(repoRoot, 'SKILL.md'), 'utf8');
|
||||
|
||||
assert.match(source, /@obelisk-apps\/cli/);
|
||||
assert.match(source, /install\.sh/);
|
||||
assert.match(source, /obelisk --version/);
|
||||
assert.match(source, /obelisk install/);
|
||||
assert.doesNotMatch(source, /obelisk --query/);
|
||||
});
|
||||
|
||||
test('install.sh installs and verifies only the CLI', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-install-script-'));
|
||||
const fakeBin = join(home, 'bin');
|
||||
const npmCapture = join(home, 'npm-args');
|
||||
const obeliskCapture = join(home, 'obelisk-args');
|
||||
mkdirSync(fakeBin, { recursive: true });
|
||||
|
||||
const npm = join(fakeBin, 'npm');
|
||||
writeFileSync(npm, `#!/bin/sh\nprintf '%s\\n' "$@" > "${npmCapture}"\n`);
|
||||
chmodSync(npm, 0o755);
|
||||
|
||||
const obelisk = join(fakeBin, 'obelisk');
|
||||
writeFileSync(obelisk, `#!/bin/sh\nprintf '%s\\n' "$@" > "${obeliskCapture}"\nprintf '0.1.0\\n'\n`);
|
||||
chmodSync(obelisk, 0o755);
|
||||
|
||||
const result = spawnSync('sh', [join(repoRoot, 'install.sh')], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
PATH: `${fakeBin}${delimiter}${process.env.PATH || ''}`,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.deepEqual(readFileSync(npmCapture, 'utf8').trim().split('\n'), [
|
||||
'install',
|
||||
'--global',
|
||||
'@obelisk-apps/cli',
|
||||
]);
|
||||
assert.deepEqual(readFileSync(obeliskCapture, 'utf8').trim().split('\n'), ['--version']);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { delimiter, join } from 'node:path';
|
||||
|
||||
import { repoRoot, runCli } from './cli-test-helpers.mjs';
|
||||
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const cliPackage = JSON.parse(readFileSync(join(repoRoot, 'packages', 'cli', 'package.json'), 'utf8'));
|
||||
|
||||
test('the packaged obelisk command preserves the runtime query envelope', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-cli-package-'));
|
||||
const query = join(home, 'query.mjs');
|
||||
writeFileSync(query, 'return { answer: 42 };');
|
||||
|
||||
const result = runCli(['--query', query], { home });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.equal(result.stderr, '');
|
||||
assert.equal(result.stdout, '{\n "answer": 42\n}\n');
|
||||
});
|
||||
|
||||
test('obelisk --version reports the installed CLI package version', () => {
|
||||
const result = runCli(['--version']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.equal(result.stdout, `${cliPackage.version}\n`);
|
||||
assert.equal(result.stderr, '');
|
||||
});
|
||||
|
||||
test('obelisk install delegates official skill installation to the skills CLI', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-cli-install-'));
|
||||
const fakeBin = join(home, 'bin');
|
||||
const capture = join(home, 'args.json');
|
||||
const captureScript = join(home, 'capture.mjs');
|
||||
mkdirSync(fakeBin, { recursive: true });
|
||||
writeFileSync(captureScript, `import { writeFileSync } from 'node:fs';\nwriteFileSync(process.env.OBELISK_TEST_CAPTURE, JSON.stringify(process.argv.slice(2)));\n`);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(
|
||||
join(fakeBin, 'npx.cmd'),
|
||||
`@echo off\r\n"${process.execPath}" "${captureScript}" %*\r\n`,
|
||||
);
|
||||
} else {
|
||||
const fakeNpx = join(fakeBin, 'npx');
|
||||
writeFileSync(fakeNpx, `#!/bin/sh\nexec "${process.execPath}" "${captureScript}" "$@"\n`);
|
||||
chmodSync(fakeNpx, 0o755);
|
||||
}
|
||||
|
||||
const result = runCli(['install', '--global', '--agent', 'codex'], {
|
||||
home,
|
||||
env: {
|
||||
PATH: `${fakeBin}${delimiter}${process.env.PATH || ''}`,
|
||||
OBELISK_TEST_CAPTURE: capture,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.deepEqual(JSON.parse(readFileSync(capture, 'utf8')), [
|
||||
'--yes',
|
||||
'skills',
|
||||
'add',
|
||||
'tommy0103/obelisk-skill',
|
||||
'--global',
|
||||
'--agent',
|
||||
'codex',
|
||||
]);
|
||||
});
|
||||
|
||||
test('npm pack installs one platform-neutral CLI with its schema resource', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'obelisk-cli-pack-'));
|
||||
const packDir = join(root, 'pack');
|
||||
const prefix = join(root, 'prefix');
|
||||
const npmCache = join(root, 'npm-cache');
|
||||
const npmEnv = { ...process.env, npm_config_cache: npmCache };
|
||||
mkdirSync(packDir, { recursive: true });
|
||||
|
||||
const packed = JSON.parse(execFileSync(
|
||||
npmCommand,
|
||||
[
|
||||
'pack',
|
||||
'--workspace',
|
||||
'@obelisk-apps/cli',
|
||||
'--pack-destination',
|
||||
packDir,
|
||||
'--json',
|
||||
'--ignore-scripts',
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: npmEnv,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
));
|
||||
const metadata = packed[0];
|
||||
const paths = metadata.files.map(file => file.path);
|
||||
assert.ok(paths.includes('dist/cli/src/obelisk.js'));
|
||||
assert.ok(paths.includes('dist/core/src/schema.sql'));
|
||||
assert.equal(paths.some(path => path.endsWith('.ts')), false);
|
||||
|
||||
const tarball = join(packDir, metadata.filename);
|
||||
execFileSync(
|
||||
npmCommand,
|
||||
['install', '--global', '--prefix', prefix, tarball, '--ignore-scripts'],
|
||||
{ cwd: repoRoot, env: npmEnv, encoding: 'utf8', stdio: 'pipe' },
|
||||
);
|
||||
|
||||
const installedBin = process.platform === 'win32'
|
||||
? join(prefix, 'obelisk.cmd')
|
||||
: join(prefix, 'bin', 'obelisk');
|
||||
const result = spawnSync(installedBin, ['--version'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.equal(result.stdout.trim(), cliPackage.version);
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
export const cliEntry = join(repoRoot, 'packages', 'cli', 'dist', 'cli', 'src', 'obelisk.js');
|
||||
|
||||
export function runCli(args, { home, env = {}, cwd = repoRoot } = {}) {
|
||||
return spawnSync(process.execPath, [cliEntry, ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...(home ? { HOME: home, USERPROFILE: home } : {}),
|
||||
...env,
|
||||
},
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
@@ -8,18 +8,15 @@ import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { runCli } from './cli-test-helpers.mjs';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
function runRuntime(args, home) {
|
||||
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], {
|
||||
cwd: repoRoot, env: { ...process.env, HOME: home }, encoding: 'utf8',
|
||||
});
|
||||
return runCli(args, { home });
|
||||
}
|
||||
|
||||
const ID = '019ed000-0000-7000-8000-000000000001';
|
||||
|
||||
@@ -28,7 +28,7 @@ import { createQueryApi, createAttuneApi } from '../packages/core/src/query.ts';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
|
||||
const API_REFERENCE = readFileSync(new URL('../references/api-reference.md', import.meta.url), 'utf8');
|
||||
const API_REFERENCE = readFileSync(new URL('../skill-doc/references/api-reference.md', import.meta.url), 'utf8');
|
||||
|
||||
// Every key asserted below is recorded here so the doc-sync guard can confirm
|
||||
// references/api-reference.md still documents it.
|
||||
|
||||
@@ -3,14 +3,13 @@ import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { acquireWriterLease, writerLockPathFor } from '../packages/core/src/writer-lease.ts';
|
||||
import { runCli } from './cli-test-helpers.mjs';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const repoRoot = resolve(new URL('..', import.meta.url).pathname);
|
||||
|
||||
test('a passive query does not mutate the index while a fresh daemon owns writes', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-daemon-arbitration-'));
|
||||
@@ -27,11 +26,7 @@ test('a passive query does not mutate the index while a fresh daemon owns writes
|
||||
|
||||
const queryPath = join(home, 'query.mjs');
|
||||
writeFileSync(queryPath, "return 'read-only';");
|
||||
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--query', queryPath], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, HOME: home },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const result = runCli(['--query', queryPath], { home });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.equal(JSON.parse(result.stdout), 'read-only');
|
||||
|
||||
@@ -55,11 +50,7 @@ test('attune refuses to mutate the index while a fresh daemon owns writes', () =
|
||||
|
||||
const attunePath = join(home, 'attune.mjs');
|
||||
writeFileSync(attunePath, 'return true;');
|
||||
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--attune', attunePath], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, HOME: home },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const result = runCli(['--attune', attunePath], { home });
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(JSON.parse(result.stdout).error, /daemon owns index writes/i);
|
||||
|
||||
@@ -86,11 +77,7 @@ test('a passive query stays read-only when another process holds the writer leas
|
||||
try {
|
||||
const queryPath = join(home, 'query.mjs');
|
||||
writeFileSync(queryPath, "return 'writer-busy';");
|
||||
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--query', queryPath], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, HOME: home },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const result = runCli(['--query', queryPath], { home });
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.equal(JSON.parse(result.stdout), 'writer-busy');
|
||||
} finally {
|
||||
@@ -120,11 +107,7 @@ test('a passive query fails closed when daemon ownership cannot be read', () =>
|
||||
try {
|
||||
const queryPath = join(home, 'query.mjs');
|
||||
writeFileSync(queryPath, "return 'ownership-unknown';");
|
||||
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--query', queryPath], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, HOME: home },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
const result = runCli(['--query', queryPath], { home });
|
||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||
assert.match(JSON.parse(result.stdout).error, /no such column: mtime/i);
|
||||
} finally {
|
||||
|
||||
@@ -10,18 +10,18 @@ async function readExecutableSchema() {
|
||||
}
|
||||
|
||||
async function readSchemaReference() {
|
||||
return readFile(new URL('../references/schema.md', import.meta.url), 'utf8');
|
||||
return readFile(new URL('../skill-doc/references/schema.md', import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
async function readApiReference() {
|
||||
return readFile(new URL('../references/api-reference.md', import.meta.url), 'utf8');
|
||||
return readFile(new URL('../skill-doc/references/api-reference.md', import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
async function readSkill() {
|
||||
return readFile(new URL('../SKILL.md', import.meta.url), 'utf8');
|
||||
return readFile(new URL('../skill-doc/SKILL.md', import.meta.url), 'utf8');
|
||||
}
|
||||
|
||||
test('db module loads the executable schema from scripts/schema.sql', async () => {
|
||||
test('db module loads the executable schema from packages/core/src/schema.sql', async () => {
|
||||
const source = await readFile(new URL('../packages/core/src/db.ts', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(source, /schema\.sql/);
|
||||
|
||||
@@ -10,18 +10,15 @@ import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { runCli } from './cli-test-helpers.mjs';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
function runRuntime(args, home) {
|
||||
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], {
|
||||
cwd: repoRoot, env: { ...process.env, HOME: home }, encoding: 'utf8',
|
||||
});
|
||||
return runCli(args, { home });
|
||||
}
|
||||
|
||||
function line(uuid, type, ts) {
|
||||
|
||||
@@ -23,12 +23,10 @@ test('skill release staging produces the npx skills repository layout', () => {
|
||||
const target = join(root, 'repo');
|
||||
try {
|
||||
mkdirSync(join(artifact, 'references'), { recursive: true });
|
||||
mkdirSync(join(artifact, 'scripts'), { recursive: true });
|
||||
mkdirSync(join(target, '.git'), { recursive: true });
|
||||
writeFileSync(join(artifact, 'SKILL.md'), '---\nname: obelisk\ndescription: test\n---\n');
|
||||
writeFileSync(join(artifact, 'package.json'), '{"type":"module"}\n');
|
||||
writeFileSync(join(artifact, 'references', 'api-reference.md'), '# API\n');
|
||||
writeFileSync(join(artifact, 'scripts', 'runtime.js'), 'export {};\n');
|
||||
writeFileSync(join(target, '.git', 'keep'), 'preserved\n');
|
||||
writeFileSync(join(target, 'stale.txt'), 'remove me\n');
|
||||
|
||||
@@ -44,7 +42,6 @@ test('skill release staging produces the npx skills repository layout', () => {
|
||||
'SKILL.md',
|
||||
'package.json',
|
||||
'references/api-reference.md',
|
||||
'scripts/runtime.js',
|
||||
]) {
|
||||
assert.equal(existsSync(join(target, 'skills', 'obelisk', relativePath)), true);
|
||||
}
|
||||
@@ -61,4 +58,5 @@ test('CI and local publish use the same skill repository staging step', () => {
|
||||
|
||||
assert.match(workflow, /packaging\/stage-skill-repo\.sh/);
|
||||
assert.match(localPublish, /packaging\/stage-skill-repo\.sh/);
|
||||
assert.doesNotMatch(localPublish, /SKILL_ARTIFACT\/scripts/);
|
||||
});
|
||||
@@ -15,7 +15,7 @@ const cards = [
|
||||
];
|
||||
|
||||
test('skill routes only the explicit recap intent to the split recap overview', async () => {
|
||||
const skill = await read('SKILL.md');
|
||||
const skill = await read('skill-doc/SKILL.md');
|
||||
|
||||
assert.match(skill, /## Intent Routing/);
|
||||
assert.match(skill, /references\/recap\/overview\.md/);
|
||||
@@ -35,8 +35,8 @@ test('README lists the recap folder without making recap the core retrieval path
|
||||
|
||||
assert.match(readme, /references\/recap\/overview\.md/);
|
||||
for (const [n, name] of cards) {
|
||||
assert.match(readme, new RegExp(`references/recap/pattern${n}-${name}\\.md`));
|
||||
assert.match(readme, new RegExp(`references/recap/writing${n}-${name}\\.md`));
|
||||
assert.match(readme, new RegExp(`skill-doc/references/recap/pattern${n}-${name}\\.md`));
|
||||
assert.match(readme, new RegExp(`skill-doc/references/recap/writing${n}-${name}\\.md`));
|
||||
}
|
||||
assert.match(readme, /optional .*\/obelisk recap/i);
|
||||
assert.match(readme, /explicit `\/obelisk recap` intent/);
|
||||
@@ -44,8 +44,8 @@ test('README lists the recap folder without making recap the core retrieval path
|
||||
});
|
||||
|
||||
test('old recap references are thin redirects to the split docs', async () => {
|
||||
const retrieval = await read('references/recap-patterns.md');
|
||||
const writing = await read('references/recap-writing.md');
|
||||
const retrieval = await read('skill-doc/references/recap-patterns.md');
|
||||
const writing = await read('skill-doc/references/recap-writing.md');
|
||||
|
||||
assert.match(retrieval, /compatibility/i);
|
||||
assert.match(retrieval, /references\/recap\/overview\.md/);
|
||||
@@ -58,7 +58,7 @@ test('old recap references are thin redirects to the split docs', async () => {
|
||||
});
|
||||
|
||||
test('recap overview defines the card-by-card retrieval and writing loop', async () => {
|
||||
const ref = await read('references/recap/overview.md');
|
||||
const ref = await read('skill-doc/references/recap/overview.md');
|
||||
|
||||
assert.match(ref, /Highest Priority: Phase Loop/i);
|
||||
assert.match(ref, /Spotify Wrapped-like/i);
|
||||
@@ -77,7 +77,7 @@ test('recap overview defines the card-by-card retrieval and writing loop', async
|
||||
});
|
||||
|
||||
test('recap overview stays narrow and leaves card details to per-card files', async () => {
|
||||
const ref = await read('references/recap/overview.md');
|
||||
const ref = await read('skill-doc/references/recap/overview.md');
|
||||
|
||||
assert.ok(ref.split('\n').length < 90);
|
||||
assert.match(ref, /The per-card files own retrieval details/i);
|
||||
@@ -102,8 +102,8 @@ test('recap overview stays narrow and leaves card details to per-card files', as
|
||||
|
||||
test('each recap card has a separate retrieval pattern and writing reference', async () => {
|
||||
for (const [n, name] of cards) {
|
||||
const pattern = await read(`references/recap/pattern${n}-${name}.md`);
|
||||
const writing = await read(`references/recap/writing${n}-${name}.md`);
|
||||
const pattern = await read(`skill-doc/references/recap/pattern${n}-${name}.md`);
|
||||
const writing = await read(`skill-doc/references/recap/writing${n}-${name}.md`);
|
||||
|
||||
assert.match(pattern, new RegExp(`# Card ${n} .* Retrieval`));
|
||||
assert.match(pattern, /Read this card's writing file immediately after/i);
|
||||
@@ -123,8 +123,8 @@ test('each recap card has a separate retrieval pattern and writing reference', a
|
||||
});
|
||||
|
||||
test('cover and closing writing own JSON initialization and final save rules', async () => {
|
||||
const cover = await read('references/recap/writing1-cover.md');
|
||||
const closing = await read('references/recap/writing5-closing.md');
|
||||
const cover = await read('skill-doc/references/recap/writing1-cover.md');
|
||||
const closing = await read('skill-doc/references/recap/writing5-closing.md');
|
||||
|
||||
assert.match(cover, /First JSON Write/i);
|
||||
assert.match(cover, /schema_version: "obelisk\.recap\.v1"/);
|
||||
@@ -137,8 +137,8 @@ test('cover and closing writing own JSON initialization and final save rules', a
|
||||
});
|
||||
|
||||
test('cover card retrieval and writing choose one dominant human claim', async () => {
|
||||
const pattern = await read('references/recap/pattern1-cover.md');
|
||||
const writing = await read('references/recap/writing1-cover.md');
|
||||
const pattern = await read('skill-doc/references/recap/pattern1-cover.md');
|
||||
const writing = await read('skill-doc/references/recap/writing1-cover.md');
|
||||
|
||||
assert.match(pattern, /dominant claim/i);
|
||||
assert.match(pattern, /persona/i);
|
||||
@@ -153,8 +153,8 @@ test('cover card retrieval and writing choose one dominant human claim', async (
|
||||
});
|
||||
|
||||
test('cover card schema uses claim instead of subtitle', async () => {
|
||||
const pattern = await read('references/recap/pattern1-cover.md');
|
||||
const writing = await read('references/recap/writing1-cover.md');
|
||||
const pattern = await read('skill-doc/references/recap/pattern1-cover.md');
|
||||
const writing = await read('skill-doc/references/recap/writing1-cover.md');
|
||||
const component = await read('app/src/renderer/src/components/recap/CoverCard.vue');
|
||||
const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
|
||||
const list = await read('app/src/renderer/src/views/RecapList.vue');
|
||||
@@ -176,8 +176,8 @@ test('cover card schema uses claim instead of subtitle', async () => {
|
||||
});
|
||||
|
||||
test('thinking card retrieval searches for turns instead of implementation timeline', async () => {
|
||||
const pattern = await read('references/recap/pattern2-thinking.md');
|
||||
const writing = await read('references/recap/writing2-thinking.md');
|
||||
const pattern = await read('skill-doc/references/recap/pattern2-thinking.md');
|
||||
const writing = await read('skill-doc/references/recap/writing2-thinking.md');
|
||||
|
||||
assert.match(pattern, /turning points/i);
|
||||
assert.match(pattern, /user question/i);
|
||||
@@ -195,8 +195,8 @@ test('thinking card retrieval searches for turns instead of implementation timel
|
||||
});
|
||||
|
||||
test('thinking path schema uses turn instead of outcome', async () => {
|
||||
const pattern = await read('references/recap/pattern2-thinking.md');
|
||||
const writing = await read('references/recap/writing2-thinking.md');
|
||||
const pattern = await read('skill-doc/references/recap/pattern2-thinking.md');
|
||||
const writing = await read('skill-doc/references/recap/writing2-thinking.md');
|
||||
const component = await read('app/src/renderer/src/components/recap/PathCard.vue');
|
||||
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
|
||||
|
||||
@@ -211,8 +211,8 @@ test('thinking path schema uses turn instead of outcome', async () => {
|
||||
});
|
||||
|
||||
test('vibe card retrieval finds small user voice, not a correction audit', async () => {
|
||||
const pattern = await read('references/recap/pattern3-vibe.md');
|
||||
const writing = await read('references/recap/writing3-vibe.md');
|
||||
const pattern = await read('skill-doc/references/recap/pattern3-vibe.md');
|
||||
const writing = await read('skill-doc/references/recap/writing3-vibe.md');
|
||||
|
||||
assert.match(pattern, /catchphrases/i);
|
||||
assert.match(pattern, /visible user messages/i);
|
||||
@@ -229,7 +229,7 @@ test('vibe card retrieval finds small user voice, not a correction audit', async
|
||||
});
|
||||
|
||||
test('vibe card schema uses voice_lines instead of observations', async () => {
|
||||
const writing = await read('references/recap/writing3-vibe.md');
|
||||
const writing = await read('skill-doc/references/recap/writing3-vibe.md');
|
||||
const component = await read('app/src/renderer/src/components/recap/VibeCard.vue');
|
||||
const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
|
||||
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
|
||||
@@ -245,8 +245,8 @@ test('vibe card schema uses voice_lines instead of observations', async () => {
|
||||
});
|
||||
|
||||
test('workflow card retrieval scopes real workflow runs and user reactions', async () => {
|
||||
const pattern = await read('references/recap/pattern4-workflow.md');
|
||||
const writing = await read('references/recap/writing4-workflow.md');
|
||||
const pattern = await read('skill-doc/references/recap/pattern4-workflow.md');
|
||||
const writing = await read('skill-doc/references/recap/writing4-workflow.md');
|
||||
|
||||
assert.match(pattern, /workflows\.timestamp/);
|
||||
assert.match(pattern, /workflows\(\{ project: .* after, before/i);
|
||||
@@ -271,8 +271,8 @@ test('workflow card retrieval scopes real workflow runs and user reactions', asy
|
||||
});
|
||||
|
||||
test('workflow card uses reaction instead of outcome for row copy', async () => {
|
||||
const pattern = await read('references/recap/pattern4-workflow.md');
|
||||
const writing = await read('references/recap/writing4-workflow.md');
|
||||
const pattern = await read('skill-doc/references/recap/pattern4-workflow.md');
|
||||
const writing = await read('skill-doc/references/recap/writing4-workflow.md');
|
||||
const component = await read('app/src/renderer/src/components/recap/WorkflowCard.vue');
|
||||
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
|
||||
|
||||
@@ -287,7 +287,7 @@ test('workflow card uses reaction instead of outcome for row copy', async () =>
|
||||
});
|
||||
|
||||
test('workflow card schema uses deck instead of summary for the visible line', async () => {
|
||||
const writing = await read('references/recap/writing4-workflow.md');
|
||||
const writing = await read('skill-doc/references/recap/writing4-workflow.md');
|
||||
const component = await read('app/src/renderer/src/components/recap/WorkflowCard.vue');
|
||||
const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
|
||||
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
|
||||
@@ -303,8 +303,8 @@ test('workflow card schema uses deck instead of summary for the visible line', a
|
||||
});
|
||||
|
||||
test('closing card retrieval and writing keep a small personal receipt', async () => {
|
||||
const pattern = await read('references/recap/pattern5-closing.md');
|
||||
const writing = await read('references/recap/writing5-closing.md');
|
||||
const pattern = await read('skill-doc/references/recap/pattern5-closing.md');
|
||||
const writing = await read('skill-doc/references/recap/writing5-closing.md');
|
||||
|
||||
assert.match(pattern, /same period and source scope/i);
|
||||
assert.match(pattern, /streak/i);
|
||||
@@ -321,8 +321,8 @@ test('closing card retrieval and writing keep a small personal receipt', async (
|
||||
});
|
||||
|
||||
test('closing card schema uses receipts instead of stats', async () => {
|
||||
const pattern = await read('references/recap/pattern5-closing.md');
|
||||
const writing = await read('references/recap/writing5-closing.md');
|
||||
const pattern = await read('skill-doc/references/recap/pattern5-closing.md');
|
||||
const writing = await read('skill-doc/references/recap/writing5-closing.md');
|
||||
const component = await read('app/src/renderer/src/components/recap/ClosingCard.vue');
|
||||
const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
|
||||
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
|
||||
@@ -340,9 +340,9 @@ test('closing card schema uses receipts instead of stats', async () => {
|
||||
});
|
||||
|
||||
test('split recap writing keeps mixed-language rhythm and plain speech', async () => {
|
||||
const overview = await read('references/recap/overview.md');
|
||||
const overview = await read('skill-doc/references/recap/overview.md');
|
||||
const writingDocs = await Promise.all(
|
||||
cards.map(([n, name]) => read(`references/recap/writing${n}-${name}.md`)),
|
||||
cards.map(([n, name]) => read(`skill-doc/references/recap/writing${n}-${name}.md`)),
|
||||
);
|
||||
const combined = [overview, ...writingDocs].join('\n');
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// These lock the four-verb CLI I/O envelope at the process boundary so the
|
||||
// upcoming TypeScript / runtime-core refactor cannot silently change what an
|
||||
// agent (or the skill/CLI/MCP transports) observes on stdout:
|
||||
// agent (through the CLI or a future MCP transport) observes on stdout:
|
||||
// --build -> { ok: true, db }
|
||||
// --search -> JSON array
|
||||
// --query -> pretty-printed JSON result, or { error, stack } + exit 1 on throw
|
||||
@@ -16,19 +16,9 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function runRuntime(args, { home }) {
|
||||
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, HOME: home },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
import { runCli as runRuntime } from './cli-test-helpers.mjs';
|
||||
|
||||
function tempHome() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-cli-envelope-'));
|
||||
@@ -118,4 +108,3 @@ test('--search tolerates FTS-special input via safe tokenization', () => {
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.ok(Array.isArray(JSON.parse(result.stdout)), 'search must return a JSON array');
|
||||
});
|
||||
|
||||
+3
-12
@@ -3,22 +3,13 @@ import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtempSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { runCli as runRuntime } from './cli-test-helpers.mjs';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const require = createRequire(import.meta.url);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
function runRuntime(args, { home }) {
|
||||
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], {
|
||||
cwd: repoRoot,
|
||||
env: { ...process.env, HOME: home },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function tempHome() {
|
||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-runtime-home-'));
|
||||
mkdirSync(join(home, '.claude'), { recursive: true });
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const artifact = join(repoRoot, 'dist', 'obelisk-skill');
|
||||
|
||||
test('build:skill produces a docs-only skill that delegates execution to the CLI', () => {
|
||||
execFileSync(npmCommand, ['run', 'build:skill'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
stdio: 'pipe',
|
||||
});
|
||||
|
||||
assert.equal(existsSync(join(artifact, 'SKILL.md')), true);
|
||||
assert.equal(existsSync(join(artifact, 'references', 'api-reference.md')), true);
|
||||
assert.equal(existsSync(join(artifact, 'package.json')), true);
|
||||
assert.equal(existsSync(join(artifact, 'scripts')), false, 'skill must not ship a second runtime');
|
||||
|
||||
const skill = readFileSync(join(artifact, 'SKILL.md'), 'utf8');
|
||||
const schema = readFileSync(join(artifact, 'references', 'schema.md'), 'utf8');
|
||||
assert.match(skill, /Bash\(obelisk:\*\)/);
|
||||
assert.match(skill, /obelisk --query \/tmp\/q\.mjs/);
|
||||
assert.match(skill, /obelisk --attune \/tmp\/register-memory\.mjs/);
|
||||
assert.doesNotMatch(skill, /\$SKILL_DIR\/scripts\/runtime\.js/);
|
||||
assert.doesNotMatch(`${skill}\n${schema}`, /scripts\//);
|
||||
});
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["packages/core/src/**/*", "tests/**/*"],
|
||||
"include": ["packages/core/src/**/*", "packages/cli/src/**/*", "tests/**/*"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"app",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"outDir": "dist/obelisk-skill/scripts",
|
||||
"rootDir": "packages/core/src",
|
||||
"declaration": false,
|
||||
"rewriteRelativeImportExtensions": true
|
||||
},
|
||||
"include": ["packages/core/src/**/*.ts"]
|
||||
}
|
||||
Reference in new issue
Block a user