talon-agent 3.0.5 → 3.1.1
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/package.json +8 -5
- package/src/backend/kilo/models/index.ts +4 -1
- package/src/backend/kilo/server.ts +17 -4
- package/src/backend/openai-agents/session.ts +1 -1
- package/src/backend/opencode/models/index.ts +4 -1
- package/src/backend/opencode/server.ts +17 -4
- package/src/backend/shared/system-prompt.ts +5 -0
- package/src/cli/events.ts +1 -1
- package/src/cli/index.ts +1 -1
- package/src/cli/tasks.ts +5 -1
- package/src/core/background/triggers/pid.ts +28 -0
- package/src/core/background/triggers/resume.ts +4 -24
- package/src/core/background/triggers/spawn.ts +1 -1
- package/src/core/engine/gateway-actions/plugins.ts +3 -3
- package/src/core/prompt/invalidation.ts +23 -0
- package/src/core/types.ts +2 -2
- package/src/frontend/discord/callbacks/components.ts +1 -1
- package/src/frontend/discord/commands/settings.ts +1 -1
- package/src/frontend/native/auth.ts +52 -0
- package/src/frontend/native/extensions.ts +2 -2
- package/src/frontend/native/index.ts +8 -2
- package/src/frontend/native/server.ts +96 -15
- package/src/frontend/telegram/callbacks/model.ts +1 -1
- package/src/frontend/telegram/commands/admin.ts +1 -1
- package/src/frontend/telegram/commands/settings.ts +1 -1
- package/src/frontend/telegram/formatting.ts +1 -1
- package/src/frontend/terminal/commands.ts +1 -1
- package/src/native/htmlents-wasm-bytes.ts +4 -4
- package/src/native/htmlents.ts +4 -4
- package/src/native/registry.ts +9 -9
- package/src/native/runtime.ts +2 -2
- package/src/native/sqlguard-wasm-bytes.ts +4 -4
- package/src/native/sqlguard.ts +2 -2
- package/src/native/strsim-wasm-bytes.ts +4 -4
- package/src/native/strsim.ts +3 -3
- package/src/plugins/github/index.ts +1 -1
- package/src/plugins/mem0/index.ts +1 -1
- package/src/plugins/mempalace/index.ts +1 -1
- package/src/plugins/playwright/index.ts +1 -1
- package/src/storage/chat-settings.ts +3 -60
- package/src/storage/cron-store.ts +6 -80
- package/src/storage/goal-store.ts +10 -19
- package/src/storage/history.ts +3 -14
- package/src/storage/journal.ts +20 -9
- package/src/storage/media-index.ts +2 -22
- package/src/storage/repositories/chat-settings-repo.ts +55 -1
- package/src/storage/repositories/cron-repo.ts +82 -6
- package/src/storage/repositories/goals-repo.ts +20 -1
- package/src/storage/repositories/history-repo.ts +14 -1
- package/src/storage/repositories/media-index-repo.ts +23 -1
- package/src/storage/repositories/scripts-repo.ts +16 -1
- package/src/storage/repositories/sessions-repo.ts +1 -1
- package/src/storage/repositories/triggers-repo.ts +60 -5
- package/src/storage/script-store.ts +2 -15
- package/src/storage/session-record.ts +220 -0
- package/src/storage/sessions.ts +22 -210
- package/src/storage/trigger-store.ts +6 -60
- package/src/types/assets.d.ts +9 -0
- package/src/types/effort.ts +12 -0
- package/src/util/config.ts +9 -2
- package/src/util/harden.ts +50 -0
- package/src/util/log.ts +2 -1
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session record shapes + their constructors and normalisers — the
|
|
3
|
+
* vocabulary both the public store (storage/sessions.ts) and the
|
|
4
|
+
* repository (repositories/sessions-repo.ts) speak, extracted so the
|
|
5
|
+
* repo never has to import the store (which imports the repo).
|
|
6
|
+
*
|
|
7
|
+
* Mutation logic (fold/add/bucket) stays in the store; this module owns
|
|
8
|
+
* only "what a well-formed record looks like" and "how a raw JSON blob
|
|
9
|
+
* becomes one".
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type SessionUsage = {
|
|
13
|
+
totalInputTokens: number;
|
|
14
|
+
totalOutputTokens: number;
|
|
15
|
+
totalCacheRead: number;
|
|
16
|
+
totalCacheWrite: number;
|
|
17
|
+
/** Last turn's total prompt tokens (cumulative across all API calls in the turn, including tool-use loops). */
|
|
18
|
+
lastPromptTokens: number;
|
|
19
|
+
/** Actual context window fill from the last API call (last iteration's prompt tokens). */
|
|
20
|
+
contextTokens: number;
|
|
21
|
+
/** Model's context window size in tokens (from SDK modelUsage). */
|
|
22
|
+
contextWindow: number;
|
|
23
|
+
/** Number of API round-trips in the last turn (tool-use steps). */
|
|
24
|
+
numApiCalls: number;
|
|
25
|
+
/** Estimated cost in USD. */
|
|
26
|
+
estimatedCostUsd: number;
|
|
27
|
+
/** Total response time in ms (for averaging). */
|
|
28
|
+
totalResponseMs: number;
|
|
29
|
+
/** Last response time in ms. */
|
|
30
|
+
lastResponseMs: number;
|
|
31
|
+
/** Fastest response time in ms. */
|
|
32
|
+
fastestResponseMs: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type MetricsCounterSet = {
|
|
36
|
+
queries: number;
|
|
37
|
+
toolCalls: number;
|
|
38
|
+
turnsWithTools: number;
|
|
39
|
+
apiCalls: number;
|
|
40
|
+
inputTokens: number;
|
|
41
|
+
outputTokens: number;
|
|
42
|
+
cacheReadTokens: number;
|
|
43
|
+
cacheWriteTokens: number;
|
|
44
|
+
failedTurns: number;
|
|
45
|
+
flowViolationRetries: number;
|
|
46
|
+
flowViolationCapExhausted: number;
|
|
47
|
+
trailingTextDropped: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type MetricsLatencyAgg = {
|
|
51
|
+
count: number;
|
|
52
|
+
sumMs: number;
|
|
53
|
+
minMs: number;
|
|
54
|
+
maxMs: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type MetricsGrain = {
|
|
58
|
+
counters: MetricsCounterSet;
|
|
59
|
+
latency: MetricsLatencyAgg;
|
|
60
|
+
toolCallsByName: Record<string, number>;
|
|
61
|
+
backend: Record<string, MetricsCounterSet & { latency: MetricsLatencyAgg }>;
|
|
62
|
+
cacheHitPercent: MetricsLatencyAgg;
|
|
63
|
+
toolCallsPerTurn: MetricsLatencyAgg;
|
|
64
|
+
apiCallsPerTurn: MetricsLatencyAgg;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type SessionMetrics = {
|
|
68
|
+
lifetime: MetricsGrain;
|
|
69
|
+
buckets: Record<string, MetricsGrain>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type SessionState = {
|
|
73
|
+
/** Backend server-side session ID. */
|
|
74
|
+
sessionId: string | undefined;
|
|
75
|
+
/** Turn count. */
|
|
76
|
+
turns: number;
|
|
77
|
+
/** Last activity timestamp. */
|
|
78
|
+
lastActive: number;
|
|
79
|
+
/** Created timestamp. */
|
|
80
|
+
createdAt: number;
|
|
81
|
+
/** Cumulative usage stats. */
|
|
82
|
+
usage: SessionUsage;
|
|
83
|
+
/** Persistent per-session metrics, bucketed by UTC day. */
|
|
84
|
+
metrics: SessionMetrics;
|
|
85
|
+
/** ID of the last message sent by the bot in this chat. */
|
|
86
|
+
lastBotMessageId?: number;
|
|
87
|
+
/** Descriptive session name derived from first message. */
|
|
88
|
+
sessionName?: string;
|
|
89
|
+
/** Model used for this session's cost tracking. */
|
|
90
|
+
lastModel?: string;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const emptyUsage = (): SessionUsage => ({
|
|
94
|
+
totalInputTokens: 0,
|
|
95
|
+
totalOutputTokens: 0,
|
|
96
|
+
totalCacheRead: 0,
|
|
97
|
+
totalCacheWrite: 0,
|
|
98
|
+
lastPromptTokens: 0,
|
|
99
|
+
contextTokens: 0,
|
|
100
|
+
contextWindow: 0,
|
|
101
|
+
numApiCalls: 0,
|
|
102
|
+
estimatedCostUsd: 0,
|
|
103
|
+
totalResponseMs: 0,
|
|
104
|
+
lastResponseMs: 0,
|
|
105
|
+
fastestResponseMs: Infinity,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
export const emptyCounters = (): MetricsCounterSet => ({
|
|
109
|
+
queries: 0,
|
|
110
|
+
toolCalls: 0,
|
|
111
|
+
turnsWithTools: 0,
|
|
112
|
+
apiCalls: 0,
|
|
113
|
+
inputTokens: 0,
|
|
114
|
+
outputTokens: 0,
|
|
115
|
+
cacheReadTokens: 0,
|
|
116
|
+
cacheWriteTokens: 0,
|
|
117
|
+
failedTurns: 0,
|
|
118
|
+
flowViolationRetries: 0,
|
|
119
|
+
flowViolationCapExhausted: 0,
|
|
120
|
+
trailingTextDropped: 0,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export const emptyLatency = (): MetricsLatencyAgg => ({
|
|
124
|
+
count: 0,
|
|
125
|
+
sumMs: 0,
|
|
126
|
+
minMs: Infinity,
|
|
127
|
+
maxMs: 0,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const emptyGrain = (): MetricsGrain => ({
|
|
131
|
+
counters: emptyCounters(),
|
|
132
|
+
latency: emptyLatency(),
|
|
133
|
+
toolCallsByName: {},
|
|
134
|
+
backend: {},
|
|
135
|
+
cacheHitPercent: emptyLatency(),
|
|
136
|
+
toolCallsPerTurn: emptyLatency(),
|
|
137
|
+
apiCallsPerTurn: emptyLatency(),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
export const emptyMetrics = (): SessionMetrics => ({
|
|
141
|
+
lifetime: emptyGrain(),
|
|
142
|
+
buckets: {},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
function normaliseLatency(raw: unknown): MetricsLatencyAgg {
|
|
146
|
+
const r = raw as Partial<MetricsLatencyAgg> | undefined;
|
|
147
|
+
const count =
|
|
148
|
+
typeof r?.count === "number" && Number.isFinite(r.count) ? r.count : 0;
|
|
149
|
+
return {
|
|
150
|
+
count,
|
|
151
|
+
sumMs:
|
|
152
|
+
typeof r?.sumMs === "number" && Number.isFinite(r.sumMs) ? r.sumMs : 0,
|
|
153
|
+
minMs:
|
|
154
|
+
typeof r?.minMs === "number" && Number.isFinite(r.minMs)
|
|
155
|
+
? r.minMs
|
|
156
|
+
: count > 0
|
|
157
|
+
? 0
|
|
158
|
+
: Infinity,
|
|
159
|
+
maxMs:
|
|
160
|
+
typeof r?.maxMs === "number" && Number.isFinite(r.maxMs) ? r.maxMs : 0,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normaliseCounters(raw: unknown): MetricsCounterSet {
|
|
165
|
+
const base = emptyCounters();
|
|
166
|
+
const r = raw as Partial<MetricsCounterSet> | undefined;
|
|
167
|
+
for (const key of Object.keys(base) as Array<keyof MetricsCounterSet>) {
|
|
168
|
+
const value = r?.[key];
|
|
169
|
+
if (typeof value === "number" && Number.isFinite(value)) base[key] = value;
|
|
170
|
+
}
|
|
171
|
+
return base;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function normaliseGrain(raw: unknown): MetricsGrain {
|
|
175
|
+
const r = raw as Partial<MetricsGrain> | undefined;
|
|
176
|
+
const grain = emptyGrain();
|
|
177
|
+
grain.counters = normaliseCounters(r?.counters);
|
|
178
|
+
grain.latency = normaliseLatency(r?.latency);
|
|
179
|
+
grain.cacheHitPercent = normaliseLatency(r?.cacheHitPercent);
|
|
180
|
+
grain.toolCallsPerTurn = normaliseLatency(r?.toolCallsPerTurn);
|
|
181
|
+
grain.apiCallsPerTurn = normaliseLatency(r?.apiCallsPerTurn);
|
|
182
|
+
if (r?.toolCallsByName && typeof r.toolCallsByName === "object") {
|
|
183
|
+
for (const [key, value] of Object.entries(r.toolCallsByName)) {
|
|
184
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
185
|
+
grain.toolCallsByName[key] = value;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (r?.backend && typeof r.backend === "object") {
|
|
190
|
+
for (const [key, value] of Object.entries(r.backend)) {
|
|
191
|
+
grain.backend[key] = {
|
|
192
|
+
...normaliseCounters(value),
|
|
193
|
+
latency: normaliseLatency((value as { latency?: unknown }).latency),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return grain;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Coerce an untrusted/persisted metrics blob into a well-formed
|
|
202
|
+
* SessionMetrics: missing fields backfilled, non-finite numbers dropped,
|
|
203
|
+
* JSON's `minMs: null` (Infinity doesn't survive JSON.stringify) restored
|
|
204
|
+
* to the domain sentinel. Runs once at the load boundary (sessions-repo);
|
|
205
|
+
* in-memory metrics stay well-formed by construction after that.
|
|
206
|
+
*/
|
|
207
|
+
export function normaliseMetrics(metrics: unknown): SessionMetrics {
|
|
208
|
+
const raw = metrics as Partial<SessionMetrics> | undefined;
|
|
209
|
+
const result: SessionMetrics = {
|
|
210
|
+
lifetime: normaliseGrain(raw?.lifetime),
|
|
211
|
+
buckets: {},
|
|
212
|
+
};
|
|
213
|
+
if (raw?.buckets && typeof raw.buckets === "object") {
|
|
214
|
+
for (const [day, grain] of Object.entries(raw.buckets)) {
|
|
215
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(day))
|
|
216
|
+
result.buckets[day] = normaliseGrain(grain);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
}
|
package/src/storage/sessions.ts
CHANGED
|
@@ -24,87 +24,28 @@ import { files } from "../util/paths.js";
|
|
|
24
24
|
import { importLegacyJson } from "./legacy-import.js";
|
|
25
25
|
import * as repo from "./repositories/sessions-repo.js";
|
|
26
26
|
|
|
27
|
-
export type
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export type MetricsCounterSet = {
|
|
51
|
-
queries: number;
|
|
52
|
-
toolCalls: number;
|
|
53
|
-
turnsWithTools: number;
|
|
54
|
-
apiCalls: number;
|
|
55
|
-
inputTokens: number;
|
|
56
|
-
outputTokens: number;
|
|
57
|
-
cacheReadTokens: number;
|
|
58
|
-
cacheWriteTokens: number;
|
|
59
|
-
failedTurns: number;
|
|
60
|
-
flowViolationRetries: number;
|
|
61
|
-
flowViolationCapExhausted: number;
|
|
62
|
-
trailingTextDropped: number;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
export type MetricsLatencyAgg = {
|
|
66
|
-
count: number;
|
|
67
|
-
sumMs: number;
|
|
68
|
-
minMs: number;
|
|
69
|
-
maxMs: number;
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
export type MetricsGrain = {
|
|
73
|
-
counters: MetricsCounterSet;
|
|
74
|
-
latency: MetricsLatencyAgg;
|
|
75
|
-
toolCallsByName: Record<string, number>;
|
|
76
|
-
backend: Record<string, MetricsCounterSet & { latency: MetricsLatencyAgg }>;
|
|
77
|
-
cacheHitPercent: MetricsLatencyAgg;
|
|
78
|
-
toolCallsPerTurn: MetricsLatencyAgg;
|
|
79
|
-
apiCallsPerTurn: MetricsLatencyAgg;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
export type SessionMetrics = {
|
|
83
|
-
lifetime: MetricsGrain;
|
|
84
|
-
buckets: Record<string, MetricsGrain>;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
export type SessionState = {
|
|
88
|
-
/** Backend server-side session ID. */
|
|
89
|
-
sessionId: string | undefined;
|
|
90
|
-
/** Turn count. */
|
|
91
|
-
turns: number;
|
|
92
|
-
/** Last activity timestamp. */
|
|
93
|
-
lastActive: number;
|
|
94
|
-
/** Created timestamp. */
|
|
95
|
-
createdAt: number;
|
|
96
|
-
/** Cumulative usage stats. */
|
|
97
|
-
usage: SessionUsage;
|
|
98
|
-
/** Persistent per-session metrics, bucketed by UTC day. */
|
|
99
|
-
metrics: SessionMetrics;
|
|
100
|
-
/** ID of the last message sent by the bot in this chat. */
|
|
101
|
-
lastBotMessageId?: number;
|
|
102
|
-
/** Descriptive session name derived from first message. */
|
|
103
|
-
sessionName?: string;
|
|
104
|
-
/** Model used for this session's cost tracking. */
|
|
105
|
-
lastModel?: string;
|
|
106
|
-
};
|
|
107
|
-
|
|
27
|
+
export type {
|
|
28
|
+
MetricsCounterSet,
|
|
29
|
+
MetricsGrain,
|
|
30
|
+
MetricsLatencyAgg,
|
|
31
|
+
SessionMetrics,
|
|
32
|
+
SessionState,
|
|
33
|
+
SessionUsage,
|
|
34
|
+
} from "./session-record.js";
|
|
35
|
+
export { emptyMetrics, normaliseMetrics } from "./session-record.js";
|
|
36
|
+
import {
|
|
37
|
+
emptyCounters,
|
|
38
|
+
emptyGrain,
|
|
39
|
+
emptyLatency,
|
|
40
|
+
emptyMetrics,
|
|
41
|
+
emptyUsage,
|
|
42
|
+
type MetricsCounterSet,
|
|
43
|
+
type MetricsGrain,
|
|
44
|
+
type MetricsLatencyAgg,
|
|
45
|
+
type SessionMetrics,
|
|
46
|
+
type SessionState,
|
|
47
|
+
type SessionUsage,
|
|
48
|
+
} from "./session-record.js";
|
|
108
49
|
// In-memory cache over the sessions table. Reads serve live references
|
|
109
50
|
// from here; writes go through persist() so each mutation commits.
|
|
110
51
|
const cache = new Map<string, SessionState>();
|
|
@@ -273,135 +214,6 @@ function persist(chatId: string, session: SessionState): void {
|
|
|
273
214
|
|
|
274
215
|
// ── Core operations ─────────────────────────────────────────────────────────
|
|
275
216
|
|
|
276
|
-
const emptyUsage = (): SessionUsage => ({
|
|
277
|
-
totalInputTokens: 0,
|
|
278
|
-
totalOutputTokens: 0,
|
|
279
|
-
totalCacheRead: 0,
|
|
280
|
-
totalCacheWrite: 0,
|
|
281
|
-
lastPromptTokens: 0,
|
|
282
|
-
contextTokens: 0,
|
|
283
|
-
contextWindow: 0,
|
|
284
|
-
numApiCalls: 0,
|
|
285
|
-
estimatedCostUsd: 0,
|
|
286
|
-
totalResponseMs: 0,
|
|
287
|
-
lastResponseMs: 0,
|
|
288
|
-
fastestResponseMs: Infinity,
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
const emptyCounters = (): MetricsCounterSet => ({
|
|
292
|
-
queries: 0,
|
|
293
|
-
toolCalls: 0,
|
|
294
|
-
turnsWithTools: 0,
|
|
295
|
-
apiCalls: 0,
|
|
296
|
-
inputTokens: 0,
|
|
297
|
-
outputTokens: 0,
|
|
298
|
-
cacheReadTokens: 0,
|
|
299
|
-
cacheWriteTokens: 0,
|
|
300
|
-
failedTurns: 0,
|
|
301
|
-
flowViolationRetries: 0,
|
|
302
|
-
flowViolationCapExhausted: 0,
|
|
303
|
-
trailingTextDropped: 0,
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
const emptyLatency = (): MetricsLatencyAgg => ({
|
|
307
|
-
count: 0,
|
|
308
|
-
sumMs: 0,
|
|
309
|
-
minMs: Infinity,
|
|
310
|
-
maxMs: 0,
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
const emptyGrain = (): MetricsGrain => ({
|
|
314
|
-
counters: emptyCounters(),
|
|
315
|
-
latency: emptyLatency(),
|
|
316
|
-
toolCallsByName: {},
|
|
317
|
-
backend: {},
|
|
318
|
-
cacheHitPercent: emptyLatency(),
|
|
319
|
-
toolCallsPerTurn: emptyLatency(),
|
|
320
|
-
apiCallsPerTurn: emptyLatency(),
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
export const emptyMetrics = (): SessionMetrics => ({
|
|
324
|
-
lifetime: emptyGrain(),
|
|
325
|
-
buckets: {},
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
function normaliseLatency(raw: unknown): MetricsLatencyAgg {
|
|
329
|
-
const r = raw as Partial<MetricsLatencyAgg> | undefined;
|
|
330
|
-
const count =
|
|
331
|
-
typeof r?.count === "number" && Number.isFinite(r.count) ? r.count : 0;
|
|
332
|
-
return {
|
|
333
|
-
count,
|
|
334
|
-
sumMs:
|
|
335
|
-
typeof r?.sumMs === "number" && Number.isFinite(r.sumMs) ? r.sumMs : 0,
|
|
336
|
-
minMs:
|
|
337
|
-
typeof r?.minMs === "number" && Number.isFinite(r.minMs)
|
|
338
|
-
? r.minMs
|
|
339
|
-
: count > 0
|
|
340
|
-
? 0
|
|
341
|
-
: Infinity,
|
|
342
|
-
maxMs:
|
|
343
|
-
typeof r?.maxMs === "number" && Number.isFinite(r.maxMs) ? r.maxMs : 0,
|
|
344
|
-
};
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function normaliseCounters(raw: unknown): MetricsCounterSet {
|
|
348
|
-
const base = emptyCounters();
|
|
349
|
-
const r = raw as Partial<MetricsCounterSet> | undefined;
|
|
350
|
-
for (const key of Object.keys(base) as Array<keyof MetricsCounterSet>) {
|
|
351
|
-
const value = r?.[key];
|
|
352
|
-
if (typeof value === "number" && Number.isFinite(value)) base[key] = value;
|
|
353
|
-
}
|
|
354
|
-
return base;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
function normaliseGrain(raw: unknown): MetricsGrain {
|
|
358
|
-
const r = raw as Partial<MetricsGrain> | undefined;
|
|
359
|
-
const grain = emptyGrain();
|
|
360
|
-
grain.counters = normaliseCounters(r?.counters);
|
|
361
|
-
grain.latency = normaliseLatency(r?.latency);
|
|
362
|
-
grain.cacheHitPercent = normaliseLatency(r?.cacheHitPercent);
|
|
363
|
-
grain.toolCallsPerTurn = normaliseLatency(r?.toolCallsPerTurn);
|
|
364
|
-
grain.apiCallsPerTurn = normaliseLatency(r?.apiCallsPerTurn);
|
|
365
|
-
if (r?.toolCallsByName && typeof r.toolCallsByName === "object") {
|
|
366
|
-
for (const [key, value] of Object.entries(r.toolCallsByName)) {
|
|
367
|
-
if (typeof value === "number" && Number.isFinite(value)) {
|
|
368
|
-
grain.toolCallsByName[key] = value;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
if (r?.backend && typeof r.backend === "object") {
|
|
373
|
-
for (const [key, value] of Object.entries(r.backend)) {
|
|
374
|
-
grain.backend[key] = {
|
|
375
|
-
...normaliseCounters(value),
|
|
376
|
-
latency: normaliseLatency((value as { latency?: unknown }).latency),
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
return grain;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Coerce an untrusted/persisted metrics blob into a well-formed
|
|
385
|
-
* SessionMetrics: missing fields backfilled, non-finite numbers dropped,
|
|
386
|
-
* JSON's `minMs: null` (Infinity doesn't survive JSON.stringify) restored
|
|
387
|
-
* to the domain sentinel. Runs once at the load boundary (sessions-repo);
|
|
388
|
-
* in-memory metrics stay well-formed by construction after that.
|
|
389
|
-
*/
|
|
390
|
-
export function normaliseMetrics(metrics: unknown): SessionMetrics {
|
|
391
|
-
const raw = metrics as Partial<SessionMetrics> | undefined;
|
|
392
|
-
const result: SessionMetrics = {
|
|
393
|
-
lifetime: normaliseGrain(raw?.lifetime),
|
|
394
|
-
buckets: {},
|
|
395
|
-
};
|
|
396
|
-
if (raw?.buckets && typeof raw.buckets === "object") {
|
|
397
|
-
for (const [day, grain] of Object.entries(raw.buckets)) {
|
|
398
|
-
if (/^\d{4}-\d{2}-\d{2}$/.test(day))
|
|
399
|
-
result.buckets[day] = normaliseGrain(grain);
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
return result;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
217
|
function addLatency(agg: MetricsLatencyAgg, value: number): void {
|
|
406
218
|
if (!Number.isFinite(value)) return;
|
|
407
219
|
agg.count += 1;
|
|
@@ -27,66 +27,12 @@ import * as repo from "./repositories/triggers-repo.js";
|
|
|
27
27
|
|
|
28
28
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
29
29
|
|
|
30
|
-
export type
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
| "errored" // exited non-zero — fired error wake message
|
|
37
|
-
| "cancelled" // killed by user (trigger_cancel)
|
|
38
|
-
| "timed_out" // killed by hard timeout
|
|
39
|
-
| "terminated"; // killed by Talon shutdown / restart
|
|
40
|
-
|
|
41
|
-
export type Trigger = {
|
|
42
|
-
id: string;
|
|
43
|
-
chatId: string;
|
|
44
|
-
numericChatId: number;
|
|
45
|
-
name: string;
|
|
46
|
-
language: TriggerLanguage;
|
|
47
|
-
/** Absolute path to the script body on disk. */
|
|
48
|
-
scriptPath: string;
|
|
49
|
-
/** Absolute path to the run log (interleaved stdout+stderr). */
|
|
50
|
-
logPath: string;
|
|
51
|
-
description?: string;
|
|
52
|
-
status: TriggerStatus;
|
|
53
|
-
createdAt: number;
|
|
54
|
-
startedAt?: number;
|
|
55
|
-
endedAt?: number;
|
|
56
|
-
/** PID of the child process while running (cleared on exit). */
|
|
57
|
-
pid?: number;
|
|
58
|
-
/** Linux /proc/<pid>/stat field 22 (start time in jiffies) captured at
|
|
59
|
-
* spawn. Used by killOrphan to defend against PID reuse — start time is
|
|
60
|
-
* monotonic per boot and unchanged by exec(), so a match guarantees the
|
|
61
|
-
* current owner of the PID is the same process we spawned. Undefined on
|
|
62
|
-
* non-Linux platforms. */
|
|
63
|
-
pidStarttime?: number;
|
|
64
|
-
/** Hard timeout in seconds. Default 24h, max 7d. */
|
|
65
|
-
timeoutSeconds: number;
|
|
66
|
-
/** Exit code on terminal status. */
|
|
67
|
-
exitCode?: number;
|
|
68
|
-
/** Total wake-ups fired for this trigger — sum of mid-run TALON_FIRE: lines
|
|
69
|
-
* plus the terminal exit fire. Incremented every time fireWake() runs. */
|
|
70
|
-
fireCount: number;
|
|
71
|
-
lastFireAt?: number;
|
|
72
|
-
/** Truncated tail of the most recent fire payload (for diagnostics). */
|
|
73
|
-
lastFirePayload?: string;
|
|
74
|
-
lastError?: string;
|
|
75
|
-
/** If true, the trigger is respawned on Talon startup if it was still
|
|
76
|
-
* active when Talon went down. Triggers in any terminal state
|
|
77
|
-
* (fired/errored/cancelled) are NOT respawned — only ones interrupted
|
|
78
|
-
* by Talon shutdown or crash. (Persistent triggers have no hard timeout,
|
|
79
|
-
* so timed_out is unreachable for them — see spawnTrigger.) */
|
|
80
|
-
persistent?: boolean;
|
|
81
|
-
/**
|
|
82
|
-
* Optional model override for the wake-up turn — a model id valid on the
|
|
83
|
-
* chat's own backend. Unset = inherit the chat's model (preferred). When set,
|
|
84
|
-
* the fired wake-up runs on this (typically cheaper) model instead, while
|
|
85
|
-
* still resuming the chat session (restricted to the same backend so
|
|
86
|
-
* continuity is preserved).
|
|
87
|
-
*/
|
|
88
|
-
model?: string;
|
|
89
|
-
};
|
|
30
|
+
export type {
|
|
31
|
+
Trigger,
|
|
32
|
+
TriggerLanguage,
|
|
33
|
+
TriggerStatus,
|
|
34
|
+
} from "./repositories/triggers-repo.js";
|
|
35
|
+
import type { Trigger, TriggerLanguage } from "./repositories/triggers-repo.js";
|
|
90
36
|
|
|
91
37
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
92
38
|
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Ambient declaration for prompt file-asset imports. The Bun-only
|
|
2
|
+
// `import x from "./foo.md" with { type: "file" }` form (used only by the
|
|
3
|
+
// generated embedded-prompts.ts, reached through the `bun` condition of
|
|
4
|
+
// the `#prompt-assets` package import) resolves to the embedded file path
|
|
5
|
+
// as a string. tsx/node never load that module — they use disk-prompts.ts.
|
|
6
|
+
declare module "*.md" {
|
|
7
|
+
const path: string;
|
|
8
|
+
export default path;
|
|
9
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reasoning-effort vocabulary — a dependency-free primitive shared by
|
|
3
|
+
* every layer (storage persists it, core resolves it, backends map it
|
|
4
|
+
* to provider knobs, frontends render pickers over it).
|
|
5
|
+
*
|
|
6
|
+
* Lives in src/types/ (a leaf: imports nothing from src/) so the layers
|
|
7
|
+
* below core/ can name the type without importing upward. core/types.ts
|
|
8
|
+
* re-exports it for the engine-side importers.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type ReasoningEffortLevel =
|
|
12
|
+
"off" | "minimal" | "low" | "medium" | "high" | "max" | "xhigh";
|
package/src/util/config.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, mkdirSync } from "node:fs";
|
|
|
2
2
|
import writeFileAtomic from "write-file-atomic";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { dirs, files as pathFiles } from "./paths.js";
|
|
5
|
+
import { hardenTalonPermissions } from "./harden.js";
|
|
5
6
|
import { setTimezone } from "./time.js";
|
|
6
7
|
import { BACKEND_IDS } from "../core/agent-runtime/model-ref.js";
|
|
7
8
|
import {
|
|
@@ -137,6 +138,8 @@ const frontendEnum = z.enum([
|
|
|
137
138
|
* Defaults are loopback-only and unauthenticated (single-machine use). To
|
|
138
139
|
* reach Talon remotely, set `host: "0.0.0.0"` and a `token` — the bridge
|
|
139
140
|
* then requires `Authorization: Bearer <token>` (or `?token=` for SSE).
|
|
141
|
+
* A non-loopback bind with no token auto-mints a persistent one
|
|
142
|
+
* (~/.talon/keys/bridge-token) rather than serving the LAN open.
|
|
140
143
|
*/
|
|
141
144
|
const nativeConfigSchema = z
|
|
142
145
|
.object({
|
|
@@ -150,8 +153,9 @@ const nativeConfigSchema = z
|
|
|
150
153
|
host: z.string().default("127.0.0.1"),
|
|
151
154
|
/**
|
|
152
155
|
* Optional shared secret. When set, every request must present it as a
|
|
153
|
-
* bearer token (header) or `?token=` query param (SSE).
|
|
154
|
-
*
|
|
156
|
+
* bearer token (header) or `?token=` query param (SSE). When unset on a
|
|
157
|
+
* non-loopback `host`, the bridge mints and persists one automatically
|
|
158
|
+
* (~/.talon/keys/bridge-token) — the network never gets an open bridge.
|
|
155
159
|
*/
|
|
156
160
|
token: z.string().optional(),
|
|
157
161
|
/**
|
|
@@ -596,9 +600,11 @@ function ensureConfigFile(): boolean {
|
|
|
596
600
|
if (!existsSync(dirs.root)) mkdirSync(dirs.root, { recursive: true });
|
|
597
601
|
if (!existsSync(dirs.data)) mkdirSync(dirs.data, { recursive: true });
|
|
598
602
|
if (!existsSync(CONFIG_FILE)) {
|
|
603
|
+
// Owner-only from birth: the config accumulates bot tokens and API keys.
|
|
599
604
|
writeFileAtomic.sync(
|
|
600
605
|
CONFIG_FILE,
|
|
601
606
|
JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n",
|
|
607
|
+
{ mode: 0o600 },
|
|
602
608
|
);
|
|
603
609
|
return true;
|
|
604
610
|
}
|
|
@@ -613,6 +619,7 @@ function ensureConfigFile(): boolean {
|
|
|
613
619
|
|
|
614
620
|
export function loadConfig(): TalonConfig {
|
|
615
621
|
ensureConfigFile();
|
|
622
|
+
hardenTalonPermissions();
|
|
616
623
|
const fileConfig = normalizeDeprecatedFrontendConfig(loadConfigFile());
|
|
617
624
|
|
|
618
625
|
// Runtime frontend override (TALON_FRONTEND_OVERRIDE). Lets the native
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* At-rest permission hardening for ~/.talon/.
|
|
3
|
+
*
|
|
4
|
+
* The tree holds credentials (config.json carries bot tokens, API keys and
|
|
5
|
+
* the bridge token; .user-session is a full Telegram login) plus chat
|
|
6
|
+
* history and logs. Node creates most of it with umask defaults, so on a
|
|
7
|
+
* multi-user machine everything is group/world readable. This pass clamps
|
|
8
|
+
* the sensitive surface to owner-only on every boot — idempotent, cheap,
|
|
9
|
+
* and it repairs installs created before the tightened modes existed.
|
|
10
|
+
*
|
|
11
|
+
* Best-effort by design: a missing path just hasn't been created yet, and
|
|
12
|
+
* a chmod failure (read-only mount, foreign owner) must never block boot.
|
|
13
|
+
* Windows has no POSIX modes worth mapping, so the pass is a no-op there.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { chmodSync } from "node:fs";
|
|
17
|
+
import { dirs, files } from "./paths.js";
|
|
18
|
+
|
|
19
|
+
const OWNER_DIR = 0o700;
|
|
20
|
+
const OWNER_FILE = 0o600;
|
|
21
|
+
|
|
22
|
+
/** Owner-only directories: everything under them inherits the protection. */
|
|
23
|
+
const HARDENED_DIRS: readonly string[] = [dirs.root, dirs.data, dirs.keys];
|
|
24
|
+
|
|
25
|
+
/** Belt-and-braces owner-only files, should they ever move out of the tree. */
|
|
26
|
+
const HARDENED_FILES: readonly string[] = [
|
|
27
|
+
files.config,
|
|
28
|
+
files.log,
|
|
29
|
+
files.database,
|
|
30
|
+
files.userSession,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/** Clamp permissions on the sensitive parts of ~/.talon/. */
|
|
34
|
+
export function hardenTalonPermissions(): void {
|
|
35
|
+
if (process.platform === "win32") return;
|
|
36
|
+
for (const dir of HARDENED_DIRS) {
|
|
37
|
+
try {
|
|
38
|
+
chmodSync(dir, OWNER_DIR);
|
|
39
|
+
} catch {
|
|
40
|
+
// absent or not ours — nothing to protect yet
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const file of HARDENED_FILES) {
|
|
44
|
+
try {
|
|
45
|
+
chmodSync(file, OWNER_FILE);
|
|
46
|
+
} catch {
|
|
47
|
+
// absent or not ours — nothing to protect yet
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/util/log.ts
CHANGED
|
@@ -137,7 +137,8 @@ if (!quiet) {
|
|
|
137
137
|
if (!IS_VITEST) {
|
|
138
138
|
streams.push({
|
|
139
139
|
level: "trace",
|
|
140
|
-
|
|
140
|
+
// 0600: turns and tool output land here — same sensitivity as history.
|
|
141
|
+
stream: createWriteStream(LOG_FILE, { flags: "a", mode: 0o600 }),
|
|
141
142
|
});
|
|
142
143
|
}
|
|
143
144
|
|
package/tsconfig.json
CHANGED
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
"moduleResolution": "bundler",
|
|
6
6
|
"resolveJsonModule": true,
|
|
7
7
|
"strict": true,
|
|
8
|
+
// Beyond-strict gates. Two more are measured but not yet on — the fix
|
|
9
|
+
// waves are their own PRs: noUncheckedIndexedAccess (666 errors),
|
|
10
|
+
// exactOptionalPropertyTypes (303 errors).
|
|
11
|
+
"noImplicitOverride": true,
|
|
12
|
+
"noFallthroughCasesInSwitch": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
8
14
|
"esModuleInterop": true,
|
|
9
15
|
"skipLibCheck": true,
|
|
10
16
|
"outDir": "dist",
|