Skip to content

Commit b9297db

Browse files
feat: make external services optional with LOCAL_ONLY mode
- Add feature flags system (src/core/config/feature-flags.ts) - Add STACKMEMORY_LOCAL=true env to disable all external services - Lazy-load Linear integration (only when API key present) - Lazy-load WhatsApp/SMS commands (only when Twilio configured) - Add LocalFallbackProvider for AI summaries without API key - MCP server now works without Linear credentials Set STACKMEMORY_LOCAL=true to run fully offline as open source. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 74186b0 commit b9297db

4 files changed

Lines changed: 213 additions & 25 deletions

File tree

src/cli/index.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ import { sharedContextLayer } from '../core/context/shared-context-layer.js';
2222
import { UpdateChecker } from '../core/utils/update-checker.js';
2323
import { ProgressTracker } from '../core/monitoring/progress-tracker.js';
2424
import { registerProjectCommands } from './commands/projects.js';
25-
import { registerLinearCommands } from './commands/linear.js';
2625
import { createSessionCommands } from './commands/session.js';
26+
import { isFeatureEnabled, isLocalOnly } from '../core/config/feature-flags.js';
2727
import { registerWorktreeCommands } from './commands/worktree.js';
2828
import { registerOnboardingCommand } from './commands/onboard.js';
2929
import { createTaskCommands } from './commands/tasks.js';
@@ -53,7 +53,6 @@ import { createShellCommand } from './commands/shell.js';
5353
import { createAPICommand } from './commands/api.js';
5454
import { createCleanupProcessesCommand } from './commands/cleanup-processes.js';
5555
import { createAutoBackgroundCommand } from './commands/auto-background.js';
56-
import { createSMSNotifyCommand } from './commands/sms-notify.js';
5756
import { createSettingsCommand } from './commands/settings.js';
5857
import { createRetrievalCommands } from './commands/retrieval.js';
5958
import { createDiscoveryCommands } from './commands/discovery.js';
@@ -69,7 +68,6 @@ import {
6968
enableChromaDB,
7069
getStorageModeDescription,
7170
} from '../core/config/storage-config.js';
72-
import { loadSMSConfig } from '../hooks/sms-notify.js';
7371
import { spawn } from 'child_process';
7472
import { homedir } from 'os';
7573

@@ -86,7 +84,11 @@ UpdateChecker.checkForUpdates(VERSION, true).catch(() => {
8684

8785
// Auto-start webhook and ngrok if notifications are enabled
8886
async function startNotificationServices(): Promise<void> {
87+
// Skip in local-only mode
88+
if (isLocalOnly() || !isFeatureEnabled('whatsapp')) return;
89+
8990
try {
91+
const { loadSMSConfig } = await import('../hooks/sms-notify.js');
9092
const config = loadSMSConfig();
9193
if (!config.enabled) return;
9294

@@ -644,8 +646,14 @@ registerDbCommands(program);
644646
registerProjectCommands(program);
645647
registerWorktreeCommands(program);
646648

647-
// Register Linear integration commands
648-
registerLinearCommands(program);
649+
// Register Linear integration commands (lazy-loaded, optional)
650+
if (isFeatureEnabled('linear')) {
651+
import('./commands/linear.js')
652+
.then(({ registerLinearCommands }) => registerLinearCommands(program))
653+
.catch(() => {
654+
// Linear integration not available - silently skip
655+
});
656+
}
649657

650658
// Register session management commands
651659
program.addCommand(createSessionCommands());
@@ -673,8 +681,18 @@ program.addCommand(createShellCommand());
673681
program.addCommand(createAPICommand());
674682
program.addCommand(createCleanupProcessesCommand());
675683
program.addCommand(createAutoBackgroundCommand());
676-
program.addCommand(createSMSNotifyCommand());
677684
program.addCommand(createSettingsCommand());
685+
686+
// Register WhatsApp/SMS commands (lazy-loaded, optional)
687+
if (isFeatureEnabled('whatsapp')) {
688+
import('./commands/sms-notify.js')
689+
.then(({ createSMSNotifyCommand }) =>
690+
program.addCommand(createSMSNotifyCommand())
691+
)
692+
.catch(() => {
693+
// WhatsApp integration not available - silently skip
694+
});
695+
}
678696
program.addCommand(createRetrievalCommands());
679697
program.addCommand(createDiscoveryCommands());
680698
program.addCommand(createModelCommand());

src/core/config/feature-flags.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Feature Flags Configuration
3+
* Controls which external integrations are enabled
4+
*
5+
* Set STACKMEMORY_LOCAL=true to run without any external services
6+
*/
7+
8+
export interface FeatureFlags {
9+
// Core features (always available)
10+
core: true;
11+
12+
// External integrations (can be disabled)
13+
linear: boolean;
14+
whatsapp: boolean;
15+
chromadb: boolean;
16+
aiSummaries: boolean;
17+
}
18+
19+
/**
20+
* Check if running in local-only mode
21+
* When true, all external service integrations are disabled
22+
*/
23+
export function isLocalOnly(): boolean {
24+
return (
25+
process.env['STACKMEMORY_LOCAL'] === 'true' ||
26+
process.env['STACKMEMORY_LOCAL'] === '1' ||
27+
process.env['LOCAL_ONLY'] === 'true'
28+
);
29+
}
30+
31+
/**
32+
* Check if a specific feature is enabled
33+
*/
34+
export function isFeatureEnabled(feature: keyof FeatureFlags): boolean {
35+
if (feature === 'core') return true;
36+
37+
// In local-only mode, external integrations are disabled
38+
if (isLocalOnly()) return false;
39+
40+
// Check feature-specific env vars
41+
switch (feature) {
42+
case 'linear':
43+
return (
44+
process.env['STACKMEMORY_LINEAR'] !== 'false' &&
45+
(!!process.env['LINEAR_API_KEY'] || !!process.env['LINEAR_OAUTH_TOKEN'])
46+
);
47+
case 'whatsapp':
48+
return (
49+
process.env['STACKMEMORY_WHATSAPP'] !== 'false' &&
50+
!!process.env['TWILIO_ACCOUNT_SID']
51+
);
52+
case 'chromadb':
53+
return process.env['STACKMEMORY_CHROMADB'] === 'true';
54+
case 'aiSummaries':
55+
return (
56+
process.env['STACKMEMORY_AI'] !== 'false' &&
57+
(!!process.env['ANTHROPIC_API_KEY'] || !!process.env['OPENAI_API_KEY'])
58+
);
59+
default:
60+
return false;
61+
}
62+
}
63+
64+
/**
65+
* Get all feature flags
66+
*/
67+
export function getFeatureFlags(): FeatureFlags {
68+
return {
69+
core: true,
70+
linear: isFeatureEnabled('linear'),
71+
whatsapp: isFeatureEnabled('whatsapp'),
72+
chromadb: isFeatureEnabled('chromadb'),
73+
aiSummaries: isFeatureEnabled('aiSummaries'),
74+
};
75+
}
76+
77+
/**
78+
* Log feature flags status (for debugging)
79+
*/
80+
export function logFeatureStatus(): void {
81+
const flags = getFeatureFlags();
82+
const local = isLocalOnly();
83+
84+
console.log(
85+
`StackMemory Mode: ${local ? 'LOCAL (no external services)' : 'FULL'}`
86+
);
87+
if (!local) {
88+
console.log(
89+
` Linear: ${flags.linear ? 'enabled' : 'disabled (no API key)'}`
90+
);
91+
console.log(
92+
` WhatsApp: ${flags.whatsapp ? 'enabled' : 'disabled (no Twilio)'}`
93+
);
94+
console.log(` ChromaDB: ${flags.chromadb ? 'enabled' : 'disabled'}`);
95+
console.log(
96+
` AI Summaries: ${flags.aiSummaries ? 'enabled' : 'disabled (no API key)'}`
97+
);
98+
}
99+
}

src/core/retrieval/llm-provider.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,17 +152,60 @@ export class AnthropicLLMProvider implements LLMProvider {
152152
}
153153
}
154154

155+
/**
156+
* Local fallback LLM provider - uses heuristic summarization without external APIs
157+
* This ensures StackMemory works in LOCAL_ONLY mode
158+
*/
159+
export class LocalFallbackProvider implements LLMProvider {
160+
async analyze(prompt: string, maxTokens: number): Promise<string> {
161+
// Extract content from prompt and create a heuristic summary
162+
const lines = prompt.split('\n').filter((l) => l.trim());
163+
const contentStart = lines.findIndex((l) => l.includes('Content:'));
164+
165+
if (contentStart === -1 || lines.length < 3) {
166+
return 'Context summary not available (local mode)';
167+
}
168+
169+
// Extract key information heuristically
170+
const content = lines.slice(contentStart + 1).join('\n');
171+
const sentences = content
172+
.split(/[.!?]+/)
173+
.filter((s) => s.trim().length > 10);
174+
175+
// Take first few sentences up to maxTokens (rough approximation: 4 chars = 1 token)
176+
const maxChars = maxTokens * 4;
177+
let summary = '';
178+
for (const sentence of sentences.slice(0, 5)) {
179+
if (summary.length + sentence.length > maxChars) break;
180+
summary += sentence.trim() + '. ';
181+
}
182+
183+
return (
184+
summary.trim() || 'Context available (use LLM API for detailed analysis)'
185+
);
186+
}
187+
}
188+
155189
/**
156190
* Factory function to create an LLM provider based on environment
157191
*/
158192
export function createLLMProvider(): LLMProvider | undefined {
193+
// Check for local-only mode
194+
if (
195+
process.env['STACKMEMORY_LOCAL'] === 'true' ||
196+
process.env['LOCAL_ONLY'] === 'true'
197+
) {
198+
logger.info('LOCAL mode - using heuristic summarization');
199+
return new LocalFallbackProvider();
200+
}
201+
159202
const apiKey = process.env['ANTHROPIC_API_KEY'];
160203

161204
if (!apiKey) {
162205
logger.info(
163206
'No ANTHROPIC_API_KEY found, LLM retrieval will use heuristics'
164207
);
165-
return undefined;
208+
return new LocalFallbackProvider();
166209
}
167210

168211
return new AnthropicLLMProvider({

src/integrations/mcp/server.ts

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,20 @@ import { readFileSync, existsSync, mkdirSync } from 'fs';
2222
import { join, dirname } from 'path';
2323
import { execSync } from 'child_process';
2424
import { FrameManager, FrameType } from '../../core/context/index.js';
25-
import {
26-
LinearTaskManager,
25+
import { logger } from '../../core/monitoring/logger.js';
26+
import { isFeatureEnabled } from '../../core/config/feature-flags.js';
27+
28+
// Linear types - imported dynamically when needed
29+
type LinearTaskManager =
30+
import('../../features/tasks/linear-task-manager.js').LinearTaskManager;
31+
type LinearAuthManager = import('../linear/auth.js').LinearAuthManager;
32+
type LinearSyncEngine = import('../linear/sync.js').LinearSyncEngine;
33+
34+
// Re-export task types for handlers (these are just enums/types, not runtime deps)
35+
export {
2736
TaskPriority,
2837
TaskStatus,
2938
} from '../../features/tasks/linear-task-manager.js';
30-
import { LinearAuthManager, LinearOAuthSetup } from '../linear/auth.js';
31-
import { LinearSyncEngine, DEFAULT_SYNC_CONFIG } from '../linear/sync.js';
32-
import { logger } from '../../core/monitoring/logger.js';
3339
import { BrowserMCPIntegration } from '../../features/browser/browser-mcp.js';
3440
import { TraceDetector } from '../../core/trace/trace-detector.js';
3541
import { ToolCall, Trace } from '../../core/trace/types.js';
@@ -59,9 +65,9 @@ class LocalStackMemoryMCP {
5965
private db: Database.Database;
6066
private projectRoot: string;
6167
private frameManager: FrameManager;
62-
private taskStore: LinearTaskManager;
63-
private linearAuthManager: LinearAuthManager;
64-
private linearSync: LinearSyncEngine;
68+
private taskStore: LinearTaskManager | null = null;
69+
private linearAuthManager: LinearAuthManager | null = null;
70+
private linearSync: LinearSyncEngine | null = null;
6571
private projectId: string;
6672
private contexts: Map<string, any> = new Map();
6773
private browserMCP: BrowserMCPIntegration;
@@ -88,16 +94,8 @@ class LocalStackMemoryMCP {
8894
// Initialize frame manager
8995
this.frameManager = new FrameManager(this.db, this.projectId);
9096

91-
// Initialize task store
92-
this.taskStore = new LinearTaskManager(this.projectRoot, this.db);
93-
94-
// Initialize Linear integration
95-
this.linearAuthManager = new LinearAuthManager(this.projectRoot);
96-
this.linearSync = new LinearSyncEngine(
97-
this.taskStore,
98-
this.linearAuthManager,
99-
DEFAULT_SYNC_CONFIG
100-
);
97+
// Initialize Linear integration (optional - lazy loaded)
98+
this.initLinearIfEnabled();
10199

102100
// Initialize MCP server
103101
this.server = new Server(
@@ -161,6 +159,36 @@ class LocalStackMemoryMCP {
161159
return process.cwd();
162160
}
163161

162+
/**
163+
* Initialize Linear integration if enabled and credentials available
164+
*/
165+
private async initLinearIfEnabled(): Promise<void> {
166+
if (!isFeatureEnabled('linear')) {
167+
logger.info('Linear integration disabled (no API key or LOCAL mode)');
168+
return;
169+
}
170+
171+
try {
172+
const { LinearTaskManager } =
173+
await import('../../features/tasks/linear-task-manager.js');
174+
const { LinearAuthManager } = await import('../linear/auth.js');
175+
const { LinearSyncEngine, DEFAULT_SYNC_CONFIG } =
176+
await import('../linear/sync.js');
177+
178+
this.taskStore = new LinearTaskManager(this.projectRoot, this.db);
179+
this.linearAuthManager = new LinearAuthManager(this.projectRoot);
180+
this.linearSync = new LinearSyncEngine(
181+
this.taskStore,
182+
this.linearAuthManager,
183+
DEFAULT_SYNC_CONFIG
184+
);
185+
186+
logger.info('Linear integration initialized');
187+
} catch (error) {
188+
logger.warn('Failed to initialize Linear integration', { error });
189+
}
190+
}
191+
164192
private initDB() {
165193
// Note: Don't create frames table here - FrameManager handles the schema
166194
// with the full run_id, project_id, parent_frame_id columns

0 commit comments

Comments
 (0)