@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 +4 -4
- package/features/completions.js +6 -6
- package/features/definition.js +40 -8
- package/features/diagnostics.js +85 -25
- package/features/formatting.js +9 -4
- package/features/graph.js +53 -0
- package/features/hover.js +14 -12
- package/features/links.js +33 -9
- package/features/references.js +1 -6
- package/features/rename.js +1 -6
- package/features/symbols.js +1 -1
- package/package.json +4 -4
- package/parser.js +13 -6
- package/server.js +31 -3
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 | `.
|
|
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
|
|
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 = { '
|
|
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 = "
|
|
100
|
+
name = "description"
|
|
101
101
|
language-servers = ["agent-dsl-lsp"]
|
|
102
102
|
|
|
103
103
|
[[language]]
|
package/features/completions.js
CHANGED
|
@@ -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'
|
|
23
|
-
const BEHAVIOR_BLOCK_KW = ['guide', 'teach', 'goal', 'interact', 'run', 'transition', 'set', 'if', 'else', 'after', 'parallel', 'apply', 'remove', 'on intent', 'on offtopic', 'on
|
|
24
|
-
const
|
|
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', '
|
|
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 === '
|
|
75
|
+
if (langId === 'description') {
|
|
76
76
|
if (!/^\s/.test(line)) {
|
|
77
|
-
return
|
|
77
|
+
return DESCRIPTION_TOP_KW.map(kw);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
// Inside a strict block → suggest declared types
|
package/features/definition.js
CHANGED
|
@@ -16,7 +16,33 @@
|
|
|
16
16
|
|
|
17
17
|
'use strict';
|
|
18
18
|
|
|
19
|
-
const
|
|
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
|
-
|
|
54
|
+
const local = nodesOfType(tree, 'state_decl')
|
|
31
55
|
.find(n => n.childForFieldName('name')?.text === word);
|
|
32
|
-
|
|
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
|
-
|
|
39
|
-
return { uri, range: nodeToRange(targetNode) };
|
|
71
|
+
return null;
|
|
40
72
|
}
|
|
41
73
|
|
|
42
74
|
module.exports = { provideDefinition };
|
package/features/diagnostics.js
CHANGED
|
@@ -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
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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('
|
|
129
|
-
ancestor.descendantsOfType('
|
|
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/
|
|
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 === '
|
|
148
|
-
if (langId === 'behavior') return
|
|
207
|
+
if (langId === 'description') return diagnoseDescription(tree, text);
|
|
208
|
+
if (langId === 'behavior') return diagnoseBehavior(tree, uri);
|
|
149
209
|
return [];
|
|
150
210
|
}
|
|
151
211
|
|
package/features/formatting.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
'use strict';
|
|
18
18
|
|
|
19
|
-
const BLOCK_HEADERS = /^(on\s+(intent|offtopic|
|
|
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
|
-
|
|
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
|
|
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 === '
|
|
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
|
|
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|
|
|
42
|
-
'run': '**`run script|subagent|tool "target"`**\n\nExecutes a script, subagent, or tool.
|
|
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 "
|
|
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
|
|
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
|
|
57
|
-
'apply': '**`apply css
|
|
58
|
-
'remove': '**`remove css
|
|
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 === '
|
|
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
|
|
36
|
-
const filename = rawText.replace(/^"|"$/g, '');
|
|
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
|
-
|
|
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)
|
|
63
|
+
if (fileNode) addFileLink(fileNode, fileNode.text);
|
|
48
64
|
}
|
|
49
|
-
for (const node of nodesOfType(tree, '
|
|
65
|
+
for (const node of nodesOfType(tree, 'persona_block')) {
|
|
50
66
|
const fileNode = node.childForFieldName('file');
|
|
51
|
-
if (fileNode)
|
|
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)
|
|
82
|
+
if (pathNode) addFileLink(pathNode, pathNode.text);
|
|
59
83
|
}
|
|
60
84
|
for (const node of nodesOfType(tree, 'run_stmt')) {
|
|
61
|
-
const runTypeNode = node.childForFieldName('
|
|
85
|
+
const runTypeNode = node.childForFieldName('type');
|
|
62
86
|
if (runTypeNode?.text !== 'script') continue;
|
|
63
87
|
const targetNode = node.childForFieldName('target');
|
|
64
|
-
if (targetNode)
|
|
88
|
+
if (targetNode) addFileLink(targetNode, targetNode.text);
|
|
65
89
|
}
|
|
66
90
|
}
|
|
67
91
|
|
package/features/references.js
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/features/rename.js
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/features/symbols.js
CHANGED
|
@@ -49,7 +49,7 @@ function provideDocumentSymbols(langId, tree) {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
if (langId === '
|
|
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
|
+
"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.
|
|
31
|
-
"vscode-languageserver": "^
|
|
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.
|
|
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
|
|
24
|
+
const DESCRIPTION_WASM = path.resolve(grammar.agentWasmPath);
|
|
25
25
|
const BEHAVIOR_WASM = path.resolve(grammar.behaviorWasmPath);
|
|
26
26
|
|
|
27
|
-
let
|
|
27
|
+
let descriptionParser, behaviorParser;
|
|
28
28
|
|
|
29
29
|
async function initParsers() {
|
|
30
30
|
await Parser.init();
|
|
31
|
-
const
|
|
31
|
+
const DescriptionLang = await Language.load(DESCRIPTION_WASM);
|
|
32
32
|
const Behavior = await Language.load(BEHAVIOR_WASM);
|
|
33
|
-
|
|
34
|
-
|
|
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 :
|
|
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 !== '
|
|
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);
|