@opencode_weave/weave 0.4.1 → 0.5.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.
@@ -1,3 +1,5 @@
1
1
  export type { WorkState, PlanProgress } from "./types";
2
+ export type { ValidationResult, ValidationIssue, ValidationSeverity, ValidationCategory } from "./validation-types";
2
3
  export { WEAVE_DIR, WORK_STATE_FILE, WORK_STATE_PATH, PLANS_DIR } from "./constants";
3
4
  export { readWorkState, writeWorkState, clearWorkState, appendSessionId, createWorkState, findPlans, getPlanProgress, getPlanName, } from "./storage";
5
+ export { validatePlan } from "./validation";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The 6 validation categories checked by validatePlan().
3
+ */
4
+ export type ValidationCategory = "structure" | "checkboxes" | "file-references" | "numbering" | "effort-estimate" | "verification";
5
+ /** Severity level for a validation issue. */
6
+ export type ValidationSeverity = "error" | "warning";
7
+ /**
8
+ * A single validation issue found in a plan file.
9
+ */
10
+ export interface ValidationIssue {
11
+ severity: ValidationSeverity;
12
+ category: ValidationCategory;
13
+ message: string;
14
+ }
15
+ /**
16
+ * The result of validating a plan file.
17
+ * `valid` is false if there are any blocking errors.
18
+ */
19
+ export interface ValidationResult {
20
+ /** False when there is at least one error (blocking). True when only warnings or clean. */
21
+ valid: boolean;
22
+ /** Blocking issues — prevent /start-work from proceeding */
23
+ errors: ValidationIssue[];
24
+ /** Non-blocking issues — surfaced to user but don't block execution */
25
+ warnings: ValidationIssue[];
26
+ }
@@ -0,0 +1,9 @@
1
+ import type { ValidationResult } from "./validation-types";
2
+ /**
3
+ * Validates a plan file's structure and content before /start-work execution.
4
+ *
5
+ * @param planPath Absolute path to the plan markdown file
6
+ * @param projectDir Absolute path to the project root (used for file-reference checks)
7
+ * @returns ValidationResult with errors (blocking) and warnings (non-blocking)
8
+ */
9
+ export declare function validatePlan(planPath: string, projectDir: string): ValidationResult;
@@ -10,3 +10,5 @@ export { detectKeywords, buildKeywordInjection, processMessageForKeywords, DEFAU
10
10
  export type { KeywordAction } from "./keyword-detector";
11
11
  export { buildVerificationReminder } from "./verification-reminder";
12
12
  export type { VerificationInput, VerificationResult } from "./verification-reminder";
13
+ export { setContextLimit, updateUsage, getState, clearSession as clearTokenSession, clear as clearAllTokenState, } from "./session-token-state";
14
+ export type { SessionTokenEntry } from "./session-token-state";
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Per-session token state tracker.
3
+ *
4
+ * Bridges the gap between:
5
+ * - `chat.params` hook → provides model context limit (maxTokens)
6
+ * - `message.updated` event → provides latest input token usage (usedTokens)
7
+ *
8
+ * Uses an in-memory Map keyed by sessionId. State resets on plugin restart,
9
+ * which is acceptable — data rebuilds on the next `chat.params` + `message.updated` pair.
10
+ */
11
+ export interface SessionTokenEntry {
12
+ maxTokens: number;
13
+ usedTokens: number;
14
+ }
15
+ /**
16
+ * Store the model's context limit for a session.
17
+ * Called from the `chat.params` hook when a session starts.
18
+ * Does NOT overwrite existing `usedTokens`.
19
+ */
20
+ export declare function setContextLimit(sessionId: string, maxTokens: number): void;
21
+ /**
22
+ * Update the latest input token usage for a session.
23
+ * Called from `message.updated` events with AssistantMessage tokens.
24
+ * Stores the latest value — NOT cumulative across messages.
25
+ * Does NOT overwrite existing `maxTokens`.
26
+ * Only updates when inputTokens > 0 (guards against partial streaming updates).
27
+ */
28
+ export declare function updateUsage(sessionId: string, inputTokens: number): void;
29
+ /**
30
+ * Get the current token state for a session.
31
+ * Returns undefined if the session is unknown.
32
+ */
33
+ export declare function getState(sessionId: string): SessionTokenEntry | undefined;
34
+ /**
35
+ * Remove a session from the tracker.
36
+ * Called on `session.deleted` events.
37
+ */
38
+ export declare function clearSession(sessionId: string): void;
39
+ /**
40
+ * Clear all session state. Used in tests only.
41
+ */
42
+ export declare function clear(): void;
@@ -2,6 +2,7 @@
2
2
  * Start-work hook: detects the /start-work command, resolves the target plan,
3
3
  * creates/updates work state, and returns context for injection into the prompt.
4
4
  */
5
+ import type { ValidationResult } from "../features/work-state";
5
6
  export interface StartWorkInput {
6
7
  promptText: string;
7
8
  sessionId: string;
@@ -18,3 +19,7 @@ export interface StartWorkResult {
18
19
  * Returns null contextInjection if this message is not a /start-work command.
19
20
  */
20
21
  export declare function handleStartWork(input: StartWorkInput): StartWorkResult;
22
+ /**
23
+ * Format validation errors and warnings as a markdown string.
24
+ */
25
+ export declare function formatValidationResults(result: ValidationResult): string;
package/dist/index.js CHANGED
@@ -563,7 +563,9 @@ For complex tasks that benefit from structured planning before execution:
563
563
  1. PLAN: Delegate to Pattern to produce a plan saved to \`.weave/plans/{name}.md\`
564
564
  - Pattern researches the codebase, produces a structured plan with \`- [ ]\` checkboxes
565
565
  - Pattern ONLY writes .md files in .weave/ — it never writes code
566
- 2. REVIEW (optional): For complex plans, delegate to Weft to validate the plan before execution
566
+ 2. REVIEW: Delegate to Weft to validate the plan before execution
567
+ - TRIGGER: Plan touches 3+ files OR has 5+ tasks — Weft review is mandatory
568
+ - SKIP ONLY IF: User explicitly says "skip review"
567
569
  - Weft reads the plan, verifies file references, checks executability
568
570
  - If Weft rejects, send issues back to Pattern for revision
569
571
  3. EXECUTE: Tell the user to run \`/start-work\` to begin execution
@@ -1875,6 +1877,286 @@ function getPlanProgress(planPath) {
1875
1877
  function getPlanName(planPath) {
1876
1878
  return basename(planPath, ".md");
1877
1879
  }
1880
+ // src/features/work-state/validation.ts
1881
+ import { readFileSync as readFileSync5, existsSync as existsSync6 } from "fs";
1882
+ import { resolve as resolve2 } from "path";
1883
+ function validatePlan(planPath, projectDir) {
1884
+ const errors = [];
1885
+ const warnings = [];
1886
+ const resolvedPlanPath = resolve2(planPath);
1887
+ const allowedDir = resolve2(projectDir, PLANS_DIR);
1888
+ if (!resolvedPlanPath.startsWith(allowedDir + "/") && resolvedPlanPath !== allowedDir) {
1889
+ errors.push({
1890
+ severity: "error",
1891
+ category: "structure",
1892
+ message: `Plan path is outside the allowed directory (${PLANS_DIR}/): ${planPath}`
1893
+ });
1894
+ return { valid: false, errors, warnings };
1895
+ }
1896
+ if (!existsSync6(resolvedPlanPath)) {
1897
+ errors.push({
1898
+ severity: "error",
1899
+ category: "structure",
1900
+ message: `Plan file not found: ${planPath}`
1901
+ });
1902
+ return { valid: false, errors, warnings };
1903
+ }
1904
+ const content = readFileSync5(resolvedPlanPath, "utf-8");
1905
+ validateStructure(content, errors, warnings);
1906
+ validateCheckboxes(content, errors, warnings);
1907
+ validateFileReferences(content, projectDir, warnings);
1908
+ validateNumbering(content, errors, warnings);
1909
+ validateEffortEstimate(content, warnings);
1910
+ validateVerificationSection(content, errors);
1911
+ return {
1912
+ valid: errors.length === 0,
1913
+ errors,
1914
+ warnings
1915
+ };
1916
+ }
1917
+ function extractSection(content, heading) {
1918
+ const lines = content.split(`
1919
+ `);
1920
+ let startIdx = -1;
1921
+ for (let i = 0;i < lines.length; i++) {
1922
+ if (lines[i].trim() === heading) {
1923
+ startIdx = i + 1;
1924
+ break;
1925
+ }
1926
+ }
1927
+ if (startIdx === -1)
1928
+ return null;
1929
+ const sectionLines = [];
1930
+ for (let i = startIdx;i < lines.length; i++) {
1931
+ if (/^## /.test(lines[i]))
1932
+ break;
1933
+ sectionLines.push(lines[i]);
1934
+ }
1935
+ return sectionLines.join(`
1936
+ `);
1937
+ }
1938
+ function hasSection(content, heading) {
1939
+ return content.split(`
1940
+ `).some((line) => line.trim() === heading);
1941
+ }
1942
+ function validateStructure(content, errors, warnings) {
1943
+ const requiredSections = [
1944
+ ["## TL;DR", "Missing required section: ## TL;DR"],
1945
+ ["## TODOs", "Missing required section: ## TODOs"],
1946
+ ["## Verification", "Missing required section: ## Verification"]
1947
+ ];
1948
+ for (const [heading, message] of requiredSections) {
1949
+ if (!hasSection(content, heading)) {
1950
+ errors.push({ severity: "error", category: "structure", message });
1951
+ }
1952
+ }
1953
+ const optionalSections = [
1954
+ ["## Context", "Missing optional section: ## Context"],
1955
+ ["## Objectives", "Missing optional section: ## Objectives"]
1956
+ ];
1957
+ for (const [heading, message] of optionalSections) {
1958
+ if (!hasSection(content, heading)) {
1959
+ warnings.push({ severity: "warning", category: "structure", message });
1960
+ }
1961
+ }
1962
+ }
1963
+ function validateCheckboxes(content, errors, warnings) {
1964
+ const todosSection = extractSection(content, "## TODOs");
1965
+ if (todosSection === null) {
1966
+ return;
1967
+ }
1968
+ const checkboxPattern = /^- \[[ x]\] /m;
1969
+ if (!checkboxPattern.test(todosSection)) {
1970
+ errors.push({
1971
+ severity: "error",
1972
+ category: "checkboxes",
1973
+ message: "## TODOs section contains no checkboxes (- [ ] or - [x])"
1974
+ });
1975
+ return;
1976
+ }
1977
+ const lines = todosSection.split(`
1978
+ `);
1979
+ let taskIndex = 0;
1980
+ for (let i = 0;i < lines.length; i++) {
1981
+ const line = lines[i];
1982
+ if (/^- \[[ x]\] /.test(line)) {
1983
+ taskIndex++;
1984
+ const taskLabel = `Task ${taskIndex}`;
1985
+ const bodyLines = [];
1986
+ let j = i + 1;
1987
+ while (j < lines.length && !/^- \[[ x]\] /.test(lines[j])) {
1988
+ bodyLines.push(lines[j]);
1989
+ j++;
1990
+ }
1991
+ const body = bodyLines.join(`
1992
+ `);
1993
+ if (!/\*\*What\*\*/.test(body)) {
1994
+ warnings.push({
1995
+ severity: "warning",
1996
+ category: "checkboxes",
1997
+ message: `${taskLabel} is missing **What** sub-field`
1998
+ });
1999
+ }
2000
+ if (!/\*\*Files\*\*/.test(body)) {
2001
+ warnings.push({
2002
+ severity: "warning",
2003
+ category: "checkboxes",
2004
+ message: `${taskLabel} is missing **Files** sub-field`
2005
+ });
2006
+ }
2007
+ if (!/\*\*Acceptance\*\*/.test(body)) {
2008
+ warnings.push({
2009
+ severity: "warning",
2010
+ category: "checkboxes",
2011
+ message: `${taskLabel} is missing **Acceptance** sub-field`
2012
+ });
2013
+ }
2014
+ }
2015
+ }
2016
+ }
2017
+ var NEW_FILE_INDICATORS = [
2018
+ /^\s*create\s+/i,
2019
+ /^\s*new:\s*/i,
2020
+ /\(new\)/i,
2021
+ /^\s*add\s+/i
2022
+ ];
2023
+ function isNewFile(rawPath) {
2024
+ return NEW_FILE_INDICATORS.some((re) => re.test(rawPath));
2025
+ }
2026
+ function extractFilePath(raw) {
2027
+ let cleaned = raw.replace(/^\s*(create|modify|new:|add)\s+/i, "").replace(/\(new\)/gi, "").trim();
2028
+ const firstToken = cleaned.split(/\s+/)[0];
2029
+ if (firstToken && (firstToken.includes("/") || firstToken.endsWith(".ts") || firstToken.endsWith(".js") || firstToken.endsWith(".json") || firstToken.endsWith(".md"))) {
2030
+ cleaned = firstToken;
2031
+ } else if (!cleaned.includes("/")) {
2032
+ return null;
2033
+ }
2034
+ return cleaned || null;
2035
+ }
2036
+ function validateFileReferences(content, projectDir, warnings) {
2037
+ const todosSection = extractSection(content, "## TODOs");
2038
+ if (todosSection === null)
2039
+ return;
2040
+ const lines = todosSection.split(`
2041
+ `);
2042
+ for (const line of lines) {
2043
+ const filesMatch = /^\s*\*\*Files\*\*:?\s*(.+)$/.exec(line);
2044
+ if (!filesMatch)
2045
+ continue;
2046
+ const rawValue = filesMatch[1].trim();
2047
+ const parts = rawValue.split(",");
2048
+ for (const part of parts) {
2049
+ const trimmed = part.trim();
2050
+ if (!trimmed)
2051
+ continue;
2052
+ const newFile = isNewFile(trimmed);
2053
+ const filePath = extractFilePath(trimmed);
2054
+ if (!filePath)
2055
+ continue;
2056
+ if (newFile)
2057
+ continue;
2058
+ if (filePath.startsWith("/")) {
2059
+ warnings.push({
2060
+ severity: "warning",
2061
+ category: "file-references",
2062
+ message: `Absolute file path not allowed in plan references: ${filePath}`
2063
+ });
2064
+ continue;
2065
+ }
2066
+ const resolvedProject = resolve2(projectDir);
2067
+ const absolutePath = resolve2(projectDir, filePath);
2068
+ if (!absolutePath.startsWith(resolvedProject + "/") && absolutePath !== resolvedProject) {
2069
+ warnings.push({
2070
+ severity: "warning",
2071
+ category: "file-references",
2072
+ message: `File reference escapes project directory (path traversal): ${filePath}`
2073
+ });
2074
+ continue;
2075
+ }
2076
+ if (!existsSync6(absolutePath)) {
2077
+ warnings.push({
2078
+ severity: "warning",
2079
+ category: "file-references",
2080
+ message: `Referenced file does not exist (may be created by an earlier task): ${filePath}`
2081
+ });
2082
+ }
2083
+ }
2084
+ }
2085
+ }
2086
+ function validateNumbering(content, errors, warnings) {
2087
+ const todosSection = extractSection(content, "## TODOs");
2088
+ if (todosSection === null)
2089
+ return;
2090
+ const numbers = [];
2091
+ const seen = new Set;
2092
+ for (const line of todosSection.split(`
2093
+ `)) {
2094
+ const match = /^- \[[ x]\] (\d+)\./.exec(line);
2095
+ if (!match)
2096
+ continue;
2097
+ const n = parseInt(match[1], 10);
2098
+ if (seen.has(n)) {
2099
+ errors.push({
2100
+ severity: "error",
2101
+ category: "numbering",
2102
+ message: `Duplicate task number: ${n}`
2103
+ });
2104
+ } else {
2105
+ seen.add(n);
2106
+ numbers.push(n);
2107
+ }
2108
+ }
2109
+ if (numbers.length < 2)
2110
+ return;
2111
+ const sorted = [...numbers].sort((a, b) => a - b);
2112
+ for (let i = 1;i < sorted.length; i++) {
2113
+ if (sorted[i] !== sorted[i - 1] + 1) {
2114
+ warnings.push({
2115
+ severity: "warning",
2116
+ category: "numbering",
2117
+ message: `Gap in task numbering: expected ${sorted[i - 1] + 1} but found ${sorted[i]}`
2118
+ });
2119
+ }
2120
+ }
2121
+ }
2122
+ var VALID_EFFORT_VALUES = ["quick", "short", "medium", "large", "xl"];
2123
+ function validateEffortEstimate(content, warnings) {
2124
+ const tldrSection = extractSection(content, "## TL;DR");
2125
+ if (tldrSection === null) {
2126
+ return;
2127
+ }
2128
+ const effortMatch = /\*\*Estimated Effort\*\*:?\s*(.+)/i.exec(tldrSection);
2129
+ if (!effortMatch) {
2130
+ warnings.push({
2131
+ severity: "warning",
2132
+ category: "effort-estimate",
2133
+ message: "Missing **Estimated Effort** in ## TL;DR section"
2134
+ });
2135
+ return;
2136
+ }
2137
+ const value = effortMatch[1].trim().toLowerCase();
2138
+ if (!VALID_EFFORT_VALUES.includes(value)) {
2139
+ warnings.push({
2140
+ severity: "warning",
2141
+ category: "effort-estimate",
2142
+ message: `Invalid effort estimate value: "${effortMatch[1].trim()}". Expected one of: Quick, Short, Medium, Large, XL`
2143
+ });
2144
+ }
2145
+ }
2146
+ function validateVerificationSection(content, errors) {
2147
+ const verificationSection = extractSection(content, "## Verification");
2148
+ if (verificationSection === null) {
2149
+ return;
2150
+ }
2151
+ const hasCheckbox = /^- \[[ x]\] /m.test(verificationSection);
2152
+ if (!hasCheckbox) {
2153
+ errors.push({
2154
+ severity: "error",
2155
+ category: "verification",
2156
+ message: "## Verification section contains no checkboxes — at least one verifiable condition is required"
2157
+ });
2158
+ }
2159
+ }
1878
2160
  // src/hooks/start-work-hook.ts
1879
2161
  function handleStartWork(input) {
1880
2162
  const { promptText, sessionId, directory } = input;
@@ -1890,10 +2172,33 @@ function handleStartWork(input) {
1890
2172
  if (existingState) {
1891
2173
  const progress = getPlanProgress(existingState.active_plan);
1892
2174
  if (!progress.isComplete) {
2175
+ const validation = validatePlan(existingState.active_plan, directory);
2176
+ if (!validation.valid) {
2177
+ clearWorkState(directory);
2178
+ return {
2179
+ switchAgent: "tapestry",
2180
+ contextInjection: `## Plan Validation Failed
2181
+ The active plan "${existingState.plan_name}" has structural issues. Work state has been cleared.
2182
+
2183
+ ${formatValidationResults(validation)}
2184
+
2185
+ Tell the user to fix the plan file and run /start-work again.`
2186
+ };
2187
+ }
1893
2188
  appendSessionId(directory, sessionId);
2189
+ const resumeContext = buildResumeContext(existingState.active_plan, existingState.plan_name, progress);
2190
+ if (validation.warnings.length > 0) {
2191
+ return {
2192
+ switchAgent: "tapestry",
2193
+ contextInjection: `${resumeContext}
2194
+
2195
+ ### Validation Warnings
2196
+ ${formatValidationResults(validation)}`
2197
+ };
2198
+ }
1894
2199
  return {
1895
2200
  switchAgent: "tapestry",
1896
- contextInjection: buildResumeContext(existingState.active_plan, existingState.plan_name, progress)
2201
+ contextInjection: resumeContext
1897
2202
  };
1898
2203
  }
1899
2204
  }
@@ -1932,12 +2237,34 @@ The plan "${getPlanName(matched)}" has all ${progress.total} tasks completed.
1932
2237
  Tell the user this plan is already done and suggest creating a new one with Pattern.`
1933
2238
  };
1934
2239
  }
2240
+ const validation = validatePlan(matched, directory);
2241
+ if (!validation.valid) {
2242
+ return {
2243
+ switchAgent: "tapestry",
2244
+ contextInjection: `## Plan Validation Failed
2245
+ The plan "${getPlanName(matched)}" has structural issues that must be fixed before execution can begin.
2246
+
2247
+ ${formatValidationResults(validation)}
2248
+
2249
+ Tell the user to fix these issues in the plan file and try again.`
2250
+ };
2251
+ }
1935
2252
  clearWorkState(directory);
1936
2253
  const state = createWorkState(matched, sessionId, "tapestry");
1937
2254
  writeWorkState(directory, state);
2255
+ const freshContext = buildFreshContext(matched, getPlanName(matched), progress);
2256
+ if (validation.warnings.length > 0) {
2257
+ return {
2258
+ switchAgent: "tapestry",
2259
+ contextInjection: `${freshContext}
2260
+
2261
+ ### Validation Warnings
2262
+ ${formatValidationResults(validation)}`
2263
+ };
2264
+ }
1938
2265
  return {
1939
2266
  switchAgent: "tapestry",
1940
- contextInjection: buildFreshContext(matched, getPlanName(matched), progress)
2267
+ contextInjection: freshContext
1941
2268
  };
1942
2269
  }
1943
2270
  function handlePlanDiscovery(allPlans, sessionId, directory) {
@@ -1959,11 +2286,33 @@ Tell the user to switch to Pattern agent to create a new plan.`
1959
2286
  if (incompletePlans.length === 1) {
1960
2287
  const plan = incompletePlans[0];
1961
2288
  const progress = getPlanProgress(plan);
2289
+ const validation = validatePlan(plan, directory);
2290
+ if (!validation.valid) {
2291
+ return {
2292
+ switchAgent: "tapestry",
2293
+ contextInjection: `## Plan Validation Failed
2294
+ The plan "${getPlanName(plan)}" has structural issues that must be fixed before execution can begin.
2295
+
2296
+ ${formatValidationResults(validation)}
2297
+
2298
+ Tell the user to fix these issues in the plan file and try again.`
2299
+ };
2300
+ }
1962
2301
  const state = createWorkState(plan, sessionId, "tapestry");
1963
2302
  writeWorkState(directory, state);
2303
+ const freshContext = buildFreshContext(plan, getPlanName(plan), progress);
2304
+ if (validation.warnings.length > 0) {
2305
+ return {
2306
+ switchAgent: "tapestry",
2307
+ contextInjection: `${freshContext}
2308
+
2309
+ ### Validation Warnings
2310
+ ${formatValidationResults(validation)}`
2311
+ };
2312
+ }
1964
2313
  return {
1965
2314
  switchAgent: "tapestry",
1966
- contextInjection: buildFreshContext(plan, getPlanName(plan), progress)
2315
+ contextInjection: freshContext
1967
2316
  };
1968
2317
  }
1969
2318
  const listing = incompletePlans.map((p) => {
@@ -1988,6 +2337,25 @@ function findPlanByName(plans, requestedName) {
1988
2337
  const partial = plans.find((p) => getPlanName(p).toLowerCase().includes(lower));
1989
2338
  return partial || null;
1990
2339
  }
2340
+ function formatValidationResults(result) {
2341
+ const lines = [];
2342
+ if (result.errors.length > 0) {
2343
+ lines.push("**Errors (blocking):**");
2344
+ for (const err of result.errors) {
2345
+ lines.push(`- [${err.category}] ${err.message}`);
2346
+ }
2347
+ }
2348
+ if (result.warnings.length > 0) {
2349
+ if (result.errors.length > 0)
2350
+ lines.push("");
2351
+ lines.push("**Warnings:**");
2352
+ for (const warn of result.warnings) {
2353
+ lines.push(`- [${warn.category}] ${warn.message}`);
2354
+ }
2355
+ }
2356
+ return lines.join(`
2357
+ `);
2358
+ }
1991
2359
  function buildFreshContext(planPath, planName, progress) {
1992
2360
  return `## Starting Plan: ${planName}
1993
2361
  **Plan file**: ${planPath}
@@ -2074,12 +2442,12 @@ Only mark complete when ALL checks pass.`
2074
2442
 
2075
2443
  // src/hooks/create-hooks.ts
2076
2444
  function createHooks(args) {
2077
- const { isHookEnabled, directory } = args;
2445
+ const { pluginConfig, isHookEnabled, directory } = args;
2078
2446
  const writeGuardState = createWriteGuardState();
2079
2447
  const writeGuard = createWriteGuard(writeGuardState);
2080
2448
  const contextWindowThresholds = {
2081
- warningPct: 0.8,
2082
- criticalPct: 0.95
2449
+ warningPct: pluginConfig.experimental?.context_window_warning_threshold ?? 0.8,
2450
+ criticalPct: pluginConfig.experimental?.context_window_critical_threshold ?? 0.95
2083
2451
  };
2084
2452
  return {
2085
2453
  checkContextWindow: isHookEnabled("context-window-monitor") ? (state) => checkContextWindow(state, contextWindowThresholds) : null,
@@ -2094,7 +2462,30 @@ function createHooks(args) {
2094
2462
  verificationReminder: isHookEnabled("verification-reminder") ? buildVerificationReminder : null
2095
2463
  };
2096
2464
  }
2097
-
2465
+ // src/hooks/session-token-state.ts
2466
+ var sessionMap = new Map;
2467
+ function setContextLimit(sessionId, maxTokens) {
2468
+ const existing = sessionMap.get(sessionId);
2469
+ sessionMap.set(sessionId, {
2470
+ usedTokens: existing?.usedTokens ?? 0,
2471
+ maxTokens
2472
+ });
2473
+ }
2474
+ function updateUsage(sessionId, inputTokens) {
2475
+ if (inputTokens <= 0)
2476
+ return;
2477
+ const existing = sessionMap.get(sessionId);
2478
+ sessionMap.set(sessionId, {
2479
+ maxTokens: existing?.maxTokens ?? 0,
2480
+ usedTokens: inputTokens
2481
+ });
2482
+ }
2483
+ function getState(sessionId) {
2484
+ return sessionMap.get(sessionId);
2485
+ }
2486
+ function clearSession2(sessionId) {
2487
+ sessionMap.delete(sessionId);
2488
+ }
2098
2489
  // src/plugin/plugin-interface.ts
2099
2490
  function createPluginInterface(args) {
2100
2491
  const { pluginConfig, hooks, tools, configHandler, agents, client } = args;
@@ -2114,13 +2505,6 @@ function createPluginInterface(args) {
2114
2505
  },
2115
2506
  "chat.message": async (input, _output) => {
2116
2507
  const { sessionID } = input;
2117
- if (hooks.checkContextWindow) {
2118
- hooks.checkContextWindow({
2119
- usedTokens: 0,
2120
- maxTokens: 0,
2121
- sessionId: sessionID
2122
- });
2123
- }
2124
2508
  if (hooks.firstMessageVariant) {
2125
2509
  if (hooks.firstMessageVariant.shouldApplyVariant(sessionID)) {
2126
2510
  hooks.firstMessageVariant.markApplied(sessionID);
@@ -2159,7 +2543,15 @@ ${result.contextInjection}`;
2159
2543
  }
2160
2544
  }
2161
2545
  },
2162
- "chat.params": async (_input, _output) => {},
2546
+ "chat.params": async (_input, _output) => {
2547
+ const input = _input;
2548
+ const sessionId = input.sessionID ?? "";
2549
+ const maxTokens = input.model?.limit?.context ?? 0;
2550
+ if (sessionId && maxTokens > 0) {
2551
+ setContextLimit(sessionId, maxTokens);
2552
+ log("[context-window] Captured context limit", { sessionId, maxTokens });
2553
+ }
2554
+ },
2163
2555
  "chat.headers": async (_input, _output) => {},
2164
2556
  event: async (input) => {
2165
2557
  const { event } = input;
@@ -2173,6 +2565,35 @@ ${result.contextInjection}`;
2173
2565
  hooks.firstMessageVariant.clearSession(evt.properties.info.id);
2174
2566
  }
2175
2567
  }
2568
+ if (event.type === "session.deleted") {
2569
+ const evt = event;
2570
+ clearSession2(evt.properties.info.id);
2571
+ }
2572
+ if (event.type === "message.updated" && hooks.checkContextWindow) {
2573
+ const evt = event;
2574
+ const info = evt.properties?.info;
2575
+ if (info?.role === "assistant" && info.sessionID) {
2576
+ const inputTokens = info.tokens?.input ?? 0;
2577
+ if (inputTokens > 0) {
2578
+ updateUsage(info.sessionID, inputTokens);
2579
+ const tokenState = getState(info.sessionID);
2580
+ if (tokenState && tokenState.maxTokens > 0) {
2581
+ const result = hooks.checkContextWindow({
2582
+ usedTokens: tokenState.usedTokens,
2583
+ maxTokens: tokenState.maxTokens,
2584
+ sessionId: info.sessionID
2585
+ });
2586
+ if (result.action !== "none") {
2587
+ log("[context-window] Threshold crossed", {
2588
+ sessionId: info.sessionID,
2589
+ action: result.action,
2590
+ usagePct: result.usagePct
2591
+ });
2592
+ }
2593
+ }
2594
+ }
2595
+ }
2596
+ }
2176
2597
  if (hooks.workContinuation && event.type === "session.idle") {
2177
2598
  const evt = event;
2178
2599
  const sessionId = evt.properties?.sessionID ?? "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode_weave/weave",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Weave — lean OpenCode plugin with multi-agent orchestration",
5
5
  "author": "Weave",
6
6
  "license": "MIT",