polygram 0.38.2 → 0.38.4
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/.claude-plugin/plugin.json +1 -1
- package/lib/approvals/store.js +45 -7
- package/lib/codex/runtime-controller.js +107 -1
- package/lib/db/event-detail-schema.js +524 -0
- package/lib/db/pairings.js +9 -1
- package/lib/db/secret-sweep.js +6 -3
- package/lib/db/sessions.js +2 -2
- package/lib/db.js +308 -49
- package/lib/handlers/abort.js +20 -3
- package/lib/handlers/approvals.js +2 -2
- package/lib/handlers/config-callback.js +7 -7
- package/lib/handlers/dispatcher.js +11 -9
- package/lib/handlers/questions.js +546 -45
- package/lib/handlers/slash-commands.js +54 -13
- package/lib/handlers/voice.js +1 -1
- package/lib/ops/clean-shutdown.js +5 -0
- package/lib/questions/questions.js +51 -0
- package/lib/questions/store.js +61 -5
- package/lib/rewind/execute.js +12 -2
- package/lib/rewind/rewind.js +1 -1
- package/lib/sdk/build-options.js +2 -2
- package/lib/sdk/callbacks.js +14 -9
- package/lib/secret-detect.js +280 -12
- package/lib/telegram/api.js +10 -2
- package/lib/telegram/rich-edit.js +9 -5
- package/lib/telegram/rich-send.js +6 -6
- package/migrations/020-drop-secret-fingerprint.sql +13 -0
- package/package.json +2 -2
- package/polygram.js +82 -45
package/lib/db.js
CHANGED
|
@@ -26,6 +26,48 @@ const {
|
|
|
26
26
|
const {
|
|
27
27
|
sanitizeCodexFaultProvenance,
|
|
28
28
|
} = require('./codex/fault-provenance');
|
|
29
|
+
const {
|
|
30
|
+
sanitizeForDurableWrite,
|
|
31
|
+
sanitizeDurableStructured,
|
|
32
|
+
sanitizeDurableJsonText,
|
|
33
|
+
} = require('./secret-detect');
|
|
34
|
+
const {
|
|
35
|
+
enforceEventDetailSchema,
|
|
36
|
+
} = require('./db/event-detail-schema');
|
|
37
|
+
|
|
38
|
+
// Pre-write secret boundary. Every durable text sink below routes its text
|
|
39
|
+
// through this so a recognized credential never reaches a row, the
|
|
40
|
+
// external-content FTS index that mirrors it, a persisted error, or telemetry.
|
|
41
|
+
// The caller's own value is untouched: the live provider turn always sees the
|
|
42
|
+
// original message. Detection is deterministic shape/keyword matching, so
|
|
43
|
+
// prose that never declares a credential is not covered here — the sweep and
|
|
44
|
+
// the agent-reported redaction remain the defense-in-depth behind it.
|
|
45
|
+
const durableText = (value) => (
|
|
46
|
+
typeof value === 'string' ? sanitizeForDurableWrite(value).text : value
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Truncating a masked text can cut a placeholder in half, leaving a fragment
|
|
50
|
+
// like `‹redacted:kv-sec` that reads as ordinary text and no longer survives
|
|
51
|
+
// a second masking pass unchanged. Cut before the placeholder that straddles
|
|
52
|
+
// the limit instead.
|
|
53
|
+
const truncateAfterMask = (text, limit) => {
|
|
54
|
+
if (typeof text !== 'string' || text.length <= limit) return text;
|
|
55
|
+
const cut = text.slice(0, limit);
|
|
56
|
+
const open = cut.lastIndexOf('‹');
|
|
57
|
+
if (open !== -1 && cut.indexOf('›', open) === -1) return cut.slice(0, open);
|
|
58
|
+
return cut;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// A JSON-valued column is sanitized as STRUCTURE, not as text: masking a
|
|
62
|
+
// serialized document can splice across its delimiters, and an escaped quote
|
|
63
|
+
// hides a declared value from the detector. Callers usually hand over an
|
|
64
|
+
// object; a string is parsed back so a caller that pre-serialized still gets
|
|
65
|
+
// the structural pass. Text that is not JSON falls back to string masking.
|
|
66
|
+
const durableJson = (value) => {
|
|
67
|
+
if (value == null) return value;
|
|
68
|
+
if (typeof value !== 'string') return JSON.stringify(sanitizeDurableStructured(value));
|
|
69
|
+
return sanitizeDurableJsonText(value).text;
|
|
70
|
+
};
|
|
29
71
|
|
|
30
72
|
// 0.8.0 Phase 1: bumped from 9 → 10. Adds migration
|
|
31
73
|
// 010-tool-use-id.sql (pending_approvals.tool_use_id column for the
|
|
@@ -45,7 +87,7 @@ const {
|
|
|
45
87
|
//
|
|
46
88
|
// 0.15: bumped 13 → 14. Adds migration 014-secret-redactions.sql
|
|
47
89
|
// (secret_redactions audit table + messages.secret_scanned_at).
|
|
48
|
-
const SCHEMA_VERSION =
|
|
90
|
+
const SCHEMA_VERSION = 20;
|
|
49
91
|
|
|
50
92
|
// Sentinel `error` value for outbound rows whose API call may or may not
|
|
51
93
|
// have reached Telegram. markStalePending writes it; hasOutboundReplyTo
|
|
@@ -1421,10 +1463,20 @@ function wrap(db) {
|
|
|
1421
1463
|
itemId,
|
|
1422
1464
|
detailJson,
|
|
1423
1465
|
})) {
|
|
1424
|
-
|
|
1466
|
+
const deferred = (
|
|
1467
|
+
kind === 'telegram-delivery-failed'
|
|
1468
|
+
&& isCleanRetirementDelivery(attempt)
|
|
1469
|
+
);
|
|
1470
|
+
return {
|
|
1471
|
+
changes: 0,
|
|
1472
|
+
attemptId,
|
|
1473
|
+
kind,
|
|
1474
|
+
...(deferred ? { deferred: true } : {}),
|
|
1475
|
+
};
|
|
1425
1476
|
}
|
|
1426
1477
|
}
|
|
1427
1478
|
assertCodexCheckpointOwner(generation, kind);
|
|
1479
|
+
let deferred = false;
|
|
1428
1480
|
|
|
1429
1481
|
if (kind === 'request-prepared') {
|
|
1430
1482
|
requiredCodexString(method, 'request method');
|
|
@@ -1728,21 +1780,24 @@ function wrap(db) {
|
|
|
1728
1780
|
);
|
|
1729
1781
|
}
|
|
1730
1782
|
const deliveryFailed = kind === 'telegram-delivery-failed';
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
? '
|
|
1735
|
-
: attempt.terminal_status === '
|
|
1736
|
-
? '
|
|
1737
|
-
: '
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1783
|
+
deferred = deliveryFailed && isCleanRetirementDelivery(attempt);
|
|
1784
|
+
if (!deferred) {
|
|
1785
|
+
const linkedState = deliveryFailed
|
|
1786
|
+
? 'failed'
|
|
1787
|
+
: attempt.terminal_status === 'interrupted'
|
|
1788
|
+
? 'interrupted'
|
|
1789
|
+
: attempt.terminal_status === 'failed'
|
|
1790
|
+
? 'failed'
|
|
1791
|
+
: 'settled';
|
|
1792
|
+
settleCodexAttemptConsumerOutcome({
|
|
1793
|
+
attempt,
|
|
1794
|
+
generationId,
|
|
1795
|
+
linkedState,
|
|
1796
|
+
attemptRecoveryState: 'settled',
|
|
1797
|
+
settleQueuedReservation: true,
|
|
1798
|
+
ts,
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1746
1801
|
} else if (
|
|
1747
1802
|
['active-start-cancelled', 'queued-send-cancelled'].includes(kind)
|
|
1748
1803
|
) {
|
|
@@ -1902,7 +1957,166 @@ function wrap(db) {
|
|
|
1902
1957
|
detailJson,
|
|
1903
1958
|
ts,
|
|
1904
1959
|
);
|
|
1905
|
-
return {
|
|
1960
|
+
return {
|
|
1961
|
+
changes: 1,
|
|
1962
|
+
attemptId,
|
|
1963
|
+
kind,
|
|
1964
|
+
...(deferred ? { deferred: true } : {}),
|
|
1965
|
+
};
|
|
1966
|
+
})();
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
function isCleanRetirementDelivery(attempt) {
|
|
1970
|
+
if (
|
|
1971
|
+
attempt?.method !== 'turn/start'
|
|
1972
|
+
|| attempt.terminal_status !== 'interrupted'
|
|
1973
|
+
|| !['terminal-pending', 'clean-pending'].includes(
|
|
1974
|
+
attempt.recovery_state,
|
|
1975
|
+
)
|
|
1976
|
+
) return false;
|
|
1977
|
+
return db.prepare(`
|
|
1978
|
+
SELECT 1
|
|
1979
|
+
FROM codex_attempt_checkpoints
|
|
1980
|
+
WHERE generation_id = ?
|
|
1981
|
+
AND attempt_id = ?
|
|
1982
|
+
AND kind = 'clean-retirement-requested'
|
|
1983
|
+
AND thread_id = ?
|
|
1984
|
+
AND turn_id = ?
|
|
1985
|
+
LIMIT 1
|
|
1986
|
+
`).get(
|
|
1987
|
+
attempt.generation_id,
|
|
1988
|
+
attempt.attempt_id,
|
|
1989
|
+
attempt.thread_id,
|
|
1990
|
+
attempt.turn_id,
|
|
1991
|
+
) != null;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
function prepareCodexCleanRetirement(input) {
|
|
1995
|
+
const allowed = new Set([
|
|
1996
|
+
'generation_id',
|
|
1997
|
+
'session_key',
|
|
1998
|
+
'attempt_id',
|
|
1999
|
+
'provider_session_id',
|
|
2000
|
+
'provider_turn_id',
|
|
2001
|
+
'source_message_id',
|
|
2002
|
+
'stable_host_id',
|
|
2003
|
+
'boot_session_id',
|
|
2004
|
+
'ts',
|
|
2005
|
+
]);
|
|
2006
|
+
assertExactKeys(
|
|
2007
|
+
input,
|
|
2008
|
+
allowed,
|
|
2009
|
+
'CODEX_RETIREMENT_PREPARATION_REJECTED',
|
|
2010
|
+
'clean retirement preparation',
|
|
2011
|
+
);
|
|
2012
|
+
const generationId = requiredCodexString(
|
|
2013
|
+
input?.generation_id,
|
|
2014
|
+
'generation ID',
|
|
2015
|
+
);
|
|
2016
|
+
const sessionKey = requiredCodexString(input?.session_key, 'session key');
|
|
2017
|
+
const attemptId = requiredCodexString(input?.attempt_id, 'attempt ID');
|
|
2018
|
+
const providerSessionId = requiredCodexString(
|
|
2019
|
+
input?.provider_session_id,
|
|
2020
|
+
'provider session ID',
|
|
2021
|
+
);
|
|
2022
|
+
const providerTurnId = requiredCodexString(
|
|
2023
|
+
input?.provider_turn_id,
|
|
2024
|
+
'provider turn ID',
|
|
2025
|
+
);
|
|
2026
|
+
const sourceMessageId = requiredCodexString(
|
|
2027
|
+
input?.source_message_id == null
|
|
2028
|
+
? input?.source_message_id
|
|
2029
|
+
: String(input.source_message_id),
|
|
2030
|
+
'source message ID',
|
|
2031
|
+
);
|
|
2032
|
+
const stableHostId = requiredCodexString(
|
|
2033
|
+
input?.stable_host_id,
|
|
2034
|
+
'stable host identity',
|
|
2035
|
+
);
|
|
2036
|
+
const bootSessionId = requiredCodexString(
|
|
2037
|
+
input?.boot_session_id,
|
|
2038
|
+
'boot-session identity',
|
|
2039
|
+
);
|
|
2040
|
+
const ts = requiredTimestamp(
|
|
2041
|
+
input?.ts ?? Date.now(),
|
|
2042
|
+
'clean retirement preparation timestamp',
|
|
2043
|
+
);
|
|
2044
|
+
|
|
2045
|
+
return db.transaction(() => {
|
|
2046
|
+
const generation = assertCodexGenerationIdentity(
|
|
2047
|
+
generationId,
|
|
2048
|
+
stableHostId,
|
|
2049
|
+
bootSessionId,
|
|
2050
|
+
);
|
|
2051
|
+
assertCodexCheckpointOwner(generation, 'clean-retirement-requested');
|
|
2052
|
+
const attempt = requireCodexAttempt(attemptId, generationId);
|
|
2053
|
+
const accepted = hasCodexTurnCheckpoint(
|
|
2054
|
+
generationId,
|
|
2055
|
+
attemptId,
|
|
2056
|
+
'turn-accepted',
|
|
2057
|
+
providerTurnId,
|
|
2058
|
+
);
|
|
2059
|
+
if (
|
|
2060
|
+
generation.session_key !== sessionKey
|
|
2061
|
+
|| attempt.session_key !== sessionKey
|
|
2062
|
+
|| attempt.method !== 'turn/start'
|
|
2063
|
+
|| attempt.thread_id !== providerSessionId
|
|
2064
|
+
|| attempt.turn_id !== providerTurnId
|
|
2065
|
+
|| attempt.telegram_source_message_id !== sourceMessageId
|
|
2066
|
+
|| attempt.delivery_state !== 'response-observed'
|
|
2067
|
+
|| attempt.response_outcome !== 'result'
|
|
2068
|
+
|| attempt.recovery_state !== 'active'
|
|
2069
|
+
|| attempt.terminal_status != null
|
|
2070
|
+
|| !accepted
|
|
2071
|
+
) {
|
|
2072
|
+
throw codexError(
|
|
2073
|
+
'Codex clean retirement target is not the exact active accepted turn',
|
|
2074
|
+
'CODEX_RETIREMENT_PREPARATION_REJECTED',
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
const existing = db.prepare(`
|
|
2078
|
+
SELECT thread_id, turn_id
|
|
2079
|
+
FROM codex_attempt_checkpoints
|
|
2080
|
+
WHERE generation_id = ?
|
|
2081
|
+
AND attempt_id = ?
|
|
2082
|
+
AND kind = 'clean-retirement-requested'
|
|
2083
|
+
`).all(generationId, attemptId);
|
|
2084
|
+
if (existing.length > 0) {
|
|
2085
|
+
if (
|
|
2086
|
+
existing.length === 1
|
|
2087
|
+
&& existing[0].thread_id === providerSessionId
|
|
2088
|
+
&& existing[0].turn_id === providerTurnId
|
|
2089
|
+
) {
|
|
2090
|
+
return {
|
|
2091
|
+
changes: 0,
|
|
2092
|
+
disposition: 'retirement-requested',
|
|
2093
|
+
generationId,
|
|
2094
|
+
attemptId,
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
throw codexError(
|
|
2098
|
+
'Codex clean retirement target has conflicting ownership',
|
|
2099
|
+
'CODEX_RETIREMENT_PREPARATION_REJECTED',
|
|
2100
|
+
);
|
|
2101
|
+
}
|
|
2102
|
+
db.prepare(`
|
|
2103
|
+
INSERT INTO codex_attempt_checkpoints (
|
|
2104
|
+
generation_id, attempt_id, kind, thread_id, turn_id,
|
|
2105
|
+
request_id, item_id, detail_json, ts
|
|
2106
|
+
) VALUES (?, ?, 'clean-retirement-requested', ?, ?, NULL, NULL, NULL, ?)
|
|
2107
|
+
`).run(
|
|
2108
|
+
generationId,
|
|
2109
|
+
attemptId,
|
|
2110
|
+
providerSessionId,
|
|
2111
|
+
providerTurnId,
|
|
2112
|
+
ts,
|
|
2113
|
+
);
|
|
2114
|
+
return {
|
|
2115
|
+
changes: 1,
|
|
2116
|
+
disposition: 'retirement-requested',
|
|
2117
|
+
generationId,
|
|
2118
|
+
attemptId,
|
|
2119
|
+
};
|
|
1906
2120
|
})();
|
|
1907
2121
|
}
|
|
1908
2122
|
|
|
@@ -1930,7 +2144,7 @@ function wrap(db) {
|
|
|
1930
2144
|
}
|
|
1931
2145
|
return db.transaction(() => {
|
|
1932
2146
|
const result = recordCodexCheckpoint(checkpoint);
|
|
1933
|
-
if (!input.retireGeneration) {
|
|
2147
|
+
if (!input.retireGeneration || result.deferred === true) {
|
|
1934
2148
|
return { ...result, retired: false };
|
|
1935
2149
|
}
|
|
1936
2150
|
const generationId = requiredCodexString(
|
|
@@ -3788,7 +4002,7 @@ function wrap(db) {
|
|
|
3788
4002
|
msg_id: row.msg_id,
|
|
3789
4003
|
user: row.user || null,
|
|
3790
4004
|
user_id: row.user_id || null,
|
|
3791
|
-
text: row.text || '',
|
|
4005
|
+
text: durableText(row.text || ''),
|
|
3792
4006
|
reply_to_id: row.reply_to_id || null,
|
|
3793
4007
|
direction: row.direction || 'in',
|
|
3794
4008
|
source: row.source || 'polygram',
|
|
@@ -3798,7 +4012,7 @@ function wrap(db) {
|
|
|
3798
4012
|
effort: row.effort || null,
|
|
3799
4013
|
turn_id: row.turn_id || null,
|
|
3800
4014
|
status: row.status || 'received',
|
|
3801
|
-
error: row.error || null,
|
|
4015
|
+
error: durableText(row.error) || null,
|
|
3802
4016
|
cost_usd: row.cost_usd ?? null,
|
|
3803
4017
|
ts: row.ts || Date.now(),
|
|
3804
4018
|
});
|
|
@@ -3809,7 +4023,7 @@ function wrap(db) {
|
|
|
3809
4023
|
chat_id: String(row.chat_id),
|
|
3810
4024
|
thread_id: row.thread_id ? String(row.thread_id) : null,
|
|
3811
4025
|
user: row.user || null,
|
|
3812
|
-
text: row.text || '',
|
|
4026
|
+
text: durableText(row.text || ''),
|
|
3813
4027
|
source: row.source || 'polygram',
|
|
3814
4028
|
bot_name: row.bot_name || null,
|
|
3815
4029
|
turn_id: row.turn_id || null,
|
|
@@ -3825,7 +4039,10 @@ function wrap(db) {
|
|
|
3825
4039
|
},
|
|
3826
4040
|
|
|
3827
4041
|
markOutboundFailed(id, err) {
|
|
3828
|
-
return markOutboundFailedStmt.run({
|
|
4042
|
+
return markOutboundFailedStmt.run({
|
|
4043
|
+
id,
|
|
4044
|
+
error: truncateAfterMask(durableText(String(err)), 500),
|
|
4045
|
+
});
|
|
3829
4046
|
},
|
|
3830
4047
|
|
|
3831
4048
|
updateOutboundText({ chat_id, msg_id, text, ts }) {
|
|
@@ -3833,7 +4050,7 @@ function wrap(db) {
|
|
|
3833
4050
|
return updateOutboundTextStmt.run({
|
|
3834
4051
|
chat_id: String(chat_id),
|
|
3835
4052
|
msg_id: Number(msg_id),
|
|
3836
|
-
text,
|
|
4053
|
+
text: durableText(text),
|
|
3837
4054
|
ts: ts || Date.now(),
|
|
3838
4055
|
});
|
|
3839
4056
|
},
|
|
@@ -3898,6 +4115,7 @@ function wrap(db) {
|
|
|
3898
4115
|
},
|
|
3899
4116
|
|
|
3900
4117
|
createCodexGeneration,
|
|
4118
|
+
prepareCodexCleanRetirement,
|
|
3901
4119
|
recordCodexCheckpoint,
|
|
3902
4120
|
recordCodexDeliveryCheckpoint,
|
|
3903
4121
|
getCodexAttempt(attemptId) {
|
|
@@ -3963,7 +4181,7 @@ function wrap(db) {
|
|
|
3963
4181
|
return setMessageTextStmt.run({
|
|
3964
4182
|
chat_id: String(chat_id),
|
|
3965
4183
|
msg_id,
|
|
3966
|
-
text: text ?? '',
|
|
4184
|
+
text: durableText(text ?? ''),
|
|
3967
4185
|
});
|
|
3968
4186
|
},
|
|
3969
4187
|
|
|
@@ -3976,16 +4194,28 @@ function wrap(db) {
|
|
|
3976
4194
|
return row?.new_chat_id || String(chatId);
|
|
3977
4195
|
},
|
|
3978
4196
|
|
|
4197
|
+
// Telemetry passes two gates before it is persisted: the content-free
|
|
4198
|
+
// field schema decides WHICH fields may exist at all, then the durable
|
|
4199
|
+
// secret boundary masks recognized credentials in what survived. The
|
|
4200
|
+
// schema is the primary guarantee — masking alone would only remove
|
|
4201
|
+
// content a detector recognizes.
|
|
3979
4202
|
logEvent(kind, { chat_id = null, ...detail } = {}) {
|
|
4203
|
+
const { detail: allowed, dropped, droppedCount } = enforceEventDetailSchema(detail);
|
|
4204
|
+
// The count is always safe to record; the names only when they came
|
|
4205
|
+
// from the schema's own vocabulary (an unknown key can be content).
|
|
4206
|
+
if (droppedCount) allowed.dropped_field_count = droppedCount;
|
|
4207
|
+
if (dropped.length) allowed.dropped_fields = dropped;
|
|
3980
4208
|
return logEventStmt.run(
|
|
3981
4209
|
Date.now(),
|
|
3982
4210
|
chat_id ? String(chat_id) : null,
|
|
3983
4211
|
kind,
|
|
3984
|
-
Object.keys(
|
|
4212
|
+
Object.keys(allowed).length
|
|
4213
|
+
? JSON.stringify(sanitizeDurableStructured(allowed))
|
|
4214
|
+
: null,
|
|
3985
4215
|
);
|
|
3986
4216
|
},
|
|
3987
4217
|
|
|
3988
|
-
//
|
|
4218
|
+
// Find compact-command events from the recent window that
|
|
3989
4219
|
// never produced a matching compact-boundary on the same
|
|
3990
4220
|
// session_key. These are "interrupted by deploy" cases — polygram
|
|
3991
4221
|
// pushed /compact into SDK input but the daemon restarted before
|
|
@@ -3997,23 +4227,38 @@ function wrap(db) {
|
|
|
3997
4227
|
// session_key AND ts > compact_command.ts. If none within
|
|
3998
4228
|
// search-window, it's orphaned.
|
|
3999
4229
|
//
|
|
4230
|
+
// The command text itself is message content, so telemetry carries only
|
|
4231
|
+
// the id of the inbound message that holds it. The text is joined back
|
|
4232
|
+
// from that row — already sanitized by the durable-write boundary — and
|
|
4233
|
+
// is null when the row is gone or the event predates the id, which the
|
|
4234
|
+
// caller treats as "surface a retry notice instead of replaying".
|
|
4235
|
+
//
|
|
4236
|
+
// Events written before the hint moved out of telemetry still carry it in
|
|
4237
|
+
// their detail. Those stay recoverable, from the row when one exists and
|
|
4238
|
+
// otherwise from the stored copy, sanitized on the way out so a legacy
|
|
4239
|
+
// row cannot hand a credential back.
|
|
4240
|
+
//
|
|
4000
4241
|
// @param {object} opts
|
|
4001
4242
|
// @param {number} opts.olderThanMs - cutoff (only events newer than this are scanned)
|
|
4002
|
-
// @returns {Array<{ts, chat_id, thread_id, session_key, user, user_id, text_len}>}
|
|
4243
|
+
// @returns {Array<{ts, chat_id, thread_id, session_key, user, user_id, text_len, text}>}
|
|
4003
4244
|
findOrphanedCompactCommands({ olderThanMs = 30 * 60 * 1000 } = {}) {
|
|
4004
4245
|
const cutoff = Date.now() - olderThanMs;
|
|
4005
4246
|
const cmds = db.prepare(`
|
|
4006
|
-
SELECT id, ts, chat_id,
|
|
4007
|
-
json_extract(detail_json, '$.thread_id') AS thread_id,
|
|
4008
|
-
json_extract(detail_json, '$.session_key') AS session_key,
|
|
4009
|
-
json_extract(detail_json, '$.
|
|
4010
|
-
json_extract(detail_json, '$.
|
|
4011
|
-
|
|
4012
|
-
json_extract(detail_json, '$.text') AS
|
|
4013
|
-
FROM events
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4247
|
+
SELECT event.id, event.ts, event.chat_id,
|
|
4248
|
+
json_extract(event.detail_json, '$.thread_id') AS thread_id,
|
|
4249
|
+
json_extract(event.detail_json, '$.session_key') AS session_key,
|
|
4250
|
+
json_extract(event.detail_json, '$.user_id') AS user_id,
|
|
4251
|
+
json_extract(event.detail_json, '$.text_len') AS text_len,
|
|
4252
|
+
source.text AS row_text,
|
|
4253
|
+
json_extract(event.detail_json, '$.text') AS legacy_text
|
|
4254
|
+
FROM events AS event
|
|
4255
|
+
LEFT JOIN messages AS source
|
|
4256
|
+
ON source.chat_id = event.chat_id
|
|
4257
|
+
AND source.msg_id = json_extract(event.detail_json, '$.msg_id')
|
|
4258
|
+
AND source.direction = 'in'
|
|
4259
|
+
WHERE event.kind = 'compact-command'
|
|
4260
|
+
AND event.ts > ?
|
|
4261
|
+
ORDER BY event.ts ASC, event.id ASC
|
|
4017
4262
|
`).all(cutoff);
|
|
4018
4263
|
const orphans = [];
|
|
4019
4264
|
for (const c of cmds) {
|
|
@@ -4030,7 +4275,7 @@ function wrap(db) {
|
|
|
4030
4275
|
LIMIT 1
|
|
4031
4276
|
`).get(c.id, c.session_key);
|
|
4032
4277
|
if (boundary) continue;
|
|
4033
|
-
//
|
|
4278
|
+
// Also skip if a previous boot has already handled
|
|
4034
4279
|
// this orphan (silent replay via compact-replay event, OR
|
|
4035
4280
|
// surface-fallback via compact-failed-restart event). Both
|
|
4036
4281
|
// of those record `original_ts` in their detail_json
|
|
@@ -4044,7 +4289,15 @@ function wrap(db) {
|
|
|
4044
4289
|
LIMIT 1
|
|
4045
4290
|
`).get(c.ts);
|
|
4046
4291
|
if (handled) continue;
|
|
4047
|
-
|
|
4292
|
+
const { row_text, legacy_text, ...rest } = c;
|
|
4293
|
+
// Both sources are sanitized on the way out. The row is normally
|
|
4294
|
+
// masked at write, but rows written before this boundary existed are
|
|
4295
|
+
// not — and those are exactly the oldest, most likely to be replayed.
|
|
4296
|
+
const recovered = row_text ?? legacy_text;
|
|
4297
|
+
orphans.push({
|
|
4298
|
+
...rest,
|
|
4299
|
+
text: recovered != null ? durableText(recovered) : null,
|
|
4300
|
+
});
|
|
4048
4301
|
}
|
|
4049
4302
|
return orphans;
|
|
4050
4303
|
},
|
|
@@ -4068,7 +4321,8 @@ function wrap(db) {
|
|
|
4068
4321
|
num_assistant_messages: row.num_assistant_messages ?? null,
|
|
4069
4322
|
num_tool_uses: row.num_tool_uses ?? null,
|
|
4070
4323
|
result_subtype: row.result_subtype || null,
|
|
4071
|
-
|
|
4324
|
+
// The turn's failure text can echo the provider's own output.
|
|
4325
|
+
error: durableText(row.error) || null,
|
|
4072
4326
|
});
|
|
4073
4327
|
},
|
|
4074
4328
|
|
|
@@ -4111,7 +4365,12 @@ function wrap(db) {
|
|
|
4111
4365
|
chat_id: String(row.chat_id),
|
|
4112
4366
|
tool_name: String(row.tool_name),
|
|
4113
4367
|
match_type: row.match_type,
|
|
4114
|
-
|
|
4368
|
+
// The pattern is derived from a tool input, so it can carry a
|
|
4369
|
+
// credential into durable storage. Masking it means a rule whose
|
|
4370
|
+
// pattern contained one no longer matches that live input: the call
|
|
4371
|
+
// falls through to a fresh approval prompt rather than silently
|
|
4372
|
+
// auto-allowing or auto-denying it.
|
|
4373
|
+
input_pattern: durableText(row.input_pattern),
|
|
4115
4374
|
decision: row.decision,
|
|
4116
4375
|
issued_ts: row.issued_ts || Date.now(),
|
|
4117
4376
|
issued_by_user_id: row.issued_by_user_id != null
|
|
@@ -4558,7 +4817,6 @@ function wrap(db) {
|
|
|
4558
4817
|
redactSecretInChat({ chat_id, thread_id = null, secret, now = Date.now(), limit = 200 }) {
|
|
4559
4818
|
if (typeof secret !== 'string' || secret.length < 3) return { redacted: 0 };
|
|
4560
4819
|
const PLACEHOLDER = '‹redacted:reported›';
|
|
4561
|
-
const sha = require('crypto').createHash('sha256').update(secret).digest('hex');
|
|
4562
4820
|
const rows = (thread_id != null
|
|
4563
4821
|
? db.prepare(`SELECT id, msg_id, text FROM messages WHERE chat_id=? AND thread_id=? AND direction='in' ORDER BY id DESC LIMIT ?`).all(String(chat_id), String(thread_id), limit)
|
|
4564
4822
|
: db.prepare(`SELECT id, msg_id, text FROM messages WHERE chat_id=? AND direction='in' ORDER BY id DESC LIMIT ?`).all(String(chat_id), limit));
|
|
@@ -4568,8 +4826,8 @@ function wrap(db) {
|
|
|
4568
4826
|
if (!r.text || !r.text.includes(secret)) continue;
|
|
4569
4827
|
const newText = r.text.split(secret).join(PLACEHOLDER);
|
|
4570
4828
|
db.prepare('UPDATE messages SET text = ? WHERE id = ?').run(newText, r.id);
|
|
4571
|
-
db.prepare(`INSERT INTO secret_redactions (chat_id, msg_id, rule, tier, length,
|
|
4572
|
-
VALUES (
|
|
4829
|
+
db.prepare(`INSERT INTO secret_redactions (chat_id, msg_id, rule, tier, length, action, ts)
|
|
4830
|
+
VALUES (?,?,?,?,?,?,?)`).run(String(chat_id), r.msg_id, 'reported', 'reported', secret.length, 'redacted', now);
|
|
4573
4831
|
redacted += 1;
|
|
4574
4832
|
}
|
|
4575
4833
|
});
|
|
@@ -4605,7 +4863,8 @@ function wrap(db) {
|
|
|
4605
4863
|
file_id,
|
|
4606
4864
|
file_unique_id || null,
|
|
4607
4865
|
kind,
|
|
4608
|
-
|
|
4866
|
+
// A user-supplied filename can carry a declared credential.
|
|
4867
|
+
durableText(name) || null,
|
|
4609
4868
|
mime_type || null,
|
|
4610
4869
|
size_bytes ?? null,
|
|
4611
4870
|
ts || Date.now(),
|
|
@@ -4629,13 +4888,13 @@ function wrap(db) {
|
|
|
4629
4888
|
SET download_status = 'failed',
|
|
4630
4889
|
download_error = ?
|
|
4631
4890
|
WHERE id = ?
|
|
4632
|
-
`).run(String(error || 'unknown')
|
|
4891
|
+
`).run(truncateAfterMask(durableText(String(error || 'unknown')), 500), id);
|
|
4633
4892
|
},
|
|
4634
4893
|
|
|
4635
4894
|
setAttachmentTranscription(id, text) {
|
|
4636
4895
|
return db.prepare(`
|
|
4637
4896
|
UPDATE attachments SET transcription = ? WHERE id = ?
|
|
4638
|
-
`).run(text || null, id);
|
|
4897
|
+
`).run(durableJson(text) || null, id);
|
|
4639
4898
|
},
|
|
4640
4899
|
|
|
4641
4900
|
getAttachmentsByMessage(message_id) {
|
package/lib/handlers/abort.js
CHANGED
|
@@ -69,6 +69,10 @@ function createHandleAbort({
|
|
|
69
69
|
isAbortRequest,
|
|
70
70
|
markSessionAborted,
|
|
71
71
|
clearAutosteeredReactions,
|
|
72
|
+
// Narrow async hook: retire the question state for a session whose process
|
|
73
|
+
// is being stopped. Kept as a callback so this handler never learns about
|
|
74
|
+
// the question store.
|
|
75
|
+
retireQuestionSession = null,
|
|
72
76
|
getSessionKey,
|
|
73
77
|
botName,
|
|
74
78
|
// Cancel-cheap (spec Finding 5): delay before the second background-shell
|
|
@@ -146,6 +150,12 @@ function createHandleAbort({
|
|
|
146
150
|
);
|
|
147
151
|
}
|
|
148
152
|
|
|
153
|
+
// Only after the stop is proven, and only ever cleanup: Codex cannot
|
|
154
|
+
// raise an interactive question, so this closes a row a Claude turn left
|
|
155
|
+
// behind on a session that has since moved to Codex. An unverified stop
|
|
156
|
+
// changes nothing, exactly as before.
|
|
157
|
+
if (stopVerified) await retireQuestionSession?.(sessionKey);
|
|
158
|
+
|
|
149
159
|
logEvent('abort-requested', {
|
|
150
160
|
chat_id: chatId,
|
|
151
161
|
user_id: msg.from?.id || null,
|
|
@@ -160,7 +170,8 @@ function createHandleAbort({
|
|
|
160
170
|
feedback_clear_error_code: feedbackClearErrorCode,
|
|
161
171
|
stop_verified: stopVerified,
|
|
162
172
|
stop_error_code: stopErrorCode,
|
|
163
|
-
|
|
173
|
+
// The stop phrasing is the user's own message; its size is the signal.
|
|
174
|
+
text_len: cleanText.length,
|
|
164
175
|
});
|
|
165
176
|
|
|
166
177
|
// 👍 means the complete native stop proof settled, not merely that an
|
|
@@ -301,6 +312,9 @@ function createHandleAbort({
|
|
|
301
312
|
}
|
|
302
313
|
|
|
303
314
|
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
315
|
+
// The stopped turn owned any open question; retire its state with the same
|
|
316
|
+
// operation every other retirement path uses.
|
|
317
|
+
await retireQuestionSession?.(sessionKey);
|
|
304
318
|
logEvent('abort-requested', {
|
|
305
319
|
chat_id: chatId, user_id: msg.from?.id || null,
|
|
306
320
|
had_active: hadActive,
|
|
@@ -320,9 +334,12 @@ function createHandleAbort({
|
|
|
320
334
|
in_flight: busyProbe.inFlight,
|
|
321
335
|
pending_turns: busyProbe.pendingTurns,
|
|
322
336
|
captured: busyProbe.captured,
|
|
323
|
-
|
|
337
|
+
// The pane tail is live TUI transcript; only whether one was
|
|
338
|
+
// captured is recorded, never its content.
|
|
324
339
|
} : null,
|
|
325
|
-
|
|
340
|
+
// The stop phrasing is the user's own message; its length is enough to
|
|
341
|
+
// tell a bare "stop" from a sentence that happened to contain one.
|
|
342
|
+
text_len: cleanText.length,
|
|
326
343
|
});
|
|
327
344
|
|
|
328
345
|
// Ack (locked design 2026-06-12, Ivan): a 👍 reaction on the user's stop
|
|
@@ -243,7 +243,7 @@ function createApprovals({
|
|
|
243
243
|
return;
|
|
244
244
|
}
|
|
245
245
|
logEvent('approval-resolved', {
|
|
246
|
-
id, status, by: userId,
|
|
246
|
+
id, status, by: userId, bot: botName,
|
|
247
247
|
});
|
|
248
248
|
|
|
249
249
|
// Edit the card to show the decision.
|
|
@@ -309,7 +309,7 @@ function createApprovals({
|
|
|
309
309
|
rows = approvals.sweepTimedOut();
|
|
310
310
|
} catch (err) {
|
|
311
311
|
logger.error?.(`[approvals] sweeper DB error: ${err.message}`);
|
|
312
|
-
logEvent('approval-sweep-failed', {
|
|
312
|
+
logEvent('approval-sweep-failed', { error_class: err.name || 'Error' });
|
|
313
313
|
return;
|
|
314
314
|
}
|
|
315
315
|
for (const row of rows) {
|
|
@@ -605,19 +605,19 @@ function createHandleConfigCallback({
|
|
|
605
605
|
for (const { scope, value } of cleared) scope[configField] = value;
|
|
606
606
|
};
|
|
607
607
|
// The user tapped one card; topics they never touched can change value
|
|
608
|
-
// with it. Record
|
|
609
|
-
//
|
|
610
|
-
//
|
|
608
|
+
// with it. Record WHICH ones, by id, so a config that moved on its own
|
|
609
|
+
// is still explicable afterwards — the values they held are
|
|
610
|
+
// configuration content and the ids answer the question without them.
|
|
611
|
+
// Logged only once the sweep is persisted — an event for a rolled-back
|
|
612
|
+
// sweep is worse than none.
|
|
611
613
|
const logTopicRichTextSweep = (cleared) => {
|
|
612
614
|
if (!cleared.length) return;
|
|
613
615
|
dbWrite(() => {
|
|
614
616
|
db.logEvent('config-topic-override-swept', {
|
|
615
617
|
chat_id: chatId,
|
|
616
618
|
field: configField,
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
old_value: value,
|
|
620
|
-
})),
|
|
619
|
+
topic_ids: cleared.map(({ threadId }) => String(threadId)),
|
|
620
|
+
topics_cleared: cleared.length,
|
|
621
621
|
});
|
|
622
622
|
}, 'log topic-override sweep');
|
|
623
623
|
};
|