chron-mcp 0.1.5 → 0.1.7
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/dist/db/index.js +4 -2
- package/dist/db/schema.js +2 -2
- package/dist/http/server.js +28 -12
- package/dist/server.js +3 -3
- package/dist/tools/messages.d.ts +14 -0
- package/dist/tools/messages.js +8 -0
- package/dist/tools/sessions.js +32 -24
- package/dist/utils/time.js +2 -1
- package/package.json +1 -1
- package/skills/chron.skill.md +7 -3
package/dist/db/index.js
CHANGED
|
@@ -49,7 +49,7 @@ function getDbPath() {
|
|
|
49
49
|
const CREATE_SQL = [
|
|
50
50
|
`CREATE TABLE IF NOT EXISTS sessions (
|
|
51
51
|
id TEXT PRIMARY KEY,
|
|
52
|
-
title TEXT NOT NULL,
|
|
52
|
+
title TEXT NOT NULL UNIQUE,
|
|
53
53
|
ai_tool TEXT,
|
|
54
54
|
created_at TEXT NOT NULL,
|
|
55
55
|
updated_at TEXT NOT NULL
|
|
@@ -60,8 +60,10 @@ const CREATE_SQL = [
|
|
|
60
60
|
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
|
61
61
|
content TEXT NOT NULL,
|
|
62
62
|
created_at TEXT NOT NULL,
|
|
63
|
-
FOREIGN KEY (session_id) REFERENCES sessions(id)
|
|
63
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
64
64
|
)`,
|
|
65
|
+
`CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_title ON sessions(title)`,
|
|
66
|
+
`CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)`,
|
|
65
67
|
];
|
|
66
68
|
async function initDb(dbPath) {
|
|
67
69
|
const path = dbPath ?? getDbPath();
|
package/dist/db/schema.js
CHANGED
|
@@ -4,14 +4,14 @@ exports.messages = exports.sessions = void 0;
|
|
|
4
4
|
const sqlite_core_1 = require("drizzle-orm/sqlite-core");
|
|
5
5
|
exports.sessions = (0, sqlite_core_1.sqliteTable)('sessions', {
|
|
6
6
|
id: (0, sqlite_core_1.text)('id').primaryKey(),
|
|
7
|
-
title: (0, sqlite_core_1.text)('title').notNull(),
|
|
7
|
+
title: (0, sqlite_core_1.text)('title').notNull().unique(),
|
|
8
8
|
ai_tool: (0, sqlite_core_1.text)('ai_tool'),
|
|
9
9
|
created_at: (0, sqlite_core_1.text)('created_at').notNull(),
|
|
10
10
|
updated_at: (0, sqlite_core_1.text)('updated_at').notNull(),
|
|
11
11
|
});
|
|
12
12
|
exports.messages = (0, sqlite_core_1.sqliteTable)('messages', {
|
|
13
13
|
id: (0, sqlite_core_1.text)('id').primaryKey(),
|
|
14
|
-
session_id: (0, sqlite_core_1.text)('session_id').notNull(),
|
|
14
|
+
session_id: (0, sqlite_core_1.text)('session_id').notNull().references(() => exports.sessions.id),
|
|
15
15
|
role: (0, sqlite_core_1.text)('role', { enum: ['user', 'assistant'] }).notNull(),
|
|
16
16
|
content: (0, sqlite_core_1.text)('content').notNull(),
|
|
17
17
|
created_at: (0, sqlite_core_1.text)('created_at').notNull(),
|
package/dist/http/server.js
CHANGED
|
@@ -10,29 +10,45 @@ const middleware_1 = require("./middleware");
|
|
|
10
10
|
const server_1 = require("../server");
|
|
11
11
|
async function startHttpServer(server, db) {
|
|
12
12
|
const app = (0, express_1.default)();
|
|
13
|
-
app.use(express_1.default.json());
|
|
13
|
+
app.use(express_1.default.json({ limit: '1mb' }));
|
|
14
14
|
const apiKey = process.env.CHRON_API_KEY;
|
|
15
15
|
if (apiKey) {
|
|
16
16
|
app.use((0, middleware_1.apiKeyMiddleware)(apiKey));
|
|
17
17
|
}
|
|
18
18
|
const transports = new Map();
|
|
19
19
|
app.get('/sse', async (req, res) => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
try {
|
|
21
|
+
const instanceServer = (0, server_1.createServer)(db);
|
|
22
|
+
const transport = new sse_js_1.SSEServerTransport('/messages', res);
|
|
23
|
+
transports.set(transport.sessionId, { transport, server: instanceServer });
|
|
24
|
+
transport.onclose = () => transports.delete(transport.sessionId);
|
|
25
|
+
await instanceServer.connect(transport);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
process.stderr.write(`SSE error: ${err}\n`);
|
|
29
|
+
if (!res.headersSent)
|
|
30
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
31
|
+
}
|
|
25
32
|
});
|
|
26
33
|
app.post('/messages', async (req, res) => {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
try {
|
|
35
|
+
const sessionId = req.query.sessionId;
|
|
36
|
+
const entry = transports.get(sessionId);
|
|
37
|
+
if (!entry) {
|
|
38
|
+
res.status(404).json({ error: 'Session not found' });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
await entry.transport.handlePostMessage(req, res);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
process.stderr.write(`Messages error: ${err}\n`);
|
|
45
|
+
if (!res.headersSent)
|
|
46
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
32
47
|
}
|
|
33
|
-
await entry.transport.handlePostMessage(req, res);
|
|
34
48
|
});
|
|
35
49
|
const port = parseInt(process.env.PORT ?? '3001', 10);
|
|
50
|
+
if (isNaN(port))
|
|
51
|
+
throw new Error(`Invalid PORT value: ${process.env.PORT}`);
|
|
36
52
|
await new Promise(resolve => app.listen(port, resolve));
|
|
37
53
|
process.stderr.write(`Chron HTTP server listening on port ${port}\n`);
|
|
38
54
|
}
|
package/dist/server.js
CHANGED
|
@@ -9,19 +9,19 @@ const roleEnum = zod_1.z.enum(['user', 'assistant']);
|
|
|
9
9
|
function createServer(db) {
|
|
10
10
|
const server = new mcp_js_1.McpServer({
|
|
11
11
|
name: 'chron',
|
|
12
|
-
version: '0.1.
|
|
12
|
+
version: '0.1.7',
|
|
13
13
|
});
|
|
14
14
|
// @ts-expect-error — MCP SDK deep generic type instantiation limit hit; runtime types are correct
|
|
15
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
16
|
title: zod_1.z.string().describe('Descriptive session title, e.g. "Contract review — 2026-05-08"'),
|
|
17
17
|
ai_tool: zod_1.z.string().optional().describe('AI tool name: "claude", "cursor", "windsurf", etc.'),
|
|
18
18
|
}, (0, sessions_1.startSession)(db));
|
|
19
|
-
server.tool('log_message', 'Record a single message (user or assistant) with the current
|
|
19
|
+
server.tool('log_message', 'Record a single message (user or assistant) with the current local datetime and timezone offset. Call before responding for user messages, and before sending for assistant messages.', {
|
|
20
20
|
session_id: zod_1.z.string().describe('Session ID returned by start_session'),
|
|
21
21
|
role: roleEnum,
|
|
22
22
|
content: zod_1.z.string().describe('Full message text'),
|
|
23
23
|
}, (0, messages_1.logMessage)(db));
|
|
24
|
-
server.tool('log_exchange', '
|
|
24
|
+
server.tool('log_exchange', 'Record a user+assistant exchange from historical or batch imports only. Do NOT use for live conversations — both timestamps are captured at the same instant with no real gap. For live sessions always call log_message twice: once for the user message, once for the assistant response.', {
|
|
25
25
|
session_id: zod_1.z.string().describe('Session ID returned by start_session'),
|
|
26
26
|
user_content: zod_1.z.string().describe('The exact user message'),
|
|
27
27
|
assistant_content: zod_1.z.string().describe('The exact assistant response'),
|
package/dist/tools/messages.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ export declare function logMessage(db: Db): (args: {
|
|
|
8
8
|
type: "text";
|
|
9
9
|
text: string;
|
|
10
10
|
}[];
|
|
11
|
+
isError: boolean;
|
|
12
|
+
} | {
|
|
13
|
+
content: {
|
|
14
|
+
type: "text";
|
|
15
|
+
text: string;
|
|
16
|
+
}[];
|
|
17
|
+
isError?: undefined;
|
|
11
18
|
}>;
|
|
12
19
|
export declare function logExchange(db: Db): (args: {
|
|
13
20
|
session_id: string;
|
|
@@ -18,4 +25,11 @@ export declare function logExchange(db: Db): (args: {
|
|
|
18
25
|
type: "text";
|
|
19
26
|
text: string;
|
|
20
27
|
}[];
|
|
28
|
+
isError: boolean;
|
|
29
|
+
} | {
|
|
30
|
+
content: {
|
|
31
|
+
type: "text";
|
|
32
|
+
text: string;
|
|
33
|
+
}[];
|
|
34
|
+
isError?: undefined;
|
|
21
35
|
}>;
|
package/dist/tools/messages.js
CHANGED
|
@@ -8,6 +8,10 @@ const schema_1 = require("../db/schema");
|
|
|
8
8
|
const time_1 = require("../utils/time");
|
|
9
9
|
function logMessage(db) {
|
|
10
10
|
return async (args) => {
|
|
11
|
+
const existing = await db.select({ id: schema_1.sessions.id }).from(schema_1.sessions).where((0, drizzle_orm_1.eq)(schema_1.sessions.id, args.session_id)).limit(1);
|
|
12
|
+
if (existing.length === 0) {
|
|
13
|
+
return { content: [{ type: 'text', text: `Session not found: ${args.session_id}` }], isError: true };
|
|
14
|
+
}
|
|
11
15
|
const now = (0, time_1.localISOString)();
|
|
12
16
|
const id = (0, uuid_1.v4)();
|
|
13
17
|
await db.insert(schema_1.messages).values({
|
|
@@ -27,6 +31,10 @@ function logMessage(db) {
|
|
|
27
31
|
}
|
|
28
32
|
function logExchange(db) {
|
|
29
33
|
return async (args) => {
|
|
34
|
+
const existing = await db.select({ id: schema_1.sessions.id }).from(schema_1.sessions).where((0, drizzle_orm_1.eq)(schema_1.sessions.id, args.session_id)).limit(1);
|
|
35
|
+
if (existing.length === 0) {
|
|
36
|
+
return { content: [{ type: 'text', text: `Session not found: ${args.session_id}` }], isError: true };
|
|
37
|
+
}
|
|
30
38
|
const userNow = (0, time_1.localISOString)();
|
|
31
39
|
const userId = (0, uuid_1.v4)();
|
|
32
40
|
const assistantNow = (0, time_1.localISOString)();
|
package/dist/tools/sessions.js
CHANGED
|
@@ -9,34 +9,37 @@ const schema_1 = require("../db/schema");
|
|
|
9
9
|
const time_1 = require("../utils/time");
|
|
10
10
|
function startSession(db) {
|
|
11
11
|
return async (args) => {
|
|
12
|
-
const
|
|
13
|
-
|
|
12
|
+
const id = (0, uuid_1.v4)();
|
|
13
|
+
const now = (0, time_1.localISOString)();
|
|
14
|
+
try {
|
|
15
|
+
await db.insert(schema_1.sessions).values({
|
|
16
|
+
id,
|
|
17
|
+
title: args.title,
|
|
18
|
+
ai_tool: args.ai_tool ?? null,
|
|
19
|
+
created_at: now,
|
|
20
|
+
updated_at: now,
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
content: [{
|
|
24
|
+
type: 'text',
|
|
25
|
+
text: JSON.stringify({ session_id: id, created: true, message_count: 0, ai_tool: args.ai_tool ?? null }),
|
|
26
|
+
}],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
catch (e) {
|
|
30
|
+
if (!e?.message?.includes('UNIQUE constraint failed'))
|
|
31
|
+
throw e;
|
|
32
|
+
const existing = await db.select().from(schema_1.sessions).where((0, drizzle_orm_1.eq)(schema_1.sessions.title, args.title)).limit(1);
|
|
14
33
|
const session = existing[0];
|
|
15
|
-
const
|
|
16
|
-
const allMessages = await db.select().from(schema_1.messages).where((0, drizzle_orm_1.eq)(schema_1.messages.session_id, session.id));
|
|
34
|
+
const [countRow] = await db.select({ count: (0, drizzle_orm_1.sql) `count(*)` }).from(schema_1.messages).where((0, drizzle_orm_1.eq)(schema_1.messages.session_id, session.id));
|
|
17
35
|
await db.update(schema_1.sessions).set({ updated_at: now }).where((0, drizzle_orm_1.eq)(schema_1.sessions.id, session.id));
|
|
18
36
|
return {
|
|
19
37
|
content: [{
|
|
20
38
|
type: 'text',
|
|
21
|
-
text: JSON.stringify({ session_id: session.id, created: false, message_count:
|
|
39
|
+
text: JSON.stringify({ session_id: session.id, created: false, message_count: countRow?.count ?? 0, ai_tool: session.ai_tool }),
|
|
22
40
|
}],
|
|
23
41
|
};
|
|
24
42
|
}
|
|
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
43
|
};
|
|
41
44
|
}
|
|
42
45
|
function listSessions(db) {
|
|
@@ -64,16 +67,21 @@ function listSessions(db) {
|
|
|
64
67
|
}
|
|
65
68
|
function getSessionHistory(db) {
|
|
66
69
|
return async (args) => {
|
|
67
|
-
const
|
|
70
|
+
const [countRow] = await db.select({ count: (0, drizzle_orm_1.sql) `count(*)` })
|
|
71
|
+
.from(schema_1.messages)
|
|
72
|
+
.where((0, drizzle_orm_1.eq)(schema_1.messages.session_id, args.session_id));
|
|
73
|
+
const total = countRow?.count ?? 0;
|
|
74
|
+
const baseQuery = db.select().from(schema_1.messages)
|
|
68
75
|
.where((0, drizzle_orm_1.eq)(schema_1.messages.session_id, args.session_id))
|
|
69
|
-
.orderBy((0, drizzle_orm_1.
|
|
70
|
-
const
|
|
76
|
+
.orderBy((0, drizzle_orm_1.desc)(schema_1.messages.created_at), (0, drizzle_orm_1.desc)((0, drizzle_orm_1.sql) `rowid`));
|
|
77
|
+
const rows = await (args.limit != null ? baseQuery.limit(args.limit) : baseQuery);
|
|
78
|
+
const limited = rows.reverse();
|
|
71
79
|
return {
|
|
72
80
|
content: [{
|
|
73
81
|
type: 'text',
|
|
74
82
|
text: JSON.stringify({
|
|
75
83
|
messages: limited.map(m => ({ role: m.role, content: m.content, created_at: m.created_at })),
|
|
76
|
-
total
|
|
84
|
+
total,
|
|
77
85
|
}),
|
|
78
86
|
}],
|
|
79
87
|
};
|
package/dist/utils/time.js
CHANGED
|
@@ -14,5 +14,6 @@ function localISOString() {
|
|
|
14
14
|
const hours = String(d.getHours()).padStart(2, '0');
|
|
15
15
|
const minutes = String(d.getMinutes()).padStart(2, '0');
|
|
16
16
|
const seconds = String(d.getSeconds()).padStart(2, '0');
|
|
17
|
-
|
|
17
|
+
const ms = String(d.getMilliseconds()).padStart(3, '0');
|
|
18
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${ms}${sign}${hh}:${mm}`;
|
|
18
19
|
}
|
package/package.json
CHANGED
package/skills/chron.skill.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Chron — AI Audit Log Skill
|
|
2
2
|
|
|
3
|
+
> **Applies in ALL contexts** — main sessions, dispatched subagents, parallel agents, and worktrees. The `SUBAGENT-STOP` guard in `using-superpowers` does NOT apply here. Log every exchange regardless of context.
|
|
4
|
+
|
|
3
5
|
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
6
|
|
|
5
7
|
## Session Start
|
|
@@ -14,9 +16,9 @@ Call `get_session_history` for the returned `session_id`. If prior messages exis
|
|
|
14
16
|
The rule is simple: **any time you output text and wait for input, log it.** This includes mid-task check-ins, not just clean user→assistant→user flows.
|
|
15
17
|
|
|
16
18
|
1. Call `log_message(session_id, role="user", content=<exact user message>)` → returns T1
|
|
17
|
-
2. Compose your response body (plain text only — no timestamp header)
|
|
19
|
+
2. Compose your response body internally (plain text only — no timestamp header). **Do NOT output any text yet.**
|
|
18
20
|
3. Call `log_message(session_id, role="assistant", content=<response body>)` → returns T2
|
|
19
|
-
4.
|
|
21
|
+
4. Only now output the header + body — exactly once:
|
|
20
22
|
|
|
21
23
|
```
|
|
22
24
|
[user: YYYY-MM-DD HH:MM:SS ±HH:MM | assistant: YYYY-MM-DD HH:MM:SS ±HH:MM]
|
|
@@ -24,11 +26,13 @@ The rule is simple: **any time you output text and wait for input, log it.** Thi
|
|
|
24
26
|
<response body>
|
|
25
27
|
```
|
|
26
28
|
|
|
29
|
+
**Never output text before step 3.** Doing so causes the response to appear twice — once before the log call and once after. Compose fully in your head, log, then output.
|
|
30
|
+
|
|
27
31
|
T1 and T2 are real server timestamps — T2 − T1 is the actual generation time. The DB stores clean body content without the header.
|
|
28
32
|
|
|
29
33
|
**Mid-task check-ins follow the same rule.** If you pause mid-task and ask the user something ("I've done the analysis, what's next?" / "Which approach do you prefer?"), that pause is an exchange — log the assistant message before outputting it, log the user's reply when it arrives. Every turn that crosses the user boundary gets timestamped, regardless of whether the task is "complete."
|
|
30
34
|
|
|
31
|
-
**Permission grants** —
|
|
35
|
+
**Permission grants** — whenever Claude resumes after a permission prompt (allow or deny), log a user message IMMEDIATELY before any other output or tool call: `"[permission: <tool> <action> — allowed/denied]"`. This applies mid-task, mid-worktree, and mid-subagent. Do not skip it even if the task is already in progress. This is the only visible record of the interaction since permission clicks are not typed messages.
|
|
32
36
|
|
|
33
37
|
**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.
|
|
34
38
|
|