feat(observations): add "sensitive" type with Telegram notification (#3468)

Adds a ninth observation type to the code mode: `sensitive` — information
that isn't quite private, but that you wouldn't want leaking into further
content development in the wrong context (internal URLs, unreleased plans,
personal details, business metrics, client or partner names). These fire a
Telegram notification by default, alongside security_alert.

The notifier already matched obs.type against CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES,
so wiring notifications needed only the new type in the default trigger set
plus its own emoji.

- plugin/modes/code.json: new `sensitive` type. Also fixes pre-existing
  staleness in type_guidance, which still said "6 options" and never listed
  the security types from #2084 — that comment is the only type prose the
  observer agent sees, so a type missing from it is invisible to the model.
- SettingsDefaultsManager: default trigger types now security_alert,sensitive,
  plus a migration for the legacy persisted value. A fresh settings.json is
  seeded with every default and persisted values win on load, so installs
  created after #2084 had "security_alert" frozen on disk and could never
  receive the new default — the feature would have been dead on arrival for
  them. Only the exact legacy value is rewritten; customized lists are left
  alone. (Greptile P1.)
- TelegramNotifier: sensitive observations post with their own emoji
- bmp-safe: astral fallback for the new emoji (issue #2787 failure class)
- CorpusRoutes: allow the new type in knowledge-corpus filters
- openclaw feed + weekly-digests legend: recognize the new type

Parser validation, the observer prompt type enum, context legends, and the
viewer all derive from the mode file, so they pick this up with no change.

Regression coverage for the migration: legacy-default migration, write-back
with unrelated keys preserved, customized list preserved, superset list
preserved, empty opt-out preserved, idempotency across repeated loads.
This commit is contained in:
Alex Newman
2026-08-02 10:11:25 -07:00
committed by GitHub
parent a90066f9cf
commit dd68d21353
8 changed files with 119 additions and 5 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ interface SSENewObservationEvent {
type ConnectionState = "disconnected" | "connected" | "reconnecting";
const DETAILED_FEED_TYPES = new Set(["security_alert", "security_note", "bugfix", "decision"]);
const DETAILED_FEED_TYPES = new Set(["security_alert", "security_note", "sensitive", "bugfix", "decision"]);
const COMPACT_FEED_MAX_CHARS = 900;
const DETAILED_FEED_MAX_CHARS = 2200;
const DETAILED_FACT_LIMIT = 5;
+8 -1
View File
@@ -58,6 +58,13 @@
"description": "A security-relevant observation worth recording, but not urgent.",
"emoji": "⚷",
"work_emoji": "⚷"
},
{
"id": "sensitive",
"label": "Sensitive Info",
"description": "Information that isn't quite private, but shouldn't leak into further content development in the wrong context.",
"emoji": "⊘",
"work_emoji": "⊘"
}
],
"observation_concepts": [
@@ -103,7 +110,7 @@
"observer_role": "Your job is to monitor a different Claude Code session happening RIGHT NOW, with the goal of creating observations and progress summaries as the work is being done LIVE by the user. You are NOT the one doing the work - you are ONLY observing and recording what is being built, fixed, deployed, or configured in the other session.",
"recording_focus": "WHAT TO RECORD\n--------------\nFocus on durable technical signal:\n- What the system NOW DOES differently (new capabilities)\n- What shipped to users/production (features, fixes, configs, docs)\n- Changes in technical domains (auth, data, UI, infra, DevOps, docs)\n- Concrete debugging or investigative findings from logs, traces, queue state, database rows, and code-path inspection\n\nUse verbs like: implemented, fixed, deployed, configured, migrated, optimized, added, refactored, discovered, confirmed, traced\n\n✅ GOOD EXAMPLES (describes what was built or learned):\n- \"Authentication now supports OAuth2 with PKCE flow\"\n- \"Deployment pipeline runs canary releases with auto-rollback\"\n- \"Database indexes optimized for common query patterns\"\n- \"Observation queue for claude-mem session timed out waiting for an agent pool slot\"\n- \"Fallback processing abandoned pending messages after Gemini and OpenRouter returned 404\"\n\n❌ BAD EXAMPLES (describes observation process - DO NOT DO THIS):\n- \"Analyzed authentication implementation and stored findings\"\n- \"Tracked deployment steps and logged outcomes\"\n- \"Monitored database performance and recorded metrics\"",
"skip_guidance": "WHEN TO SKIP\n------------\nSkip routine operations:\n- Empty status checks\n- Package installations with no errors\n- Simple file listings with no follow-on finding\n- Repetitive operations you've already documented\n- File related research that comes back empty or not found\n\nIf skipping, return an empty response only. Do not explain the skip in prose.",
"type_guidance": "**type**: MUST be EXACTLY one of these 6 options (no other values allowed):\n - bugfix: something was broken, now fixed\n - feature: new capability or functionality added\n - refactor: code restructured, behavior unchanged\n - change: generic modification (docs, config, misc)\n - discovery: learning about existing system\n - decision: architectural/design choice with rationale",
"type_guidance": "**type**: MUST be EXACTLY one of these 9 options (no other values allowed):\n - bugfix: something was broken, now fixed\n - feature: new capability or functionality added\n - refactor: code restructured, behavior unchanged\n - change: generic modification (docs, config, misc)\n - discovery: learning about existing system\n - decision: architectural/design choice with rationale\n - security_alert: a security issue that needs attention before continuing\n - security_note: a security-relevant observation worth recording, but not urgent\n - sensitive: information surfaced that isn't quite private, but that you wouldn't want leaking into further content development in the wrong context (internal URLs, unreleased plans, personal details, business metrics, client or partner names)",
"concept_guidance": "**concepts**: 2-5 knowledge-type categories. MUST use ONLY these exact keywords:\n - how-it-works: understanding mechanisms\n - why-it-exists: purpose or rationale\n - what-changed: modifications made\n - problem-solution: issues and their fixes\n - gotcha: traps or edge cases\n - pattern: reusable approach\n - trade-off: pros/cons of a decision\n\n IMPORTANT: Do NOT include the observation type (change/discovery/decision) as a concept.\n Types and concepts are separate dimensions.\n\n OUTPUT RULE: Each <concept> element must contain ONLY the bare keyword (e.g. <concept>gotcha</concept>) - never a description, sentence, or colon.",
"field_guidance": "**facts**: Concise, self-contained statements\nEach fact is ONE piece of information\n No pronouns - each fact must stand alone\n Include specific details: filenames, functions, values\n\n**files**: All files touched (full paths from project root)",
"output_format_header": "OUTPUT FORMAT\n-------------\nOutput observations using this XML structure:",
+1 -1
View File
@@ -114,7 +114,7 @@ You are writing chapter {N} of {TOTAL} in a serial week-by-week digest of the {P
**Format key for the source file:**
- Numeric lines like `1 7:59p 🔵 Save hook file is empty` are observations (ID, time, type-emoji, title)
- `S##` lines are session boundaries (the user prompt that started the session)
- Emoji legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖decision 🚨security_alert 🔐security_note
- Emoji legend: 🎯session 🔴bugfix 🟣feature 🔄refactor ✅change 🔵discovery ⚖decision 🚨security_alert 🔐security_note 🤫sensitive
**Story so far (carry-forward from Week {N-1}):**
@@ -16,6 +16,7 @@ const MARKDOWN_V2_RESERVED = /[_*\[\]()~`>#+\-=|{}.!\\]/g;
const TYPE_EMOJI: Record<string, string> = {
security_alert: '🚨',
security_note: '🔐',
sensitive: '🤫',
};
const DEFAULT_EMOJI = '🔔';
@@ -9,7 +9,7 @@ import { KnowledgeAgent } from '../../knowledge/KnowledgeAgent.js';
import type { CorpusFilter } from '../../knowledge/types.js';
import { logger } from '../../../../utils/logger.js';
const ALLOWED_CORPUS_TYPES = ['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change', 'security_alert', 'security_note'] as const;
const ALLOWED_CORPUS_TYPES = ['decision', 'bugfix', 'feature', 'refactor', 'discovery', 'change', 'security_alert', 'security_note', 'sensitive'] as const;
const ALLOWED_CORPUS_TYPE_SET = new Set<string>(ALLOWED_CORPUS_TYPES);
const stringArrayLike = z.preprocess((value) => {
+31 -1
View File
@@ -5,6 +5,20 @@ import { homedir, hostname } from 'os';
import { HOOK_TIMEOUTS, getTimeout } from './hook-constants.js';
import { parseJsonWithBom, writeJsonFileAtomic } from './atomic-json.js';
// A fresh settings.json is seeded with EVERY default (see loadFromFile), and
// persisted values then win over DEFAULTS. So any install created after the
// Telegram notifier shipped (#2084) has that era's trigger list frozen on
// disk, and adding a type to the default list can never reach it — the new
// type would silently never notify. Rewrite the one exact legacy value to the
// current default; any other list is user-customized and is left untouched.
//
// This cannot distinguish a user who deliberately set exactly 'security_alert'
// from the seeded default — they read identically. Such a user is migrated and
// starts receiving `sensitive` notifications, which is the recoverable side of
// the trade: it is opt-out via this same key, whereas the alternative leaves
// the feature dead on arrival for every pre-existing install.
const LEGACY_TELEGRAM_TRIGGER_TYPES = 'security_alert';
export interface SettingsDefaults {
CLAUDE_MEM_MODEL: string;
CLAUDE_MEM_CONTEXT_OBSERVATIONS: string;
@@ -170,7 +184,7 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_TELEGRAM_ENABLED: 'true',
CLAUDE_MEM_TELEGRAM_BOT_TOKEN: '',
CLAUDE_MEM_TELEGRAM_CHAT_ID: '',
CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES: 'security_alert',
CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES: 'security_alert,sensitive',
CLAUDE_MEM_TELEGRAM_TRIGGER_CONCEPTS: '',
CLAUDE_MEM_QUEUE_ENGINE: 'sqlite',
CLAUDE_MEM_REDIS_URL: '',
@@ -247,6 +261,22 @@ export class SettingsDefaultsManager {
}
}
if (flatSettings.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES === LEGACY_TELEGRAM_TRIGGER_TYPES) {
flatSettings = {
...flatSettings,
CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES: this.DEFAULTS.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES,
};
try {
writeJsonFileAtomic(settingsPath, flatSettings);
// stderr, never stdout — same JSON-on-stdout contract as above.
console.warn('[SETTINGS] Migrated Telegram trigger types off the legacy default:', settingsPath);
} catch (error: unknown) {
console.warn('[SETTINGS] Failed to migrate Telegram trigger types:', settingsPath, error instanceof Error ? error.message : String(error));
// Continue with the in-memory migration even if the write fails
}
}
const result: SettingsDefaults = { ...this.DEFAULTS };
for (const key of Object.keys(this.DEFAULTS) as Array<keyof SettingsDefaults>) {
if (flatSettings[key] !== undefined) {
+1
View File
@@ -23,6 +23,7 @@ const ASTRAL_FALLBACKS: Record<string, string> = {
'🔵': '○', // discovery
'🚨': '⚠', // security_alert
'🔐': '⚷', // security_note
'🤫': '⊘', // sensitive
'🛠': '⚒', // tool/build
'🔍': '⌕', // search/discovery
'🎯': '◎', // session
@@ -237,6 +237,81 @@ describe('SettingsDefaultsManager', () => {
});
});
// A fresh settings.json is seeded with every default, so installs created
// while 'security_alert' was the default have it frozen on disk. Without
// this migration a newly-added trigger type never reaches them.
describe('Telegram trigger types migration', () => {
it('should migrate the exact legacy default to the current default', () => {
writeFileSync(settingsPath, JSON.stringify({
CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES: 'security_alert',
}));
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
expect(result.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES).toBe(
SettingsDefaultsManager.getAllDefaults().CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES
);
expect(result.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES.split(',')).toContain('sensitive');
});
it('should persist the migrated trigger types back to the file', () => {
writeFileSync(settingsPath, JSON.stringify({
CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES: 'security_alert',
CLAUDE_MEM_TELEGRAM_CHAT_ID: '12345',
}));
SettingsDefaultsManager.loadFromFile(settingsPath);
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(parsed.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES.split(',')).toContain('sensitive');
// Unrelated persisted keys survive the rewrite.
expect(parsed.CLAUDE_MEM_TELEGRAM_CHAT_ID).toBe('12345');
});
it('should preserve a customized trigger list', () => {
writeFileSync(settingsPath, JSON.stringify({
CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES: 'bugfix,decision',
}));
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
expect(result.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES).toBe('bugfix,decision');
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(parsed.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES).toBe('bugfix,decision');
});
it('should preserve a customized list that merely contains the legacy value', () => {
writeFileSync(settingsPath, JSON.stringify({
CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES: 'security_alert,security_note',
}));
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
expect(result.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES).toBe('security_alert,security_note');
});
it('should leave an empty opt-out list alone', () => {
writeFileSync(settingsPath, JSON.stringify({
CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES: '',
}));
const result = SettingsDefaultsManager.loadFromFile(settingsPath);
expect(result.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES).toBe('');
});
it('should be idempotent across repeated loads', () => {
writeFileSync(settingsPath, JSON.stringify({
CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES: 'security_alert',
}));
const first = SettingsDefaultsManager.loadFromFile(settingsPath);
const second = SettingsDefaultsManager.loadFromFile(settingsPath);
expect(second.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES).toBe(first.CLAUDE_MEM_TELEGRAM_TRIGGER_TYPES);
});
});
describe('edge cases', () => {
it('should handle empty object in file', () => {
writeFileSync(settingsPath, '{}');