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:
@@ -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**:
|
**Runtime interface**:
|
||||||
The public contract, expressed as four verbs — `build`, `search(text)`,
|
The public contract, expressed as four verbs — `build`, `search(text)`,
|
||||||
`query(code)`, `attune(code)`. Skill, CLI, and MCP are transports over this same
|
`query(code)`, `attune(code)`. CLI and a future MCP server are transports over
|
||||||
shape; none of them add their own retrieval surface.
|
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
|
_Avoid_: API, tool surface
|
||||||
|
|
||||||
**CodeAct**:
|
**CodeAct**:
|
||||||
@@ -44,7 +45,7 @@ persistence happens.
|
|||||||
**Persist layer**:
|
**Persist layer**:
|
||||||
The single shared, provider- and binding-agnostic writer that consumes records
|
The single shared, provider- and binding-agnostic writer that consumes records
|
||||||
from any adapter and writes them into an injected SQLite handle inside a
|
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
|
`better-sqlite3` (app) — so there is one persist implementation, not one per
|
||||||
binding.
|
binding.
|
||||||
_Avoid_: writer, sink, DAO
|
_Avoid_: writer, sink, DAO
|
||||||
@@ -56,8 +57,8 @@ as files change.
|
|||||||
_Avoid_: watcher mode, live indexing
|
_Avoid_: watcher mode, live indexing
|
||||||
|
|
||||||
**Passive pull mode**:
|
**Passive pull mode**:
|
||||||
On-demand incremental indexing performed by the skill when there is no active
|
On-demand incremental indexing performed by a CLI invocation when there is no
|
||||||
daemon: an invocation of the runtime brings the index up to date, then answers.
|
active daemon: the command brings the index up to date, then answers.
|
||||||
_Avoid_: lazy indexing, on-read indexing
|
_Avoid_: lazy indexing, on-read indexing
|
||||||
|
|
||||||
**index_state**:
|
**index_state**:
|
||||||
@@ -68,7 +69,7 @@ arbitration.
|
|||||||
|
|
||||||
**Daemon arbitration**:
|
**Daemon arbitration**:
|
||||||
The policy by which the passive pull mode detects a fresh daemon from the
|
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
|
setup, indexing, checkpointing, and `attune`. The heartbeat alone means “the
|
||||||
daemon should write”; `__app_last_successful_build__` records coverage/freshness,
|
daemon should write”; `__app_last_successful_build__` records coverage/freshness,
|
||||||
not ownership. Both indexing modes use the same persist layer.
|
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:
|
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.
|
**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
|
### Install
|
||||||
|
|
||||||
|
Obelisk requires Node.js 22.13 or newer. Install the platform-neutral CLI first:
|
||||||
|
|
||||||
```bash
|
```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:
|
Then in any Claude Code session:
|
||||||
|
|
||||||
@@ -72,7 +94,7 @@ You ask a question
|
|||||||
↓
|
↓
|
||||||
Agent writes a JS query against the SQLite index
|
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
|
Reads the JSON result, answers in natural language
|
||||||
```
|
```
|
||||||
@@ -81,11 +103,12 @@ Core API: `search()`, `context()`, `sql()`, plus structured helpers (`sessions`,
|
|||||||
|
|
||||||
### Memory layer
|
### 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
|
## 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">
|
<div align="center">
|
||||||
<img src=".github/assets/app-screenshot.png" alt="Obelisk App" width="720">
|
<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)
|
│ ├── parsing.ts # Pure helpers (node:sqlite-free, app-consumable)
|
||||||
│ ├── db.ts # node:sqlite lifecycle + migrations
|
│ ├── db.ts # node:sqlite lifecycle + migrations
|
||||||
│ ├── query.ts # Query/attune sandbox API (helpers)
|
│ ├── query.ts # Query/attune sandbox API (helpers)
|
||||||
│ ├── runtime.ts # Thin CLI shell (--build/--search/--query/--attune)
|
|
||||||
│ └── schema.sql # SQLite schema (single source of truth)
|
│ └── schema.sql # SQLite schema (single source of truth)
|
||||||
├── package.json
|
├── package.json
|
||||||
└── dist/ # Generated package JS, declarations, and schema
|
└── dist/ # Generated package JS, declarations, and schema
|
||||||
|
|
||||||
references/ # Agent-readable docs (progressive disclosure)
|
packages/cli/ # @obelisk-apps/cli npm workspace
|
||||||
├── schema.md
|
├── src/obelisk.ts # CLI shell + skill installer delegation
|
||||||
├── api-reference.md
|
├── scripts/build.mjs # Compiles CLI + readable Core into one package
|
||||||
├── query-patterns.md
|
├── package.json
|
||||||
├── retrieval-semantics.md
|
└── dist/ # Generated platform-neutral npm payload
|
||||||
├── pitfalls.md
|
|
||||||
├── recap-patterns.md
|
skill-doc/ # Source for the docs-only obelisk agent skill
|
||||||
├── recap-writing.md
|
├── SKILL.md # Query and memory workflow
|
||||||
└── recap/ # Per-card pattern + writing references
|
└── references/ # Progressive-disclosure API/schema/pattern docs
|
||||||
├── overview.md
|
└── recap/ # Per-card recap retrieval + writing references
|
||||||
├── pattern1-cover.md … pattern5-closing.md
|
|
||||||
└── writing1-cover.md … writing5-closing.md
|
|
||||||
|
|
||||||
app/ # Electron desktop app (electron-vite + Vue)
|
app/ # Electron desktop app (electron-vite + Vue)
|
||||||
├── src/main/ # TypeScript main process (consumes shared core)
|
├── src/main/ # TypeScript main process (consumes shared core)
|
||||||
@@ -195,32 +215,37 @@ app/ # Electron desktop app (electron-vite + Vue)
|
|||||||
└── electron.vite.config.ts
|
└── electron.vite.config.ts
|
||||||
|
|
||||||
packaging/ # Skill publish infrastructure
|
packaging/ # Skill publish infrastructure
|
||||||
|
├── build-skill.mjs # Builds the docs-only skill artifact
|
||||||
├── skill-package.json
|
├── skill-package.json
|
||||||
├── skill-README.md
|
├── skill-README.md
|
||||||
├── skill-LICENSE # MIT (relicensed for the skill artifact)
|
├── skill-LICENSE # MIT (relicensed for the skill artifact)
|
||||||
└── publish-skill.sh
|
└── 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
|
CONTEXT.md # Project glossary
|
||||||
docs/adr/ # Architecture decision records (0001–0006)
|
docs/adr/ # Architecture decision records (0001–0006)
|
||||||
```
|
```
|
||||||
|
|
||||||
The optional `/obelisk recap` flow is loaded only for explicit `/obelisk recap` intent.
|
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`
|
- `skill-doc/references/recap/pattern1-cover.md` + `skill-doc/references/recap/writing1-cover.md`
|
||||||
- `references/recap/pattern2-thinking.md` + `references/recap/writing2-thinking.md`
|
- `skill-doc/references/recap/pattern2-thinking.md` + `skill-doc/references/recap/writing2-thinking.md`
|
||||||
- `references/recap/pattern3-vibe.md` + `references/recap/writing3-vibe.md`
|
- `skill-doc/references/recap/pattern3-vibe.md` + `skill-doc/references/recap/writing3-vibe.md`
|
||||||
- `references/recap/pattern4-workflow.md` + `references/recap/writing4-workflow.md`
|
- `skill-doc/references/recap/pattern4-workflow.md` + `skill-doc/references/recap/writing4-workflow.md`
|
||||||
- `references/recap/pattern5-closing.md` + `references/recap/writing5-closing.md`
|
- `skill-doc/references/recap/pattern5-closing.md` + `skill-doc/references/recap/writing5-closing.md`
|
||||||
|
|
||||||
### Generated build outputs
|
### Generated build outputs
|
||||||
|
|
||||||
- `packages/core/dist/` is produced by `npm run build:core`. It is the compiled
|
- `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
|
- `dist/obelisk-skill/` is produced by `npm run build:skill`. It is the
|
||||||
install-ready skill artifact: readable plain JavaScript under `scripts/`,
|
docs-only skill artifact: `SKILL.md`, references, and skill package metadata.
|
||||||
`SKILL.md`, references, and the skill package metadata.
|
|
||||||
- Skill publishing stages that artifact at `skills/obelisk/` in the
|
- Skill publishing stages that artifact at `skills/obelisk/` in the
|
||||||
`obelisk-skill` repository; only `README.md` and `LICENSE` remain at the
|
`obelisk-skill` repository; only `README.md` and `LICENSE` remain at the
|
||||||
repository root for `npx skills` discovery.
|
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.
|
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
|
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
|
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
|
writer lease prevents cross-process writes from overlapping. The
|
||||||
`__app_last_successful_build__` marker records index freshness, not ownership.
|
`__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.
|
20K lines of scattered JSONL → something the agent can search() and sql() against in milliseconds.
|
||||||
|
|
||||||
|
|||||||
@@ -1,408 +1,61 @@
|
|||||||
---
|
---
|
||||||
name: obelisk
|
name: obelisk-installer
|
||||||
description: >
|
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.
|
||||||
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:
|
allowed-tools:
|
||||||
- Read
|
- Bash
|
||||||
- Bash(node:*)
|
|
||||||
- Write
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# obelisk
|
# Install Obelisk
|
||||||
|
|
||||||
Search and query Claude Code and Codex session history stored in `~/.claude/`
|
This is a one-time bootstrap guide. It installs the local Obelisk runtime, then
|
||||||
and `~/.codex/`.
|
uses that runtime to install the official agent skill from
|
||||||
Obelisk indexes sessions, messages, tool calls, tool results, summaries,
|
`github.com/tommy0103/obelisk-skill`.
|
||||||
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:
|
## 1. Check for the CLI
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```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. Install the CLI
|
||||||
2. Run:
|
|
||||||
|
|
||||||
```bash
|
Installing a global command changes the user's machine. Show the command and get
|
||||||
node $SKILL_DIR/scripts/runtime.js --query /tmp/q.mjs
|
the user's approval before running one of these official installation methods.
|
||||||
```
|
|
||||||
|
|
||||||
3. Parse JSON stdout and answer with concise evidence.
|
With npm:
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node $SKILL_DIR/scripts/runtime.js --attune /tmp/register-memory.mjs
|
npm install --global @obelisk-apps/cli
|
||||||
```
|
```
|
||||||
|
|
||||||
`--attune` exposes only memory mutation helpers: `remember()` and `forget()`.
|
On macOS, Linux, or WSL, the official installer performs the same CLI-only
|
||||||
It does not expose `search()`, `sql()`, `memories()`, or other retrieval
|
installation and never installs an agent skill:
|
||||||
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
|
```bash
|
||||||
paths are resolved against the source session's `project_path` when
|
curl -fsSL https://raw.githubusercontent.com/tommy0103/obelisk/main/install.sh | sh
|
||||||
`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
|
Never use `sudo`, install a daemon, or download Obelisk from another source.
|
||||||
`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:
|
Verify the result:
|
||||||
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
|
```bash
|
||||||
|
obelisk --version
|
||||||
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:
|
## 3. Install the official skill
|
||||||
|
|
||||||
```js
|
```bash
|
||||||
const rows = summaries({ project: '%quiet-zero%', limit: 1 });
|
obelisk install
|
||||||
return rows.length ? Object.keys(rows[0]) : [];
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Fetch message neighbors without a full thread:
|
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
|
After installation, tell the user to reload their agent if the new `/obelisk`
|
||||||
const hit = search('runtime query', { limit: 1 })[0];
|
skill is not discovered immediately. This bootstrap document is not the query
|
||||||
return sql(
|
skill and must not answer session-history questions itself.
|
||||||
`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.
|
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ messages[focusMessageIndex].text = `Truncated preview ${'indexed content '.repea
|
|||||||
const fullTextSentinel = `FULL TEXT SENTINEL ${'complete content '.repeat(80)}`;
|
const fullTextSentinel = `FULL TEXT SENTINEL ${'complete content '.repeat(80)}`;
|
||||||
const codexExecSource = 'const result = { ok: true };\nreturn result;';
|
const codexExecSource = 'const result = { ok: true };\nreturn result;';
|
||||||
const liveBashToolInput = {
|
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',
|
description: 'Query for activity since last sync',
|
||||||
};
|
};
|
||||||
let codexExecOutput = JSON.stringify([{
|
let codexExecOutput = JSON.stringify([{
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
> persist layer, not one per binding.
|
> persist layer, not one per binding.
|
||||||
|
|
||||||
**Context.** Obelisk had two divergent full indexers — the former
|
**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
|
(`better-sqlite3`, Electron
|
||||||
app) — that duplicated the same Claude and Codex JSONL parsing and had silently
|
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`,
|
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
|
incremental `index_state` bookkeeping, FTS maintenance, and the canonical
|
||||||
**upsert** (`ON CONFLICT(uuid) DO UPDATE`) write semantics reconciled from the
|
**upsert** (`ON CONFLICT(uuid) DO UPDATE`) write semantics reconciled from the
|
||||||
drift on 2026-07-08. The database handle is *injected*, so `node:sqlite`
|
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.
|
per-binding persist layer.
|
||||||
|
|
||||||
**Two indexing modes** share all of the above and differ only in trigger:
|
**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
|
**daemon mode** (the app, and potentially a future CLI daemon, watches and keeps
|
||||||
mode** (skill indexes on invocation when no daemon is active). They never write
|
the index fresh) and **passive pull mode** (a CLI command indexes on invocation
|
||||||
concurrently — passive mode detects a fresh daemon via heartbeat markers in
|
when no daemon is active). They never write concurrently — passive mode detects
|
||||||
`index_state` (**daemon arbitration**).
|
a fresh daemon via heartbeat markers in `index_state` (**daemon arbitration**).
|
||||||
|
|
||||||
**Consequences.** Golden tests anchor on each adapter's `parse` output (feed
|
**Consequences.** Golden tests anchor on each adapter's `parse` output (feed
|
||||||
fixture JSONL, assert the yielded record sequence) — independent of binding and
|
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
|
behavior. The four verbs (`build`/`search`/`query`/`attune`) are only the entry
|
||||||
surface; agents actually depend on the *return shapes* of the sandbox helpers
|
surface; agents actually depend on the *return shapes* of the sandbox helpers
|
||||||
(`search`, `overview`, `memories`, …), which are already documented in
|
(`search`, `overview`, `memories`, …), which are already documented in
|
||||||
`references/api-reference.md` and relied on by every example in
|
`skill-doc/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/query-patterns.md`. Current behavior is good and there is no reason to
|
||||||
change it during migration.
|
change it during migration.
|
||||||
|
|
||||||
**Decision.** Freeze the contract in two tiers. **Tier 1 (hard freeze, golden
|
**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
|
globals/helpers available inside `query`/`attune`). **Tier 2 (locked to
|
||||||
api-reference.md):** each helper's documented return shape — not frozen forever,
|
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
|
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
|
`skill-doc/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
|
deliberate version bump. `skill-doc/references/api-reference.md` is therefore promoted from
|
||||||
description to authoritative contract, and Phase 1 becomes "make it authoritative
|
description to authoritative contract, and Phase 1 becomes "make it authoritative
|
||||||
and enforce it," not "write a new contract doc."
|
and enforce it," not "write a new contract doc."
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
# Core is authored in TypeScript, shipped as precompiled ESM JavaScript
|
# Core is authored in TypeScript, shipped as precompiled ESM JavaScript
|
||||||
|
|
||||||
**Context.** The extracted Obelisk Core must serve two consumers — the ESM skill
|
**Context.** The extracted Obelisk Core must serve two consumers — the ESM CLI
|
||||||
runtime (`node:sqlite`) and the CommonJS Electron app (`better-sqlite3`) — while
|
runtime (`node:sqlite`) and the Electron app (`better-sqlite3`) — while the CLI
|
||||||
the skill artifact must install with **zero build step** on the user's machine
|
must install with **zero build step** on the user's machine. Authoring in TS
|
||||||
(the clone-and-run, "low-friction skill" goal). Authoring in TS gives the infra
|
gives the infrastructure checkable contracts, but raises how compiled output is
|
||||||
its checkable contracts, but raises how the compiled output is shipped and which
|
shipped and which module format it targets. The formal agent skill is a separate
|
||||||
module format it targets.
|
docs-only artifact and must not carry a second runtime.
|
||||||
|
|
||||||
**Decision.** Author all of Core in the `@obelisk/core` npm workspace
|
**Decision.** Author all of Core in the `@obelisk/core` npm workspace
|
||||||
(`packages/core`) in TypeScript and compile it ahead-of-time to
|
(`packages/core`) in TypeScript and compile it ahead-of-time to
|
||||||
**ESM JavaScript plus `.d.ts`**. The skill/CLI runtime ships the *precompiled*
|
**ESM JavaScript plus `.d.ts`**. `@obelisk-apps/cli` ships the *precompiled*
|
||||||
ESM JS, so installing the skill never runs a build. Rather than have Core
|
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
|
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
|
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
|
truth; the package build lives in the main repo (`build:cli`), never on the
|
||||||
machine.
|
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),
|
**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,
|
in exchange for no dual-build maintenance and a single module format across the
|
||||||
CLI, and app. The shipped skill artifact contains compiled JS, not TS. 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
|
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.
|
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
|
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,
|
**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
|
so auditability is the foundation of trust. Bundling/minifying Core into one
|
||||||
their data, they must be able to read what it does. The obvious way to shrink a
|
opaque file would make the runtime harder to inspect. Shipping executable Core
|
||||||
clone-and-run skill artifact is to bundle/minify Core into a single `runtime.js`,
|
inside `.claude/skills` / `.agents/skills` would also blur the boundary between
|
||||||
but that ships an opaque blob into `.claude/skills` / `.agents/skills`. The
|
the agent's instructions and the local data runtime.
|
||||||
"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.
|
|
||||||
|
|
||||||
**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
|
compiled JavaScript emitted straight from `tsc` (module structure and comments
|
||||||
preserved, ~1:1 with the TypeScript source), plus `schema.sql`, `SKILL.md`, and
|
preserved, ~1:1 with the TypeScript source), plus `schema.sql`. It excludes the
|
||||||
`references/`. It excludes `app/`, `release/`, `renderer/`, Electron code, and
|
app, renderer, release assets, and tests. The separately published agent skill
|
||||||
`tests/`, which is what keeps it small. Bundling into one file is deliberately
|
ships only `SKILL.md`, `references/`, and metadata; every executable action in
|
||||||
rejected: it trades auditability for marginal size, the wrong trade for a
|
the skill delegates to the installed `obelisk` command. Bundling into one file
|
||||||
history-reading tool. The public TS source in the main repo allows cross-checking.
|
is deliberately rejected because it trades auditability for marginal size.
|
||||||
|
|
||||||
**Consequences.** The installed skill is a few readable files rather than one
|
**Consequences.** Runtime ownership is unambiguous: npm installs the CLI, while
|
||||||
blob; a future contributor may be tempted to "optimize" by bundling — this ADR
|
the skills installer installs only agent guidance. A future contributor may be
|
||||||
records that the un-bundled form is intentional. Small artifact size comes from
|
tempted to re-embed Core in the skill or bundle the CLI — this ADR records that
|
||||||
scoping the artifact to Core, handled by `build:skill`, not from a bundler.
|
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
|
injecting `better-sqlite3`. This
|
||||||
works because the provider→parsing import graph is node:sqlite-free (ADR-0001),
|
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`
|
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
|
- **better-sqlite3 stays the app's binding**, externalized (not bundled) and
|
||||||
unpacked from the asar.
|
unpacked from the asar.
|
||||||
- **The app main + preload source is TypeScript with types at its seams**, but
|
- **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
|
whole-build failure. The masked exception was not preserved, so contention
|
||||||
(`SQLITE_BUSY` / `SQLITE_BUSY_SNAPSHOT`) is the leading explanation rather than
|
(`SQLITE_BUSY` / `SQLITE_BUSY_SNAPSHOT`) is the leading explanation rather than
|
||||||
a proven historical fact. It is plausible because daemon builds, manual
|
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.
|
database.
|
||||||
|
|
||||||
`busy_timeout` alone is not a correctness fix. In particular,
|
`busy_timeout` alone is not a correctness fix. In particular,
|
||||||
@@ -35,15 +35,15 @@ layers.
|
|||||||
failures propagate. `affectedSessionIds` is updated only after the relevant
|
failures propagate. `affectedSessionIds` is updated only after the relevant
|
||||||
commit. Force cleanup is one atomic, retryable transaction, and finalize is
|
commit. Force cleanup is one atomic, retryable transaction, and finalize is
|
||||||
likewise retried as a complete idempotent transaction.
|
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,
|
opens no write connection and performs no migration, schema setup, checkpoint,
|
||||||
index build, or `attune`. `__app_last_successful_build__` remains an
|
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
|
checks ownership again after acquiring the hard lease to close the TOCTOU
|
||||||
window. Search/query connections are read-only.
|
window. Search/query connections are read-only.
|
||||||
- A dedicated `.obelisk/writer.lock.sqlite` provides the cross-process safety
|
- A dedicated `.obelisk/writer.lock.sqlite` provides the cross-process safety
|
||||||
mutex on every platform. Acquisition is `BEGIN IMMEDIATE` with non-blocking or
|
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
|
and attune, app schema/legacy migrations and memory mutations, and manual
|
||||||
rebuild all participate. Manual rebuild's main process owns the lease across
|
rebuild all participate. Manual rebuild's main process owns the lease across
|
||||||
worker build, atomic target replacement, and database reopen; the worker uses
|
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
|
deferral retains changed paths and schedules a short retry without announcing
|
||||||
a successful build. Service start publishes the ownership heartbeat
|
a successful build. Service start publishes the ownership heartbeat
|
||||||
immediately, then refreshes it periodically.
|
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
|
timeout inside the larger bounded coordination budget. The long-lived app
|
||||||
query connection retains a 5 s timeout; heartbeat is deliberately non-blocking
|
query connection retains a 5 s timeout; heartbeat is deliberately non-blocking
|
||||||
(`0 ms`) so it never stalls the Electron main thread. Builds use
|
(`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).
|
// Flat ESLint config for the Obelisk root (Core + CLI + packaging + tests).
|
||||||
// Scope: the ESM/TS sources under packages/core/src/ and tests/. The Electron app has its
|
// Scope: the root ESM/TS sources, including packages/core/src/ and
|
||||||
// own package and toolchain and is intentionally excluded (see docs/adr/0003).
|
// 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 js from '@eslint/js';
|
||||||
import globals from 'globals';
|
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"
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@obelisk-apps/cli": {
|
||||||
|
"resolved": "packages/cli",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@obelisk/core": {
|
"node_modules/@obelisk/core": {
|
||||||
"resolved": "packages/core",
|
"resolved": "packages/core",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -1283,6 +1287,17 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"packages/core": {
|
||||||
"name": "@obelisk/core",
|
"name": "@obelisk/core",
|
||||||
"version": "0.1.0"
|
"version": "0.1.0"
|
||||||
|
|||||||
+3
-1
@@ -9,11 +9,13 @@
|
|||||||
"packages/*"
|
"packages/*"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"pretest": "npm run build:cli",
|
||||||
"test": "node --experimental-test-module-mocks --test tests/*.test.mjs",
|
"test": "node --experimental-test-module-mocks --test tests/*.test.mjs",
|
||||||
"typecheck": "tsc --noEmit && tsc --noEmit -p app/tsconfig.json",
|
"typecheck": "tsc --noEmit && tsc --noEmit -p app/tsconfig.json",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"build:core": "npm run build --workspace @obelisk/core",
|
"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"
|
"publish:skill": "npm run build:skill && packaging/publish-skill.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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).
|
// Obelisk Core package (see docs/adr/0003-core-typescript-esm-precompiled.md).
|
||||||
//
|
//
|
||||||
// The single shared implementation behind every transport. runtime.js (skill),
|
// The single shared implementation behind every transport. The CLI and later
|
||||||
// and later the CLI and MCP server, are thin shells over these four functions;
|
// the MCP server are thin shells over these four functions;
|
||||||
// none of them re-implement retrieval or own the DB lifecycle.
|
// none of them re-implement retrieval or own the DB lifecycle.
|
||||||
//
|
//
|
||||||
// Authored in TypeScript with erasable-only syntax so Node can run it directly
|
// 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.
|
// non-bundled tsc output. Core source lives in the @obelisk/core workspace.
|
||||||
|
|
||||||
import { createContext, runInNewContext } from 'node:vm';
|
import { createContext, runInNewContext } from 'node:vm';
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// Provider-agnostic and binding-agnostic: it consumes the IndexRecord stream
|
// Provider-agnostic and binding-agnostic: it consumes the IndexRecord stream
|
||||||
// from any adapter's parse() and writes rows into the injected database handle
|
// 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
|
// prepare/run/get API). It is the ONLY layer that touches the database and the
|
||||||
// only place that knows the schema. Adapters stay pure.
|
// 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
|
// Binding-agnostic SQLite write plumbing shared from the Core package
|
||||||
// (docs/adr/0006). The injected db must expose `exec(sql)`; this works for both
|
// (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`.
|
// `persist`.
|
||||||
|
|
||||||
export interface WriteTxDb {
|
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"
|
SKILL_REPO="dist/obelisk-skill-repo"
|
||||||
REMOTE="git@github.com:tommy0103/obelisk-skill.git"
|
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
|
echo "Error: run 'npm run build:skill' first" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ layer over local Claude Code and Codex session history.
|
|||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```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:
|
Then in any Claude Code session:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -17,7 +21,7 @@ Then in any Claude Code session:
|
|||||||
|
|
||||||
## Source
|
## 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
|
[tommy0103/obelisk](https://github.com/tommy0103/obelisk). Do not open pull
|
||||||
requests here — contribute to the source repo instead.
|
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
|
MIT — see [LICENSE](LICENSE) in this repository. The
|
||||||
[source repository](https://github.com/tommy0103/obelisk) is AGPL-3.0; this
|
[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.
|
holder.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obelisk-skill",
|
"name": "obelisk-skill",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"description": "Obelisk agent skill documentation for the @obelisk-apps/cli runtime. Built by `npm run build:skill`; sources live in the main repo.",
|
||||||
"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.",
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ if [ "$TARGET_DIR" = "/" ] || [ "$TARGET_DIR" = "." ] || [ "$TARGET_DIR" = "$ROO
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if [ ! -e "$ARTIFACT_DIR/$required" ]; then
|
||||||
echo "Error: skill artifact missing $required at $ARTIFACT_DIR" >&2
|
echo "Error: skill artifact missing $required at $ARTIFACT_DIR" >&2
|
||||||
exit 1
|
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
|
# Obelisk -- Helper API Reference
|
||||||
|
|
||||||
Detailed reference for globals available inside `runtime.js --query` and
|
Detailed reference for globals available inside `obelisk --query` and
|
||||||
`runtime.js --attune` scripts.
|
`obelisk --attune` scripts.
|
||||||
|
|
||||||
- Use `references/schema.md` for raw SQL table/field/join checks.
|
- Use `references/schema.md` for raw SQL table/field/join checks.
|
||||||
- Use `references/query-patterns.md` for copyable retrieval plans.
|
- Use `references/query-patterns.md` for copyable retrieval plans.
|
||||||
@@ -16,7 +16,7 @@ memory mutation helpers.
|
|||||||
|
|
||||||
### Read Helpers
|
### Read Helpers
|
||||||
|
|
||||||
These globals are available only in `runtime.js --query` scripts:
|
These globals are available only in `obelisk --query` scripts:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
sql, search, context, trace, thread, raw,
|
sql, search, context, trace, thread, raw,
|
||||||
@@ -31,7 +31,7 @@ helpers is treated as `sessionId`; passing a number is treated as `limit`.
|
|||||||
|
|
||||||
### Mutation Helpers
|
### Mutation Helpers
|
||||||
|
|
||||||
These globals are available only in `runtime.js --attune` scripts:
|
These globals are available only in `obelisk --attune` scripts:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
remember, forget
|
remember, forget
|
||||||
@@ -413,7 +413,7 @@ not a counting primitive.
|
|||||||
#### `remember(record)`
|
#### `remember(record)`
|
||||||
|
|
||||||
Register a human-approved markdown memory file. Available only in
|
Register a human-approved markdown memory file. Available only in
|
||||||
`runtime.js --attune` scripts.
|
`obelisk --attune` scripts.
|
||||||
|
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -439,7 +439,7 @@ Returns:
|
|||||||
#### `forget(record)`
|
#### `forget(record)`
|
||||||
|
|
||||||
Archive a human-approved memory record. Available only in
|
Archive a human-approved memory record. Available only in
|
||||||
`runtime.js --attune` scripts.
|
`obelisk --attune` scripts.
|
||||||
|
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Obelisk Query Patterns
|
# 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
|
`--attune` memory mutation patterns. They are not new APIs. Adapt them to the
|
||||||
user's scope and return compact evidence.
|
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
|
already exists. `remember()` validates the file and stores a normalized absolute
|
||||||
path, so keep the script small and return the registered record.
|
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.
|
exposes only `remember()` and `forget()`, not retrieval helpers.
|
||||||
|
|
||||||
```js
|
```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
|
matches the user's request, that request is approval to archive it; if several
|
||||||
candidates match, ask which one to forget.
|
candidates match, ask which one to forget.
|
||||||
|
|
||||||
Run the mutation with `runtime.js --attune <script>`:
|
Run the mutation with `obelisk --attune <script>`:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
return forget({
|
return forget({
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
Read this before writing non-trivial `sql()` queries. It is a compact field and
|
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.
|
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`
|
- Helper signatures and return shapes: `references/api-reference.md`
|
||||||
- Query recipes and synthesis patterns: `references/query-patterns.md`
|
- Query recipes and synthesis patterns: `references/query-patterns.md`
|
||||||
- FTS, alias, ordering, and compactness traps: `references/pitfalls.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 { createRequire } from 'node:module';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync } from 'node:fs';
|
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { spawnSync } from 'node:child_process';
|
import { runCli } from './cli-test-helpers.mjs';
|
||||||
|
|
||||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const { DatabaseSync } = require('node:sqlite');
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
|
||||||
function runRuntime(args, home) {
|
function runRuntime(args, home) {
|
||||||
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], {
|
return runCli(args, { home });
|
||||||
cwd: repoRoot, env: { ...process.env, HOME: home }, encoding: 'utf8',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ID = '019ed000-0000-7000-8000-000000000001';
|
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 require = createRequire(import.meta.url);
|
||||||
const { DatabaseSync } = require('node:sqlite');
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
const SCHEMA = readFileSync(new URL('../packages/core/src/schema.sql', import.meta.url), 'utf8');
|
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
|
// Every key asserted below is recorded here so the doc-sync guard can confirm
|
||||||
// references/api-reference.md still documents it.
|
// references/api-reference.md still documents it.
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ import assert from 'node:assert/strict';
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join, resolve } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
|
|
||||||
import { acquireWriterLease, writerLockPathFor } from '../packages/core/src/writer-lease.ts';
|
import { acquireWriterLease, writerLockPathFor } from '../packages/core/src/writer-lease.ts';
|
||||||
|
import { runCli } from './cli-test-helpers.mjs';
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const { DatabaseSync } = require('node:sqlite');
|
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', () => {
|
test('a passive query does not mutate the index while a fresh daemon owns writes', () => {
|
||||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-daemon-arbitration-'));
|
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');
|
const queryPath = join(home, 'query.mjs');
|
||||||
writeFileSync(queryPath, "return 'read-only';");
|
writeFileSync(queryPath, "return 'read-only';");
|
||||||
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--query', queryPath], {
|
const result = runCli(['--query', queryPath], { home });
|
||||||
cwd: repoRoot,
|
|
||||||
env: { ...process.env, HOME: home },
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||||
assert.equal(JSON.parse(result.stdout), 'read-only');
|
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');
|
const attunePath = join(home, 'attune.mjs');
|
||||||
writeFileSync(attunePath, 'return true;');
|
writeFileSync(attunePath, 'return true;');
|
||||||
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--attune', attunePath], {
|
const result = runCli(['--attune', attunePath], { home });
|
||||||
cwd: repoRoot,
|
|
||||||
env: { ...process.env, HOME: home },
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
assert.equal(result.status, 1);
|
assert.equal(result.status, 1);
|
||||||
assert.match(JSON.parse(result.stdout).error, /daemon owns index writes/i);
|
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 {
|
try {
|
||||||
const queryPath = join(home, 'query.mjs');
|
const queryPath = join(home, 'query.mjs');
|
||||||
writeFileSync(queryPath, "return 'writer-busy';");
|
writeFileSync(queryPath, "return 'writer-busy';");
|
||||||
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--query', queryPath], {
|
const result = runCli(['--query', queryPath], { home });
|
||||||
cwd: repoRoot,
|
|
||||||
env: { ...process.env, HOME: home },
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||||
assert.equal(JSON.parse(result.stdout), 'writer-busy');
|
assert.equal(JSON.parse(result.stdout), 'writer-busy');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -120,11 +107,7 @@ test('a passive query fails closed when daemon ownership cannot be read', () =>
|
|||||||
try {
|
try {
|
||||||
const queryPath = join(home, 'query.mjs');
|
const queryPath = join(home, 'query.mjs');
|
||||||
writeFileSync(queryPath, "return 'ownership-unknown';");
|
writeFileSync(queryPath, "return 'ownership-unknown';");
|
||||||
const result = spawnSync(process.execPath, ['packages/core/src/runtime.ts', '--query', queryPath], {
|
const result = runCli(['--query', queryPath], { home });
|
||||||
cwd: repoRoot,
|
|
||||||
env: { ...process.env, HOME: home },
|
|
||||||
encoding: 'utf8',
|
|
||||||
});
|
|
||||||
assert.equal(result.status, 1, result.stderr || result.stdout);
|
assert.equal(result.status, 1, result.stderr || result.stdout);
|
||||||
assert.match(JSON.parse(result.stdout).error, /no such column: mtime/i);
|
assert.match(JSON.parse(result.stdout).error, /no such column: mtime/i);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -10,18 +10,18 @@ async function readExecutableSchema() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readSchemaReference() {
|
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() {
|
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() {
|
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');
|
const source = await readFile(new URL('../packages/core/src/db.ts', import.meta.url), 'utf8');
|
||||||
|
|
||||||
assert.match(source, /schema\.sql/);
|
assert.match(source, /schema\.sql/);
|
||||||
|
|||||||
@@ -10,18 +10,15 @@ import assert from 'node:assert/strict';
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync, rmSync } from 'node:fs';
|
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync, rmSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { spawnSync } from 'node:child_process';
|
import { runCli } from './cli-test-helpers.mjs';
|
||||||
|
|
||||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const { DatabaseSync } = require('node:sqlite');
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
|
||||||
function runRuntime(args, home) {
|
function runRuntime(args, home) {
|
||||||
return spawnSync(process.execPath, ['packages/core/src/runtime.ts', ...args], {
|
return runCli(args, { home });
|
||||||
cwd: repoRoot, env: { ...process.env, HOME: home }, encoding: 'utf8',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function line(uuid, type, ts) {
|
function line(uuid, type, ts) {
|
||||||
|
|||||||
@@ -23,12 +23,10 @@ test('skill release staging produces the npx skills repository layout', () => {
|
|||||||
const target = join(root, 'repo');
|
const target = join(root, 'repo');
|
||||||
try {
|
try {
|
||||||
mkdirSync(join(artifact, 'references'), { recursive: true });
|
mkdirSync(join(artifact, 'references'), { recursive: true });
|
||||||
mkdirSync(join(artifact, 'scripts'), { recursive: true });
|
|
||||||
mkdirSync(join(target, '.git'), { recursive: true });
|
mkdirSync(join(target, '.git'), { recursive: true });
|
||||||
writeFileSync(join(artifact, 'SKILL.md'), '---\nname: obelisk\ndescription: test\n---\n');
|
writeFileSync(join(artifact, 'SKILL.md'), '---\nname: obelisk\ndescription: test\n---\n');
|
||||||
writeFileSync(join(artifact, 'package.json'), '{"type":"module"}\n');
|
writeFileSync(join(artifact, 'package.json'), '{"type":"module"}\n');
|
||||||
writeFileSync(join(artifact, 'references', 'api-reference.md'), '# API\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, '.git', 'keep'), 'preserved\n');
|
||||||
writeFileSync(join(target, 'stale.txt'), 'remove me\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',
|
'SKILL.md',
|
||||||
'package.json',
|
'package.json',
|
||||||
'references/api-reference.md',
|
'references/api-reference.md',
|
||||||
'scripts/runtime.js',
|
|
||||||
]) {
|
]) {
|
||||||
assert.equal(existsSync(join(target, 'skills', 'obelisk', relativePath)), true);
|
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(workflow, /packaging\/stage-skill-repo\.sh/);
|
||||||
assert.match(localPublish, /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 () => {
|
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, /## Intent Routing/);
|
||||||
assert.match(skill, /references\/recap\/overview\.md/);
|
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/);
|
assert.match(readme, /references\/recap\/overview\.md/);
|
||||||
for (const [n, name] of cards) {
|
for (const [n, name] of cards) {
|
||||||
assert.match(readme, new RegExp(`references/recap/pattern${n}-${name}\\.md`));
|
assert.match(readme, new RegExp(`skill-doc/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/writing${n}-${name}\\.md`));
|
||||||
}
|
}
|
||||||
assert.match(readme, /optional .*\/obelisk recap/i);
|
assert.match(readme, /optional .*\/obelisk recap/i);
|
||||||
assert.match(readme, /explicit `\/obelisk recap` intent/);
|
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 () => {
|
test('old recap references are thin redirects to the split docs', async () => {
|
||||||
const retrieval = await read('references/recap-patterns.md');
|
const retrieval = await read('skill-doc/references/recap-patterns.md');
|
||||||
const writing = await read('references/recap-writing.md');
|
const writing = await read('skill-doc/references/recap-writing.md');
|
||||||
|
|
||||||
assert.match(retrieval, /compatibility/i);
|
assert.match(retrieval, /compatibility/i);
|
||||||
assert.match(retrieval, /references\/recap\/overview\.md/);
|
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 () => {
|
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, /Highest Priority: Phase Loop/i);
|
||||||
assert.match(ref, /Spotify Wrapped-like/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 () => {
|
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.ok(ref.split('\n').length < 90);
|
||||||
assert.match(ref, /The per-card files own retrieval details/i);
|
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 () => {
|
test('each recap card has a separate retrieval pattern and writing reference', async () => {
|
||||||
for (const [n, name] of cards) {
|
for (const [n, name] of cards) {
|
||||||
const pattern = await read(`references/recap/pattern${n}-${name}.md`);
|
const pattern = await read(`skill-doc/references/recap/pattern${n}-${name}.md`);
|
||||||
const writing = await read(`references/recap/writing${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, new RegExp(`# Card ${n} .* Retrieval`));
|
||||||
assert.match(pattern, /Read this card's writing file immediately after/i);
|
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 () => {
|
test('cover and closing writing own JSON initialization and final save rules', async () => {
|
||||||
const cover = await read('references/recap/writing1-cover.md');
|
const cover = await read('skill-doc/references/recap/writing1-cover.md');
|
||||||
const closing = await read('references/recap/writing5-closing.md');
|
const closing = await read('skill-doc/references/recap/writing5-closing.md');
|
||||||
|
|
||||||
assert.match(cover, /First JSON Write/i);
|
assert.match(cover, /First JSON Write/i);
|
||||||
assert.match(cover, /schema_version: "obelisk\.recap\.v1"/);
|
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 () => {
|
test('cover card retrieval and writing choose one dominant human claim', async () => {
|
||||||
const pattern = await read('references/recap/pattern1-cover.md');
|
const pattern = await read('skill-doc/references/recap/pattern1-cover.md');
|
||||||
const writing = await read('references/recap/writing1-cover.md');
|
const writing = await read('skill-doc/references/recap/writing1-cover.md');
|
||||||
|
|
||||||
assert.match(pattern, /dominant claim/i);
|
assert.match(pattern, /dominant claim/i);
|
||||||
assert.match(pattern, /persona/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 () => {
|
test('cover card schema uses claim instead of subtitle', async () => {
|
||||||
const pattern = await read('references/recap/pattern1-cover.md');
|
const pattern = await read('skill-doc/references/recap/pattern1-cover.md');
|
||||||
const writing = await read('references/recap/writing1-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 component = await read('app/src/renderer/src/components/recap/CoverCard.vue');
|
||||||
const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
|
const detail = await read('app/src/renderer/src/views/RecapDetail.vue');
|
||||||
const list = await read('app/src/renderer/src/views/RecapList.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 () => {
|
test('thinking card retrieval searches for turns instead of implementation timeline', async () => {
|
||||||
const pattern = await read('references/recap/pattern2-thinking.md');
|
const pattern = await read('skill-doc/references/recap/pattern2-thinking.md');
|
||||||
const writing = await read('references/recap/writing2-thinking.md');
|
const writing = await read('skill-doc/references/recap/writing2-thinking.md');
|
||||||
|
|
||||||
assert.match(pattern, /turning points/i);
|
assert.match(pattern, /turning points/i);
|
||||||
assert.match(pattern, /user question/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 () => {
|
test('thinking path schema uses turn instead of outcome', async () => {
|
||||||
const pattern = await read('references/recap/pattern2-thinking.md');
|
const pattern = await read('skill-doc/references/recap/pattern2-thinking.md');
|
||||||
const writing = await read('references/recap/writing2-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 component = await read('app/src/renderer/src/components/recap/PathCard.vue');
|
||||||
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
|
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 () => {
|
test('vibe card retrieval finds small user voice, not a correction audit', async () => {
|
||||||
const pattern = await read('references/recap/pattern3-vibe.md');
|
const pattern = await read('skill-doc/references/recap/pattern3-vibe.md');
|
||||||
const writing = await read('references/recap/writing3-vibe.md');
|
const writing = await read('skill-doc/references/recap/writing3-vibe.md');
|
||||||
|
|
||||||
assert.match(pattern, /catchphrases/i);
|
assert.match(pattern, /catchphrases/i);
|
||||||
assert.match(pattern, /visible user messages/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 () => {
|
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 component = await read('app/src/renderer/src/components/recap/VibeCard.vue');
|
||||||
const detail = await read('app/src/renderer/src/views/RecapDetail.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');
|
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 () => {
|
test('workflow card retrieval scopes real workflow runs and user reactions', async () => {
|
||||||
const pattern = await read('references/recap/pattern4-workflow.md');
|
const pattern = await read('skill-doc/references/recap/pattern4-workflow.md');
|
||||||
const writing = await read('references/recap/writing4-workflow.md');
|
const writing = await read('skill-doc/references/recap/writing4-workflow.md');
|
||||||
|
|
||||||
assert.match(pattern, /workflows\.timestamp/);
|
assert.match(pattern, /workflows\.timestamp/);
|
||||||
assert.match(pattern, /workflows\(\{ project: .* after, before/i);
|
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 () => {
|
test('workflow card uses reaction instead of outcome for row copy', async () => {
|
||||||
const pattern = await read('references/recap/pattern4-workflow.md');
|
const pattern = await read('skill-doc/references/recap/pattern4-workflow.md');
|
||||||
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 component = await read('app/src/renderer/src/components/recap/WorkflowCard.vue');
|
||||||
const mock = await read('app/src/renderer/src/mock/recap-2026-W24.json');
|
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 () => {
|
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 component = await read('app/src/renderer/src/components/recap/WorkflowCard.vue');
|
||||||
const detail = await read('app/src/renderer/src/views/RecapDetail.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');
|
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 () => {
|
test('closing card retrieval and writing keep a small personal receipt', async () => {
|
||||||
const pattern = await read('references/recap/pattern5-closing.md');
|
const pattern = await read('skill-doc/references/recap/pattern5-closing.md');
|
||||||
const writing = await read('references/recap/writing5-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, /same period and source scope/i);
|
||||||
assert.match(pattern, /streak/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 () => {
|
test('closing card schema uses receipts instead of stats', async () => {
|
||||||
const pattern = await read('references/recap/pattern5-closing.md');
|
const pattern = await read('skill-doc/references/recap/pattern5-closing.md');
|
||||||
const writing = await read('references/recap/writing5-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 component = await read('app/src/renderer/src/components/recap/ClosingCard.vue');
|
||||||
const detail = await read('app/src/renderer/src/views/RecapDetail.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');
|
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 () => {
|
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(
|
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');
|
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
|
// 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
|
// 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 }
|
// --build -> { ok: true, db }
|
||||||
// --search -> JSON array
|
// --search -> JSON array
|
||||||
// --query -> pretty-printed JSON result, or { error, stack } + exit 1 on throw
|
// --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 assert from 'node:assert/strict';
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { spawnSync } from 'node:child_process';
|
|
||||||
|
|
||||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
import { runCli as runRuntime } from './cli-test-helpers.mjs';
|
||||||
|
|
||||||
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() {
|
function tempHome() {
|
||||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-cli-envelope-'));
|
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.equal(result.status, 0, result.stderr || result.stdout);
|
||||||
assert.ok(Array.isArray(JSON.parse(result.stdout)), 'search must return a JSON array');
|
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 { createRequire } from 'node:module';
|
||||||
import { mkdtempSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
import { mkdtempSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { spawnSync } from 'node:child_process';
|
import { runCli as runRuntime } from './cli-test-helpers.mjs';
|
||||||
|
|
||||||
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
const { DatabaseSync } = require('node:sqlite');
|
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() {
|
function tempHome() {
|
||||||
const home = mkdtempSync(join(tmpdir(), 'obelisk-runtime-home-'));
|
const home = mkdtempSync(join(tmpdir(), 'obelisk-runtime-home-'));
|
||||||
mkdirSync(join(home, '.claude'), { recursive: true });
|
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,
|
"resolveJsonModule": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["packages/core/src/**/*", "tests/**/*"],
|
"include": ["packages/core/src/**/*", "packages/cli/src/**/*", "tests/**/*"],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"app",
|
"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