chron-mcp 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/README.md +207 -0
- package/dist/db/index.d.ts +3 -0
- package/dist/db/index.js +78 -0
- package/dist/db/schema.d.ts +134 -0
- package/dist/db/schema.js +18 -0
- package/dist/http/middleware.d.ts +2 -0
- package/dist/http/middleware.js +13 -0
- package/dist/http/server.d.ts +3 -0
- package/dist/http/server.js +38 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +55 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +38 -0
- package/dist/tools/messages.d.ts +21 -0
- package/dist/tools/messages.js +63 -0
- package/dist/tools/sessions.d.ts +27 -0
- package/dist/tools/sessions.js +81 -0
- package/dist/utils/time.d.ts +1 -0
- package/dist/utils/time.js +18 -0
- package/package.json +54 -0
- package/skills/.gitkeep +0 -0
- package/skills/chron.skill.md +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Chron
|
|
2
|
+
|
|
3
|
+
> A timestamped audit trail for every AI conversation — stored locally, owned by you.
|
|
4
|
+
|
|
5
|
+
AI tools show when you sent a message. Chron logs when the AI responded too — and keeps a permanent, queryable record of every exchange across every tool you use.
|
|
6
|
+
|
|
7
|
+
Works with Claude Desktop, Claude Code, Cursor, Windsurf, and any MCP-compatible AI tool.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
AI tools produce no audit trail by default. You cannot answer:
|
|
14
|
+
- What did the AI say, and when exactly?
|
|
15
|
+
- How long did the AI take to respond?
|
|
16
|
+
- What was the full conversation that produced this output?
|
|
17
|
+
- What did I ask Claude last week about this codebase?
|
|
18
|
+
|
|
19
|
+
Chron fixes that. Every exchange is logged with a precise local datetime (including timezone offset) to a SQLite file you own. No cloud, no vendor lock-in, no data leaving your machine.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
Add to your AI tool's MCP config:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"chron": {
|
|
31
|
+
"command": "npx",
|
|
32
|
+
"args": ["-y", "chron-mcp"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
First run creates `~/.chron/chron.db` automatically. No database setup, no env vars, no migrations.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## What it logs
|
|
43
|
+
|
|
44
|
+
Every exchange is recorded with precise local timestamps — user message when received, assistant response when sent:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
[user: 2026-05-08 14:32:11 +02:00 | assistant: 2026-05-08 14:32:43 +02:00]
|
|
48
|
+
|
|
49
|
+
The main risks of deploying this contract are...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The gap between user and assistant timestamps is real generation time. Both are stored in your local SQLite DB with full timezone offset.
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
## Config by tool
|
|
57
|
+
|
|
58
|
+
### Claude Desktop
|
|
59
|
+
|
|
60
|
+
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"mcpServers": {
|
|
65
|
+
"chron": {
|
|
66
|
+
"command": "npx",
|
|
67
|
+
"args": ["-y", "chron-mcp"]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Claude Code
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
claude mcp add chron npx -y chron-mcp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then add the skill hook to `~/.claude/settings.json`:
|
|
80
|
+
|
|
81
|
+
```json
|
|
82
|
+
{
|
|
83
|
+
"hooks": {
|
|
84
|
+
"SessionStart": [
|
|
85
|
+
{
|
|
86
|
+
"hooks": [
|
|
87
|
+
{
|
|
88
|
+
"type": "command",
|
|
89
|
+
"command": "cat ~/.chron/chron.skill.md"
|
|
90
|
+
}
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Cursor
|
|
99
|
+
|
|
100
|
+
Edit `~/.cursor/mcp.json`:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"mcpServers": {
|
|
105
|
+
"chron": {
|
|
106
|
+
"command": "npx",
|
|
107
|
+
"args": ["-y", "chron-mcp"]
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Windsurf
|
|
114
|
+
|
|
115
|
+
Edit `~/.codeium/windsurf/mcp_config.json`:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"mcpServers": {
|
|
120
|
+
"chron": {
|
|
121
|
+
"command": "npx",
|
|
122
|
+
"args": ["-y", "chron-mcp"]
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Companion skill file
|
|
131
|
+
|
|
132
|
+
Chron ships with `skills/chron.skill.md` — a plain-text instruction file that tells the AI how to use the MCP tools automatically. Load it into your AI tool once. After that, the AI:
|
|
133
|
+
|
|
134
|
+
1. Creates or resumes a named session at the start of every conversation
|
|
135
|
+
2. Logs your message before it starts responding (captures the real user timestamp)
|
|
136
|
+
3. Logs its response after composing it (captures the real assistant timestamp)
|
|
137
|
+
4. Shows `[user: YYYY-MM-DD HH:MM:SS ±HH:MM | assistant: YYYY-MM-DD HH:MM:SS ±HH:MM]` at the top of every response
|
|
138
|
+
5. Retrieves prior session history so context is never lost across conversations
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## MCP Tools
|
|
143
|
+
|
|
144
|
+
| Tool | Description |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `start_session` | Create or resume a named audit session |
|
|
147
|
+
| `log_message` | Record a single message with the current local datetime |
|
|
148
|
+
| `log_exchange` | Log a user/assistant pair atomically (for batch imports) |
|
|
149
|
+
| `list_sessions` | List all sessions ordered by most recently active |
|
|
150
|
+
| `get_session_history` | Retrieve the full timestamped log for a session |
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Configuration
|
|
155
|
+
|
|
156
|
+
| Env var | Default | Description |
|
|
157
|
+
|---|---|---|
|
|
158
|
+
| `CHRON_DB_PATH` | `~/.chron/chron.db` | Path to SQLite database file |
|
|
159
|
+
| `CHRON_TRANSPORT` | `stdio` | Set to `http` to enable HTTP+SSE mode |
|
|
160
|
+
| `CHRON_API_KEY` | _(none)_ | Bearer token for HTTP mode |
|
|
161
|
+
| `PORT` | `3001` | Port for HTTP mode |
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## HTTP+SSE mode (team / self-hosted)
|
|
166
|
+
|
|
167
|
+
For teams or remote use, run Chron as an HTTP server:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
CHRON_TRANSPORT=http CHRON_API_KEY=your-key PORT=3001 npx chron-mcp
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Point your MCP config at the URL:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{
|
|
177
|
+
"mcpServers": {
|
|
178
|
+
"chron": {
|
|
179
|
+
"url": "https://your-server/mcp",
|
|
180
|
+
"headers": {
|
|
181
|
+
"Authorization": "Bearer your-key"
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Your data
|
|
191
|
+
|
|
192
|
+
Your audit log lives at `~/.chron/chron.db` — a single SQLite file on your machine. Query it directly with any SQLite tool:
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
sqlite3 ~/.chron/chron.db \
|
|
196
|
+
"SELECT s.title, m.role, m.content, m.created_at
|
|
197
|
+
FROM messages m JOIN sessions s ON s.id = m.session_id
|
|
198
|
+
ORDER BY m.created_at"
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
No cloud, no telemetry, no data leaving your machine. Change the location with `CHRON_DB_PATH`.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT
|
package/dist/db/index.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.initDb = initDb;
|
|
37
|
+
const client_1 = require("@libsql/client");
|
|
38
|
+
const libsql_1 = require("drizzle-orm/libsql");
|
|
39
|
+
const os_1 = require("os");
|
|
40
|
+
const fs_1 = require("fs");
|
|
41
|
+
const path_1 = require("path");
|
|
42
|
+
const schema = __importStar(require("./schema"));
|
|
43
|
+
function getDbPath() {
|
|
44
|
+
const envPath = process.env.CHRON_DB_PATH;
|
|
45
|
+
if (envPath)
|
|
46
|
+
return envPath;
|
|
47
|
+
return (0, path_1.join)((0, os_1.homedir)(), '.chron', 'chron.db');
|
|
48
|
+
}
|
|
49
|
+
const CREATE_SQL = [
|
|
50
|
+
`CREATE TABLE IF NOT EXISTS sessions (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
title TEXT NOT NULL,
|
|
53
|
+
ai_tool TEXT,
|
|
54
|
+
created_at TEXT NOT NULL,
|
|
55
|
+
updated_at TEXT NOT NULL
|
|
56
|
+
)`,
|
|
57
|
+
`CREATE TABLE IF NOT EXISTS messages (
|
|
58
|
+
id TEXT PRIMARY KEY,
|
|
59
|
+
session_id TEXT NOT NULL,
|
|
60
|
+
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
|
61
|
+
content TEXT NOT NULL,
|
|
62
|
+
created_at TEXT NOT NULL,
|
|
63
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
|
64
|
+
)`,
|
|
65
|
+
];
|
|
66
|
+
async function initDb(dbPath) {
|
|
67
|
+
const path = dbPath ?? getDbPath();
|
|
68
|
+
if (!path.startsWith(':')) {
|
|
69
|
+
(0, fs_1.mkdirSync)((0, path_1.dirname)(path), { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
const client = (0, client_1.createClient)({ url: path.startsWith(':') ? path : `file:${path}` });
|
|
72
|
+
await client.execute('PRAGMA journal_mode = WAL');
|
|
73
|
+
await client.execute('PRAGMA foreign_keys = ON');
|
|
74
|
+
for (const sql of CREATE_SQL) {
|
|
75
|
+
await client.execute(sql);
|
|
76
|
+
}
|
|
77
|
+
return (0, libsql_1.drizzle)(client, { schema });
|
|
78
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
export declare const sessions: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
2
|
+
name: "sessions";
|
|
3
|
+
schema: undefined;
|
|
4
|
+
columns: {
|
|
5
|
+
id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
6
|
+
name: "id";
|
|
7
|
+
tableName: "sessions";
|
|
8
|
+
dataType: "string";
|
|
9
|
+
columnType: "SQLiteText";
|
|
10
|
+
data: string;
|
|
11
|
+
driverParam: string;
|
|
12
|
+
notNull: true;
|
|
13
|
+
hasDefault: false;
|
|
14
|
+
enumValues: [string, ...string[]];
|
|
15
|
+
baseColumn: never;
|
|
16
|
+
}, object>;
|
|
17
|
+
title: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
18
|
+
name: "title";
|
|
19
|
+
tableName: "sessions";
|
|
20
|
+
dataType: "string";
|
|
21
|
+
columnType: "SQLiteText";
|
|
22
|
+
data: string;
|
|
23
|
+
driverParam: string;
|
|
24
|
+
notNull: true;
|
|
25
|
+
hasDefault: false;
|
|
26
|
+
enumValues: [string, ...string[]];
|
|
27
|
+
baseColumn: never;
|
|
28
|
+
}, object>;
|
|
29
|
+
ai_tool: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
30
|
+
name: "ai_tool";
|
|
31
|
+
tableName: "sessions";
|
|
32
|
+
dataType: "string";
|
|
33
|
+
columnType: "SQLiteText";
|
|
34
|
+
data: string;
|
|
35
|
+
driverParam: string;
|
|
36
|
+
notNull: false;
|
|
37
|
+
hasDefault: false;
|
|
38
|
+
enumValues: [string, ...string[]];
|
|
39
|
+
baseColumn: never;
|
|
40
|
+
}, object>;
|
|
41
|
+
created_at: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
42
|
+
name: "created_at";
|
|
43
|
+
tableName: "sessions";
|
|
44
|
+
dataType: "string";
|
|
45
|
+
columnType: "SQLiteText";
|
|
46
|
+
data: string;
|
|
47
|
+
driverParam: string;
|
|
48
|
+
notNull: true;
|
|
49
|
+
hasDefault: false;
|
|
50
|
+
enumValues: [string, ...string[]];
|
|
51
|
+
baseColumn: never;
|
|
52
|
+
}, object>;
|
|
53
|
+
updated_at: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
54
|
+
name: "updated_at";
|
|
55
|
+
tableName: "sessions";
|
|
56
|
+
dataType: "string";
|
|
57
|
+
columnType: "SQLiteText";
|
|
58
|
+
data: string;
|
|
59
|
+
driverParam: string;
|
|
60
|
+
notNull: true;
|
|
61
|
+
hasDefault: false;
|
|
62
|
+
enumValues: [string, ...string[]];
|
|
63
|
+
baseColumn: never;
|
|
64
|
+
}, object>;
|
|
65
|
+
};
|
|
66
|
+
dialect: "sqlite";
|
|
67
|
+
}>;
|
|
68
|
+
export declare const messages: import("drizzle-orm/sqlite-core").SQLiteTableWithColumns<{
|
|
69
|
+
name: "messages";
|
|
70
|
+
schema: undefined;
|
|
71
|
+
columns: {
|
|
72
|
+
id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
73
|
+
name: "id";
|
|
74
|
+
tableName: "messages";
|
|
75
|
+
dataType: "string";
|
|
76
|
+
columnType: "SQLiteText";
|
|
77
|
+
data: string;
|
|
78
|
+
driverParam: string;
|
|
79
|
+
notNull: true;
|
|
80
|
+
hasDefault: false;
|
|
81
|
+
enumValues: [string, ...string[]];
|
|
82
|
+
baseColumn: never;
|
|
83
|
+
}, object>;
|
|
84
|
+
session_id: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
85
|
+
name: "session_id";
|
|
86
|
+
tableName: "messages";
|
|
87
|
+
dataType: "string";
|
|
88
|
+
columnType: "SQLiteText";
|
|
89
|
+
data: string;
|
|
90
|
+
driverParam: string;
|
|
91
|
+
notNull: true;
|
|
92
|
+
hasDefault: false;
|
|
93
|
+
enumValues: [string, ...string[]];
|
|
94
|
+
baseColumn: never;
|
|
95
|
+
}, object>;
|
|
96
|
+
role: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
97
|
+
name: "role";
|
|
98
|
+
tableName: "messages";
|
|
99
|
+
dataType: "string";
|
|
100
|
+
columnType: "SQLiteText";
|
|
101
|
+
data: "user" | "assistant";
|
|
102
|
+
driverParam: string;
|
|
103
|
+
notNull: true;
|
|
104
|
+
hasDefault: false;
|
|
105
|
+
enumValues: ["user", "assistant"];
|
|
106
|
+
baseColumn: never;
|
|
107
|
+
}, object>;
|
|
108
|
+
content: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
109
|
+
name: "content";
|
|
110
|
+
tableName: "messages";
|
|
111
|
+
dataType: "string";
|
|
112
|
+
columnType: "SQLiteText";
|
|
113
|
+
data: string;
|
|
114
|
+
driverParam: string;
|
|
115
|
+
notNull: true;
|
|
116
|
+
hasDefault: false;
|
|
117
|
+
enumValues: [string, ...string[]];
|
|
118
|
+
baseColumn: never;
|
|
119
|
+
}, object>;
|
|
120
|
+
created_at: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
121
|
+
name: "created_at";
|
|
122
|
+
tableName: "messages";
|
|
123
|
+
dataType: "string";
|
|
124
|
+
columnType: "SQLiteText";
|
|
125
|
+
data: string;
|
|
126
|
+
driverParam: string;
|
|
127
|
+
notNull: true;
|
|
128
|
+
hasDefault: false;
|
|
129
|
+
enumValues: [string, ...string[]];
|
|
130
|
+
baseColumn: never;
|
|
131
|
+
}, object>;
|
|
132
|
+
};
|
|
133
|
+
dialect: "sqlite";
|
|
134
|
+
}>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.messages = exports.sessions = void 0;
|
|
4
|
+
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
|
5
|
+
exports.sessions = (0, sqlite_core_1.sqliteTable)('sessions', {
|
|
6
|
+
id: (0, sqlite_core_1.text)('id').primaryKey(),
|
|
7
|
+
title: (0, sqlite_core_1.text)('title').notNull(),
|
|
8
|
+
ai_tool: (0, sqlite_core_1.text)('ai_tool'),
|
|
9
|
+
created_at: (0, sqlite_core_1.text)('created_at').notNull(),
|
|
10
|
+
updated_at: (0, sqlite_core_1.text)('updated_at').notNull(),
|
|
11
|
+
});
|
|
12
|
+
exports.messages = (0, sqlite_core_1.sqliteTable)('messages', {
|
|
13
|
+
id: (0, sqlite_core_1.text)('id').primaryKey(),
|
|
14
|
+
session_id: (0, sqlite_core_1.text)('session_id').notNull(),
|
|
15
|
+
role: (0, sqlite_core_1.text)('role', { enum: ['user', 'assistant'] }).notNull(),
|
|
16
|
+
content: (0, sqlite_core_1.text)('content').notNull(),
|
|
17
|
+
created_at: (0, sqlite_core_1.text)('created_at').notNull(),
|
|
18
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.apiKeyMiddleware = apiKeyMiddleware;
|
|
4
|
+
function apiKeyMiddleware(apiKey) {
|
|
5
|
+
return (req, res, next) => {
|
|
6
|
+
const header = req.headers.authorization;
|
|
7
|
+
if (!header || header !== `Bearer ${apiKey}`) {
|
|
8
|
+
res.status(401).end();
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
next();
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.startHttpServer = startHttpServer;
|
|
7
|
+
const express_1 = __importDefault(require("express"));
|
|
8
|
+
const sse_js_1 = require("@modelcontextprotocol/sdk/server/sse.js");
|
|
9
|
+
const middleware_1 = require("./middleware");
|
|
10
|
+
const server_1 = require("../server");
|
|
11
|
+
async function startHttpServer(server, db) {
|
|
12
|
+
const app = (0, express_1.default)();
|
|
13
|
+
app.use(express_1.default.json());
|
|
14
|
+
const apiKey = process.env.CHRON_API_KEY;
|
|
15
|
+
if (apiKey) {
|
|
16
|
+
app.use((0, middleware_1.apiKeyMiddleware)(apiKey));
|
|
17
|
+
}
|
|
18
|
+
const transports = new Map();
|
|
19
|
+
app.get('/sse', async (req, res) => {
|
|
20
|
+
const instanceServer = (0, server_1.createServer)(db);
|
|
21
|
+
const transport = new sse_js_1.SSEServerTransport('/messages', res);
|
|
22
|
+
transports.set(transport.sessionId, { transport, server: instanceServer });
|
|
23
|
+
transport.onclose = () => transports.delete(transport.sessionId);
|
|
24
|
+
await instanceServer.connect(transport);
|
|
25
|
+
});
|
|
26
|
+
app.post('/messages', async (req, res) => {
|
|
27
|
+
const sessionId = req.query.sessionId;
|
|
28
|
+
const entry = transports.get(sessionId);
|
|
29
|
+
if (!entry) {
|
|
30
|
+
res.status(404).json({ error: 'Session not found' });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
await entry.transport.handlePostMessage(req, res);
|
|
34
|
+
});
|
|
35
|
+
const port = parseInt(process.env.PORT ?? '3001', 10);
|
|
36
|
+
await new Promise(resolve => app.listen(port, resolve));
|
|
37
|
+
process.stderr.write(`Chron HTTP server listening on port ${port}\n`);
|
|
38
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
38
|
+
const index_1 = require("./db/index");
|
|
39
|
+
const server_1 = require("./server");
|
|
40
|
+
async function main() {
|
|
41
|
+
const db = await (0, index_1.initDb)();
|
|
42
|
+
const server = (0, server_1.createServer)(db);
|
|
43
|
+
if (process.env.CHRON_TRANSPORT === 'http') {
|
|
44
|
+
const { startHttpServer } = await Promise.resolve().then(() => __importStar(require('./http/server')));
|
|
45
|
+
await startHttpServer(server, db);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const transport = new stdio_js_1.StdioServerTransport();
|
|
49
|
+
await server.connect(transport);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
main().catch((err) => {
|
|
53
|
+
process.stderr.write(`Failed to start chron: ${err}\n`);
|
|
54
|
+
process.exit(1);
|
|
55
|
+
});
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createServer = createServer;
|
|
4
|
+
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
const sessions_1 = require("./tools/sessions");
|
|
7
|
+
const messages_1 = require("./tools/messages");
|
|
8
|
+
const roleEnum = zod_1.z.enum(['user', 'assistant']);
|
|
9
|
+
function createServer(db) {
|
|
10
|
+
const server = new mcp_js_1.McpServer({
|
|
11
|
+
name: 'chron',
|
|
12
|
+
version: '0.1.0',
|
|
13
|
+
});
|
|
14
|
+
// @ts-expect-error — MCP SDK deep generic type instantiation limit hit; runtime types are correct
|
|
15
|
+
server.tool('start_session', 'Create a new audit session or resume an existing one by title. Call this at the start of every conversation.', {
|
|
16
|
+
title: zod_1.z.string().describe('Descriptive session title, e.g. "Contract review — 2026-05-08"'),
|
|
17
|
+
ai_tool: zod_1.z.string().optional().describe('AI tool name: "claude", "cursor", "windsurf", etc.'),
|
|
18
|
+
}, (0, sessions_1.startSession)(db));
|
|
19
|
+
server.tool('log_message', 'Record a single message (user or assistant) with the current UTC datetime. Call before responding for user messages, and before sending for assistant messages.', {
|
|
20
|
+
session_id: zod_1.z.string().describe('Session ID returned by start_session'),
|
|
21
|
+
role: roleEnum,
|
|
22
|
+
content: zod_1.z.string().describe('Full message text'),
|
|
23
|
+
}, (0, messages_1.logMessage)(db));
|
|
24
|
+
server.tool('log_exchange', 'Log a user message and assistant response together in one call. Preferred over two log_message calls — faster and atomic.', {
|
|
25
|
+
session_id: zod_1.z.string().describe('Session ID returned by start_session'),
|
|
26
|
+
user_content: zod_1.z.string().describe('The exact user message'),
|
|
27
|
+
assistant_content: zod_1.z.string().describe('The exact assistant response'),
|
|
28
|
+
}, (0, messages_1.logExchange)(db));
|
|
29
|
+
// @ts-expect-error — MCP SDK deep generic type instantiation limit hit; runtime types are correct
|
|
30
|
+
server.tool('list_sessions', 'List all audit sessions ordered by most recently active. Returns id, title, ai_tool, message_count, created_at, updated_at.', {
|
|
31
|
+
limit: zod_1.z.number().int().positive().optional().describe('Return only the most recent N sessions'),
|
|
32
|
+
}, (0, sessions_1.listSessions)(db));
|
|
33
|
+
server.tool('get_session_history', 'Retrieve the full timestamped audit log for a session, oldest first.', {
|
|
34
|
+
session_id: zod_1.z.string(),
|
|
35
|
+
limit: zod_1.z.number().int().positive().optional().describe('Return only the most recent N messages'),
|
|
36
|
+
}, (0, sessions_1.getSessionHistory)(db));
|
|
37
|
+
return server;
|
|
38
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Db } from '../db/index';
|
|
2
|
+
export declare function logMessage(db: Db): (args: {
|
|
3
|
+
session_id: string;
|
|
4
|
+
role: "user" | "assistant";
|
|
5
|
+
content: string;
|
|
6
|
+
}) => Promise<{
|
|
7
|
+
content: {
|
|
8
|
+
type: "text";
|
|
9
|
+
text: string;
|
|
10
|
+
}[];
|
|
11
|
+
}>;
|
|
12
|
+
export declare function logExchange(db: Db): (args: {
|
|
13
|
+
session_id: string;
|
|
14
|
+
user_content: string;
|
|
15
|
+
assistant_content: string;
|
|
16
|
+
}) => Promise<{
|
|
17
|
+
content: {
|
|
18
|
+
type: "text";
|
|
19
|
+
text: string;
|
|
20
|
+
}[];
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.logMessage = logMessage;
|
|
4
|
+
exports.logExchange = logExchange;
|
|
5
|
+
const drizzle_orm_1 = require("drizzle-orm");
|
|
6
|
+
const uuid_1 = require("uuid");
|
|
7
|
+
const schema_1 = require("../db/schema");
|
|
8
|
+
const time_1 = require("../utils/time");
|
|
9
|
+
function logMessage(db) {
|
|
10
|
+
return async (args) => {
|
|
11
|
+
const now = (0, time_1.localISOString)();
|
|
12
|
+
const id = (0, uuid_1.v4)();
|
|
13
|
+
await db.insert(schema_1.messages).values({
|
|
14
|
+
id,
|
|
15
|
+
session_id: args.session_id,
|
|
16
|
+
role: args.role,
|
|
17
|
+
content: args.content,
|
|
18
|
+
created_at: now,
|
|
19
|
+
});
|
|
20
|
+
await db.update(schema_1.sessions)
|
|
21
|
+
.set({ updated_at: now })
|
|
22
|
+
.where((0, drizzle_orm_1.eq)(schema_1.sessions.id, args.session_id));
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: 'text', text: JSON.stringify({ message_id: id, created_at: now }) }],
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function logExchange(db) {
|
|
29
|
+
return async (args) => {
|
|
30
|
+
const userNow = (0, time_1.localISOString)();
|
|
31
|
+
const userId = (0, uuid_1.v4)();
|
|
32
|
+
const assistantNow = (0, time_1.localISOString)();
|
|
33
|
+
const assistantId = (0, uuid_1.v4)();
|
|
34
|
+
await db.insert(schema_1.messages).values({
|
|
35
|
+
id: userId,
|
|
36
|
+
session_id: args.session_id,
|
|
37
|
+
role: 'user',
|
|
38
|
+
content: args.user_content,
|
|
39
|
+
created_at: userNow,
|
|
40
|
+
});
|
|
41
|
+
await db.insert(schema_1.messages).values({
|
|
42
|
+
id: assistantId,
|
|
43
|
+
session_id: args.session_id,
|
|
44
|
+
role: 'assistant',
|
|
45
|
+
content: args.assistant_content,
|
|
46
|
+
created_at: assistantNow,
|
|
47
|
+
});
|
|
48
|
+
await db.update(schema_1.sessions)
|
|
49
|
+
.set({ updated_at: assistantNow })
|
|
50
|
+
.where((0, drizzle_orm_1.eq)(schema_1.sessions.id, args.session_id));
|
|
51
|
+
return {
|
|
52
|
+
content: [{
|
|
53
|
+
type: 'text',
|
|
54
|
+
text: JSON.stringify({
|
|
55
|
+
user_message_id: userId,
|
|
56
|
+
user_created_at: userNow,
|
|
57
|
+
assistant_message_id: assistantId,
|
|
58
|
+
assistant_created_at: assistantNow,
|
|
59
|
+
}),
|
|
60
|
+
}],
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Db } from '../db/index';
|
|
2
|
+
export declare function startSession(db: Db): (args: {
|
|
3
|
+
title: string;
|
|
4
|
+
ai_tool?: string;
|
|
5
|
+
}) => Promise<{
|
|
6
|
+
content: {
|
|
7
|
+
type: "text";
|
|
8
|
+
text: string;
|
|
9
|
+
}[];
|
|
10
|
+
}>;
|
|
11
|
+
export declare function listSessions(db: Db): (args: {
|
|
12
|
+
limit?: number;
|
|
13
|
+
}) => Promise<{
|
|
14
|
+
content: {
|
|
15
|
+
type: "text";
|
|
16
|
+
text: string;
|
|
17
|
+
}[];
|
|
18
|
+
}>;
|
|
19
|
+
export declare function getSessionHistory(db: Db): (args: {
|
|
20
|
+
session_id: string;
|
|
21
|
+
limit?: number;
|
|
22
|
+
}) => Promise<{
|
|
23
|
+
content: {
|
|
24
|
+
type: "text";
|
|
25
|
+
text: string;
|
|
26
|
+
}[];
|
|
27
|
+
}>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startSession = startSession;
|
|
4
|
+
exports.listSessions = listSessions;
|
|
5
|
+
exports.getSessionHistory = getSessionHistory;
|
|
6
|
+
const drizzle_orm_1 = require("drizzle-orm");
|
|
7
|
+
const uuid_1 = require("uuid");
|
|
8
|
+
const schema_1 = require("../db/schema");
|
|
9
|
+
const time_1 = require("../utils/time");
|
|
10
|
+
function startSession(db) {
|
|
11
|
+
return async (args) => {
|
|
12
|
+
const existing = await db.select().from(schema_1.sessions).where((0, drizzle_orm_1.eq)(schema_1.sessions.title, args.title));
|
|
13
|
+
if (existing.length > 0) {
|
|
14
|
+
const session = existing[0];
|
|
15
|
+
const now = (0, time_1.localISOString)();
|
|
16
|
+
const allMessages = await db.select().from(schema_1.messages).where((0, drizzle_orm_1.eq)(schema_1.messages.session_id, session.id));
|
|
17
|
+
await db.update(schema_1.sessions).set({ updated_at: now }).where((0, drizzle_orm_1.eq)(schema_1.sessions.id, session.id));
|
|
18
|
+
return {
|
|
19
|
+
content: [{
|
|
20
|
+
type: 'text',
|
|
21
|
+
text: JSON.stringify({ session_id: session.id, created: false, message_count: allMessages.length, ai_tool: session.ai_tool }),
|
|
22
|
+
}],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const now = (0, time_1.localISOString)();
|
|
26
|
+
const id = (0, uuid_1.v4)();
|
|
27
|
+
await db.insert(schema_1.sessions).values({
|
|
28
|
+
id,
|
|
29
|
+
title: args.title,
|
|
30
|
+
ai_tool: args.ai_tool ?? null,
|
|
31
|
+
created_at: now,
|
|
32
|
+
updated_at: now,
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
content: [{
|
|
36
|
+
type: 'text',
|
|
37
|
+
text: JSON.stringify({ session_id: id, created: true, message_count: 0, ai_tool: args.ai_tool ?? null }),
|
|
38
|
+
}],
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function listSessions(db) {
|
|
43
|
+
return async (args) => {
|
|
44
|
+
const rows = await db.select({
|
|
45
|
+
id: schema_1.sessions.id,
|
|
46
|
+
title: schema_1.sessions.title,
|
|
47
|
+
ai_tool: schema_1.sessions.ai_tool,
|
|
48
|
+
created_at: schema_1.sessions.created_at,
|
|
49
|
+
updated_at: schema_1.sessions.updated_at,
|
|
50
|
+
message_count: (0, drizzle_orm_1.sql) `count(${schema_1.messages.id})`,
|
|
51
|
+
})
|
|
52
|
+
.from(schema_1.sessions)
|
|
53
|
+
.leftJoin(schema_1.messages, (0, drizzle_orm_1.eq)(schema_1.messages.session_id, schema_1.sessions.id))
|
|
54
|
+
.groupBy(schema_1.sessions.id)
|
|
55
|
+
.orderBy((0, drizzle_orm_1.desc)(schema_1.sessions.updated_at));
|
|
56
|
+
const limited = args.limit != null ? rows.slice(0, args.limit) : rows;
|
|
57
|
+
return {
|
|
58
|
+
content: [{
|
|
59
|
+
type: 'text',
|
|
60
|
+
text: JSON.stringify({ sessions: limited, total: rows.length }),
|
|
61
|
+
}],
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function getSessionHistory(db) {
|
|
66
|
+
return async (args) => {
|
|
67
|
+
const all = await db.select().from(schema_1.messages)
|
|
68
|
+
.where((0, drizzle_orm_1.eq)(schema_1.messages.session_id, args.session_id))
|
|
69
|
+
.orderBy((0, drizzle_orm_1.asc)(schema_1.messages.created_at));
|
|
70
|
+
const limited = args.limit != null ? all.slice(-args.limit) : all;
|
|
71
|
+
return {
|
|
72
|
+
content: [{
|
|
73
|
+
type: 'text',
|
|
74
|
+
text: JSON.stringify({
|
|
75
|
+
messages: limited.map(m => ({ role: m.role, content: m.content, created_at: m.created_at })),
|
|
76
|
+
total: all.length,
|
|
77
|
+
}),
|
|
78
|
+
}],
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function localISOString(): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.localISOString = localISOString;
|
|
4
|
+
function localISOString() {
|
|
5
|
+
const d = new Date();
|
|
6
|
+
const offsetMin = -d.getTimezoneOffset(); // positive for UTC+, negative for UTC-
|
|
7
|
+
const sign = offsetMin >= 0 ? '+' : '-';
|
|
8
|
+
const absMin = Math.abs(offsetMin);
|
|
9
|
+
const hh = String(Math.floor(absMin / 60)).padStart(2, '0');
|
|
10
|
+
const mm = String(absMin % 60).padStart(2, '0');
|
|
11
|
+
const year = d.getFullYear();
|
|
12
|
+
const month = String(d.getMonth() + 1).padStart(2, '0');
|
|
13
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
14
|
+
const hours = String(d.getHours()).padStart(2, '0');
|
|
15
|
+
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|
16
|
+
const seconds = String(d.getSeconds()).padStart(2, '0');
|
|
17
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${sign}${hh}:${mm}`;
|
|
18
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chron-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Audit-grade timestamped logs for every AI conversation",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"vitest": {
|
|
7
|
+
"include": ["tests/**/*.test.ts"],
|
|
8
|
+
"globals": true
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"chron-mcp": "dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"skills",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc && chmod +x dist/index.js",
|
|
23
|
+
"dev": "tsc --watch",
|
|
24
|
+
"start": "node dist/index.js",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"prepublishOnly": "npm test && npm run build"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"mcp",
|
|
31
|
+
"ai",
|
|
32
|
+
"audit",
|
|
33
|
+
"logging",
|
|
34
|
+
"claude",
|
|
35
|
+
"cursor"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@libsql/client": "^0.17.3",
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
41
|
+
"drizzle-orm": "^0.30.10",
|
|
42
|
+
"express": "^4.18.2",
|
|
43
|
+
"uuid": "^9.0.0",
|
|
44
|
+
"zod": "^3.22.4"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/express": "^4.17.21",
|
|
48
|
+
"@types/node": "^20.0.0",
|
|
49
|
+
"@types/uuid": "^9.0.0",
|
|
50
|
+
"drizzle-kit": "^0.20.14",
|
|
51
|
+
"typescript": "^5.4.5",
|
|
52
|
+
"vitest": "^1.4.0"
|
|
53
|
+
}
|
|
54
|
+
}
|
package/skills/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Chron — AI Audit Log Skill
|
|
2
|
+
|
|
3
|
+
Chron creates an audit trail of every AI conversation. Every message is logged with a precise local datetime (with timezone offset) to a local SQLite database the user owns.
|
|
4
|
+
|
|
5
|
+
## Session Start
|
|
6
|
+
|
|
7
|
+
Call `start_session` with a descriptive title (e.g. "Contract review — 2026-05-08") and set `ai_tool` to your tool name (e.g. "claude", "cursor", "windsurf").
|
|
8
|
+
|
|
9
|
+
Call `get_session_history` for the returned `session_id`. If prior messages exist, display:
|
|
10
|
+
> "Resuming session: [N messages] since [first_message_date]"
|
|
11
|
+
|
|
12
|
+
## Every Exchange
|
|
13
|
+
|
|
14
|
+
1. Call `log_message(session_id, role="user", content=<exact user message>)` → returns T1
|
|
15
|
+
2. Compose your response body (plain text only — no timestamp header)
|
|
16
|
+
3. Call `log_message(session_id, role="assistant", content=<response body>)` → returns T2
|
|
17
|
+
4. Output header + body:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
[user: YYYY-MM-DD HH:MM:SS ±HH:MM | assistant: YYYY-MM-DD HH:MM:SS ±HH:MM]
|
|
21
|
+
|
|
22
|
+
<response body>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
T1 and T2 are real server timestamps — T2 − T1 is the actual generation time. The DB stores clean body content without the header.
|
|
26
|
+
|
|
27
|
+
**Do not use `log_exchange` for live conversations** — it captures both timestamps at the same instant and cannot show a real gap. Use it only for batch imports of historical messages.
|
|
28
|
+
|
|
29
|
+
## If Chron MCP is unavailable
|
|
30
|
+
|
|
31
|
+
State once: "Chron is not connected — this conversation will not be logged."
|
|
32
|
+
Continue normally. Do not retry repeatedly.
|