Skip to content

Commit de1dce0

Browse files
stevelucclaude
andauthored
Add NFA completions, cache improvements, and calendar browser embedding (#1932)
## Summary - **NFA-based grammar completions**: Token-boundary completion system where the shell requests completions only at word boundaries (spaces) and filters locally between boundaries. Includes token-over-wildcard preference in NFA walk, backspace recovery for mid-word filtering, and property completions for checked wildcards. - **Grammar cache improvements**: Strip optional LLM-inferred parameters (not present in user request text) from cache population instead of rejecting the entire rule. Required parameters not in the request still reject. - **Calendar embedded browser**: Google Calendar event links now open in the embedded browser tab (target="_blank") instead of the system browser, matching the email link behavior. - **Shell UI**: Autocomplete dropdown styling with blur backdrop, dark mode support, and search menu visual improvements. - **Policy and formatting**: Browser package.json sort fix, prettier formatting across packages. ## Test plan - [ ] Type characters and verify completions appear at token boundaries (after spaces) - [ ] Verify backspace recovers the completion menu (e.g., "play the " → backspace → menu reappears) - [ ] Confirm grammar cache rules generate successfully for requests with optional inferred parameters (golden roadrunner) - [ ] Open a Google Calendar event link and verify it opens in the embedded browser tab - [ ] Verify `@` command completions still work - [ ] Run `pnpm run build` — all packages compile - [ ] Run policy check — passes 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f40574d commit de1dce0

37 files changed

Lines changed: 1184 additions & 633 deletions

ts/packages/actionGrammar/src/agentGrammarRegistry.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,22 @@ export class AgentGrammar {
6262
} {
6363
const errors: string[] = [];
6464

65+
// Prepend entity declarations so generated rules can reference
66+
// entity types like CalendarDate, CalendarTime, Ordinal, etc.
67+
// Collect from both the existing grammar and the global registry.
68+
const entityNames = new Set<string>(this.grammar.entities || []);
69+
for (const name of globalEntityRegistry.getEntityNames()) {
70+
entityNames.add(name);
71+
}
72+
let fullAgrText = agrText;
73+
if (entityNames.size > 0) {
74+
fullAgrText = `entity ${[...entityNames].join(", ")};\n${agrText}`;
75+
}
76+
6577
// Parse the generated rules
6678
const newGrammar = loadGrammarRules(
6779
`<generated-${this.agentId}>`,
68-
agrText,
80+
fullAgrText,
6981
errors,
7082
);
7183

ts/packages/actionGrammar/src/generation/index.ts

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,34 @@ import { ClaudeGrammarGenerator, GrammarAnalysis } from "./grammarGenerator.js";
5656
import { loadSchemaInfo } from "./schemaReader.js";
5757
import { GrammarTestCase } from "./testTypes.js";
5858

59+
/**
60+
* Check if a parameter value appears in the normalized request text.
61+
* Short values (< 6 chars) are assumed to be in the request to avoid
62+
* false rejections for things like years, IDs, short names.
63+
*/
64+
function isValueInRequest(paramValue: any, normalizedRequest: string): boolean {
65+
if (typeof paramValue === "string") {
66+
if (paramValue.length < 6) return true;
67+
const normalizedValue = paramValue
68+
.toLowerCase()
69+
.replace(/[^\w\s]/g, " ");
70+
return normalizedRequest.includes(normalizedValue);
71+
} else if (Array.isArray(paramValue)) {
72+
for (const item of paramValue) {
73+
if (typeof item === "string" && item.length >= 6) {
74+
const normalizedItem = item
75+
.toLowerCase()
76+
.replace(/[^\w\s]/g, " ");
77+
if (!normalizedRequest.includes(normalizedItem)) {
78+
return false;
79+
}
80+
}
81+
}
82+
return true;
83+
}
84+
return true; // Non-string, non-array values (numbers, booleans) are fine
85+
}
86+
5987
/**
6088
* Convert plural parameter names to singular for grammar variable names
6189
* e.g., "artists" -> "artist"
@@ -122,43 +150,36 @@ export async function populateCache(
122150
// Load schema information
123151
const schemaInfo = loadSchemaInfo(request.schemaPath);
124152

125-
// Validate that parameter values appear in the request (prevents caching LLM corrections)
126-
// Skip validation for short values (< 6 chars) like years, IDs that LLM infers
153+
// Validate that parameter values appear in the request.
154+
// If a value was inferred by the LLM (not in the request), strip it
155+
// from the action if it's optional in the schema; reject if required.
127156
const normalizedRequest = request.request
128157
.toLowerCase()
129158
.replace(/[^\w\s]/g, " ");
159+
const actionInfo = schemaInfo.actions.get(request.action.actionName);
160+
const strippedParams: string[] = [];
130161
for (const [paramName, paramValue] of Object.entries(
131162
request.action.parameters,
132163
)) {
133-
if (typeof paramValue === "string") {
134-
// Skip short values that are likely inferred (year, ID, etc) not corrections
135-
if (paramValue.length >= 6) {
136-
const normalizedValue = paramValue
137-
.toLowerCase()
138-
.replace(/[^\w\s]/g, " ");
139-
if (!normalizedRequest.includes(normalizedValue)) {
140-
return {
141-
success: false,
142-
rejectionReason: `Parameter '${paramName}' value "${paramValue}" not found in request (possible LLM correction - don't cache)`,
143-
};
144-
}
145-
}
146-
} else if (Array.isArray(paramValue)) {
147-
for (const item of paramValue) {
148-
if (typeof item === "string" && item.length >= 6) {
149-
const normalizedItem = item
150-
.toLowerCase()
151-
.replace(/[^\w\s]/g, " ");
152-
if (!normalizedRequest.includes(normalizedItem)) {
153-
return {
154-
success: false,
155-
rejectionReason: `Parameter '${paramName}' array item "${item}" not found in request (possible LLM correction - don't cache)`,
156-
};
157-
}
158-
}
164+
const isInRequest = isValueInRequest(paramValue, normalizedRequest);
165+
if (!isInRequest) {
166+
const paramInfo = actionInfo?.parameters.get(paramName);
167+
if (paramInfo?.optional) {
168+
// Optional parameter inferred by LLM — strip it
169+
strippedParams.push(paramName);
170+
} else {
171+
// Required parameter not in request — reject
172+
return {
173+
success: false,
174+
rejectionReason: `Required parameter '${paramName}' value "${paramValue}" not found in request (possible LLM correction - don't cache)`,
175+
};
159176
}
160177
}
161178
}
179+
// Remove inferred optional parameters from the action
180+
for (const paramName of strippedParams) {
181+
delete request.action.parameters[paramName];
182+
}
162183

163184
// Create test case from request
164185
const testCase: GrammarTestCase = {
@@ -189,7 +210,6 @@ export async function populateCache(
189210

190211
// Extract checked variables from the action parameters
191212
const checkedVariables = new Set<string>();
192-
const actionInfo = schemaInfo.actions.get(testCase.action.actionName);
193213
if (actionInfo) {
194214
for (const [paramName, paramInfo] of actionInfo.parameters) {
195215
if (paramInfo.paramSpec === "checked_wildcard") {

ts/packages/actionGrammar/src/generation/schemaReader.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface ParameterValidationInfo {
1717
paramSpec?: ParamSpec; // e.g., "checked_wildcard", "ordinal", "number", etc.
1818
entityTypeName?: string; // e.g., "MusicDevice" if the parameter references an entity type
1919
isEntityType: boolean;
20+
optional?: boolean; // Whether the parameter is optional in the schema
2021
}
2122

2223
/**
@@ -91,6 +92,7 @@ export function loadSchemaInfo(pasJsonPath: string): SchemaInfo {
9192
const validationInfo: ParameterValidationInfo = {
9293
parameterName: paramName,
9394
isEntityType: false,
95+
optional: field.optional === true,
9496
};
9597

9698
// Check if this parameter has a paramSpec

ts/packages/actionGrammar/src/grammarCompiler.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,21 @@ function createCompileContext(
9393
fileUtils: FileLoader | undefined,
9494
definitions: RuleDefinition[],
9595
imports?: ImportStatement[],
96+
entityNames?: string[],
9697
): CompileContext {
9798
const ruleDefMap: DefinitionMap = new Map();
9899

99100
// Build separate sets of imported rule names and type names
100101
const importedRuleMap = new Map<string, CompileContext>();
101102
const importedTypeNames = new Set<string>();
102103

104+
// Entity declarations (e.g., "entity CalendarDate;") are valid type names
105+
if (entityNames) {
106+
for (const name of entityNames) {
107+
importedTypeNames.add(name);
108+
}
109+
}
110+
103111
// Create the context early and add to the map BEFORE processing anything
104112
// This prevents infinite recursion on circular dependencies
105113
const context: CompileContext = {
@@ -191,6 +199,7 @@ export function compileGrammar(
191199
definitions: RuleDefinition[],
192200
start: string,
193201
imports?: ImportStatement[],
202+
entityNames?: string[],
194203
): GrammarCompileResult {
195204
const grammarFileMap = new Map<string, CompileContext>();
196205
const context = createCompileContext(
@@ -200,6 +209,7 @@ export function compileGrammar(
200209
fileUtils,
201210
definitions,
202211
imports,
212+
entityNames,
203213
);
204214

205215
const grammar = { rules: createNamedGrammarRules(context, start) };

ts/packages/actionGrammar/src/grammarLoader.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export function loadGrammarRules(
6565
parseResult.definitions,
6666
start,
6767
parseResult.imports,
68+
parseResult.entities.length > 0 ? parseResult.entities : undefined,
6869
);
6970

7071
if (result.warnings.length > 0 && warnings !== undefined) {

ts/packages/actionGrammar/src/grammarMatcher.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,25 @@ export function matchGrammar(grammar: Grammar, request: string) {
766766
return results;
767767
}
768768

769+
/**
770+
* Check if the remaining input text is a case-insensitive prefix of a rule's
771+
* string part. Used for completions when the user has partially typed a keyword.
772+
* For example, prefix "p" should match and complete "play".
773+
*/
774+
function isPartialPrefixOfStringPart(
775+
prefix: string,
776+
index: number,
777+
part: StringPart,
778+
): boolean {
779+
// Get the remaining text after any leading separators
780+
const remaining = prefix.slice(index).trimStart().toLowerCase();
781+
if (remaining.length === 0) {
782+
return false; // No partial text - handled by the normal completion path
783+
}
784+
const partText = part.value.join(" ").toLowerCase();
785+
return partText.startsWith(remaining) && remaining.length < partText.length;
786+
}
787+
769788
export function matchGrammarCompletion(
770789
grammar: Grammar,
771790
prefix: string,
@@ -796,8 +815,8 @@ export function matchGrammarCompletion(
796815
completions.push(nextPart.value.join(" "));
797816
}
798817
} else {
799-
// We can't finalize the state because of empty pending wildcard.
800-
// Return a completion property.
818+
// We can't finalize the state because of empty pending wildcard
819+
// or because there's trailing unmatched text.
801820
const pendingWildcard = state.pendingWildcard;
802821
if (pendingWildcard !== undefined) {
803822
debugCompletion("Completing wildcard part");
@@ -811,6 +830,26 @@ export function matchGrammarCompletion(
811830
);
812831
properties.push(completionProperty);
813832
}
833+
} else if (!matched) {
834+
// matchState failed on a string part and there's trailing text.
835+
// Check if the remaining input is a partial prefix of the
836+
// current string part (e.g. "p" is a prefix of "play").
837+
const currentPart = state.rule.parts[state.partIndex];
838+
if (
839+
currentPart !== undefined &&
840+
currentPart.type === "string" &&
841+
isPartialPrefixOfStringPart(
842+
prefix,
843+
state.index,
844+
currentPart,
845+
)
846+
) {
847+
const fullText = currentPart.value.join(" ");
848+
debugCompletion(
849+
`Adding partial prefix completion: "${fullText}"`,
850+
);
851+
completions.push(fullText);
852+
}
814853
}
815854
}
816855
}

ts/packages/actionGrammar/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export type { GrammarJson, Grammar } from "./grammarTypes.js";
55
export { grammarFromJson } from "./grammarDeserializer.js";
66
export { grammarToJson } from "./grammarSerializer.js";
77
export { loadGrammarRules } from "./grammarLoader.js";
8+
export { parseGrammarRules } from "./grammarRuleParser.js";
9+
export { compileGrammar } from "./grammarCompiler.js";
810
export {
911
matchGrammar,
1012
GrammarMatchResult,
@@ -80,6 +82,7 @@ export {
8082
tokenizeRequest,
8183
type NFAGrammarMatchResult,
8284
} from "./nfaMatcher.js";
85+
export { computeNFACompletions } from "./nfaCompletion.js";
8386

8487
// DFA system
8588
export type {

ts/packages/actionGrammar/src/nfa.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ export interface NFATransition {
3030
typeName?: string | undefined;
3131
checked?: boolean | undefined; // true if wildcard has validation (entity type or checked_wildcard paramSpec)
3232

33+
// For checked wildcard transitions: completion metadata (set at compile time)
34+
// Allows completion to return property info without runtime action value evaluation
35+
actionName?: string | undefined; // e.g., "play"
36+
propertyPath?: string | undefined; // e.g., "parameters.songName"
37+
3338
// Slot assignment for the new environment system
3439
// When set, the captured value is written to this slot index
3540
slotIndex?: number | undefined;
@@ -104,6 +109,11 @@ export interface NFAState {
104109
// For nested rule references: which slot in the parent environment to write the result to
105110
// Set on states that enter a nested rule
106111
parentSlotIndex?: number | undefined;
112+
113+
// Completion metadata for nested rule entry states
114+
// Set when this state is the entry point of a nested rules reference that captures a property
115+
completionActionName?: string | undefined;
116+
completionPropertyPath?: string | undefined;
107117
}
108118

109119
/**
@@ -150,12 +160,14 @@ export class NFABuilder {
150160
checked?: boolean,
151161
slotIndex?: number,
152162
appendToSlot?: boolean,
163+
actionName?: string,
164+
propertyPath?: string,
153165
): void {
154166
const state = this.states[from];
155167
if (!state) {
156168
throw new Error(`State ${from} does not exist`);
157169
}
158-
state.transitions.push({
170+
const trans: NFATransition = {
159171
type,
160172
to,
161173
tokens,
@@ -164,7 +176,10 @@ export class NFABuilder {
164176
checked,
165177
slotIndex,
166178
appendToSlot,
167-
});
179+
};
180+
if (actionName) trans.actionName = actionName;
181+
if (propertyPath) trans.propertyPath = propertyPath;
182+
state.transitions.push(trans);
168183
}
169184

170185
addTokenTransition(from: number, to: number, tokens: string[]): void {
@@ -220,6 +235,8 @@ export class NFABuilder {
220235
checked?: boolean,
221236
slotIndex?: number,
222237
appendToSlot?: boolean,
238+
actionName?: string,
239+
propertyPath?: string,
223240
): void {
224241
this.addTransition(
225242
from,
@@ -231,6 +248,8 @@ export class NFABuilder {
231248
checked,
232249
slotIndex,
233250
appendToSlot,
251+
actionName,
252+
propertyPath,
234253
);
235254
}
236255

0 commit comments

Comments
 (0)