Skip to content
Merged
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
128 changes: 128 additions & 0 deletions default/skills/scan-value-chain/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
---
name: scan-value-chain
description: >
Scan an investment theme by decomposing its value chain, then surface the
handful of names actually worth researching — each with why and the next
question. Use when the user has a theme/sector/thread but no specific
ticker yet: "what's worth looking at in semis", "scan the AI-infra space",
"I'm curious about uranium / obesity drugs / power grid",
"who are the picks-and-shovels in X", "map the supply chain for Y",
"find the names worth watching in the X value chain". This is the
have-a-theme / no-target step —
it turns "I don't know what to look at" into a short, reasoned shortlist.
Runs on OpenAlice's own MCP tools (market, equity, analysis, economy,
news) — no external data subscription needed.
---

# Scan a theme by its value chain

Turn a theme the user can't yet act on into a short list of names worth
digging into. The point is NOT a data dump — it's "where is the interesting
thing, and why."

## Data sources

This skill is self-sufficient on OpenAlice's own MCP spine — it needs no
external data subscription to run. The agent will see the `openalice` tools
in-workspace; the ones easy to overlook and worth leaning on are the macro
series (`economyFredSeries`, `economyEnergyOutlook`, `economyPetroleumStatus`)
and the news archive (`globNews` / `grepNews`) — that top-down tie-in is the
edge a per-ticker tool can't match.

If the workspace has other data sources wired up, use them where they help.
If the spine can't cover an angle, say so plainly rather than guessing — a
surfaced gap is more useful than a papered-over one.

## Procedure (don't answer from memory — run the tools)

1. **Decompose the chain, not a flat list.** Break the theme into structural
layers — upstream (inputs, equipment, IP) → midstream (manufacture, core
product) → downstream (demand, end-market). Place the real names in each
layer with `marketSearchForResearch`. The whole edge here is structural
thinking a per-ticker tool can't do: who supplies whom, where the
margin/bottleneck sits, who's a picks-and-shovels play. This is the
meta-method — apply it to ANY theme, don't hardcode one taxonomy.
2. **Quick read per node.** Across the candidates: `equityGetProfile`
(valuation snapshot), `equityGetEarningsCalendar` (near catalysts),
`calculateIndicator` (stretched vs basing on its own trend). Wide and
cheap — you're triaging, not deep-diving.
3. **Find the divergence.** Surface 3–6 names where there's something to pull
on: cheap vs its layer, margin shifting along the chain, a catalyst close,
a leader/laggard gap. Drop the rest — a scan that returns everything
returns nothing.
4. **Frame the top-down driver (OpenAlice's edge).** Is the theme live right
now? Tie it to macro: rate/capex cycle via `economyFredSeries`, energy via
the EIA tools, plus any news cluster from `grepNews`. A single-stock skill
structurally can't do this top-down tie-in — lean on it hard.
5. **Hand off to research.** For each surfaced name: one-line WHY + the next
question to answer (the "is the thesis real" question). That next question
is the baton to the deeper research step.

## Output — persist as a file group, don't leave it in chat

Workspace sessions can be destroyed at any time; anything not written to a
file is lost. And coding-ifying the workflow is core to this project —
research that produces no files is a contradiction. So the result of a scan
must land in files.

- **First time on a theme:** propose a small file/directory layout and confirm
it with the user before writing — the shortlist, per-name notes, the chain
map, whatever this theme needs. Don't hardcode a layout from this skill;
settle the shape WITH the user, per theme.
- **After that:** the agreed file group IS the dossier. Every session just
CRUDs it — read it, update it, add to it. File-based, git-trackable,
survives session loss. That's the coding workflow.

The shortlist and the per-name "next question" from the procedure above are
what get written down — so the next session starts from them, not from zero.

## Worked example: semiconductors

One theme, worked end to end. Decompose freshly for any other theme — don't
pattern-match these layers (a drug theme, say, layers differently: discovery
/ developer → CDMO manufacturing → distribution / PBM → payer).

**Decompose the chain** (representative names, not exhaustive):

- **Upstream — tools & IP** (most concentrated moats):
- EDA / IP: Cadence (CDNS), Synopsys (SNPS), Arm (ARM)
- Equipment (WFE): ASML (ASML — sole EUV supplier), Applied Materials
(AMAT), Lam (LRCX), KLA (KLAC), Tokyo Electron (8035.T)
- Materials: wafers (Shin-Etsu, SUMCO), photoresist / specialty chemicals
- **Midstream — make & design:**
- Foundry (pure-play make): TSMC (TSM), GlobalFoundries (GFS), SMIC
- IDM (design + make): Intel (INTC), Samsung, Texas Instruments (TXN)
- Fabless (design only): NVIDIA (NVDA), AMD (AMD), Broadcom (AVGO),
Marvell (MRVL), Qualcomm (QCOM)
- Memory: Micron (MU), SK Hynix, Samsung — DRAM / NAND / **HBM**
- Packaging & test (OSAT): ASE (ASX), Amkor (AMKR) — **advanced packaging**
- **Downstream — demand:** hyperscalers (MSFT / GOOGL / META / AMZN — also
rolling their own silicon: TPU, Trainium, MTIA, Maia), devices (AAPL),
auto / industrial, servers (SMCI, DELL)

**Where the tension is right now** — this is what a scan surfaces, not the
full roster: the binding constraint for AI silicon has migrated from
leading-edge logic to **HBM + advanced packaging (CoWoS)**, so Micron /
SK Hynix and TSM's CoWoS capacity + Amkor deserve more attention than the
headline GPU names. ASML is the single most concentrated upstream choke point.

**Top-down frame** (OpenAlice's edge): semis run on three clocks — hyperscaler
**capex**, the **rate** cycle (long-duration growth multiples), and the
**memory inventory / pricing** cycle. Tie the scan to these via the FRED
series + news archive.

**Proposed file structure** (confirm / adjust with the user — don't impose):

```
semis/
map.md # chain decomposition + where the tension sits + macro frame
shortlist.md # the 3–6 names to dig now: one-line why + next question each
notes/ # per-name research, added as you climb scan → thesis (R3+)
NVDA.md
MU.md
...
```

`map.md` and `shortlist.md` are produced by this scan; `notes/<name>.md` grow
later as specific names get researched. The next session reads `shortlist.md`
and continues — never a cold start.
187 changes: 13 additions & 174 deletions src/webui/routes/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@

import { Hono } from 'hono';
import { randomUUID } from 'node:crypto';
import { readFile, rm } from 'node:fs/promises';
import { readFile } from 'node:fs/promises';
import { join, resolve as resolvePath } from 'node:path';

import { probeAnthropic, probeOpenAI } from '../../workspaces/agent-probe.js';
import { listDir, PathTraversal, readWorkspaceFile, writeWorkspaceFile } from '../../workspaces/file-service.js';
import { listDir, PathTraversal, readWorkspaceFile } from '../../workspaces/file-service.js';
import { gitLog, gitStatus } from '../../workspaces/git-service.js';
import { logger as launcherLogger } from '../../workspaces/logger.js';
import type { SessionRecord } from '../../workspaces/session-registry.js';
import { resumeFromRecord, type SessionFactoryContext, type WorkspaceService } from '../../workspaces/service.js';
import type { WorkspaceAiCred } from '../../workspaces/cli-adapter.js';

const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

Expand Down Expand Up @@ -729,8 +730,8 @@ export function createWorkspaceRoutes(svc: WorkspaceService): Hono {
if (!meta) return c.json({ error: 'not_found' }, 404);
try {
const [claude, codex] = await Promise.all([
readClaudeConfig(meta.dir),
readCodexConfig(meta.dir),
svc.adapters.get('claude')?.readAiConfig?.(meta.dir) ?? null,
svc.adapters.get('codex')?.readAiConfig?.(meta.dir) ?? null,
]);
return c.json({ claude, codex });
} catch (err) {
Expand All @@ -748,14 +749,12 @@ export function createWorkspaceRoutes(svc: WorkspaceService): Hono {
const meta = svc.registry.get(id);
if (!meta) return c.json({ error: 'not_found' }, 404);

const body = (await safeJson(c)) as AgentConfigInput | null;
const body = (await safeJson(c)) as WorkspaceAiCred | null;
const cfg = body && typeof body === 'object' ? body : {};
try {
if (agent === 'claude') {
await writeClaudeConfig(meta.dir, cfg);
} else {
await writeCodexConfig(meta.dir, cfg);
}
const adapter = svc.adapters.get(agent);
if (!adapter?.writeAiConfig) return c.json({ error: 'unknown_agent' }, 400);
await adapter.writeAiConfig(meta.dir, cfg);
launcherLogger.info('agent_config.saved', { id, agent });
return c.json({ ok: true });
} catch (err) {
Expand All @@ -775,7 +774,7 @@ export function createWorkspaceRoutes(svc: WorkspaceService): Hono {
return c.json({ ok: false, error: 'unknown_agent' }, 400);
}

const body = (await safeJson(c)) as AgentConfigInput | null;
const body = (await safeJson(c)) as WorkspaceAiCred | null;
const baseUrl = typeof body?.baseUrl === 'string' ? body.baseUrl.trim() : '';
const apiKey = typeof body?.apiKey === 'string' ? body.apiKey.trim() : '';
const model = typeof body?.model === 'string' ? body.model.trim() : '';
Expand Down Expand Up @@ -818,169 +817,9 @@ interface ProfileShape {
authMode?: unknown;
}

interface AgentConfigInput {
baseUrl?: string;
apiKey?: string;
model?: string;
wireApi?: 'chat' | 'responses';
/** Claude only — which header carries the key (see ClaudeProbeInput). */
authMode?: 'x-api-key' | 'bearer';
}

interface ClaudeConfigShape {
baseUrl: string | null;
apiKey: string | null;
model: string | null;
authMode: 'x-api-key' | 'bearer';
}

interface CodexConfigShape {
baseUrl: string | null;
apiKey: string | null;
model: string | null;
wireApi: 'chat' | 'responses' | null;
}

const CLAUDE_SETTINGS_PATH = '.claude/settings.local.json';
const CODEX_CONFIG_PATH = '.codex/config.toml';
const CODEX_ENV_PATH = '.codex/env.json';
const CODEX_KEY_ENV_NAME = 'OPENALICE_WORKSPACE_KEY';
const CODEX_PROVIDER_NAME = 'workspace';

async function readClaudeConfig(workspaceDir: string): Promise<ClaudeConfigShape | null> {
const raw = await readWorkspaceFile(workspaceDir, CLAUDE_SETTINGS_PATH);
if (raw === null) return null;
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(raw) as Record<string, unknown>;
} catch {
return null;
}
const env = (parsed['env'] ?? {}) as Record<string, unknown>;
const baseUrl = typeof env['ANTHROPIC_BASE_URL'] === 'string' ? (env['ANTHROPIC_BASE_URL'] as string) : null;
// The key lives in exactly one of two env vars depending on auth mode:
// ANTHROPIC_API_KEY → x-api-key header, ANTHROPIC_AUTH_TOKEN → Bearer.
// Which one is present tells us the mode to surface back to the modal.
const xApiKey = typeof env['ANTHROPIC_API_KEY'] === 'string' ? (env['ANTHROPIC_API_KEY'] as string) : null;
const authToken = typeof env['ANTHROPIC_AUTH_TOKEN'] === 'string' ? (env['ANTHROPIC_AUTH_TOKEN'] as string) : null;
const authMode: 'x-api-key' | 'bearer' = authToken !== null ? 'bearer' : 'x-api-key';
const apiKey = authToken ?? xApiKey;
const model = typeof parsed['model'] === 'string' ? (parsed['model'] as string) : null;
if (baseUrl === null && apiKey === null && model === null) return null;
return { baseUrl, apiKey, model, authMode };
}

async function writeClaudeConfig(workspaceDir: string, cfg: AgentConfigInput): Promise<void> {
const hasAny = cfg.baseUrl || cfg.apiKey || cfg.model;
if (!hasAny) {
// Reset: delete the settings file so claude falls back to its global
// OAuth / settings. We don't leave an empty `{}` behind — workspace
// files exist only when there's an actual override.
const filePath = join(workspaceDir, CLAUDE_SETTINGS_PATH);
await rm(filePath, { force: true });
return;
}
const out: Record<string, unknown> = {};
const env: Record<string, string> = {};
if (cfg.baseUrl) env['ANTHROPIC_BASE_URL'] = cfg.baseUrl;
// Write the key into exactly one env var. Bearer-mode gateways (MiniMax
// international, proxy front-ends) read ANTHROPIC_AUTH_TOKEN → the CLI sends
// `Authorization: Bearer`. Default x-api-key mode uses ANTHROPIC_API_KEY.
// Never write both: Claude Code warns on dual-set, and the two headers
// together can be rejected as ambiguous auth.
if (cfg.apiKey) {
if (cfg.authMode === 'bearer') env['ANTHROPIC_AUTH_TOKEN'] = cfg.apiKey;
else env['ANTHROPIC_API_KEY'] = cfg.apiKey;
}
if (Object.keys(env).length > 0) out['env'] = env;
if (cfg.model) out['model'] = cfg.model;
await writeWorkspaceFile(workspaceDir, CLAUDE_SETTINGS_PATH, JSON.stringify(out, null, 2) + '\n');
}

async function readCodexConfig(workspaceDir: string): Promise<CodexConfigShape | null> {
const tomlRaw = await readWorkspaceFile(workspaceDir, CODEX_CONFIG_PATH);
const envRaw = await readWorkspaceFile(workspaceDir, CODEX_ENV_PATH);
if (tomlRaw === null && envRaw === null) return null;

let baseUrl: string | null = null;
let wireApi: 'chat' | 'responses' | null = null;
let model: string | null = null;
if (tomlRaw) {
// Shape-specific extraction: we always write the provider section as
// `[model_providers.workspace]` with `base_url`, `wire_api`, plus
// top-level `model`. Regex is brittle in general but our shape is
// controlled (writer below produces deterministic output).
const providerBlock = tomlRaw.match(/\[model_providers\.workspace\][^\[]*/);
if (providerBlock) {
const block = providerBlock[0];
const base = block.match(/base_url\s*=\s*"([^"]*)"/);
if (base) baseUrl = base[1] ?? null;
const wire = block.match(/wire_api\s*=\s*"(chat|responses)"/);
if (wire) wireApi = wire[1] as 'chat' | 'responses';
}
const modelMatch = tomlRaw.match(/^model\s*=\s*"([^"]*)"\s*$/m);
if (modelMatch) model = modelMatch[1] ?? null;
}

let apiKey: string | null = null;
if (envRaw) {
try {
const env = JSON.parse(envRaw) as Record<string, unknown>;
const k = env[CODEX_KEY_ENV_NAME];
if (typeof k === 'string') apiKey = k;
} catch { /* ignore parse error, leave apiKey null */ }
}

if (baseUrl === null && apiKey === null && model === null && wireApi === null) return null;
return { baseUrl, apiKey, model, wireApi };
}

async function writeCodexConfig(workspaceDir: string, cfg: AgentConfigInput): Promise<void> {
const hasProvider = !!(cfg.baseUrl || cfg.model);

if (!hasProvider) {
// Reset: tear down the workspace's entire `.codex/` directory. The
// adapter's `composeEnv` won't set `CODEX_HOME` when the directory is
// absent, so codex falls back to the user's global `~/.codex/`. We
// don't leave empty stubs behind — workspace files exist only when
// there's an actual override. Note: `CODEX_HOME` is exclusive (not a
// merge layer), so a half-empty `.codex/` would *shadow* the user's
// global login and break auth. Full teardown is the only safe reset.
const codexDir = join(workspaceDir, '.codex');
await rm(codexDir, { recursive: true, force: true });
return;
}

// Provider override. config.toml carries only model / model_provider /
// [model_providers.*] — the OpenAlice MCP server entry is wired per-spawn
// via the codex adapter's `-c mcp_servers.openalice.url=...` flag, so we
// don't repeat it here.
let toml = '';
if (cfg.model) toml += `model = ${tomlString(cfg.model)}\n`;
if (cfg.baseUrl) toml += `model_provider = "${CODEX_PROVIDER_NAME}"\n`;
if (cfg.baseUrl) {
toml += '\n';
toml += `[model_providers.${CODEX_PROVIDER_NAME}]\n`;
toml += `name = "OpenAlice workspace provider"\n`;
toml += `base_url = ${tomlString(cfg.baseUrl)}\n`;
toml += `env_key = "${CODEX_KEY_ENV_NAME}"\n`;
toml += `wire_api = "${cfg.wireApi ?? 'chat'}"\n`;
}
await writeWorkspaceFile(workspaceDir, CODEX_CONFIG_PATH, toml);

// env.json: holds the per-workspace API key codex picks up via env_key.
// Adapter's composeEnv reads this and exports at spawn.
if (cfg.apiKey) {
const envObj: Record<string, string> = { [CODEX_KEY_ENV_NAME]: cfg.apiKey };
await writeWorkspaceFile(workspaceDir, CODEX_ENV_PATH, JSON.stringify(envObj, null, 2) + '\n');
} else {
await writeWorkspaceFile(workspaceDir, CODEX_ENV_PATH, '{}\n');
}
}

function tomlString(s: string): string {
return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
// AI-provider config IO moved into the CLI adapters (writeAiConfig /
// readAiConfig on claudeAdapter / codexAdapter). The routes above dispatch
// through svc.adapters so each CLI owns its own file format.

function validId(id: string | undefined): id is string {
return typeof id === 'string' && /^[a-zA-Z0-9_-]+$/.test(id);
Expand Down
Loading
Loading