memuron 0.1.1__py3-none-any.whl
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.
- memuron/__init__.py +3 -0
- memuron/actions/__init__.py +12 -0
- memuron/actions/context.py +63 -0
- memuron/actions/helpers.py +88 -0
- memuron/actions/memory.py +340 -0
- memuron/actions/memory_write.py +290 -0
- memuron/actions/nodes.py +340 -0
- memuron/actions/registry.py +5 -0
- memuron/actions/runtime.py +37 -0
- memuron/actions/spaces_documents.py +720 -0
- memuron/actions/sync.py +155 -0
- memuron/application/__init__.py +1 -0
- memuron/application/api.py +206 -0
- memuron/application/app.py +103 -0
- memuron/application/capabilities.py +82 -0
- memuron/application/cli.py +35 -0
- memuron/application/config.py +176 -0
- memuron/application/mcp.py +44 -0
- memuron/application/mcp_oauth.py +290 -0
- memuron/application/registry.py +52 -0
- memuron/context.py +532 -0
- memuron/documents/__init__.py +1 -0
- memuron/documents/link_guardian.py +192 -0
- memuron/documents/linking.py +292 -0
- memuron/documents/parser.py +1152 -0
- memuron/documents/storage.py +151 -0
- memuron/documents/url_ingest.py +375 -0
- memuron/domain/__init__.py +1 -0
- memuron/domain/decoders.py +1 -0
- memuron/domain/encoders.py +185 -0
- memuron/domain/lifecycles.py +8 -0
- memuron/domain/limits.py +6 -0
- memuron/domain/representations.py +56 -0
- memuron/domain/schemas.py +581 -0
- memuron/domain/scope_filter.py +104 -0
- memuron/graphfs/__init__.py +1 -0
- memuron/graphfs/manual.py +635 -0
- memuron/graphfs/projection.py +578 -0
- memuron/graphfs/query.py +1782 -0
- memuron/graphfs/read_model.py +574 -0
- memuron/ingest/__init__.py +1 -0
- memuron/ingest/guardian.py +213 -0
- memuron/ingest/jobs.py +424 -0
- memuron/ingest/prompts.py +147 -0
- memuron/memory/__init__.py +1 -0
- memuron/memory/engine.py +35 -0
- memuron/memory/projections.py +452 -0
- memuron/memory/recipes.py +3247 -0
- memuron/persistence/__init__.py +1 -0
- memuron/persistence/db_pool.py +57 -0
- memuron/persistence/identity_store.py +918 -0
- memuron/persistence/store_helpers.py +16 -0
- memuron/search/__init__.py +1 -0
- memuron/search/fulltext.py +110 -0
- memuron/search/hybrid.py +284 -0
- memuron/search/pgvector.py +252 -0
- memuron/security/__init__.py +1 -0
- memuron/security/auth.py +143 -0
- memuron/security/auth_provider.py +119 -0
- memuron/security/authorization.py +53 -0
- memuron/security/clerk_scopes.py +94 -0
- memuron/security/clerk_webhooks.py +61 -0
- memuron/security/jwt_tokens.py +53 -0
- memuron/security/passwords.py +38 -0
- memuron/security/tenant.py +58 -0
- memuron/spaces/__init__.py +1 -0
- memuron/spaces/model.py +35 -0
- memuron/spaces/service.py +155 -0
- memuron/sync/__init__.py +25 -0
- memuron/sync/folder.py +828 -0
- memuron-0.1.1.dist-info/METADATA +242 -0
- memuron-0.1.1.dist-info/RECORD +74 -0
- memuron-0.1.1.dist-info/WHEEL +4 -0
- memuron-0.1.1.dist-info/entry_points.txt +4 -0
memuron/graphfs/query.py
ADDED
|
@@ -0,0 +1,1782 @@
|
|
|
1
|
+
"""Command parser and pipeline executor for the Memuron graph filesystem."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from difflib import get_close_matches
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Iterable
|
|
10
|
+
|
|
11
|
+
from artha_engine import ArthaEngine
|
|
12
|
+
|
|
13
|
+
from memuron.graphfs.manual import COMMANDS, help_items, manual_topics
|
|
14
|
+
from memuron.graphfs.read_model import (
|
|
15
|
+
containing_collection,
|
|
16
|
+
expand_neighbors,
|
|
17
|
+
get_nodes,
|
|
18
|
+
grep_entries,
|
|
19
|
+
list_entries,
|
|
20
|
+
list_root,
|
|
21
|
+
node_edges,
|
|
22
|
+
space_edges,
|
|
23
|
+
)
|
|
24
|
+
from memuron.memory.recipes import search_memories, semantic_traverse_graph
|
|
25
|
+
from memuron.security.tenant import org_scope_token
|
|
26
|
+
|
|
27
|
+
MAX_PIPELINE_STAGES = 12
|
|
28
|
+
MAX_RESULT_ITEMS = 1000
|
|
29
|
+
SUPPORTED_COMMANDS = {
|
|
30
|
+
"cat",
|
|
31
|
+
"cd",
|
|
32
|
+
"children",
|
|
33
|
+
"explain",
|
|
34
|
+
"find",
|
|
35
|
+
"graph",
|
|
36
|
+
"grep",
|
|
37
|
+
"help",
|
|
38
|
+
"head",
|
|
39
|
+
"hubs",
|
|
40
|
+
"limit",
|
|
41
|
+
"links",
|
|
42
|
+
"ls",
|
|
43
|
+
"neighbors",
|
|
44
|
+
"neighborhood",
|
|
45
|
+
"parents",
|
|
46
|
+
"path",
|
|
47
|
+
"pwd",
|
|
48
|
+
"related",
|
|
49
|
+
"rg",
|
|
50
|
+
"select",
|
|
51
|
+
"semantic",
|
|
52
|
+
"sort",
|
|
53
|
+
"stat",
|
|
54
|
+
"traverse",
|
|
55
|
+
"tree",
|
|
56
|
+
"wc",
|
|
57
|
+
"where",
|
|
58
|
+
"why",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FsQueryError(ValueError):
|
|
63
|
+
"""Raised for invalid filesystem queries."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
message: str,
|
|
68
|
+
*,
|
|
69
|
+
code: str = "invalid_query",
|
|
70
|
+
hint: str | None = None,
|
|
71
|
+
examples: list[str] | None = None,
|
|
72
|
+
help_query: str = "help errors",
|
|
73
|
+
stage: int | None = None,
|
|
74
|
+
command: str | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
super().__init__(message)
|
|
77
|
+
self.message = message
|
|
78
|
+
self.code = code
|
|
79
|
+
self.hint = hint or "Run the suggested help query, then submit a corrected query."
|
|
80
|
+
self.examples = examples or []
|
|
81
|
+
self.help_query = help_query
|
|
82
|
+
self.stage = stage
|
|
83
|
+
self.command = command
|
|
84
|
+
|
|
85
|
+
def add_context(self, *, stage: int, command: str) -> "FsQueryError":
|
|
86
|
+
if self.stage is None:
|
|
87
|
+
self.stage = stage
|
|
88
|
+
if self.command is None:
|
|
89
|
+
self.command = command
|
|
90
|
+
if self.help_query == "help errors" and command in COMMANDS:
|
|
91
|
+
self.help_query = f"help {command}"
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def detail(self, *, query: str | None = None, cwd: str | None = None) -> dict[str, Any]:
|
|
95
|
+
payload: dict[str, Any] = {
|
|
96
|
+
"code": self.code,
|
|
97
|
+
"message": self.message,
|
|
98
|
+
"hint": self.hint,
|
|
99
|
+
"examples": self.examples,
|
|
100
|
+
"help_query": self.help_query,
|
|
101
|
+
}
|
|
102
|
+
if self.stage is not None:
|
|
103
|
+
payload["stage"] = self.stage
|
|
104
|
+
if self.command is not None:
|
|
105
|
+
payload["command"] = self.command
|
|
106
|
+
if query is not None:
|
|
107
|
+
payload["query"] = query
|
|
108
|
+
if cwd is not None:
|
|
109
|
+
payload["cwd"] = cwd
|
|
110
|
+
return payload
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True)
|
|
114
|
+
class Command:
|
|
115
|
+
name: str
|
|
116
|
+
tokens: tuple[str, ...] = ()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class Cwd:
|
|
121
|
+
space_token: str | None = None
|
|
122
|
+
collection_id: str | None = None
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def path(self) -> str:
|
|
126
|
+
if not self.space_token:
|
|
127
|
+
return "/spaces"
|
|
128
|
+
path = f"/spaces/{self.space_token}"
|
|
129
|
+
if self.collection_id:
|
|
130
|
+
path += f"/collections/{self.collection_id}"
|
|
131
|
+
return path
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def parse_query(query: str) -> list[Command]:
|
|
135
|
+
if not query.strip():
|
|
136
|
+
raise FsQueryError(
|
|
137
|
+
"Query must not be empty.",
|
|
138
|
+
code="empty_query",
|
|
139
|
+
hint="Start with `help` for the manual or `ls` to inspect the current cwd.",
|
|
140
|
+
examples=["help", "ls"],
|
|
141
|
+
help_query="help overview",
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
lexer = shlex.shlex(query, posix=True, punctuation_chars="|")
|
|
145
|
+
lexer.whitespace_split = True
|
|
146
|
+
lexer.commenters = ""
|
|
147
|
+
tokens = list(lexer)
|
|
148
|
+
except ValueError as exc:
|
|
149
|
+
raise FsQueryError(
|
|
150
|
+
f"Could not parse the query: {exc}",
|
|
151
|
+
code="parse_error",
|
|
152
|
+
hint="Close all quotes and quote multi-word search text.",
|
|
153
|
+
examples=['grep "authentication policy"', 'find --name "project notes"'],
|
|
154
|
+
help_query="help pipelines",
|
|
155
|
+
) from exc
|
|
156
|
+
|
|
157
|
+
stages: list[list[str]] = [[]]
|
|
158
|
+
for token in tokens:
|
|
159
|
+
if token == "|":
|
|
160
|
+
if not stages[-1]:
|
|
161
|
+
raise FsQueryError(
|
|
162
|
+
"Pipeline contains an empty command before `|`.",
|
|
163
|
+
code="empty_pipeline_stage",
|
|
164
|
+
hint="Put exactly one command between each pipe.",
|
|
165
|
+
examples=["ls | limit 10", 'grep "policy" | neighbors --depth 1'],
|
|
166
|
+
help_query="help pipelines",
|
|
167
|
+
)
|
|
168
|
+
stages.append([])
|
|
169
|
+
else:
|
|
170
|
+
stages[-1].append(token)
|
|
171
|
+
if not stages[-1]:
|
|
172
|
+
raise FsQueryError(
|
|
173
|
+
"Pipeline cannot end with `|`.",
|
|
174
|
+
code="empty_pipeline_stage",
|
|
175
|
+
hint="Remove the final pipe or add the next command.",
|
|
176
|
+
examples=["ls | limit 10"],
|
|
177
|
+
help_query="help pipelines",
|
|
178
|
+
)
|
|
179
|
+
if len(stages) > MAX_PIPELINE_STAGES:
|
|
180
|
+
raise FsQueryError(
|
|
181
|
+
f"Pipeline supports at most {MAX_PIPELINE_STAGES} commands.",
|
|
182
|
+
code="pipeline_too_long",
|
|
183
|
+
hint="Split the work into multiple requests and reuse returned node ids.",
|
|
184
|
+
help_query="help pipelines",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
commands: list[Command] = []
|
|
188
|
+
for stage in stages:
|
|
189
|
+
name = stage[0].lower()
|
|
190
|
+
if name not in SUPPORTED_COMMANDS:
|
|
191
|
+
close = get_close_matches(name, sorted(SUPPORTED_COMMANDS), n=1, cutoff=0.55)
|
|
192
|
+
suggestion = close[0] if close else None
|
|
193
|
+
raise FsQueryError(
|
|
194
|
+
f"Unknown command: {name}.",
|
|
195
|
+
code="unknown_command",
|
|
196
|
+
hint=(
|
|
197
|
+
f"Did you mean `{suggestion}`? Run `help {suggestion}` for its syntax."
|
|
198
|
+
if suggestion
|
|
199
|
+
else "Run `help commands` to see every supported command."
|
|
200
|
+
),
|
|
201
|
+
examples=[suggestion] if suggestion else ["help commands"],
|
|
202
|
+
help_query=f"help {suggestion}" if suggestion else "help commands",
|
|
203
|
+
)
|
|
204
|
+
commands.append(Command(name=name, tokens=tuple(stage[1:])))
|
|
205
|
+
return commands
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def parse_cwd(raw: str | None) -> Cwd:
|
|
209
|
+
value = (raw or "/spaces").strip()
|
|
210
|
+
if not value.startswith("/"):
|
|
211
|
+
value = f"/{value}"
|
|
212
|
+
parts = [part for part in value.split("/") if part]
|
|
213
|
+
if not parts or parts == ["spaces"]:
|
|
214
|
+
return Cwd()
|
|
215
|
+
if parts[0] != "spaces":
|
|
216
|
+
raise FsQueryError(
|
|
217
|
+
"cwd must begin with `/spaces`.",
|
|
218
|
+
code="invalid_cwd",
|
|
219
|
+
hint="Use `/spaces` to discover spaces, then copy a returned path.",
|
|
220
|
+
examples=["/spaces", "/spaces/space.personal"],
|
|
221
|
+
help_query="help paths",
|
|
222
|
+
)
|
|
223
|
+
if len(parts) == 2:
|
|
224
|
+
return Cwd(space_token=parts[1])
|
|
225
|
+
if len(parts) == 4 and parts[2] == "collections":
|
|
226
|
+
return Cwd(space_token=parts[1], collection_id=parts[3])
|
|
227
|
+
raise FsQueryError(
|
|
228
|
+
"cwd has an unsupported shape.",
|
|
229
|
+
code="invalid_cwd",
|
|
230
|
+
hint="Use one of the documented cwd forms exactly.",
|
|
231
|
+
examples=[
|
|
232
|
+
"/spaces",
|
|
233
|
+
"/spaces/space.personal",
|
|
234
|
+
"/spaces/space.personal/collections/COLLECTION_ID",
|
|
235
|
+
],
|
|
236
|
+
help_query="help paths",
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _options(tokens: Iterable[str]) -> tuple[dict[str, str | bool], list[str]]:
|
|
241
|
+
options: dict[str, str | bool] = {}
|
|
242
|
+
positional: list[str] = []
|
|
243
|
+
values = list(tokens)
|
|
244
|
+
index = 0
|
|
245
|
+
while index < len(values):
|
|
246
|
+
token = values[index]
|
|
247
|
+
if token.startswith("--"):
|
|
248
|
+
key, separator, inline = token[2:].partition("=")
|
|
249
|
+
if not key:
|
|
250
|
+
raise FsQueryError("invalid empty flag")
|
|
251
|
+
if separator:
|
|
252
|
+
options[key.replace("-", "_")] = inline
|
|
253
|
+
elif index + 1 < len(values) and not values[index + 1].startswith("--"):
|
|
254
|
+
options[key.replace("-", "_")] = values[index + 1]
|
|
255
|
+
index += 1
|
|
256
|
+
else:
|
|
257
|
+
options[key.replace("-", "_")] = True
|
|
258
|
+
else:
|
|
259
|
+
positional.append(token)
|
|
260
|
+
index += 1
|
|
261
|
+
return options, positional
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _checked_options(
|
|
265
|
+
command: Command,
|
|
266
|
+
*,
|
|
267
|
+
allowed: set[str],
|
|
268
|
+
) -> tuple[dict[str, str | bool], list[str]]:
|
|
269
|
+
options, positional = _options(command.tokens)
|
|
270
|
+
unknown = sorted(set(options) - allowed)
|
|
271
|
+
if unknown:
|
|
272
|
+
flag = unknown[0].replace("_", "-")
|
|
273
|
+
documented = [
|
|
274
|
+
known.replace("_", "-")
|
|
275
|
+
for known in sorted(allowed)
|
|
276
|
+
]
|
|
277
|
+
close = get_close_matches(flag, documented, n=1, cutoff=0.55)
|
|
278
|
+
suggestion = f"--{close[0]}" if close else None
|
|
279
|
+
raise FsQueryError(
|
|
280
|
+
f"`{command.name}` does not support flag `--{flag}`.",
|
|
281
|
+
code="unknown_flag",
|
|
282
|
+
hint=(
|
|
283
|
+
f"Use `{suggestion}` instead."
|
|
284
|
+
if suggestion
|
|
285
|
+
else f"Supported flags: {', '.join(f'--{item}' for item in documented) or '(none)'}."
|
|
286
|
+
),
|
|
287
|
+
examples=COMMANDS[command.name].get("examples", [])[:2],
|
|
288
|
+
help_query=f"help {command.name}",
|
|
289
|
+
)
|
|
290
|
+
return options, positional
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _integer(
|
|
294
|
+
value: Any,
|
|
295
|
+
*,
|
|
296
|
+
label: str,
|
|
297
|
+
minimum: int,
|
|
298
|
+
maximum: int,
|
|
299
|
+
command: str,
|
|
300
|
+
) -> int:
|
|
301
|
+
try:
|
|
302
|
+
parsed = int(_text(value))
|
|
303
|
+
except (TypeError, ValueError) as exc:
|
|
304
|
+
raise FsQueryError(
|
|
305
|
+
f"{label} must be an integer, received `{value}`.",
|
|
306
|
+
code="invalid_number",
|
|
307
|
+
hint=f"Use a whole number from {minimum} to {maximum}.",
|
|
308
|
+
examples=COMMANDS[command].get("examples", [])[:2],
|
|
309
|
+
help_query=f"help {command}",
|
|
310
|
+
) from exc
|
|
311
|
+
if parsed < minimum or parsed > maximum:
|
|
312
|
+
raise FsQueryError(
|
|
313
|
+
f"{label} must be between {minimum} and {maximum}, received {parsed}.",
|
|
314
|
+
code="number_out_of_range",
|
|
315
|
+
hint=f"Choose a value from {minimum} to {maximum}.",
|
|
316
|
+
examples=COMMANDS[command].get("examples", [])[:2],
|
|
317
|
+
help_query=f"help {command}",
|
|
318
|
+
)
|
|
319
|
+
return parsed
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _text(value: Any) -> str:
|
|
323
|
+
return str(value or "")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _search_options(command: Command) -> tuple[dict[str, Any], list[str]]:
|
|
327
|
+
aliases = {
|
|
328
|
+
"-i": "ignore_case",
|
|
329
|
+
"--ignore-case": "ignore_case",
|
|
330
|
+
"-s": "case_sensitive",
|
|
331
|
+
"--case-sensitive": "case_sensitive",
|
|
332
|
+
"-S": "smart_case",
|
|
333
|
+
"--smart-case": "smart_case",
|
|
334
|
+
"-F": "literal",
|
|
335
|
+
"--fixed-strings": "literal",
|
|
336
|
+
"--literal": "literal",
|
|
337
|
+
"-v": "invert",
|
|
338
|
+
"--invert-match": "invert",
|
|
339
|
+
}
|
|
340
|
+
options: dict[str, Any] = {}
|
|
341
|
+
positional: list[str] = []
|
|
342
|
+
tokens = list(command.tokens)
|
|
343
|
+
index = 0
|
|
344
|
+
while index < len(tokens):
|
|
345
|
+
token = tokens[index]
|
|
346
|
+
if token in aliases:
|
|
347
|
+
options[aliases[token]] = True
|
|
348
|
+
elif token in {"-m", "--max-count", "--limit"}:
|
|
349
|
+
if index + 1 >= len(tokens):
|
|
350
|
+
raise FsQueryError(
|
|
351
|
+
f"`{token}` requires an integer.",
|
|
352
|
+
code="missing_argument",
|
|
353
|
+
hint="Put a maximum result count after the flag.",
|
|
354
|
+
examples=COMMANDS[command.name]["examples"][:2],
|
|
355
|
+
help_query=f"help {command.name}",
|
|
356
|
+
)
|
|
357
|
+
index += 1
|
|
358
|
+
options["limit"] = tokens[index]
|
|
359
|
+
elif token.startswith("-"):
|
|
360
|
+
raise FsQueryError(
|
|
361
|
+
f"`{command.name}` does not support flag `{token}`.",
|
|
362
|
+
code="unknown_flag",
|
|
363
|
+
hint="Run the focused help to see supported ripgrep-style flags.",
|
|
364
|
+
examples=COMMANDS[command.name]["examples"][:2],
|
|
365
|
+
help_query=f"help {command.name}",
|
|
366
|
+
)
|
|
367
|
+
else:
|
|
368
|
+
positional.append(token)
|
|
369
|
+
index += 1
|
|
370
|
+
return options, positional
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _display(memory: dict[str, Any], placement: dict[str, Any] | None = None) -> str:
|
|
374
|
+
if placement and placement.get("name"):
|
|
375
|
+
return _text(placement["name"])
|
|
376
|
+
payload = memory.get("payload") if isinstance(memory.get("payload"), dict) else {}
|
|
377
|
+
if memory.get("node_type") == "collection" and payload.get("name"):
|
|
378
|
+
return _text(payload["name"])
|
|
379
|
+
for key in ("file_name", "filename"):
|
|
380
|
+
if payload.get(key):
|
|
381
|
+
return _text(payload[key])
|
|
382
|
+
content = " ".join(_text(memory.get("content")).split())
|
|
383
|
+
return content[:77] + ("..." if len(content) > 77 else "")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
class FsQueryExecutor:
|
|
387
|
+
def __init__(
|
|
388
|
+
self,
|
|
389
|
+
engine: ArthaEngine,
|
|
390
|
+
*,
|
|
391
|
+
cwd: Cwd,
|
|
392
|
+
spaces: list[dict[str, Any]],
|
|
393
|
+
org_id: str | None,
|
|
394
|
+
) -> None:
|
|
395
|
+
self.engine = engine
|
|
396
|
+
self.cwd = cwd
|
|
397
|
+
self.spaces = spaces
|
|
398
|
+
self.org_id = org_id
|
|
399
|
+
|
|
400
|
+
def execute(self, commands: list[Command]) -> dict[str, Any]:
|
|
401
|
+
items: list[dict[str, Any]] | None = None
|
|
402
|
+
trace: list[dict[str, Any]] = []
|
|
403
|
+
navigation: dict[str, Any] | None = None
|
|
404
|
+
manual: dict[str, Any] | None = None
|
|
405
|
+
for index, command in enumerate(commands):
|
|
406
|
+
try:
|
|
407
|
+
if command.name in {"cd", "pwd", "help", "explain"} and len(commands) != 1:
|
|
408
|
+
raise FsQueryError(
|
|
409
|
+
f"`{command.name}` must be used as a standalone command.",
|
|
410
|
+
code="standalone_command",
|
|
411
|
+
hint=f"Run `{command.name}` in its own request without a pipe.",
|
|
412
|
+
examples=COMMANDS[command.name].get("examples", [])[:2],
|
|
413
|
+
help_query=f"help {command.name}",
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
before = len(items) if items is not None else None
|
|
417
|
+
if command.name == "ls":
|
|
418
|
+
items = self._ls(command)
|
|
419
|
+
elif command.name == "find":
|
|
420
|
+
items = self._find(command, items)
|
|
421
|
+
elif command.name == "grep":
|
|
422
|
+
items = self._grep(command, items)
|
|
423
|
+
elif command.name == "rg":
|
|
424
|
+
items = self._rg(command, items)
|
|
425
|
+
elif command.name == "links":
|
|
426
|
+
items = self._links(command, items, neighbors=False)
|
|
427
|
+
elif command.name in {"neighbors", "neighborhood"}:
|
|
428
|
+
items = self._links(command, items, neighbors=True)
|
|
429
|
+
elif command.name == "graph":
|
|
430
|
+
items = self._graph(command)
|
|
431
|
+
elif command.name == "parents":
|
|
432
|
+
items = self._directional_relatives(command, items, parents=True)
|
|
433
|
+
elif command.name == "children":
|
|
434
|
+
items = self._directional_relatives(command, items, parents=False)
|
|
435
|
+
elif command.name == "path":
|
|
436
|
+
items = self._path(command, items)
|
|
437
|
+
elif command.name == "hubs":
|
|
438
|
+
items = self._hubs(command, items)
|
|
439
|
+
elif command.name == "semantic":
|
|
440
|
+
items = self._semantic(command, items)
|
|
441
|
+
elif command.name == "related":
|
|
442
|
+
items = self._related(command, items)
|
|
443
|
+
elif command.name == "traverse":
|
|
444
|
+
items = self._traverse(command, items)
|
|
445
|
+
elif command.name == "why":
|
|
446
|
+
items = self._why(command, items)
|
|
447
|
+
elif command.name == "tree":
|
|
448
|
+
items = self._tree(command)
|
|
449
|
+
elif command.name == "where":
|
|
450
|
+
items = self._where(command, items)
|
|
451
|
+
elif command.name == "sort":
|
|
452
|
+
items = self._sort(command, items)
|
|
453
|
+
elif command.name in {"limit", "head"}:
|
|
454
|
+
items = self._limit(command, items)
|
|
455
|
+
elif command.name == "select":
|
|
456
|
+
items = self._select(command, items)
|
|
457
|
+
elif command.name == "wc":
|
|
458
|
+
items = self._wc(command, items)
|
|
459
|
+
elif command.name == "cat":
|
|
460
|
+
items = self._cat(command, items)
|
|
461
|
+
elif command.name == "stat":
|
|
462
|
+
items = self._stat(command, items)
|
|
463
|
+
elif command.name == "cd":
|
|
464
|
+
navigation = self._cd(command)
|
|
465
|
+
items = []
|
|
466
|
+
elif command.name == "pwd":
|
|
467
|
+
if command.tokens:
|
|
468
|
+
raise FsQueryError(
|
|
469
|
+
"`pwd` does not accept arguments.",
|
|
470
|
+
code="unexpected_argument",
|
|
471
|
+
hint="Run `pwd` by itself.",
|
|
472
|
+
examples=["pwd"],
|
|
473
|
+
help_query="help pwd",
|
|
474
|
+
)
|
|
475
|
+
items = [{"kind": "cwd", "display": self.cwd.path, "path": self.cwd.path}]
|
|
476
|
+
elif command.name == "help":
|
|
477
|
+
items = self._help(command)
|
|
478
|
+
if items:
|
|
479
|
+
manual_value = items[0].get("manual")
|
|
480
|
+
manual = manual_value if isinstance(manual_value, dict) else None
|
|
481
|
+
elif command.name == "explain":
|
|
482
|
+
items = self._explain(command)
|
|
483
|
+
except FsQueryError as exc:
|
|
484
|
+
raise exc.add_context(stage=index + 1, command=command.name) from exc
|
|
485
|
+
trace.append(
|
|
486
|
+
{
|
|
487
|
+
"stage": index + 1,
|
|
488
|
+
"command": command.name,
|
|
489
|
+
"input_count": before,
|
|
490
|
+
"output_count": len(items or []),
|
|
491
|
+
}
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
kind = "navigation" if navigation else "help" if manual else "result"
|
|
495
|
+
return {
|
|
496
|
+
"kind": kind,
|
|
497
|
+
"cwd": self.cwd.path,
|
|
498
|
+
"navigation": navigation,
|
|
499
|
+
"protocol": "memuron.graph-filesystem.v1",
|
|
500
|
+
"manual": manual,
|
|
501
|
+
"items": (items or [])[:MAX_RESULT_ITEMS],
|
|
502
|
+
"count": len(items or []),
|
|
503
|
+
"trace": trace,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
def _org_token(self) -> str:
|
|
507
|
+
return org_scope_token(self.org_id) if self.org_id else ""
|
|
508
|
+
|
|
509
|
+
def _with_paths(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
510
|
+
for item in items:
|
|
511
|
+
memory_id = _text(item.get("id"))
|
|
512
|
+
if item.get("type") == "collection" or item.get("node_type") == "collection":
|
|
513
|
+
item["path"] = f"/spaces/{self.cwd.space_token}/collections/{memory_id}"
|
|
514
|
+
elif self.cwd.collection_id:
|
|
515
|
+
item["path"] = f"{self.cwd.path}/nodes/{memory_id}"
|
|
516
|
+
else:
|
|
517
|
+
item["path"] = f"/spaces/{self.cwd.space_token}/nodes/{memory_id}"
|
|
518
|
+
return items
|
|
519
|
+
|
|
520
|
+
def _default_items(self) -> list[dict[str, Any]]:
|
|
521
|
+
return self._ls(Command("ls"))
|
|
522
|
+
|
|
523
|
+
def _ls(self, command: Command) -> list[dict[str, Any]]:
|
|
524
|
+
options, positional = _checked_options(
|
|
525
|
+
command,
|
|
526
|
+
allowed={"collections_only", "floating_only"},
|
|
527
|
+
)
|
|
528
|
+
if positional:
|
|
529
|
+
raise FsQueryError(
|
|
530
|
+
"`ls` accepts flags only; it does not accept a path argument.",
|
|
531
|
+
code="unexpected_argument",
|
|
532
|
+
hint="Put the desired path in the request's `cwd`, then run `ls`.",
|
|
533
|
+
examples=["ls", "ls --collections-only"],
|
|
534
|
+
help_query="help ls",
|
|
535
|
+
)
|
|
536
|
+
if not self.cwd.space_token:
|
|
537
|
+
return [
|
|
538
|
+
{
|
|
539
|
+
"kind": "space",
|
|
540
|
+
"id": _text(space["id"]),
|
|
541
|
+
"type": "space",
|
|
542
|
+
"node_type": "space",
|
|
543
|
+
"display": _text(space["name"]),
|
|
544
|
+
"token": _text(space["token"]),
|
|
545
|
+
"description": _text(space.get("description")),
|
|
546
|
+
"enabled": bool(space.get("is_enabled")),
|
|
547
|
+
"path": f"/spaces/{space['token']}",
|
|
548
|
+
}
|
|
549
|
+
for space in self.spaces
|
|
550
|
+
]
|
|
551
|
+
if self.cwd.collection_id:
|
|
552
|
+
items = list_entries(
|
|
553
|
+
self.engine.store,
|
|
554
|
+
org_token=self._org_token(),
|
|
555
|
+
space_token=self.cwd.space_token,
|
|
556
|
+
collection_id=self.cwd.collection_id,
|
|
557
|
+
node_type="collection" if options.get("collections_only") else None,
|
|
558
|
+
limit=MAX_RESULT_ITEMS,
|
|
559
|
+
)
|
|
560
|
+
if options.get("floating_only"):
|
|
561
|
+
items = []
|
|
562
|
+
else:
|
|
563
|
+
items = list_root(
|
|
564
|
+
self.engine.store,
|
|
565
|
+
org_token=self._org_token(),
|
|
566
|
+
space_token=self.cwd.space_token,
|
|
567
|
+
collections_only=bool(options.get("collections_only")),
|
|
568
|
+
floating_only=bool(options.get("floating_only")),
|
|
569
|
+
limit=MAX_RESULT_ITEMS,
|
|
570
|
+
)
|
|
571
|
+
return self._with_paths(items)
|
|
572
|
+
|
|
573
|
+
def _find(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
574
|
+
options, positional = _checked_options(
|
|
575
|
+
command,
|
|
576
|
+
allowed={"type", "encoding", "id", "name", "floating"},
|
|
577
|
+
)
|
|
578
|
+
if positional:
|
|
579
|
+
raise FsQueryError(
|
|
580
|
+
"`find` filters must use named flags.",
|
|
581
|
+
code="unexpected_argument",
|
|
582
|
+
hint="Use `--name TEXT`, `--type TYPE`, `--encoding VALUE`, or `--id ID`.",
|
|
583
|
+
examples=COMMANDS["find"]["examples"][:2],
|
|
584
|
+
help_query="help find",
|
|
585
|
+
)
|
|
586
|
+
if items is None:
|
|
587
|
+
candidates = list_entries(
|
|
588
|
+
self.engine.store,
|
|
589
|
+
org_token=self._org_token(),
|
|
590
|
+
space_token=self.cwd.space_token or "",
|
|
591
|
+
collection_id=self.cwd.collection_id,
|
|
592
|
+
node_type=_text(options.get("type")) or None,
|
|
593
|
+
encoding=_text(options.get("encoding")) or None,
|
|
594
|
+
node_id=_text(options.get("id")) or None,
|
|
595
|
+
name=_text(options.get("name")) or None,
|
|
596
|
+
floating=bool(options.get("floating")),
|
|
597
|
+
limit=MAX_RESULT_ITEMS,
|
|
598
|
+
)
|
|
599
|
+
return self._with_paths(candidates)
|
|
600
|
+
candidates = items
|
|
601
|
+
for key in ("type", "encoding", "id"):
|
|
602
|
+
if key not in options:
|
|
603
|
+
continue
|
|
604
|
+
field = "type" if key == "type" else key
|
|
605
|
+
expected = _text(options[key]).lower()
|
|
606
|
+
candidates = [
|
|
607
|
+
item for item in candidates if _text(item.get(field)).lower() == expected
|
|
608
|
+
]
|
|
609
|
+
if "name" in options:
|
|
610
|
+
needle = _text(options["name"]).lower().replace("*", "")
|
|
611
|
+
candidates = [
|
|
612
|
+
item for item in candidates if needle in _text(item.get("display")).lower()
|
|
613
|
+
]
|
|
614
|
+
if options.get("floating"):
|
|
615
|
+
floating_ids = {
|
|
616
|
+
item["id"]
|
|
617
|
+
for item in list_entries(
|
|
618
|
+
self.engine.store,
|
|
619
|
+
org_token=self._org_token(),
|
|
620
|
+
space_token=self.cwd.space_token or "",
|
|
621
|
+
floating=True,
|
|
622
|
+
limit=MAX_RESULT_ITEMS,
|
|
623
|
+
)
|
|
624
|
+
}
|
|
625
|
+
candidates = [item for item in candidates if item.get("id") in floating_ids]
|
|
626
|
+
return candidates
|
|
627
|
+
|
|
628
|
+
def _grep(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
629
|
+
options, positional = _checked_options(command, allowed={"limit", "literal"})
|
|
630
|
+
literal_value = options.get("literal")
|
|
631
|
+
if isinstance(literal_value, str):
|
|
632
|
+
positional.insert(0, literal_value)
|
|
633
|
+
query = " ".join(positional).strip()
|
|
634
|
+
if not query:
|
|
635
|
+
raise FsQueryError(
|
|
636
|
+
"`grep` requires search text.",
|
|
637
|
+
code="missing_argument",
|
|
638
|
+
hint="Put the search text after grep and quote multi-word text.",
|
|
639
|
+
examples=['grep "authentication"', 'grep "data plane" --limit 20'],
|
|
640
|
+
help_query="help grep",
|
|
641
|
+
)
|
|
642
|
+
return self._content_search(
|
|
643
|
+
command,
|
|
644
|
+
items,
|
|
645
|
+
query=query,
|
|
646
|
+
literal=bool(literal_value),
|
|
647
|
+
case_sensitive=False,
|
|
648
|
+
invert=False,
|
|
649
|
+
limit=options.get("limit"),
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
def _rg(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
653
|
+
options, positional = _search_options(command)
|
|
654
|
+
query = " ".join(positional).strip()
|
|
655
|
+
if not query:
|
|
656
|
+
raise FsQueryError(
|
|
657
|
+
"`rg` requires a search pattern.",
|
|
658
|
+
code="missing_argument",
|
|
659
|
+
hint="Put a regex after rg, or use -F for fixed text.",
|
|
660
|
+
examples=COMMANDS["rg"]["examples"][:2],
|
|
661
|
+
help_query="help rg",
|
|
662
|
+
)
|
|
663
|
+
if options.get("ignore_case") and options.get("case_sensitive"):
|
|
664
|
+
raise FsQueryError(
|
|
665
|
+
"`rg` cannot combine -i and -s.",
|
|
666
|
+
code="conflicting_flags",
|
|
667
|
+
hint="Choose explicit insensitive (-i), explicit sensitive (-s), or smart case (-S/default).",
|
|
668
|
+
examples=COMMANDS["rg"]["examples"][:3],
|
|
669
|
+
help_query="help rg",
|
|
670
|
+
)
|
|
671
|
+
case_sensitive = bool(options.get("case_sensitive"))
|
|
672
|
+
if not options.get("ignore_case") and not options.get("case_sensitive"):
|
|
673
|
+
case_sensitive = any(character.isupper() for character in query)
|
|
674
|
+
return self._content_search(
|
|
675
|
+
command,
|
|
676
|
+
items,
|
|
677
|
+
query=query,
|
|
678
|
+
literal=bool(options.get("literal")),
|
|
679
|
+
case_sensitive=case_sensitive,
|
|
680
|
+
invert=bool(options.get("invert")),
|
|
681
|
+
limit=options.get("limit"),
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
def _content_search(
|
|
685
|
+
self,
|
|
686
|
+
command: Command,
|
|
687
|
+
items: list[dict[str, Any]] | None,
|
|
688
|
+
*,
|
|
689
|
+
query: str,
|
|
690
|
+
literal: bool,
|
|
691
|
+
case_sensitive: bool,
|
|
692
|
+
invert: bool,
|
|
693
|
+
limit: Any,
|
|
694
|
+
) -> list[dict[str, Any]]:
|
|
695
|
+
if not literal:
|
|
696
|
+
try:
|
|
697
|
+
re.compile(query, 0 if case_sensitive else re.IGNORECASE)
|
|
698
|
+
except re.error as exc:
|
|
699
|
+
raise FsQueryError(
|
|
700
|
+
f"Invalid regular expression: {exc}.",
|
|
701
|
+
code="invalid_regex",
|
|
702
|
+
hint=f"Correct the regex, or use {'-F' if command.name == 'rg' else '--literal'} for fixed text.",
|
|
703
|
+
examples=COMMANDS[command.name]["examples"][:3],
|
|
704
|
+
help_query=f"help {command.name}",
|
|
705
|
+
) from exc
|
|
706
|
+
k = _integer(
|
|
707
|
+
limit or 100,
|
|
708
|
+
label=f"{command.name} --limit",
|
|
709
|
+
minimum=1,
|
|
710
|
+
maximum=100,
|
|
711
|
+
command=command.name,
|
|
712
|
+
)
|
|
713
|
+
allowed = None
|
|
714
|
+
if items is not None:
|
|
715
|
+
allowed = {item.get("id") for item in items if item.get("kind") == "node"}
|
|
716
|
+
elif self.cwd.collection_id:
|
|
717
|
+
allowed = {item["id"] for item in self._default_items()}
|
|
718
|
+
hits = grep_entries(
|
|
719
|
+
self.engine.store,
|
|
720
|
+
query,
|
|
721
|
+
org_token=self._org_token(),
|
|
722
|
+
space_token=self.cwd.space_token or "",
|
|
723
|
+
allowed_ids={str(item) for item in allowed} if allowed is not None else None,
|
|
724
|
+
limit=k,
|
|
725
|
+
literal=literal,
|
|
726
|
+
case_sensitive=case_sensitive,
|
|
727
|
+
invert=invert,
|
|
728
|
+
)
|
|
729
|
+
return self._with_paths(hits)
|
|
730
|
+
|
|
731
|
+
def _links(
|
|
732
|
+
self,
|
|
733
|
+
command: Command,
|
|
734
|
+
items: list[dict[str, Any]] | None,
|
|
735
|
+
*,
|
|
736
|
+
neighbors: bool,
|
|
737
|
+
) -> list[dict[str, Any]]:
|
|
738
|
+
options, positional = _checked_options(
|
|
739
|
+
command,
|
|
740
|
+
allowed={
|
|
741
|
+
"depth",
|
|
742
|
+
"direction",
|
|
743
|
+
"outbound",
|
|
744
|
+
"inbound",
|
|
745
|
+
"placements",
|
|
746
|
+
"include_self",
|
|
747
|
+
},
|
|
748
|
+
)
|
|
749
|
+
if positional:
|
|
750
|
+
raise FsQueryError(
|
|
751
|
+
f"`{command.name}` accepts flags only.",
|
|
752
|
+
code="unexpected_argument",
|
|
753
|
+
hint=f"Pipe seed nodes into `{command.name}` or let it use the current `ls` entries.",
|
|
754
|
+
examples=COMMANDS[command.name]["examples"][:2],
|
|
755
|
+
help_query=f"help {command.name}",
|
|
756
|
+
)
|
|
757
|
+
seeds = items if items is not None else self._default_items()
|
|
758
|
+
seed_ids = {item.get("id") for item in seeds if item.get("kind") == "node"}
|
|
759
|
+
direction = _text(options.get("direction") or "both")
|
|
760
|
+
if options.get("outbound"):
|
|
761
|
+
direction = "outbound"
|
|
762
|
+
elif options.get("inbound"):
|
|
763
|
+
direction = "inbound"
|
|
764
|
+
if direction not in {"both", "inbound", "outbound"}:
|
|
765
|
+
raise FsQueryError(
|
|
766
|
+
f"Invalid direction: `{direction}`.",
|
|
767
|
+
code="invalid_choice",
|
|
768
|
+
hint="Use `both`, `inbound`, or `outbound`.",
|
|
769
|
+
examples=[f"{command.name} --direction both", f"{command.name} --outbound"],
|
|
770
|
+
help_query=f"help {command.name}",
|
|
771
|
+
)
|
|
772
|
+
include_placements = bool(options.get("placements")) or neighbors
|
|
773
|
+
depth = _integer(
|
|
774
|
+
options.get("depth") or (2 if neighbors else 1),
|
|
775
|
+
label=f"{command.name} --depth",
|
|
776
|
+
minimum=1,
|
|
777
|
+
maximum=5,
|
|
778
|
+
command=command.name,
|
|
779
|
+
)
|
|
780
|
+
output = expand_neighbors(
|
|
781
|
+
self.engine.store,
|
|
782
|
+
{str(item) for item in seed_ids if item},
|
|
783
|
+
org_token=self._org_token(),
|
|
784
|
+
space_token=self.cwd.space_token or "",
|
|
785
|
+
depth=depth,
|
|
786
|
+
direction=direction,
|
|
787
|
+
include_placements=include_placements,
|
|
788
|
+
include_self=bool(options.get("include_self")),
|
|
789
|
+
)
|
|
790
|
+
return self._with_paths(output)
|
|
791
|
+
|
|
792
|
+
def _graph(self, command: Command) -> list[dict[str, Any]]:
|
|
793
|
+
options, positional = _checked_options(command, allowed={"limit"})
|
|
794
|
+
if positional:
|
|
795
|
+
raise FsQueryError(
|
|
796
|
+
"`graph` accepts flags only.",
|
|
797
|
+
code="unexpected_argument",
|
|
798
|
+
hint="Use --limit to bound exported nodes.",
|
|
799
|
+
examples=COMMANDS["graph"]["examples"][:2],
|
|
800
|
+
help_query="help graph",
|
|
801
|
+
)
|
|
802
|
+
limit = _integer(
|
|
803
|
+
options.get("limit") or 100,
|
|
804
|
+
label="graph --limit",
|
|
805
|
+
minimum=1,
|
|
806
|
+
maximum=1000,
|
|
807
|
+
command="graph",
|
|
808
|
+
)
|
|
809
|
+
nodes = list_entries(
|
|
810
|
+
self.engine.store,
|
|
811
|
+
org_token=self._org_token(),
|
|
812
|
+
space_token=self.cwd.space_token or "",
|
|
813
|
+
limit=limit,
|
|
814
|
+
)
|
|
815
|
+
node_ids = {str(node["id"]) for node in nodes}
|
|
816
|
+
edges = [
|
|
817
|
+
{
|
|
818
|
+
"id": str(edge["edge_id"]),
|
|
819
|
+
"source": str(edge["source_id"]),
|
|
820
|
+
"target": str(edge["target_id"]),
|
|
821
|
+
"type": str(edge["edge_type"]),
|
|
822
|
+
"description": str(edge.get("description") or ""),
|
|
823
|
+
}
|
|
824
|
+
for edge in space_edges(
|
|
825
|
+
self.engine.store,
|
|
826
|
+
org_token=self._org_token(),
|
|
827
|
+
space_token=self.cwd.space_token or "",
|
|
828
|
+
)
|
|
829
|
+
if str(edge["source_id"]) in node_ids and str(edge["target_id"]) in node_ids
|
|
830
|
+
]
|
|
831
|
+
return [{
|
|
832
|
+
"kind": "graph",
|
|
833
|
+
"display": f"{len(nodes)} nodes, {len(edges)} edges",
|
|
834
|
+
"nodes": self._with_paths(nodes),
|
|
835
|
+
"edges": edges,
|
|
836
|
+
"node_count": len(nodes),
|
|
837
|
+
"edge_count": len(edges),
|
|
838
|
+
"bounded": len(nodes) == limit,
|
|
839
|
+
}]
|
|
840
|
+
|
|
841
|
+
def _directional_relatives(
|
|
842
|
+
self,
|
|
843
|
+
command: Command,
|
|
844
|
+
items: list[dict[str, Any]] | None,
|
|
845
|
+
*,
|
|
846
|
+
parents: bool,
|
|
847
|
+
) -> list[dict[str, Any]]:
|
|
848
|
+
options, positional = _checked_options(command, allowed={"all"})
|
|
849
|
+
if positional:
|
|
850
|
+
raise FsQueryError(
|
|
851
|
+
f"`{command.name}` accepts flags only.",
|
|
852
|
+
code="unexpected_argument",
|
|
853
|
+
hint=f"Pipe nodes into `{command.name}`.",
|
|
854
|
+
examples=COMMANDS[command.name]["examples"][:2],
|
|
855
|
+
help_query=f"help {command.name}",
|
|
856
|
+
)
|
|
857
|
+
seeds = items if items is not None else self._default_items()
|
|
858
|
+
seed_ids = {str(item["id"]) for item in seeds if item.get("kind") == "node"}
|
|
859
|
+
edges = space_edges(
|
|
860
|
+
self.engine.store,
|
|
861
|
+
org_token=self._org_token(),
|
|
862
|
+
space_token=self.cwd.space_token or "",
|
|
863
|
+
include_placements=True,
|
|
864
|
+
)
|
|
865
|
+
relative_ids: list[str] = []
|
|
866
|
+
for edge in edges:
|
|
867
|
+
if not options.get("all") and edge["edge_type"] != "placement":
|
|
868
|
+
continue
|
|
869
|
+
source_id = str(edge["source_id"])
|
|
870
|
+
target_id = str(edge["target_id"])
|
|
871
|
+
if parents and target_id in seed_ids:
|
|
872
|
+
relative_ids.append(source_id)
|
|
873
|
+
elif not parents and source_id in seed_ids:
|
|
874
|
+
relative_ids.append(target_id)
|
|
875
|
+
nodes = get_nodes(
|
|
876
|
+
self.engine.store,
|
|
877
|
+
list(dict.fromkeys(relative_ids)),
|
|
878
|
+
org_token=self._org_token(),
|
|
879
|
+
space_token=self.cwd.space_token,
|
|
880
|
+
)
|
|
881
|
+
return self._with_paths(nodes)
|
|
882
|
+
|
|
883
|
+
def _hubs(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
884
|
+
options, positional = _checked_options(command, allowed={"limit"})
|
|
885
|
+
if positional:
|
|
886
|
+
raise FsQueryError(
|
|
887
|
+
"`hubs` accepts flags only.",
|
|
888
|
+
code="unexpected_argument",
|
|
889
|
+
hint="Use --limit to control the number of hubs.",
|
|
890
|
+
examples=COMMANDS["hubs"]["examples"][:2],
|
|
891
|
+
help_query="help hubs",
|
|
892
|
+
)
|
|
893
|
+
limit = _integer(
|
|
894
|
+
options.get("limit") or 10,
|
|
895
|
+
label="hubs --limit",
|
|
896
|
+
minimum=1,
|
|
897
|
+
maximum=100,
|
|
898
|
+
command="hubs",
|
|
899
|
+
)
|
|
900
|
+
candidates = items
|
|
901
|
+
if candidates is None:
|
|
902
|
+
candidates = list_entries(
|
|
903
|
+
self.engine.store,
|
|
904
|
+
org_token=self._org_token(),
|
|
905
|
+
space_token=self.cwd.space_token or "",
|
|
906
|
+
limit=MAX_RESULT_ITEMS,
|
|
907
|
+
)
|
|
908
|
+
return sorted(
|
|
909
|
+
[item for item in candidates if item.get("kind") == "node"],
|
|
910
|
+
key=lambda item: (-int(item.get("degree") or 0), str(item.get("id"))),
|
|
911
|
+
)[:limit]
|
|
912
|
+
|
|
913
|
+
def _path(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
914
|
+
tokens = list(command.tokens)
|
|
915
|
+
if items is not None:
|
|
916
|
+
seeds = [item for item in items if item.get("kind") == "node"]
|
|
917
|
+
if len(seeds) != 1 or len(tokens) != 1:
|
|
918
|
+
raise FsQueryError(
|
|
919
|
+
"Piped `path` requires exactly one input node and one destination id.",
|
|
920
|
+
code="invalid_syntax",
|
|
921
|
+
hint="Limit the incoming stream to one node, then provide the destination id.",
|
|
922
|
+
examples=COMMANDS["path"]["examples"][:2],
|
|
923
|
+
help_query="help path",
|
|
924
|
+
)
|
|
925
|
+
from_id, to_id = str(seeds[0]["id"]), tokens[0]
|
|
926
|
+
elif len(tokens) == 2:
|
|
927
|
+
from_id, to_id = tokens
|
|
928
|
+
else:
|
|
929
|
+
raise FsQueryError(
|
|
930
|
+
"`path` requires FROM_ID TO_ID, or one piped node plus TO_ID.",
|
|
931
|
+
code="invalid_syntax",
|
|
932
|
+
hint="Provide two ids, or pipe one node into path.",
|
|
933
|
+
examples=COMMANDS["path"]["examples"][:2],
|
|
934
|
+
help_query="help path",
|
|
935
|
+
)
|
|
936
|
+
all_nodes = list_entries(
|
|
937
|
+
self.engine.store,
|
|
938
|
+
org_token=self._org_token(),
|
|
939
|
+
space_token=self.cwd.space_token or "",
|
|
940
|
+
limit=MAX_RESULT_ITEMS,
|
|
941
|
+
)
|
|
942
|
+
known = {str(item["id"]) for item in all_nodes}
|
|
943
|
+
if from_id not in known or to_id not in known:
|
|
944
|
+
raise FsQueryError(
|
|
945
|
+
"One or both path endpoints are not visible in this space.",
|
|
946
|
+
code="node_not_found",
|
|
947
|
+
hint="Use find, rg, or semantic to obtain endpoint ids in the current space.",
|
|
948
|
+
examples=COMMANDS["path"]["examples"][:2],
|
|
949
|
+
help_query="help path",
|
|
950
|
+
)
|
|
951
|
+
edges = space_edges(
|
|
952
|
+
self.engine.store,
|
|
953
|
+
org_token=self._org_token(),
|
|
954
|
+
space_token=self.cwd.space_token or "",
|
|
955
|
+
)
|
|
956
|
+
adjacency: dict[str, list[tuple[str, dict[str, Any]]]] = {}
|
|
957
|
+
for edge in edges:
|
|
958
|
+
source_id, target_id = str(edge["source_id"]), str(edge["target_id"])
|
|
959
|
+
if source_id == target_id:
|
|
960
|
+
continue
|
|
961
|
+
adjacency.setdefault(source_id, []).append((target_id, edge))
|
|
962
|
+
adjacency.setdefault(target_id, []).append((source_id, edge))
|
|
963
|
+
queue: list[tuple[str, list[str], list[dict[str, Any]]]] = [(from_id, [from_id], [])]
|
|
964
|
+
visited = {from_id}
|
|
965
|
+
found_nodes: list[str] | None = None
|
|
966
|
+
found_edges: list[dict[str, Any]] = []
|
|
967
|
+
while queue:
|
|
968
|
+
current, node_path, edge_path = queue.pop(0)
|
|
969
|
+
if current == to_id:
|
|
970
|
+
found_nodes, found_edges = node_path, edge_path
|
|
971
|
+
break
|
|
972
|
+
for neighbor_id, edge in adjacency.get(current, []):
|
|
973
|
+
if neighbor_id in visited:
|
|
974
|
+
continue
|
|
975
|
+
visited.add(neighbor_id)
|
|
976
|
+
queue.append((neighbor_id, [*node_path, neighbor_id], [*edge_path, edge]))
|
|
977
|
+
if found_nodes is None:
|
|
978
|
+
return [{
|
|
979
|
+
"kind": "path",
|
|
980
|
+
"status": "no_path",
|
|
981
|
+
"from_id": from_id,
|
|
982
|
+
"to_id": to_id,
|
|
983
|
+
"display": "No graph path found.",
|
|
984
|
+
}]
|
|
985
|
+
nodes_by_id = {
|
|
986
|
+
str(item["id"]): item
|
|
987
|
+
for item in get_nodes(
|
|
988
|
+
self.engine.store,
|
|
989
|
+
found_nodes,
|
|
990
|
+
org_token=self._org_token(),
|
|
991
|
+
space_token=self.cwd.space_token,
|
|
992
|
+
)
|
|
993
|
+
}
|
|
994
|
+
output = []
|
|
995
|
+
for index, node_id in enumerate(found_nodes):
|
|
996
|
+
item = dict(nodes_by_id[node_id])
|
|
997
|
+
item["path_index"] = index
|
|
998
|
+
item["path_length"] = len(found_nodes) - 1
|
|
999
|
+
if index:
|
|
1000
|
+
edge = found_edges[index - 1]
|
|
1001
|
+
item["via"] = {
|
|
1002
|
+
"id": edge["edge_id"],
|
|
1003
|
+
"type": edge["edge_type"],
|
|
1004
|
+
"description": edge.get("description") or "",
|
|
1005
|
+
}
|
|
1006
|
+
output.append(item)
|
|
1007
|
+
return self._with_paths(output)
|
|
1008
|
+
|
|
1009
|
+
def _semantic(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
1010
|
+
options, positional = _checked_options(command, allowed={"limit", "links"})
|
|
1011
|
+
query = " ".join(positional).strip()
|
|
1012
|
+
if not query:
|
|
1013
|
+
raise FsQueryError(
|
|
1014
|
+
"`semantic` requires a natural-language query.",
|
|
1015
|
+
code="missing_argument",
|
|
1016
|
+
hint="Describe the memory you want to retrieve.",
|
|
1017
|
+
examples=COMMANDS["semantic"]["examples"][:2],
|
|
1018
|
+
help_query="help semantic",
|
|
1019
|
+
)
|
|
1020
|
+
limit = _integer(
|
|
1021
|
+
options.get("limit") or 10,
|
|
1022
|
+
label="semantic --limit",
|
|
1023
|
+
minimum=1,
|
|
1024
|
+
maximum=100,
|
|
1025
|
+
command="semantic",
|
|
1026
|
+
)
|
|
1027
|
+
results, _scope = search_memories(
|
|
1028
|
+
self.engine,
|
|
1029
|
+
query,
|
|
1030
|
+
k=limit,
|
|
1031
|
+
scope=[self._org_token(), self.cwd.space_token or ""],
|
|
1032
|
+
include_links=bool(options.get("links")),
|
|
1033
|
+
)
|
|
1034
|
+
allowed_ids = None
|
|
1035
|
+
if items is not None:
|
|
1036
|
+
allowed_ids = {str(item["id"]) for item in items if item.get("kind") == "node"}
|
|
1037
|
+
scores = {
|
|
1038
|
+
str(result["id"]): float(result.get("semantic_score") or 0)
|
|
1039
|
+
for result in results
|
|
1040
|
+
if result.get("type") == "memory_node"
|
|
1041
|
+
and (allowed_ids is None or str(result["id"]) in allowed_ids)
|
|
1042
|
+
}
|
|
1043
|
+
matches = {
|
|
1044
|
+
str(result["id"]): result
|
|
1045
|
+
for result in results
|
|
1046
|
+
if result.get("type") == "memory_node"
|
|
1047
|
+
and (allowed_ids is None or str(result["id"]) in allowed_ids)
|
|
1048
|
+
}
|
|
1049
|
+
nodes = get_nodes(
|
|
1050
|
+
self.engine.store,
|
|
1051
|
+
list(scores),
|
|
1052
|
+
org_token=self._org_token(),
|
|
1053
|
+
space_token=self.cwd.space_token,
|
|
1054
|
+
)
|
|
1055
|
+
for node in nodes:
|
|
1056
|
+
node["score"] = scores.get(str(node["id"]), 0)
|
|
1057
|
+
node["match"] = "semantic"
|
|
1058
|
+
node["semantic_query"] = query
|
|
1059
|
+
match = matches.get(str(node["id"]), {})
|
|
1060
|
+
if match.get("matched_via"):
|
|
1061
|
+
node["matched_via"] = match.get("matched_via")
|
|
1062
|
+
node["matched_via_link_id"] = match.get("matched_via_link_id")
|
|
1063
|
+
node["matched_link_description"] = match.get("matched_link_description")
|
|
1064
|
+
return self._with_paths(sorted(nodes, key=lambda item: -float(item["score"])))
|
|
1065
|
+
|
|
1066
|
+
def _related(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
1067
|
+
options, positional = _checked_options(command, allowed={"limit"})
|
|
1068
|
+
if positional:
|
|
1069
|
+
raise FsQueryError(
|
|
1070
|
+
"`related` accepts flags only.",
|
|
1071
|
+
code="unexpected_argument",
|
|
1072
|
+
hint="Pipe one or more seed nodes into related.",
|
|
1073
|
+
examples=COMMANDS["related"]["examples"][:2],
|
|
1074
|
+
help_query="help related",
|
|
1075
|
+
)
|
|
1076
|
+
seeds = [item for item in (items or []) if item.get("kind") == "node"]
|
|
1077
|
+
if not seeds:
|
|
1078
|
+
raise FsQueryError(
|
|
1079
|
+
"`related` requires pipeline input.",
|
|
1080
|
+
code="missing_input",
|
|
1081
|
+
hint="Find or search for seed memories first.",
|
|
1082
|
+
examples=COMMANDS["related"]["examples"][:2],
|
|
1083
|
+
help_query="help related",
|
|
1084
|
+
)
|
|
1085
|
+
limit = _integer(
|
|
1086
|
+
options.get("limit") or 10,
|
|
1087
|
+
label="related --limit",
|
|
1088
|
+
minimum=1,
|
|
1089
|
+
maximum=100,
|
|
1090
|
+
command="related",
|
|
1091
|
+
)
|
|
1092
|
+
seed_ids = {str(item["id"]) for item in seeds}
|
|
1093
|
+
semantic_queries = {
|
|
1094
|
+
str(item["semantic_query"])
|
|
1095
|
+
for item in seeds
|
|
1096
|
+
if item.get("semantic_query")
|
|
1097
|
+
}
|
|
1098
|
+
if len(semantic_queries) == 1:
|
|
1099
|
+
query = semantic_queries.pop()
|
|
1100
|
+
else:
|
|
1101
|
+
query = "\n".join(
|
|
1102
|
+
str(item.get("preview") or item.get("display") or "")[:500]
|
|
1103
|
+
for item in seeds[:5]
|
|
1104
|
+
)
|
|
1105
|
+
results = self._semantic(Command("semantic", (query, "--limit", str(min(100, limit + len(seeds))))), None)
|
|
1106
|
+
return [item for item in results if str(item.get("id")) not in seed_ids][:limit]
|
|
1107
|
+
|
|
1108
|
+
def _traverse(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
1109
|
+
options, positional = _checked_options(command, allowed={"depth", "threshold"})
|
|
1110
|
+
query = " ".join(positional).strip()
|
|
1111
|
+
seeds = [item for item in (items or []) if item.get("kind") == "node"]
|
|
1112
|
+
if len(seeds) != 1 or not query:
|
|
1113
|
+
raise FsQueryError(
|
|
1114
|
+
"`traverse` requires exactly one piped seed node and a semantic query.",
|
|
1115
|
+
code="invalid_syntax",
|
|
1116
|
+
hint="Find one seed, limit to one, then describe which edges to follow.",
|
|
1117
|
+
examples=COMMANDS["traverse"]["examples"][:2],
|
|
1118
|
+
help_query="help traverse",
|
|
1119
|
+
)
|
|
1120
|
+
depth = _integer(
|
|
1121
|
+
options.get("depth") or 2,
|
|
1122
|
+
label="traverse --depth",
|
|
1123
|
+
minimum=1,
|
|
1124
|
+
maximum=5,
|
|
1125
|
+
command="traverse",
|
|
1126
|
+
)
|
|
1127
|
+
try:
|
|
1128
|
+
threshold = float(options.get("threshold") or 0.7)
|
|
1129
|
+
except (TypeError, ValueError) as exc:
|
|
1130
|
+
raise FsQueryError(
|
|
1131
|
+
"traverse --threshold must be a number from 0 to 1.",
|
|
1132
|
+
code="invalid_number",
|
|
1133
|
+
hint="Use a decimal such as 0.7.",
|
|
1134
|
+
examples=COMMANDS["traverse"]["examples"][:2],
|
|
1135
|
+
help_query="help traverse",
|
|
1136
|
+
) from exc
|
|
1137
|
+
if not 0 <= threshold <= 1:
|
|
1138
|
+
raise FsQueryError(
|
|
1139
|
+
"traverse --threshold must be between 0 and 1.",
|
|
1140
|
+
code="number_out_of_range",
|
|
1141
|
+
hint="Use a decimal from 0 to 1.",
|
|
1142
|
+
examples=COMMANDS["traverse"]["examples"][:2],
|
|
1143
|
+
help_query="help traverse",
|
|
1144
|
+
)
|
|
1145
|
+
payload = semantic_traverse_graph(
|
|
1146
|
+
self.engine,
|
|
1147
|
+
start_memory_id=str(seeds[0]["id"]),
|
|
1148
|
+
query=query,
|
|
1149
|
+
max_hops=depth,
|
|
1150
|
+
edge_similarity_threshold=threshold,
|
|
1151
|
+
scope=[self._org_token(), self.cwd.space_token or ""],
|
|
1152
|
+
)
|
|
1153
|
+
scores: dict[str, float] = {}
|
|
1154
|
+
for edge in payload["traversed_edges"]:
|
|
1155
|
+
destination = str(edge["to_memory_id"])
|
|
1156
|
+
scores[destination] = max(scores.get(destination, 0), float(edge["similarity"]))
|
|
1157
|
+
ids = [str(memory["id"]) for memory in payload["memories"]]
|
|
1158
|
+
nodes = get_nodes(
|
|
1159
|
+
self.engine.store,
|
|
1160
|
+
ids,
|
|
1161
|
+
org_token=self._org_token(),
|
|
1162
|
+
space_token=self.cwd.space_token,
|
|
1163
|
+
)
|
|
1164
|
+
hops = {str(memory["id"]): int(memory["hop_distance"]) for memory in payload["memories"]}
|
|
1165
|
+
for node in nodes:
|
|
1166
|
+
node["hop_distance"] = hops.get(str(node["id"]), 0)
|
|
1167
|
+
node["score"] = scores.get(str(node["id"]), 1.0 if node["id"] == seeds[0]["id"] else 0)
|
|
1168
|
+
node["match"] = "semantic_traversal"
|
|
1169
|
+
return self._with_paths(sorted(nodes, key=lambda item: (item["hop_distance"], -item["score"])))
|
|
1170
|
+
|
|
1171
|
+
def _why(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
1172
|
+
tokens = list(command.tokens)
|
|
1173
|
+
seeds = [item for item in (items or []) if item.get("kind") == "node"]
|
|
1174
|
+
if len(seeds) != 1 or len(tokens) != 1:
|
|
1175
|
+
raise FsQueryError(
|
|
1176
|
+
"`why` requires one piped node and one related node id.",
|
|
1177
|
+
code="invalid_syntax",
|
|
1178
|
+
hint="Limit the source stream to one node, then provide the other node id.",
|
|
1179
|
+
examples=COMMANDS["why"]["examples"][:2],
|
|
1180
|
+
help_query="help why",
|
|
1181
|
+
)
|
|
1182
|
+
source_id, target_id = str(seeds[0]["id"]), tokens[0]
|
|
1183
|
+
edges = space_edges(
|
|
1184
|
+
self.engine.store,
|
|
1185
|
+
org_token=self._org_token(),
|
|
1186
|
+
space_token=self.cwd.space_token or "",
|
|
1187
|
+
)
|
|
1188
|
+
reasons = []
|
|
1189
|
+
for edge in edges:
|
|
1190
|
+
endpoints = {str(edge["source_id"]), str(edge["target_id"])}
|
|
1191
|
+
if endpoints != {source_id, target_id}:
|
|
1192
|
+
continue
|
|
1193
|
+
reasons.append({
|
|
1194
|
+
"kind": "relationship_reason",
|
|
1195
|
+
"id": str(edge["edge_id"]),
|
|
1196
|
+
"type": str(edge["edge_type"]),
|
|
1197
|
+
"source_id": str(edge["source_id"]),
|
|
1198
|
+
"target_id": str(edge["target_id"]),
|
|
1199
|
+
"description": str(edge.get("description") or ""),
|
|
1200
|
+
"display": str(edge.get("description") or edge["edge_type"]),
|
|
1201
|
+
})
|
|
1202
|
+
if reasons:
|
|
1203
|
+
return reasons
|
|
1204
|
+
return [{
|
|
1205
|
+
"kind": "relationship_reason",
|
|
1206
|
+
"type": "none",
|
|
1207
|
+
"source_id": source_id,
|
|
1208
|
+
"target_id": target_id,
|
|
1209
|
+
"description": "No direct projected edge connects these nodes. Use path to inspect indirect connections.",
|
|
1210
|
+
"display": "No direct relationship.",
|
|
1211
|
+
}]
|
|
1212
|
+
|
|
1213
|
+
def _tree(self, command: Command) -> list[dict[str, Any]]:
|
|
1214
|
+
options, positional = _checked_options(command, allowed={"depth"})
|
|
1215
|
+
if positional:
|
|
1216
|
+
raise FsQueryError(
|
|
1217
|
+
"`tree` accepts flags only.",
|
|
1218
|
+
code="unexpected_argument",
|
|
1219
|
+
hint="Set the root with cwd and optionally use --depth.",
|
|
1220
|
+
examples=COMMANDS["tree"]["examples"][:2],
|
|
1221
|
+
help_query="help tree",
|
|
1222
|
+
)
|
|
1223
|
+
max_depth = _integer(
|
|
1224
|
+
options.get("depth") or 3,
|
|
1225
|
+
label="tree --depth",
|
|
1226
|
+
minimum=1,
|
|
1227
|
+
maximum=10,
|
|
1228
|
+
command="tree",
|
|
1229
|
+
)
|
|
1230
|
+
nodes = list_entries(
|
|
1231
|
+
self.engine.store,
|
|
1232
|
+
org_token=self._org_token(),
|
|
1233
|
+
space_token=self.cwd.space_token or "",
|
|
1234
|
+
limit=MAX_RESULT_ITEMS,
|
|
1235
|
+
)
|
|
1236
|
+
by_id = {str(node["id"]): node for node in nodes}
|
|
1237
|
+
edges = space_edges(
|
|
1238
|
+
self.engine.store,
|
|
1239
|
+
org_token=self._org_token(),
|
|
1240
|
+
space_token=self.cwd.space_token or "",
|
|
1241
|
+
)
|
|
1242
|
+
children: dict[str, list[str]] = {}
|
|
1243
|
+
placed: set[str] = set()
|
|
1244
|
+
for edge in edges:
|
|
1245
|
+
if edge["edge_type"] != "placement":
|
|
1246
|
+
continue
|
|
1247
|
+
parent_id, child_id = str(edge["source_id"]), str(edge["target_id"])
|
|
1248
|
+
children.setdefault(parent_id, []).append(child_id)
|
|
1249
|
+
placed.add(child_id)
|
|
1250
|
+
roots = [self.cwd.collection_id] if self.cwd.collection_id else [
|
|
1251
|
+
node_id for node_id in by_id if node_id not in placed
|
|
1252
|
+
]
|
|
1253
|
+
output: list[dict[str, Any]] = []
|
|
1254
|
+
|
|
1255
|
+
def visit(node_id: str, depth: int, ancestors: set[str]) -> None:
|
|
1256
|
+
if node_id not in by_id or node_id in ancestors or depth > max_depth:
|
|
1257
|
+
return
|
|
1258
|
+
item = dict(by_id[node_id])
|
|
1259
|
+
item["tree_depth"] = depth
|
|
1260
|
+
item["tree_prefix"] = " " * depth
|
|
1261
|
+
output.append(item)
|
|
1262
|
+
for child_id in children.get(node_id, []):
|
|
1263
|
+
visit(child_id, depth + 1, {*ancestors, node_id})
|
|
1264
|
+
|
|
1265
|
+
for root_id in roots:
|
|
1266
|
+
if root_id:
|
|
1267
|
+
visit(str(root_id), 0, set())
|
|
1268
|
+
return self._with_paths(output)
|
|
1269
|
+
|
|
1270
|
+
def _where(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
1271
|
+
if items is None:
|
|
1272
|
+
items = self._default_items()
|
|
1273
|
+
tokens = list(command.tokens)
|
|
1274
|
+
if len(tokens) != 3:
|
|
1275
|
+
raise FsQueryError(
|
|
1276
|
+
"`where` requires exactly FIELD OPERATOR VALUE.",
|
|
1277
|
+
code="invalid_syntax",
|
|
1278
|
+
hint="Use a supported field and comparison operator.",
|
|
1279
|
+
examples=COMMANDS["where"]["examples"][:2],
|
|
1280
|
+
help_query="help where",
|
|
1281
|
+
)
|
|
1282
|
+
field, operator, raw_expected = tokens
|
|
1283
|
+
if field not in {"degree", "display", "encoding", "node_type", "type"}:
|
|
1284
|
+
raise FsQueryError(
|
|
1285
|
+
f"`where` does not support field `{field}`.",
|
|
1286
|
+
code="invalid_field",
|
|
1287
|
+
hint="Use degree, display, encoding, or type. node_type is deprecated.",
|
|
1288
|
+
examples=COMMANDS["where"]["examples"][:2],
|
|
1289
|
+
help_query="help where",
|
|
1290
|
+
)
|
|
1291
|
+
if field == "type":
|
|
1292
|
+
field = "type"
|
|
1293
|
+
if operator not in {"=", "==", "!=", ">", ">=", "<", "<=", "~"}:
|
|
1294
|
+
raise FsQueryError(
|
|
1295
|
+
f"Unsupported where operator: `{operator}`.",
|
|
1296
|
+
code="invalid_operator",
|
|
1297
|
+
hint="Use =, ==, !=, >, >=, <, <=, or ~.",
|
|
1298
|
+
examples=COMMANDS["where"]["examples"][:2],
|
|
1299
|
+
help_query="help where",
|
|
1300
|
+
)
|
|
1301
|
+
if field != "degree" and operator not in {"=", "==", "!=", "~"}:
|
|
1302
|
+
raise FsQueryError(
|
|
1303
|
+
f"Operator `{operator}` cannot compare text field `{field}`.",
|
|
1304
|
+
code="invalid_operator",
|
|
1305
|
+
hint="Use =, ==, !=, or ~ for text fields. Use numeric operators with degree.",
|
|
1306
|
+
examples=COMMANDS["where"]["examples"][:2],
|
|
1307
|
+
help_query="help where",
|
|
1308
|
+
)
|
|
1309
|
+
if field == "degree":
|
|
1310
|
+
try:
|
|
1311
|
+
int(raw_expected)
|
|
1312
|
+
except ValueError as exc:
|
|
1313
|
+
raise FsQueryError(
|
|
1314
|
+
f"`degree` must be compared with an integer, received `{raw_expected}`.",
|
|
1315
|
+
code="invalid_number",
|
|
1316
|
+
hint="Use a whole number, for example `where degree > 5`.",
|
|
1317
|
+
examples=["ls | where degree > 5"],
|
|
1318
|
+
help_query="help where",
|
|
1319
|
+
) from exc
|
|
1320
|
+
|
|
1321
|
+
def matches(item: dict[str, Any]) -> bool:
|
|
1322
|
+
actual = item.get(field)
|
|
1323
|
+
if field == "degree":
|
|
1324
|
+
left, right = int(actual or 0), int(raw_expected)
|
|
1325
|
+
return {
|
|
1326
|
+
"=": left == right,
|
|
1327
|
+
"==": left == right,
|
|
1328
|
+
"!=": left != right,
|
|
1329
|
+
">": left > right,
|
|
1330
|
+
">=": left >= right,
|
|
1331
|
+
"<": left < right,
|
|
1332
|
+
"<=": left <= right,
|
|
1333
|
+
}.get(operator, False)
|
|
1334
|
+
left = _text(actual).lower()
|
|
1335
|
+
right = raw_expected.lower()
|
|
1336
|
+
return {
|
|
1337
|
+
"=": left == right,
|
|
1338
|
+
"==": left == right,
|
|
1339
|
+
"!=": left != right,
|
|
1340
|
+
"~": right in left,
|
|
1341
|
+
}.get(operator, False)
|
|
1342
|
+
|
|
1343
|
+
return [item for item in items if matches(item)]
|
|
1344
|
+
|
|
1345
|
+
def _sort(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
1346
|
+
if items is None:
|
|
1347
|
+
items = self._default_items()
|
|
1348
|
+
tokens = list(command.tokens)
|
|
1349
|
+
if not tokens or len(tokens) > 2:
|
|
1350
|
+
raise FsQueryError(
|
|
1351
|
+
"`sort` requires FIELD and an optional asc or desc direction.",
|
|
1352
|
+
code="invalid_syntax",
|
|
1353
|
+
hint="Choose one supported field and optionally add `asc` or `desc`.",
|
|
1354
|
+
examples=COMMANDS["sort"]["examples"][:2],
|
|
1355
|
+
help_query="help sort",
|
|
1356
|
+
)
|
|
1357
|
+
field = "type" if tokens[0] in {"type", "node_type"} else tokens[0]
|
|
1358
|
+
if field not in {"degree", "display", "encoding", "id", "type", "score"}:
|
|
1359
|
+
raise FsQueryError(
|
|
1360
|
+
f"`sort` does not support field `{field}`.",
|
|
1361
|
+
code="invalid_field",
|
|
1362
|
+
hint="Use degree, display, encoding, id, type, or score. node_type is deprecated.",
|
|
1363
|
+
examples=COMMANDS["sort"]["examples"][:2],
|
|
1364
|
+
help_query="help sort",
|
|
1365
|
+
)
|
|
1366
|
+
if len(tokens) == 2 and tokens[1].lower() not in {"asc", "desc"}:
|
|
1367
|
+
raise FsQueryError(
|
|
1368
|
+
f"Invalid sort direction: `{tokens[1]}`.",
|
|
1369
|
+
code="invalid_choice",
|
|
1370
|
+
hint="Use `asc` or `desc`.",
|
|
1371
|
+
examples=COMMANDS["sort"]["examples"][:2],
|
|
1372
|
+
help_query="help sort",
|
|
1373
|
+
)
|
|
1374
|
+
reverse = len(tokens) == 2 and tokens[1].lower() == "desc"
|
|
1375
|
+
return sorted(
|
|
1376
|
+
items,
|
|
1377
|
+
key=lambda item: (
|
|
1378
|
+
item.get(field) is None,
|
|
1379
|
+
item.get(field) if field in {"degree", "score"} else _text(item.get(field)).lower(),
|
|
1380
|
+
),
|
|
1381
|
+
reverse=reverse,
|
|
1382
|
+
)
|
|
1383
|
+
|
|
1384
|
+
def _limit(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
1385
|
+
if items is None:
|
|
1386
|
+
items = self._default_items()
|
|
1387
|
+
if len(command.tokens) != 1:
|
|
1388
|
+
raise FsQueryError(
|
|
1389
|
+
f"`{command.name}` requires exactly one integer.",
|
|
1390
|
+
code="invalid_syntax",
|
|
1391
|
+
hint=f"Put one whole number after {command.name}.",
|
|
1392
|
+
examples=COMMANDS[command.name]["examples"][:2],
|
|
1393
|
+
help_query=f"help {command.name}",
|
|
1394
|
+
)
|
|
1395
|
+
amount = _integer(
|
|
1396
|
+
command.tokens[0],
|
|
1397
|
+
label=command.name,
|
|
1398
|
+
minimum=0,
|
|
1399
|
+
maximum=MAX_RESULT_ITEMS,
|
|
1400
|
+
command=command.name,
|
|
1401
|
+
)
|
|
1402
|
+
return items[:amount]
|
|
1403
|
+
|
|
1404
|
+
def _select(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
1405
|
+
if items is None:
|
|
1406
|
+
items = self._default_items()
|
|
1407
|
+
fields = [
|
|
1408
|
+
field.strip()
|
|
1409
|
+
for token in command.tokens
|
|
1410
|
+
for field in token.split(",")
|
|
1411
|
+
if field.strip()
|
|
1412
|
+
]
|
|
1413
|
+
if not fields:
|
|
1414
|
+
raise FsQueryError(
|
|
1415
|
+
"`select` requires one or more field names.",
|
|
1416
|
+
code="missing_argument",
|
|
1417
|
+
hint="Use comma-separated fields to reduce response size.",
|
|
1418
|
+
examples=COMMANDS["select"]["examples"][:2],
|
|
1419
|
+
help_query="help select",
|
|
1420
|
+
)
|
|
1421
|
+
available = sorted({key for item in items for key in item})
|
|
1422
|
+
unknown = [field for field in fields if field not in available]
|
|
1423
|
+
if unknown:
|
|
1424
|
+
raise FsQueryError(
|
|
1425
|
+
f"Unknown select field: {unknown[0]}.",
|
|
1426
|
+
code="invalid_field",
|
|
1427
|
+
hint=f"Fields available on this stream: {', '.join(available)}.",
|
|
1428
|
+
examples=COMMANDS["select"]["examples"][:2],
|
|
1429
|
+
help_query="help select",
|
|
1430
|
+
)
|
|
1431
|
+
return [{field: item.get(field) for field in fields} for item in items]
|
|
1432
|
+
|
|
1433
|
+
def _wc(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
1434
|
+
options, positional = _checked_options(command, allowed={"tokens"})
|
|
1435
|
+
if positional:
|
|
1436
|
+
raise FsQueryError(
|
|
1437
|
+
"`wc` accepts flags only.",
|
|
1438
|
+
code="unexpected_argument",
|
|
1439
|
+
hint="Pipe a node stream into wc.",
|
|
1440
|
+
examples=COMMANDS["wc"]["examples"][:2],
|
|
1441
|
+
help_query="help wc",
|
|
1442
|
+
)
|
|
1443
|
+
stream = items if items is not None else self._default_items()
|
|
1444
|
+
contents = [str(item.get("content") or "") for item in stream]
|
|
1445
|
+
characters = sum(len(content) for content in contents)
|
|
1446
|
+
words = sum(len(content.split()) for content in contents)
|
|
1447
|
+
result = {
|
|
1448
|
+
"kind": "aggregate",
|
|
1449
|
+
"display": f"{len(stream)} items",
|
|
1450
|
+
"items": len(stream),
|
|
1451
|
+
"nodes": sum(1 for item in stream if item.get("kind") == "node"),
|
|
1452
|
+
"characters": characters,
|
|
1453
|
+
"words": words,
|
|
1454
|
+
"bytes": sum(len(content.encode("utf-8")) for content in contents),
|
|
1455
|
+
}
|
|
1456
|
+
if options.get("tokens"):
|
|
1457
|
+
result["estimated_tokens"] = (characters + 3) // 4
|
|
1458
|
+
return [result]
|
|
1459
|
+
|
|
1460
|
+
def _explain(self, command: Command) -> list[dict[str, Any]]:
|
|
1461
|
+
query = " ".join(command.tokens).strip()
|
|
1462
|
+
if not query:
|
|
1463
|
+
raise FsQueryError(
|
|
1464
|
+
"`explain` requires a quoted query pipeline.",
|
|
1465
|
+
code="missing_argument",
|
|
1466
|
+
hint="Wrap the pipeline in quotes so its pipes belong to explain.",
|
|
1467
|
+
examples=COMMANDS["explain"]["examples"][:2],
|
|
1468
|
+
help_query="help explain",
|
|
1469
|
+
)
|
|
1470
|
+
commands = parse_query(query)
|
|
1471
|
+
semantic_commands = {"semantic", "related", "traverse"}
|
|
1472
|
+
graph_commands = {
|
|
1473
|
+
"graph",
|
|
1474
|
+
"neighbors",
|
|
1475
|
+
"neighborhood",
|
|
1476
|
+
"links",
|
|
1477
|
+
"parents",
|
|
1478
|
+
"children",
|
|
1479
|
+
"path",
|
|
1480
|
+
"hubs",
|
|
1481
|
+
"why",
|
|
1482
|
+
"tree",
|
|
1483
|
+
}
|
|
1484
|
+
stages = []
|
|
1485
|
+
for index, parsed in enumerate(commands, start=1):
|
|
1486
|
+
if parsed.name in semantic_commands:
|
|
1487
|
+
execution = "embedding and semantic projection"
|
|
1488
|
+
cost = "high"
|
|
1489
|
+
elif parsed.name in graph_commands:
|
|
1490
|
+
execution = "indexed filesystem graph projection"
|
|
1491
|
+
cost = "medium"
|
|
1492
|
+
elif parsed.name in {"grep", "rg"}:
|
|
1493
|
+
execution = "bounded filesystem content scan"
|
|
1494
|
+
cost = "medium"
|
|
1495
|
+
else:
|
|
1496
|
+
execution = "in-memory stream transform or indexed lookup"
|
|
1497
|
+
cost = "low"
|
|
1498
|
+
stages.append({
|
|
1499
|
+
"stage": index,
|
|
1500
|
+
"command": parsed.name,
|
|
1501
|
+
"tokens": list(parsed.tokens),
|
|
1502
|
+
"execution": execution,
|
|
1503
|
+
"estimated_cost": cost,
|
|
1504
|
+
})
|
|
1505
|
+
return [{
|
|
1506
|
+
"kind": "query_plan",
|
|
1507
|
+
"display": query,
|
|
1508
|
+
"query": query,
|
|
1509
|
+
"stages": stages,
|
|
1510
|
+
"stage_count": len(stages),
|
|
1511
|
+
"executes": False,
|
|
1512
|
+
}]
|
|
1513
|
+
|
|
1514
|
+
def _cat(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
1515
|
+
targets = self._targets(command, items, "cat")
|
|
1516
|
+
return [{**item, "view": "content"} for item in targets]
|
|
1517
|
+
|
|
1518
|
+
def _stat(self, command: Command, items: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
|
1519
|
+
targets = self._targets(command, items, "stat")
|
|
1520
|
+
edges = node_edges(
|
|
1521
|
+
self.engine.store,
|
|
1522
|
+
{item["id"] for item in targets},
|
|
1523
|
+
org_token=self._org_token(),
|
|
1524
|
+
space_token=self.cwd.space_token or "",
|
|
1525
|
+
)
|
|
1526
|
+
output = []
|
|
1527
|
+
for item in targets:
|
|
1528
|
+
memory_id = item["id"]
|
|
1529
|
+
output.append(
|
|
1530
|
+
{
|
|
1531
|
+
**item,
|
|
1532
|
+
"view": "stat",
|
|
1533
|
+
"placements": [
|
|
1534
|
+
edge
|
|
1535
|
+
for edge in edges
|
|
1536
|
+
if edge.get("edge_type") == "placement"
|
|
1537
|
+
and memory_id in {edge["source_id"], edge["target_id"]}
|
|
1538
|
+
],
|
|
1539
|
+
"semantic_links": [
|
|
1540
|
+
edge
|
|
1541
|
+
for edge in edges
|
|
1542
|
+
if edge.get("edge_type") == "semantic_link"
|
|
1543
|
+
and memory_id in {edge["source_id"], edge["target_id"]}
|
|
1544
|
+
],
|
|
1545
|
+
"self_loop_count": sum(
|
|
1546
|
+
1
|
|
1547
|
+
for edge in edges
|
|
1548
|
+
if edge.get("edge_type") == "semantic_link"
|
|
1549
|
+
and edge["source_id"] == edge["target_id"] == memory_id
|
|
1550
|
+
),
|
|
1551
|
+
}
|
|
1552
|
+
)
|
|
1553
|
+
return output
|
|
1554
|
+
|
|
1555
|
+
def _targets(
|
|
1556
|
+
self,
|
|
1557
|
+
command: Command,
|
|
1558
|
+
items: list[dict[str, Any]] | None,
|
|
1559
|
+
name: str,
|
|
1560
|
+
) -> list[dict[str, Any]]:
|
|
1561
|
+
if command.tokens:
|
|
1562
|
+
if len(command.tokens) != 1:
|
|
1563
|
+
raise FsQueryError(
|
|
1564
|
+
f"`{name}` accepts exactly one node id.",
|
|
1565
|
+
code="invalid_syntax",
|
|
1566
|
+
hint=f"Pass one id, or pipe nodes into `{name}` without an argument.",
|
|
1567
|
+
examples=COMMANDS[name]["examples"][:2],
|
|
1568
|
+
help_query=f"help {name}",
|
|
1569
|
+
)
|
|
1570
|
+
targets = get_nodes(
|
|
1571
|
+
self.engine.store,
|
|
1572
|
+
[command.tokens[0]],
|
|
1573
|
+
org_token=self._org_token(),
|
|
1574
|
+
space_token=self.cwd.space_token,
|
|
1575
|
+
)
|
|
1576
|
+
if not targets:
|
|
1577
|
+
raise FsQueryError(
|
|
1578
|
+
f"Node not found: {command.tokens[0]}.",
|
|
1579
|
+
code="node_not_found",
|
|
1580
|
+
hint="Use `ls`, `find`, or `grep` to obtain a valid node id in this space.",
|
|
1581
|
+
examples=["ls | limit 20", "find --id NODE_ID"],
|
|
1582
|
+
help_query=f"help {name}",
|
|
1583
|
+
)
|
|
1584
|
+
return self._with_paths(targets)
|
|
1585
|
+
if items is None:
|
|
1586
|
+
raise FsQueryError(
|
|
1587
|
+
f"`{name}` requires a node id or pipeline input.",
|
|
1588
|
+
code="missing_argument",
|
|
1589
|
+
hint=f"Pass a node id or pipe a node-producing command into `{name}`.",
|
|
1590
|
+
examples=COMMANDS[name]["examples"][:2],
|
|
1591
|
+
help_query=f"help {name}",
|
|
1592
|
+
)
|
|
1593
|
+
return [item for item in items if item.get("kind") == "node"]
|
|
1594
|
+
|
|
1595
|
+
def _cd(self, command: Command) -> dict[str, Any]:
|
|
1596
|
+
if len(command.tokens) != 1:
|
|
1597
|
+
raise FsQueryError(
|
|
1598
|
+
"`cd` requires exactly one destination.",
|
|
1599
|
+
code="invalid_syntax",
|
|
1600
|
+
hint="Use a space token, collection id, @node-id, absolute cwd, or `..`.",
|
|
1601
|
+
examples=COMMANDS["cd"]["examples"][:3],
|
|
1602
|
+
help_query="help cd",
|
|
1603
|
+
)
|
|
1604
|
+
target = command.tokens[0]
|
|
1605
|
+
if target in {"/", "/spaces"}:
|
|
1606
|
+
cwd = Cwd()
|
|
1607
|
+
elif target == "..":
|
|
1608
|
+
cwd = Cwd(space_token=self.cwd.space_token) if self.cwd.collection_id else Cwd()
|
|
1609
|
+
elif target.startswith("/spaces"):
|
|
1610
|
+
cwd = parse_cwd(target)
|
|
1611
|
+
elif target.startswith("space."):
|
|
1612
|
+
cwd = Cwd(space_token=target)
|
|
1613
|
+
elif target.startswith("@"):
|
|
1614
|
+
cwd = self._resolve_node(target[1:])
|
|
1615
|
+
else:
|
|
1616
|
+
cwd = self._resolve_collection(target)
|
|
1617
|
+
self._validate_cwd(cwd)
|
|
1618
|
+
return {"cwd": cwd.path, "url": cwd.path}
|
|
1619
|
+
|
|
1620
|
+
def _resolve_collection(self, target: str) -> Cwd:
|
|
1621
|
+
if not self.cwd.space_token:
|
|
1622
|
+
matching_space = next(
|
|
1623
|
+
(
|
|
1624
|
+
space for space in self.spaces
|
|
1625
|
+
if target in {_text(space["id"]), _text(space["token"]), _text(space["name"])}
|
|
1626
|
+
),
|
|
1627
|
+
None,
|
|
1628
|
+
)
|
|
1629
|
+
if matching_space:
|
|
1630
|
+
return Cwd(space_token=_text(matching_space["token"]))
|
|
1631
|
+
available = [_text(space.get("token")) for space in self.spaces]
|
|
1632
|
+
raise FsQueryError(
|
|
1633
|
+
f"Space not found: {target}.",
|
|
1634
|
+
code="space_not_found",
|
|
1635
|
+
hint=f"Use a registered space token. Available: {', '.join(available) or '(none)'}.",
|
|
1636
|
+
examples=["cd /spaces", "cd space.personal"],
|
|
1637
|
+
help_query="help paths",
|
|
1638
|
+
)
|
|
1639
|
+
collections = list_entries(
|
|
1640
|
+
self.engine.store,
|
|
1641
|
+
org_token=self._org_token(),
|
|
1642
|
+
space_token=self.cwd.space_token,
|
|
1643
|
+
node_type="collection",
|
|
1644
|
+
limit=MAX_RESULT_ITEMS,
|
|
1645
|
+
)
|
|
1646
|
+
matches = [
|
|
1647
|
+
memory
|
|
1648
|
+
for memory in collections
|
|
1649
|
+
if target == memory["id"]
|
|
1650
|
+
or target.lower() == _text(memory.get("display")).lower()
|
|
1651
|
+
]
|
|
1652
|
+
if len(matches) != 1:
|
|
1653
|
+
raise FsQueryError(
|
|
1654
|
+
f"Collection {'not found' if not matches else 'is ambiguous'}: {target}.",
|
|
1655
|
+
code="collection_not_found" if not matches else "ambiguous_collection",
|
|
1656
|
+
hint=(
|
|
1657
|
+
"Run `ls --collections-only` and retry with an exact collection id."
|
|
1658
|
+
if not matches
|
|
1659
|
+
else "Retry with the exact collection id instead of its display name."
|
|
1660
|
+
),
|
|
1661
|
+
examples=["ls --collections-only", "cd COLLECTION_ID"],
|
|
1662
|
+
help_query="help cd",
|
|
1663
|
+
)
|
|
1664
|
+
return Cwd(space_token=self.cwd.space_token, collection_id=_text(matches[0]["id"]))
|
|
1665
|
+
|
|
1666
|
+
def _resolve_node(self, memory_id: str) -> Cwd:
|
|
1667
|
+
memories = get_nodes(self.engine.store, [memory_id], org_token=self._org_token())
|
|
1668
|
+
if not memories:
|
|
1669
|
+
raise FsQueryError(
|
|
1670
|
+
f"Node not found: {memory_id}.",
|
|
1671
|
+
code="node_not_found",
|
|
1672
|
+
hint="Use `find --id`, `ls`, or `grep` to obtain a valid node id.",
|
|
1673
|
+
examples=["ls | limit 20", "find --id NODE_ID"],
|
|
1674
|
+
help_query="help cd",
|
|
1675
|
+
)
|
|
1676
|
+
memory = memories[0]
|
|
1677
|
+
space_tokens = [
|
|
1678
|
+
token for token in memory.get("scope") or [] if _text(token).startswith("space.")
|
|
1679
|
+
]
|
|
1680
|
+
preferred_space = self.cwd.space_token if self.cwd.space_token in space_tokens else None
|
|
1681
|
+
space_token = preferred_space or (space_tokens[0] if space_tokens else None)
|
|
1682
|
+
if not space_token:
|
|
1683
|
+
raise FsQueryError(
|
|
1684
|
+
f"Node has no space scope: {memory_id}.",
|
|
1685
|
+
code="node_without_space",
|
|
1686
|
+
hint="This node cannot be navigated through the space filesystem.",
|
|
1687
|
+
examples=[f"stat {memory_id}"],
|
|
1688
|
+
help_query="help paths",
|
|
1689
|
+
)
|
|
1690
|
+
parent_id = containing_collection(
|
|
1691
|
+
self.engine.store,
|
|
1692
|
+
memory_id,
|
|
1693
|
+
org_token=self._org_token(),
|
|
1694
|
+
space_token=space_token,
|
|
1695
|
+
)
|
|
1696
|
+
return Cwd(space_token=space_token, collection_id=parent_id)
|
|
1697
|
+
|
|
1698
|
+
def _validate_cwd(self, cwd: Cwd) -> None:
|
|
1699
|
+
if cwd.space_token and not any(
|
|
1700
|
+
_text(space.get("token")) == cwd.space_token for space in self.spaces
|
|
1701
|
+
):
|
|
1702
|
+
available = [_text(space.get("token")) for space in self.spaces]
|
|
1703
|
+
raise FsQueryError(
|
|
1704
|
+
f"Space not found: {cwd.space_token}.",
|
|
1705
|
+
code="space_not_found",
|
|
1706
|
+
hint=f"Start at `/spaces`. Available tokens: {', '.join(available) or '(none)'}.",
|
|
1707
|
+
examples=["cwd=/spaces, query=ls"],
|
|
1708
|
+
help_query="help paths",
|
|
1709
|
+
)
|
|
1710
|
+
if cwd.collection_id:
|
|
1711
|
+
collections = get_nodes(
|
|
1712
|
+
self.engine.store,
|
|
1713
|
+
[cwd.collection_id],
|
|
1714
|
+
org_token=self._org_token(),
|
|
1715
|
+
space_token=cwd.space_token,
|
|
1716
|
+
)
|
|
1717
|
+
if not collections:
|
|
1718
|
+
raise FsQueryError(
|
|
1719
|
+
f"Collection not found: {cwd.collection_id}.",
|
|
1720
|
+
code="collection_not_found",
|
|
1721
|
+
hint="Run `ls --collections-only` at the space root and copy an exact id.",
|
|
1722
|
+
examples=["ls --collections-only"],
|
|
1723
|
+
help_query="help paths",
|
|
1724
|
+
)
|
|
1725
|
+
collection = collections[0]
|
|
1726
|
+
if collection.get("node_type") != "collection":
|
|
1727
|
+
raise FsQueryError(
|
|
1728
|
+
f"cwd target `{cwd.collection_id}` is not a collection.",
|
|
1729
|
+
code="not_a_collection",
|
|
1730
|
+
hint="Use `cd @NODE_ID` to resolve a node's containing collection.",
|
|
1731
|
+
examples=[f"cd @{cwd.collection_id}"],
|
|
1732
|
+
help_query="help cd",
|
|
1733
|
+
)
|
|
1734
|
+
|
|
1735
|
+
def _help(self, command: Command) -> list[dict[str, Any]]:
|
|
1736
|
+
if len(command.tokens) > 1:
|
|
1737
|
+
raise FsQueryError(
|
|
1738
|
+
"`help` accepts at most one topic.",
|
|
1739
|
+
code="invalid_syntax",
|
|
1740
|
+
hint="Use `help`, `help commands`, or `help COMMAND`.",
|
|
1741
|
+
examples=["help", "help commands", "help grep"],
|
|
1742
|
+
help_query="help overview",
|
|
1743
|
+
)
|
|
1744
|
+
topic = command.tokens[0].lower() if command.tokens else "overview"
|
|
1745
|
+
try:
|
|
1746
|
+
return help_items(topic)
|
|
1747
|
+
except KeyError as exc:
|
|
1748
|
+
close = get_close_matches(topic, manual_topics(), n=1, cutoff=0.55)
|
|
1749
|
+
suggestion = close[0] if close else "commands"
|
|
1750
|
+
raise FsQueryError(
|
|
1751
|
+
f"Unknown help topic: {topic}.",
|
|
1752
|
+
code="unknown_help_topic",
|
|
1753
|
+
hint=f"Try `help {suggestion}`.",
|
|
1754
|
+
examples=["help overview", "help commands", f"help {suggestion}"],
|
|
1755
|
+
help_query="help commands",
|
|
1756
|
+
) from exc
|
|
1757
|
+
|
|
1758
|
+
|
|
1759
|
+
def run_fs_query(
|
|
1760
|
+
engine: ArthaEngine,
|
|
1761
|
+
*,
|
|
1762
|
+
query: str,
|
|
1763
|
+
cwd: str | None,
|
|
1764
|
+
spaces: list[dict[str, Any]],
|
|
1765
|
+
org_id: str | None,
|
|
1766
|
+
) -> dict[str, Any]:
|
|
1767
|
+
parsed_cwd = parse_cwd(cwd)
|
|
1768
|
+
commands = parse_query(query)
|
|
1769
|
+
executor = FsQueryExecutor(
|
|
1770
|
+
engine,
|
|
1771
|
+
cwd=parsed_cwd,
|
|
1772
|
+
spaces=spaces,
|
|
1773
|
+
org_id=org_id,
|
|
1774
|
+
)
|
|
1775
|
+
executor._validate_cwd(parsed_cwd)
|
|
1776
|
+
result = executor.execute(commands)
|
|
1777
|
+
result["query"] = query
|
|
1778
|
+
result["ast"] = [
|
|
1779
|
+
{"command": command.name, "tokens": list(command.tokens)}
|
|
1780
|
+
for command in commands
|
|
1781
|
+
]
|
|
1782
|
+
return result
|