flint-agent 1.14.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.
Files changed (171) hide show
  1. package/.env.example +108 -0
  2. package/CHANGELOG.md +55 -0
  3. package/FEATURES.md +298 -0
  4. package/LICENSE +21 -0
  5. package/README.md +435 -0
  6. package/bin/flint.js +47 -0
  7. package/config/classifier-prompt.md +218 -0
  8. package/config/models-curated.json +4 -0
  9. package/config/providers.json +74 -0
  10. package/package.json +92 -0
  11. package/patches/ink+6.8.0.patch +78 -0
  12. package/profiles/desktop.md +65 -0
  13. package/profiles/generic.md +20 -0
  14. package/profiles/marketer.md +20 -0
  15. package/profiles/profiles.json +34 -0
  16. package/profiles/ux-reviewer.md +25 -0
  17. package/src/agent/agent.js +1743 -0
  18. package/src/agent/auto.js +346 -0
  19. package/src/agent/backoff.js +143 -0
  20. package/src/agent/compression.js +310 -0
  21. package/src/agent/content-resolver.js +180 -0
  22. package/src/agent/flow-controller.js +309 -0
  23. package/src/agent/intent-manifest.js +231 -0
  24. package/src/agent/intent-timeout.js +46 -0
  25. package/src/agent/intent.js +633 -0
  26. package/src/agent/knowledge.js +114 -0
  27. package/src/agent/learning.js +180 -0
  28. package/src/agent/modes.js +187 -0
  29. package/src/agent/outcome-ask.js +91 -0
  30. package/src/agent/project-context.js +76 -0
  31. package/src/agent/prompt-budget.js +117 -0
  32. package/src/agent/reflection-extractor.js +140 -0
  33. package/src/agent/steering.js +86 -0
  34. package/src/agent/supervisor.js +430 -0
  35. package/src/agent/swap.js +443 -0
  36. package/src/agent/system-prompt.js +446 -0
  37. package/src/agent/time-stamp.js +48 -0
  38. package/src/agent/tool-guard.js +201 -0
  39. package/src/agent/toolcall-text.js +162 -0
  40. package/src/agent/usage.js +297 -0
  41. package/src/agent/vision.js +94 -0
  42. package/src/agent/watchdog.js +139 -0
  43. package/src/agent/workspace-changes.js +177 -0
  44. package/src/api/address.js +14 -0
  45. package/src/api/client.js +280 -0
  46. package/src/api/server.js +535 -0
  47. package/src/api/stream-pipe.js +113 -0
  48. package/src/app-state.js +39 -0
  49. package/src/bootstrap.js +501 -0
  50. package/src/bus/drain-loop.js +497 -0
  51. package/src/bus/index.js +270 -0
  52. package/src/bus/plugins.js +65 -0
  53. package/src/child-idle.js +14 -0
  54. package/src/cli.js +118 -0
  55. package/src/commands/commands.js +1297 -0
  56. package/src/commands/registry.js +132 -0
  57. package/src/components/App.js +491 -0
  58. package/src/components/CarefulMenu.js +145 -0
  59. package/src/components/HistoryWriter.js +86 -0
  60. package/src/components/LineInput.js +69 -0
  61. package/src/components/LiveZone.js +294 -0
  62. package/src/components/OverlayMenu.js +179 -0
  63. package/src/components/SystemPanel.js +156 -0
  64. package/src/components/Table.js +54 -0
  65. package/src/config.js +249 -0
  66. package/src/free-models.js +230 -0
  67. package/src/index.js +1111 -0
  68. package/src/input-handler.js +13 -0
  69. package/src/input-text.js +123 -0
  70. package/src/launcher.js +129 -0
  71. package/src/logging/api-log.js +95 -0
  72. package/src/logging/chat-log-follower.js +113 -0
  73. package/src/logging/chat-log.js +15 -0
  74. package/src/logging/log-collector.js +182 -0
  75. package/src/logging/logger.js +112 -0
  76. package/src/logging/tool-log.js +20 -0
  77. package/src/mcp-client.js +314 -0
  78. package/src/memory/conversation-digest.js +113 -0
  79. package/src/memory/extract-facts.js +98 -0
  80. package/src/memory/facts.js +181 -0
  81. package/src/memory/inbox.js +63 -0
  82. package/src/memory/markdown.js +38 -0
  83. package/src/memory/patterns.js +185 -0
  84. package/src/memory/project.js +66 -0
  85. package/src/memory/reflections.js +74 -0
  86. package/src/memory/retrieval.js +84 -0
  87. package/src/memory/rules.js +105 -0
  88. package/src/memory/session-facts.js +125 -0
  89. package/src/memory/skills.js +191 -0
  90. package/src/memory/sqlite-store.js +653 -0
  91. package/src/memory/store.js +208 -0
  92. package/src/memory/tools.js +196 -0
  93. package/src/memory/user-model.js +86 -0
  94. package/src/message-handler.js +775 -0
  95. package/src/model-check.js +218 -0
  96. package/src/plugins/loader.js +120 -0
  97. package/src/plugins/manager.js +88 -0
  98. package/src/production-env.js +22 -0
  99. package/src/profiles.js +42 -0
  100. package/src/providers/adapters/anthropic.js +270 -0
  101. package/src/providers/adapters/openai.js +120 -0
  102. package/src/providers/keys-dpapi.js +41 -0
  103. package/src/providers/keys-fallback.js +31 -0
  104. package/src/providers/keys.js +132 -0
  105. package/src/providers/models.js +154 -0
  106. package/src/providers/registry.js +56 -0
  107. package/src/providers/state.js +56 -0
  108. package/src/registry.js +96 -0
  109. package/src/restart.js +29 -0
  110. package/src/sandbox/backend.js +130 -0
  111. package/src/security/api-auth.js +132 -0
  112. package/src/security/audit.js +98 -0
  113. package/src/security/child-policy.js +41 -0
  114. package/src/security/command-guard.js +173 -0
  115. package/src/security/content-fence.js +250 -0
  116. package/src/security/content-validator.js +132 -0
  117. package/src/security/index.js +143 -0
  118. package/src/security/network-guard.js +126 -0
  119. package/src/security/pairing.js +180 -0
  120. package/src/security/path-guard.js +140 -0
  121. package/src/security/persona-guard.js +67 -0
  122. package/src/security/policies.js +452 -0
  123. package/src/security/safety-constants.js +34 -0
  124. package/src/security/watchdog.js +107 -0
  125. package/src/sessions.js +130 -0
  126. package/src/spend.js +97 -0
  127. package/src/startup-watchdog.js +59 -0
  128. package/src/stdio/args.js +71 -0
  129. package/src/stdio/guard.js +59 -0
  130. package/src/stdio/protocol.js +167 -0
  131. package/src/stdio/run.js +106 -0
  132. package/src/stdio/session.js +180 -0
  133. package/src/store/agent-slice.js +306 -0
  134. package/src/store/dataset-slice.js +73 -0
  135. package/src/store/index.js +22 -0
  136. package/src/store/process-slice.js +135 -0
  137. package/src/store/session-slice.js +191 -0
  138. package/src/store/ui-slice.js +119 -0
  139. package/src/tasks/db.js +184 -0
  140. package/src/tasks/queries.js +589 -0
  141. package/src/tools/agent-tools.js +473 -0
  142. package/src/tools/checkpoint.js +152 -0
  143. package/src/tools/command-approvals.js +180 -0
  144. package/src/tools/dataset.js +50 -0
  145. package/src/tools/filesystem.js +682 -0
  146. package/src/tools/inbox-tools.js +48 -0
  147. package/src/tools/mesh.js +135 -0
  148. package/src/tools/own-env.js +136 -0
  149. package/src/tools/permissions.js +681 -0
  150. package/src/tools/plugin-tools.js +123 -0
  151. package/src/tools/process-tools.js +595 -0
  152. package/src/tools/registry.js +307 -0
  153. package/src/tools/swap-tools.js +72 -0
  154. package/src/tools/system.js +662 -0
  155. package/src/tools/tasks.js +532 -0
  156. package/src/tools/tool-search.js +171 -0
  157. package/src/ui/header.js +140 -0
  158. package/src/ui/input-cursor.js +23 -0
  159. package/src/ui/last-line.js +25 -0
  160. package/src/ui/line-edit.js +135 -0
  161. package/src/ui/output.js +399 -0
  162. package/src/ui/paste-tokens.js +131 -0
  163. package/src/ui/prompt-attention.js +134 -0
  164. package/src/ui/render-options.js +13 -0
  165. package/src/ui/replay.js +94 -0
  166. package/src/ui/splash.js +49 -0
  167. package/src/ui/status-level.js +36 -0
  168. package/src/ui/tool-ledger.js +203 -0
  169. package/src/ui/window-title.js +150 -0
  170. package/src/update.js +205 -0
  171. package/system.md +63 -0
@@ -0,0 +1,191 @@
1
+ // Session slice: messages, sessionId, cost, tokens, model, provider, inputHistory, profile, plan
2
+
3
+ import { resetSessionSpend } from "../agent/usage.js";
4
+ import { resetVision, setModelSeesImages } from "../agent/vision.js";
5
+
6
+ /**
7
+ * Rough size of a conversation in tokens (4 characters a token), for the
8
+ * context counter before the model has answered once. A loaded session used
9
+ * to show no context at all until the next reply (owner, 2026-10-02).
10
+ */
11
+ export function estimateContextTokens(messages) {
12
+ let chars = 0;
13
+ for (const m of messages || []) {
14
+ if (typeof m?.content === "string") chars += m.content.length;
15
+ else if (m?.content != null) chars += JSON.stringify(m.content).length;
16
+ if (m?.tool_calls) chars += JSON.stringify(m.tool_calls).length;
17
+ }
18
+ return Math.ceil(chars / 4);
19
+ }
20
+
21
+ function mergeSource(bySource, source, entry) {
22
+ const prev = bySource[source] || {
23
+ calls: 0, promptTokens: 0, completionTokens: 0,
24
+ cachedTokens: 0, cacheWriteTokens: 0, cost: 0, estimated: false,
25
+ };
26
+ return {
27
+ ...bySource,
28
+ [source]: {
29
+ calls: prev.calls + (entry.calls || 0),
30
+ promptTokens: prev.promptTokens + (entry.promptTokens || 0),
31
+ completionTokens: prev.completionTokens + (entry.completionTokens || 0),
32
+ cachedTokens: prev.cachedTokens + (entry.cachedTokens || 0),
33
+ cacheWriteTokens: prev.cacheWriteTokens + (entry.cacheWriteTokens || 0),
34
+ cost: prev.cost + (entry.cost || 0),
35
+ estimated: prev.estimated || !!entry.estimated,
36
+ },
37
+ };
38
+ }
39
+
40
+ export const createSessionSlice = (set, get) => ({
41
+ sessionId: null,
42
+ messages: [],
43
+ model: null,
44
+ provider: null,
45
+ pricing: null,
46
+ contextLimit: null,
47
+ sessionCost: 0,
48
+ sessionPromptTokens: 0,
49
+ sessionCompletionTokens: 0,
50
+ // What the number is made of. sessionCost is the real charge when the
51
+ // provider reports one; sessionCostEstimated says when any part of it is a
52
+ // guess. Cached tokens are a subset of sessionPromptTokens.
53
+ sessionCachedTokens: 0,
54
+ sessionCacheWriteTokens: 0,
55
+ sessionCostEstimated: false,
56
+ // Per caller, so "what is even counted here" has an answer on screen:
57
+ // the main loop, the intent classifier, the judge, the reflection extractor.
58
+ sessionUsageBySource: {},
59
+ lastContextTokens: 0,
60
+ inputHistory: [],
61
+ profile: null,
62
+ lastSummary: null,
63
+ plan: null,
64
+ pastedImages: [],
65
+ userMessageCount: 0,
66
+
67
+ setSession(sessionId, messages, inputHistory) {
68
+ const userCount = messages ? messages.filter((m) => m.role === "user").length : 0;
69
+ // The ledger follows the session. Seven places clear the session; wiring
70
+ // each of them to also clear the ledger is how the two drift apart, and a
71
+ // session ceiling enforced against a stale total is worse than none.
72
+ resetSessionSpend();
73
+ set({
74
+ sessionId,
75
+ messages,
76
+ inputHistory: inputHistory || [],
77
+ userMessageCount: userCount,
78
+ // An estimate until the model answers and reports the real size.
79
+ lastContextTokens: (messages?.length || 0) > 1 ? estimateContextTokens(messages) : 0,
80
+ contextEstimated: (messages?.length || 0) > 1,
81
+ sessionCost: 0,
82
+ sessionPromptTokens: 0,
83
+ sessionCompletionTokens: 0,
84
+ sessionCachedTokens: 0,
85
+ sessionCacheWriteTokens: 0,
86
+ sessionCostEstimated: false,
87
+ sessionUsageBySource: {},
88
+ });
89
+ },
90
+
91
+ setModel(model) {
92
+ // What was learned about the old model's eyes says nothing about the new
93
+ // one. Every model switch passes here, and the provider's answer for the
94
+ // new model arrives through setPricing.
95
+ if (get().model !== model) resetVision();
96
+ set({ model });
97
+ },
98
+
99
+ setProvider(providerId) {
100
+ set({ provider: providerId });
101
+ },
102
+
103
+ setPricing(pricing) {
104
+ if (pricing && pricing.seesImages != null) setModelSeesImages(pricing.seesImages, "provider-metadata");
105
+ set({
106
+ pricing,
107
+ contextLimit: pricing?.contextLength || null,
108
+ });
109
+ },
110
+
111
+ pushMessage(msg) {
112
+ const s = get();
113
+ const newMessages = [...s.messages, msg];
114
+ const update = { messages: newMessages };
115
+ if (msg.role === "user") update.userMessageCount = s.userMessageCount + 1;
116
+ set(update);
117
+ },
118
+
119
+ /**
120
+ * Fold one drained ledger into the session totals, for display.
121
+ *
122
+ * There used to be two of these: addUsage(stats, cost) for the main loop and
123
+ * addSideUsage(bySource) for everything else, each with its own idea of what
124
+ * a call cost. The main loop is a source like any other now, so there is one
125
+ * way in.
126
+ *
127
+ * @param {object} bySource - map of source -> usage entry, from drainUsage()
128
+ */
129
+ addUsage(bySource) {
130
+ const s = get();
131
+ let promptTokens = 0, completionTokens = 0, cached = 0, cacheWrite = 0, cost = 0, estimated = false;
132
+ let merged = s.sessionUsageBySource;
133
+ for (const [source, entry] of Object.entries(bySource || {})) {
134
+ if (!entry || !entry.calls) continue;
135
+ promptTokens += entry.promptTokens || 0;
136
+ completionTokens += entry.completionTokens || 0;
137
+ cached += entry.cachedTokens || 0;
138
+ cacheWrite += entry.cacheWriteTokens || 0;
139
+ cost += entry.cost || 0;
140
+ if (entry.estimated) estimated = true;
141
+ merged = mergeSource(merged, source, entry);
142
+ }
143
+ if (merged === s.sessionUsageBySource) return;
144
+ set({
145
+ sessionPromptTokens: s.sessionPromptTokens + promptTokens,
146
+ sessionCompletionTokens: s.sessionCompletionTokens + completionTokens,
147
+ sessionCachedTokens: s.sessionCachedTokens + cached,
148
+ sessionCacheWriteTokens: s.sessionCacheWriteTokens + cacheWrite,
149
+ sessionCost: s.sessionCost + cost,
150
+ sessionCostEstimated: s.sessionCostEstimated || estimated,
151
+ sessionUsageBySource: merged,
152
+ });
153
+ },
154
+
155
+ pushInputHistory(input) {
156
+ const { inputHistory } = get();
157
+ set({ inputHistory: [...inputHistory, input] });
158
+ },
159
+
160
+ resetSession(sessionId, messages) {
161
+ resetSessionSpend();
162
+ set({
163
+ sessionId,
164
+ messages,
165
+ sessionCost: 0,
166
+ sessionPromptTokens: 0,
167
+ sessionCompletionTokens: 0,
168
+ sessionCachedTokens: 0,
169
+ sessionCacheWriteTokens: 0,
170
+ sessionCostEstimated: false,
171
+ sessionUsageBySource: {},
172
+ lastContextTokens: 0,
173
+ userMessageCount: 0,
174
+ plan: null,
175
+ lastSummary: null,
176
+ pastedImages: [],
177
+ });
178
+ },
179
+
180
+ setProfile(profile) {
181
+ set({ profile });
182
+ },
183
+
184
+ setPlan(plan) {
185
+ set({ plan });
186
+ },
187
+
188
+ setLastSummary(lastSummary) {
189
+ set({ lastSummary });
190
+ },
191
+ });
@@ -0,0 +1,119 @@
1
+ // UI slice: lines[], streamText, activeTab
2
+ import { config } from "../config.js";
3
+ import { createLogger } from "../logging/logger.js";
4
+
5
+ const log = createLogger("ui-slice");
6
+
7
+ export const createUiSlice = (set, get) => ({
8
+ lines: [], // [{id, text}]
9
+ nextLineId: 1,
10
+ streamText: "",
11
+ processStreams: {}, // procId → text, one updating line per background process
12
+ activeTab: "chat", // "chat" | "toollog" | "processes" | "system"
13
+ headerPrinted: false, // guard: prevent duplicate header on tab-cycle
14
+
15
+ setActiveTab(tab) {
16
+ set({ activeTab: tab });
17
+ },
18
+
19
+ /**
20
+ * One history line. `replay: true` marks a line shown again from the
21
+ * session's chat log (ui/replay.js); the chat log follower skips it, so the
22
+ * log does not get its own lines a second time.
23
+ */
24
+ addLine(text, opts = {}) {
25
+ const { nextLineId, lines } = get();
26
+ const maxLines = config.maxDisplayLines || 1000;
27
+ const item = { id: nextLineId, text: String(text) || " " };
28
+ if (opts.replay) item.replay = true;
29
+ const updated = [...lines, item];
30
+ log.debug("addLine", { id: nextLineId, totalLines: updated.length, textLen: (text || "").length });
31
+ set({
32
+ lines: updated.length > maxLines ? updated.slice(-maxLines) : updated,
33
+ nextLineId: nextLineId + 1,
34
+ });
35
+ },
36
+
37
+ addTable({ columns, rows, title, footer }) {
38
+ const { nextLineId, lines } = get();
39
+ const maxLines = config.maxDisplayLines || 1000;
40
+ const item = { id: nextLineId, type: "table", columns, rows, title, footer };
41
+ const updated = [...lines, item];
42
+ set({
43
+ lines: updated.length > maxLines ? updated.slice(-maxLines) : updated,
44
+ nextLineId: nextLineId + 1,
45
+ });
46
+ },
47
+
48
+ // Messages the operator typed while the agent worked and it has not read
49
+ // yet: shown above the input, moved into the history when read (owner,
50
+ // 2026-10-01: printed lines cannot be marked read afterwards).
51
+ queuedInputs: [], // [{ id: busId, display }]
52
+ addQueuedInput(entry) {
53
+ set({ queuedInputs: [...get().queuedInputs, entry] });
54
+ },
55
+ /** Remove and return the entry for a bus message, or null. */
56
+ takeQueuedInput(id) {
57
+ const list = get().queuedInputs;
58
+ const entry = list.find((q) => q.id === id) || null;
59
+ if (entry) set({ queuedInputs: list.filter((q) => q !== entry) });
60
+ return entry;
61
+ },
62
+ clearQueuedInputs() {
63
+ set({ queuedInputs: [] });
64
+ },
65
+
66
+ setStreamText(text) {
67
+ log.debug("setStreamText", { len: (text || "").length, empty: !text });
68
+ set({ streamText: text });
69
+ },
70
+
71
+ setProcessStream(procId, text) {
72
+ const streams = { ...get().processStreams };
73
+ if (text) {
74
+ streams[procId] = text;
75
+ } else {
76
+ delete streams[procId];
77
+ }
78
+ set({ processStreams: streams });
79
+ },
80
+
81
+ // Overlay menu state
82
+ overlay: null, // null | { type: "provider" | "model", title, items, index, parent }
83
+ openOverlay(type, title, items) {
84
+ set({ overlay: { type, title, items, index: 0, parent: null, loading: false, sortMode: "name" } });
85
+ },
86
+ overlayDrilldown(type, title, items, parent) {
87
+ set({ overlay: { type, title, items, index: 0, parent, loading: false, sortMode: "name" } });
88
+ },
89
+ overlayCycleSort() {
90
+ const o = get().overlay;
91
+ if (!o) return;
92
+ const modes = ["name", "price", "context"];
93
+ const next = modes[(modes.indexOf(o.sortMode || "name") + 1) % modes.length];
94
+ set({ overlay: { ...o, sortMode: next, index: 0 } });
95
+ },
96
+ overlaySetIndex(index) {
97
+ const o = get().overlay;
98
+ if (o) set({ overlay: { ...o, index } });
99
+ },
100
+ overlaySetLoading(loading) {
101
+ const o = get().overlay;
102
+ if (o) set({ overlay: { ...o, loading } });
103
+ },
104
+ closeOverlay() {
105
+ set({ overlay: null });
106
+ },
107
+
108
+ clearCounter: 0,
109
+
110
+ clearScreen() {
111
+ const { clearCounter, _inkClear } = get();
112
+ // 1. ANSI: clear scrollback + visible screen + move cursor home
113
+ process.stdout.write("\x1b[3J\x1b[2J\x1b[H");
114
+ // 2. Ink: reset internal output tracking so it doesn't re-render old Static lines
115
+ if (_inkClear) _inkClear();
116
+ // 3. Reset lines array so Static starts fresh
117
+ set({ lines: [], nextLineId: 1, streamText: "", activeTab: "chat", clearCounter: clearCounter + 1, headerPrinted: false });
118
+ },
119
+ });
@@ -0,0 +1,184 @@
1
+ // SQLite persistent task database for Flint
2
+ // File: ~/.flint/tasks.db
3
+
4
+ import Database from "better-sqlite3";
5
+ import path from "node:path";
6
+ import { mkdirSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+
9
+ // FLINT_DATA_DIR moves this database too, like sessions/ and memory/.
10
+ // It holds the message bus, and the bus recovers stale "processing" messages
11
+ // on startup: a bench instance killed mid-turn left its prompt here, and the
12
+ // next instance, started for a different task, picked it up and worked on it
13
+ // for fifteen minutes (SWE pilot, 2026-09-26). A subject with its own data
14
+ // dir must not share a queue with anyone.
15
+ const FLINT_DIR = process.env.FLINT_DATA_DIR
16
+ ? path.resolve(process.env.FLINT_DATA_DIR)
17
+ : path.join(homedir(), ".flint");
18
+ const DB_PATH = path.join(FLINT_DIR, "tasks.db");
19
+
20
+ /** Where the task and message-bus database lives, for isolation checks. */
21
+ export function tasksDbPath() {
22
+ return DB_PATH;
23
+ }
24
+
25
+ let db = null;
26
+
27
+ export function getDb() {
28
+ if (db) return db;
29
+
30
+ mkdirSync(FLINT_DIR, { recursive: true });
31
+ db = new Database(DB_PATH);
32
+ db.pragma("journal_mode = WAL");
33
+ db.pragma("foreign_keys = ON");
34
+
35
+ migrate(db);
36
+ return db;
37
+ }
38
+
39
+ function migrate(db) {
40
+ db.exec(`
41
+ CREATE TABLE IF NOT EXISTS goals (
42
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
43
+ title TEXT NOT NULL,
44
+ project TEXT DEFAULT 'default',
45
+ status TEXT DEFAULT 'active' CHECK(status IN ('active', 'completed', 'abandoned')),
46
+ session_id TEXT,
47
+ created_at TEXT DEFAULT (datetime('now')),
48
+ completed_at TEXT
49
+ );
50
+
51
+ CREATE TABLE IF NOT EXISTS tasks (
52
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
53
+ goal_id INTEGER REFERENCES goals(id) ON DELETE CASCADE,
54
+ title TEXT NOT NULL,
55
+ description TEXT,
56
+ status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'done', 'skipped')),
57
+ priority INTEGER DEFAULT 0,
58
+ result TEXT,
59
+ next_run TEXT,
60
+ repeat TEXT,
61
+ created_at TEXT DEFAULT (datetime('now')),
62
+ updated_at TEXT DEFAULT (datetime('now'))
63
+ );
64
+
65
+ CREATE TABLE IF NOT EXISTS task_files (
66
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
67
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
68
+ path TEXT NOT NULL,
69
+ role TEXT DEFAULT 'related' CHECK(role IN ('created', 'modified', 'related')),
70
+ added_at TEXT DEFAULT (datetime('now'))
71
+ );
72
+
73
+ CREATE TABLE IF NOT EXISTS task_notes (
74
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
75
+ task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
76
+ note TEXT NOT NULL,
77
+ created_at TEXT DEFAULT (datetime('now'))
78
+ );
79
+
80
+ CREATE INDEX IF NOT EXISTS idx_tasks_goal ON tasks(goal_id);
81
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
82
+ CREATE INDEX IF NOT EXISTS idx_goals_status ON goals(status);
83
+ CREATE INDEX IF NOT EXISTS idx_goals_project ON goals(project);
84
+ `);
85
+
86
+ // Migration: add next_run and repeat columns to existing tables
87
+ try { db.exec("ALTER TABLE tasks ADD COLUMN next_run TEXT"); } catch {}
88
+ try { db.exec("ALTER TABLE tasks ADD COLUMN repeat TEXT"); } catch {}
89
+
90
+ // Migration: add assignee and agent_port for task-driven child agents
91
+ try { db.exec("ALTER TABLE tasks ADD COLUMN assignee TEXT"); } catch {}
92
+ try { db.exec("ALTER TABLE tasks ADD COLUMN agent_port INTEGER"); } catch {}
93
+
94
+ // Migration: scope (session/project/global) and parent_task_id
95
+ try { db.exec("ALTER TABLE tasks ADD COLUMN scope TEXT DEFAULT 'session'"); } catch {}
96
+ try { db.exec("ALTER TABLE tasks ADD COLUMN parent_task_id INTEGER REFERENCES tasks(id)"); } catch {}
97
+
98
+ // Migration: daily_focus date for day planning
99
+ try { db.exec("ALTER TABLE tasks ADD COLUMN daily_focus TEXT"); } catch {}
100
+
101
+ // Index on next_run (after migration ensures column exists)
102
+ db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_next_run ON tasks(next_run)");
103
+ db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)");
104
+ db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_scope ON tasks(scope)");
105
+ db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id)");
106
+
107
+ // Migration: drop NOT NULL on tasks.goal_id for standalone reminders (#v1.0.1)
108
+ // Old schema had goal_id INTEGER NOT NULL — blocks add_task without a goal.
109
+ // SQLite can't ALTER COLUMN, so we rebuild the table if NOT NULL is detected.
110
+ try {
111
+ const tableSql = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'").get();
112
+ if (tableSql && /goal_id\s+INTEGER\s+NOT NULL/i.test(tableSql.sql)) {
113
+ const columns = db.prepare("PRAGMA table_info(tasks)").all();
114
+ const columnList = columns.map(c => c.name).join(", ");
115
+ db.exec("BEGIN");
116
+ db.exec(`CREATE TABLE tasks_new (
117
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
118
+ goal_id INTEGER REFERENCES goals(id) ON DELETE CASCADE,
119
+ title TEXT NOT NULL,
120
+ description TEXT,
121
+ status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'done', 'skipped')),
122
+ priority INTEGER DEFAULT 0,
123
+ result TEXT,
124
+ next_run TEXT,
125
+ repeat TEXT,
126
+ created_at TEXT DEFAULT (datetime('now')),
127
+ updated_at TEXT DEFAULT (datetime('now'))
128
+ )`);
129
+ // Add any extra columns from prior ALTER TABLE migrations
130
+ const knownCols = new Set(["id","goal_id","title","description","status","priority","result","next_run","repeat","created_at","updated_at"]);
131
+ for (const col of columns) {
132
+ if (knownCols.has(col.name)) continue;
133
+ const typeDef = col.type || "TEXT";
134
+ const dflt = col.dflt_value != null ? ` DEFAULT ${col.dflt_value}` : "";
135
+ try { db.exec(`ALTER TABLE tasks_new ADD COLUMN ${col.name} ${typeDef}${dflt}`); } catch {}
136
+ }
137
+ db.exec(`INSERT INTO tasks_new (${columnList}) SELECT ${columnList} FROM tasks`);
138
+ db.exec("DROP TABLE tasks");
139
+ db.exec("ALTER TABLE tasks_new RENAME TO tasks");
140
+ db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_goal ON tasks(goal_id)");
141
+ db.exec("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)");
142
+ db.exec("COMMIT");
143
+ }
144
+ } catch (e) {
145
+ try { db.exec("ROLLBACK"); } catch {}
146
+ }
147
+
148
+ // Sessions registry: track session lifecycle for GC
149
+ db.exec(`
150
+ CREATE TABLE IF NOT EXISTS sessions (
151
+ id TEXT PRIMARY KEY,
152
+ created_at TEXT DEFAULT (datetime('now')),
153
+ last_updated TEXT DEFAULT (datetime('now')),
154
+ focused_goal_id INTEGER REFERENCES goals(id)
155
+ );
156
+ CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(last_updated);
157
+ `);
158
+
159
+ // Message Bus: unified priority queue for all input channels
160
+ db.exec(`
161
+ CREATE TABLE IF NOT EXISTS message_queue (
162
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
163
+ channel TEXT NOT NULL,
164
+ content TEXT NOT NULL,
165
+ priority INTEGER DEFAULT 5,
166
+ source TEXT,
167
+ metadata TEXT,
168
+ status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'done', 'failed')),
169
+ session_id TEXT,
170
+ created_at TEXT DEFAULT (datetime('now')),
171
+ processed_at TEXT,
172
+ result TEXT
173
+ );
174
+ CREATE INDEX IF NOT EXISTS idx_queue_drain ON message_queue(status, priority, created_at);
175
+ CREATE INDEX IF NOT EXISTS idx_queue_cleanup ON message_queue(status, processed_at);
176
+ `);
177
+ }
178
+
179
+ export function closeDb() {
180
+ if (db) {
181
+ db.close();
182
+ db = null;
183
+ }
184
+ }