@hivemagnus/ardent 0.0.0-stage → 1.0.2

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.
Files changed (115) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +274 -2
  3. package/dist/a2a-client.d.ts +35 -0
  4. package/dist/a2a-client.js +172 -0
  5. package/dist/a2a-hermes-alias.d.ts +3 -0
  6. package/dist/a2a-hermes-alias.js +57 -0
  7. package/dist/a2a-idempotency.d.ts +13 -0
  8. package/dist/a2a-idempotency.js +86 -0
  9. package/dist/a2a-peers.d.ts +28 -0
  10. package/dist/a2a-peers.js +108 -0
  11. package/dist/a2a-push-inbox.d.ts +22 -0
  12. package/dist/a2a-push-inbox.js +84 -0
  13. package/dist/a2a-push.d.ts +27 -0
  14. package/dist/a2a-push.js +188 -0
  15. package/dist/a2a-server.d.ts +37 -0
  16. package/dist/a2a-server.js +186 -0
  17. package/dist/a2a-store.d.ts +10 -0
  18. package/dist/a2a-store.js +122 -0
  19. package/dist/a2a.d.ts +19 -0
  20. package/dist/a2a.js +466 -0
  21. package/dist/api.d.ts +6 -0
  22. package/dist/api.js +381 -0
  23. package/dist/artifacts.d.ts +15 -0
  24. package/dist/artifacts.js +41 -0
  25. package/dist/backend-events.d.ts +49 -0
  26. package/dist/backend-events.js +8 -0
  27. package/dist/channels.d.ts +52 -0
  28. package/dist/channels.js +184 -0
  29. package/dist/cli.d.ts +2 -0
  30. package/dist/cli.js +845 -0
  31. package/dist/codex-output-budget.d.ts +17 -0
  32. package/dist/codex-output-budget.js +31 -0
  33. package/dist/codex-output-budget.test.d.ts +1 -0
  34. package/dist/codex-output-budget.test.js +29 -0
  35. package/dist/codex.d.ts +82 -0
  36. package/dist/codex.js +431 -0
  37. package/dist/config.d.ts +171 -0
  38. package/dist/config.js +244 -0
  39. package/dist/context-checkpoints.d.ts +49 -0
  40. package/dist/context-checkpoints.js +248 -0
  41. package/dist/context.d.ts +16 -0
  42. package/dist/context.js +101 -0
  43. package/dist/gateway-lock.d.ts +18 -0
  44. package/dist/gateway-lock.js +104 -0
  45. package/dist/hermes-compat.d.ts +15 -0
  46. package/dist/hermes-compat.js +62 -0
  47. package/dist/identity.d.ts +15 -0
  48. package/dist/identity.js +52 -0
  49. package/dist/index.d.ts +9 -0
  50. package/dist/index.js +8 -0
  51. package/dist/job-engine.d.ts +222 -0
  52. package/dist/job-engine.js +497 -0
  53. package/dist/job-news-handlers.d.ts +54 -0
  54. package/dist/job-news-handlers.js +153 -0
  55. package/dist/job-ownership.d.ts +9 -0
  56. package/dist/job-ownership.js +17 -0
  57. package/dist/json-schema.d.ts +9 -0
  58. package/dist/json-schema.js +20 -0
  59. package/dist/llamacpp.d.ts +49 -0
  60. package/dist/llamacpp.js +216 -0
  61. package/dist/magnus-control.d.ts +3 -0
  62. package/dist/magnus-control.js +99 -0
  63. package/dist/magnus-node-dependency-impact.d.ts +1 -0
  64. package/dist/magnus-node-dependency-impact.js +126 -0
  65. package/dist/magnus-owner-reads.d.ts +4 -0
  66. package/dist/magnus-owner-reads.js +99 -0
  67. package/dist/magnus-readiness.d.ts +1 -0
  68. package/dist/magnus-readiness.js +108 -0
  69. package/dist/mattermost.d.ts +18 -0
  70. package/dist/mattermost.js +303 -0
  71. package/dist/memory-tools.d.ts +5 -0
  72. package/dist/memory-tools.js +49 -0
  73. package/dist/memory.d.ts +40 -0
  74. package/dist/memory.js +128 -0
  75. package/dist/model-policy.d.ts +64 -0
  76. package/dist/model-policy.js +56 -0
  77. package/dist/news.d.ts +10 -0
  78. package/dist/news.js +291 -0
  79. package/dist/owner-read.d.ts +32 -0
  80. package/dist/owner-read.js +130 -0
  81. package/dist/plugins.d.ts +74 -0
  82. package/dist/plugins.js +115 -0
  83. package/dist/research.d.ts +110 -0
  84. package/dist/research.js +164 -0
  85. package/dist/runtime.d.ts +33 -0
  86. package/dist/runtime.js +665 -0
  87. package/dist/skills.d.ts +13 -0
  88. package/dist/skills.js +87 -0
  89. package/dist/store.d.ts +142 -0
  90. package/dist/store.js +322 -0
  91. package/dist/team-artifacts.d.ts +138 -0
  92. package/dist/team-artifacts.js +241 -0
  93. package/dist/team-child.d.ts +21 -0
  94. package/dist/team-child.js +130 -0
  95. package/dist/team-contract.d.ts +116 -0
  96. package/dist/team-contract.js +84 -0
  97. package/dist/team-control.d.ts +8 -0
  98. package/dist/team-control.js +152 -0
  99. package/dist/telegram.d.ts +35 -0
  100. package/dist/telegram.js +284 -0
  101. package/dist/tools.d.ts +48 -0
  102. package/dist/tools.js +58 -0
  103. package/dist/version.d.ts +1 -0
  104. package/dist/version.js +5 -0
  105. package/model-policy.default.json +20 -0
  106. package/package.json +84 -4
  107. package/profiles/assistant/IDENTITY.md +9 -0
  108. package/profiles/assistant/SOUL.md +8 -0
  109. package/profiles/assistant/config.yaml +88 -0
  110. package/profiles/assistant/peers.yaml +12 -0
  111. package/profiles/assistant/skills/grounded-answers/SKILL.md +18 -0
  112. package/protocol/LICENSE-A2A.txt +202 -0
  113. package/protocol/README.md +12 -0
  114. package/protocol/a2a.proto +811 -0
  115. package/protocol/lock.json +9 -0
@@ -0,0 +1,13 @@
1
+ import type { ArdentConfig } from './config.js';
2
+ import { type ToolRegistry } from './tools.js';
3
+ export type SkillInfo = {
4
+ name: string;
5
+ description: string;
6
+ hash: string;
7
+ enabled: boolean;
8
+ path: string;
9
+ };
10
+ export declare function listSkills(profileDir: string, config: ArdentConfig): Promise<SkillInfo[]>;
11
+ export declare function readSkill(profileDir: string, config: ArdentConfig, name: string): Promise<string>;
12
+ export declare function registerSkillTool(registry: ToolRegistry, profileDir: string, config: ArdentConfig): Promise<SkillInfo[]>;
13
+ export declare function setSkillEnabled(profileDir: string, name: string, enabled: boolean, expectedRevision: string): Promise<string>;
package/dist/skills.js ADDED
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, readdir, realpath } from 'node:fs/promises';
3
+ import { join, relative, resolve, sep } from 'node:path';
4
+ import YAML from 'yaml';
5
+ import { loadConfig, saveConfig } from './config.js';
6
+ import { defineTool } from './tools.js';
7
+ import { z } from 'zod';
8
+ const safeName = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
9
+ async function inside(root, target) {
10
+ const realRoot = await realpath(root);
11
+ const realTarget = await realpath(target);
12
+ const rel = relative(realRoot, realTarget);
13
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !resolve(realRoot, rel).startsWith(`..${sep}`));
14
+ }
15
+ function metadata(text) {
16
+ if (!text.startsWith('---\n'))
17
+ return undefined;
18
+ const end = text.indexOf('\n---\n', 4);
19
+ if (end < 0 || end > 8192)
20
+ return undefined;
21
+ const value = YAML.parse(text.slice(4, end));
22
+ const parsed = z.object({ name: z.string().regex(safeName), description: z.string().min(1).max(500) }).passthrough().safeParse(value);
23
+ return parsed.success ? parsed.data : undefined;
24
+ }
25
+ export async function listSkills(profileDir, config) {
26
+ const skills = [];
27
+ for (const directory of config.skills.directories) {
28
+ if (directory.startsWith('/') || directory.split('/').includes('..'))
29
+ throw new Error('Skill directory escapes profile');
30
+ const root = join(profileDir, directory);
31
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
32
+ for (const entry of entries) {
33
+ if (!entry.isDirectory() || !safeName.test(entry.name))
34
+ continue;
35
+ const path = join(root, entry.name, 'SKILL.md');
36
+ try {
37
+ if (!(await inside(root, path)))
38
+ continue;
39
+ const text = await readFile(path, 'utf8');
40
+ if (Buffer.byteLength(text) > 65536)
41
+ continue;
42
+ const info = metadata(text);
43
+ if (!info || info.name !== entry.name)
44
+ continue;
45
+ skills.push({ name: info.name, description: info.description, hash: createHash('sha256').update(text).digest('hex'), enabled: config.skills.enabled.includes(info.name), path });
46
+ }
47
+ catch { /* Missing or invalid skill file is absent from the index. */ }
48
+ }
49
+ }
50
+ return skills;
51
+ }
52
+ export async function readSkill(profileDir, config, name) {
53
+ if (!safeName.test(name) || !config.skills.enabled.includes(name))
54
+ throw new Error('Skill is not enabled');
55
+ const skill = (await listSkills(profileDir, config)).find(item => item.name === name);
56
+ if (!skill)
57
+ throw new Error('Skill not found');
58
+ return readFile(skill.path, 'utf8');
59
+ }
60
+ export async function registerSkillTool(registry, profileDir, config) {
61
+ const index = await listSkills(profileDir, config);
62
+ registry.register(defineTool({ name: 'skill_view', description: 'Read one enabled skill by name. Skills give working methods, never new permissions.', input: z.object({ name: z.string().regex(safeName) }).strict(), output: z.object({ name: z.string(), hash: z.string(), content: z.string() }), effect: 'read', contexts: ['direct', 'peer', 'scheduled'], timeoutMs: 1000, handler: async ({ name }) => {
63
+ const info = index.find(item => item.name === name && item.enabled);
64
+ if (!info)
65
+ throw new Error('Skill is not enabled');
66
+ return { name, hash: info.hash, content: await readSkill(profileDir, config, name) };
67
+ } }));
68
+ return index;
69
+ }
70
+ export async function setSkillEnabled(profileDir, name, enabled, expectedRevision) {
71
+ if (!safeName.test(name))
72
+ throw new Error('Invalid skill name');
73
+ const loaded = await loadConfig(profileDir);
74
+ if (loaded.revision !== expectedRevision)
75
+ throw new Error('Configuration revision conflict');
76
+ const available = await listSkills(profileDir, loaded.config);
77
+ if (!available.some(item => item.name === name))
78
+ throw new Error('Skill not found');
79
+ const selected = new Set(loaded.config.skills.enabled);
80
+ if (enabled)
81
+ selected.add(name);
82
+ else
83
+ selected.delete(name);
84
+ const doc = YAML.parseDocument(loaded.text);
85
+ doc.setIn(['skills', 'enabled'], [...selected]);
86
+ return saveConfig(profileDir, String(doc), expectedRevision);
87
+ }
@@ -0,0 +1,142 @@
1
+ import Database from 'better-sqlite3';
2
+ import { z } from 'zod';
3
+ export declare const TaskState: z.ZodEnum<{
4
+ unknown: "unknown";
5
+ queued: "queued";
6
+ failed: "failed";
7
+ completed: "completed";
8
+ running: "running";
9
+ input_required: "input_required";
10
+ partial: "partial";
11
+ cancelled: "cancelled";
12
+ }>;
13
+ export type TaskState = z.infer<typeof TaskState>;
14
+ export declare const TaskSchema: z.ZodObject<{
15
+ id: z.ZodString;
16
+ caller: z.ZodString;
17
+ context: z.ZodEnum<{
18
+ direct: "direct";
19
+ peer: "peer";
20
+ scheduled: "scheduled";
21
+ }>;
22
+ session_id: z.ZodNullable<z.ZodString>;
23
+ prompt: z.ZodString;
24
+ content_hash: z.ZodString;
25
+ idempotency_key: z.ZodNullable<z.ZodString>;
26
+ backend: z.ZodEnum<{
27
+ llamacpp: "llamacpp";
28
+ codex: "codex";
29
+ }>;
30
+ state: z.ZodEnum<{
31
+ unknown: "unknown";
32
+ queued: "queued";
33
+ failed: "failed";
34
+ completed: "completed";
35
+ running: "running";
36
+ input_required: "input_required";
37
+ partial: "partial";
38
+ cancelled: "cancelled";
39
+ }>;
40
+ result: z.ZodNullable<z.ZodString>;
41
+ error: z.ZodNullable<z.ZodString>;
42
+ deadline_ms: z.ZodNumber;
43
+ created_ms: z.ZodNumber;
44
+ updated_ms: z.ZodNumber;
45
+ }, z.core.$strip>;
46
+ export type Task = z.infer<typeof TaskSchema>;
47
+ export declare const SessionSchema: z.ZodObject<{
48
+ id: z.ZodString;
49
+ caller: z.ZodString;
50
+ context: z.ZodEnum<{
51
+ direct: "direct";
52
+ peer: "peer";
53
+ scheduled: "scheduled";
54
+ }>;
55
+ key: z.ZodString;
56
+ created_ms: z.ZodNumber;
57
+ updated_ms: z.ZodNumber;
58
+ }, z.core.$strip>;
59
+ export type Session = z.infer<typeof SessionSchema>;
60
+ export declare const SessionMessageSchema: z.ZodObject<{
61
+ sequence: z.ZodNumber;
62
+ session_id: z.ZodString;
63
+ task_id: z.ZodString;
64
+ role: z.ZodEnum<{
65
+ assistant: "assistant";
66
+ user: "user";
67
+ }>;
68
+ content: z.ZodString;
69
+ created_ms: z.ZodNumber;
70
+ }, z.core.$strip>;
71
+ export type SessionMessage = z.infer<typeof SessionMessageSchema>;
72
+ export declare const EventSchema: z.ZodObject<{
73
+ sequence: z.ZodNumber;
74
+ task_id: z.ZodString;
75
+ kind: z.ZodString;
76
+ detail: z.ZodOptional<z.ZodUnknown>;
77
+ created_ms: z.ZodNumber;
78
+ }, z.core.$strip>;
79
+ export type TaskEvent = z.infer<typeof EventSchema>;
80
+ export declare class TaskStore {
81
+ readonly db: Database.Database;
82
+ constructor(path: string);
83
+ close(): void;
84
+ admissionFence(): {
85
+ release_id: string;
86
+ closed_ms: number;
87
+ } | null;
88
+ closeAdmission(releaseId: string): {
89
+ release_id: string;
90
+ closed_ms: number;
91
+ queued: number;
92
+ running: number;
93
+ unknown: number;
94
+ deliveries_unsettled: number;
95
+ inbound_unbound: number;
96
+ };
97
+ session(caller: string, context: 'direct' | 'peer' | 'scheduled', key: string): Session;
98
+ resetSession(caller: string, context: 'direct' | 'peer' | 'scheduled', key: string): Session;
99
+ listSessions(caller: string, context: 'direct' | 'peer' | 'scheduled', limit?: number): Session[];
100
+ sessionMessages(sessionId: string, caller: string, limit?: number): SessionMessage[];
101
+ accept(input: {
102
+ caller: string;
103
+ context: 'direct' | 'peer' | 'scheduled';
104
+ sessionId?: string;
105
+ prompt: string;
106
+ idempotencyKey?: string;
107
+ backend: 'llamacpp' | 'codex';
108
+ deadlineMs: number;
109
+ }): Task;
110
+ appendAssistant(task: Task, content: string): void;
111
+ finishWithAssistant(id: string, state: 'completed' | 'partial', content: string): void;
112
+ latestSessionTask(sessionId: string, caller: string): Task | undefined;
113
+ hasInbound(platform: string, account: string, updateId: string): boolean;
114
+ claimInbound(platform: string, account: string, updateId: string, channelType?: 'unknown' | 'telegram_private' | 'telegram_group' | 'telegram_supergroup' | 'mattermost_dm' | 'mattermost_channel'): {
115
+ claimed: boolean;
116
+ taskId: string | null;
117
+ };
118
+ bindInboundTask(platform: string, account: string, updateId: string, taskId: string): void;
119
+ cursor(key: string): string | undefined;
120
+ setCursor(key: string, value: string): void;
121
+ channelBackend(caller: string, sessionKey: string): 'llamacpp' | 'codex' | undefined;
122
+ setChannelBackend(caller: string, sessionKey: string, backend: 'llamacpp' | 'codex'): void;
123
+ delivery(platform: string, account: string, updateId: string): {
124
+ id: string;
125
+ state: 'queued' | 'sent' | 'failed' | 'unknown';
126
+ taskId: string | null;
127
+ externalId: string | null;
128
+ } | undefined;
129
+ queueDelivery(platform: string, account: string, updateId: string, taskId: string | null): string;
130
+ setDelivery(id: string, state: 'sent' | 'failed' | 'unknown', externalId?: string | null, error?: string | null): void;
131
+ get(id: string): Task | undefined;
132
+ list(limit?: number): Task[];
133
+ listByCaller(caller: string, limit?: number): Task[];
134
+ setState(id: string, state: TaskState, result?: string | null, error?: string | null): void;
135
+ startQueued(id: string): void;
136
+ append(id: string, kind: string, detail: unknown): void;
137
+ events(id: string, after?: number, limit?: number): TaskEvent[];
138
+ recover(): {
139
+ queued: Task[];
140
+ interrupted: number;
141
+ };
142
+ }
package/dist/store.js ADDED
@@ -0,0 +1,322 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import Database from 'better-sqlite3';
5
+ import { z } from 'zod';
6
+ export const TaskState = z.enum(['queued', 'running', 'input_required', 'completed', 'partial', 'failed', 'cancelled', 'unknown']);
7
+ export const TaskSchema = z.object({ id: z.string().guid(), caller: z.string(), context: z.enum(['direct', 'peer', 'scheduled']), session_id: z.string().guid().nullable(), prompt: z.string(), content_hash: z.string(), idempotency_key: z.string().nullable(), backend: z.enum(['llamacpp', 'codex']), state: TaskState, result: z.string().nullable(), error: z.string().nullable(), deadline_ms: z.number().int(), created_ms: z.number().int(), updated_ms: z.number().int() });
8
+ export const SessionSchema = z.object({ id: z.string().guid(), caller: z.string(), context: z.enum(['direct', 'peer', 'scheduled']), key: z.string(), created_ms: z.number().int(), updated_ms: z.number().int() });
9
+ export const SessionMessageSchema = z.object({ sequence: z.number().int(), session_id: z.string().guid(), task_id: z.string().guid(), role: z.enum(['user', 'assistant']), content: z.string(), created_ms: z.number().int() });
10
+ export const EventSchema = z.object({ sequence: z.number().int(), task_id: z.string(), kind: z.string(), detail: z.unknown().optional(), created_ms: z.number().int() });
11
+ export class TaskStore {
12
+ db;
13
+ constructor(path) {
14
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
15
+ this.db = new Database(path);
16
+ this.db.pragma('journal_mode = WAL');
17
+ this.db.pragma('foreign_keys = ON');
18
+ this.db.exec(`CREATE TABLE IF NOT EXISTS tasks (
19
+ id TEXT PRIMARY KEY, caller TEXT NOT NULL, context TEXT NOT NULL, prompt TEXT NOT NULL,
20
+ session_id TEXT, content_hash TEXT NOT NULL, idempotency_key TEXT, backend TEXT NOT NULL, state TEXT NOT NULL,
21
+ result TEXT, error TEXT, deadline_ms INTEGER NOT NULL, created_ms INTEGER NOT NULL, updated_ms INTEGER NOT NULL,
22
+ UNIQUE(caller, context, idempotency_key));
23
+ CREATE TABLE IF NOT EXISTS sessions (
24
+ id TEXT PRIMARY KEY, caller TEXT NOT NULL, context TEXT NOT NULL, key TEXT NOT NULL,
25
+ created_ms INTEGER NOT NULL, updated_ms INTEGER NOT NULL, UNIQUE(caller, context, key));
26
+ CREATE TABLE IF NOT EXISTS session_messages (
27
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL REFERENCES sessions(id),
28
+ task_id TEXT NOT NULL REFERENCES tasks(id), role TEXT NOT NULL, content TEXT NOT NULL, created_ms INTEGER NOT NULL);
29
+ CREATE INDEX IF NOT EXISTS session_messages_session_sequence ON session_messages(session_id, sequence);
30
+ CREATE TABLE IF NOT EXISTS channel_inbound (
31
+ platform TEXT NOT NULL, account TEXT NOT NULL, update_id TEXT NOT NULL,
32
+ task_id TEXT REFERENCES tasks(id), created_ms INTEGER NOT NULL,
33
+ channel_type TEXT NOT NULL DEFAULT 'unknown' CHECK(channel_type IN
34
+ ('unknown','telegram_private','telegram_group','telegram_supergroup',
35
+ 'mattermost_dm','mattermost_channel')),
36
+ PRIMARY KEY(platform, account, update_id));
37
+ CREATE TABLE IF NOT EXISTS channel_cursors (
38
+ key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_ms INTEGER NOT NULL);
39
+ CREATE TABLE IF NOT EXISTS channel_preferences (
40
+ caller TEXT NOT NULL, session_key TEXT NOT NULL, backend TEXT NOT NULL,
41
+ updated_ms INTEGER NOT NULL, PRIMARY KEY(caller, session_key));
42
+ CREATE TABLE IF NOT EXISTS channel_deliveries (
43
+ id TEXT PRIMARY KEY, platform TEXT NOT NULL, account TEXT NOT NULL, update_id TEXT NOT NULL,
44
+ task_id TEXT REFERENCES tasks(id), state TEXT NOT NULL, external_id TEXT, error TEXT,
45
+ created_ms INTEGER NOT NULL, updated_ms INTEGER NOT NULL,
46
+ UNIQUE(platform, account, update_id));
47
+ CREATE TABLE IF NOT EXISTS events (
48
+ sequence INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(id),
49
+ kind TEXT NOT NULL, detail_json TEXT NOT NULL, created_ms INTEGER NOT NULL);
50
+ CREATE INDEX IF NOT EXISTS events_task_sequence ON events(task_id, sequence);
51
+ CREATE TABLE IF NOT EXISTS admission_fence (
52
+ singleton INTEGER PRIMARY KEY CHECK(singleton = 1), release_id TEXT NOT NULL,
53
+ closed_ms INTEGER NOT NULL);
54
+ CREATE TABLE IF NOT EXISTS context_checkpoints (
55
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES sessions(id),
56
+ caller TEXT NOT NULL, context TEXT NOT NULL, parent_id TEXT,
57
+ source_watermark INTEGER NOT NULL, source_sha256 TEXT NOT NULL,
58
+ summary_json TEXT NOT NULL, summary_sha256 TEXT NOT NULL,
59
+ model TEXT NOT NULL, reasoning_effort TEXT NOT NULL,
60
+ policy_revision INTEGER NOT NULL, policy_sha256 TEXT NOT NULL,
61
+ source_tokens INTEGER NOT NULL, summary_tokens INTEGER NOT NULL,
62
+ created_ms INTEGER NOT NULL, validation TEXT NOT NULL CHECK(validation='passed'));
63
+ CREATE INDEX IF NOT EXISTS context_checkpoints_session ON context_checkpoints(session_id,created_ms);
64
+ CREATE TABLE IF NOT EXISTS context_checkpoint_heads (
65
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id), checkpoint_id TEXT NOT NULL REFERENCES context_checkpoints(id));
66
+ CREATE TABLE IF NOT EXISTS context_compaction_attempts (
67
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES sessions(id),
68
+ source_watermark INTEGER, parent_id TEXT, policy_revision INTEGER,
69
+ model TEXT, outcome TEXT NOT NULL, reason TEXT,
70
+ created_ms INTEGER NOT NULL);
71
+ CREATE INDEX IF NOT EXISTS context_attempts_session ON context_compaction_attempts(session_id,created_ms);`);
72
+ const columns = z.array(z.object({ name: z.string() })).parse(this.db.pragma('table_info(tasks)'));
73
+ if (!columns.some(column => column.name === 'session_id'))
74
+ this.db.exec('ALTER TABLE tasks ADD COLUMN session_id TEXT');
75
+ // Additive lookup indexes for caller listing, session history and recovery.
76
+ // Created after the column migration so an older database gains session_id first.
77
+ this.db.exec(`CREATE INDEX IF NOT EXISTS tasks_caller_created ON tasks(caller, created_ms);
78
+ CREATE INDEX IF NOT EXISTS tasks_session_created ON tasks(session_id, created_ms);
79
+ CREATE INDEX IF NOT EXISTS tasks_state ON tasks(state);`);
80
+ const inboundColumns = z.array(z.object({ name: z.string() })).parse(this.db.pragma('table_info(channel_inbound)'));
81
+ if (!inboundColumns.some(column => column.name === 'channel_type'))
82
+ this.db.exec(`ALTER TABLE channel_inbound ADD COLUMN
83
+ channel_type TEXT NOT NULL DEFAULT 'unknown' CHECK(channel_type IN
84
+ ('unknown','telegram_private','telegram_group','telegram_supergroup',
85
+ 'mattermost_dm','mattermost_channel'))`);
86
+ this.db.exec(`CREATE TRIGGER IF NOT EXISTS channel_inbound_type_immutable
87
+ BEFORE UPDATE OF channel_type ON channel_inbound
88
+ WHEN OLD.channel_type != NEW.channel_type
89
+ BEGIN SELECT RAISE(ABORT, 'Inbound channel type is immutable'); END;`);
90
+ this.db.exec(`CREATE TRIGGER IF NOT EXISTS session_messages_immutable_update
91
+ BEFORE UPDATE ON session_messages BEGIN SELECT RAISE(ABORT, 'Session messages are immutable'); END;
92
+ CREATE TRIGGER IF NOT EXISTS session_messages_immutable_delete
93
+ BEFORE DELETE ON session_messages BEGIN SELECT RAISE(ABORT, 'Session messages are immutable'); END;`);
94
+ }
95
+ close() { this.db.close(); }
96
+ admissionFence() {
97
+ const row = this.db.prepare('SELECT release_id,closed_ms FROM admission_fence WHERE singleton=1').get();
98
+ return row ? z.object({ release_id: z.string().guid(), closed_ms: z.number().int() }).parse(row) : null;
99
+ }
100
+ closeAdmission(releaseId) {
101
+ z.string().guid().parse(releaseId);
102
+ return this.db.transaction(() => {
103
+ const prior = this.admissionFence();
104
+ if (prior && prior.release_id !== releaseId)
105
+ throw new Error('Admission is already fenced by another release');
106
+ if (!prior)
107
+ this.db.prepare('INSERT INTO admission_fence(singleton,release_id,closed_ms) VALUES (1,?,?)').run(releaseId, Date.now());
108
+ const state = this.admissionFence();
109
+ const count = (sql) => z.object({ count: z.number().int() }).parse(this.db.prepare(sql).get()).count;
110
+ return { ...state,
111
+ queued: count("SELECT count(*) AS count FROM tasks WHERE state='queued'"),
112
+ running: count("SELECT count(*) AS count FROM tasks WHERE state='running'"),
113
+ unknown: count("SELECT count(*) AS count FROM tasks WHERE state='unknown'"),
114
+ deliveries_unsettled: count("SELECT count(*) AS count FROM channel_deliveries WHERE state IN ('queued','unknown')"),
115
+ inbound_unbound: count(`SELECT count(*) AS count FROM channel_inbound AS inbound
116
+ LEFT JOIN channel_deliveries AS delivery ON delivery.platform=inbound.platform
117
+ AND delivery.account=inbound.account AND delivery.update_id=inbound.update_id
118
+ WHERE inbound.task_id IS NULL AND (delivery.id IS NULL OR delivery.state IN ('queued','unknown'))`) };
119
+ })();
120
+ }
121
+ session(caller, context, key) {
122
+ if (!caller || caller.length > 128 || !key || key.length > 256)
123
+ throw new Error('Invalid session identity');
124
+ return this.db.transaction(() => {
125
+ const previous = this.db.prepare('SELECT * FROM sessions WHERE caller=? AND context=? AND key=?').get(caller, context, key);
126
+ if (previous)
127
+ return SessionSchema.parse(previous);
128
+ const now = Date.now();
129
+ const value = { id: randomUUID(), caller, context, key, created_ms: now, updated_ms: now };
130
+ this.db.prepare('INSERT INTO sessions(id,caller,context,key,created_ms,updated_ms) VALUES (@id,@caller,@context,@key,@created_ms,@updated_ms)').run(value);
131
+ return value;
132
+ })();
133
+ }
134
+ resetSession(caller, context, key) {
135
+ return this.db.transaction(() => {
136
+ const previous = this.db.prepare('SELECT * FROM sessions WHERE caller=? AND context=? AND key=?').get(caller, context, key);
137
+ if (previous)
138
+ this.db.prepare('UPDATE sessions SET key=? WHERE id=?').run(`${key}:archived:${randomUUID()}`, SessionSchema.parse(previous).id);
139
+ return this.session(caller, context, key);
140
+ })();
141
+ }
142
+ listSessions(caller, context, limit = 50) {
143
+ return this.db.prepare('SELECT * FROM sessions WHERE caller=? AND context=? ORDER BY updated_ms DESC LIMIT ?').all(caller, context, Math.max(1, Math.min(limit, 100))).map(row => SessionSchema.parse(row));
144
+ }
145
+ sessionMessages(sessionId, caller, limit = 100) {
146
+ const session = this.db.prepare('SELECT * FROM sessions WHERE id=?').get(sessionId);
147
+ if (!session || SessionSchema.parse(session).caller !== caller)
148
+ throw new Error('Session not found');
149
+ return this.db.prepare('SELECT * FROM (SELECT * FROM session_messages WHERE session_id=? ORDER BY sequence DESC LIMIT ?) ORDER BY sequence').all(sessionId, Math.max(1, Math.min(limit, 500))).map(row => SessionMessageSchema.parse(row));
150
+ }
151
+ accept(input) {
152
+ if (!input.caller || input.caller.length > 128 || !input.prompt.trim() || input.prompt.length > 16000 || (input.idempotencyKey && input.idempotencyKey.length > 128))
153
+ throw new Error('Invalid task input');
154
+ const hash = createHash('sha256').update(JSON.stringify([input.prompt, input.backend])).digest('hex');
155
+ return this.db.transaction(() => {
156
+ if (this.admissionFence())
157
+ throw new Error('Profile admission is fenced for rollback');
158
+ if (input.sessionId) {
159
+ const session = this.db.prepare('SELECT * FROM sessions WHERE id=?').get(input.sessionId);
160
+ if (!session)
161
+ throw new Error('Session not found');
162
+ const scoped = SessionSchema.parse(session);
163
+ if (scoped.caller !== input.caller || scoped.context !== input.context)
164
+ throw new Error('Session caller or context mismatch');
165
+ }
166
+ if (input.idempotencyKey) {
167
+ const previous = this.db.prepare('SELECT * FROM tasks WHERE caller=? AND context=? AND idempotency_key=?').get(input.caller, input.context, input.idempotencyKey);
168
+ if (previous) {
169
+ const task = TaskSchema.parse(previous);
170
+ if (task.content_hash !== hash)
171
+ throw new Error('Idempotency key reused with different task content');
172
+ return task;
173
+ }
174
+ }
175
+ const now = Date.now();
176
+ const task = { id: randomUUID(), caller: input.caller, context: input.context, session_id: input.sessionId ?? null, prompt: input.prompt, content_hash: hash, idempotency_key: input.idempotencyKey ?? null, backend: input.backend, state: 'queued', result: null, error: null, deadline_ms: input.deadlineMs, created_ms: now, updated_ms: now };
177
+ this.db.prepare(`INSERT INTO tasks (id,caller,context,session_id,prompt,content_hash,idempotency_key,backend,state,result,error,deadline_ms,created_ms,updated_ms)
178
+ VALUES (@id,@caller,@context,@session_id,@prompt,@content_hash,@idempotency_key,@backend,@state,@result,@error,@deadline_ms,@created_ms,@updated_ms)`).run(task);
179
+ if (task.session_id)
180
+ this.db.prepare('INSERT INTO session_messages(session_id,task_id,role,content,created_ms) VALUES (?,?,?,?,?)').run(task.session_id, task.id, 'user', task.prompt, now);
181
+ this.append(task.id, 'accepted', { caller: input.caller, context: input.context, backend: input.backend });
182
+ return task;
183
+ })();
184
+ }
185
+ appendAssistant(task, content) {
186
+ if (!task.session_id)
187
+ return;
188
+ this.db.transaction(() => {
189
+ this.db.prepare('INSERT INTO session_messages(session_id,task_id,role,content,created_ms) VALUES (?,?,?,?,?)').run(task.session_id, task.id, 'assistant', content, Date.now());
190
+ this.db.prepare('UPDATE sessions SET updated_ms=? WHERE id=?').run(Date.now(), task.session_id);
191
+ })();
192
+ }
193
+ finishWithAssistant(id, state, content) {
194
+ this.db.transaction(() => {
195
+ const prior = this.get(id);
196
+ if (!prior || ['completed', 'partial', 'failed', 'cancelled', 'unknown'].includes(prior.state))
197
+ throw new Error('Task is absent or terminal');
198
+ const now = Date.now();
199
+ this.db.prepare('UPDATE tasks SET state=?,result=?,error=NULL,updated_ms=? WHERE id=?').run(state, content, now, id);
200
+ this.append(id, 'state', { from: prior.state, to: state, error: null });
201
+ if (prior.session_id) {
202
+ this.db.prepare('INSERT INTO session_messages(session_id,task_id,role,content,created_ms) VALUES (?,?,?,?,?)')
203
+ .run(prior.session_id, id, 'assistant', content, now);
204
+ this.db.prepare('UPDATE sessions SET updated_ms=? WHERE id=?').run(now, prior.session_id);
205
+ }
206
+ })();
207
+ }
208
+ latestSessionTask(sessionId, caller) {
209
+ const row = this.db.prepare('SELECT t.* FROM tasks t JOIN sessions s ON s.id=t.session_id WHERE t.session_id=? AND s.caller=? ORDER BY t.created_ms DESC LIMIT 1').get(sessionId, caller);
210
+ return row ? TaskSchema.parse(row) : undefined;
211
+ }
212
+ hasInbound(platform, account, updateId) {
213
+ return this.db.prepare('SELECT 1 FROM channel_inbound WHERE platform=? AND account=? AND update_id=?')
214
+ .get(platform, account, updateId) !== undefined;
215
+ }
216
+ claimInbound(platform, account, updateId, channelType = 'unknown') {
217
+ if (!platform || !account || !updateId || updateId.length > 256)
218
+ throw new Error('Invalid channel update identity');
219
+ if ((platform === 'telegram' && channelType.startsWith('mattermost_')) ||
220
+ (platform === 'mattermost' && channelType.startsWith('telegram_')) ||
221
+ !['unknown', 'telegram_private', 'telegram_group', 'telegram_supergroup',
222
+ 'mattermost_dm', 'mattermost_channel'].includes(channelType))
223
+ throw new Error('Inbound channel type differs from platform');
224
+ return this.db.transaction(() => {
225
+ if (this.admissionFence())
226
+ throw new Error('Profile admission is fenced for rollback');
227
+ const row = this.db.prepare('SELECT task_id,channel_type FROM channel_inbound WHERE platform=? AND account=? AND update_id=?').get(platform, account, updateId);
228
+ if (row) {
229
+ const prior = z.object({ task_id: z.string().nullable(), channel_type: z.string() }).parse(row);
230
+ if (prior.channel_type !== 'unknown' && channelType !== 'unknown' &&
231
+ prior.channel_type !== channelType)
232
+ throw new Error('Inbound channel type changed');
233
+ return { claimed: false, taskId: prior.task_id };
234
+ }
235
+ this.db.prepare(`INSERT INTO channel_inbound
236
+ (platform,account,update_id,task_id,created_ms,channel_type) VALUES (?,?,?,?,?,?)`)
237
+ .run(platform, account, updateId, null, Date.now(), channelType);
238
+ return { claimed: true, taskId: null };
239
+ })();
240
+ }
241
+ bindInboundTask(platform, account, updateId, taskId) {
242
+ this.db.prepare('UPDATE channel_inbound SET task_id=? WHERE platform=? AND account=? AND update_id=? AND task_id IS NULL').run(taskId, platform, account, updateId);
243
+ }
244
+ cursor(key) {
245
+ const row = this.db.prepare('SELECT value FROM channel_cursors WHERE key=?').get(key);
246
+ return row ? z.object({ value: z.string() }).parse(row).value : undefined;
247
+ }
248
+ setCursor(key, value) {
249
+ this.db.prepare('INSERT INTO channel_cursors(key,value,updated_ms) VALUES (?,?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value,updated_ms=excluded.updated_ms').run(key, value, Date.now());
250
+ }
251
+ channelBackend(caller, sessionKey) {
252
+ const row = this.db.prepare('SELECT backend FROM channel_preferences WHERE caller=? AND session_key=?').get(caller, sessionKey);
253
+ return row ? z.object({ backend: z.enum(['llamacpp', 'codex']) }).parse(row).backend : undefined;
254
+ }
255
+ setChannelBackend(caller, sessionKey, backend) {
256
+ this.db.prepare('INSERT INTO channel_preferences(caller,session_key,backend,updated_ms) VALUES (?,?,?,?) ON CONFLICT(caller,session_key) DO UPDATE SET backend=excluded.backend,updated_ms=excluded.updated_ms').run(caller, sessionKey, backend, Date.now());
257
+ }
258
+ delivery(platform, account, updateId) {
259
+ const row = this.db.prepare('SELECT id,state,task_id,external_id FROM channel_deliveries WHERE platform=? AND account=? AND update_id=?').get(platform, account, updateId);
260
+ return row ? z.object({ id: z.string(), state: z.enum(['queued', 'sent', 'failed', 'unknown']), task_id: z.string().nullable(), external_id: z.string().nullable() }).transform(value => ({ id: value.id, state: value.state, taskId: value.task_id, externalId: value.external_id })).parse(row) : undefined;
261
+ }
262
+ queueDelivery(platform, account, updateId, taskId) {
263
+ const id = randomUUID();
264
+ this.db.prepare('INSERT OR IGNORE INTO channel_deliveries(id,platform,account,update_id,task_id,state,external_id,error,created_ms,updated_ms) VALUES (?,?,?,?,?,?,?,?,?,?)').run(id, platform, account, updateId, taskId, 'queued', null, null, Date.now(), Date.now());
265
+ return this.delivery(platform, account, updateId)?.id ?? id;
266
+ }
267
+ setDelivery(id, state, externalId = null, error = null) {
268
+ this.db.prepare('UPDATE channel_deliveries SET state=?,external_id=?,error=?,updated_ms=? WHERE id=?').run(state, externalId, error, Date.now(), id);
269
+ }
270
+ get(id) {
271
+ const row = this.db.prepare('SELECT * FROM tasks WHERE id=?').get(id);
272
+ return row ? TaskSchema.parse(row) : undefined;
273
+ }
274
+ list(limit = 50) {
275
+ return this.db.prepare('SELECT * FROM tasks ORDER BY created_ms DESC LIMIT ?').all(Math.max(1, Math.min(limit, 100))).map(row => TaskSchema.parse(row));
276
+ }
277
+ listByCaller(caller, limit = 50) {
278
+ return this.db.prepare('SELECT * FROM tasks WHERE caller=? ORDER BY created_ms DESC LIMIT ?').all(caller, Math.max(1, Math.min(limit, 100))).map(row => TaskSchema.parse(row));
279
+ }
280
+ setState(id, state, result = null, error = null) {
281
+ const prior = this.get(id);
282
+ if (!prior)
283
+ throw new Error('Task not found');
284
+ const terminal = ['completed', 'partial', 'failed', 'cancelled', 'unknown'];
285
+ if (terminal.includes(prior.state))
286
+ throw new Error('Terminal task cannot transition');
287
+ this.db.transaction(() => {
288
+ this.db.prepare('UPDATE tasks SET state=?,result=?,error=?,updated_ms=? WHERE id=?').run(state, result, error, Date.now(), id);
289
+ this.append(id, 'state', { from: prior.state, to: state, error });
290
+ })();
291
+ }
292
+ startQueued(id) {
293
+ this.db.transaction(() => {
294
+ if (this.admissionFence())
295
+ throw new Error('Profile admission is fenced for rollback');
296
+ const task = this.get(id);
297
+ if (!task || task.state !== 'queued')
298
+ throw new Error('Task is no longer queued');
299
+ this.db.prepare('UPDATE tasks SET state=?,updated_ms=? WHERE id=?').run('running', Date.now(), id);
300
+ this.append(id, 'state', { from: 'queued', to: 'running', error: null });
301
+ })();
302
+ }
303
+ append(id, kind, detail) {
304
+ this.db.prepare('INSERT INTO events(task_id,kind,detail_json,created_ms) VALUES (?,?,?,?)').run(id, kind, JSON.stringify(detail), Date.now());
305
+ }
306
+ events(id, after = 0, limit = 100) {
307
+ const rows = this.db.prepare('SELECT * FROM events WHERE task_id=? AND sequence>? ORDER BY sequence LIMIT ?').all(id, after, Math.max(1, Math.min(limit, 500)));
308
+ return rows.map(row => {
309
+ const value = z.object({ sequence: z.number(), task_id: z.string(), kind: z.string(), detail_json: z.string(), created_ms: z.number() }).parse(row);
310
+ return EventSchema.parse({ sequence: value.sequence, task_id: value.task_id, kind: value.kind, detail: JSON.parse(value.detail_json), created_ms: value.created_ms });
311
+ });
312
+ }
313
+ recover() {
314
+ const running = this.db.prepare("SELECT id FROM tasks WHERE state='running'").all().map(row => z.object({ id: z.string() }).parse(row));
315
+ this.db.transaction(() => {
316
+ for (const row of running)
317
+ this.setState(row.id, 'unknown', null, 'Interrupted during execution; provider/tool effect may be unresolved');
318
+ this.db.prepare("UPDATE channel_deliveries SET state='unknown',error='Process stopped during a queued delivery; send outcome is uncertain',updated_ms=? WHERE state='queued'").run(Date.now());
319
+ })();
320
+ return { queued: this.db.prepare("SELECT * FROM tasks WHERE state='queued' ORDER BY created_ms").all().map(row => TaskSchema.parse(row)), interrupted: running.length };
321
+ }
322
+ }