smaran 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/db.js +152 -0
- package/dist/index.js +42 -0
- package/dist/store.js +653 -0
- package/dist/tools.js +294 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ajaybabu Putti
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# smaran
|
|
2
|
+
|
|
3
|
+
**Professional memory for AI agents — with an undo button.**
|
|
4
|
+
|
|
5
|
+
*Smaran* (स्मरण) is Sanskrit for remembrance — not just storing something, but the act of holding it with care.
|
|
6
|
+
|
|
7
|
+
Every memory tool promises your agent will remember. The real problem is what agents *write*: guesses saved as facts, duplicates, stale state — then recalled with perfect confidence forever. smaran is a local MCP memory server built around the write path:
|
|
8
|
+
|
|
9
|
+
- **Typed writes** — agents can't dump arbitrary text; a memory is a *decision*, a *preference*, a *task*, an *event*, with structure
|
|
10
|
+
- **Refuse by default** — writes against unknown projects/people are rejected; creation is explicit and audited
|
|
11
|
+
- **Append-only audit log** — every write records before/after state and which app made it
|
|
12
|
+
- **Undo** — `revert_write` reverses any write; the history of the revert is itself kept
|
|
13
|
+
- **Supersede, don't delete** — when a fact changes, the old one is replaced, never silently lost
|
|
14
|
+
|
|
15
|
+
Local-first: one SQLite file on your machine (`~/.smaran/memory.db`). No Docker, no API keys, no account, no open ports, no telemetry.
|
|
16
|
+
|
|
17
|
+
## Install (under 5 minutes)
|
|
18
|
+
|
|
19
|
+
**Claude Code**
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
claude mcp add memory -- npx -y smaran
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Claude Desktop / Cursor / Windsurf** — add to your MCP config:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"memory": { "command": "npx", "args": ["-y", "smaran"] }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
That's it. Ask your agent to *"create a project for X and remember that we decided Y"*, then in a new session ask *"what do you know about project X?"*
|
|
36
|
+
|
|
37
|
+
Custom database location: `npx smaran --db /path/to/memory.db` or set `SMARAN_DB`.
|
|
38
|
+
|
|
39
|
+
## Tools
|
|
40
|
+
|
|
41
|
+
| Read | Write | Audit |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| `search_memories` (BM25) | `create_project` | `list_recent_writes` |
|
|
44
|
+
| `list_projects` / `list_people` / `list_companies` | `add_person` / `add_company` | `revert_write` |
|
|
45
|
+
| `get_project_context` | `store_decision` | |
|
|
46
|
+
| `get_person_context` | `log_event` | |
|
|
47
|
+
| `get_company_context` | `update_project_state` | |
|
|
48
|
+
| `get_recent_decisions` | `save_preference` | |
|
|
49
|
+
| `get_open_loops` | `set_next_step` / `mark_done` | |
|
|
50
|
+
| `prepare_task_brief` | `append_note` | |
|
|
51
|
+
|
|
52
|
+
Entity tools accept ids **or names** (`get_project_context("atlas")`), because that's how models actually call them.
|
|
53
|
+
|
|
54
|
+
## Why keyword search instead of embeddings?
|
|
55
|
+
|
|
56
|
+
The caller is an LLM. If a search misses, it retries with synonyms or browses with the context tools — the semantic understanding lives in the model, not the index. At personal scale (thousands of memories, not millions), BM25 over *typed* content retrieves what matters, with zero API keys and zero setup. Typed entities are the source of truth; the search index is regenerated from them and safe to rebuild.
|
|
57
|
+
|
|
58
|
+
## The agent contract
|
|
59
|
+
|
|
60
|
+
Add this to your agent instructions (CLAUDE.md, Cursor rules, custom instructions):
|
|
61
|
+
|
|
62
|
+
> Before starting work, call `prepare_task_brief` or `get_project_context` to load relevant context.
|
|
63
|
+
> After finishing, store only durable outcomes: decisions made, status changes, preferences learned, promises, blockers, next steps. Use the most specific tool available. Never store speculation, drafts, or chain-of-thought.
|
|
64
|
+
> When a stored fact is no longer true, supersede it — don't ignore it.
|
|
65
|
+
|
|
66
|
+
## Recovering from bad writes
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
you : what did you save to memory this session?
|
|
70
|
+
agent: [calls list_recent_writes] I stored 3 things: …
|
|
71
|
+
you : the second one is wrong, undo it
|
|
72
|
+
agent: [calls revert_write] Done — reverted, and the revert is logged.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The audit log is append-only. Reverts never erase history; they add to it.
|
|
76
|
+
|
|
77
|
+
## Roadmap
|
|
78
|
+
|
|
79
|
+
- Hosted sync (same typed schema, cloud endpoint for browser ChatGPT/Claude/Gemini and cross-device memory)
|
|
80
|
+
- Smart reconciliation: fuzzy dedupe, conflict detection, memory-quality sweeps
|
|
81
|
+
- Optional local embeddings for semantic search
|
|
82
|
+
|
|
83
|
+
MIT licensed.
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
// Same typed schema as the cloud product (infrastructure/init.sql), minus
|
|
6
|
+
// users/tokens (single-user local) and memory_chunks/embeddings (FTS5 takes
|
|
7
|
+
// the retrieval role here). Arrays become JSON TEXT columns.
|
|
8
|
+
const SCHEMA = `
|
|
9
|
+
CREATE TABLE IF NOT EXISTS companies (
|
|
10
|
+
id TEXT PRIMARY KEY,
|
|
11
|
+
name TEXT NOT NULL,
|
|
12
|
+
industry TEXT, stage TEXT, priority TEXT, notes TEXT,
|
|
13
|
+
source_type TEXT DEFAULT 'model', source_app TEXT, source_ref TEXT,
|
|
14
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS people (
|
|
18
|
+
id TEXT PRIMARY KEY,
|
|
19
|
+
name TEXT NOT NULL,
|
|
20
|
+
role TEXT,
|
|
21
|
+
company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
|
|
22
|
+
relationship_type TEXT,
|
|
23
|
+
communication_preferences TEXT,
|
|
24
|
+
important_context TEXT,
|
|
25
|
+
last_interaction_at TEXT,
|
|
26
|
+
source_type TEXT DEFAULT 'model', source_app TEXT, source_ref TEXT,
|
|
27
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
CREATE TABLE IF NOT EXISTS projects (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
name TEXT NOT NULL,
|
|
33
|
+
description TEXT,
|
|
34
|
+
status TEXT DEFAULT 'active',
|
|
35
|
+
goals TEXT, -- JSON array
|
|
36
|
+
constraints TEXT, -- JSON array
|
|
37
|
+
owners TEXT, -- JSON array
|
|
38
|
+
source_type TEXT DEFAULT 'model', source_app TEXT, source_ref TEXT,
|
|
39
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS decisions (
|
|
43
|
+
id TEXT PRIMARY KEY,
|
|
44
|
+
title TEXT NOT NULL,
|
|
45
|
+
decision TEXT NOT NULL,
|
|
46
|
+
rationale TEXT,
|
|
47
|
+
scope TEXT,
|
|
48
|
+
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
|
49
|
+
decided_by TEXT,
|
|
50
|
+
decided_at TEXT,
|
|
51
|
+
superseded_by TEXT REFERENCES decisions(id) ON DELETE SET NULL,
|
|
52
|
+
confidence REAL DEFAULT 0.8,
|
|
53
|
+
source_type TEXT DEFAULT 'model', source_app TEXT, source_ref TEXT,
|
|
54
|
+
created_at TEXT NOT NULL
|
|
55
|
+
);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_decisions_created ON decisions(created_at DESC);
|
|
57
|
+
|
|
58
|
+
CREATE TABLE IF NOT EXISTS preferences (
|
|
59
|
+
id TEXT PRIMARY KEY,
|
|
60
|
+
subject_type TEXT NOT NULL, -- 'person' | 'company' | 'project' | 'self'
|
|
61
|
+
subject_id TEXT,
|
|
62
|
+
preference_type TEXT,
|
|
63
|
+
value TEXT NOT NULL,
|
|
64
|
+
strength REAL DEFAULT 0.8,
|
|
65
|
+
last_verified_at TEXT,
|
|
66
|
+
superseded_by TEXT REFERENCES preferences(id) ON DELETE SET NULL,
|
|
67
|
+
source_type TEXT DEFAULT 'model', source_app TEXT, source_ref TEXT,
|
|
68
|
+
created_at TEXT NOT NULL
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
72
|
+
id TEXT PRIMARY KEY,
|
|
73
|
+
title TEXT,
|
|
74
|
+
summary TEXT NOT NULL,
|
|
75
|
+
occurred_at TEXT,
|
|
76
|
+
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
|
77
|
+
person_ids TEXT, -- JSON array
|
|
78
|
+
company_ids TEXT, -- JSON array
|
|
79
|
+
durability REAL DEFAULT 0.7,
|
|
80
|
+
source_type TEXT DEFAULT 'model', source_app TEXT, source_ref TEXT,
|
|
81
|
+
created_at TEXT NOT NULL
|
|
82
|
+
);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS idx_events_occurred ON events(occurred_at DESC);
|
|
84
|
+
|
|
85
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
86
|
+
id TEXT PRIMARY KEY,
|
|
87
|
+
title TEXT NOT NULL,
|
|
88
|
+
status TEXT DEFAULT 'open', -- open | in_progress | blocked | done | cancelled
|
|
89
|
+
owner TEXT,
|
|
90
|
+
due_at TEXT,
|
|
91
|
+
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
|
92
|
+
blocked_by TEXT REFERENCES tasks(id) ON DELETE SET NULL,
|
|
93
|
+
completed_at TEXT,
|
|
94
|
+
source_type TEXT DEFAULT 'model', source_app TEXT, source_ref TEXT,
|
|
95
|
+
created_at TEXT NOT NULL, updated_at TEXT NOT NULL
|
|
96
|
+
);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
98
|
+
|
|
99
|
+
-- One row per project; '' = global working state.
|
|
100
|
+
CREATE TABLE IF NOT EXISTS working_state (
|
|
101
|
+
project_id TEXT PRIMARY KEY DEFAULT '',
|
|
102
|
+
current_focus TEXT,
|
|
103
|
+
active_blockers TEXT, -- JSON array
|
|
104
|
+
this_week_priorities TEXT, -- JSON array
|
|
105
|
+
open_loops TEXT, -- JSON array
|
|
106
|
+
last_updated_at TEXT NOT NULL
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
110
|
+
id TEXT PRIMARY KEY,
|
|
111
|
+
content TEXT NOT NULL,
|
|
112
|
+
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
|
113
|
+
tags TEXT, -- JSON array
|
|
114
|
+
source_type TEXT DEFAULT 'model', source_app TEXT, source_ref TEXT,
|
|
115
|
+
created_at TEXT NOT NULL
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
-- Append-only. Rows are never deleted; revert marks them and adds a new row.
|
|
119
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
120
|
+
id TEXT PRIMARY KEY,
|
|
121
|
+
action TEXT NOT NULL, -- 'create' | 'update' | 'supersede' | 'revert'
|
|
122
|
+
entity_kind TEXT NOT NULL,
|
|
123
|
+
entity_id TEXT NOT NULL,
|
|
124
|
+
before_state TEXT, -- JSON
|
|
125
|
+
after_state TEXT, -- JSON
|
|
126
|
+
source_type TEXT DEFAULT 'model',
|
|
127
|
+
source_app TEXT,
|
|
128
|
+
source_ref TEXT,
|
|
129
|
+
reverted INTEGER DEFAULT 0,
|
|
130
|
+
created_at TEXT NOT NULL
|
|
131
|
+
);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
|
|
133
|
+
|
|
134
|
+
-- Keyword retrieval layer (BM25). Typed rows are the source of truth; this
|
|
135
|
+
-- index is regenerated from them and safe to rebuild.
|
|
136
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
137
|
+
content, kind UNINDEXED, ref_id UNINDEXED, project_id UNINDEXED
|
|
138
|
+
);
|
|
139
|
+
`;
|
|
140
|
+
export function defaultDbPath() {
|
|
141
|
+
return (process.env.SMARAN_DB ??
|
|
142
|
+
path.join(homedir(), '.smaran', 'memory.db'));
|
|
143
|
+
}
|
|
144
|
+
export function openDb(dbPath) {
|
|
145
|
+
const file = dbPath ?? defaultDbPath();
|
|
146
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
147
|
+
const db = new Database(file);
|
|
148
|
+
db.pragma('journal_mode = WAL');
|
|
149
|
+
db.pragma('foreign_keys = ON');
|
|
150
|
+
db.exec(SCHEMA);
|
|
151
|
+
return db;
|
|
152
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { openDb, defaultDbPath } from './db.js';
|
|
6
|
+
import { Store } from './store.js';
|
|
7
|
+
import { buildTools } from './tools.js';
|
|
8
|
+
function parseDbArg() {
|
|
9
|
+
const i = process.argv.indexOf('--db');
|
|
10
|
+
if (i !== -1 && process.argv[i + 1])
|
|
11
|
+
return process.argv[i + 1];
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
const dbPath = parseDbArg() ?? defaultDbPath();
|
|
15
|
+
const db = openDb(dbPath);
|
|
16
|
+
const store = new Store(db);
|
|
17
|
+
const tools = buildTools(store);
|
|
18
|
+
const server = new Server({ name: 'smaran', version: '0.1.0' }, { capabilities: { tools: {} } });
|
|
19
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
20
|
+
tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })),
|
|
21
|
+
}));
|
|
22
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
23
|
+
const tool = tools.find((t) => t.name === req.params.name);
|
|
24
|
+
if (!tool) {
|
|
25
|
+
return { content: [{ type: 'text', text: `Unknown tool: ${req.params.name}` }], isError: true };
|
|
26
|
+
}
|
|
27
|
+
const ctx = {
|
|
28
|
+
sourceType: 'model',
|
|
29
|
+
sourceApp: server.getClientVersion()?.name ?? 'mcp',
|
|
30
|
+
sourceRef: null,
|
|
31
|
+
};
|
|
32
|
+
try {
|
|
33
|
+
const result = await tool.handler(req.params.arguments ?? {}, ctx);
|
|
34
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return { content: [{ type: 'text', text: `Error: ${e?.message ?? e}` }], isError: true };
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
await server.connect(new StdioServerTransport());
|
|
41
|
+
// stdout carries the protocol; human-facing output goes to stderr.
|
|
42
|
+
console.error(`smaran MCP server ready · db: ${dbPath}`);
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
const TABLE = {
|
|
3
|
+
project: 'projects',
|
|
4
|
+
person: 'people',
|
|
5
|
+
company: 'companies',
|
|
6
|
+
decision: 'decisions',
|
|
7
|
+
preference: 'preferences',
|
|
8
|
+
event: 'events',
|
|
9
|
+
task: 'tasks',
|
|
10
|
+
note: 'notes',
|
|
11
|
+
};
|
|
12
|
+
const now = () => new Date().toISOString();
|
|
13
|
+
const toJson = (v) => (v == null ? null : JSON.stringify(v));
|
|
14
|
+
const fromJson = (v) => (typeof v === 'string' ? JSON.parse(v) : v);
|
|
15
|
+
function parseRow(row, jsonCols) {
|
|
16
|
+
if (!row)
|
|
17
|
+
return row;
|
|
18
|
+
const out = { ...row };
|
|
19
|
+
for (const c of jsonCols)
|
|
20
|
+
if (out[c] != null)
|
|
21
|
+
out[c] = fromJson(out[c]);
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
export class Store {
|
|
25
|
+
db;
|
|
26
|
+
constructor(db) {
|
|
27
|
+
this.db = db;
|
|
28
|
+
}
|
|
29
|
+
// ------------------------------------------------------------- audit + fts
|
|
30
|
+
audit(ctx, action, kind, entityId, before, after) {
|
|
31
|
+
const id = randomUUID();
|
|
32
|
+
this.db
|
|
33
|
+
.prepare(`INSERT INTO audit_log (id, action, entity_kind, entity_id, before_state, after_state,
|
|
34
|
+
source_type, source_app, source_ref, reverted, created_at)
|
|
35
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`)
|
|
36
|
+
.run(id, action, kind, entityId, toJson(before), toJson(after), ctx.sourceType, ctx.sourceApp, ctx.sourceRef, now());
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
ftsSet(kind, refId, content, projectId) {
|
|
40
|
+
this.db.prepare(`DELETE FROM memory_fts WHERE kind = ? AND ref_id = ?`).run(kind, refId);
|
|
41
|
+
this.db
|
|
42
|
+
.prepare(`INSERT INTO memory_fts (content, kind, ref_id, project_id) VALUES (?, ?, ?, ?)`)
|
|
43
|
+
.run(content, kind, refId, projectId);
|
|
44
|
+
}
|
|
45
|
+
ftsDelete(kind, refId) {
|
|
46
|
+
this.db.prepare(`DELETE FROM memory_fts WHERE kind = ? AND ref_id = ?`).run(kind, refId);
|
|
47
|
+
}
|
|
48
|
+
ftsContent(kind, row) {
|
|
49
|
+
switch (kind) {
|
|
50
|
+
case 'decision':
|
|
51
|
+
return [row.title, row.decision, row.rationale, row.scope].filter(Boolean).join('. ');
|
|
52
|
+
case 'event':
|
|
53
|
+
return [row.title, row.summary].filter(Boolean).join('. ');
|
|
54
|
+
case 'preference':
|
|
55
|
+
return [row.subject_type, row.preference_type, row.value].filter(Boolean).join(': ');
|
|
56
|
+
case 'project':
|
|
57
|
+
return [row.name, row.description, ...(fromJson(row.goals) ?? []), ...(fromJson(row.constraints) ?? [])]
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join('. ');
|
|
60
|
+
case 'person':
|
|
61
|
+
return [row.name, row.role, row.relationship_type, row.communication_preferences, row.important_context]
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.join('. ');
|
|
64
|
+
case 'company':
|
|
65
|
+
return [row.name, row.industry, row.stage, row.notes].filter(Boolean).join('. ');
|
|
66
|
+
case 'task':
|
|
67
|
+
return [row.title, row.status, row.owner].filter(Boolean).join('. ');
|
|
68
|
+
case 'note':
|
|
69
|
+
return row.content;
|
|
70
|
+
default:
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// ------------------------------------------------------------- resolution
|
|
75
|
+
// Accept either an id or an exact (case-insensitive) name. We deliberately
|
|
76
|
+
// do NOT auto-create on miss: writes against unknown entities are refused so
|
|
77
|
+
// the model must create them explicitly (and that creation is audited).
|
|
78
|
+
resolveProject(idOrName) {
|
|
79
|
+
if (!idOrName)
|
|
80
|
+
return null;
|
|
81
|
+
const byId = this.db.prepare(`SELECT * FROM projects WHERE id = ?`).get(idOrName);
|
|
82
|
+
if (byId)
|
|
83
|
+
return parseRow(byId, ['goals', 'constraints', 'owners']);
|
|
84
|
+
const byName = this.db
|
|
85
|
+
.prepare(`SELECT * FROM projects WHERE lower(name) = lower(?)`)
|
|
86
|
+
.get(idOrName);
|
|
87
|
+
if (byName)
|
|
88
|
+
return parseRow(byName, ['goals', 'constraints', 'owners']);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
requireProjectId(idOrName) {
|
|
92
|
+
if (!idOrName)
|
|
93
|
+
return null;
|
|
94
|
+
const p = this.resolveProject(idOrName);
|
|
95
|
+
if (!p) {
|
|
96
|
+
throw new Error(`project "${idOrName}" not found — call list_projects to see existing projects or create_project to add it first`);
|
|
97
|
+
}
|
|
98
|
+
return p.id;
|
|
99
|
+
}
|
|
100
|
+
resolvePerson(idOrName) {
|
|
101
|
+
if (!idOrName)
|
|
102
|
+
return null;
|
|
103
|
+
return (this.db.prepare(`SELECT * FROM people WHERE id = ?`).get(idOrName) ??
|
|
104
|
+
this.db.prepare(`SELECT * FROM people WHERE lower(name) = lower(?)`).get(idOrName) ??
|
|
105
|
+
null);
|
|
106
|
+
}
|
|
107
|
+
resolveCompany(idOrName) {
|
|
108
|
+
if (!idOrName)
|
|
109
|
+
return null;
|
|
110
|
+
return (this.db.prepare(`SELECT * FROM companies WHERE id = ?`).get(idOrName) ??
|
|
111
|
+
this.db.prepare(`SELECT * FROM companies WHERE lower(name) = lower(?)`).get(idOrName) ??
|
|
112
|
+
null);
|
|
113
|
+
}
|
|
114
|
+
// ------------------------------------------------------------- entity writes
|
|
115
|
+
createProject(ctx, args) {
|
|
116
|
+
const existing = this.resolveProject(args.name);
|
|
117
|
+
if (existing)
|
|
118
|
+
throw new Error(`project "${args.name}" already exists (id ${existing.id})`);
|
|
119
|
+
const id = randomUUID();
|
|
120
|
+
const ts = now();
|
|
121
|
+
const row = {
|
|
122
|
+
id,
|
|
123
|
+
name: args.name,
|
|
124
|
+
description: args.description ?? null,
|
|
125
|
+
status: args.status ?? 'active',
|
|
126
|
+
goals: toJson(args.goals),
|
|
127
|
+
constraints: toJson(args.constraints),
|
|
128
|
+
owners: toJson(args.owners),
|
|
129
|
+
source_type: ctx.sourceType,
|
|
130
|
+
source_app: ctx.sourceApp,
|
|
131
|
+
source_ref: ctx.sourceRef,
|
|
132
|
+
created_at: ts,
|
|
133
|
+
updated_at: ts,
|
|
134
|
+
};
|
|
135
|
+
this.db
|
|
136
|
+
.prepare(`INSERT INTO projects (id, name, description, status, goals, constraints, owners,
|
|
137
|
+
source_type, source_app, source_ref, created_at, updated_at)
|
|
138
|
+
VALUES (@id, @name, @description, @status, @goals, @constraints, @owners,
|
|
139
|
+
@source_type, @source_app, @source_ref, @created_at, @updated_at)`)
|
|
140
|
+
.run(row);
|
|
141
|
+
this.ftsSet('project', id, this.ftsContent('project', row), id);
|
|
142
|
+
const auditId = this.audit(ctx, 'create', 'project', id, null, row);
|
|
143
|
+
return { project: parseRow(row, ['goals', 'constraints', 'owners']), audit_id: auditId };
|
|
144
|
+
}
|
|
145
|
+
addPerson(ctx, args) {
|
|
146
|
+
const company = args.company ? this.resolveCompany(args.company) : null;
|
|
147
|
+
const id = randomUUID();
|
|
148
|
+
const ts = now();
|
|
149
|
+
const row = {
|
|
150
|
+
id,
|
|
151
|
+
name: args.name,
|
|
152
|
+
role: args.role ?? null,
|
|
153
|
+
company_id: company?.id ?? null,
|
|
154
|
+
relationship_type: args.relationship_type ?? null,
|
|
155
|
+
communication_preferences: args.communication_preferences ?? null,
|
|
156
|
+
important_context: args.important_context ?? null,
|
|
157
|
+
last_interaction_at: null,
|
|
158
|
+
source_type: ctx.sourceType,
|
|
159
|
+
source_app: ctx.sourceApp,
|
|
160
|
+
source_ref: ctx.sourceRef,
|
|
161
|
+
created_at: ts,
|
|
162
|
+
updated_at: ts,
|
|
163
|
+
};
|
|
164
|
+
this.db
|
|
165
|
+
.prepare(`INSERT INTO people (id, name, role, company_id, relationship_type, communication_preferences,
|
|
166
|
+
important_context, last_interaction_at, source_type, source_app, source_ref,
|
|
167
|
+
created_at, updated_at)
|
|
168
|
+
VALUES (@id, @name, @role, @company_id, @relationship_type, @communication_preferences,
|
|
169
|
+
@important_context, @last_interaction_at, @source_type, @source_app, @source_ref,
|
|
170
|
+
@created_at, @updated_at)`)
|
|
171
|
+
.run(row);
|
|
172
|
+
this.ftsSet('person', id, this.ftsContent('person', row), null);
|
|
173
|
+
const auditId = this.audit(ctx, 'create', 'person', id, null, row);
|
|
174
|
+
return { person: row, audit_id: auditId };
|
|
175
|
+
}
|
|
176
|
+
addCompany(ctx, args) {
|
|
177
|
+
const id = randomUUID();
|
|
178
|
+
const ts = now();
|
|
179
|
+
const row = {
|
|
180
|
+
id,
|
|
181
|
+
name: args.name,
|
|
182
|
+
industry: args.industry ?? null,
|
|
183
|
+
stage: args.stage ?? null,
|
|
184
|
+
priority: args.priority ?? null,
|
|
185
|
+
notes: args.notes ?? null,
|
|
186
|
+
source_type: ctx.sourceType,
|
|
187
|
+
source_app: ctx.sourceApp,
|
|
188
|
+
source_ref: ctx.sourceRef,
|
|
189
|
+
created_at: ts,
|
|
190
|
+
updated_at: ts,
|
|
191
|
+
};
|
|
192
|
+
this.db
|
|
193
|
+
.prepare(`INSERT INTO companies (id, name, industry, stage, priority, notes,
|
|
194
|
+
source_type, source_app, source_ref, created_at, updated_at)
|
|
195
|
+
VALUES (@id, @name, @industry, @stage, @priority, @notes,
|
|
196
|
+
@source_type, @source_app, @source_ref, @created_at, @updated_at)`)
|
|
197
|
+
.run(row);
|
|
198
|
+
this.ftsSet('company', id, this.ftsContent('company', row), null);
|
|
199
|
+
const auditId = this.audit(ctx, 'create', 'company', id, null, row);
|
|
200
|
+
return { company: row, audit_id: auditId };
|
|
201
|
+
}
|
|
202
|
+
storeDecision(ctx, args) {
|
|
203
|
+
const projectId = this.requireProjectId(args.project_id);
|
|
204
|
+
const id = randomUUID();
|
|
205
|
+
const ts = now();
|
|
206
|
+
// Validate the supersede target before writing anything; the old row is
|
|
207
|
+
// marked only after the new row exists (superseded_by is a FK to it).
|
|
208
|
+
let oldDecision = null;
|
|
209
|
+
if (args.supersedes_id) {
|
|
210
|
+
oldDecision = this.db.prepare(`SELECT * FROM decisions WHERE id = ?`).get(args.supersedes_id);
|
|
211
|
+
if (!oldDecision)
|
|
212
|
+
throw new Error(`decision to supersede not found: ${args.supersedes_id}`);
|
|
213
|
+
}
|
|
214
|
+
const row = {
|
|
215
|
+
id,
|
|
216
|
+
title: args.title,
|
|
217
|
+
decision: args.decision,
|
|
218
|
+
rationale: args.rationale ?? null,
|
|
219
|
+
scope: args.scope ?? null,
|
|
220
|
+
project_id: projectId,
|
|
221
|
+
decided_by: args.decided_by ?? null,
|
|
222
|
+
decided_at: ts,
|
|
223
|
+
superseded_by: null,
|
|
224
|
+
confidence: args.confidence ?? 0.8,
|
|
225
|
+
source_type: ctx.sourceType,
|
|
226
|
+
source_app: ctx.sourceApp,
|
|
227
|
+
source_ref: ctx.sourceRef,
|
|
228
|
+
created_at: ts,
|
|
229
|
+
};
|
|
230
|
+
this.db
|
|
231
|
+
.prepare(`INSERT INTO decisions (id, title, decision, rationale, scope, project_id, decided_by, decided_at,
|
|
232
|
+
superseded_by, confidence, source_type, source_app, source_ref, created_at)
|
|
233
|
+
VALUES (@id, @title, @decision, @rationale, @scope, @project_id, @decided_by, @decided_at,
|
|
234
|
+
@superseded_by, @confidence, @source_type, @source_app, @source_ref, @created_at)`)
|
|
235
|
+
.run(row);
|
|
236
|
+
this.ftsSet('decision', id, this.ftsContent('decision', row), projectId);
|
|
237
|
+
if (oldDecision) {
|
|
238
|
+
this.db.prepare(`UPDATE decisions SET superseded_by = ? WHERE id = ?`).run(id, oldDecision.id);
|
|
239
|
+
this.ftsDelete('decision', oldDecision.id);
|
|
240
|
+
this.audit(ctx, 'supersede', 'decision', oldDecision.id, oldDecision, { ...oldDecision, superseded_by: id });
|
|
241
|
+
}
|
|
242
|
+
const auditId = this.audit(ctx, 'create', 'decision', id, null, row);
|
|
243
|
+
return { decision: row, audit_id: auditId };
|
|
244
|
+
}
|
|
245
|
+
logEvent(ctx, args) {
|
|
246
|
+
const projectId = this.requireProjectId(args.project_id);
|
|
247
|
+
const id = randomUUID();
|
|
248
|
+
const ts = now();
|
|
249
|
+
const row = {
|
|
250
|
+
id,
|
|
251
|
+
title: args.title ?? null,
|
|
252
|
+
summary: args.summary,
|
|
253
|
+
occurred_at: args.occurred_at ?? ts,
|
|
254
|
+
project_id: projectId,
|
|
255
|
+
person_ids: toJson(args.person_ids ?? []),
|
|
256
|
+
company_ids: toJson(args.company_ids ?? []),
|
|
257
|
+
durability: args.durability ?? 0.7,
|
|
258
|
+
source_type: ctx.sourceType,
|
|
259
|
+
source_app: ctx.sourceApp,
|
|
260
|
+
source_ref: ctx.sourceRef,
|
|
261
|
+
created_at: ts,
|
|
262
|
+
};
|
|
263
|
+
this.db
|
|
264
|
+
.prepare(`INSERT INTO events (id, title, summary, occurred_at, project_id, person_ids, company_ids,
|
|
265
|
+
durability, source_type, source_app, source_ref, created_at)
|
|
266
|
+
VALUES (@id, @title, @summary, @occurred_at, @project_id, @person_ids, @company_ids,
|
|
267
|
+
@durability, @source_type, @source_app, @source_ref, @created_at)`)
|
|
268
|
+
.run(row);
|
|
269
|
+
this.ftsSet('event', id, this.ftsContent('event', row), projectId);
|
|
270
|
+
const auditId = this.audit(ctx, 'create', 'event', id, null, row);
|
|
271
|
+
return { event: parseRow(row, ['person_ids', 'company_ids']), audit_id: auditId };
|
|
272
|
+
}
|
|
273
|
+
updateProjectState(ctx, projectRef, patch) {
|
|
274
|
+
const project = this.resolveProject(projectRef);
|
|
275
|
+
if (!project) {
|
|
276
|
+
throw new Error(`project "${projectRef}" not found — call create_project first`);
|
|
277
|
+
}
|
|
278
|
+
const pid = project.id;
|
|
279
|
+
const wsBefore = parseRow(this.db.prepare(`SELECT * FROM working_state WHERE project_id = ?`).get(pid), ['active_blockers', 'this_week_priorities', 'open_loops']);
|
|
280
|
+
const before = { project: { ...project }, working_state: wsBefore ?? null };
|
|
281
|
+
// Top-level project fields
|
|
282
|
+
const projectPatch = {};
|
|
283
|
+
if (patch.status !== undefined)
|
|
284
|
+
projectPatch.status = patch.status;
|
|
285
|
+
if (patch.goals !== undefined)
|
|
286
|
+
projectPatch.goals = toJson(patch.goals);
|
|
287
|
+
if (patch.constraints !== undefined)
|
|
288
|
+
projectPatch.constraints = toJson(patch.constraints);
|
|
289
|
+
if (Object.keys(projectPatch).length > 0) {
|
|
290
|
+
const sets = Object.keys(projectPatch).map((k) => `${k} = @${k}`).join(', ');
|
|
291
|
+
this.db
|
|
292
|
+
.prepare(`UPDATE projects SET ${sets}, updated_at = @updated_at WHERE id = @id`)
|
|
293
|
+
.run({ ...projectPatch, updated_at: now(), id: pid });
|
|
294
|
+
}
|
|
295
|
+
// Working state (semi-durable)
|
|
296
|
+
const ws = {
|
|
297
|
+
project_id: pid,
|
|
298
|
+
current_focus: patch.current_focus ?? wsBefore?.current_focus ?? null,
|
|
299
|
+
active_blockers: toJson(patch.active_blockers ?? wsBefore?.active_blockers ?? []),
|
|
300
|
+
this_week_priorities: toJson(patch.this_week_priorities ?? wsBefore?.this_week_priorities ?? []),
|
|
301
|
+
open_loops: toJson(patch.open_loops ?? wsBefore?.open_loops ?? []),
|
|
302
|
+
last_updated_at: now(),
|
|
303
|
+
};
|
|
304
|
+
this.db
|
|
305
|
+
.prepare(`INSERT INTO working_state (project_id, current_focus, active_blockers, this_week_priorities, open_loops, last_updated_at)
|
|
306
|
+
VALUES (@project_id, @current_focus, @active_blockers, @this_week_priorities, @open_loops, @last_updated_at)
|
|
307
|
+
ON CONFLICT(project_id) DO UPDATE SET
|
|
308
|
+
current_focus = excluded.current_focus,
|
|
309
|
+
active_blockers = excluded.active_blockers,
|
|
310
|
+
this_week_priorities = excluded.this_week_priorities,
|
|
311
|
+
open_loops = excluded.open_loops,
|
|
312
|
+
last_updated_at = excluded.last_updated_at`)
|
|
313
|
+
.run(ws);
|
|
314
|
+
const projectAfter = this.resolveProject(pid);
|
|
315
|
+
const wsAfter = parseRow(this.db.prepare(`SELECT * FROM working_state WHERE project_id = ?`).get(pid), ['active_blockers', 'this_week_priorities', 'open_loops']);
|
|
316
|
+
this.ftsSet('project', pid, this.ftsContent('project', {
|
|
317
|
+
...projectAfter,
|
|
318
|
+
goals: toJson(projectAfter.goals),
|
|
319
|
+
constraints: toJson(projectAfter.constraints),
|
|
320
|
+
}), pid);
|
|
321
|
+
const after = { project: projectAfter, working_state: wsAfter };
|
|
322
|
+
const auditId = this.audit(ctx, 'update', 'working_state', pid, before, after);
|
|
323
|
+
return { ...after, audit_id: auditId };
|
|
324
|
+
}
|
|
325
|
+
savePreference(ctx, args) {
|
|
326
|
+
let subjectId = null;
|
|
327
|
+
if (args.subject_type === 'project')
|
|
328
|
+
subjectId = this.requireProjectId(args.subject_id);
|
|
329
|
+
else if (args.subject_type === 'person')
|
|
330
|
+
subjectId = this.resolvePerson(args.subject_id)?.id ?? null;
|
|
331
|
+
else if (args.subject_type === 'company')
|
|
332
|
+
subjectId = this.resolveCompany(args.subject_id)?.id ?? null;
|
|
333
|
+
if (args.subject_type !== 'self' && args.subject_id && !subjectId) {
|
|
334
|
+
throw new Error(`${args.subject_type} "${args.subject_id}" not found`);
|
|
335
|
+
}
|
|
336
|
+
const id = randomUUID();
|
|
337
|
+
const ts = now();
|
|
338
|
+
let oldPref = null;
|
|
339
|
+
if (args.supersedes_id) {
|
|
340
|
+
oldPref = this.db.prepare(`SELECT * FROM preferences WHERE id = ?`).get(args.supersedes_id);
|
|
341
|
+
if (!oldPref)
|
|
342
|
+
throw new Error(`preference to supersede not found: ${args.supersedes_id}`);
|
|
343
|
+
}
|
|
344
|
+
const row = {
|
|
345
|
+
id,
|
|
346
|
+
subject_type: args.subject_type,
|
|
347
|
+
subject_id: subjectId,
|
|
348
|
+
preference_type: args.preference_type ?? null,
|
|
349
|
+
value: args.value,
|
|
350
|
+
strength: args.strength ?? 0.8,
|
|
351
|
+
last_verified_at: ts,
|
|
352
|
+
superseded_by: null,
|
|
353
|
+
source_type: ctx.sourceType,
|
|
354
|
+
source_app: ctx.sourceApp,
|
|
355
|
+
source_ref: ctx.sourceRef,
|
|
356
|
+
created_at: ts,
|
|
357
|
+
};
|
|
358
|
+
this.db
|
|
359
|
+
.prepare(`INSERT INTO preferences (id, subject_type, subject_id, preference_type, value, strength,
|
|
360
|
+
last_verified_at, superseded_by, source_type, source_app, source_ref, created_at)
|
|
361
|
+
VALUES (@id, @subject_type, @subject_id, @preference_type, @value, @strength,
|
|
362
|
+
@last_verified_at, @superseded_by, @source_type, @source_app, @source_ref, @created_at)`)
|
|
363
|
+
.run(row);
|
|
364
|
+
const projectScope = args.subject_type === 'project' ? subjectId : null;
|
|
365
|
+
this.ftsSet('preference', id, this.ftsContent('preference', row), projectScope);
|
|
366
|
+
if (oldPref) {
|
|
367
|
+
this.db.prepare(`UPDATE preferences SET superseded_by = ? WHERE id = ?`).run(id, oldPref.id);
|
|
368
|
+
this.ftsDelete('preference', oldPref.id);
|
|
369
|
+
this.audit(ctx, 'supersede', 'preference', oldPref.id, oldPref, { ...oldPref, superseded_by: id });
|
|
370
|
+
}
|
|
371
|
+
const auditId = this.audit(ctx, 'create', 'preference', id, null, row);
|
|
372
|
+
return { preference: row, audit_id: auditId };
|
|
373
|
+
}
|
|
374
|
+
setNextStep(ctx, args) {
|
|
375
|
+
const projectId = this.requireProjectId(args.project_id);
|
|
376
|
+
const id = randomUUID();
|
|
377
|
+
const ts = now();
|
|
378
|
+
const row = {
|
|
379
|
+
id,
|
|
380
|
+
title: args.title,
|
|
381
|
+
status: args.status ?? 'open',
|
|
382
|
+
owner: args.owner ?? null,
|
|
383
|
+
due_at: args.due_at ?? null,
|
|
384
|
+
project_id: projectId,
|
|
385
|
+
blocked_by: args.blocked_by ?? null,
|
|
386
|
+
completed_at: null,
|
|
387
|
+
source_type: ctx.sourceType,
|
|
388
|
+
source_app: ctx.sourceApp,
|
|
389
|
+
source_ref: ctx.sourceRef,
|
|
390
|
+
created_at: ts,
|
|
391
|
+
updated_at: ts,
|
|
392
|
+
};
|
|
393
|
+
this.db
|
|
394
|
+
.prepare(`INSERT INTO tasks (id, title, status, owner, due_at, project_id, blocked_by, completed_at,
|
|
395
|
+
source_type, source_app, source_ref, created_at, updated_at)
|
|
396
|
+
VALUES (@id, @title, @status, @owner, @due_at, @project_id, @blocked_by, @completed_at,
|
|
397
|
+
@source_type, @source_app, @source_ref, @created_at, @updated_at)`)
|
|
398
|
+
.run(row);
|
|
399
|
+
this.ftsSet('task', id, this.ftsContent('task', row), projectId);
|
|
400
|
+
const auditId = this.audit(ctx, 'create', 'task', id, null, row);
|
|
401
|
+
return { task: row, audit_id: auditId };
|
|
402
|
+
}
|
|
403
|
+
markDone(ctx, taskId) {
|
|
404
|
+
const before = this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(taskId);
|
|
405
|
+
if (!before)
|
|
406
|
+
throw new Error(`task not found: ${taskId}`);
|
|
407
|
+
const ts = now();
|
|
408
|
+
this.db
|
|
409
|
+
.prepare(`UPDATE tasks SET status = 'done', completed_at = ?, updated_at = ? WHERE id = ?`)
|
|
410
|
+
.run(ts, ts, taskId);
|
|
411
|
+
const after = this.db.prepare(`SELECT * FROM tasks WHERE id = ?`).get(taskId);
|
|
412
|
+
this.ftsSet('task', taskId, this.ftsContent('task', after), after.project_id);
|
|
413
|
+
const auditId = this.audit(ctx, 'update', 'task', taskId, before, after);
|
|
414
|
+
return { task: after, audit_id: auditId };
|
|
415
|
+
}
|
|
416
|
+
appendNote(ctx, args) {
|
|
417
|
+
const projectId = this.requireProjectId(args.project_id);
|
|
418
|
+
const id = randomUUID();
|
|
419
|
+
const row = {
|
|
420
|
+
id,
|
|
421
|
+
content: args.content,
|
|
422
|
+
project_id: projectId,
|
|
423
|
+
tags: toJson(args.tags ?? []),
|
|
424
|
+
source_type: ctx.sourceType,
|
|
425
|
+
source_app: ctx.sourceApp,
|
|
426
|
+
source_ref: ctx.sourceRef,
|
|
427
|
+
created_at: now(),
|
|
428
|
+
};
|
|
429
|
+
this.db
|
|
430
|
+
.prepare(`INSERT INTO notes (id, content, project_id, tags, source_type, source_app, source_ref, created_at)
|
|
431
|
+
VALUES (@id, @content, @project_id, @tags, @source_type, @source_app, @source_ref, @created_at)`)
|
|
432
|
+
.run(row);
|
|
433
|
+
this.ftsSet('note', id, row.content, projectId);
|
|
434
|
+
const auditId = this.audit(ctx, 'create', 'note', id, null, row);
|
|
435
|
+
return { note: parseRow(row, ['tags']), audit_id: auditId };
|
|
436
|
+
}
|
|
437
|
+
// ------------------------------------------------------------- reads
|
|
438
|
+
listProjects() {
|
|
439
|
+
return this.db
|
|
440
|
+
.prepare(`SELECT id, name, description, status, updated_at FROM projects ORDER BY updated_at DESC`)
|
|
441
|
+
.all();
|
|
442
|
+
}
|
|
443
|
+
listPeople() {
|
|
444
|
+
return this.db
|
|
445
|
+
.prepare(`SELECT p.id, p.name, p.role, c.name AS company, p.relationship_type
|
|
446
|
+
FROM people p LEFT JOIN companies c ON c.id = p.company_id
|
|
447
|
+
ORDER BY p.updated_at DESC`)
|
|
448
|
+
.all();
|
|
449
|
+
}
|
|
450
|
+
listCompanies() {
|
|
451
|
+
return this.db
|
|
452
|
+
.prepare(`SELECT id, name, industry, stage, priority FROM companies ORDER BY updated_at DESC`)
|
|
453
|
+
.all();
|
|
454
|
+
}
|
|
455
|
+
// The caller is an LLM: it can retry with synonyms, so BM25 keyword search
|
|
456
|
+
// over typed content covers the retrieval need without embeddings.
|
|
457
|
+
searchMemories(query, projectRef, limit = 10) {
|
|
458
|
+
const terms = query.match(/[A-Za-z0-9_]+/g);
|
|
459
|
+
if (!terms?.length)
|
|
460
|
+
return [];
|
|
461
|
+
const match = terms.map((t) => `"${t}"`).join(' OR ');
|
|
462
|
+
const projectId = projectRef ? this.requireProjectId(projectRef) : null;
|
|
463
|
+
const sql = projectId
|
|
464
|
+
? `SELECT kind, ref_id, content, bm25(memory_fts) AS rank FROM memory_fts
|
|
465
|
+
WHERE memory_fts MATCH ? AND project_id = ? ORDER BY rank LIMIT ?`
|
|
466
|
+
: `SELECT kind, ref_id, content, bm25(memory_fts) AS rank FROM memory_fts
|
|
467
|
+
WHERE memory_fts MATCH ? ORDER BY rank LIMIT ?`;
|
|
468
|
+
const rows = projectId
|
|
469
|
+
? this.db.prepare(sql).all(match, projectId, Math.min(limit, 50))
|
|
470
|
+
: this.db.prepare(sql).all(match, Math.min(limit, 50));
|
|
471
|
+
return rows.map((r) => ({ kind: r.kind, id: r.ref_id, content: r.content }));
|
|
472
|
+
}
|
|
473
|
+
getProjectContext(ref) {
|
|
474
|
+
const project = this.resolveProject(ref);
|
|
475
|
+
if (!project)
|
|
476
|
+
throw new Error(`project "${ref}" not found — call list_projects to see what exists`);
|
|
477
|
+
const pid = project.id;
|
|
478
|
+
return {
|
|
479
|
+
project,
|
|
480
|
+
working_state: parseRow(this.db.prepare(`SELECT * FROM working_state WHERE project_id = ?`).get(pid), ['active_blockers', 'this_week_priorities', 'open_loops']) ?? null,
|
|
481
|
+
recent_decisions: this.recentDecisions(pid, 10),
|
|
482
|
+
open_tasks: this.db
|
|
483
|
+
.prepare(`SELECT * FROM tasks WHERE project_id = ? AND status != 'done' AND status != 'cancelled' ORDER BY created_at DESC`)
|
|
484
|
+
.all(pid),
|
|
485
|
+
recent_events: this.db
|
|
486
|
+
.prepare(`SELECT * FROM events WHERE project_id = ? ORDER BY occurred_at DESC LIMIT 10`)
|
|
487
|
+
.all(pid).map((e) => parseRow(e, ['person_ids', 'company_ids'])),
|
|
488
|
+
preferences: this.db
|
|
489
|
+
.prepare(`SELECT * FROM preferences WHERE subject_type = 'project' AND subject_id = ? AND superseded_by IS NULL`)
|
|
490
|
+
.all(pid),
|
|
491
|
+
recent_notes: this.db
|
|
492
|
+
.prepare(`SELECT * FROM notes WHERE project_id = ? ORDER BY created_at DESC LIMIT 5`)
|
|
493
|
+
.all(pid).map((n) => parseRow(n, ['tags'])),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
getPersonContext(ref) {
|
|
497
|
+
const person = this.resolvePerson(ref);
|
|
498
|
+
if (!person)
|
|
499
|
+
throw new Error(`person "${ref}" not found — call list_people to see what exists`);
|
|
500
|
+
const company = person.company_id
|
|
501
|
+
? this.db.prepare(`SELECT id, name, industry FROM companies WHERE id = ?`).get(person.company_id)
|
|
502
|
+
: null;
|
|
503
|
+
return {
|
|
504
|
+
person,
|
|
505
|
+
company,
|
|
506
|
+
preferences: this.db
|
|
507
|
+
.prepare(`SELECT * FROM preferences WHERE subject_type = 'person' AND subject_id = ? AND superseded_by IS NULL`)
|
|
508
|
+
.all(person.id),
|
|
509
|
+
recent_events: this.db
|
|
510
|
+
.prepare(`SELECT * FROM events WHERE person_ids LIKE ? ORDER BY occurred_at DESC LIMIT 10`)
|
|
511
|
+
.all(`%"${person.id}"%`).map((e) => parseRow(e, ['person_ids', 'company_ids'])),
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
getCompanyContext(ref) {
|
|
515
|
+
const company = this.resolveCompany(ref);
|
|
516
|
+
if (!company)
|
|
517
|
+
throw new Error(`company "${ref}" not found — call list_companies to see what exists`);
|
|
518
|
+
return {
|
|
519
|
+
company,
|
|
520
|
+
people: this.db.prepare(`SELECT id, name, role FROM people WHERE company_id = ?`).all(company.id),
|
|
521
|
+
preferences: this.db
|
|
522
|
+
.prepare(`SELECT * FROM preferences WHERE subject_type = 'company' AND subject_id = ? AND superseded_by IS NULL`)
|
|
523
|
+
.all(company.id),
|
|
524
|
+
recent_events: this.db
|
|
525
|
+
.prepare(`SELECT * FROM events WHERE company_ids LIKE ? ORDER BY occurred_at DESC LIMIT 10`)
|
|
526
|
+
.all(`%"${company.id}"%`).map((e) => parseRow(e, ['person_ids', 'company_ids'])),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
recentDecisions(projectRef, limit = 20) {
|
|
530
|
+
const projectId = projectRef ? this.requireProjectId(projectRef) : null;
|
|
531
|
+
const sql = projectId
|
|
532
|
+
? `SELECT * FROM decisions WHERE project_id = ? AND superseded_by IS NULL ORDER BY created_at DESC LIMIT ?`
|
|
533
|
+
: `SELECT * FROM decisions WHERE superseded_by IS NULL ORDER BY created_at DESC LIMIT ?`;
|
|
534
|
+
return projectId
|
|
535
|
+
? this.db.prepare(sql).all(projectId, Math.min(limit, 100))
|
|
536
|
+
: this.db.prepare(sql).all(Math.min(limit, 100));
|
|
537
|
+
}
|
|
538
|
+
openLoops(projectRef) {
|
|
539
|
+
const projectId = projectRef ? this.requireProjectId(projectRef) : null;
|
|
540
|
+
const taskSql = projectId
|
|
541
|
+
? `SELECT t.*, p.name AS project_name FROM tasks t LEFT JOIN projects p ON p.id = t.project_id
|
|
542
|
+
WHERE t.status IN ('open','in_progress','blocked') AND t.project_id = ? ORDER BY t.created_at DESC`
|
|
543
|
+
: `SELECT t.*, p.name AS project_name FROM tasks t LEFT JOIN projects p ON p.id = t.project_id
|
|
544
|
+
WHERE t.status IN ('open','in_progress','blocked') ORDER BY t.created_at DESC`;
|
|
545
|
+
const tasks = projectId ? this.db.prepare(taskSql).all(projectId) : this.db.prepare(taskSql).all();
|
|
546
|
+
const wsSql = projectId
|
|
547
|
+
? `SELECT w.*, p.name AS project_name FROM working_state w LEFT JOIN projects p ON p.id = w.project_id WHERE w.project_id = ?`
|
|
548
|
+
: `SELECT w.*, p.name AS project_name FROM working_state w LEFT JOIN projects p ON p.id = w.project_id`;
|
|
549
|
+
const states = (projectId ? this.db.prepare(wsSql).all(projectId) : this.db.prepare(wsSql).all());
|
|
550
|
+
return {
|
|
551
|
+
open_tasks: tasks,
|
|
552
|
+
working_state: states
|
|
553
|
+
.map((s) => parseRow(s, ['active_blockers', 'this_week_priorities', 'open_loops']))
|
|
554
|
+
.filter((s) => s.open_loops?.length || s.active_blockers?.length || s.current_focus),
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
prepareTaskBrief(args) {
|
|
558
|
+
const brief = { task: args.task };
|
|
559
|
+
if (args.project_id)
|
|
560
|
+
brief.project = this.getProjectContext(args.project_id);
|
|
561
|
+
if (args.person_id)
|
|
562
|
+
brief.person = this.getPersonContext(args.person_id);
|
|
563
|
+
brief.self_preferences = this.db
|
|
564
|
+
.prepare(`SELECT * FROM preferences WHERE subject_type = 'self' AND superseded_by IS NULL`)
|
|
565
|
+
.all();
|
|
566
|
+
brief.related_memories = this.searchMemories(args.task, args.project_id ?? null, 8);
|
|
567
|
+
if (!args.project_id)
|
|
568
|
+
brief.open_loops = this.openLoops(null);
|
|
569
|
+
return brief;
|
|
570
|
+
}
|
|
571
|
+
// ------------------------------------------------------------- audit + revert
|
|
572
|
+
recentWrites(limit = 50, offset = 0) {
|
|
573
|
+
const rows = this.db
|
|
574
|
+
.prepare(`SELECT * FROM audit_log ORDER BY created_at DESC LIMIT ? OFFSET ?`)
|
|
575
|
+
.all(Math.min(limit, 200), offset);
|
|
576
|
+
return rows.map((r) => ({
|
|
577
|
+
...r,
|
|
578
|
+
before_state: r.before_state ? fromJson(r.before_state) : null,
|
|
579
|
+
after_state: r.after_state ? fromJson(r.after_state) : null,
|
|
580
|
+
reverted: !!r.reverted,
|
|
581
|
+
}));
|
|
582
|
+
}
|
|
583
|
+
// Soft-revert: the audit row is marked, never deleted, and the revert itself
|
|
584
|
+
// is a new audit row. create → remove the row; update → restore before_state;
|
|
585
|
+
// supersede → re-activate the older row.
|
|
586
|
+
revertWrite(ctx, auditId) {
|
|
587
|
+
const audit = this.db.prepare(`SELECT * FROM audit_log WHERE id = ?`).get(auditId);
|
|
588
|
+
if (!audit)
|
|
589
|
+
throw new Error(`audit entry not found: ${auditId}`);
|
|
590
|
+
if (audit.reverted)
|
|
591
|
+
throw new Error(`already reverted: ${auditId}`);
|
|
592
|
+
if (audit.action === 'revert')
|
|
593
|
+
throw new Error(`cannot revert a revert entry`);
|
|
594
|
+
const kind = audit.entity_kind;
|
|
595
|
+
const id = audit.entity_id;
|
|
596
|
+
const before = audit.before_state ? fromJson(audit.before_state) : null;
|
|
597
|
+
const after = audit.after_state ? fromJson(audit.after_state) : null;
|
|
598
|
+
if (audit.action === 'create') {
|
|
599
|
+
const table = TABLE[kind];
|
|
600
|
+
if (table) {
|
|
601
|
+
this.db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
|
|
602
|
+
this.ftsDelete(kind, id);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
else if (audit.action === 'supersede') {
|
|
606
|
+
const table = TABLE[kind];
|
|
607
|
+
if (table && (kind === 'decision' || kind === 'preference')) {
|
|
608
|
+
this.db.prepare(`UPDATE ${table} SET superseded_by = NULL WHERE id = ?`).run(id);
|
|
609
|
+
const row = this.db.prepare(`SELECT * FROM ${table} WHERE id = ?`).get(id);
|
|
610
|
+
if (row) {
|
|
611
|
+
this.ftsSet(kind, id, this.ftsContent(kind, row), row.project_id ?? null);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
else if (audit.action === 'update' && before) {
|
|
616
|
+
if (kind === 'task') {
|
|
617
|
+
this.db
|
|
618
|
+
.prepare(`UPDATE tasks SET title = @title, status = @status, owner = @owner, due_at = @due_at,
|
|
619
|
+
project_id = @project_id, blocked_by = @blocked_by, completed_at = @completed_at,
|
|
620
|
+
updated_at = @updated_at WHERE id = @id`)
|
|
621
|
+
.run({ ...before, updated_at: now(), id });
|
|
622
|
+
this.ftsSet('task', id, this.ftsContent('task', before), before.project_id ?? null);
|
|
623
|
+
}
|
|
624
|
+
else if (kind === 'working_state') {
|
|
625
|
+
const p = before.project;
|
|
626
|
+
if (p) {
|
|
627
|
+
this.db
|
|
628
|
+
.prepare(`UPDATE projects SET status = ?, goals = ?, constraints = ?, updated_at = ? WHERE id = ?`)
|
|
629
|
+
.run(p.status, toJson(p.goals), toJson(p.constraints), now(), p.id);
|
|
630
|
+
}
|
|
631
|
+
const ws = before.working_state;
|
|
632
|
+
if (ws) {
|
|
633
|
+
this.db
|
|
634
|
+
.prepare(`INSERT INTO working_state (project_id, current_focus, active_blockers, this_week_priorities, open_loops, last_updated_at)
|
|
635
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
636
|
+
ON CONFLICT(project_id) DO UPDATE SET
|
|
637
|
+
current_focus = excluded.current_focus,
|
|
638
|
+
active_blockers = excluded.active_blockers,
|
|
639
|
+
this_week_priorities = excluded.this_week_priorities,
|
|
640
|
+
open_loops = excluded.open_loops,
|
|
641
|
+
last_updated_at = excluded.last_updated_at`)
|
|
642
|
+
.run(id, ws.current_focus, toJson(ws.active_blockers), toJson(ws.this_week_priorities), toJson(ws.open_loops), now());
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
this.db.prepare(`DELETE FROM working_state WHERE project_id = ?`).run(id);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
this.db.prepare(`UPDATE audit_log SET reverted = 1 WHERE id = ?`).run(auditId);
|
|
650
|
+
const revertAuditId = this.audit(ctx, 'revert', kind, id, after, before);
|
|
651
|
+
return { reverted: true, audit_id: auditId, revert_audit_id: revertAuditId };
|
|
652
|
+
}
|
|
653
|
+
}
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
const j = (type, extra = {}) => ({ type, ...extra });
|
|
2
|
+
const strArray = { type: 'array', items: { type: 'string' } };
|
|
3
|
+
export function buildTools(store) {
|
|
4
|
+
return [
|
|
5
|
+
// ---------------------------------------------------------------- read
|
|
6
|
+
{
|
|
7
|
+
name: 'search_memories',
|
|
8
|
+
description: 'Keyword search (BM25) across all stored memory: decisions, events, preferences, projects, people, companies, tasks, notes. If a query misses, retry with synonyms or related terms. Returns typed entries with their ids.',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
query: j('string', { description: 'Search terms.' }),
|
|
13
|
+
project_id: j('string', { description: 'Optional project id or name to scope results.' }),
|
|
14
|
+
limit: j('number', { description: 'Max results (default 10).' }),
|
|
15
|
+
},
|
|
16
|
+
required: ['query'],
|
|
17
|
+
},
|
|
18
|
+
handler: (args) => ({ results: store.searchMemories(args.query, args.project_id ?? null, args.limit ?? 10) }),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: 'list_projects',
|
|
22
|
+
description: 'List all projects with id, name, status. Call this first when unsure which project something belongs to.',
|
|
23
|
+
inputSchema: { type: 'object', properties: {} },
|
|
24
|
+
handler: () => ({ projects: store.listProjects() }),
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'list_people',
|
|
28
|
+
description: 'List all known people with id, name, role, company.',
|
|
29
|
+
inputSchema: { type: 'object', properties: {} },
|
|
30
|
+
handler: () => ({ people: store.listPeople() }),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'list_companies',
|
|
34
|
+
description: 'List all known companies with id, name, industry.',
|
|
35
|
+
inputSchema: { type: 'object', properties: {} },
|
|
36
|
+
handler: () => ({ companies: store.listCompanies() }),
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'get_project_context',
|
|
40
|
+
description: 'Get full context for a project (by id or name): details, working state, recent decisions, open tasks, recent events, preferences, notes. Call before starting work related to the project.',
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: { project_id: j('string', { description: 'Project id or exact name.' }) },
|
|
44
|
+
required: ['project_id'],
|
|
45
|
+
},
|
|
46
|
+
handler: (args) => store.getProjectContext(args.project_id),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'get_person_context',
|
|
50
|
+
description: 'Get full context for a person (by id or name): profile, preferences, recent events.',
|
|
51
|
+
inputSchema: {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: { person_id: j('string', { description: 'Person id or exact name.' }) },
|
|
54
|
+
required: ['person_id'],
|
|
55
|
+
},
|
|
56
|
+
handler: (args) => store.getPersonContext(args.person_id),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'get_company_context',
|
|
60
|
+
description: 'Get full context for a company (by id or name): profile, people, preferences, recent events.',
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: { company_id: j('string', { description: 'Company id or exact name.' }) },
|
|
64
|
+
required: ['company_id'],
|
|
65
|
+
},
|
|
66
|
+
handler: (args) => store.getCompanyContext(args.company_id),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'get_recent_decisions',
|
|
70
|
+
description: 'Most recent non-superseded decisions, optionally scoped by project (id or name).',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
74
|
+
project_id: j('string'),
|
|
75
|
+
limit: j('number', { description: 'Default 20.' }),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
handler: (args) => ({ decisions: store.recentDecisions(args.project_id ?? null, args.limit ?? 20) }),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: 'get_open_loops',
|
|
82
|
+
description: 'Open tasks plus per-project open loops and active blockers from working state.',
|
|
83
|
+
inputSchema: {
|
|
84
|
+
type: 'object',
|
|
85
|
+
properties: { project_id: j('string') },
|
|
86
|
+
},
|
|
87
|
+
handler: (args) => store.openLoops(args.project_id ?? null),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
name: 'prepare_task_brief',
|
|
91
|
+
description: 'Build a context packet for a task — project state, decisions, open loops, related memories. Call this before doing real work so you start with full context.',
|
|
92
|
+
inputSchema: {
|
|
93
|
+
type: 'object',
|
|
94
|
+
properties: {
|
|
95
|
+
task: j('string', { description: 'What you are about to do.' }),
|
|
96
|
+
project_id: j('string'),
|
|
97
|
+
person_id: j('string'),
|
|
98
|
+
},
|
|
99
|
+
required: ['task'],
|
|
100
|
+
},
|
|
101
|
+
handler: (args) => store.prepareTaskBrief({ task: args.task, project_id: args.project_id ?? null, person_id: args.person_id ?? null }),
|
|
102
|
+
},
|
|
103
|
+
// ---------------------------------------------------------------- write
|
|
104
|
+
{
|
|
105
|
+
name: 'create_project',
|
|
106
|
+
description: 'Create a project — the top-level scope for decisions, tasks, events, and working state.',
|
|
107
|
+
inputSchema: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
properties: {
|
|
110
|
+
name: j('string'),
|
|
111
|
+
description: j('string'),
|
|
112
|
+
status: j('string'),
|
|
113
|
+
goals: strArray,
|
|
114
|
+
constraints: strArray,
|
|
115
|
+
owners: strArray,
|
|
116
|
+
},
|
|
117
|
+
required: ['name'],
|
|
118
|
+
},
|
|
119
|
+
handler: (args, ctx) => store.createProject(ctx, args),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'add_person',
|
|
123
|
+
description: 'Add a person worth remembering (colleague, client, stakeholder).',
|
|
124
|
+
inputSchema: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: {
|
|
127
|
+
name: j('string'),
|
|
128
|
+
role: j('string'),
|
|
129
|
+
company: j('string', { description: 'Company id or name (must exist).' }),
|
|
130
|
+
relationship_type: j('string'),
|
|
131
|
+
communication_preferences: j('string'),
|
|
132
|
+
important_context: j('string'),
|
|
133
|
+
},
|
|
134
|
+
required: ['name'],
|
|
135
|
+
},
|
|
136
|
+
handler: (args, ctx) => store.addPerson(ctx, args),
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'add_company',
|
|
140
|
+
description: 'Add a company (client, partner, account).',
|
|
141
|
+
inputSchema: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: {
|
|
144
|
+
name: j('string'),
|
|
145
|
+
industry: j('string'),
|
|
146
|
+
stage: j('string'),
|
|
147
|
+
priority: j('string'),
|
|
148
|
+
notes: j('string'),
|
|
149
|
+
},
|
|
150
|
+
required: ['name'],
|
|
151
|
+
},
|
|
152
|
+
handler: (args, ctx) => store.addCompany(ctx, args),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: 'store_decision',
|
|
156
|
+
description: 'Persist a durable decision. Use when a real choice was made — direction, plan, trade-off — not for speculation or drafts. Pass supersedes_id when this replaces an earlier decision.',
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: 'object',
|
|
159
|
+
properties: {
|
|
160
|
+
title: j('string'),
|
|
161
|
+
decision: j('string', { description: 'Concise statement of what was decided.' }),
|
|
162
|
+
rationale: j('string'),
|
|
163
|
+
scope: j('string'),
|
|
164
|
+
project_id: j('string', { description: 'Project id or name (must exist).' }),
|
|
165
|
+
confidence: j('number', { description: '0..1 confidence in the decision.' }),
|
|
166
|
+
supersedes_id: j('string', { description: 'Id of an earlier decision this replaces.' }),
|
|
167
|
+
},
|
|
168
|
+
required: ['title', 'decision'],
|
|
169
|
+
},
|
|
170
|
+
handler: (args, ctx) => store.storeDecision(ctx, args),
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
name: 'log_event',
|
|
174
|
+
description: 'Log a durable event (meeting outcome, milestone, signal). Do not log exploratory chatter.',
|
|
175
|
+
inputSchema: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: {
|
|
178
|
+
summary: j('string'),
|
|
179
|
+
title: j('string'),
|
|
180
|
+
occurred_at: j('string', { description: 'ISO timestamp; defaults to now.' }),
|
|
181
|
+
project_id: j('string'),
|
|
182
|
+
person_ids: strArray,
|
|
183
|
+
company_ids: strArray,
|
|
184
|
+
durability: j('number', { description: '0..1, how long this stays relevant.' }),
|
|
185
|
+
},
|
|
186
|
+
required: ['summary'],
|
|
187
|
+
},
|
|
188
|
+
handler: (args, ctx) => store.logEvent(ctx, args),
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
name: 'update_project_state',
|
|
192
|
+
description: 'Update working state for a project: current_focus, active_blockers, this_week_priorities, open_loops. Can also update top-level status/goals/constraints.',
|
|
193
|
+
inputSchema: {
|
|
194
|
+
type: 'object',
|
|
195
|
+
properties: {
|
|
196
|
+
project_id: j('string', { description: 'Project id or name.' }),
|
|
197
|
+
status: j('string'),
|
|
198
|
+
current_focus: j('string'),
|
|
199
|
+
active_blockers: strArray,
|
|
200
|
+
this_week_priorities: strArray,
|
|
201
|
+
open_loops: strArray,
|
|
202
|
+
goals: strArray,
|
|
203
|
+
constraints: strArray,
|
|
204
|
+
},
|
|
205
|
+
required: ['project_id'],
|
|
206
|
+
},
|
|
207
|
+
handler: (args, ctx) => {
|
|
208
|
+
const { project_id, ...patch } = args;
|
|
209
|
+
return store.updateProjectState(ctx, project_id, patch);
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
name: 'save_preference',
|
|
214
|
+
description: 'Record a learned preference about a person, company, project, or self (e.g. "prefers concise updates"). Pass supersedes_id when this replaces an earlier preference.',
|
|
215
|
+
inputSchema: {
|
|
216
|
+
type: 'object',
|
|
217
|
+
properties: {
|
|
218
|
+
subject_type: j('string', { enum: ['person', 'company', 'project', 'self'] }),
|
|
219
|
+
subject_id: j('string', { description: 'Id or name of the subject; omit for self.' }),
|
|
220
|
+
preference_type: j('string'),
|
|
221
|
+
value: j('string'),
|
|
222
|
+
strength: j('number'),
|
|
223
|
+
supersedes_id: j('string'),
|
|
224
|
+
},
|
|
225
|
+
required: ['subject_type', 'value'],
|
|
226
|
+
},
|
|
227
|
+
handler: (args, ctx) => store.savePreference(ctx, args),
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
name: 'set_next_step',
|
|
231
|
+
description: 'Create a next step / task. Use when a concrete next action becomes clear.',
|
|
232
|
+
inputSchema: {
|
|
233
|
+
type: 'object',
|
|
234
|
+
properties: {
|
|
235
|
+
title: j('string'),
|
|
236
|
+
status: j('string', { enum: ['open', 'in_progress', 'blocked'] }),
|
|
237
|
+
owner: j('string'),
|
|
238
|
+
due_at: j('string'),
|
|
239
|
+
project_id: j('string'),
|
|
240
|
+
blocked_by: j('string', { description: 'Id of a blocking task.' }),
|
|
241
|
+
},
|
|
242
|
+
required: ['title'],
|
|
243
|
+
},
|
|
244
|
+
handler: (args, ctx) => store.setNextStep(ctx, args),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'mark_done',
|
|
248
|
+
description: 'Mark an existing task as done.',
|
|
249
|
+
inputSchema: {
|
|
250
|
+
type: 'object',
|
|
251
|
+
properties: { task_id: j('string') },
|
|
252
|
+
required: ['task_id'],
|
|
253
|
+
},
|
|
254
|
+
handler: (args, ctx) => store.markDone(ctx, args.task_id),
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: 'append_note',
|
|
258
|
+
description: 'Free-form fallback note. Prefer the typed tools (store_decision, log_event, save_preference) when one fits.',
|
|
259
|
+
inputSchema: {
|
|
260
|
+
type: 'object',
|
|
261
|
+
properties: {
|
|
262
|
+
content: j('string'),
|
|
263
|
+
project_id: j('string'),
|
|
264
|
+
tags: strArray,
|
|
265
|
+
},
|
|
266
|
+
required: ['content'],
|
|
267
|
+
},
|
|
268
|
+
handler: (args, ctx) => store.appendNote(ctx, args),
|
|
269
|
+
},
|
|
270
|
+
// ---------------------------------------------------------------- audit
|
|
271
|
+
{
|
|
272
|
+
name: 'list_recent_writes',
|
|
273
|
+
description: 'List recent memory writes with before/after state, source app, and revert status. This is the audit trail — use it to show the user what has been remembered.',
|
|
274
|
+
inputSchema: {
|
|
275
|
+
type: 'object',
|
|
276
|
+
properties: {
|
|
277
|
+
limit: j('number', { description: 'Default 50, max 200.' }),
|
|
278
|
+
offset: j('number'),
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
handler: (args) => ({ writes: store.recentWrites(args.limit ?? 50, args.offset ?? 0) }),
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: 'revert_write',
|
|
285
|
+
description: 'Undo a prior memory write by its audit_id (from list_recent_writes or any write result). Soft-revert: the audit entry is preserved and the revert itself is audited.',
|
|
286
|
+
inputSchema: {
|
|
287
|
+
type: 'object',
|
|
288
|
+
properties: { audit_id: j('string') },
|
|
289
|
+
required: ['audit_id'],
|
|
290
|
+
},
|
|
291
|
+
handler: (args, ctx) => store.revertWrite(ctx, args.audit_id),
|
|
292
|
+
},
|
|
293
|
+
];
|
|
294
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "smaran",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Smaran (Sanskrit: remembrance) — local-first professional memory for AI agents over MCP. Typed writes, append-only audit log, and an undo button. No Docker, no API keys, no account.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"smaran": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc",
|
|
15
|
+
"prepublishOnly": "npm run build",
|
|
16
|
+
"test": "npm run build && node test/e2e.mjs"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"mcp",
|
|
23
|
+
"memory",
|
|
24
|
+
"agent",
|
|
25
|
+
"claude",
|
|
26
|
+
"audit",
|
|
27
|
+
"model-context-protocol"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
32
|
+
"better-sqlite3": "^11.10.0"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
36
|
+
"@types/node": "^20.17.0",
|
|
37
|
+
"typescript": "^5.6.3"
|
|
38
|
+
}
|
|
39
|
+
}
|