kimi-cli 0.44__py3-none-any.whl → 0.78__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.
Potentially problematic release.
This version of kimi-cli might be problematic. Click here for more details.
- kimi_cli/CHANGELOG.md +349 -40
- kimi_cli/__init__.py +6 -0
- kimi_cli/acp/AGENTS.md +91 -0
- kimi_cli/acp/__init__.py +13 -0
- kimi_cli/acp/convert.py +111 -0
- kimi_cli/acp/kaos.py +270 -0
- kimi_cli/acp/mcp.py +46 -0
- kimi_cli/acp/server.py +335 -0
- kimi_cli/acp/session.py +445 -0
- kimi_cli/acp/tools.py +158 -0
- kimi_cli/acp/types.py +13 -0
- kimi_cli/agents/default/agent.yaml +4 -4
- kimi_cli/agents/default/sub.yaml +2 -1
- kimi_cli/agents/default/system.md +79 -21
- kimi_cli/agents/okabe/agent.yaml +17 -0
- kimi_cli/agentspec.py +53 -25
- kimi_cli/app.py +180 -52
- kimi_cli/cli/__init__.py +595 -0
- kimi_cli/cli/__main__.py +8 -0
- kimi_cli/cli/info.py +63 -0
- kimi_cli/cli/mcp.py +349 -0
- kimi_cli/config.py +153 -17
- kimi_cli/constant.py +3 -0
- kimi_cli/exception.py +23 -2
- kimi_cli/flow/__init__.py +117 -0
- kimi_cli/flow/d2.py +376 -0
- kimi_cli/flow/mermaid.py +218 -0
- kimi_cli/llm.py +129 -23
- kimi_cli/metadata.py +32 -7
- kimi_cli/platforms.py +262 -0
- kimi_cli/prompts/__init__.py +2 -0
- kimi_cli/prompts/compact.md +4 -5
- kimi_cli/session.py +223 -31
- kimi_cli/share.py +2 -0
- kimi_cli/skill.py +145 -0
- kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
- kimi_cli/skills/skill-creator/SKILL.md +351 -0
- kimi_cli/soul/__init__.py +51 -20
- kimi_cli/soul/agent.py +213 -85
- kimi_cli/soul/approval.py +86 -17
- kimi_cli/soul/compaction.py +64 -53
- kimi_cli/soul/context.py +38 -5
- kimi_cli/soul/denwarenji.py +2 -0
- kimi_cli/soul/kimisoul.py +442 -60
- kimi_cli/soul/message.py +54 -54
- kimi_cli/soul/slash.py +72 -0
- kimi_cli/soul/toolset.py +387 -6
- kimi_cli/toad.py +74 -0
- kimi_cli/tools/AGENTS.md +5 -0
- kimi_cli/tools/__init__.py +42 -34
- kimi_cli/tools/display.py +25 -0
- kimi_cli/tools/dmail/__init__.py +10 -10
- kimi_cli/tools/dmail/dmail.md +11 -9
- kimi_cli/tools/file/__init__.py +1 -3
- kimi_cli/tools/file/glob.py +20 -23
- kimi_cli/tools/file/grep.md +1 -1
- kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
- kimi_cli/tools/file/read.md +24 -6
- kimi_cli/tools/file/read.py +134 -50
- kimi_cli/tools/file/replace.md +1 -1
- kimi_cli/tools/file/replace.py +36 -29
- kimi_cli/tools/file/utils.py +282 -0
- kimi_cli/tools/file/write.py +43 -22
- kimi_cli/tools/multiagent/__init__.py +7 -0
- kimi_cli/tools/multiagent/create.md +11 -0
- kimi_cli/tools/multiagent/create.py +50 -0
- kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
- kimi_cli/tools/shell/__init__.py +120 -0
- kimi_cli/tools/{bash → shell}/bash.md +1 -2
- kimi_cli/tools/shell/powershell.md +25 -0
- kimi_cli/tools/test.py +4 -4
- kimi_cli/tools/think/__init__.py +2 -2
- kimi_cli/tools/todo/__init__.py +14 -8
- kimi_cli/tools/utils.py +64 -24
- kimi_cli/tools/web/fetch.py +68 -13
- kimi_cli/tools/web/search.py +10 -12
- kimi_cli/ui/acp/__init__.py +65 -412
- kimi_cli/ui/print/__init__.py +37 -49
- kimi_cli/ui/print/visualize.py +179 -0
- kimi_cli/ui/shell/__init__.py +141 -84
- kimi_cli/ui/shell/console.py +2 -0
- kimi_cli/ui/shell/debug.py +28 -23
- kimi_cli/ui/shell/keyboard.py +5 -1
- kimi_cli/ui/shell/prompt.py +220 -194
- kimi_cli/ui/shell/replay.py +111 -46
- kimi_cli/ui/shell/setup.py +89 -82
- kimi_cli/ui/shell/slash.py +422 -0
- kimi_cli/ui/shell/update.py +4 -2
- kimi_cli/ui/shell/usage.py +271 -0
- kimi_cli/ui/shell/visualize.py +574 -72
- kimi_cli/ui/wire/__init__.py +267 -0
- kimi_cli/ui/wire/jsonrpc.py +142 -0
- kimi_cli/ui/wire/protocol.py +1 -0
- kimi_cli/utils/__init__.py +0 -0
- kimi_cli/utils/aiohttp.py +2 -0
- kimi_cli/utils/aioqueue.py +72 -0
- kimi_cli/utils/broadcast.py +37 -0
- kimi_cli/utils/changelog.py +12 -7
- kimi_cli/utils/clipboard.py +12 -0
- kimi_cli/utils/datetime.py +37 -0
- kimi_cli/utils/environment.py +58 -0
- kimi_cli/utils/envvar.py +12 -0
- kimi_cli/utils/frontmatter.py +44 -0
- kimi_cli/utils/logging.py +7 -6
- kimi_cli/utils/message.py +9 -14
- kimi_cli/utils/path.py +99 -9
- kimi_cli/utils/pyinstaller.py +6 -0
- kimi_cli/utils/rich/__init__.py +33 -0
- kimi_cli/utils/rich/columns.py +99 -0
- kimi_cli/utils/rich/markdown.py +961 -0
- kimi_cli/utils/rich/markdown_sample.md +108 -0
- kimi_cli/utils/rich/markdown_sample_short.md +2 -0
- kimi_cli/utils/signals.py +2 -0
- kimi_cli/utils/slashcmd.py +124 -0
- kimi_cli/utils/string.py +2 -0
- kimi_cli/utils/term.py +168 -0
- kimi_cli/utils/typing.py +20 -0
- kimi_cli/wire/__init__.py +98 -29
- kimi_cli/wire/serde.py +45 -0
- kimi_cli/wire/types.py +299 -0
- kimi_cli-0.78.dist-info/METADATA +200 -0
- kimi_cli-0.78.dist-info/RECORD +135 -0
- kimi_cli-0.78.dist-info/entry_points.txt +4 -0
- kimi_cli/cli.py +0 -250
- kimi_cli/soul/runtime.py +0 -96
- kimi_cli/tools/bash/__init__.py +0 -99
- kimi_cli/tools/file/patch.md +0 -8
- kimi_cli/tools/file/patch.py +0 -143
- kimi_cli/tools/mcp.py +0 -85
- kimi_cli/ui/shell/liveview.py +0 -386
- kimi_cli/ui/shell/metacmd.py +0 -262
- kimi_cli/wire/message.py +0 -91
- kimi_cli-0.44.dist-info/METADATA +0 -188
- kimi_cli-0.44.dist-info/RECORD +0 -89
- kimi_cli-0.44.dist-info/entry_points.txt +0 -3
- /kimi_cli/tools/{task → multiagent}/task.md +0 -0
- {kimi_cli-0.44.dist-info → kimi_cli-0.78.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from kosong.message import ContentPart
|
|
8
|
+
|
|
9
|
+
FlowNodeKind = Literal["begin", "end", "task", "decision"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PromptFlowError(ValueError):
|
|
13
|
+
"""Base error for prompt flow parsing/validation."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PromptFlowParseError(PromptFlowError):
|
|
17
|
+
"""Raised when prompt flow parsing fails."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PromptFlowValidationError(PromptFlowError):
|
|
21
|
+
"""Raised when a flowchart fails validation."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True, slots=True)
|
|
25
|
+
class FlowNode:
|
|
26
|
+
id: str
|
|
27
|
+
label: str | list[ContentPart]
|
|
28
|
+
kind: FlowNodeKind
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class FlowEdge:
|
|
33
|
+
src: str
|
|
34
|
+
dst: str
|
|
35
|
+
label: str | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class PromptFlow:
|
|
40
|
+
nodes: dict[str, FlowNode]
|
|
41
|
+
outgoing: dict[str, list[FlowEdge]]
|
|
42
|
+
begin_id: str
|
|
43
|
+
end_id: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_CHOICE_RE = re.compile(r"<choice>([^<]*)</choice>")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_choice(text: str) -> str | None:
|
|
50
|
+
matches = _CHOICE_RE.findall(text or "")
|
|
51
|
+
if not matches:
|
|
52
|
+
return None
|
|
53
|
+
return matches[-1].strip()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def validate_flow(
|
|
57
|
+
nodes: dict[str, FlowNode],
|
|
58
|
+
outgoing: dict[str, list[FlowEdge]],
|
|
59
|
+
) -> tuple[str, str]:
|
|
60
|
+
begin_ids = [node.id for node in nodes.values() if node.kind == "begin"]
|
|
61
|
+
end_ids = [node.id for node in nodes.values() if node.kind == "end"]
|
|
62
|
+
|
|
63
|
+
if len(begin_ids) != 1:
|
|
64
|
+
raise PromptFlowValidationError(f"Expected exactly one BEGIN node, found {len(begin_ids)}")
|
|
65
|
+
if len(end_ids) != 1:
|
|
66
|
+
raise PromptFlowValidationError(f"Expected exactly one END node, found {len(end_ids)}")
|
|
67
|
+
|
|
68
|
+
begin_id = begin_ids[0]
|
|
69
|
+
end_id = end_ids[0]
|
|
70
|
+
|
|
71
|
+
reachable: set[str] = set()
|
|
72
|
+
queue: list[str] = [begin_id]
|
|
73
|
+
while queue:
|
|
74
|
+
node_id = queue.pop()
|
|
75
|
+
if node_id in reachable:
|
|
76
|
+
continue
|
|
77
|
+
reachable.add(node_id)
|
|
78
|
+
for edge in outgoing.get(node_id, []):
|
|
79
|
+
if edge.dst not in reachable:
|
|
80
|
+
queue.append(edge.dst)
|
|
81
|
+
|
|
82
|
+
for node in nodes.values():
|
|
83
|
+
if node.id not in reachable:
|
|
84
|
+
continue
|
|
85
|
+
edges = outgoing.get(node.id, [])
|
|
86
|
+
if node.kind == "begin":
|
|
87
|
+
if len(edges) != 1:
|
|
88
|
+
raise PromptFlowValidationError("BEGIN node must have exactly one outgoing edge")
|
|
89
|
+
continue
|
|
90
|
+
if node.kind == "end":
|
|
91
|
+
if edges:
|
|
92
|
+
raise PromptFlowValidationError("END node must not have outgoing edges")
|
|
93
|
+
continue
|
|
94
|
+
if node.kind == "decision":
|
|
95
|
+
if not edges:
|
|
96
|
+
raise PromptFlowValidationError(
|
|
97
|
+
f'Decision node "{node.id}" must have outgoing edges'
|
|
98
|
+
)
|
|
99
|
+
labels: list[str] = []
|
|
100
|
+
for edge in edges:
|
|
101
|
+
if edge.label is None or not edge.label.strip():
|
|
102
|
+
raise PromptFlowValidationError(
|
|
103
|
+
f'Decision node "{node.id}" has an unlabeled edge'
|
|
104
|
+
)
|
|
105
|
+
labels.append(edge.label)
|
|
106
|
+
if len(set(labels)) != len(labels):
|
|
107
|
+
raise PromptFlowValidationError(
|
|
108
|
+
f'Decision node "{node.id}" has duplicate edge labels'
|
|
109
|
+
)
|
|
110
|
+
continue
|
|
111
|
+
if len(edges) != 1:
|
|
112
|
+
raise PromptFlowValidationError(f'Node "{node.id}" must have exactly one outgoing edge')
|
|
113
|
+
|
|
114
|
+
if end_id not in reachable:
|
|
115
|
+
raise PromptFlowValidationError("END node is not reachable from BEGIN")
|
|
116
|
+
|
|
117
|
+
return begin_id, end_id
|
kimi_cli/flow/d2.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from . import (
|
|
8
|
+
FlowEdge,
|
|
9
|
+
FlowNode,
|
|
10
|
+
FlowNodeKind,
|
|
11
|
+
PromptFlow,
|
|
12
|
+
PromptFlowParseError,
|
|
13
|
+
validate_flow,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_NODE_ID_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_./-]*")
|
|
17
|
+
_PROPERTY_SEGMENTS = {
|
|
18
|
+
"shape",
|
|
19
|
+
"style",
|
|
20
|
+
"label",
|
|
21
|
+
"link",
|
|
22
|
+
"icon",
|
|
23
|
+
"near",
|
|
24
|
+
"width",
|
|
25
|
+
"height",
|
|
26
|
+
"direction",
|
|
27
|
+
"grid-rows",
|
|
28
|
+
"grid-columns",
|
|
29
|
+
"grid-gap",
|
|
30
|
+
"font-size",
|
|
31
|
+
"font-family",
|
|
32
|
+
"font-color",
|
|
33
|
+
"stroke",
|
|
34
|
+
"fill",
|
|
35
|
+
"opacity",
|
|
36
|
+
"padding",
|
|
37
|
+
"border-radius",
|
|
38
|
+
"shadow",
|
|
39
|
+
"sketch",
|
|
40
|
+
"animated",
|
|
41
|
+
"multiple",
|
|
42
|
+
"constraint",
|
|
43
|
+
"tooltip",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class _NodeDef:
|
|
49
|
+
node: FlowNode
|
|
50
|
+
explicit: bool
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_d2_flowchart(text: str) -> PromptFlow:
|
|
54
|
+
nodes: dict[str, _NodeDef] = {}
|
|
55
|
+
outgoing: dict[str, list[FlowEdge]] = {}
|
|
56
|
+
|
|
57
|
+
for line_no, statement in _iter_top_level_statements(text):
|
|
58
|
+
if _has_unquoted_token(statement, "->"):
|
|
59
|
+
_parse_edge_statement(statement, line_no, nodes, outgoing)
|
|
60
|
+
else:
|
|
61
|
+
_parse_node_statement(statement, line_no, nodes)
|
|
62
|
+
|
|
63
|
+
flow_nodes = {node_id: node_def.node for node_id, node_def in nodes.items()}
|
|
64
|
+
for node_id in flow_nodes:
|
|
65
|
+
outgoing.setdefault(node_id, [])
|
|
66
|
+
|
|
67
|
+
flow_nodes = _infer_decision_nodes(flow_nodes, outgoing)
|
|
68
|
+
begin_id, end_id = validate_flow(flow_nodes, outgoing)
|
|
69
|
+
return PromptFlow(nodes=flow_nodes, outgoing=outgoing, begin_id=begin_id, end_id=end_id)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _iter_top_level_statements(text: str) -> Iterable[tuple[int, str]]:
|
|
73
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
74
|
+
brace_depth = 0
|
|
75
|
+
in_single = False
|
|
76
|
+
in_double = False
|
|
77
|
+
escape = False
|
|
78
|
+
drop_line = False
|
|
79
|
+
buf: list[str] = []
|
|
80
|
+
line_no = 1
|
|
81
|
+
stmt_line = 1
|
|
82
|
+
i = 0
|
|
83
|
+
|
|
84
|
+
while i < len(text):
|
|
85
|
+
ch = text[i]
|
|
86
|
+
next_ch = text[i + 1] if i + 1 < len(text) else ""
|
|
87
|
+
|
|
88
|
+
if ch == "\\" and next_ch == "\n":
|
|
89
|
+
i += 2
|
|
90
|
+
line_no += 1
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if ch == "\n":
|
|
94
|
+
if brace_depth == 0 and not in_single and not in_double and not drop_line:
|
|
95
|
+
statement = "".join(buf).strip()
|
|
96
|
+
if statement:
|
|
97
|
+
yield stmt_line, statement
|
|
98
|
+
buf = []
|
|
99
|
+
drop_line = False
|
|
100
|
+
stmt_line = line_no + 1
|
|
101
|
+
line_no += 1
|
|
102
|
+
i += 1
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
if not in_single and not in_double:
|
|
106
|
+
if ch == "#":
|
|
107
|
+
while i < len(text) and text[i] != "\n":
|
|
108
|
+
i += 1
|
|
109
|
+
continue
|
|
110
|
+
if ch == "{":
|
|
111
|
+
if brace_depth == 0:
|
|
112
|
+
statement = "".join(buf).strip()
|
|
113
|
+
if statement:
|
|
114
|
+
yield stmt_line, statement
|
|
115
|
+
drop_line = True
|
|
116
|
+
buf.clear()
|
|
117
|
+
brace_depth += 1
|
|
118
|
+
i += 1
|
|
119
|
+
continue
|
|
120
|
+
if ch == "}" and brace_depth > 0:
|
|
121
|
+
brace_depth -= 1
|
|
122
|
+
i += 1
|
|
123
|
+
continue
|
|
124
|
+
if ch == "}" and brace_depth == 0:
|
|
125
|
+
raise PromptFlowParseError(_line_error(line_no, "Unmatched '}'"))
|
|
126
|
+
|
|
127
|
+
if ch == "'" and not in_double and not escape:
|
|
128
|
+
in_single = not in_single
|
|
129
|
+
elif ch == '"' and not in_single and not escape:
|
|
130
|
+
in_double = not in_double
|
|
131
|
+
|
|
132
|
+
if escape:
|
|
133
|
+
escape = False
|
|
134
|
+
elif ch == "\\" and (in_single or in_double):
|
|
135
|
+
escape = True
|
|
136
|
+
|
|
137
|
+
if brace_depth == 0 and not drop_line:
|
|
138
|
+
buf.append(ch)
|
|
139
|
+
|
|
140
|
+
i += 1
|
|
141
|
+
|
|
142
|
+
if brace_depth != 0:
|
|
143
|
+
raise PromptFlowParseError(_line_error(line_no, "Unclosed '{' block"))
|
|
144
|
+
if in_single or in_double:
|
|
145
|
+
raise PromptFlowParseError(_line_error(line_no, "Unclosed string"))
|
|
146
|
+
|
|
147
|
+
statement = "".join(buf).strip()
|
|
148
|
+
if statement:
|
|
149
|
+
yield stmt_line, statement
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _has_unquoted_token(text: str, token: str) -> bool:
|
|
153
|
+
parts = _split_on_token(text, token)
|
|
154
|
+
return len(parts) > 1
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _parse_edge_statement(
|
|
158
|
+
statement: str,
|
|
159
|
+
line_no: int,
|
|
160
|
+
nodes: dict[str, _NodeDef],
|
|
161
|
+
outgoing: dict[str, list[FlowEdge]],
|
|
162
|
+
) -> None:
|
|
163
|
+
parts = _split_on_token(statement, "->")
|
|
164
|
+
if len(parts) < 2:
|
|
165
|
+
raise PromptFlowParseError(_line_error(line_no, "Expected edge arrow"))
|
|
166
|
+
|
|
167
|
+
last_part = parts[-1]
|
|
168
|
+
target_text, edge_label = _split_unquoted_once(last_part, ":")
|
|
169
|
+
parts[-1] = target_text
|
|
170
|
+
|
|
171
|
+
node_ids: list[str] = []
|
|
172
|
+
for idx, part in enumerate(parts):
|
|
173
|
+
node_id = _parse_node_id(part, line_no, allow_inline_label=(idx < len(parts) - 1))
|
|
174
|
+
node_ids.append(node_id)
|
|
175
|
+
|
|
176
|
+
if any(_is_property_path(node_id) for node_id in node_ids):
|
|
177
|
+
return
|
|
178
|
+
if len(node_ids) < 2:
|
|
179
|
+
raise PromptFlowParseError(_line_error(line_no, "Edge must have at least two nodes"))
|
|
180
|
+
|
|
181
|
+
label = _parse_label(edge_label, line_no) if edge_label is not None else None
|
|
182
|
+
for idx in range(len(node_ids) - 1):
|
|
183
|
+
edge = FlowEdge(
|
|
184
|
+
src=node_ids[idx],
|
|
185
|
+
dst=node_ids[idx + 1],
|
|
186
|
+
label=label if idx == len(node_ids) - 2 else None,
|
|
187
|
+
)
|
|
188
|
+
outgoing.setdefault(edge.src, []).append(edge)
|
|
189
|
+
outgoing.setdefault(edge.dst, [])
|
|
190
|
+
|
|
191
|
+
for node_id in node_ids:
|
|
192
|
+
_add_node(nodes, node_id=node_id, label=None, explicit=False, line_no=line_no)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _parse_node_statement(statement: str, line_no: int, nodes: dict[str, _NodeDef]) -> None:
|
|
196
|
+
node_text, label_text = _split_unquoted_once(statement, ":")
|
|
197
|
+
if label_text is not None and _is_property_path(node_text):
|
|
198
|
+
return
|
|
199
|
+
node_id = _parse_node_id(node_text, line_no, allow_inline_label=False)
|
|
200
|
+
label = None
|
|
201
|
+
explicit = False
|
|
202
|
+
if label_text is not None and not label_text.strip():
|
|
203
|
+
return
|
|
204
|
+
if label_text is not None:
|
|
205
|
+
label = _parse_label(label_text, line_no)
|
|
206
|
+
explicit = True
|
|
207
|
+
_add_node(nodes, node_id=node_id, label=label, explicit=explicit, line_no=line_no)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parse_node_id(text: str, line_no: int, *, allow_inline_label: bool) -> str:
|
|
211
|
+
cleaned = text.strip()
|
|
212
|
+
if allow_inline_label and ":" in cleaned:
|
|
213
|
+
cleaned = _split_unquoted_once(cleaned, ":")[0].strip()
|
|
214
|
+
if not cleaned:
|
|
215
|
+
raise PromptFlowParseError(_line_error(line_no, "Expected node id"))
|
|
216
|
+
match = _NODE_ID_RE.fullmatch(cleaned)
|
|
217
|
+
if not match:
|
|
218
|
+
raise PromptFlowParseError(_line_error(line_no, f'Invalid node id "{cleaned}"'))
|
|
219
|
+
return match.group(0)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _is_property_path(node_id: str) -> bool:
|
|
223
|
+
if "." not in node_id:
|
|
224
|
+
return False
|
|
225
|
+
parts = [part for part in node_id.split(".") if part]
|
|
226
|
+
for part in parts[1:]:
|
|
227
|
+
if part in _PROPERTY_SEGMENTS or part.startswith("style"):
|
|
228
|
+
return True
|
|
229
|
+
return parts[-1] in _PROPERTY_SEGMENTS
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _parse_label(text: str, line_no: int) -> str:
|
|
233
|
+
label = text.strip()
|
|
234
|
+
if not label:
|
|
235
|
+
raise PromptFlowParseError(_line_error(line_no, "Label cannot be empty"))
|
|
236
|
+
if label[0] in {"'", '"'}:
|
|
237
|
+
return _parse_quoted_label(label, line_no)
|
|
238
|
+
return label
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _parse_quoted_label(text: str, line_no: int) -> str:
|
|
242
|
+
quote = text[0]
|
|
243
|
+
buf: list[str] = []
|
|
244
|
+
escape = False
|
|
245
|
+
i = 1
|
|
246
|
+
while i < len(text):
|
|
247
|
+
ch = text[i]
|
|
248
|
+
if escape:
|
|
249
|
+
buf.append(ch)
|
|
250
|
+
escape = False
|
|
251
|
+
i += 1
|
|
252
|
+
continue
|
|
253
|
+
if ch == "\\":
|
|
254
|
+
escape = True
|
|
255
|
+
i += 1
|
|
256
|
+
continue
|
|
257
|
+
if ch == quote:
|
|
258
|
+
trailing = text[i + 1 :].strip()
|
|
259
|
+
if trailing:
|
|
260
|
+
raise PromptFlowParseError(_line_error(line_no, "Unexpected trailing content"))
|
|
261
|
+
return "".join(buf)
|
|
262
|
+
buf.append(ch)
|
|
263
|
+
i += 1
|
|
264
|
+
raise PromptFlowParseError(_line_error(line_no, "Unclosed quoted label"))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _split_on_token(text: str, token: str) -> list[str]:
|
|
268
|
+
parts: list[str] = []
|
|
269
|
+
buf: list[str] = []
|
|
270
|
+
in_single = False
|
|
271
|
+
in_double = False
|
|
272
|
+
escape = False
|
|
273
|
+
i = 0
|
|
274
|
+
|
|
275
|
+
while i < len(text):
|
|
276
|
+
if not in_single and not in_double and text.startswith(token, i):
|
|
277
|
+
parts.append("".join(buf).strip())
|
|
278
|
+
buf = []
|
|
279
|
+
i += len(token)
|
|
280
|
+
continue
|
|
281
|
+
ch = text[i]
|
|
282
|
+
if escape:
|
|
283
|
+
escape = False
|
|
284
|
+
elif ch == "\\" and (in_single or in_double):
|
|
285
|
+
escape = True
|
|
286
|
+
elif ch == "'" and not in_double:
|
|
287
|
+
in_single = not in_single
|
|
288
|
+
elif ch == '"' and not in_single:
|
|
289
|
+
in_double = not in_double
|
|
290
|
+
buf.append(ch)
|
|
291
|
+
i += 1
|
|
292
|
+
|
|
293
|
+
if in_single or in_double:
|
|
294
|
+
raise PromptFlowParseError("Unclosed string in statement")
|
|
295
|
+
parts.append("".join(buf).strip())
|
|
296
|
+
return parts
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _split_unquoted_once(text: str, token: str) -> tuple[str, str | None]:
|
|
300
|
+
in_single = False
|
|
301
|
+
in_double = False
|
|
302
|
+
escape = False
|
|
303
|
+
for idx, ch in enumerate(text):
|
|
304
|
+
if escape:
|
|
305
|
+
escape = False
|
|
306
|
+
continue
|
|
307
|
+
if ch == "\\" and (in_single or in_double):
|
|
308
|
+
escape = True
|
|
309
|
+
continue
|
|
310
|
+
if ch == "'" and not in_double:
|
|
311
|
+
in_single = not in_single
|
|
312
|
+
continue
|
|
313
|
+
if ch == '"' and not in_single:
|
|
314
|
+
in_double = not in_double
|
|
315
|
+
continue
|
|
316
|
+
if ch == token and not in_single and not in_double:
|
|
317
|
+
return text[:idx].strip(), text[idx + 1 :].strip()
|
|
318
|
+
return text.strip(), None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _add_node(
|
|
322
|
+
nodes: dict[str, _NodeDef],
|
|
323
|
+
*,
|
|
324
|
+
node_id: str,
|
|
325
|
+
label: str | None,
|
|
326
|
+
explicit: bool,
|
|
327
|
+
line_no: int,
|
|
328
|
+
) -> FlowNode:
|
|
329
|
+
label = label if label is not None else node_id
|
|
330
|
+
label_norm = label.strip().lower()
|
|
331
|
+
if not label:
|
|
332
|
+
raise PromptFlowParseError(_line_error(line_no, "Node label cannot be empty"))
|
|
333
|
+
|
|
334
|
+
kind: FlowNodeKind = "task"
|
|
335
|
+
if label_norm == "begin":
|
|
336
|
+
kind = "begin"
|
|
337
|
+
elif label_norm == "end":
|
|
338
|
+
kind = "end"
|
|
339
|
+
|
|
340
|
+
node = FlowNode(id=node_id, label=label, kind=kind)
|
|
341
|
+
existing = nodes.get(node_id)
|
|
342
|
+
if existing is None:
|
|
343
|
+
nodes[node_id] = _NodeDef(node=node, explicit=explicit)
|
|
344
|
+
return node
|
|
345
|
+
|
|
346
|
+
if existing.node == node:
|
|
347
|
+
return existing.node
|
|
348
|
+
|
|
349
|
+
if not explicit and existing.explicit:
|
|
350
|
+
return existing.node
|
|
351
|
+
|
|
352
|
+
if explicit and not existing.explicit:
|
|
353
|
+
nodes[node_id] = _NodeDef(node=node, explicit=True)
|
|
354
|
+
return node
|
|
355
|
+
|
|
356
|
+
raise PromptFlowParseError(_line_error(line_no, f'Conflicting definition for node "{node_id}"'))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _infer_decision_nodes(
|
|
360
|
+
nodes: dict[str, FlowNode],
|
|
361
|
+
outgoing: dict[str, list[FlowEdge]],
|
|
362
|
+
) -> dict[str, FlowNode]:
|
|
363
|
+
updated: dict[str, FlowNode] = {}
|
|
364
|
+
for node_id, node in nodes.items():
|
|
365
|
+
kind = node.kind
|
|
366
|
+
if kind == "task" and len(outgoing.get(node_id, [])) > 1:
|
|
367
|
+
kind = "decision"
|
|
368
|
+
if kind != node.kind:
|
|
369
|
+
updated[node_id] = FlowNode(id=node.id, label=node.label, kind=kind)
|
|
370
|
+
else:
|
|
371
|
+
updated[node_id] = node
|
|
372
|
+
return updated
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _line_error(line_no: int, message: str) -> str:
|
|
376
|
+
return f"Line {line_no}: {message}"
|
kimi_cli/flow/mermaid.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from . import (
|
|
7
|
+
FlowEdge,
|
|
8
|
+
FlowNode,
|
|
9
|
+
FlowNodeKind,
|
|
10
|
+
PromptFlow,
|
|
11
|
+
PromptFlowParseError,
|
|
12
|
+
validate_flow,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class _NodeSpec:
|
|
18
|
+
node_id: str
|
|
19
|
+
label: str | None
|
|
20
|
+
shape: str | None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(slots=True)
|
|
24
|
+
class _NodeDef:
|
|
25
|
+
node: FlowNode
|
|
26
|
+
explicit: bool
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_NODE_ID_RE = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_-]*")
|
|
30
|
+
_HEADER_RE = re.compile(r"^(flowchart|graph)\b", re.IGNORECASE)
|
|
31
|
+
|
|
32
|
+
_SHAPES = {
|
|
33
|
+
"[": ("square", "]"),
|
|
34
|
+
"(": ("paren", ")"),
|
|
35
|
+
"{": ("curly", "}"),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_mermaid_flowchart(text: str) -> PromptFlow:
|
|
40
|
+
nodes: dict[str, _NodeDef] = {}
|
|
41
|
+
outgoing: dict[str, list[FlowEdge]] = {}
|
|
42
|
+
|
|
43
|
+
for line_no, raw_line in enumerate(text.splitlines(), start=1):
|
|
44
|
+
line = raw_line.strip()
|
|
45
|
+
if not line or line.startswith("%%"):
|
|
46
|
+
continue
|
|
47
|
+
if _HEADER_RE.match(line):
|
|
48
|
+
continue
|
|
49
|
+
if "-->" in line:
|
|
50
|
+
src_spec, label, dst_spec = _parse_edge_line(line, line_no)
|
|
51
|
+
src_node = _add_node(nodes, src_spec, line_no)
|
|
52
|
+
dst_node = _add_node(nodes, dst_spec, line_no)
|
|
53
|
+
edge = FlowEdge(src=src_node.id, dst=dst_node.id, label=label)
|
|
54
|
+
outgoing.setdefault(edge.src, []).append(edge)
|
|
55
|
+
outgoing.setdefault(edge.dst, [])
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
node_spec, idx = _parse_node_token(line, 0, line_no)
|
|
59
|
+
idx = _skip_ws(line, idx)
|
|
60
|
+
if idx != len(line):
|
|
61
|
+
raise PromptFlowParseError(_line_error(line_no, "Unexpected trailing content"))
|
|
62
|
+
_add_node(nodes, node_spec, line_no)
|
|
63
|
+
|
|
64
|
+
flow_nodes = {node_id: node_def.node for node_id, node_def in nodes.items()}
|
|
65
|
+
for node_id in flow_nodes:
|
|
66
|
+
outgoing.setdefault(node_id, [])
|
|
67
|
+
|
|
68
|
+
begin_id, end_id = validate_flow(flow_nodes, outgoing)
|
|
69
|
+
return PromptFlow(nodes=flow_nodes, outgoing=outgoing, begin_id=begin_id, end_id=end_id)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_edge_line(line: str, line_no: int) -> tuple[_NodeSpec, str | None, _NodeSpec]:
|
|
73
|
+
src_spec, idx = _parse_node_token(line, 0, line_no)
|
|
74
|
+
idx = _skip_ws(line, idx)
|
|
75
|
+
if line.startswith("-->", idx):
|
|
76
|
+
idx += 3
|
|
77
|
+
idx = _skip_ws(line, idx)
|
|
78
|
+
label = None
|
|
79
|
+
if idx < len(line) and line[idx] == "|":
|
|
80
|
+
label, idx = _parse_pipe_label(line, idx, line_no)
|
|
81
|
+
idx = _skip_ws(line, idx)
|
|
82
|
+
dst_spec, idx = _parse_node_token(line, idx, line_no)
|
|
83
|
+
idx = _skip_ws(line, idx)
|
|
84
|
+
if idx != len(line):
|
|
85
|
+
raise PromptFlowParseError(_line_error(line_no, "Unexpected trailing content"))
|
|
86
|
+
return src_spec, label, dst_spec
|
|
87
|
+
|
|
88
|
+
if line.startswith("--", idx):
|
|
89
|
+
idx += 2
|
|
90
|
+
arrow_idx = line.find("-->", idx)
|
|
91
|
+
if arrow_idx == -1:
|
|
92
|
+
raise PromptFlowParseError(_line_error(line_no, "Expected '-->' to end edge label"))
|
|
93
|
+
label = line[idx:arrow_idx].strip()
|
|
94
|
+
if not label:
|
|
95
|
+
raise PromptFlowParseError(_line_error(line_no, "Edge label cannot be empty"))
|
|
96
|
+
idx = arrow_idx + 3
|
|
97
|
+
idx = _skip_ws(line, idx)
|
|
98
|
+
dst_spec, idx = _parse_node_token(line, idx, line_no)
|
|
99
|
+
idx = _skip_ws(line, idx)
|
|
100
|
+
if idx != len(line):
|
|
101
|
+
raise PromptFlowParseError(_line_error(line_no, "Unexpected trailing content"))
|
|
102
|
+
return src_spec, label, dst_spec
|
|
103
|
+
|
|
104
|
+
raise PromptFlowParseError(_line_error(line_no, "Expected edge arrow"))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_node_token(line: str, idx: int, line_no: int) -> tuple[_NodeSpec, int]:
|
|
108
|
+
match = _NODE_ID_RE.match(line, idx)
|
|
109
|
+
if not match:
|
|
110
|
+
raise PromptFlowParseError(_line_error(line_no, "Expected node id"))
|
|
111
|
+
node_id = match.group(0)
|
|
112
|
+
idx = match.end()
|
|
113
|
+
|
|
114
|
+
if idx >= len(line) or line[idx] not in _SHAPES:
|
|
115
|
+
return _NodeSpec(node_id=node_id, label=None, shape=None), idx
|
|
116
|
+
|
|
117
|
+
shape, close_char = _SHAPES[line[idx]]
|
|
118
|
+
idx += 1
|
|
119
|
+
label, idx = _parse_label(line, idx, close_char, line_no)
|
|
120
|
+
return _NodeSpec(node_id=node_id, label=label, shape=shape), idx
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _parse_label(line: str, idx: int, close_char: str, line_no: int) -> tuple[str, int]:
|
|
124
|
+
if idx >= len(line):
|
|
125
|
+
raise PromptFlowParseError(_line_error(line_no, "Expected node label"))
|
|
126
|
+
if close_char == ")" and line[idx] == "[":
|
|
127
|
+
label, idx = _parse_label(line, idx + 1, "]", line_no)
|
|
128
|
+
while idx < len(line) and line[idx].isspace():
|
|
129
|
+
idx += 1
|
|
130
|
+
if idx >= len(line) or line[idx] != ")":
|
|
131
|
+
raise PromptFlowParseError(_line_error(line_no, "Unclosed node label"))
|
|
132
|
+
return label, idx + 1
|
|
133
|
+
if line[idx] == '"':
|
|
134
|
+
idx += 1
|
|
135
|
+
buf: list[str] = []
|
|
136
|
+
while idx < len(line):
|
|
137
|
+
ch = line[idx]
|
|
138
|
+
if ch == '"':
|
|
139
|
+
idx += 1
|
|
140
|
+
while idx < len(line) and line[idx].isspace():
|
|
141
|
+
idx += 1
|
|
142
|
+
if idx >= len(line) or line[idx] != close_char:
|
|
143
|
+
raise PromptFlowParseError(_line_error(line_no, "Unclosed node label"))
|
|
144
|
+
return "".join(buf), idx + 1
|
|
145
|
+
if ch == "\\" and idx + 1 < len(line):
|
|
146
|
+
buf.append(line[idx + 1])
|
|
147
|
+
idx += 2
|
|
148
|
+
continue
|
|
149
|
+
buf.append(ch)
|
|
150
|
+
idx += 1
|
|
151
|
+
raise PromptFlowParseError(_line_error(line_no, "Unclosed quoted label"))
|
|
152
|
+
|
|
153
|
+
end = line.find(close_char, idx)
|
|
154
|
+
if end == -1:
|
|
155
|
+
raise PromptFlowParseError(_line_error(line_no, "Unclosed node label"))
|
|
156
|
+
label = line[idx:end].strip()
|
|
157
|
+
if not label:
|
|
158
|
+
raise PromptFlowParseError(_line_error(line_no, "Node label cannot be empty"))
|
|
159
|
+
return label, end + 1
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_pipe_label(line: str, idx: int, line_no: int) -> tuple[str, int]:
|
|
163
|
+
if line[idx] != "|":
|
|
164
|
+
raise PromptFlowParseError(_line_error(line_no, "Expected '|' for edge label"))
|
|
165
|
+
end = line.find("|", idx + 1)
|
|
166
|
+
if end == -1:
|
|
167
|
+
raise PromptFlowParseError(_line_error(line_no, "Unclosed edge label"))
|
|
168
|
+
label = line[idx + 1 : end].strip()
|
|
169
|
+
if not label:
|
|
170
|
+
raise PromptFlowParseError(_line_error(line_no, "Edge label cannot be empty"))
|
|
171
|
+
return label, end + 1
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _skip_ws(line: str, idx: int) -> int:
|
|
175
|
+
while idx < len(line) and line[idx].isspace():
|
|
176
|
+
idx += 1
|
|
177
|
+
return idx
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _add_node(nodes: dict[str, _NodeDef], spec: _NodeSpec, line_no: int) -> FlowNode:
|
|
181
|
+
label = spec.label if spec.label is not None else spec.node_id
|
|
182
|
+
label_norm = label.strip().lower()
|
|
183
|
+
if not label:
|
|
184
|
+
raise PromptFlowParseError(_line_error(line_no, "Node label cannot be empty"))
|
|
185
|
+
|
|
186
|
+
kind: FlowNodeKind = "task"
|
|
187
|
+
if spec.shape == "curly":
|
|
188
|
+
kind = "decision"
|
|
189
|
+
if label_norm == "begin":
|
|
190
|
+
kind = "begin"
|
|
191
|
+
elif label_norm == "end":
|
|
192
|
+
kind = "end"
|
|
193
|
+
|
|
194
|
+
node = FlowNode(id=spec.node_id, label=label, kind=kind)
|
|
195
|
+
explicit = spec.label is not None
|
|
196
|
+
|
|
197
|
+
existing = nodes.get(spec.node_id)
|
|
198
|
+
if existing is None:
|
|
199
|
+
nodes[spec.node_id] = _NodeDef(node=node, explicit=explicit)
|
|
200
|
+
return node
|
|
201
|
+
|
|
202
|
+
if existing.node == node:
|
|
203
|
+
return existing.node
|
|
204
|
+
|
|
205
|
+
if not explicit and existing.explicit:
|
|
206
|
+
return existing.node
|
|
207
|
+
|
|
208
|
+
if explicit and not existing.explicit:
|
|
209
|
+
nodes[spec.node_id] = _NodeDef(node=node, explicit=True)
|
|
210
|
+
return node
|
|
211
|
+
|
|
212
|
+
raise PromptFlowParseError(
|
|
213
|
+
_line_error(line_no, f'Conflicting definition for node "{spec.node_id}"')
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _line_error(line_no: int, message: str) -> str:
|
|
218
|
+
return f"Line {line_no}: {message}"
|