Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
419 changes: 419 additions & 0 deletions docs/OPERATIONS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"node": ">=20.0.0"
},
"scripts": {
"build": "tsc --project tsconfig.json",
"build": "tsc --project tsconfig.json && node scripts/copy-prompts.js",
"build:watch": "tsc --project tsconfig.json --watch",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
Expand Down
38 changes: 38 additions & 0 deletions scripts/copy-prompts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/usr/bin/env node
/**
* copy-prompts.js
*
* Copies Markdown prompt files from src/prompts/ → dist/prompts/.
* Run as part of the build pipeline after `tsc`.
*
* Why this exists:
* TypeScript's compiler only emits .js/.d.ts files — it ignores .md assets.
* PromptLoader resolves prompts from dist/prompts/ at runtime, so we must
* mirror the src/prompts/ tree into dist/ after every build.
*
* Usage:
* node scripts/copy-prompts.js
*/

import { cpSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const root = join(__dirname, '..');
const src = join(root, 'src', 'prompts');
const dest = join(root, 'dist', 'prompts');

mkdirSync(dest, { recursive: true });

cpSync(src, dest, {
recursive: true,
filter: (source) => {
// Only copy .md files and directories
return source.endsWith('.md') || !source.includes('.');
},
});

console.log(`✅ Prompts copied: ${src} → ${dest}`);
17 changes: 17 additions & 0 deletions src/adapters/context/PersistentContextAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,23 @@ export class PersistentContextAdapter implements ContextStorePort {
.digest();
}

/**
* Ensure the base storage directory exists.
* Call once at startup before any reads/writes.
*/
async initialize(): Promise<void> {
try {
await mkdir(this.basePath, { recursive: true });
logger.debug({ basePath: this.basePath }, 'PersistentContextAdapter: storage directory ready');
} catch (err) {
logger.error(
{ basePath: this.basePath, error: String(err) },
'PersistentContextAdapter: failed to create storage directory',
);
throw err;
}
}

// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
Expand Down
5 changes: 4 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ export async function createAppModule(): Promise<AppModule> {

// --- Orchestrators ---
const promptLoader = new PromptLoader();
const agentOrchestrator = new AgentOrchestrator(primaryAI, toolRegistry);
const agentOrchestrator = new AgentOrchestrator(primaryAI, toolRegistry, wallet);

// --- Interaction log store ---
const logStore = new (await import('./core/context/InteractionLogStore.js')).InteractionLogStore(
Expand Down Expand Up @@ -309,6 +309,9 @@ export async function createAppModule(): Promise<AppModule> {
export async function initModule(module: AppModule): Promise<void> {
logger.info('AppModule: initialising');

// Ensure cold store directory exists before any context reads/writes
await module.coldContextStore.initialize();

if (config.WHATSAPP_PHONE_NUMBER_ID && config.WHATSAPP_ACCESS_TOKEN) {
await module.messaging.initialize();
module.messageOrchestrator.register();
Expand Down
5 changes: 5 additions & 0 deletions src/core/agent/AgentLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { isTerminalSignal, requiresUserInput } from './CompletionSignal.js';
import type { ToolRegistry } from './ToolRegistry.js';
import type { AIProviderPort, AIMessage } from '../../ports/AIProviderPort.js';
import type { UserContext } from '../context/UserContext.js';
import type { WalletPort } from '../../ports/WalletPort.js';
import { patchContext } from '../context/UserContext.js';
import { logger } from '../../config/logger.js';

Expand Down Expand Up @@ -62,15 +63,18 @@ export class AgentLoop {
private readonly config: AgentConfig;
private readonly toolRegistry: ToolRegistry;
private readonly aiProvider: AIProviderPort;
private readonly walletPort: WalletPort;

constructor(
config: AgentConfig,
toolRegistry: ToolRegistry,
aiProvider: AIProviderPort,
walletPort: WalletPort,
) {
this.config = config;
this.toolRegistry = toolRegistry;
this.aiProvider = aiProvider;
this.walletPort = walletPort;
}

/**
Expand Down Expand Up @@ -184,6 +188,7 @@ export class AgentLoop {
contextPatch = { ...contextPatch, ...patch };
},
requestId,
walletPort: this.walletPort,
};

const toolResult = await this.toolRegistry.execute(
Expand Down
26 changes: 20 additions & 6 deletions src/core/context/ContextManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export class ContextManager {
// 1. Memory LRU cache
if (this.options.enableCache) {
const cached = this.memCache.get(phoneHash);
if (cached && Date.now() - cached.cachedAt < 60_000) {
if (cached && Date.now() - cached.cachedAt < 1_800_000) { // 30 minutes
logger.debug({ phoneHash }, 'ContextManager: context loaded from memory cache');
return cached.context;
}
Expand Down Expand Up @@ -157,23 +157,37 @@ export class ContextManager {
async saveContext(context: UserContext): Promise<void> {
const phoneHash = context.identity.phoneHash;

// Always update the in-memory cache first — this ensures the context is
// available for the next turn even if durable writes fail.
this.setMemCache(phoneHash, context);

// Write to both stores concurrently; cold store is authoritative
const opts = { ttlSeconds: this.options.defaultTtlSeconds };
const [, coldErr] = await Promise.allSettled([
const [hotResult, coldResult] = await Promise.allSettled([
this.hotCache.saveContext(phoneHash, context, opts),
this.coldStore.saveContext(phoneHash, context, opts),
]).then((results) => results.map((r) => (r.status === 'rejected' ? r.reason : null)));
]);

const hotErr = hotResult.status === 'rejected' ? hotResult.reason : null;
const coldErr = coldResult.status === 'rejected' ? coldResult.reason : null;

if (hotErr) {
logger.warn(
{ phoneHash, error: String(hotErr) },
'ContextManager: hot cache write failed (memory cache still valid)',
);
}

if (coldErr) {
logger.error(
{ phoneHash, error: String(coldErr) },
'ContextManager: cold store write failed',
'ContextManager: cold store write failed (memory cache still valid)',
);
// Don't swallow — durable write failure should surface
// Surface the error so callers know persistence failed,
// but session remains available via in-memory cache.
throw coldErr as Error;
}

this.setMemCache(phoneHash, context);
logger.debug({ phoneHash }, 'ContextManager: context saved (write-through)');
}

Expand Down
7 changes: 7 additions & 0 deletions src/core/context/UserContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export const IdentitySchema = z.object({
displayName: z.string().optional(),
/** User's timezone, e.g. 'America/Jamaica' */
timezone: z.string().optional(),
/**
* Flash API Bearer token for this user session.
* Obtained during OTP verification and used by wallet tools.
* Stored in context so it can be injected into WalletPort per-request.
*/
authToken: z.string().optional(),
});

/** Language and dialect understanding */
Expand Down Expand Up @@ -214,6 +220,7 @@ export type PartialIdentityInput = {
flashAccountId?: string;
accountLinked?: boolean;
kycTier?: 0 | 1 | 2;
authToken?: string;
};

/**
Expand Down
7 changes: 7 additions & 0 deletions src/core/tools/Tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import type { CompletionSignal } from '../agent/CompletionSignal.js';
import type { UserContext } from '../context/UserContext.js';
import type { WalletPort } from '../../ports/WalletPort.js';

/** Category grouping for tools. */
export type ToolCategory =
Expand Down Expand Up @@ -58,6 +59,12 @@ export interface ToolExecutionContext {
* Correlation ID for tracing this request through logs.
*/
requestId: string;
/**
* Wallet adapter — provides access to Flash API wallet operations.
* Injected by AgentOrchestrator. Required for all wallet tools.
* Use walletPort.setAuthToken(accountId, token) before calling wallet methods.
*/
walletPort: WalletPort;
}

/**
Expand Down
3 changes: 3 additions & 0 deletions src/core/tools/identity/VerifyOTP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ export class VerifyOTP extends BaseTool {
...context.userContext.identity,
accountLinked: true,
phoneNumber: phone,
authToken,
},
});
return this.complete(
Expand All @@ -193,6 +194,7 @@ export class VerifyOTP extends BaseTool {
phoneNumber: phone,
flashUsername: me?.username ?? undefined,
flashAccountId: defaultAccount?.id ?? undefined,
authToken,
},
});

Expand All @@ -214,6 +216,7 @@ export class VerifyOTP extends BaseTool {
...context.userContext.identity,
accountLinked: true,
phoneNumber: phone,
authToken,
},
});
return this.complete(
Expand Down
45 changes: 41 additions & 4 deletions src/core/tools/wallet/CheckBalance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,52 @@ export class CheckBalance extends BaseTool {
params: Record<string, unknown>,
context: ToolExecutionContext,
): Promise<ToolResult> {
const { userContext } = context;
const { userContext, walletPort } = context;
const accountId = userContext.identity.flashAccountId;
const authToken = userContext.identity.authToken;

if (!accountId) {
return this.fail('No Flash account ID found. Account may not be fully linked.');
}

// WalletPort is injected at runtime by the Orchestrator via DI
// For now, return a not-implemented error that signals the adapter is missing
throw new Error('WalletPort not injected. CheckBalance requires a WalletPort adapter.');
if (!authToken) {
return this.fail('No auth token found. Please link your Flash account first.');
}

try {
// Register this user's auth token before querying
// Register token so the adapter can authenticate this user's requests
if ('setAuthToken' in walletPort) {
(walletPort as unknown as { setAuthToken(id: string, tok: string): void }).setAuthToken(accountId, authToken);
}

const balance = await walletPort.getBalance(accountId);
const { available, total, pendingOut } = balance;

const lines: string[] = [
`Balance for account ${accountId}:`,
` Available: ${available.display} ${available.currency}`,
];

if (total.amountCents !== available.amountCents) {
lines.push(` Total (incl. pending): ${total.display} ${total.currency}`);
}

if (pendingOut.amountCents > 0) {
lines.push(` Pending outgoing: ${pendingOut.display} ${pendingOut.currency}`);
}

lines.push(` As of: ${balance.asOf.toISOString()}`);

return this.success(lines.join('\n'), {
accountId,
availableCents: available.amountCents,
currency: available.currency,
asOf: balance.asOf.toISOString(),
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return this.fail(`Failed to fetch balance: ${message}`);
}
}
}
57 changes: 54 additions & 3 deletions src/core/tools/wallet/EstimateFee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@
*/

import { BaseTool, type ToolResult, type ToolExecutionContext } from '../Tool.js';
import type { Money } from '../../../ports/WalletPort.js';

function registerToken(context: ToolExecutionContext, accountId: string): void {
const { authToken } = context.userContext.identity;
if (authToken && 'setAuthToken' in context.walletPort) {
(context.walletPort as unknown as { setAuthToken(id: string, tok: string): void })
.setAuthToken(accountId, authToken);
}
}

export class EstimateFee extends BaseTool {
readonly name = 'estimate_fee';
Expand Down Expand Up @@ -35,9 +44,51 @@ export class EstimateFee extends BaseTool {
};

async execute(
_params: Record<string, unknown>,
_context: ToolExecutionContext,
params: Record<string, unknown>,
context: ToolExecutionContext,
): Promise<ToolResult> {
throw new Error('WalletPort not injected. EstimateFee requires a WalletPort adapter.');
const { userContext, walletPort } = context;
const accountId = userContext.identity.flashAccountId;

if (!accountId) {
return this.fail('No Flash account ID found. Account may not be fully linked.');
}

if (accountId) registerToken(context, accountId);

const destination = params['destination'] as string | undefined;
if (!destination) return this.fail('Destination is required.');

const amountParam = params['amount'] as { value: number; currency: string } | undefined;
const amount: Money = amountParam
? {
amountCents: Math.round(amountParam.value * 100),
currency: amountParam.currency,
display: `${amountParam.value} ${amountParam.currency}`,
}
: { amountCents: 0, currency: 'SAT', display: '0 SAT' };

try {
const estimate = await walletPort.estimateFee(destination, amount);

return this.success(
[
'Fee estimates:',
` Low: ${estimate.low.display}`,
` Medium: ${estimate.medium.display} (recommended)`,
` High: ${estimate.high.display}`,
` Est. settlement time: ~${Math.round(estimate.estimatedSettlementSeconds / 60)} min`,
].join('\n'),
{
lowCents: estimate.low.amountCents,
mediumCents: estimate.medium.amountCents,
highCents: estimate.high.amountCents,
currency: estimate.medium.currency,
},
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return this.fail(`Failed to estimate fee: ${message}`);
}
}
}
Loading