Skip to content
Open
100 changes: 54 additions & 46 deletions plugin/scripts/context-generator.cjs

Large diffs are not rendered by default.

31 changes: 24 additions & 7 deletions src/services/context/ContextBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type { ContextInput, ContextConfig, Observation, SessionSummary } from '.
import { loadContextConfig } from './ContextConfigLoader.js';
import { calculateTokenEconomics } from './TokenCalculator.js';
import {
countObservationsByProjects,
countSummariesByProjects,
queryObservations,
queryObservationsMulti,
querySummaries,
Expand Down Expand Up @@ -61,6 +63,11 @@ function renderEmptyState(project: string, forHuman: boolean): string {
return forHuman ? renderHumanEmptyState(project) : renderAgentEmptyState(project);
}

export function getPrimaryContextProject(projects: string[], fallback: string): string {
const rawProjects = projects.filter((candidate) => !candidate.endsWith(':dream'));
return rawProjects[rawProjects.length - 1] ?? fallback;
}

function buildContextOutput(
project: string,
observations: Observation[],
Expand Down Expand Up @@ -168,7 +175,7 @@ export async function generateContextWithStats(
const context = getProjectContext(cwd);

const projects = input?.projects?.length ? input.projects : context.allProjects;
const project = projects[projects.length - 1] ?? context.primary;
const project = getPrimaryContextProject(projects, context.primary);

if (input?.full) {
config.totalObservationCount = 999999;
Expand All @@ -181,12 +188,22 @@ export async function generateContextWithStats(
}

try {
const observations = projects.length > 1
? queryObservationsMulti(db, projects, config)
: queryObservations(db, project, config);
const summaries = projects.length > 1
? querySummariesMulti(db, projects, config)
: querySummaries(db, project, config);
const dreamProjects = projects.filter((candidate) => candidate.endsWith(':dream'));
const rawProjects = projects.filter((candidate) => !candidate.endsWith(':dream'));
const useDreamQueries = dreamProjects.length > 0 && (
countObservationsByProjects(db, dreamProjects) > 0 ||
countSummariesByProjects(db, dreamProjects) > 0
);
const queryProjects = useDreamQueries ? projects : rawProjects;
const useMultiQuery = queryProjects.length > 1;
const singleQueryProject = queryProjects[0] ?? project;

const observations = useMultiQuery
? queryObservationsMulti(db, queryProjects, config)
: queryObservations(db, singleQueryProject, config);
const summaries = useMultiQuery
? querySummariesMulti(db, queryProjects, config)
: querySummaries(db, singleQueryProject, config);

if (observations.length === 0 && summaries.length === 0) {
return { text: renderEmptyState(project, forHuman), stats: null };
Expand Down
120 changes: 114 additions & 6 deletions src/services/context/ObservationCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,86 @@ import type {
} from './types.js';
import { SUMMARY_LOOKAHEAD } from './types.js';

function isDreamProject(project: string): boolean {
return project.endsWith(':dream');
}

function rawProjectsForFallback(projects: string[]): string[] {
return projects.filter(project => !isDreamProject(project));
}

function queryLatestRawObservation(
db: SessionStore,
projects: string[],
typeArray: string[],
conceptArray: string[]
): Observation | null {
const rawProjects = rawProjectsForFallback(projects);
if (rawProjects.length === 0) return null;

const projectPlaceholders = rawProjects.map(() => '?').join(',');
const typePlaceholders = typeArray.map(() => '?').join(',');
const conceptPlaceholders = conceptArray.map(() => '?').join(',');

return db.db.prepare(`
SELECT
o.id,
o.memory_session_id,
COALESCE(s.platform_source, 'claude') as platform_source,
o.type,
o.title,
o.subtitle,
o.narrative,
o.facts,
o.concepts,
o.files_read,
o.files_modified,
o.discovery_tokens,
o.created_at,
o.created_at_epoch,
o.project
FROM observations o
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
WHERE (o.project IN (${projectPlaceholders})
OR o.merged_into_project IN (${projectPlaceholders}))
AND o.project NOT LIKE '%:dream'
AND type IN (${typePlaceholders})
AND EXISTS (
SELECT 1 FROM json_each(o.concepts)
WHERE value IN (${conceptPlaceholders})
)
ORDER BY o.created_at_epoch DESC
LIMIT 1
`).get(
...rawProjects,
...rawProjects,
...typeArray,
...conceptArray
) as Observation | null;
}

function includeRawFallback(
db: SessionStore,
projects: string[],
rows: Observation[],
typeArray: string[],
conceptArray: string[],
limit: number
): Observation[] {
const selected = rows.slice(0, limit);
if (rawProjectsForFallback(projects).length === 0) return selected;
if (selected.some(row => row.project && !isDreamProject(row.project))) return selected;

const fallback = queryLatestRawObservation(db, projects, typeArray, conceptArray);
if (!fallback) return selected;

const withFallback = selected.length >= limit
? [...selected.slice(0, Math.max(0, limit - 1)), fallback]
: [...selected, fallback];

return withFallback.sort((a, b) => b.created_at_epoch - a.created_at_epoch);
}

export function queryObservations(
db: SessionStore,
project: string,
Expand All @@ -40,7 +120,8 @@ export function queryObservations(
o.files_modified,
o.discovery_tokens,
o.created_at,
o.created_at_epoch
o.created_at_epoch,
o.project
FROM observations o
LEFT JOIN sdk_sessions s ON o.memory_session_id = s.memory_session_id
WHERE (o.project = ? OR o.merged_into_project = ?)
Expand Down Expand Up @@ -96,8 +177,9 @@ export function queryObservationsMulti(
const conceptPlaceholders = conceptArray.map(() => '?').join(',');

const projectPlaceholders = projects.map(() => '?').join(',');
const queryLimit = config.totalObservationCount * Math.max(2, projects.length);

return db.db.prepare(`
const rows = db.db.prepare(`
SELECT
o.id,
o.memory_session_id,
Expand Down Expand Up @@ -130,8 +212,17 @@ export function queryObservationsMulti(
...projects,
...typeArray,
...conceptArray,
config.totalObservationCount
queryLimit
) as Observation[];

return includeRawFallback(
db,
projects,
rows,
typeArray,
conceptArray,
config.totalObservationCount
);
}

export function countObservationsByProjects(db: SessionStore, projects: string[]): number {
Expand All @@ -145,14 +236,27 @@ export function countObservationsByProjects(db: SessionStore, projects: string[]
return row?.count ?? 0;
}

export function countSummariesByProjects(db: SessionStore, projects: string[]): number {
if (projects.length === 0) return 0;
const projectPlaceholders = projects.map(() => '?').join(',');
const row = db.db.prepare(`
SELECT COUNT(*) as count FROM session_summaries
WHERE project IN (${projectPlaceholders})
OR merged_into_project IN (${projectPlaceholders})
`).get(...projects, ...projects) as { count: number } | undefined;
return row?.count ?? 0;
}

export function querySummariesMulti(
db: SessionStore,
projects: string[],
config: ContextConfig
): SessionSummary[] {
const projectPlaceholders = projects.map(() => '?').join(',');
const resultLimit = config.sessionCount + SUMMARY_LOOKAHEAD;
const queryLimit = resultLimit * Math.max(2, projects.length);

return db.db.prepare(`
const rows = db.db.prepare(`
SELECT
ss.id,
ss.memory_session_id,
Expand All @@ -171,7 +275,9 @@ export function querySummariesMulti(
OR ss.merged_into_project IN (${projectPlaceholders}))
ORDER BY ss.created_at_epoch DESC
LIMIT ?
`).all(...projects, ...projects, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[];
`).all(...projects, ...projects, queryLimit) as SessionSummary[];

return rows.slice(0, resultLimit);
}

export function cwdToDashed(cwd: string): string {
Expand Down Expand Up @@ -244,7 +350,9 @@ export function getPriorSessionMessages(
return { userMessage: '', assistantMessage: '' };
}

const priorSessionObs = observations.find(obs => obs.memory_session_id !== currentSessionId);
const priorSessionObs = observations.find(
obs => obs.memory_session_id !== currentSessionId && !isDreamProject(obs.project ?? '')
);
if (!priorSessionObs) {
return { userMessage: '', assistantMessage: '' };
}
Expand Down
34 changes: 19 additions & 15 deletions src/services/telemetry/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,34 @@ let isShutdown = false;
* via the CLI is picked up by a running worker within the TTL.
*/
const CONSENT_CACHE_TTL_MS = 30_000;
let consentCache: { value: boolean; expiresAt: number } | null = null;
let consentCache: { value: boolean; expiresAt: number; envKey: string } | null = null;

function getConsentEnvKey(env: NodeJS.ProcessEnv): string {
return JSON.stringify({
doNotTrack: env.DO_NOT_TRACK ?? null,
telemetry: env.CLAUDE_MEM_TELEMETRY ?? null,
});
}

function hasConsent(): boolean {
const now = Date.now();
if (consentCache && now < consentCache.expiresAt) {
const envKey = getConsentEnvKey(process.env);
if (consentCache && consentCache.envKey === envKey && now < consentCache.expiresAt) {
return consentCache.value;
}
const value = resolveTelemetryConsent(process.env, loadTelemetryConfig());
consentCache = { value, expiresAt: now + CONSENT_CACHE_TTL_MS };
consentCache = { value, expiresAt: now + CONSENT_CACHE_TTL_MS, envKey };
return value;
}

export function __resetTelemetryForTests(): void {
client = null;
isShutdown = false;
consentCache = null;
}

export const __resetTelemetryStateForTesting = __resetTelemetryForTests;

function getClient(): PostHog {
if (!client) {
client = new PostHog(getTelemetryApiKey(), {
Expand Down Expand Up @@ -117,18 +133,6 @@ export function captureEvent(
}
}

/**
* Test-only. The module state (singleton client, 30s consent TTL cache,
* shutdown latch) is process-wide, and the whole bun test suite shares one
* process — without a reset, a test asserting client construction inherits
* whatever earlier test files did. Never called by production code.
*/
export function __resetTelemetryForTests(): void {
client = null;
consentCache = null;
isShutdown = false;
}

/**
* Flush queued events on graceful shutdown. Races the SDK shutdown against a
* 3s timeout so a slow/unreachable ingestion host can never hang worker stop.
Expand Down
21 changes: 19 additions & 2 deletions src/utils/project-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ export interface ProjectContext {
allProjects: string[];
}

export function getDreamProjectName(project: string): string {
return `${project}:dream`;
}

function withDreamProject(primaryProject: string, additionalProjects: string[] = []): string[] {
const uniqueProjects = [primaryProject, ...additionalProjects].filter(
(project, index, allProjects) => allProjects.indexOf(project) === index
);
const dreamProjects = uniqueProjects.map(getDreamProjectName);
return [...dreamProjects, ...uniqueProjects];
}

export function getProjectContext(cwd: string | null | undefined): ProjectContext {
const cwdProjectName = getProjectName(cwd);

Expand All @@ -89,9 +101,14 @@ export function getProjectContext(cwd: string | null | undefined): ProjectContex
primary: composite,
parent: worktreeInfo.parentProjectName,
isWorktree: true,
allProjects: [worktreeInfo.parentProjectName, composite]
allProjects: withDreamProject(worktreeInfo.parentProjectName, [composite])
};
}

return { primary: cwdProjectName, parent: null, isWorktree: false, allProjects: [cwdProjectName] };
return {
primary: cwdProjectName,
parent: null,
isWorktree: false,
allProjects: withDreamProject(cwdProjectName)
};
}
Loading
Loading