mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 22:36:57 +10:00
213 lines
30 KiB
TypeScript
213 lines
30 KiB
TypeScript
|
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||
|
|
import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js';
|
||
|
|
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js';
|
||
|
|
import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js';
|
||
|
|
import { isAutoMemoryEnabled } from '../../memdir/paths.js';
|
||
|
|
import { isPolicyAllowed } from '../../services/policyLimits/index.js';
|
||
|
|
import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js';
|
||
|
|
import type { Message } from '../../types/message.js';
|
||
|
|
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
|
||
|
|
import { isEnvTruthy } from '../../utils/envUtils.js';
|
||
|
|
import { isAutoManagedMemoryFile } from '../../utils/memoryFileDetection.js';
|
||
|
|
import { extractTextContent, getLastAssistantMessage } from '../../utils/messages.js';
|
||
|
|
import { logOTelEvent } from '../../utils/telemetry/events.js';
|
||
|
|
import { submitTranscriptShare } from './submitTranscriptShare.js';
|
||
|
|
import type { TranscriptShareResponse } from './TranscriptSharePrompt.js';
|
||
|
|
import { useSurveyState } from './useSurveyState.js';
|
||
|
|
import type { FeedbackSurveyResponse } from './utils.js';
|
||
|
|
const HIDE_THANKS_AFTER_MS = 3000;
|
||
|
|
const MEMORY_SURVEY_GATE = 'tengu_dunwich_bell';
|
||
|
|
const MEMORY_SURVEY_EVENT = 'tengu_memory_survey_event';
|
||
|
|
const SURVEY_PROBABILITY = 0.2;
|
||
|
|
const TRANSCRIPT_SHARE_TRIGGER = 'memory_survey';
|
||
|
|
const MEMORY_WORD_RE = /\bmemor(?:y|ies)\b/i;
|
||
|
|
function hasMemoryFileRead(messages: Message[]): boolean {
|
||
|
|
for (const message of messages) {
|
||
|
|
if (message.type !== 'assistant') {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const content = message.message.content;
|
||
|
|
if (!Array.isArray(content)) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
for (const block of content) {
|
||
|
|
if (block.type !== 'tool_use' || block.name !== FILE_READ_TOOL_NAME) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const input = block.input as {
|
||
|
|
file_path?: unknown;
|
||
|
|
};
|
||
|
|
if (typeof input.file_path === 'string' && isAutoManagedMemoryFile(input.file_path)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
export function useMemorySurvey(messages: Message[], isLoading: boolean, hasActivePrompt = false, {
|
||
|
|
enabled = true
|
||
|
|
}: {
|
||
|
|
enabled?: boolean;
|
||
|
|
} = {}): {
|
||
|
|
state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted';
|
||
|
|
lastResponse: FeedbackSurveyResponse | null;
|
||
|
|
handleSelect: (selected: FeedbackSurveyResponse) => void;
|
||
|
|
handleTranscriptSelect: (selected: TranscriptShareResponse) => void;
|
||
|
|
} {
|
||
|
|
// Track assistant message UUIDs that were already evaluated so we don't
|
||
|
|
// re-roll probability on re-renders or re-scan messages for the same turn.
|
||
|
|
const seenAssistantUuids = useRef<Set<string>>(new Set());
|
||
|
|
// Once a memory file read is observed it stays true for the session —
|
||
|
|
// skip the O(n) scan on subsequent turns.
|
||
|
|
const memoryReadSeen = useRef(false);
|
||
|
|
const messagesRef = useRef(messages);
|
||
|
|
messagesRef.current = messages;
|
||
|
|
const onOpen = useCallback((appearanceId: string) => {
|
||
|
|
logEvent(MEMORY_SURVEY_EVENT, {
|
||
|
|
event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
|
|
appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||
|
|
});
|
||
|
|
void logOTelEvent('feedback_survey', {
|
||
|
|
event_type: 'appeared',
|
||
|
|
appearance_id: appearanceId,
|
||
|
|
survey_type: 'memory'
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
const onSelect = useCallback((appearanceId_0: string, selected: FeedbackSurveyResponse) => {
|
||
|
|
logEvent(MEMORY_SURVEY_EVENT, {
|
||
|
|
event_type: 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
|
|
appearance_id: appearanceId_0 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
|
|
response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||
|
|
});
|
||
|
|
void logOTelEvent('feedback_survey', {
|
||
|
|
event_type: 'responded',
|
||
|
|
appearance_id: appearanceId_0,
|
||
|
|
response: selected,
|
||
|
|
survey_type: 'memory'
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
const shouldShowTranscriptPrompt = useCallback((selected_0: FeedbackSurveyResponse) => {
|
||
|
|
if ("external" !== 'ant') {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (selected_0 !== 'bad' && selected_0 !== 'good') {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (getGlobalConfig().transcriptShareDismissed) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (!isPolicyAllowed('allow_product_feedback')) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
return true;
|
||
|
|
}, []);
|
||
|
|
const onTranscriptPromptShown = useCallback((appearanceId_1: string) => {
|
||
|
|
logEvent(MEMORY_SURVEY_EVENT, {
|
||
|
|
event_type: 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
|
|
appearance_id: appearanceId_1 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
|
|
trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||
|
|
});
|
||
|
|
void logOTelEvent('feedback_survey', {
|
||
|
|
event_type: 'transcript_prompt_appeared',
|
||
|
|
appearance_id: appearanceId_1,
|
||
|
|
survey_type: 'memory'
|
||
|
|
});
|
||
|
|
}, []);
|
||
|
|
const onTranscriptSelect = useCallback(async (appearanceId_2: string, selected_1: TranscriptShareResponse): Promise<boolean> => {
|
||
|
|
logEvent(MEMORY_SURVEY_EVENT, {
|
||
|
|
event_type: `transcript_share_${selected_1}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
|
|
appearance_id: appearanceId_2 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
|
|
trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||
|
|
});
|
||
|
|
if (selected_1 === 'dont_ask_again') {
|
||
|
|
saveGlobalConfig(current => ({
|
||
|
|
...current,
|
||
|
|
transcriptShareDismissed: true
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
if (selected_1 === 'yes') {
|
||
|
|
const result = await submitTranscriptShare(messagesRef.current, TRANSCRIPT_SHARE_TRIGGER, appearanceId_2);
|
||
|
|
logEvent(MEMORY_SURVEY_EVENT, {
|
||
|
|
event_type: (result.success ? 'transcript_share_submitted' : 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
|
|
appearance_id: appearanceId_2 as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||
|
|
trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
|
||
|
|
});
|
||
|
|
return result.success;
|
||
|
|
}
|
||
|
|
return false;
|
||
|
|
}, []);
|
||
|
|
const {
|
||
|
|
state,
|
||
|
|
lastResponse,
|
||
|
|
open,
|
||
|
|
handleSelect,
|
||
|
|
handleTranscriptSelect
|
||
|
|
} = useSurveyState({
|
||
|
|
hideThanksAfterMs: HIDE_THANKS_AFTER_MS,
|
||
|
|
onOpen,
|
||
|
|
onSelect,
|
||
|
|
shouldShowTranscriptPrompt,
|
||
|
|
onTranscriptPromptShown,
|
||
|
|
onTranscriptSelect
|
||
|
|
});
|
||
|
|
const lastAssistant = useMemo(() => getLastAssistantMessage(messages), [messages]);
|
||
|
|
useEffect(() => {
|
||
|
|
if (!enabled) return;
|
||
|
|
|
||
|
|
// /clear resets messages but REPL stays mounted — reset refs so a memory
|
||
|
|
// read from the previous conversation doesn't leak into the new one.
|
||
|
|
if (messages.length === 0) {
|
||
|
|
memoryReadSeen.current = false;
|
||
|
|
seenAssistantUuids.current.clear();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (state !== 'closed' || isLoading || hasActivePrompt) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3P default: survey off (no GrowthBook on Bedrock/Vertex/Foundry).
|
||
|
|
if (!getFeatureValue_CACHED_MAY_BE_STALE(MEMORY_SURVEY_GATE, false)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!isAutoMemoryEnabled()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (isFeedbackSurveyDisabled()) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!isPolicyAllowed('allow_product_feedback')) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!lastAssistant || seenAssistantUuids.current.has(lastAssistant.uuid)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const text = extractTextContent(lastAssistant.message.content, ' ');
|
||
|
|
if (!MEMORY_WORD_RE.test(text)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Mark as evaluated before the memory-read scan so a turn that mentions
|
||
|
|
// "memory" but has no memory read doesn't trigger repeated O(n) scans
|
||
|
|
// on subsequent renders with the same last assistant message.
|
||
|
|
seenAssistantUuids.current.add(lastAssistant.uuid);
|
||
|
|
if (!memoryReadSeen.current) {
|
||
|
|
memoryReadSeen.current = hasMemoryFileRead(messages);
|
||
|
|
}
|
||
|
|
if (!memoryReadSeen.current) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (Math.random() < SURVEY_PROBABILITY) {
|
||
|
|
open();
|
||
|
|
}
|
||
|
|
}, [enabled, state, isLoading, hasActivePrompt, lastAssistant, messages, open]);
|
||
|
|
return {
|
||
|
|
state,
|
||
|
|
lastResponse,
|
||
|
|
handleSelect,
|
||
|
|
handleTranscriptSelect
|
||
|
|
};
|
||
|
|
}
|
||
|
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VDYWxsYmFjayIsInVzZUVmZmVjdCIsInVzZU1lbW8iLCJ1c2VSZWYiLCJpc0ZlZWRiYWNrU3VydmV5RGlzYWJsZWQiLCJnZXRGZWF0dXJlVmFsdWVfQ0FDSEVEX01BWV9CRV9TVEFMRSIsIkFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMiLCJsb2dFdmVudCIsImlzQXV0b01lbW9yeUVuYWJsZWQiLCJpc1BvbGljeUFsbG93ZWQiLCJGSUxFX1JFQURfVE9PTF9OQU1FIiwiTWVzc2FnZSIsImdldEdsb2JhbENvbmZpZyIsInNhdmVHbG9iYWxDb25maWciLCJpc0VudlRydXRoeSIsImlzQXV0b01hbmFnZWRNZW1vcnlGaWxlIiwiZXh0cmFjdFRleHRDb250ZW50IiwiZ2V0TGFzdEFzc2lzdGFudE1lc3NhZ2UiLCJsb2dPVGVsRXZlbnQiLCJzdWJtaXRUcmFuc2NyaXB0U2hhcmUiLCJUcmFuc2NyaXB0U2hhcmVSZXNwb25zZSIsInVzZVN1cnZleVN0YXRlIiwiRmVlZGJhY2tTdXJ2ZXlSZXNwb25zZSIsIkhJREVfVEhBTktTX0FGVEVSX01TIiwiTUVNT1JZX1NVUlZFWV9HQVRFIiwiTUVNT1JZX1NVUlZFWV9FVkVOVCIsIlNVUlZFWV9QUk9CQUJJTElUWSIsIlRSQU5TQ1JJUFRfU0hBUkVfVFJJR0dFUiIsIk1FTU9SWV9XT1JEX1JFIiwiaGFzTWVtb3J5RmlsZVJlYWQiLCJtZXNzYWdlcyIsIm1lc3NhZ2UiLCJ0eXBlIiwiY29udGVudCIsIkFycmF5IiwiaXNBcnJheSIsImJsb2NrIiwibmFtZSIsImlucHV0IiwiZmlsZV9wYXRoIiwidXNlTWVtb3J5U3VydmV5IiwiaXNMb2FkaW5nIiwiaGFzQWN0aXZlUHJvbXB0IiwiZW5hYmxlZCIsInN0YXRlIiwibGFzdFJlc3BvbnNlIiwiaGFuZGxlU2VsZWN0Iiwic2VsZWN0ZWQiLCJoYW5kbGVUcmFuc2NyaXB0U2VsZWN0Iiwic2VlbkFzc2lzdGFudFV1aWRzIiwiU2V0IiwibWVtb3J5UmVhZFNlZW4iLCJtZXNzYWdlc1JlZiIsImN1cnJlbnQiLCJvbk9wZW4iLCJhcHBlYXJhbmNlSWQiLCJldmVudF90eXBlIiwiYXBwZWFyYW5jZV9pZCIsInN1cnZleV90eXBlIiwib25TZWxlY3QiLCJyZXNwb25zZSIsInNob3VsZFNob3dUcmFuc2NyaXB0UHJvbXB0IiwidHJhbnNjcmlwdFNoYXJlRGlzbWlzc2VkIiwib25UcmFuc2NyaXB0UHJvbXB0U2hvd24iLCJ0cmlnZ2VyIiwib25UcmFuc2NyaXB0U2VsZWN0IiwiUHJvbWlzZSIsInJlc3VsdCIsInN1Y2Nlc3MiLCJvcGVuIiwiaGlkZVRoYW5rc0FmdGVyTXMiLCJsYXN0QXNzaXN0YW50IiwibGVuZ3RoIiwiY2xlYXIiLCJwcm9jZXNzIiwiZW52IiwiQ0xBVURFX0NPREVfRElTQUJMRV9GRUVEQkFDS19TVVJWRVkiLCJoYXMiLCJ1dWlkIiwidGV4dCIsInRlc3QiLCJhZGQiLCJNYXRoIiwicmFuZG9tIl0sInNvdXJjZXMiOlsidXNlTWVtb3J5U3VydmV5LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyB1c2VDYWxsYmFjaywgdXNlRWZmZWN0LCB1c2VNZW1vLCB1c2VSZWYgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IGlzRmVlZGJhY2tTdXJ2ZXlEaXNhYmxlZCB9IGZyb20gJ3NyYy9zZXJ2aWNlcy9hbmFseXRpY3MvY29uZmlnLmpzJ1xuaW1wb3J0IHsgZ2V0RmVhdHVyZVZhbHVlX0NBQ0hFRF9NQVlfQkVfU1RBTEUgfSBmcm9tICdzcmMvc2VydmljZXMvYW5hbHl0aWNzL2dyb3d0aGJvb2suanMnXG5pbXBvcnQge1xuICB0eXBlIEFuYWx5dGljc01ldGFkYXRhX0lfVkVSSUZJRURfVEhJU19JU19OT1RfQ09ERV9PUl9GSUxFUEFUSFMsXG4gIGxvZ0V2ZW50LFxufSBmcm9tICdzcmMvc2VydmljZXMvYW5hbHl0aWNzL2luZGV4LmpzJ1xuaW1wb3J0IHsgaXNBdXRvTWVtb3J5RW5hYmxlZCB9IGZyb20gJy4uLy4uL21lbWRpci9wYXRocy5qcydcbmltcG9ydCB7IGlzUG9saWN5QWxsb3dlZCB9IGZyb20gJy4uLy4uL3NlcnZpY2VzL3BvbGljeUxpbWl0cy9pbmRleC5qcydcbmltcG9ydCB7IEZJTEVfUkVBRF9UT09MX05BTUUgfSBmcm9tICcuLi8uLi90b29scy9GaWxlUmVhZFRvb2wvcHJvbXB0LmpzJ1xuaW1wb3J0IHR5cGUgeyBNZXNzYWdlIH0gZnJvbSAnLi4vLi4vdHlwZXMvbWVzc2FnZS5qcydcbmltcG9ydCB7IGdldEdsb2JhbENvbmZpZywgc2F2ZUdsb2JhbENvbmZpZyB9IGZyb20gJy4uLy4uL3V0aWxzL2NvbmZpZy5qcydcbmltcG9ydCB7IGlzRW52VHJ1dGh5IH0gZnJvbSAnLi4vLi4vdXRpbHMvZW52VXRpbHMuanMnXG5pbXBvcnQgeyBpc0F1dG9NYW5hZ2VkTWVtb3J5RmlsZSB9IGZyb20gJy4uLy4uL3V0aWxzL21lbW9yeUZpbGVEZXRlY3Rpb24uanMnXG5pbXBvcnQge1xuICBleHRyYWN0VGV4dENvbnRlbnQsXG4gIGdldExhc3RBc3Npc3RhbnRNZXNzYWdlLFxufSBmcm9tICcuLi8uLi91dGlscy9tZXNzYWdlcy5qcydcbmltcG9ydCB7IGxvZ09UZWxFdmVudCB9IGZyb20gJy4uLy4uL3V0aWxzL3RlbGVtZXRyeS9ldmVudHMuanMnXG5pbXBvcnQgeyBzdWJtaXRUcmFuc2NyaXB0U2hhcmUgfSBmcm9tICcuL3N1Ym1pdFRyYW5zY3JpcHRTaGFyZS5qcydcbmltcG9ydCB0eXBlIHsgVHJhbnNjcmlwdFNoYXJlUmVzcG9uc2UgfSBmcm9tICcuL1RyYW5zY3JpcHRTaGFyZVByb21wdC5qcydcbmltcG9ydCB7IHVzZVN1cnZleVN0YXRlIH0gZnJvbSAnLi91c2VTdXJ2ZXlTdGF0ZS5qcydcbmltcG9ydCB0eXBlIHsgRmVlZGJhY2tTdXJ2ZXlSZXNwb25zZSB9IGZyb20gJy4vdXRpbHMuanMnXG5cbmNvbnN0IEhJREVfVEhBTktTX0FGVEVSX01TID0gMzAwMFxuY29uc3QgTUVNT1JZX1NVUlZFWV9HQVRFID0gJ3Rlbmd1X2R1bndpY2hfYmVsbCdcbmNvbnN0IE1FTU9SWV9TVVJWRVlfRVZFTlQgPSAndGVuZ3VfbWVtb3J5X3N1cnZleV9ldmVudCdcbmNvbnN0IFNVUlZFWV9QUk9CQUJJTElUWSA9IDAuMlxuY29uc3QgVFJBTlNDUklQVF9TSEFSRV9UUklHR0VSID0gJ21lbW9yeV9zdXJ2ZXknXG5cbmNvbnN0IE1FTU9SWV9XT1JEX1JFID0gL1xcYm1lbW9yKD86eXxpZXMpXFxiL2lcblxuZnVuY3Rpb24gaGFzTWVtb3J5RmlsZVJlYWQobWVzc2FnZXM6IE1lc3NhZ2VbXSk6IGJvb2xlYW4ge1xuICBmb3IgKGNvbnN0IG1lc3NhZ2Ugb2YgbWVzc2FnZXMpIHtcbiA
|