feat(memory): persistent memory layer with remember/recall CodeAct API
Add a memories table (survives index rebuilds) for agent-written conclusions with provenance (session, message range, project). The agent writes markdown files via Write tool (user-approved), then registers them via a --remember CodeAct script with remember(). Recall via memories() in --query scripts, filtered by project/session/time. Separates query (read-only, assertReadOnlySql) from remember (write) execution contexts in runtime.mjs.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
.DS_Store
|
||||
plans/
|
||||
.skillopt-backups
|
||||
tests/
|
||||
|
||||
@@ -28,8 +28,9 @@ Every past session, subagent, and workflow -- queryable by your agent.
|
||||
Most history tools help humans find old chats.
|
||||
|
||||
Obelisk is built for agents. It exposes past work as structured data: sessions,
|
||||
messages, tool calls, subagents, workflows, file history, failures, and parent
|
||||
chains. The agent writes the query, runs it locally, and answers in plain language.
|
||||
messages, tool calls, subagents, workflows, file history, failures, parent
|
||||
chains, and human-approved markdown memories. The agent writes the query, runs
|
||||
it locally, and answers in plain language.
|
||||
|
||||
You don't manage history. You ask questions about past work.
|
||||
|
||||
@@ -88,6 +89,10 @@ Runs it via node runtime.mjs --query <script>
|
||||
Reads the JSON result, answers you in natural language
|
||||
```
|
||||
|
||||
When a retrieval produces a memory worth keeping, the agent proposes a markdown
|
||||
memory file. After user approval, it registers that file with the narrow
|
||||
`runtime.mjs --remember <script>` runtime, which exposes only `remember()`.
|
||||
|
||||
**The core idea: don't make humans browse, tag, or organize sessions.**
|
||||
Don't invent a rigid query DSL either.
|
||||
|
||||
@@ -101,10 +106,11 @@ references only when the question needs them:
|
||||
|
||||
- `search(text)` — FTS5 full-text search, returns matches with surrounding context
|
||||
- `context(uuid)` — full story around a message (parent chain, subagent/workflow metadata)
|
||||
- `sql(query, ...params)` — raw SQL for anything else
|
||||
- `sql(query, ...params)` — read-only SQL for structured queries
|
||||
|
||||
**Structured shortcuts** — session, summary, subagent, workflow, file-history,
|
||||
failure, raw-window, and parent-chain helpers over the same SQLite data.
|
||||
**Structured shortcuts** — session, memory, summary, subagent, workflow,
|
||||
file-history, failure, raw-window, and parent-chain helpers over the same
|
||||
SQLite data.
|
||||
|
||||
**References** — agent reads on demand when the task needs deeper structure:
|
||||
|
||||
@@ -126,6 +132,7 @@ schema stay out of the first prompt until the agent needs them.
|
||||
| **Subagents** | `subagents/agent-<id>.jsonl` | Agent type, description, full conversation |
|
||||
| **Workflows** | `workflows/wf_<runId>.json` | Script, structured result, agent count |
|
||||
| **Workflow agents** | `subagents/workflows/wf_<runId>/` | Per-agent transcripts linked to workflow |
|
||||
| **Memories** | markdown files registered by the agent after user approval | Prior conclusions linked to source sessions/messages |
|
||||
|
||||
Full-text search via FTS5 covers message text across every layer, while the SQLite tables preserve the structure agents need for investigation.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ description: >
|
||||
Search and query past Claude Code 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(node:*)
|
||||
@@ -43,6 +44,8 @@ Custom query:
|
||||
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()` is not available, and `sql()` only
|
||||
accepts read-only SELECT/WITH queries.
|
||||
|
||||
## Query Routing
|
||||
|
||||
@@ -97,7 +100,7 @@ expand vertically from one evidence point without dumping the whole session.
|
||||
|
||||
### `sql(query, ...params)`
|
||||
|
||||
Raw SQL with `?` placeholders. Returns array rows.
|
||||
Read-only SQL SELECT/WITH with `?` placeholders. Returns array rows.
|
||||
|
||||
Before writing non-trivial SQL, read `references/schema.md`. Common safe joins:
|
||||
|
||||
@@ -107,7 +110,7 @@ Before writing non-trivial SQL, read `references/schema.md`. Common safe joins:
|
||||
- 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`,
|
||||
`subagents`, `workflows`, `workflow_agents`, `messages_fts`.
|
||||
`memories`, `subagents`, `workflows`, `workflow_agents`, `messages_fts`.
|
||||
|
||||
## Structured Helpers
|
||||
|
||||
@@ -130,6 +133,7 @@ tiny sample before relying on less common filters.
|
||||
- `trace(uuid)` -- parent chain from root to message.
|
||||
- `thread(sessionId)` -- full session messages; last resort only.
|
||||
- `raw(uuid, opts?)` -- windowed access to the original JSONL line.
|
||||
- `memories(opts?)` -- recall memory layer, newest first. opts: `{ query, project, sessionId, sessions, after, before, branch, limit }`. `query` filters summary/path by terms. Returns registered memory records (id, path, summary, project, session_id, created_at). Read the file at `path` for full content.
|
||||
|
||||
## Retrieval Contract
|
||||
|
||||
@@ -144,6 +148,61 @@ 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.
|
||||
|
||||
**Recall:** query `memories({ query: 'topic terms', project: '...' })` to find
|
||||
prior conclusions relevant to the current task. 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.
|
||||
|
||||
**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',
|
||||
summary: 'Detailed summary: what was decided, why, what alternatives were considered, and what constraints drove the choice.'
|
||||
})
|
||||
```
|
||||
|
||||
Run the registration script with:
|
||||
|
||||
```bash
|
||||
node $SKILL_DIR/scripts/runtime.mjs --remember /tmp/register-memory.mjs
|
||||
```
|
||||
|
||||
`--remember` exposes only `remember()`. It does not expose `search()`, `sql()`,
|
||||
`memories()`, or other retrieval helpers. If you need source 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`.
|
||||
|
||||
`summary` should be 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.
|
||||
|
||||
Memory records survive index rebuilds. They are never auto-deleted.
|
||||
|
||||
## Minimal Patterns
|
||||
|
||||
Search, then expand one promising hit:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Obelisk Query Patterns
|
||||
|
||||
These are copyable CodeAct patterns for `runtime.mjs --query` scripts. They are
|
||||
not new APIs. Adapt them to the user's scope and return compact evidence.
|
||||
These are copyable CodeAct patterns for `runtime.mjs --query` scripts, plus one
|
||||
`--remember` registration pattern. They are not new APIs. Adapt them to the
|
||||
user's scope and return compact evidence.
|
||||
|
||||
## Bounded Search To Context
|
||||
|
||||
@@ -26,6 +27,76 @@ return hits.slice(0, 5).map(h => {
|
||||
});
|
||||
```
|
||||
|
||||
## Memory Plus Session Evidence
|
||||
|
||||
Use this when prior conclusions may exist but the answer still depends on raw
|
||||
session evidence. Keep memory as prior notes, not final authority; compare it
|
||||
with session evidence in your final answer when correctness matters.
|
||||
|
||||
```js
|
||||
const project = '%quiet-zero%';
|
||||
const topic = 'markdown memory layer';
|
||||
const ftsTopic = topic.replace(/[-_]/g, ' ');
|
||||
|
||||
const prior_memories = memories({
|
||||
project,
|
||||
query: topic,
|
||||
limit: 5,
|
||||
}).map(m => ({
|
||||
id: m.id,
|
||||
path: m.path,
|
||||
session_id: m.session_id,
|
||||
message_start: m.message_start,
|
||||
message_end: m.message_end,
|
||||
created_at: m.created_at,
|
||||
summary: m.summary?.slice(0, 260),
|
||||
}));
|
||||
|
||||
const session_evidence = search(ftsTopic, { project, limit: 8 })
|
||||
.slice(0, 6)
|
||||
.map(h => ({
|
||||
session_id: h.session.id,
|
||||
session_title: h.session.title,
|
||||
uuid: h.message.uuid,
|
||||
timestamp: h.message.timestamp,
|
||||
snippet: h.message.text?.slice(0, 220),
|
||||
}));
|
||||
|
||||
return {
|
||||
query_plan: {
|
||||
project,
|
||||
topic,
|
||||
memory_limit: 5,
|
||||
session_limit: 8,
|
||||
},
|
||||
prior_memories,
|
||||
session_evidence,
|
||||
};
|
||||
```
|
||||
|
||||
## Register Approved Memory
|
||||
|
||||
Use this only after the user approves writing memory and the markdown file
|
||||
already exists. `remember()` validates the file and stores a normalized absolute
|
||||
path, so keep the script small and return the registered record.
|
||||
|
||||
Run this script with `runtime.mjs --remember <script>`. The `--remember` runtime
|
||||
exposes only `remember()`, not retrieval helpers.
|
||||
|
||||
```js
|
||||
return remember({
|
||||
path: '.obelisk/memories/memory-layer-design.md',
|
||||
session_id: 'source-session-id',
|
||||
message_start: 'first-message-uuid',
|
||||
message_end: 'last-message-uuid',
|
||||
summary: [
|
||||
'Decision: Obelisk uses one user-facing entry that queries both memory and raw sessions.',
|
||||
'Memory records are prior notes and must be identified naturally when they influence an answer.',
|
||||
'New memory writes require human confirmation before the markdown file is written and registered.',
|
||||
].join(' '),
|
||||
});
|
||||
```
|
||||
|
||||
## One-Shot Retrieval For Synthesis
|
||||
|
||||
Use this for conclusion, broad history, failure investigation, or file evolution
|
||||
|
||||
@@ -13,7 +13,7 @@ Classify the user's request before choosing tools.
|
||||
|-------------|--------------|------------|-------------|
|
||||
| project name/path, session, cwd, file, time range | scope | `sessions()`, exact SQL on `project_path`, `sessionId`, `fileHistory()` | broad FTS |
|
||||
| workflow, subagent, tool call, summary, edit | artifact | `workflows()`, `subagents()`, `summaries()`, `tool_calls`, `tool_results` | all-session search |
|
||||
| concept, conclusion, design history, vague memory | semantic | `search()`, summaries, bounded facet sweep | session dumps |
|
||||
| concept, conclusion, design history, vague memory | semantic | `memories({ query })`, `search()`, summaries, bounded facet sweep | session dumps |
|
||||
|
||||
One-shot retrieval is not all-shot retrieval. A query script may perform
|
||||
multiple steps, but the first locator should be the narrowest semantic fit. If a
|
||||
@@ -23,6 +23,7 @@ unless scoped evidence is insufficient and `query_plan` says why.
|
||||
Project-like fields are distinct:
|
||||
|
||||
- `sessions.project`: stored Claude Code project slug.
|
||||
- `memories.project`: stored project slug copied onto registered memory records.
|
||||
- `sessions.project_path`: reconstructed absolute project path.
|
||||
- `messages.cwd`: working directory at message time.
|
||||
- helper `project`: SQL `LIKE` over `sessions.project`, not exact membership.
|
||||
@@ -62,23 +63,27 @@ Use the database shape before asking the model to read text.
|
||||
|
||||
Ordering and context are semantic:
|
||||
|
||||
- `sessions()`, `summaries()`, `workflows()`, and `failures()` are newest first.
|
||||
- `sessions()`, `memories()`, `summaries()`, `workflows()`, and `failures()` are newest first.
|
||||
- `fileHistory()` is oldest first.
|
||||
- `search().context` is temporal neighbors in one session, not causal context.
|
||||
- `context(uuid)` and `trace(uuid)` are for parent-chain/causal expansion.
|
||||
|
||||
### Evidence Before Conclusion
|
||||
|
||||
Obelisk stores original structure, not precompiled claims. It has sessions,
|
||||
messages, summaries, tool calls/results, files, subagents, workflows, parent
|
||||
chains, and raw JSONL windows. It does not store "claim", "stance",
|
||||
"contradiction", or "conclusion" entities.
|
||||
Obelisk's raw session layer stores original structure, not precompiled claims:
|
||||
sessions, messages, summaries, tool calls/results, files, subagents, workflows,
|
||||
parent chains, and raw JSONL windows. The memory layer can store
|
||||
human-approved markdown conclusions, but treat them as prior notes to compare
|
||||
against raw evidence when correctness matters.
|
||||
|
||||
For semantic questions, build a task-local evidence view:
|
||||
|
||||
```js
|
||||
{
|
||||
query_plan: { mode, scope, facets, limits },
|
||||
prior_memories: [
|
||||
{ id, path, session_id, created_at, summary }
|
||||
],
|
||||
evidence: [
|
||||
{ type, id, session_id, timestamp, facet, snippet }
|
||||
],
|
||||
@@ -86,8 +91,8 @@ For semantic questions, build a task-local evidence view:
|
||||
}
|
||||
```
|
||||
|
||||
Then synthesize the conclusion in the final answer. Do not pretend the evidence
|
||||
view is a stored Obelisk entity.
|
||||
Then synthesize the conclusion in the final answer. Do not pretend the raw
|
||||
evidence view is itself a stored Obelisk entity.
|
||||
|
||||
## Text Search Semantics
|
||||
|
||||
|
||||
+102
-4
@@ -177,6 +177,28 @@ CREATE TABLE index_state (
|
||||
);
|
||||
```
|
||||
|
||||
### memories
|
||||
|
||||
Human-approved markdown memory records registered in Obelisk. The markdown
|
||||
file at `path` is the durable memory content; `summary` is the compact retrieval
|
||||
surface.
|
||||
|
||||
```sql
|
||||
CREATE TABLE memories (
|
||||
id TEXT PRIMARY KEY, -- memory record ID
|
||||
session_id TEXT, -- FK -> sessions.id where the memory was drawn, if known
|
||||
project TEXT, -- project slug used for scoped recall
|
||||
message_start TEXT, -- first relevant message UUID, if known
|
||||
message_end TEXT, -- last relevant message UUID, if known
|
||||
path TEXT, -- normalized absolute markdown memory file path
|
||||
summary TEXT, -- retrieval summary of the memory
|
||||
created_at TEXT -- ISO 8601 registration time
|
||||
);
|
||||
```
|
||||
|
||||
Indexes: `idx_memories_project(project)`,
|
||||
`idx_memories_session(session_id)`, `idx_memories_created(created_at)`.
|
||||
|
||||
### Key Relationships
|
||||
|
||||
```
|
||||
@@ -185,8 +207,10 @@ sessions.id <-- tool_calls.session_id
|
||||
sessions.id <-- tool_results.session_id
|
||||
sessions.id <-- subagents.session_id
|
||||
sessions.id <-- workflows.session_id
|
||||
sessions.id <-- memories.session_id
|
||||
messages.uuid <-- tool_calls.message_uuid
|
||||
messages.uuid <-- tool_results.message_uuid
|
||||
messages.uuid <-- memories.message_start / memories.message_end
|
||||
messages.agent_id --> subagents.agent_id (for subagent messages)
|
||||
messages.agent_id --> workflow_agents.agent_id (for workflow agent messages)
|
||||
tool_calls.id <-- tool_results.tool_use_id
|
||||
@@ -197,8 +221,9 @@ workflows.run_id <-- workflow_agents.run_id
|
||||
|
||||
## 2. Query API Reference
|
||||
|
||||
All functions are available as globals inside `--query` scripts.
|
||||
Scripts run in an async IIFE with a 30-second timeout.
|
||||
Read helpers are available as globals inside `--query` scripts. Memory write
|
||||
helpers are available only inside `--remember` scripts. Scripts run in an async
|
||||
IIFE with a 30-second timeout.
|
||||
|
||||
### Simple Layer
|
||||
|
||||
@@ -250,15 +275,18 @@ return { chain_length: c.parentChain.length, session_title: c.session?.title };
|
||||
|
||||
#### `sql(query, ...params)`
|
||||
|
||||
Raw SQL with parameterized bindings. Returns an array of row objects.
|
||||
Read-only SQL with parameterized bindings. Returns an array of row objects.
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `query` | `string` | SQL SELECT statement |
|
||||
| `query` | `string` | SQL SELECT/WITH statement |
|
||||
| `...params` | `any` | Bind parameters (positional `?`) |
|
||||
|
||||
**Returns:** `Array<Object>` -- each row as `{ column: value }`.
|
||||
|
||||
Write statements are rejected. Use `--remember` and `remember()` for memory
|
||||
registration after user approval.
|
||||
|
||||
```js
|
||||
const rows = sql('SELECT id, title FROM sessions WHERE project = ? ORDER BY ended_at DESC LIMIT 5', 'Users-tomiya-Code-quiet-zero');
|
||||
return rows;
|
||||
@@ -412,6 +440,76 @@ const qz = sessions({ project: '%quiet-zero%', limit: 5 });
|
||||
return qz.map(s => ({ title: s.title, branch: s.git_branch, ended: s.ended_at }));
|
||||
```
|
||||
|
||||
#### `memories(opts?)`
|
||||
|
||||
Registered markdown memory records. Like other list helpers, passing a string
|
||||
is treated as `sessionId`, and passing a number is treated as `limit`.
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `opts.query` | `string` | Term filter over `summary` and `path`; hyphens/underscores are treated as spaces |
|
||||
| `opts.project` | `string` | SQL `LIKE` pattern over `memories.project` |
|
||||
| `opts.sessionId` | `string` | Restrict to one source session |
|
||||
| `opts.sessions` | `string[]` | Restrict to a set of source session IDs |
|
||||
| `opts.after` | `string` | ISO 8601 lower bound on `created_at` |
|
||||
| `opts.before` | `string` | ISO 8601 upper bound on `created_at` |
|
||||
| `opts.branch` | `string` | Filter by source session git branch (exact match) |
|
||||
| `opts.limit` | `number` | Max results (default 50) |
|
||||
|
||||
**Returns:** `Array<memory_row>` ordered by `created_at` descending.
|
||||
|
||||
`query` is a lightweight term filter, not FTS5 ranking. Use it to avoid pulling
|
||||
all recent memories, then read the markdown file at `path` when a memory looks
|
||||
relevant.
|
||||
|
||||
```js
|
||||
const prior = memories({
|
||||
project: '%quiet-zero%',
|
||||
query: 'memory layer markdown',
|
||||
limit: 5,
|
||||
});
|
||||
return prior.map(m => ({
|
||||
id: m.id,
|
||||
path: m.path,
|
||||
session_id: m.session_id,
|
||||
summary: m.summary?.slice(0, 240),
|
||||
}));
|
||||
```
|
||||
|
||||
#### `remember(record)`
|
||||
|
||||
Register a human-approved markdown memory file. This is a write helper, not a
|
||||
recall helper; use it only after the user has approved writing memory. It is
|
||||
available only in scripts run with `runtime.mjs --remember`.
|
||||
|
||||
`--remember` exposes only `remember()`, not `search()`, `sql()`, `memories()`,
|
||||
or other retrieval helpers. If source IDs are unknown, find them first with a
|
||||
normal `--query` script.
|
||||
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `record.path` | `string` | Existing markdown file path. Relative paths resolve against the source session `project_path` when `session_id` is provided, otherwise against the runtime cwd |
|
||||
| `record.summary` | `string` | Required retrieval summary: decision, reasoning, constraints |
|
||||
| `record.session_id` | `string` | Source session ID, if known |
|
||||
| `record.message_start` | `string` | First relevant source message UUID, if known |
|
||||
| `record.message_end` | `string` | Last relevant source message UUID, if known |
|
||||
| `record.project` | `string` | Project slug override. Defaults from `sessions.project` for `session_id` |
|
||||
|
||||
`remember()` validates that `path` exists and is a regular file. It stores the
|
||||
normalized absolute path in `memories.path`.
|
||||
|
||||
**Returns:** `{ id, path, project, created_at }`.
|
||||
|
||||
```js
|
||||
return remember({
|
||||
path: '.obelisk/memories/memory-layer-design.md',
|
||||
session_id: 'source-session-id',
|
||||
message_start: 'first-message-uuid',
|
||||
message_end: 'last-message-uuid',
|
||||
summary: 'Decision: keep Obelisk as one user-facing entry that queries both memory and raw session evidence. Memory is prior notes, not final authority.',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Common Query Patterns
|
||||
|
||||
@@ -54,6 +54,13 @@ CREATE INDEX IF NOT EXISTS idx_sa_session ON subagents(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wf_session ON workflows(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wa_run ON workflow_agents(run_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_summaries_session ON summaries(session_id);
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY, session_id TEXT, project TEXT,
|
||||
message_start TEXT, message_end TEXT,
|
||||
path TEXT, summary TEXT, created_at TEXT);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_session ON memories(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at);
|
||||
`;
|
||||
|
||||
function openDb() {
|
||||
|
||||
+74
-3
@@ -24,8 +24,21 @@ function buildWhere(opts, aliases) {
|
||||
|
||||
const BASH_EXIT_PAT = 'Exit code %';
|
||||
|
||||
function assertReadOnlySql(sql) {
|
||||
const text = String(sql || '').trim();
|
||||
if (!/^(SELECT|WITH)\b/i.test(text)) {
|
||||
throw new Error('sql() only supports read-only SELECT/WITH queries');
|
||||
}
|
||||
if (/\b(INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|ALTER|PRAGMA|VACUUM|ATTACH|DETACH)\b/i.test(text)) {
|
||||
throw new Error('sql() only supports read-only SELECT/WITH queries');
|
||||
}
|
||||
}
|
||||
|
||||
function createQueryApi(db) {
|
||||
const q = (sql, ...p) => db.prepare(sql).all(...p);
|
||||
const q = (sql, ...p) => {
|
||||
assertReadOnlySql(sql);
|
||||
return db.prepare(sql).all(...p);
|
||||
};
|
||||
|
||||
const search = (text, opts = {}) => {
|
||||
const { limit = 20, sessionId, project, after, before, cwd } = opts;
|
||||
@@ -213,7 +226,65 @@ function createQueryApi(db) {
|
||||
};
|
||||
};
|
||||
|
||||
return { sql: q, search, context, trace, thread, subagents, workflows, workflowTree, fileHistory, failures, sessions, recent, summaries, raw };
|
||||
const memories = (optsOrSid) => {
|
||||
const opts = normalizeOpts(optsOrSid);
|
||||
const { limit = 50, query } = opts;
|
||||
const needsJoin = opts.branch;
|
||||
const { where: baseWhere, params } = buildWhere(opts, {
|
||||
sessionId: 'mem.session_id',
|
||||
project: 'mem.project',
|
||||
timestamp: 'mem.created_at',
|
||||
branch: 's.git_branch',
|
||||
});
|
||||
const terms = String(query || '')
|
||||
.trim()
|
||||
.replace(/[-_]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
let where = baseWhere;
|
||||
for (const term of terms) {
|
||||
where += " AND lower(coalesce(mem.summary,'') || ' ' || coalesce(mem.path,'')) LIKE ?";
|
||||
params.push(`%${term.toLowerCase()}%`);
|
||||
}
|
||||
params.push(limit);
|
||||
const join = needsJoin ? 'LEFT JOIN sessions s ON s.id=mem.session_id' : '';
|
||||
return db.prepare(`SELECT mem.* FROM memories mem ${join} WHERE ${where} ORDER BY mem.created_at DESC LIMIT ?`).all(...params);
|
||||
};
|
||||
|
||||
return { sql: q, search, context, trace, thread, subagents, workflows, workflowTree, fileHistory, failures, sessions, recent, summaries, raw, memories };
|
||||
}
|
||||
|
||||
export { createQueryApi };
|
||||
function createRememberApi(db) {
|
||||
const resolveMemoryPath = (memoryPath, sessionId) => {
|
||||
let base = null;
|
||||
if (sessionId) {
|
||||
base = db.prepare('SELECT project_path FROM sessions WHERE id=?').get(sessionId)?.project_path || null;
|
||||
}
|
||||
const resolved = path.isAbsolute(memoryPath)
|
||||
? path.normalize(memoryPath)
|
||||
: path.resolve(base || process.cwd(), memoryPath);
|
||||
let stat;
|
||||
try {
|
||||
stat = fs.statSync(resolved);
|
||||
} catch {
|
||||
throw new Error(`remember() memory file does not exist: ${resolved}`);
|
||||
}
|
||||
if (!stat.isFile()) throw new Error(`remember() memory path is not a file: ${resolved}`);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const remember = ({ path: memoryPath, session_id, message_start, message_end, summary, project }) => {
|
||||
if (!memoryPath || !summary) throw new Error('remember() requires path and summary');
|
||||
const normalizedPath = resolveMemoryPath(memoryPath, session_id);
|
||||
const id = `mem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const proj = project || db.prepare('SELECT project FROM sessions WHERE id=?').get(session_id)?.project || null;
|
||||
const created_at = new Date().toISOString();
|
||||
db.prepare('INSERT OR REPLACE INTO memories (id, session_id, project, message_start, message_end, path, summary, created_at) VALUES (?,?,?,?,?,?,?,?)').run(
|
||||
id, session_id || null, proj, message_start || null, message_end || null, normalizedPath, summary, created_at);
|
||||
return { id, path: normalizedPath, project: proj, created_at };
|
||||
};
|
||||
|
||||
return { remember };
|
||||
}
|
||||
|
||||
export { createQueryApi, createRememberApi };
|
||||
|
||||
+20
-4
@@ -7,10 +7,9 @@ const vm = require('node:vm');
|
||||
|
||||
import { DB_PATH, openDb } from './db.mjs';
|
||||
import { buildIndex } from './indexer.mjs';
|
||||
import { createQueryApi } from './query.mjs';
|
||||
import { createQueryApi, createRememberApi } from './query.mjs';
|
||||
|
||||
function executeQuery(db, scriptContent) {
|
||||
const api = createQueryApi(db);
|
||||
function executeScript(api, scriptContent) {
|
||||
const sandbox = {
|
||||
...api, JSON, Math, Array, Object, Set, Map, Date, RegExp,
|
||||
parseInt, parseFloat, String, Number, Boolean, Error, Promise, console, setTimeout,
|
||||
@@ -19,6 +18,14 @@ function executeQuery(db, scriptContent) {
|
||||
return vm.runInNewContext(`(async()=>{${scriptContent}})()`, ctx, { timeout: 30000 });
|
||||
}
|
||||
|
||||
function executeQuery(db, scriptContent) {
|
||||
return executeScript(createQueryApi(db), scriptContent);
|
||||
}
|
||||
|
||||
function executeRemember(db, scriptContent) {
|
||||
return executeScript(createRememberApi(db), scriptContent);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
if (args[0] === '--build') {
|
||||
@@ -42,7 +49,16 @@ function main() {
|
||||
.catch(e => { process.stdout.write(JSON.stringify({ error: e.message, stack: e.stack }) + '\n'); db.close(); process.exitCode = 1; });
|
||||
return;
|
||||
}
|
||||
process.stderr.write('Usage:\n node runtime.mjs --build\n node runtime.mjs --search "text"\n node runtime.mjs --query <file.js>\n');
|
||||
if (args[0] === '--remember' && args[1]) {
|
||||
buildIndex();
|
||||
const db = openDb();
|
||||
const script = fs.readFileSync(path.resolve(args[1]), 'utf8');
|
||||
executeRemember(db, script)
|
||||
.then(r => { process.stdout.write(JSON.stringify(r, null, 2) + '\n'); db.close(); })
|
||||
.catch(e => { process.stdout.write(JSON.stringify({ error: e.message, stack: e.stack }) + '\n'); db.close(); process.exitCode = 1; });
|
||||
return;
|
||||
}
|
||||
process.stderr.write('Usage:\n node runtime.mjs --build\n node runtime.mjs --search "text"\n node runtime.mjs --query <file.js>\n node runtime.mjs --remember <file.js>\n');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user