Skip to content

Commit 1b04d6a

Browse files
kulvirgitclaude
andcommitted
feat: env-based skill selection with session caching and tracing
Run LLM skill selector once per session using environment fingerprint, cache by working directory, and apply filtering to both system prompt and tool description. Adds tracing spans for fingerprint, skill selection, and system prompt. - Use LLM.stream for skill selection (proper provider auth) - Plain text response parsing (one skill name per line) - Cache keyed by cwd — invalidates on project change - Filter skills in both SystemPrompt.skills() and SkillTool - Add env_fingerprint_skill_selection config (default: true) - Trim fingerprint to data-engineering detections only - Add tracing for fingerprint, skill-selection, and system-prompt spans Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d097682 commit 1b04d6a

13 files changed

Lines changed: 912 additions & 8 deletions

File tree

docs/docs/configure/config.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,25 @@ Configuration is loaded from multiple sources, with later sources overriding ear
6161
| `compaction` | `object` | Context compaction settings (see [Context Management](context-management.md)) |
6262
| `experimental` | `object` | Experimental feature flags |
6363

64+
### Experimental Flags
65+
66+
These flags are under `experimental` and may change or be removed in future releases.
67+
68+
| Flag | Type | Default | Description |
69+
|------|------|---------|-------------|
70+
| `env_fingerprint_skill_selection` | `boolean` | `false` | Use environment fingerprint (dbt, airflow, databricks, SQL) to select relevant skills once per session via LLM. When enabled, the configured model runs once at session start to filter skills based on detected project environment. Results are cached per working directory. When disabled (default), all skills are shown. |
71+
| `auto_enhance_prompt` | `boolean` | `false` | Automatically rewrite prompts with AI before sending. Uses a small model to clarify rough prompts. |
72+
73+
Example:
74+
75+
```json
76+
{
77+
"experimental": {
78+
"env_fingerprint_skill_selection": true
79+
}
80+
}
81+
```
82+
6483
## Value Substitution
6584

6685
Config values support dynamic substitution so you never need to hardcode secrets.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Filesystem } from "../../util/filesystem"
2+
import { Glob } from "../../util/glob"
3+
import { Log } from "../../util/log"
4+
import { Tracer } from "../observability/tracing"
5+
import path from "path"
6+
7+
const log = Log.create({ service: "fingerprint" })
8+
9+
export namespace Fingerprint {
10+
export interface Result {
11+
tags: string[]
12+
detectedAt: number
13+
cwd: string
14+
}
15+
16+
let cached: Result | undefined
17+
18+
export function get(): Result | undefined {
19+
return cached
20+
}
21+
22+
export async function refresh(): Promise<Result> {
23+
const previousCwd = cached?.cwd ?? process.cwd()
24+
cached = undefined
25+
return detect(previousCwd)
26+
}
27+
28+
export async function detect(cwd: string, root?: string): Promise<Result> {
29+
if (cached && cached.cwd === cwd) return cached
30+
31+
const startTime = Date.now()
32+
const timer = log.time("detect", { cwd, root })
33+
const tags: string[] = []
34+
35+
const dirs = root && root !== cwd ? [cwd, root] : [cwd]
36+
37+
await Promise.all(
38+
dirs.map((dir) => detectDir(dir, tags)),
39+
)
40+
41+
// Deduplicate
42+
const unique = [...new Set(tags)]
43+
44+
const result: Result = {
45+
tags: unique,
46+
detectedAt: Date.now(),
47+
cwd,
48+
}
49+
50+
cached = result
51+
timer.stop()
52+
log.info("detected", { tags: unique.join(","), cwd })
53+
54+
Tracer.active?.logSpan({
55+
name: "fingerprint",
56+
startTime,
57+
endTime: Date.now(),
58+
input: { cwd, root },
59+
output: { tags: unique },
60+
})
61+
62+
return result
63+
}
64+
65+
async function detectDir(dir: string, tags: string[]): Promise<void> {
66+
// Data-engineering detections only
67+
const [
68+
hasDbtProject,
69+
hasProfilesYml,
70+
hasSqlfluff,
71+
hasDbtPackagesYml,
72+
hasAirflowCfg,
73+
hasDagsDir,
74+
hasDatabricksYml,
75+
] = await Promise.all([
76+
Filesystem.exists(path.join(dir, "dbt_project.yml")),
77+
Filesystem.exists(path.join(dir, "profiles.yml")),
78+
Filesystem.exists(path.join(dir, ".sqlfluff")),
79+
Filesystem.exists(path.join(dir, "dbt_packages.yml")),
80+
Filesystem.exists(path.join(dir, "airflow.cfg")),
81+
Filesystem.isDir(path.join(dir, "dags")),
82+
Filesystem.exists(path.join(dir, "databricks.yml")),
83+
])
84+
85+
// dbt detection
86+
if (hasDbtProject) {
87+
tags.push("dbt", "data-engineering")
88+
}
89+
90+
// dbt packages
91+
if (hasDbtPackagesYml) {
92+
tags.push("dbt-packages")
93+
}
94+
95+
// profiles.yml - extract adapter type
96+
if (hasProfilesYml) {
97+
try {
98+
const content = await Filesystem.readText(path.join(dir, "profiles.yml"))
99+
const adapterMatch = content.match(
100+
/type:\s*(snowflake|bigquery|redshift|databricks|postgres|mysql|sqlite|duckdb|trino|spark|clickhouse)/i,
101+
)
102+
if (adapterMatch) {
103+
tags.push(adapterMatch[1]!.toLowerCase())
104+
}
105+
} catch (e) {
106+
log.debug("profiles.yml unreadable", { dir, error: e })
107+
}
108+
}
109+
110+
// SQL - check for .sqlfluff or any .sql files
111+
if (hasSqlfluff) {
112+
tags.push("sql")
113+
} else {
114+
try {
115+
const sqlFiles = await Glob.scan("*.sql", {
116+
cwd: dir,
117+
include: "file",
118+
})
119+
if (sqlFiles.length > 0) {
120+
tags.push("sql")
121+
}
122+
} catch (e) {
123+
log.debug("sql glob scan failed", { dir, error: e })
124+
}
125+
}
126+
127+
// Airflow
128+
if (hasAirflowCfg || hasDagsDir) {
129+
tags.push("airflow")
130+
}
131+
132+
// Databricks
133+
if (hasDatabricksYml) {
134+
tags.push("databricks")
135+
}
136+
}
137+
}

packages/opencode/src/altimate/observability/tracing.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface TraceSpan {
4141
spanId: string
4242
parentSpanId: string | null
4343
name: string
44-
kind: "session" | "generation" | "tool" | "text"
44+
kind: "session" | "generation" | "tool" | "text" | "span"
4545
startTime: number
4646
endTime?: number
4747
status: "ok" | "error"
@@ -246,6 +246,11 @@ interface TracerOptions {
246246
}
247247

248248
export class Tracer {
249+
// Global active tracer — set when a session starts, cleared on end.
250+
private static _active: Tracer | null = null
251+
static get active(): Tracer | null { return Tracer._active }
252+
static setActive(tracer: Tracer | null) { Tracer._active = tracer }
253+
249254
private traceId: string
250255
private sessionId: string | undefined
251256
private rootSpanId: string | undefined
@@ -561,6 +566,39 @@ export class Tracer {
561566
if (part.text != null) this.generationText.push(String(part.text))
562567
}
563568

569+
/**
570+
* Log a custom span (e.g., fingerprint detection, skill selection).
571+
* Used for internal operations that aren't LLM generations or tool calls.
572+
*/
573+
logSpan(span: {
574+
name: string
575+
startTime: number
576+
endTime: number
577+
status?: "ok" | "error"
578+
input?: unknown
579+
output?: unknown
580+
attributes?: Record<string, unknown>
581+
}) {
582+
if (!this.rootSpanId) return
583+
try {
584+
this.spans.push({
585+
spanId: randomUUIDv7(),
586+
parentSpanId: this.rootSpanId,
587+
name: span.name,
588+
kind: "span",
589+
startTime: span.startTime,
590+
endTime: span.endTime,
591+
status: span.status ?? "ok",
592+
input: span.input,
593+
output: span.output,
594+
attributes: span.attributes,
595+
})
596+
this.snapshot()
597+
} catch {
598+
// best-effort
599+
}
600+
}
601+
564602
/**
565603
* Build a TraceFile snapshot of the current state (in-progress or complete).
566604
* Used for incremental writes and live viewing.

0 commit comments

Comments
 (0)