dsh-context-mode 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSING.md +37 -0
- package/README.md +40 -14
- package/lib/types/cjk.d.ts +54 -0
- package/lib/types/cjk.d.ts.map +1 -0
- package/lib/types/cjk.js +64 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +71 -22
- package/lib/types/output-containment.d.ts +35 -0
- package/lib/types/output-containment.d.ts.map +1 -0
- package/lib/types/output-containment.js +103 -0
- package/lib/types/routing.d.ts +3 -1
- package/lib/types/routing.d.ts.map +1 -1
- package/lib/types/routing.js +81 -6
- package/lib/types/session-memory.d.ts.map +1 -1
- package/lib/types/session-memory.js +14 -3
- package/package.json +9 -5
- package/skills/context-mode/SKILL.md +104 -11
- package/vendor/context-mode/LICENSE +94 -0
- package/vendor/context-mode/server.bundle.mjs +1126 -0
- package/vendor/context-mode/src/cli.ts +2040 -0
- package/vendor/context-mode/src/db-base.ts +617 -0
- package/vendor/context-mode/src/executor.ts +785 -0
- package/vendor/context-mode/src/exit-classify.ts +33 -0
- package/vendor/context-mode/src/fetch-cache.ts +15 -0
- package/vendor/context-mode/src/lifecycle.ts +305 -0
- package/vendor/context-mode/src/platform/client-map.ts +45 -0
- package/vendor/context-mode/src/platform/detect.ts +645 -0
- package/vendor/context-mode/src/platform/dsh.ts +206 -0
- package/vendor/context-mode/src/platform/types.ts +503 -0
- package/vendor/context-mode/src/runPool.ts +81 -0
- package/vendor/context-mode/src/runtime.ts +765 -0
- package/vendor/context-mode/src/search/auto-memory.ts +200 -0
- package/vendor/context-mode/src/search/ctx-search-schema.ts +143 -0
- package/vendor/context-mode/src/search/flood-guard.ts +111 -0
- package/vendor/context-mode/src/search/unified.ts +176 -0
- package/vendor/context-mode/src/security.ts +889 -0
- package/vendor/context-mode/src/server.ts +4991 -0
- package/vendor/context-mode/src/session/analytics.ts +3085 -0
- package/vendor/context-mode/src/session/db.ts +1726 -0
- package/vendor/context-mode/src/session/error-classifier.ts +392 -0
- package/vendor/context-mode/src/session/event-emit.ts +132 -0
- package/vendor/context-mode/src/session/extract.ts +2958 -0
- package/vendor/context-mode/src/session/index.ts +130 -0
- package/vendor/context-mode/src/session/model-prices.json +429 -0
- package/vendor/context-mode/src/session/persist-tool-calls.ts +128 -0
- package/vendor/context-mode/src/session/pricing.ts +191 -0
- package/vendor/context-mode/src/session/project-attribution.ts +309 -0
- package/vendor/context-mode/src/session/purge.ts +338 -0
- package/vendor/context-mode/src/session/retrieval-marker.ts +65 -0
- package/vendor/context-mode/src/session/snapshot.ts +577 -0
- package/vendor/context-mode/src/store-directory.ts +290 -0
- package/vendor/context-mode/src/store.ts +2071 -0
- package/vendor/context-mode/src/truncate.ts +154 -0
- package/vendor/context-mode/src/types.ts +147 -0
- package/vendor/context-mode/src/util/claude-config.ts +95 -0
- package/vendor/context-mode/src/util/hook-config.ts +78 -0
- package/vendor/context-mode/src/util/jsonc.ts +70 -0
- package/vendor/context-mode/src/util/plugin-cache-integrity.ts +167 -0
- package/vendor/context-mode/src/util/project-dir.ts +347 -0
- package/vendor/context-mode/src/util/sibling-mcp.ts +228 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pricing catalog — single source of truth for per-model USD cost.
|
|
3
|
+
*
|
|
4
|
+
* Deep module, tiny interface. At load it reads one curated multi-vendor JSON
|
|
5
|
+
* (src/session/model-prices.json) into a Map<modelId, Price> in per-Mtok units,
|
|
6
|
+
* then exposes three pure-ish functions:
|
|
7
|
+
*
|
|
8
|
+
* lookupPrice(modelId) → Price | null
|
|
9
|
+
* computeCostUsd(modelId, tokens) → number | null
|
|
10
|
+
* nativeOrComputed(id, t, native) → number | null
|
|
11
|
+
*
|
|
12
|
+
* WHY THIS EXISTS — the bug it kills:
|
|
13
|
+
* The old table in src/session/extract.ts hardcoded ~5 Claude rows plus a
|
|
14
|
+
* `default` row, and any unmatched id (every OpenAI / Gemini / Qwen / DeepSeek
|
|
15
|
+
* / Grok model) silently inherited Claude-Sonnet pricing. Non-Claude turns were
|
|
16
|
+
* therefore mispriced. Here each model is priced from ITS OWN curated row, and
|
|
17
|
+
* an unknown id resolves to `null` (no price) instead of a wrong Claude rate.
|
|
18
|
+
*
|
|
19
|
+
* The large litellm catalog (~1.5MB, ~2900 models) is NOT bundled — it lives at
|
|
20
|
+
* tools/pricing/litellm-catalog.json as the dev-only refresh base for this
|
|
21
|
+
* curated JSON.
|
|
22
|
+
*
|
|
23
|
+
* The curated JSON is small (~13KB, 61 models) and esbuild inlines it into the
|
|
24
|
+
* hook/server bundles at build time (no runtime fs read, no external file).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import catalog from "./model-prices.json" with { type: "json" };
|
|
28
|
+
|
|
29
|
+
/** Per-Mtok price for one model. Any of the four rates may be null ("unknown"). */
|
|
30
|
+
export interface Price {
|
|
31
|
+
input_per_mtok: number | null;
|
|
32
|
+
output_per_mtok: number | null;
|
|
33
|
+
cache_read_per_mtok: number | null;
|
|
34
|
+
cache_write_per_mtok: number | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Token counts for one turn. All fields optional; absent ⇒ treated as 0. */
|
|
38
|
+
export interface TokenCounts {
|
|
39
|
+
input_tokens?: number;
|
|
40
|
+
output_tokens?: number;
|
|
41
|
+
cache_read_tokens?: number;
|
|
42
|
+
cache_creation_tokens?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Raw shape of a curated source row (carries a provenance `source` we drop). */
|
|
46
|
+
interface RawRow {
|
|
47
|
+
input_per_mtok: number | null;
|
|
48
|
+
output_per_mtok: number | null;
|
|
49
|
+
cache_read_per_mtok: number | null;
|
|
50
|
+
cache_write_per_mtok: number | null;
|
|
51
|
+
[extra: string]: unknown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read the curated JSON into one Map. A row with a null *input* price is
|
|
56
|
+
* unusable for cost (the primary bucket has no rate) and is dropped at load so
|
|
57
|
+
* lookupPrice returns null for it — matching "null-priced entries → no price".
|
|
58
|
+
* (The two null-input ids are already pruned from the JSON itself; this guard
|
|
59
|
+
* keeps the loader robust if one is ever re-added.)
|
|
60
|
+
*/
|
|
61
|
+
function buildCatalog(): Map<string, Price> {
|
|
62
|
+
const map = new Map<string, Price>();
|
|
63
|
+
const src = catalog as Record<string, RawRow>;
|
|
64
|
+
for (const id of Object.keys(src)) {
|
|
65
|
+
const row = src[id];
|
|
66
|
+
if (row == null || typeof row !== "object") continue;
|
|
67
|
+
// No input rate ⇒ no usable price for this model.
|
|
68
|
+
if (typeof row.input_per_mtok !== "number") continue;
|
|
69
|
+
map.set(id, {
|
|
70
|
+
input_per_mtok: row.input_per_mtok,
|
|
71
|
+
output_per_mtok: typeof row.output_per_mtok === "number" ? row.output_per_mtok : null,
|
|
72
|
+
cache_read_per_mtok:
|
|
73
|
+
typeof row.cache_read_per_mtok === "number" ? row.cache_read_per_mtok : null,
|
|
74
|
+
cache_write_per_mtok:
|
|
75
|
+
typeof row.cache_write_per_mtok === "number" ? row.cache_write_per_mtok : null,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return map;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const CATALOG: Map<string, Price> = buildCatalog();
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Strip a single leading `provider/` segment, char-algorithmically (NO regex).
|
|
85
|
+
* Walks to the first '/'; everything after it is the bare model id. Only the
|
|
86
|
+
* FIRST segment is stripped — `openai/gpt-5` → `gpt-5`, `a/b/c` → `b/c` — so a
|
|
87
|
+
* model id that legitimately contains a slash keeps its remaining segments.
|
|
88
|
+
* Returns null when there is no '/' (caller already tried the raw form).
|
|
89
|
+
*/
|
|
90
|
+
function stripProviderPrefix(id: string): string | null {
|
|
91
|
+
for (let i = 0; i < id.length; i++) {
|
|
92
|
+
if (id.charCodeAt(i) === 47 /* '/' */) {
|
|
93
|
+
// Guard against a leading or trailing slash producing an empty segment.
|
|
94
|
+
if (i === 0 || i === id.length - 1) return null;
|
|
95
|
+
return id.slice(i + 1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** trim + lowercase, char-safe (String.prototype.trim/toLowerCase, no regex). */
|
|
102
|
+
function normalize(id: string): string {
|
|
103
|
+
return id.trim().toLowerCase();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolve a model id to its curated Price, or null on miss.
|
|
108
|
+
* Strategy: exact, then normalized (trim+lowercase), then provider-stripped
|
|
109
|
+
* normalized. Misses return null so the caller can decide (warn / fall back).
|
|
110
|
+
*/
|
|
111
|
+
export function lookupPrice(modelId: string): Price | null {
|
|
112
|
+
if (typeof modelId !== "string" || modelId.length === 0) return null;
|
|
113
|
+
|
|
114
|
+
// 1. Exact — fastest path, covers ids already in canonical form.
|
|
115
|
+
const exact = CATALOG.get(modelId);
|
|
116
|
+
if (exact) return exact;
|
|
117
|
+
|
|
118
|
+
// 2. Normalized — trimmed + lowercased.
|
|
119
|
+
const norm = normalize(modelId);
|
|
120
|
+
const byNorm = CATALOG.get(norm);
|
|
121
|
+
if (byNorm) return byNorm;
|
|
122
|
+
|
|
123
|
+
// 3. Provider-stripped (pi / openclaw / opencode report `provider/model`).
|
|
124
|
+
const bare = stripProviderPrefix(norm);
|
|
125
|
+
if (bare) {
|
|
126
|
+
const byBare = CATALOG.get(bare);
|
|
127
|
+
if (byBare) return byBare;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Price one token bucket. A null bucket rate falls back to the input rate. */
|
|
134
|
+
function bucketCost(tokens: number, rate: number | null, inputRate: number): number {
|
|
135
|
+
if (tokens <= 0) return 0;
|
|
136
|
+
const effective = typeof rate === "number" ? rate : inputRate;
|
|
137
|
+
return tokens * effective;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Σ tokens × per-Mtok price / 1e6 over the four buckets. Returns null when no
|
|
142
|
+
* price is found OR every token count is zero/absent (so dashboards never show
|
|
143
|
+
* a misleading "$0.00 for nothing" row). On a price miss, warns exactly once
|
|
144
|
+
* with the unmatched id so the curated catalog can be extended.
|
|
145
|
+
*
|
|
146
|
+
* Bucket → price mapping:
|
|
147
|
+
* input_tokens → input_per_mtok
|
|
148
|
+
* output_tokens → output_per_mtok (null ⇒ input rate)
|
|
149
|
+
* cache_read_tokens → cache_read_per_mtok (null ⇒ input rate)
|
|
150
|
+
* cache_creation_tokens → cache_write_per_mtok (null ⇒ input rate)
|
|
151
|
+
*/
|
|
152
|
+
export function computeCostUsd(modelId: string, t: TokenCounts): number | null {
|
|
153
|
+
const input = typeof t.input_tokens === "number" ? t.input_tokens : 0;
|
|
154
|
+
const output = typeof t.output_tokens === "number" ? t.output_tokens : 0;
|
|
155
|
+
const cacheRead = typeof t.cache_read_tokens === "number" ? t.cache_read_tokens : 0;
|
|
156
|
+
const cacheCreate =
|
|
157
|
+
typeof t.cache_creation_tokens === "number" ? t.cache_creation_tokens : 0;
|
|
158
|
+
|
|
159
|
+
// All buckets empty ⇒ nothing to price, regardless of model.
|
|
160
|
+
if (input <= 0 && output <= 0 && cacheRead <= 0 && cacheCreate <= 0) return null;
|
|
161
|
+
|
|
162
|
+
const price = lookupPrice(modelId);
|
|
163
|
+
if (!price || typeof price.input_per_mtok !== "number") {
|
|
164
|
+
// Unknown model — emit one line so the id can be added to the catalog.
|
|
165
|
+
console.warn(`[pricing] no curated price for model id: ${modelId}`);
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const inputRate = price.input_per_mtok;
|
|
170
|
+
const microDollars =
|
|
171
|
+
bucketCost(input, inputRate, inputRate) +
|
|
172
|
+
bucketCost(output, price.output_per_mtok, inputRate) +
|
|
173
|
+
bucketCost(cacheRead, price.cache_read_per_mtok, inputRate) +
|
|
174
|
+
bucketCost(cacheCreate, price.cache_write_per_mtok, inputRate);
|
|
175
|
+
|
|
176
|
+
return microDollars / 1_000_000;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Prefer a provider-supplied native cost when present, else compute from the
|
|
181
|
+
* catalog. A native cost of exactly 0 is a real value (free tier) and passes
|
|
182
|
+
* through — only null/undefined defers to computeCostUsd.
|
|
183
|
+
*/
|
|
184
|
+
export function nativeOrComputed(
|
|
185
|
+
modelId: string,
|
|
186
|
+
t: TokenCounts,
|
|
187
|
+
nativeCostUsd?: number | null,
|
|
188
|
+
): number | null {
|
|
189
|
+
if (typeof nativeCostUsd === "number") return nativeCostUsd;
|
|
190
|
+
return computeCostUsd(modelId, t);
|
|
191
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project attribution heuristics for session events.
|
|
3
|
+
*
|
|
4
|
+
* Goal: avoid pinning all activity to the startup directory when work shifts
|
|
5
|
+
* across projects mid-session. This module resolves a best-effort project
|
|
6
|
+
* directory per event and attaches a confidence score + source signal.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { dirname, isAbsolute, normalize, resolve, sep } from "node:path";
|
|
10
|
+
import type { SessionEvent } from "../types.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Confidence scores for project attribution sources.
|
|
14
|
+
*
|
|
15
|
+
* Higher = more reliable signal. The hierarchy reflects how directly
|
|
16
|
+
* the signal indicates the user's intended project:
|
|
17
|
+
* - Explicit config (workspace roots) > explicit navigation (cd) > implicit context
|
|
18
|
+
* - Path-bearing events score higher than fallbacks without path signals
|
|
19
|
+
*/
|
|
20
|
+
export const ATTRIBUTION_CONFIDENCE = {
|
|
21
|
+
/** Explicit workspace root from IDE/editor config */
|
|
22
|
+
WORKSPACE_ROOT: 0.98,
|
|
23
|
+
/** User explicitly navigated here (cd command) */
|
|
24
|
+
CWD_EVENT: 0.9,
|
|
25
|
+
/** Hook payload cwd — reliable but implicit */
|
|
26
|
+
INPUT_CWD: 0.88,
|
|
27
|
+
/** Session startup directory */
|
|
28
|
+
SESSION_ORIGIN: 0.82,
|
|
29
|
+
/** Carry-forward from previous high-confidence event */
|
|
30
|
+
LAST_SEEN: 0.76,
|
|
31
|
+
/** Inferred from file path prefix matching */
|
|
32
|
+
EVENT_PATH: 0.7,
|
|
33
|
+
/** Minimum confidence to carry forward as lastKnownProjectDir */
|
|
34
|
+
CARRY_FORWARD_THRESHOLD: 0.55,
|
|
35
|
+
/** Fallback: input_cwd without path signal */
|
|
36
|
+
FALLBACK_INPUT_CWD: 0.45,
|
|
37
|
+
/** Fallback: last_seen without path signal */
|
|
38
|
+
FALLBACK_LAST_SEEN: 0.4,
|
|
39
|
+
/** Fallback: session_origin without path signal */
|
|
40
|
+
FALLBACK_SESSION_ORIGIN: 0.35,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
export type AttributionSource =
|
|
44
|
+
| "event_path"
|
|
45
|
+
| "cwd_event"
|
|
46
|
+
| "input_cwd"
|
|
47
|
+
| "workspace_root"
|
|
48
|
+
| "last_seen"
|
|
49
|
+
| "session_origin"
|
|
50
|
+
| "env"
|
|
51
|
+
| "test"
|
|
52
|
+
| "unknown"
|
|
53
|
+
| "dsh";
|
|
54
|
+
|
|
55
|
+
export interface ProjectAttribution {
|
|
56
|
+
projectDir: string;
|
|
57
|
+
source: AttributionSource;
|
|
58
|
+
confidence: number; // 0..1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface AttributionContext {
|
|
62
|
+
sessionOriginDir?: string | null;
|
|
63
|
+
inputProjectDir?: string | null;
|
|
64
|
+
workspaceRoots?: string[] | null;
|
|
65
|
+
lastKnownProjectDir?: string | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PathSignal {
|
|
69
|
+
rawPath: string;
|
|
70
|
+
fromCwdEvent: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function normalizePath(path: string): string {
|
|
74
|
+
const norm = normalize(path).replace(/\\/g, "/");
|
|
75
|
+
if (norm.length <= 1) return norm;
|
|
76
|
+
return norm.replace(/\/+$/, "");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isPrefixPath(path: string, prefix: string): boolean {
|
|
80
|
+
if (!path || !prefix) return false;
|
|
81
|
+
if (path === prefix) return true;
|
|
82
|
+
return path.startsWith(`${prefix}/`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeRoots(roots: string[] | null | undefined): string[] {
|
|
86
|
+
if (!roots || roots.length === 0) return [];
|
|
87
|
+
const normalized = roots
|
|
88
|
+
.filter((r): r is string => typeof r === "string" && r.trim().length > 0)
|
|
89
|
+
.map((r) => normalizePath(r));
|
|
90
|
+
|
|
91
|
+
// dedupe + longest-first for stable best match
|
|
92
|
+
const unique = Array.from(new Set(normalized));
|
|
93
|
+
return unique.sort((a, b) => b.length - a.length);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseFileSearchPath(data: string): string | null {
|
|
97
|
+
const marker = " in ";
|
|
98
|
+
const idx = data.lastIndexOf(marker);
|
|
99
|
+
if (idx < 0) return null;
|
|
100
|
+
const path = data.slice(idx + marker.length).trim();
|
|
101
|
+
return path.length > 0 ? path : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function looksLikePath(value: string): boolean {
|
|
105
|
+
if (!value) return false;
|
|
106
|
+
// Fast path-like checks: separators, dot segments, drive roots.
|
|
107
|
+
return value.includes("/")
|
|
108
|
+
|| value.includes("\\")
|
|
109
|
+
|| value.startsWith(".")
|
|
110
|
+
|| /^[A-Za-z]:[\\/]/.test(value);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractPathSignal(event: SessionEvent): PathSignal | null {
|
|
114
|
+
if (event.type === "cwd") {
|
|
115
|
+
return { rawPath: event.data, fromCwdEvent: true };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (event.type === "file_search") {
|
|
119
|
+
const path = parseFileSearchPath(event.data);
|
|
120
|
+
if (path) return { rawPath: path, fromCwdEvent: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const fileTypes = new Set([
|
|
124
|
+
"file_read",
|
|
125
|
+
"file_write",
|
|
126
|
+
"file_edit",
|
|
127
|
+
"file_glob",
|
|
128
|
+
"rule",
|
|
129
|
+
]);
|
|
130
|
+
if (fileTypes.has(event.type) && looksLikePath(event.data)) {
|
|
131
|
+
return { rawPath: event.data, fromCwdEvent: false };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function absolutizePath(rawPath: string, context: AttributionContext): string | null {
|
|
138
|
+
if (!rawPath) return null;
|
|
139
|
+
|
|
140
|
+
// Ignore broad glob-only patterns that aren't useful for attribution.
|
|
141
|
+
if (rawPath.includes("*") && !isAbsolute(rawPath) && !/^[A-Za-z]:[\\/]/.test(rawPath)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isAbsolute(rawPath) || /^[A-Za-z]:[\\/]/.test(rawPath)) {
|
|
146
|
+
return normalizePath(rawPath);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// For relative paths, anchor to the most recent known project first.
|
|
150
|
+
const anchor = context.lastKnownProjectDir
|
|
151
|
+
|| context.inputProjectDir
|
|
152
|
+
|| context.sessionOriginDir
|
|
153
|
+
|| null;
|
|
154
|
+
|
|
155
|
+
if (!anchor) return null;
|
|
156
|
+
return normalizePath(resolve(anchor, rawPath));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function inferProjectFromAbsolutePath(
|
|
160
|
+
absPath: string,
|
|
161
|
+
event: SessionEvent,
|
|
162
|
+
context: AttributionContext,
|
|
163
|
+
): ProjectAttribution {
|
|
164
|
+
const normalizedRoots = normalizeRoots(context.workspaceRoots);
|
|
165
|
+
const normalizedOrigin = context.sessionOriginDir ? normalizePath(context.sessionOriginDir) : "";
|
|
166
|
+
const normalizedInput = context.inputProjectDir ? normalizePath(context.inputProjectDir) : "";
|
|
167
|
+
const normalizedLast = context.lastKnownProjectDir ? normalizePath(context.lastKnownProjectDir) : "";
|
|
168
|
+
|
|
169
|
+
// 1) Prefer explicit workspace roots (highest confidence).
|
|
170
|
+
const workspaceRoot = normalizedRoots.find((root) => isPrefixPath(absPath, root));
|
|
171
|
+
if (workspaceRoot) {
|
|
172
|
+
return { projectDir: workspaceRoot, source: "workspace_root", confidence: ATTRIBUTION_CONFIDENCE.WORKSPACE_ROOT };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 2) Prefer stable known roots from session context.
|
|
176
|
+
if (normalizedInput && isPrefixPath(absPath, normalizedInput)) {
|
|
177
|
+
return { projectDir: normalizedInput, source: "input_cwd", confidence: ATTRIBUTION_CONFIDENCE.INPUT_CWD };
|
|
178
|
+
}
|
|
179
|
+
if (normalizedOrigin && isPrefixPath(absPath, normalizedOrigin)) {
|
|
180
|
+
return { projectDir: normalizedOrigin, source: "session_origin", confidence: ATTRIBUTION_CONFIDENCE.SESSION_ORIGIN };
|
|
181
|
+
}
|
|
182
|
+
if (normalizedLast && isPrefixPath(absPath, normalizedLast)) {
|
|
183
|
+
return { projectDir: normalizedLast, source: "last_seen", confidence: ATTRIBUTION_CONFIDENCE.LAST_SEEN };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// 3) Direct cwd events indicate explicit operator intent to shift project.
|
|
187
|
+
if (event.type === "cwd") {
|
|
188
|
+
return { projectDir: absPath, source: "cwd_event", confidence: ATTRIBUTION_CONFIDENCE.CWD_EVENT };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 4) Fallback for out-of-root absolute paths.
|
|
192
|
+
// For known file events, use parent directory to avoid attributing to a file path.
|
|
193
|
+
const fileLike = new Set(["file_read", "file_write", "file_edit", "rule"]);
|
|
194
|
+
const projectDir = fileLike.has(event.type) ? normalizePath(dirname(absPath)) : absPath;
|
|
195
|
+
return { projectDir, source: "event_path", confidence: ATTRIBUTION_CONFIDENCE.EVENT_PATH };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function fallbackAttribution(context: AttributionContext): ProjectAttribution {
|
|
199
|
+
if (context.inputProjectDir) {
|
|
200
|
+
return {
|
|
201
|
+
projectDir: normalizePath(context.inputProjectDir),
|
|
202
|
+
source: "input_cwd",
|
|
203
|
+
confidence: ATTRIBUTION_CONFIDENCE.FALLBACK_INPUT_CWD,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
if (context.lastKnownProjectDir) {
|
|
207
|
+
return {
|
|
208
|
+
projectDir: normalizePath(context.lastKnownProjectDir),
|
|
209
|
+
source: "last_seen",
|
|
210
|
+
confidence: ATTRIBUTION_CONFIDENCE.FALLBACK_LAST_SEEN,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (context.sessionOriginDir) {
|
|
214
|
+
return {
|
|
215
|
+
projectDir: normalizePath(context.sessionOriginDir),
|
|
216
|
+
source: "session_origin",
|
|
217
|
+
confidence: ATTRIBUTION_CONFIDENCE.FALLBACK_SESSION_ORIGIN,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
return { projectDir: "", source: "unknown", confidence: 0 };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Resolve the most likely project directory for one event.
|
|
225
|
+
*/
|
|
226
|
+
export function resolveProjectAttribution(
|
|
227
|
+
event: SessionEvent,
|
|
228
|
+
context: AttributionContext,
|
|
229
|
+
): ProjectAttribution {
|
|
230
|
+
try {
|
|
231
|
+
const pathSignal = extractPathSignal(event);
|
|
232
|
+
if (!pathSignal) return fallbackAttribution(context);
|
|
233
|
+
|
|
234
|
+
const absPath = absolutizePath(pathSignal.rawPath, context);
|
|
235
|
+
if (!absPath) return fallbackAttribution(context);
|
|
236
|
+
|
|
237
|
+
return inferProjectFromAbsolutePath(absPath, event, context);
|
|
238
|
+
} catch {
|
|
239
|
+
return fallbackAttribution(context);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Convenience helper: resolve attributions for a stream of events while
|
|
245
|
+
* carrying forward the latest confident project as context.
|
|
246
|
+
*/
|
|
247
|
+
export function resolveProjectAttributions(
|
|
248
|
+
events: SessionEvent[],
|
|
249
|
+
context: AttributionContext,
|
|
250
|
+
): ProjectAttribution[] {
|
|
251
|
+
const out: ProjectAttribution[] = [];
|
|
252
|
+
let lastKnown = context.lastKnownProjectDir ? normalizePath(context.lastKnownProjectDir) : "";
|
|
253
|
+
// v1.0.162 Bug 8 — track whether an in-batch CWD-level event has explicitly
|
|
254
|
+
// re-scoped the project. When extract.ts emits a cwd event for `cd /projB`
|
|
255
|
+
// or `git -C /projB ...`, subsequent path-less events in the same batch
|
|
256
|
+
// (e.g. the git operation event itself) currently fall back to the hook's
|
|
257
|
+
// inputProjectDir, which still points at the session startup cwd. The
|
|
258
|
+
// user's INTENTIONAL scoping should win over the hook's startup cwd —
|
|
259
|
+
// shadow inputProjectDir with the carried-forward lastKnown once a high-
|
|
260
|
+
// confidence cwd event has fired in this batch.
|
|
261
|
+
let inBatchCwdScope = false;
|
|
262
|
+
|
|
263
|
+
for (const ev of events) {
|
|
264
|
+
const effectiveInputCwd = inBatchCwdScope ? lastKnown : context.inputProjectDir;
|
|
265
|
+
const attribution = resolveProjectAttribution(ev, {
|
|
266
|
+
...context,
|
|
267
|
+
inputProjectDir: effectiveInputCwd,
|
|
268
|
+
lastKnownProjectDir: lastKnown || context.lastKnownProjectDir || null,
|
|
269
|
+
});
|
|
270
|
+
out.push(attribution);
|
|
271
|
+
|
|
272
|
+
if (attribution.projectDir && attribution.confidence >= ATTRIBUTION_CONFIDENCE.CARRY_FORWARD_THRESHOLD) {
|
|
273
|
+
lastKnown = attribution.projectDir;
|
|
274
|
+
if (attribution.confidence >= ATTRIBUTION_CONFIDENCE.CWD_EVENT) {
|
|
275
|
+
inBatchCwdScope = true;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 0..100 score for UI display.
|
|
285
|
+
*/
|
|
286
|
+
export function confidenceToPercent(confidence: number): number {
|
|
287
|
+
const clamped = Math.max(0, Math.min(1, confidence));
|
|
288
|
+
return Math.round(clamped * 100);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* True when attribution is strong enough for project-level spending claims.
|
|
293
|
+
*/
|
|
294
|
+
export function isHighConfidenceAttribution(confidence: number): boolean {
|
|
295
|
+
return confidence >= 0.8;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Lightweight utility used by some hooks to normalize path separators
|
|
300
|
+
* before writing attribution metadata.
|
|
301
|
+
*/
|
|
302
|
+
export function normalizeProjectDir(projectDir: string): string {
|
|
303
|
+
return normalizePath(projectDir);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export const PROJECT_ATTRIBUTION_VERSION = 1;
|
|
307
|
+
|
|
308
|
+
// Keep explicit references to path separator for bundlers that tree-shake too aggressively.
|
|
309
|
+
void sep;
|