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.

Files changed (137) hide show
  1. kimi_cli/CHANGELOG.md +349 -40
  2. kimi_cli/__init__.py +6 -0
  3. kimi_cli/acp/AGENTS.md +91 -0
  4. kimi_cli/acp/__init__.py +13 -0
  5. kimi_cli/acp/convert.py +111 -0
  6. kimi_cli/acp/kaos.py +270 -0
  7. kimi_cli/acp/mcp.py +46 -0
  8. kimi_cli/acp/server.py +335 -0
  9. kimi_cli/acp/session.py +445 -0
  10. kimi_cli/acp/tools.py +158 -0
  11. kimi_cli/acp/types.py +13 -0
  12. kimi_cli/agents/default/agent.yaml +4 -4
  13. kimi_cli/agents/default/sub.yaml +2 -1
  14. kimi_cli/agents/default/system.md +79 -21
  15. kimi_cli/agents/okabe/agent.yaml +17 -0
  16. kimi_cli/agentspec.py +53 -25
  17. kimi_cli/app.py +180 -52
  18. kimi_cli/cli/__init__.py +595 -0
  19. kimi_cli/cli/__main__.py +8 -0
  20. kimi_cli/cli/info.py +63 -0
  21. kimi_cli/cli/mcp.py +349 -0
  22. kimi_cli/config.py +153 -17
  23. kimi_cli/constant.py +3 -0
  24. kimi_cli/exception.py +23 -2
  25. kimi_cli/flow/__init__.py +117 -0
  26. kimi_cli/flow/d2.py +376 -0
  27. kimi_cli/flow/mermaid.py +218 -0
  28. kimi_cli/llm.py +129 -23
  29. kimi_cli/metadata.py +32 -7
  30. kimi_cli/platforms.py +262 -0
  31. kimi_cli/prompts/__init__.py +2 -0
  32. kimi_cli/prompts/compact.md +4 -5
  33. kimi_cli/session.py +223 -31
  34. kimi_cli/share.py +2 -0
  35. kimi_cli/skill.py +145 -0
  36. kimi_cli/skills/kimi-cli-help/SKILL.md +55 -0
  37. kimi_cli/skills/skill-creator/SKILL.md +351 -0
  38. kimi_cli/soul/__init__.py +51 -20
  39. kimi_cli/soul/agent.py +213 -85
  40. kimi_cli/soul/approval.py +86 -17
  41. kimi_cli/soul/compaction.py +64 -53
  42. kimi_cli/soul/context.py +38 -5
  43. kimi_cli/soul/denwarenji.py +2 -0
  44. kimi_cli/soul/kimisoul.py +442 -60
  45. kimi_cli/soul/message.py +54 -54
  46. kimi_cli/soul/slash.py +72 -0
  47. kimi_cli/soul/toolset.py +387 -6
  48. kimi_cli/toad.py +74 -0
  49. kimi_cli/tools/AGENTS.md +5 -0
  50. kimi_cli/tools/__init__.py +42 -34
  51. kimi_cli/tools/display.py +25 -0
  52. kimi_cli/tools/dmail/__init__.py +10 -10
  53. kimi_cli/tools/dmail/dmail.md +11 -9
  54. kimi_cli/tools/file/__init__.py +1 -3
  55. kimi_cli/tools/file/glob.py +20 -23
  56. kimi_cli/tools/file/grep.md +1 -1
  57. kimi_cli/tools/file/{grep.py → grep_local.py} +51 -23
  58. kimi_cli/tools/file/read.md +24 -6
  59. kimi_cli/tools/file/read.py +134 -50
  60. kimi_cli/tools/file/replace.md +1 -1
  61. kimi_cli/tools/file/replace.py +36 -29
  62. kimi_cli/tools/file/utils.py +282 -0
  63. kimi_cli/tools/file/write.py +43 -22
  64. kimi_cli/tools/multiagent/__init__.py +7 -0
  65. kimi_cli/tools/multiagent/create.md +11 -0
  66. kimi_cli/tools/multiagent/create.py +50 -0
  67. kimi_cli/tools/{task/__init__.py → multiagent/task.py} +48 -53
  68. kimi_cli/tools/shell/__init__.py +120 -0
  69. kimi_cli/tools/{bash → shell}/bash.md +1 -2
  70. kimi_cli/tools/shell/powershell.md +25 -0
  71. kimi_cli/tools/test.py +4 -4
  72. kimi_cli/tools/think/__init__.py +2 -2
  73. kimi_cli/tools/todo/__init__.py +14 -8
  74. kimi_cli/tools/utils.py +64 -24
  75. kimi_cli/tools/web/fetch.py +68 -13
  76. kimi_cli/tools/web/search.py +10 -12
  77. kimi_cli/ui/acp/__init__.py +65 -412
  78. kimi_cli/ui/print/__init__.py +37 -49
  79. kimi_cli/ui/print/visualize.py +179 -0
  80. kimi_cli/ui/shell/__init__.py +141 -84
  81. kimi_cli/ui/shell/console.py +2 -0
  82. kimi_cli/ui/shell/debug.py +28 -23
  83. kimi_cli/ui/shell/keyboard.py +5 -1
  84. kimi_cli/ui/shell/prompt.py +220 -194
  85. kimi_cli/ui/shell/replay.py +111 -46
  86. kimi_cli/ui/shell/setup.py +89 -82
  87. kimi_cli/ui/shell/slash.py +422 -0
  88. kimi_cli/ui/shell/update.py +4 -2
  89. kimi_cli/ui/shell/usage.py +271 -0
  90. kimi_cli/ui/shell/visualize.py +574 -72
  91. kimi_cli/ui/wire/__init__.py +267 -0
  92. kimi_cli/ui/wire/jsonrpc.py +142 -0
  93. kimi_cli/ui/wire/protocol.py +1 -0
  94. kimi_cli/utils/__init__.py +0 -0
  95. kimi_cli/utils/aiohttp.py +2 -0
  96. kimi_cli/utils/aioqueue.py +72 -0
  97. kimi_cli/utils/broadcast.py +37 -0
  98. kimi_cli/utils/changelog.py +12 -7
  99. kimi_cli/utils/clipboard.py +12 -0
  100. kimi_cli/utils/datetime.py +37 -0
  101. kimi_cli/utils/environment.py +58 -0
  102. kimi_cli/utils/envvar.py +12 -0
  103. kimi_cli/utils/frontmatter.py +44 -0
  104. kimi_cli/utils/logging.py +7 -6
  105. kimi_cli/utils/message.py +9 -14
  106. kimi_cli/utils/path.py +99 -9
  107. kimi_cli/utils/pyinstaller.py +6 -0
  108. kimi_cli/utils/rich/__init__.py +33 -0
  109. kimi_cli/utils/rich/columns.py +99 -0
  110. kimi_cli/utils/rich/markdown.py +961 -0
  111. kimi_cli/utils/rich/markdown_sample.md +108 -0
  112. kimi_cli/utils/rich/markdown_sample_short.md +2 -0
  113. kimi_cli/utils/signals.py +2 -0
  114. kimi_cli/utils/slashcmd.py +124 -0
  115. kimi_cli/utils/string.py +2 -0
  116. kimi_cli/utils/term.py +168 -0
  117. kimi_cli/utils/typing.py +20 -0
  118. kimi_cli/wire/__init__.py +98 -29
  119. kimi_cli/wire/serde.py +45 -0
  120. kimi_cli/wire/types.py +299 -0
  121. kimi_cli-0.78.dist-info/METADATA +200 -0
  122. kimi_cli-0.78.dist-info/RECORD +135 -0
  123. kimi_cli-0.78.dist-info/entry_points.txt +4 -0
  124. kimi_cli/cli.py +0 -250
  125. kimi_cli/soul/runtime.py +0 -96
  126. kimi_cli/tools/bash/__init__.py +0 -99
  127. kimi_cli/tools/file/patch.md +0 -8
  128. kimi_cli/tools/file/patch.py +0 -143
  129. kimi_cli/tools/mcp.py +0 -85
  130. kimi_cli/ui/shell/liveview.py +0 -386
  131. kimi_cli/ui/shell/metacmd.py +0 -262
  132. kimi_cli/wire/message.py +0 -91
  133. kimi_cli-0.44.dist-info/METADATA +0 -188
  134. kimi_cli-0.44.dist-info/RECORD +0 -89
  135. kimi_cli-0.44.dist-info/entry_points.txt +0 -3
  136. /kimi_cli/tools/{task → multiagent}/task.md +0 -0
  137. {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}"
@@ -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}"