@dot-agent/language-server 0.3.1 → 0.4.1

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/README.md CHANGED
@@ -4,7 +4,7 @@ A standalone [Language Server Protocol (LSP)](https://microsoft.github.io/langua
4
4
 
5
5
  ## Features
6
6
 
7
- | Capability | `.agent` | `.behavior` |
7
+ | Capability | `.description` | `.behavior` |
8
8
  |---|---|---|
9
9
  | **Hover** | Keyword documentation | Keyword documentation |
10
10
  | **Completion** | Manifest keywords, custom types | Keywords, state names, memory domains (`context.`, `session.`, …) |
@@ -36,7 +36,7 @@ A standalone [Language Server Protocol (LSP)](https://microsoft.github.io/langua
36
36
 
37
37
  The server speaks LSP over `stdio`. Each editor starts it as a subprocess and communicates via JSON-RPC messages.
38
38
 
39
- All structural analysis uses the **tree-sitter** parse trees from [`@dot-agent/tree-sitter-agent`](https://github.com/daniloborges/tree-sitter-agent). `parser.js` initializes the WASM-based parsers during `initialize` and maintains a per-document AST cache with incremental reparse.
39
+ All structural analysis uses the **tree-sitter** parse trees from [`@dot-agent/tree-sitter`](https://github.com/dot-agent-spec/tree-sitter). `parser.js` initializes the WASM-based parsers during `initialize` and maintains a per-document AST cache with incremental reparse.
40
40
 
41
41
  ## Prerequisites
42
42
 
@@ -84,7 +84,7 @@ if not configs.agent_dsl then
84
84
  configs.agent_dsl = {
85
85
  default_config = {
86
86
  cmd = { 'node', '/path/to/language-server/server.js', '--stdio' },
87
- filetypes = { 'agent', 'behavior' },
87
+ filetypes = { 'description', 'behavior' },
88
88
  root_dir = lspconfig.util.root_pattern('.git'),
89
89
  },
90
90
  }
@@ -97,7 +97,7 @@ lspconfig.agent_dsl.setup {}
97
97
 
98
98
  ```toml
99
99
  [[language]]
100
- name = "agent"
100
+ name = "description"
101
101
  language-servers = ["agent-dsl-lsp"]
102
102
 
103
103
  [[language]]
@@ -19,9 +19,9 @@
19
19
  const { CompletionItemKind } = require('vscode-languageserver');
20
20
  const { nodesOfType, positionToOffset, getContextNode } = require('../parser');
21
21
 
22
- const BEHAVIOR_TOP_KW = ['state', 'merge', 'on event', 'on intent', 'on offtopic', 'on fallback'];
23
- const BEHAVIOR_BLOCK_KW = ['guide', 'teach', 'goal', 'interact', 'run', 'transition', 'set', 'if', 'else', 'after', 'parallel', 'apply', 'remove', 'on intent', 'on offtopic', 'on fallback', 'on complete', 'on failed'];
24
- const AGENT_TOP_KW = ['agent', 'domain', 'license', 'terms', 'privacy', 'description', 'behavior', 'requires', 'input', 'capabilities', 'output', 'type', 'concept', 'schema'];
22
+ const BEHAVIOR_TOP_KW = ['state', 'merge', 'on event', 'on intent', 'on offtopic'];
23
+ const BEHAVIOR_BLOCK_KW = ['guide', 'teach', 'goal', 'interact', 'run', 'transition', 'set', 'if', 'else', 'end', 'after', 'parallel', 'apply', 'remove', 'on intent', 'on offtopic', 'on failure', 'on success'];
24
+ const DESCRIPTION_TOP_KW = ['agent', 'domain', 'license', 'terms', 'privacy', 'description', 'behavior', 'requires', 'input', 'capabilities', 'output', 'type', 'concept'];
25
25
  const STRICT_BLOCKS = new Set(['input_block', 'output_block', 'requires_block', 'capabilities_block']);
26
26
 
27
27
  function kw(label) {
@@ -59,7 +59,7 @@ function provideCompletions(langId, tree, text, position) {
59
59
  return ['script', 'subagent', 'tool'].map(kw);
60
60
  }
61
61
  if (/\bon\s+\S*$/.test(before)) {
62
- return ['event', 'intent', 'offtopic', 'fallback', 'complete', 'failed'].map(kw);
62
+ return ['event', 'intent', 'offtopic', 'failure', 'success'].map(kw);
63
63
  }
64
64
 
65
65
  // Context-aware: top-level vs. inside a block
@@ -72,9 +72,9 @@ function provideCompletions(langId, tree, text, position) {
72
72
  return (/^\s/.test(line) ? BEHAVIOR_BLOCK_KW : BEHAVIOR_TOP_KW).map(kw);
73
73
  }
74
74
 
75
- if (langId === 'agent') {
75
+ if (langId === 'description') {
76
76
  if (!/^\s/.test(line)) {
77
- return AGENT_TOP_KW.map(kw);
77
+ return DESCRIPTION_TOP_KW.map(kw);
78
78
  }
79
79
 
80
80
  // Inside a strict block → suggest declared types
@@ -16,7 +16,33 @@
16
16
 
17
17
  'use strict';
18
18
 
19
- const { nodesOfType, nodeToRange, wordAtPosition } = require('../parser');
19
+ const fs = require('fs');
20
+ const { fileURLToPath, pathToFileURL } = require('url');
21
+ const path = require('path');
22
+ const { nodesOfType, nodeToRange, wordAtPosition, parseText } = require('../parser');
23
+
24
+ // Busca recursiva de state_decl em arquivos mergeados.
25
+ // visited (Set<absPath>) evita loops em grafos de merge circulares.
26
+ function findStateInMerges(tree, docDir, word, visited = new Set()) {
27
+ for (const mergeNode of nodesOfType(tree, 'merge_decl')) {
28
+ const pathNode = mergeNode.childForFieldName('path');
29
+ if (!pathNode) continue;
30
+ const filename = pathNode.text.replace(/^"|"$/g, '');
31
+ const absPath = path.resolve(docDir, filename);
32
+ if (visited.has(absPath)) continue;
33
+ visited.add(absPath);
34
+ let mergedText;
35
+ try { mergedText = fs.readFileSync(absPath, 'utf8'); } catch { continue; }
36
+ const mergedTree = parseText('behavior', mergedText);
37
+ if (!mergedTree) continue;
38
+ const found = nodesOfType(mergedTree, 'state_decl')
39
+ .find(n => n.childForFieldName('name')?.text === word);
40
+ if (found) return { uri: pathToFileURL(absPath).toString(), range: nodeToRange(found) };
41
+ const sub = findStateInMerges(mergedTree, path.dirname(absPath), word, visited);
42
+ if (sub) return sub;
43
+ }
44
+ return null;
45
+ }
20
46
 
21
47
  function provideDefinition(langId, tree, text, uri, position) {
22
48
  if (!tree) return null;
@@ -24,19 +50,25 @@ function provideDefinition(langId, tree, text, uri, position) {
24
50
  const { word } = wordAtPosition(text, position.line, position.character);
25
51
  if (!word) return null;
26
52
 
27
- let targetNode = null;
28
-
29
53
  if (langId === 'behavior') {
30
- targetNode = nodesOfType(tree, 'state_decl')
54
+ const local = nodesOfType(tree, 'state_decl')
31
55
  .find(n => n.childForFieldName('name')?.text === word);
32
- } else if (langId === 'agent') {
56
+ if (local) return { uri, range: nodeToRange(local) };
57
+ try {
58
+ const docDir = path.dirname(fileURLToPath(uri));
59
+ return findStateInMerges(tree, docDir, word) ?? null;
60
+ } catch { return null; }
61
+ }
62
+
63
+ if (langId === 'description') {
33
64
  if (!/^[A-Z]/.test(word) && !word.includes('.')) return null;
34
- targetNode = nodesOfType(tree, 'type_decl')
65
+ const targetNode = nodesOfType(tree, 'type_decl')
35
66
  .find(n => n.childForFieldName('name')?.text === word);
67
+ if (!targetNode) return null;
68
+ return { uri, range: nodeToRange(targetNode) };
36
69
  }
37
70
 
38
- if (!targetNode) return null;
39
- return { uri, range: nodeToRange(targetNode) };
71
+ return null;
40
72
  }
41
73
 
42
74
  module.exports = { provideDefinition };
@@ -16,8 +16,11 @@
16
16
 
17
17
  'use strict';
18
18
 
19
+ const fs = require('fs');
20
+ const { fileURLToPath } = require('url');
21
+ const path = require('path');
19
22
  const { DiagnosticSeverity } = require('vscode-languageserver');
20
- const { nodesOfType, nodeToRange } = require('../parser');
23
+ const { nodesOfType, nodeToRange, parseText } = require('../parser');
21
24
 
22
25
  const STRICT_BLOCK_TYPES = {
23
26
  input_block: 'input',
@@ -26,7 +29,7 @@ const STRICT_BLOCK_TYPES = {
26
29
  capabilities_block: 'capabilities',
27
30
  };
28
31
 
29
- function diagnoseAgent(tree, text) {
32
+ function diagnoseDescription(tree, text) {
30
33
  const diagnostics = [];
31
34
 
32
35
  // ── Strict block validation ───────────────────────────────────────────────
@@ -44,7 +47,7 @@ function diagnoseAgent(tree, text) {
44
47
  range: nodeToRange(errorNode),
45
48
  message: `Syntax error in '${blockName}' block. Expected: Type or Type "Description", or compact: Type1, Type2.`,
46
49
  severity: DiagnosticSeverity.Error,
47
- source: 'agent-dsl',
50
+ source: 'description-dsl',
48
51
  });
49
52
  }
50
53
 
@@ -59,7 +62,7 @@ function diagnoseAgent(tree, text) {
59
62
  range: nodeToRange(idNode),
60
63
  message: `Type '${typeName}' is not declared in this file (assuming native or external).`,
61
64
  severity: DiagnosticSeverity.Warning,
62
- source: 'agent-dsl',
65
+ source: 'description-dsl',
63
66
  });
64
67
  }
65
68
  }
@@ -70,17 +73,87 @@ function diagnoseAgent(tree, text) {
70
73
  return diagnostics;
71
74
  }
72
75
 
73
- function diagnoseFlow(tree) {
76
+ // Coleta recursivamente todos os state names declarados nos arquivos mergeados.
77
+ // visited (Set<absPath>) evita loops em grafos de merge circulares.
78
+ function collectMergedStates(tree, docDir, definedStates, visited = new Set()) {
79
+ for (const mergeNode of nodesOfType(tree, 'merge_decl')) {
80
+ const pathNode = mergeNode.childForFieldName('path');
81
+ if (!pathNode) continue;
82
+ const filename = pathNode.text.replace(/^"|"$/g, '');
83
+ const absPath = path.resolve(docDir, filename);
84
+ if (visited.has(absPath)) continue;
85
+ visited.add(absPath);
86
+ let mergedText;
87
+ try { mergedText = fs.readFileSync(absPath, 'utf8'); } catch { continue; }
88
+ const mergedTree = parseText('behavior', mergedText);
89
+ if (!mergedTree) continue;
90
+ nodesOfType(mergedTree, 'state_decl').forEach(n => {
91
+ const name = n.childForFieldName('name')?.text;
92
+ if (name) definedStates.add(name);
93
+ });
94
+ collectMergedStates(mergedTree, path.dirname(absPath), definedStates, visited);
95
+ }
96
+ }
97
+
98
+ // Coleta erros de sintaxe (nós ERROR e MISSING) para que a causa real apareça
99
+ // sublinhada no editor, e não apenas o efeito colateral (ex.: "state not defined").
100
+ // Reporta apenas o ERROR mais externo de cada ramo, e tokens MISSING (ex.: falta 'to').
101
+ function collectSyntaxErrors(root, diagnostics) {
102
+ const seen = new Set();
103
+
104
+ function push(node, message) {
105
+ const key = `${node.startIndex}:${node.endIndex}:${message}`;
106
+ if (seen.has(key)) return;
107
+ seen.add(key);
108
+ diagnostics.push({
109
+ range: nodeToRange(node),
110
+ message,
111
+ severity: DiagnosticSeverity.Error,
112
+ source: 'behavior-dsl',
113
+ });
114
+ }
115
+
116
+ function walk(node) {
117
+ if (node.isMissing) {
118
+ push(node, `Syntax error: missing '${node.type}'.`);
119
+ return;
120
+ }
121
+ if (node.type === 'ERROR') {
122
+ // Reporta o ERROR mais profundo (folha) para apontar o token preciso,
123
+ // em vez do span externo que pode embrulhar o bloco inteiro.
124
+ const hasNestedError = node.descendantsOfType('ERROR').some(e => e.id !== node.id);
125
+ if (!hasNestedError) {
126
+ const snippet = node.text.replace(/\s+/g, ' ').trim().slice(0, 40);
127
+ push(node, snippet ? `Syntax error near '${snippet}'.` : 'Syntax error.');
128
+ return;
129
+ }
130
+ // tem ERROR aninhado → desce para reportar o(s) mais específico(s)
131
+ }
132
+ for (const child of node.children) walk(child);
133
+ }
134
+
135
+ walk(root);
136
+ }
137
+
138
+ function diagnoseBehavior(tree, docUri) {
74
139
  const diagnostics = [];
75
140
 
141
+ // ── Rule 0: Syntax errors (ERROR / MISSING nodes) ─────────────────────────
142
+ collectSyntaxErrors(tree.rootNode, diagnostics);
143
+
76
144
  // ── Rule 1: Dangling transitions ──────────────────────────────────────────
77
- // Collect all defined state names (from both oriented_state_body and setup_state_body)
78
145
  const definedStates = new Set(
79
146
  nodesOfType(tree, 'state_decl')
80
147
  .map(n => n.childForFieldName('name')?.text)
81
148
  .filter(Boolean)
82
149
  );
83
150
 
151
+ // Enriquecer com estados de arquivos mergeados (recursivo, anti-loop)
152
+ try {
153
+ const docDir = path.dirname(fileURLToPath(docUri));
154
+ collectMergedStates(tree, docDir, definedStates);
155
+ } catch { /* URI inválida ou sem acesso — diagnostica apenas com estados locais */ }
156
+
84
157
  function checkTransitionTarget(stateNode) {
85
158
  if (!stateNode) return;
86
159
  const target = stateNode.text;
@@ -100,18 +173,6 @@ function diagnoseFlow(tree) {
100
173
  for (const n of nodesOfType(tree, 'transition_stmt')) {
101
174
  checkTransitionTarget(n.childForFieldName('state'));
102
175
  }
103
- for (const n of nodesOfType(tree, 'intent_trigger')) {
104
- // inline form: on intent "..." transition to <state>
105
- checkTransitionTarget(n.childForFieldName('state'));
106
- }
107
- for (const n of nodesOfType(tree, 'fallback_stmt')) {
108
- // inline form: on fallback transition to <state>
109
- checkTransitionTarget(n.childForFieldName('state'));
110
- }
111
- for (const n of nodesOfType(tree, 'offtopic_stmt')) {
112
- // inline form: on offtopic transition to <state>
113
- checkTransitionTarget(n.childForFieldName('state'));
114
- }
115
176
 
116
177
  // ── Rule 2: Dead-end interact (now redundant but kept for safety) ────────────
117
178
  // With the new grammar, oriented_state_body requires repeat1(handlers),
@@ -125,14 +186,13 @@ function diagnoseFlow(tree) {
125
186
 
126
187
  // With new grammar: oriented_state_body always has handlers after interact (repeat1)
127
188
  // So this check should never trigger, but kept for robustness
128
- const hasHandlers = ancestor.descendantsOfType('intent_trigger').length +
129
- ancestor.descendantsOfType('fallback_stmt').length +
130
- ancestor.descendantsOfType('offtopic_stmt').length > 0;
189
+ const hasHandlers = ancestor.descendantsOfType('intent_handler').length +
190
+ ancestor.descendantsOfType('offtopic_handler').length > 0;
131
191
 
132
192
  if (!hasHandlers) {
133
193
  diagnostics.push({
134
194
  range: nodeToRange(interactNode),
135
- message: "This state calls interact but has no handlers (on intent/fallback/offtopic). This will trap the agent.",
195
+ message: "This state calls interact but has no handlers (on intent/offtopic). This will trap the agent.",
136
196
  severity: DiagnosticSeverity.Warning,
137
197
  source: 'behavior-dsl',
138
198
  });
@@ -142,10 +202,10 @@ function diagnoseFlow(tree) {
142
202
  return diagnostics;
143
203
  }
144
204
 
145
- function diagnose(langId, tree, text) {
205
+ function diagnose(langId, tree, text, uri) {
146
206
  if (!tree) return [];
147
- if (langId === 'agent') return diagnoseAgent(tree, text);
148
- if (langId === 'behavior') return diagnoseFlow(tree);
207
+ if (langId === 'description') return diagnoseDescription(tree, text);
208
+ if (langId === 'behavior') return diagnoseBehavior(tree, uri);
149
209
  return [];
150
210
  }
151
211
 
@@ -16,7 +16,7 @@
16
16
 
17
17
  'use strict';
18
18
 
19
- const BLOCK_HEADERS = /^(on\s+(intent|offtopic|fallback|complete|failed)|if|else|after|parallel)\b/;
19
+ const BLOCK_HEADERS = /^(on\s+(intent|offtopic|failure|success)|if|else|after|parallel)\b/;
20
20
  const TOP_LEVEL_LINE = /^(state|merge)\s|^on\s+event\b/;
21
21
 
22
22
  function formatBehavior(text) {
@@ -39,7 +39,12 @@ function formatBehavior(text) {
39
39
  if (BLOCK_HEADERS.test(trimmed)) mode = 'NESTED_BODY';
40
40
  expectedIndent = 2;
41
41
  } else {
42
- expectedIndent = BLOCK_HEADERS.test(trimmed) ? 2 : 4;
42
+ if (/^end\b/.test(trimmed)) {
43
+ mode = 'STATE_BODY';
44
+ expectedIndent = 2;
45
+ } else {
46
+ expectedIndent = BLOCK_HEADERS.test(trimmed) ? 2 : 4;
47
+ }
43
48
  }
44
49
 
45
50
  const actualIndent = raw.length - raw.trimStart().length;
@@ -53,7 +58,7 @@ function formatBehavior(text) {
53
58
  return edits;
54
59
  }
55
60
 
56
- function formatAgent(text) {
61
+ function formatDescription(text) {
57
62
  const edits = [];
58
63
  const lines = text.split('\n');
59
64
  for (let i = 0; i < lines.length; i++) {
@@ -73,7 +78,7 @@ function formatAgent(text) {
73
78
 
74
79
  function format(langId, text) {
75
80
  if (langId === 'behavior') return formatBehavior(text);
76
- if (langId === 'agent') return formatAgent(text);
81
+ if (langId === 'description') return formatDescription(text);
77
82
  return [];
78
83
  }
79
84
 
@@ -0,0 +1,53 @@
1
+ /*
2
+ * Copyright (c) 2026 Danilo Borges (https://github.com/daniloborges)
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const { nodesOfType } = require('../parser');
20
+
21
+ function extractBehaviorGraph(tree) {
22
+ const states = nodesOfType(tree, 'state_decl')
23
+ .map(n => n.childForFieldName('name')?.text)
24
+ .filter(Boolean);
25
+
26
+ const transitions = [];
27
+ const seen = new Set();
28
+ for (const stateNode of nodesOfType(tree, 'state_decl')) {
29
+ const from = stateNode.childForFieldName('name')?.text;
30
+ if (!from) continue;
31
+ for (const t of stateNode.descendantsOfType('transition_stmt')) {
32
+ const to = t.childForFieldName('state')?.text;
33
+ if (to) {
34
+ const key = `${from}→${to}`;
35
+ if (!seen.has(key)) { seen.add(key); transitions.push({ from, to }); }
36
+ }
37
+ }
38
+ }
39
+
40
+ const entryPoints = [];
41
+ for (const triggerNode of nodesOfType(tree, 'trigger_decl')) {
42
+ // event field is a quoted_string WITHOUT surrounding quotes (quotes are literals in the grammar rule)
43
+ const eventText = triggerNode.childForFieldName('event')?.text;
44
+ for (const t of triggerNode.descendantsOfType('transition_stmt')) {
45
+ const to = t.childForFieldName('state')?.text;
46
+ if (eventText && to) entryPoints.push({ event: eventText, to });
47
+ }
48
+ }
49
+
50
+ return { states, transitions, entryPoints };
51
+ }
52
+
53
+ module.exports = { extractBehaviorGraph };
package/features/hover.js CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  const { MarkupKind } = require('vscode-languageserver');
20
20
 
21
- const AGENT_DOCS = {
21
+ const DESCRIPTION_DOCS = {
22
22
  'agent': '**`agent name`**\n\nDeclares a new agent. The central node of the manifest.',
23
23
  'domain': '**`domain url`**\n\nDeclares the canonical domain for this agent, establishing cryptographic identity and ownership.',
24
24
  'license': '**`license type`**\n\nDeclares the license under which this agent is distributed (e.g., MIT, Copyright).',
@@ -32,30 +32,32 @@ const AGENT_DOCS = {
32
32
  'output': '**`output Type`**\n\nThe data type this agent returns.',
33
33
  'type': '**`type name`**\n\nDeclares a custom type to anchor custom typing to Wikidata or Schema.org.',
34
34
  'concept': '**`concept url`**\n\nThe Wikidata or Schema.org concept URL this type maps to.',
35
- 'schema': '**`schema file.json`**\n\nA JSON schema file for this type.',
36
35
  };
37
36
 
38
37
  const BEHAVIOR_DOCS = {
39
38
  'merge': '**`merge "file.behavior"`**\n\nIncludes another `.behavior` file. Must appear before any `state` or `on event` declarations (preamble-only, eager loading).',
40
39
  'state': '**`state name`**\n\nDeclares a named state. States contain the logic that runs while the agent is in that state.',
41
- 'on': '**`on event|intent|offtopic|fallback|complete|failed`**\n\nBinds a handler to a trigger. Top-level: `on event`. Inside a state: `on intent`, `on offtopic`, `on fallback`. After parallel: `on complete`, `on failed`.',
42
- 'run': '**`run script|subagent|tool "target"`**\n\nExecutes a script, subagent, or tool. Accepts optional modifiers: `silent`, `in background`, `each collection`.',
40
+ 'on': '**`on event|intent|offtopic|failure|success`**\n\nBinds a handler to a trigger. Top-level: `on event`. Inside a state: `on intent`, `on offtopic`. After `run`/`apply`/`remove`: `on failure`. Inside `parallel`: `on success` (optional), `on failure` (required).',
41
+ 'run': '**`run script|subagent|tool "target" ["parameters"]`**\n\nExecutes a script, subagent, or tool. Optionally followed by `on failure` to handle errors.',
43
42
  'guide': '**`guide "text"`**\n\nInjects a system-level instruction into the conversation context, shaping the agent\'s persona or approach without being visible as a reply.',
44
- 'teach': '**`teach "text"`**\n\nAdds a fact or constraint to the agent\'s working knowledge for the duration of this state.',
43
+ 'teach': '**`teach "file"`**\n\nLoads a file into the agent\'s working knowledge for the duration of this state.',
45
44
  'goal': '**`goal "text"`**\n\nSets the agent\'s objective for this state, used by the runtime for planning and alignment checks.',
46
- 'interact': '**`interact [requiring "text"]`**\n\nPauses execution and waits for user input. Optionally enforces a requirement before continuing.',
45
+ 'interact': '**`interact`**\n\nPauses execution and waits for user input.',
47
46
  'set': '**`set domain.var = value`**\n\nAssigns a value to a memory variable. Domains: `context`, `session`, `worksession`, `user`.',
48
47
  'context': '**`context`** memory domain — scoped to the current agent run.',
49
48
  'session': '**`session`** memory domain — persists for the user\'s current session.',
50
49
  'worksession': '**`worksession`** memory domain — persists across a task-oriented work session (isolated per task).',
51
50
  'user': '**`user`** memory domain — persists across sessions for a given user.',
52
51
  'transition': '**`transition to state`**\n\nTransitions immediately to the named state.',
53
- 'if': '**`if condition`**\n\nConditional execution. Condition can use `==`, `!=`, `>`, `<`, `>=`, `<=`, `and`, `or`.',
54
- 'else': '**`else`**\n\nAlternative branch of an `if` statement.',
52
+ 'if': '**`if condition`**\n\nConditional execution. Condition can use `==`, `!=`, `>`, `<`, `>=`, `<=`, `and`, `or`. Close the block with `end`.',
53
+ 'else': '**`else`**\n\nAlternative branch of an `if` statement. Close the block with `end`.',
54
+ 'end': '**`end`**\n\nCloses an `if`/`else` block.',
55
55
  'after': '**`after N prompts`**\n\n[Experimental] Executes a block after N user prompts have occurred in this state.',
56
- 'parallel': '**`parallel`**\n\n[Experimental] Runs a block of `run` statements concurrently. Follow with `on complete` and `on failed` handlers.',
57
- 'apply': '**`apply css|html|video "text"`**\n\nApplies a UI manipulation to a CSS selector, HTML element, or video element.',
58
- 'remove': '**`remove css|html|video "text"`**\n\nRemoves a UI element by selector or reference.',
56
+ 'parallel': '**`parallel`**\n\n[Experimental] Runs a block of `run` statements concurrently. Follow with optional `on success` and required `on failure` handlers.',
57
+ 'apply': '**`apply css "selector"`**\n\nApplies a UI manipulation to a CSS selector. Optionally followed by `on failure`.',
58
+ 'remove': '**`remove css "selector"`**\n\nRemoves a UI element by CSS selector. Optionally followed by `on failure`.',
59
+ 'failure': '**`on failure`**\n\nError handler block executed when a `run`, `apply`, `remove`, or `parallel` statement fails.',
60
+ 'success': '**`on success`**\n\nOptional success handler block inside a `parallel` statement.',
59
61
  };
60
62
 
61
63
  function provideHover(langId, text, position) {
@@ -70,7 +72,7 @@ function provideHover(langId, text, position) {
70
72
  const word = line.slice(start, end);
71
73
  if (!word) return null;
72
74
 
73
- const docs = langId === 'agent' ? AGENT_DOCS : BEHAVIOR_DOCS;
75
+ const docs = langId === 'description' ? DESCRIPTION_DOCS : BEHAVIOR_DOCS;
74
76
  const doc = docs[word];
75
77
  if (!doc) return null;
76
78
 
package/features/links.js CHANGED
@@ -20,6 +20,16 @@ const { fileURLToPath, pathToFileURL } = require('url');
20
20
  const path = require('path');
21
21
  const { nodesOfType, nodeToRange } = require('../parser');
22
22
 
23
+ // Detecta se um nó bare_string representa um caminho de arquivo.
24
+ // Usa o subtipo do tree-sitter (filename) como primeira fonte de verdade;
25
+ // cai no regex do grammar.js como fallback para ambientes que não expõem subnós.
26
+ // O padrão aceita: extensão simples (a.md), dupla (a.b.persona), longa (.behavior)
27
+ // e exclui texto livre (que tem espaços/pontuação fora do charset).
28
+ function isFilename(node) {
29
+ if (node.firstNamedChild?.type === 'filename') return true;
30
+ return /^[a-zA-Z0-9_-]+(\.[a-zA-Z0-9_-]+)+$/.test(node.text.replace(/^"|"$/g, ''));
31
+ }
32
+
23
33
  function provideDocumentLinks(langId, tree, docUri) {
24
34
  if (!tree) return [];
25
35
 
@@ -32,8 +42,8 @@ function provideDocumentLinks(langId, tree, docUri) {
32
42
 
33
43
  const links = [];
34
44
 
35
- function addLink(fileNode, rawText) {
36
- const filename = rawText.replace(/^"|"$/g, ''); // strip optional quotes
45
+ function addFileLink(fileNode, rawText) {
46
+ const filename = rawText.replace(/^"|"$/g, '');
37
47
  if (!filename) return;
38
48
  links.push({
39
49
  range: nodeToRange(fileNode),
@@ -41,27 +51,41 @@ function provideDocumentLinks(langId, tree, docUri) {
41
51
  });
42
52
  }
43
53
 
44
- if (langId === 'agent') {
54
+ function addUrlLink(uriNode) {
55
+ const url = uriNode.text.trim();
56
+ if (!url) return;
57
+ links.push({ range: nodeToRange(uriNode), target: url });
58
+ }
59
+
60
+ if (langId === 'description') {
45
61
  for (const node of nodesOfType(tree, 'behavior_block')) {
46
62
  const fileNode = node.childForFieldName('file');
47
- if (fileNode) addLink(fileNode, fileNode.text);
63
+ if (fileNode) addFileLink(fileNode, fileNode.text);
48
64
  }
49
- for (const node of nodesOfType(tree, 'schema_prop')) {
65
+ for (const node of nodesOfType(tree, 'persona_block')) {
50
66
  const fileNode = node.childForFieldName('file');
51
- if (fileNode) addLink(fileNode, fileNode.text);
67
+ if (fileNode && isFilename(fileNode)) addFileLink(fileNode, fileNode.text);
68
+ }
69
+ for (const node of nodesOfType(tree, 'category_prop')) {
70
+ const uriNode = node.childForFieldName('uri');
71
+ if (uriNode) addUrlLink(uriNode);
72
+ }
73
+ for (const node of nodesOfType(tree, 'concept_prop')) {
74
+ const uriNode = node.childForFieldName('uri');
75
+ if (uriNode) addUrlLink(uriNode);
52
76
  }
53
77
  }
54
78
 
55
79
  if (langId === 'behavior') {
56
80
  for (const node of nodesOfType(tree, 'merge_decl')) {
57
81
  const pathNode = node.childForFieldName('path');
58
- if (pathNode) addLink(pathNode, pathNode.text);
82
+ if (pathNode) addFileLink(pathNode, pathNode.text);
59
83
  }
60
84
  for (const node of nodesOfType(tree, 'run_stmt')) {
61
- const runTypeNode = node.childForFieldName('run_type');
85
+ const runTypeNode = node.childForFieldName('type');
62
86
  if (runTypeNode?.text !== 'script') continue;
63
87
  const targetNode = node.childForFieldName('target');
64
- if (targetNode) addLink(targetNode, targetNode.text);
88
+ if (targetNode) addFileLink(targetNode, targetNode.text);
65
89
  }
66
90
  }
67
91
 
@@ -41,12 +41,7 @@ function provideReferences(langId, tree, text, uri, position) {
41
41
  const stateNode = n.childForFieldName('state');
42
42
  if (stateNode?.text === word) add(stateNode);
43
43
  }
44
- // Inline intent handlers: on intent "..." next <state>
45
- for (const n of nodesOfType(tree, 'intent_trigger')) {
46
- const stateNode = n.childForFieldName('state');
47
- if (stateNode?.text === word) add(stateNode);
48
- }
49
- } else if (langId === 'agent') {
44
+ } else if (langId === 'description') {
50
45
  // Declaration
51
46
  for (const n of nodesOfType(tree, 'type_decl')) {
52
47
  const nameNode = n.childForFieldName('name');
@@ -41,12 +41,7 @@ function provideRenameEdits(langId, tree, text, uri, position, newName) {
41
41
  const stateNode = n.childForFieldName('state');
42
42
  if (stateNode?.text === oldName) addEdit(stateNode);
43
43
  }
44
- // Rename inline intent handler targets
45
- for (const n of nodesOfType(tree, 'intent_trigger')) {
46
- const stateNode = n.childForFieldName('state');
47
- if (stateNode?.text === oldName) addEdit(stateNode);
48
- }
49
- } else if (langId === 'agent') {
44
+ } else if (langId === 'description') {
50
45
  // Rename type declarations
51
46
  for (const n of nodesOfType(tree, 'type_decl')) {
52
47
  const nameNode = n.childForFieldName('name');
@@ -49,7 +49,7 @@ function provideDocumentSymbols(langId, tree) {
49
49
  }
50
50
  }
51
51
 
52
- if (langId === 'agent') {
52
+ if (langId === 'description') {
53
53
  for (const node of nodesOfType(tree, 'agent_decl')) {
54
54
  const nameNode = node.childForFieldName('name');
55
55
  if (!nameNode) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dot-agent/language-server",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "LSP server for .agent DSL files",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Danilo Borges <contato@daniloborg.es>",
@@ -27,10 +27,10 @@
27
27
  "start": "node server.js --stdio"
28
28
  },
29
29
  "dependencies": {
30
- "@dot-agent/tree-sitter": "^0.3.2",
31
- "vscode-languageserver": "^9.0.1",
30
+ "@dot-agent/tree-sitter": "^0.3.6",
31
+ "vscode-languageserver": "^10.0.0",
32
32
  "vscode-languageserver-textdocument": "^1.0.12",
33
- "web-tree-sitter": "^0.25.10"
33
+ "web-tree-sitter": "^0.26.9"
34
34
  },
35
35
  "publishConfig": {
36
36
  "access": "public"
package/parser.js CHANGED
@@ -21,17 +21,17 @@ const { Parser, Language } = require('web-tree-sitter');
21
21
  const grammar = require('@dot-agent/tree-sitter');
22
22
 
23
23
  // path.resolve ensures absolute paths survive vsix packaging and cwd changes
24
- const AGENT_WASM = path.resolve(grammar.agentWasmPath);
24
+ const DESCRIPTION_WASM = path.resolve(grammar.agentWasmPath);
25
25
  const BEHAVIOR_WASM = path.resolve(grammar.behaviorWasmPath);
26
26
 
27
- let agentParser, behaviorParser;
27
+ let descriptionParser, behaviorParser;
28
28
 
29
29
  async function initParsers() {
30
30
  await Parser.init();
31
- const Agent = await Language.load(AGENT_WASM);
31
+ const DescriptionLang = await Language.load(DESCRIPTION_WASM);
32
32
  const Behavior = await Language.load(BEHAVIOR_WASM);
33
- agentParser = new Parser();
34
- agentParser.setLanguage(Agent);
33
+ descriptionParser = new Parser();
34
+ descriptionParser.setLanguage(DescriptionLang);
35
35
  behaviorParser = new Parser();
36
36
  behaviorParser.setLanguage(Behavior);
37
37
  }
@@ -42,7 +42,7 @@ const cache = new Map();
42
42
  function parse(uri, langId, text, version) {
43
43
  const prev = cache.get(uri);
44
44
  if (prev?.version === version) return prev.tree;
45
- const parser = langId === 'behavior' ? behaviorParser : agentParser;
45
+ const parser = langId === 'behavior' ? behaviorParser : descriptionParser;
46
46
  if (!parser) return null;
47
47
  const tree = parser.parse(text, prev?.tree); // incremental reuse when possible
48
48
  cache.set(uri, { version, tree });
@@ -53,6 +53,12 @@ function evict(uri) {
53
53
  cache.delete(uri);
54
54
  }
55
55
 
56
+ function parseText(langId, text) {
57
+ const parser = langId === 'behavior' ? behaviorParser : descriptionParser;
58
+ if (!parser) return null;
59
+ return parser.parse(text);
60
+ }
61
+
56
62
  // ── AST helpers ──────────────────────────────────────────────────────────────
57
63
 
58
64
  function nodesOfType(tree, type) {
@@ -116,6 +122,7 @@ function getContextNode(tree, offset) {
116
122
  module.exports = {
117
123
  initParsers,
118
124
  parse,
125
+ parseText,
119
126
  evict,
120
127
  nodesOfType,
121
128
  nodeAtOffset,
package/server.js CHANGED
@@ -25,7 +25,7 @@ const {
25
25
  } = require('vscode-languageserver/node');
26
26
  const { TextDocument } = require('vscode-languageserver-textdocument');
27
27
 
28
- const { initParsers, parse, evict } = require('./parser');
28
+ const { initParsers, parse, evict, nodesOfType } = require('./parser');
29
29
 
30
30
  const { provideHover } = require('./features/hover');
31
31
  const { provideCompletions } = require('./features/completions');
@@ -36,6 +36,7 @@ const { provideReferences } = require('./features/references');
36
36
  const { provideRenameEdits } = require('./features/rename');
37
37
  const { format } = require('./features/formatting');
38
38
  const { provideDocumentLinks } = require('./features/links');
39
+ const { extractBehaviorGraph } = require('./features/graph');
39
40
 
40
41
  const connection = createConnection(ProposedFeatures.all);
41
42
  const documents = new TextDocuments(TextDocument);
@@ -71,9 +72,9 @@ function getTree(doc) {
71
72
 
72
73
  function validate(doc) {
73
74
  const langId = doc.languageId;
74
- if (langId !== 'agent' && langId !== 'behavior') return;
75
+ if (langId !== 'description' && langId !== 'behavior') return;
75
76
  const tree = getTree(doc);
76
- const diagnostics = diagnose(langId, tree, doc.getText());
77
+ const diagnostics = diagnose(langId, tree, doc.getText(), doc.uri);
77
78
  connection.sendDiagnostics({ uri: doc.uri, diagnostics });
78
79
  }
79
80
 
@@ -163,6 +164,33 @@ connection.onDocumentFormatting(({ textDocument }) => {
163
164
  return format(doc.languageId, doc.getText());
164
165
  });
165
166
 
167
+ // ── Behavior Graph (custom request) ──────────────────────────────────────────
168
+
169
+ connection.onRequest('agent/behaviorGraph', ({ uri }) => {
170
+ const doc = documents.get(uri);
171
+ if (!doc || doc.languageId !== 'behavior') return null;
172
+ const tree = getTree(doc);
173
+ return tree ? extractBehaviorGraph(tree) : null;
174
+ });
175
+
176
+ // ── Current State at Position (custom request) ────────────────────────────────
177
+
178
+ connection.onRequest('agent/currentState', ({ uri, position }) => {
179
+ const doc = documents.get(uri);
180
+ if (!doc || doc.languageId !== 'behavior') return null;
181
+ const tree = getTree(doc);
182
+ if (!tree) return null;
183
+ const line = position.line;
184
+ let result = null;
185
+ for (const node of nodesOfType(tree, 'state_decl')) {
186
+ if (node.startPosition.row > line) break;
187
+ if (node.startPosition.row <= line && node.endPosition.row >= line) {
188
+ result = node.childForFieldName('name')?.text ?? null;
189
+ }
190
+ }
191
+ return result;
192
+ });
193
+
166
194
  // ── Start ────────────────────────────────────────────────────────────────────
167
195
 
168
196
  documents.listen(connection);