@margay/ccl-core 1.4.5-rc.9 → 1.5.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/QUICKSTART.md +6 -5
- package/README.md +7 -6
- package/dist/cli.js +13315 -9910
- package/dist/sdk/index.d.ts +3 -1
- package/dist/sdk/index.js +1 -0
- package/dist/sdk/query.d.ts +2 -29
- package/dist/sdk/query.js +84 -32
- package/dist/sdk/queryHandle.d.ts +4 -4
- package/dist/sdk/queryHandle.js +44 -9
- package/dist/sdk/queryInput.d.ts +14 -0
- package/dist/sdk/queryInput.js +82 -0
- package/dist/sdk/requestProgress.d.ts +14 -0
- package/dist/sdk/requestProgress.js +26 -0
- package/dist/sdk/session.d.ts +53 -0
- package/dist/sdk/session.js +222 -0
- package/dist/sdk/types.d.ts +57 -1
- package/docs/ccl-duo-agent.md +2 -0
- package/docs/cli-installation.md +86 -0
- package/docs/context-checkpoints-and-daemon.md +91 -0
- package/docs/gateway-quickstart.md +52 -0
- package/docs/releases/1.4.5-rc.10.md +50 -0
- package/docs/releases/1.4.6-beta.0.md +54 -0
- package/docs/releases/1.5.0-alpha.0.md +44 -0
- package/examples/assistant/README.md +23 -0
- package/examples/assistant/client.mjs +227 -0
- package/examples/assistant/office/SKILL.md +18 -0
- package/examples/assistant/office/build.py +113 -0
- package/examples/assistant/office/check.py +174 -0
- package/examples/assistant/office/render.py +99 -0
- package/examples/assistant/office/workflow.md +46 -0
- package/examples/assistant/terminal.mjs +60 -0
- package/package.json +11 -4
- package/sdk/API.md +51 -3
- package/sdk/README.md +101 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Independent mechanical Office checks. No inference and no semantic verdict.
|
|
3
|
+
Usage: python3 check.py INPUT_DIRECTORY OUTPUT_DIRECTORY
|
|
4
|
+
stdout: one JSON report; exit 0 mechanical pass, 1 mismatch, 2 invalid input.
|
|
5
|
+
"""
|
|
6
|
+
import csv
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
import sys
|
|
11
|
+
import zipfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from xml.etree import ElementTree as ET
|
|
14
|
+
|
|
15
|
+
NS = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
|
|
16
|
+
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
|
17
|
+
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
|
|
18
|
+
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
|
19
|
+
'c': 'http://schemas.openxmlformats.org/drawingml/2006/chart'}
|
|
20
|
+
|
|
21
|
+
def sha(path):
|
|
22
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
23
|
+
|
|
24
|
+
def source_facts(inputs):
|
|
25
|
+
def rows(name):
|
|
26
|
+
with (inputs / name).open(encoding='utf-8-sig', newline='') as f:
|
|
27
|
+
return list(csv.DictReader(f))
|
|
28
|
+
monthly, channels = rows('monthly.csv'), rows('channels.csv')
|
|
29
|
+
if len(monthly) < 2 or not channels:
|
|
30
|
+
raise ValueError('At least two monthly rows and one channel are required')
|
|
31
|
+
monthly.sort(key=lambda row: row['month'])
|
|
32
|
+
months = [row['month'] for row in monthly]
|
|
33
|
+
if len(set(months)) != len(months):
|
|
34
|
+
raise ValueError('Duplicate months')
|
|
35
|
+
for rows_ in (monthly, channels):
|
|
36
|
+
for row in rows_:
|
|
37
|
+
for key in ('revenue', 'cost', 'orders'):
|
|
38
|
+
if key in row:
|
|
39
|
+
row[key] = float(row[key])
|
|
40
|
+
if not math.isfinite(row[key]) or row[key] < 0:
|
|
41
|
+
raise ValueError(f'Invalid {key}')
|
|
42
|
+
if any(row['revenue'] <= 0 for row in monthly):
|
|
43
|
+
raise ValueError('Revenue must be positive for ratio calculations')
|
|
44
|
+
revenue = sum(row['revenue'] for row in monthly)
|
|
45
|
+
channel_total = sum(row['revenue'] for row in channels)
|
|
46
|
+
if not math.isclose(revenue, channel_total, abs_tol=.01):
|
|
47
|
+
raise ValueError('Monthly and channel revenue disagree; resolve source scope first')
|
|
48
|
+
cost = sum(row['cost'] for row in monthly)
|
|
49
|
+
series = [{**row, 'gross_profit': row['revenue'] - row['cost'],
|
|
50
|
+
'cost_ratio': row['cost'] / row['revenue'],
|
|
51
|
+
'gross_margin': (row['revenue'] - row['cost']) / row['revenue'],
|
|
52
|
+
'revenue_mom': None if i == 0 else row['revenue'] / monthly[i-1]['revenue'] - 1}
|
|
53
|
+
for i, row in enumerate(monthly)]
|
|
54
|
+
top = max(channels, key=lambda row: row['revenue'])
|
|
55
|
+
highest_cost = max(series, key=lambda row: row['cost_ratio'])
|
|
56
|
+
metrics = {'revenue': revenue, 'cost': cost, 'gross_profit': revenue - cost,
|
|
57
|
+
'gross_margin': (revenue - cost) / revenue,
|
|
58
|
+
'revenue_growth': monthly[-1]['revenue'] / monthly[0]['revenue'] - 1,
|
|
59
|
+
'top_channel': top['channel'], 'top_channel_share': top['revenue'] / revenue,
|
|
60
|
+
'highest_cost_ratio_month': highest_cost['month'],
|
|
61
|
+
'highest_cost_ratio': highest_cost['cost_ratio'],
|
|
62
|
+
'revenue_monotonically_increasing': all(row['revenue_mom'] > 0 for row in series[1:])}
|
|
63
|
+
return {'period': [months[0], months[-1]], 'metrics': metrics, 'monthly': series, 'channels': channels,
|
|
64
|
+
'formulas': {'gross_margin': '(sum(revenue)-sum(cost))/sum(revenue)',
|
|
65
|
+
'revenue_growth': 'last_month_revenue/first_month_revenue-1',
|
|
66
|
+
'cost_ratio': 'month_cost/month_revenue',
|
|
67
|
+
'revenue_mom': 'month_revenue/previous_month_revenue-1',
|
|
68
|
+
'top_channel_share': 'largest_channel_revenue/sum(revenue)'},
|
|
69
|
+
'sources': {name: sha(inputs / name) for name in ('monthly.csv', 'channels.csv')}}
|
|
70
|
+
|
|
71
|
+
def xml(package, path):
|
|
72
|
+
return ET.fromstring(package.read(path))
|
|
73
|
+
|
|
74
|
+
def text(node, prefix):
|
|
75
|
+
return ''.join(element.text or '' for element in node.findall('.//' + prefix + ':t', NS))
|
|
76
|
+
|
|
77
|
+
def embedded_media(package, part):
|
|
78
|
+
"""Count distinct images/native charts actually referenced by this part."""
|
|
79
|
+
root = xml(package, part)
|
|
80
|
+
ids = [e.get('{' + NS['r'] + '}embed') for e in root.findall('.//a:blip', NS)]
|
|
81
|
+
ids += [e.get('{' + NS['r'] + '}id') for e in root.findall('.//c:chart', NS)]
|
|
82
|
+
path = Path(part)
|
|
83
|
+
rels = xml(package, str(path.parent / '_rels' / (path.name + '.rels')))
|
|
84
|
+
targets = {e.get('Id'): e for e in rels}
|
|
85
|
+
hashes = set()
|
|
86
|
+
for id_ in ids:
|
|
87
|
+
rel = targets.get(id_)
|
|
88
|
+
if rel is None or rel.get('TargetMode') == 'External':
|
|
89
|
+
raise ValueError(f'{part}: unresolved/external media {id_}')
|
|
90
|
+
import posixpath
|
|
91
|
+
target = posixpath.normpath(posixpath.join(str(path.parent), rel.get('Target', '')))
|
|
92
|
+
if target.startswith('/'):
|
|
93
|
+
target = target.lstrip('/')
|
|
94
|
+
hashes.add(hashlib.sha256(package.read(target)).hexdigest())
|
|
95
|
+
return hashes
|
|
96
|
+
|
|
97
|
+
def inspect_docx(path):
|
|
98
|
+
with zipfile.ZipFile(path) as package:
|
|
99
|
+
root = xml(package, 'word/document.xml')
|
|
100
|
+
body = root.find('w:body', NS)
|
|
101
|
+
paragraphs = []
|
|
102
|
+
for paragraph in body.findall('w:p', NS):
|
|
103
|
+
style = paragraph.find('w:pPr/w:pStyle', NS)
|
|
104
|
+
style_id = style.get('{' + NS['w'] + '}val', 'Normal') if style is not None else 'Normal'
|
|
105
|
+
if style_id in ('Normal', 'BodyText', 'BodyText2', 'BodyText3'):
|
|
106
|
+
paragraphs.append(text(paragraph, 'w'))
|
|
107
|
+
# The task defines Chinese body characters, excluding tables, titles and captions.
|
|
108
|
+
count = sum(1 for paragraph in paragraphs for char in paragraph
|
|
109
|
+
if '\u3400' <= char <= '\u4dbf' or '\u4e00' <= char <= '\u9fff' or '\U00020000' <= char <= '\U0002fa1f')
|
|
110
|
+
return {'body_han_characters': count, 'body_paragraphs': paragraphs,
|
|
111
|
+
'full_text': text(body, 'w'),
|
|
112
|
+
'embedded_media_count': len(embedded_media(package, 'word/document.xml'))}
|
|
113
|
+
|
|
114
|
+
def inspect_pptx(path):
|
|
115
|
+
with zipfile.ZipFile(path) as package:
|
|
116
|
+
presentation = xml(package, 'ppt/presentation.xml')
|
|
117
|
+
size = presentation.find('p:sldSz', NS)
|
|
118
|
+
rels = {r.get('Id'): r.get('Target') for r in xml(package, 'ppt/_rels/presentation.xml.rels')}
|
|
119
|
+
parts = []
|
|
120
|
+
import posixpath
|
|
121
|
+
for slide in presentation.findall('p:sldIdLst/p:sldId', NS):
|
|
122
|
+
target = rels[slide.get('{' + NS['r'] + '}id')]
|
|
123
|
+
parts.append(target.lstrip('/') if target.startswith('/') else posixpath.normpath('ppt/' + target))
|
|
124
|
+
media = set()
|
|
125
|
+
texts = []
|
|
126
|
+
for part in parts:
|
|
127
|
+
texts.append(text(xml(package, part), 'a'))
|
|
128
|
+
media |= embedded_media(package, part)
|
|
129
|
+
return {'slide_count': len(parts), 'ratio': int(size.get('cx')) / int(size.get('cy')),
|
|
130
|
+
'slides_text': texts, 'embedded_media_count': len(media)}
|
|
131
|
+
|
|
132
|
+
def check(inputs, outputs):
|
|
133
|
+
facts = source_facts(inputs)
|
|
134
|
+
metrics = json.loads((outputs / 'metrics.json').read_text(encoding='utf8'))
|
|
135
|
+
# Deliberately read only for deliverable validity; model assertions are not a verdict.
|
|
136
|
+
json.loads((outputs / 'validation.json').read_text(encoding='utf8'))
|
|
137
|
+
docx, pptx = inspect_docx(outputs / 'report.docx'), inspect_pptx(outputs / 'presentation.pptx')
|
|
138
|
+
failures = []
|
|
139
|
+
for key, expected in facts['metrics'].items():
|
|
140
|
+
actual = metrics.get(key)
|
|
141
|
+
if isinstance(expected, bool) or isinstance(expected, str):
|
|
142
|
+
equal = type(actual) is type(expected) and actual == expected
|
|
143
|
+
else:
|
|
144
|
+
equal = type(actual) in (int, float) and math.isfinite(actual) and math.isclose(actual, expected, rel_tol=1e-5, abs_tol=1e-6)
|
|
145
|
+
if not equal:
|
|
146
|
+
failures.append({'check': 'metric.' + key, 'expected': expected, 'actual': actual})
|
|
147
|
+
for ok, name, expected, actual in [
|
|
148
|
+
(docx['body_han_characters'] >= 1000, 'docx.body_han_characters', '>=1000', docx['body_han_characters']),
|
|
149
|
+
(pptx['slide_count'] == 6, 'pptx.slide_count', 6, pptx['slide_count']),
|
|
150
|
+
(math.isclose(pptx['ratio'], 16 / 9, abs_tol=.001), 'pptx.aspect_ratio', '16:9', pptx['ratio']),
|
|
151
|
+
(docx['embedded_media_count'] >= 2, 'docx.embedded_media', '>=2 distinct', docx['embedded_media_count']),
|
|
152
|
+
(pptx['embedded_media_count'] >= 2, 'pptx.embedded_media', '>=2 distinct', pptx['embedded_media_count']),
|
|
153
|
+
]:
|
|
154
|
+
if not ok:
|
|
155
|
+
failures.append({'check': name, 'expected': expected, 'actual': actual})
|
|
156
|
+
return {'schema_version': 1, 'mechanical': 'failed' if failures else 'passed',
|
|
157
|
+
'semantic': 'unverified', 'overall': 'failed' if failures else 'unverified',
|
|
158
|
+
'failures': failures, 'facts': facts, 'docx': docx, 'pptx': pptx,
|
|
159
|
+
'artifacts': {name: sha(outputs / name) for name in ('report.docx', 'presentation.pptx', 'metrics.json', 'validation.json')},
|
|
160
|
+
'review_required': ['Read full Word and all slide text against source facts and supplied brief/meeting notes.',
|
|
161
|
+
'Inspect every rendered page/slide for clipping, readable labels and at least two genuine data charts in each document.',
|
|
162
|
+
'Check at least four actionable proposals with suggested owner, date and measurable acceptance criterion.',
|
|
163
|
+
'Separate established facts, meeting observations and proposals; do not infer unsupported causation or unavailable metrics.']}
|
|
164
|
+
|
|
165
|
+
if __name__ == '__main__':
|
|
166
|
+
try:
|
|
167
|
+
if len(sys.argv) != 3:
|
|
168
|
+
raise ValueError('Usage: check.py INPUT_DIRECTORY OUTPUT_DIRECTORY')
|
|
169
|
+
report = check(Path(sys.argv[1]), Path(sys.argv[2]))
|
|
170
|
+
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
171
|
+
sys.exit(0 if report['mechanical'] == 'passed' else 1)
|
|
172
|
+
except Exception as error:
|
|
173
|
+
print(json.dumps({'schema_version': 1, 'mechanical': 'error', 'semantic': 'unverified', 'overall': 'failed', 'error': str(error)}, ensure_ascii=False))
|
|
174
|
+
sys.exit(2)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Render every page through isolated LibreOffice, then PyMuPDF. Never uses an open office window."""
|
|
3
|
+
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import signal
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def digest(path):
|
|
14
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def previous_pages(output):
|
|
18
|
+
"""Recover only this renderer's page names; unknown metadata grants no ownership."""
|
|
19
|
+
path = output / 'manifest.json'
|
|
20
|
+
if path.is_symlink():
|
|
21
|
+
return {}
|
|
22
|
+
try:
|
|
23
|
+
manifest = json.loads(path.read_text(encoding='utf8'))
|
|
24
|
+
except (OSError, ValueError):
|
|
25
|
+
return {}
|
|
26
|
+
if not isinstance(manifest, dict) or not isinstance(manifest.get('documents'), list):
|
|
27
|
+
return {}
|
|
28
|
+
pages = {}
|
|
29
|
+
for document in manifest['documents']:
|
|
30
|
+
if not isinstance(document, dict) or document.get('source') not in ('report.docx', 'presentation.pptx'):
|
|
31
|
+
continue
|
|
32
|
+
if not isinstance(document.get('pages'), list):
|
|
33
|
+
continue
|
|
34
|
+
for page in document['pages']:
|
|
35
|
+
if not isinstance(page, dict) or type(page.get('page')) is not int or page['page'] < 1:
|
|
36
|
+
continue
|
|
37
|
+
name = Path(document['source']).stem + f"-page-{page['page']:03d}.png"
|
|
38
|
+
if page.get('path') == name and isinstance(page.get('sha256'), str):
|
|
39
|
+
pages[name] = page['sha256']
|
|
40
|
+
return pages
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def render(soffice, source, output):
|
|
44
|
+
import pymupdf as fitz
|
|
45
|
+
source, output = source.resolve(), output.resolve()
|
|
46
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
previous = previous_pages(output)
|
|
48
|
+
manifest = {'renderer': str(Path(soffice).resolve()), 'documents': []}
|
|
49
|
+
with tempfile.TemporaryDirectory(prefix='ccl-office-render-') as own:
|
|
50
|
+
profile = (Path(own) / 'profile').as_uri()
|
|
51
|
+
for name in ('report.docx', 'presentation.pptx'):
|
|
52
|
+
original = source / name
|
|
53
|
+
# Fresh conversion directory prevents accidentally accepting an old PDF.
|
|
54
|
+
conversion = Path(own) / name
|
|
55
|
+
conversion.mkdir()
|
|
56
|
+
command = [soffice, '-env:UserInstallation=' + profile, '--headless', '--convert-to', 'pdf', '--outdir', str(conversion), str(original)]
|
|
57
|
+
child = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
|
58
|
+
start_new_session=True, env={**os.environ, 'TMPDIR': own})
|
|
59
|
+
try:
|
|
60
|
+
log, _ = child.communicate(timeout=120)
|
|
61
|
+
except subprocess.TimeoutExpired:
|
|
62
|
+
os.killpg(child.pid, signal.SIGTERM)
|
|
63
|
+
try: child.communicate(timeout=5)
|
|
64
|
+
except subprocess.TimeoutExpired:
|
|
65
|
+
os.killpg(child.pid, signal.SIGKILL)
|
|
66
|
+
child.communicate()
|
|
67
|
+
raise RuntimeError('Office conversion timed out: ' + name)
|
|
68
|
+
pdf = conversion / (Path(name).stem + '.pdf')
|
|
69
|
+
if child.returncode != 0 or not pdf.is_file():
|
|
70
|
+
raise RuntimeError('Office conversion failed: ' + log.decode(errors='replace')[-2000:])
|
|
71
|
+
destination = output / pdf.name
|
|
72
|
+
destination.write_bytes(pdf.read_bytes())
|
|
73
|
+
pages = []
|
|
74
|
+
with fitz.open(destination) as document:
|
|
75
|
+
for index, page in enumerate(document):
|
|
76
|
+
image = output / (pdf.stem + f'-page-{index + 1:03d}.png')
|
|
77
|
+
page.get_pixmap(matrix=fitz.Matrix(1.5, 1.5), alpha=False).save(image)
|
|
78
|
+
pages.append({'page': index + 1, 'path': image.name, 'sha256': digest(image)})
|
|
79
|
+
if not pages:
|
|
80
|
+
raise RuntimeError('Renderer returned no pages: ' + name)
|
|
81
|
+
manifest['documents'].append({'source': name, 'source_sha256': digest(original), 'pdf': destination.name,
|
|
82
|
+
'pdf_sha256': digest(destination), 'pages': pages})
|
|
83
|
+
# Both conversions succeeded. Remove only unchanged obsolete pages owned by
|
|
84
|
+
# the previous manifest; preserve unlisted files, user edits and symlinks.
|
|
85
|
+
current = {page['path'] for document in manifest['documents'] for page in document['pages']}
|
|
86
|
+
for name, sha256 in previous.items():
|
|
87
|
+
path = output / name
|
|
88
|
+
if name not in current and not path.is_symlink() and path.is_file() and digest(path) == sha256:
|
|
89
|
+
path.unlink()
|
|
90
|
+
(output / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf8')
|
|
91
|
+
print(json.dumps(manifest, ensure_ascii=False))
|
|
92
|
+
|
|
93
|
+
if __name__ == '__main__':
|
|
94
|
+
parser = argparse.ArgumentParser()
|
|
95
|
+
parser.add_argument('--soffice', required=True)
|
|
96
|
+
parser.add_argument('source', type=Path)
|
|
97
|
+
parser.add_argument('output', type=Path)
|
|
98
|
+
args = parser.parse_args()
|
|
99
|
+
render(args.soffice, args.source, args.output)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# 制作与验收
|
|
2
|
+
|
|
3
|
+
本目录随 npm 包发布。命令里的 WORKFLOW 指本目录的绝对路径;INPUT/OUTPUT 是调用者提供的任务目录。原件留在 INPUT,产物写入 OUTPUT。中文字体和渲染依赖以运行环境实际可用项为准。
|
|
4
|
+
|
|
5
|
+
制作依赖:Python 3、python-docx、python-pptx、Pillow。独立 `check.py` 只使用 Python 标准库。缺依赖时使用任务自己的虚拟环境安装,或采用已授权且能满足相同合同的制品工具,不改用户全局环境。
|
|
6
|
+
|
|
7
|
+
模板 `build.py` 接受如下 `content.json`(这里是结构示意,正文与分析由助手根据本次材料写):
|
|
8
|
+
|
|
9
|
+
```json
|
|
10
|
+
{
|
|
11
|
+
"title": "报告标题",
|
|
12
|
+
"subtitle": "期间、人民币元、编制日期",
|
|
13
|
+
"sections": [{"title":"章节", "paragraphs":["完整正文段落"], "chart":"monthly", "caption":"图注及来源"}],
|
|
14
|
+
"actions": [{"action":"建议行动", "owner":"建议负责人", "date":"建议日期", "metric":"可检查指标"}],
|
|
15
|
+
"slides": [{"title":"页标题", "bullets":["观点和依据"], "chart":"channels"}],
|
|
16
|
+
"metrics": {"revenue":0,"cost":0,"gross_profit":0,"gross_margin":0,"revenue_growth":0,"top_channel":"渠道","top_channel_share":0,"highest_cost_ratio_month":"YYYY-MM","highest_cost_ratio":0,"revenue_monotonically_increasing":false},
|
|
17
|
+
"author_checks": ["作者已完成的具体检查"]
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
sections/slides 的 chart 可省略;支持 monthly(收入和成本)、channels(渠道收入),每份文档都应含二者。slides 提供 6 项;actions 至少 4 项;正文写足实质分析。比例使用 0–1 数值。输入 CSV 字段分别为 `month,revenue,cost,orders` 与 `channel,revenue,orders`,此外读取用户提供的业务简报与会议材料。别的结构应先做显式映射、保留原件与映射记录,不把未知单位默认为人民币。
|
|
22
|
+
|
|
23
|
+
写分析前,明确每项比较的起点、终点、分母和期间。首月与末月的变化只能称“末月较首月”,不能改称半年累计增长或同比;同比需要上一年同期数据。收入、成本率、毛利率各自检查完整月度序列:首尾改善不意味着逐月改善,中途反向变化要在 Word 与 PPT 中一致披露,不在另一段又称单调变化。不要把不同月份的数值合并成同一个值。
|
|
24
|
+
|
|
25
|
+
把可观察事实、业务假设、会议观察和建议分别表述。成本率下降本身不能证明固定成本摊薄、采购提效或交付改善;没有成本拆分与运营数据时,把这些作为待验证解释,不能在正文、图注或 PPT 中改写成已证实原因。收入和订单数据也不能证明获客触点、低成本渠道或未来增长潜力。只对明确推导的比率作数值结论,其他判断给出已有依据或注明需要验证。
|
|
26
|
+
|
|
27
|
+
Word 的行动表与 PPT 的行动页分别能够独立使用:至少四项行动都给出建议负责人、建议日期和可检查指标;“建议”标识可用全页说明,不能只在 Word 写完整而在 PPT 丢失日期和验收指标。内容过密时优先压缩重复叙述、调整版式,不能删除决策所需字段。
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
python3 "$WORKFLOW/build.py" "$INPUT" content.json "$OUTPUT"
|
|
31
|
+
python3 "$WORKFLOW/check.py" "$INPUT" "$OUTPUT" > independent-check.json
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
build 替换 OUTPUT 中 report.docx、presentation.pptx、metrics.json、validation.json、monthly.png、channels.png;非零退出需读错误并修复具体原因。模板图表取自 CSV,报告正文和 metrics 取自 content.json;不从检查器复制计算来伪造独立比较。可修改模板布局或直接编辑 Office 文件,但再次检查实际最终文件。
|
|
35
|
+
|
|
36
|
+
check 从 CSV 重新计算事实,直接解析 Office 文件;输出 JSON 含输入/产物哈希、实际正文汉字数、页数、比例、内嵌媒体数量、全文、事实表及差异。0 只表示机械检查通过;1 表示差异,2 表示输入/制品不可解析。内嵌媒体检查证明有不同图片/原生图表,不证明它们是正确数据图;独立审阅须检查。`semantic`/`overall` 不因机械检查通过自动变绿。源材料的会议观察、因果陈述、行动可行性须审阅全文,不用关键词正则裁判。
|
|
37
|
+
|
|
38
|
+
用真实 Office 渲染器导出两个制品的全部页面,再逐页检查。若调用者明确由宿主渲染,以下命令由宿主执行;生成助手完成制品和机械检查后交回,不在代理沙箱内查找、启动或排障渲染器。`render.py` 使用给定 LibreOffice 可执行文件,独立用户配置与输出目录,不调用用户正在使用的 Office 窗口。需要 PyMuPDF 将 PDF 每页转 PNG。执行:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
python3 "$WORKFLOW/render.py" --soffice /absolute/path/to/soffice "$OUTPUT" "$OUTPUT/rendered"
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
返回 manifest.json 包含源文件哈希、PDF/全部页图片哈希和页数;按本次 manifest 及哈希选取审阅页面,不把目录中其他图片当作当前页面。复用输出目录时,两个制品均成功转换后,渲染器只清除上次 manifest 记录、内容未被修改且本次已不再存在的旧页;未登记文件、用户修改过的文件和符号链接保留。调用者提供渲染器/Python 路径时直接使用。未提供时只检查 PATH 和系统标准安装位置一次;确认缺失后,在任务目录准备依赖或报告具体依赖缺口,继续完成不依赖渲染的部分。不要重复全盘搜索或用像素数量、XML 抽取、自制近似 PDF 替代真实渲染。只看首页不能称为版式验收。渲染完成后交给现有独立审阅者;没有独立审阅能力时保存全部页面并报告待审,不虚构另一个审阅者。
|
|
45
|
+
|
|
46
|
+
独立审阅者应读取原简报、会议记录、CSV、independent-check.json、报告全文和 manifest 的全部页面,逐条判断:事实/趋势/极值正确;会议观察与建议分开;至少 4 条建议各有建议负责人、日期、可检查指标;图表真实且无裁切、遮挡、字号过小。记录看到的文件哈希和具体页/段问题,留存审阅来源。修复后只复核受影响内容及结构,不制造自签独立 PASS。
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from 'node:readline'
|
|
3
|
+
import { parseArgs } from 'node:util'
|
|
4
|
+
import { AssistantClient } from './client.mjs'
|
|
5
|
+
|
|
6
|
+
const { values } = parseArgs({ options: {
|
|
7
|
+
cwd: { type: 'string' }, 'state-dir': { type: 'string' }, model: { type: 'string' },
|
|
8
|
+
resume: { type: 'boolean' }, 'cli-path': { type: 'string' }, 'extra-args': { type: 'string' }, help: { type: 'boolean' },
|
|
9
|
+
} })
|
|
10
|
+
if (values.help) {
|
|
11
|
+
console.log('node terminal.mjs [--cwd PROJECT] [--state-dir DIR] [--model MODEL] [--resume]\nCommands: /status, /steer TEXT, /cancel [TURN_ID], /allow TOOL_ID, /deny TOOL_ID, /defer TOOL_ID, /artifact FILE, /office ["INPUT","OUTPUT"], /verify-office ["INPUT","OUTPUT"], /exit\nExecution, verification and file availability are separate. Restart with --resume; uncertain work is never replayed automatically.')
|
|
12
|
+
process.exit(0)
|
|
13
|
+
}
|
|
14
|
+
let textOpen = false
|
|
15
|
+
function line(message) { if (textOpen) { process.stdout.write('\n'); textOpen = false }; console.log(message) }
|
|
16
|
+
function display(event) {
|
|
17
|
+
if (event.type === 'text_delta') { process.stdout.write(event.text); textOpen = true; return }
|
|
18
|
+
if (event.type === 'connected') line(`[连接] session=${event.sessionId} pid=${event.pid}`)
|
|
19
|
+
else if (event.type === 'turn_queued') line(`[排队] ${event.turnId}`)
|
|
20
|
+
else if (event.type === 'turn_started') line(`[已发送] ${event.turnId},等待执行响应`)
|
|
21
|
+
else if (event.type === 'turn_cancel_requested') line(`[取消请求] ${event.turnId};已发生的操作不会撤销`)
|
|
22
|
+
else if (event.type === 'turn_finished') line(`[执行结束] ${event.turnId}: ${event.outcome}`)
|
|
23
|
+
else if (event.type === 'execution_result') line(`[验证] ${event.verification};执行结果不等于独立验收`)
|
|
24
|
+
else if (event.type === 'tool_use') line(`[工具] ${event.toolName}`)
|
|
25
|
+
else if (event.type === 'request_progress') line(`[请求] ${event.progress.phase}`)
|
|
26
|
+
else if (event.type === 'approval') line(`[等待权限] ${event.toolName} ${JSON.stringify(event.input)}\n/allow ${event.toolUseId} 或 /deny ${event.toolUseId};/defer ${event.toolUseId} 保存后再决定`)
|
|
27
|
+
else if (event.type === 'suspended') line(`[权限已保存] ${event.toolUseId};可退出并用 --resume 恢复后批准/拒绝`)
|
|
28
|
+
else if (event.type === 'independent_check') line(`[独立检查] 数值/结构=${event.verification.mechanical},内容审阅=${event.verification.semantic}\n${JSON.stringify(event.verification.report.failures ?? event.verification.report.error)}`)
|
|
29
|
+
else if (event.type === 'artifact_available') line(`[交付] ${event.artifact.path} 可读取,${event.artifact.bytes} 字节;内容验证另行完成`)
|
|
30
|
+
else if (['diagnostic', 'turn_error', 'error'].includes(event.type)) line(`[诊断] ${event.message}`)
|
|
31
|
+
}
|
|
32
|
+
const client = new AssistantClient({ cwd: values.cwd, stateDirectory: values['state-dir'], model: values.model,
|
|
33
|
+
cliPath: values['cli-path'], resume: values.resume, extraArgs: values['extra-args'] ? JSON.parse(values['extra-args']) : undefined,
|
|
34
|
+
onDisplay: display })
|
|
35
|
+
const input = createInterface({ input: process.stdin, terminal: !!process.stdin.isTTY })
|
|
36
|
+
let exiting = false
|
|
37
|
+
async function exit() { if (exiting) return; exiting = true; input.close(); await client.close() }
|
|
38
|
+
input.on('line', text => {
|
|
39
|
+
const [command, ...args] = text.trim().split(/\s+/)
|
|
40
|
+
const rest = args.join(' ')
|
|
41
|
+
void (async () => {
|
|
42
|
+
if (command === '/exit') await exit()
|
|
43
|
+
else if (command === '/status') line(JSON.stringify(client.status(), null, 2))
|
|
44
|
+
else if (command === '/steer') client.prompt(rest, true)
|
|
45
|
+
else if (command === '/cancel') await client.cancel(rest || undefined)
|
|
46
|
+
else if (['/allow', '/deny', '/defer'].includes(command)) await client.decide(rest, command === '/defer' ? 'pending' : command.slice(1))
|
|
47
|
+
else if (command === '/artifact') client.artifact(rest)
|
|
48
|
+
else if (['/office', '/verify-office'].includes(command)) {
|
|
49
|
+
const paths = JSON.parse(rest)
|
|
50
|
+
if (!Array.isArray(paths) || paths.length !== 2 || paths.some(path => typeof path !== 'string')) throw new Error('Use ["INPUT_DIRECTORY","OUTPUT_DIRECTORY"]')
|
|
51
|
+
if (command === '/office') client.office(...paths)
|
|
52
|
+
else await client.verifyOffice(...paths)
|
|
53
|
+
}
|
|
54
|
+
else if (text.trim()) client.prompt(text)
|
|
55
|
+
})().catch(error => line(`[错误] ${String(error)}`))
|
|
56
|
+
})
|
|
57
|
+
input.on('close', () => { if (!exiting) void client.drain().finally(exit) })
|
|
58
|
+
process.once('SIGINT', () => { void client.cancel().catch(error => line(String(error))) })
|
|
59
|
+
process.once('SIGTERM', () => { void exit() })
|
|
60
|
+
line('输入任务,或 /status 查看执行、验证与交付状态。')
|
package/package.json
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@margay/ccl-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0-alpha.0",
|
|
4
4
|
"description": "Core runtime extracted from Claude Code (ccl) for Margay integration",
|
|
5
5
|
"license": "SEE LICENSE IN README.md",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
+
"ccl": "dist/cli.js",
|
|
8
9
|
"margay": "dist/cli.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"dist",
|
|
12
13
|
"README.md",
|
|
13
14
|
"QUICKSTART.md",
|
|
15
|
+
"docs/cli-installation.md",
|
|
14
16
|
"INTENT-GATEWAY-GUIDE.md",
|
|
15
17
|
"docs/index.md",
|
|
16
18
|
"docs/error-recovery-usage.md",
|
|
@@ -37,10 +39,15 @@
|
|
|
37
39
|
"docs/features/business-facts.md",
|
|
38
40
|
"docs/releases/1.4.5-rc.8.md",
|
|
39
41
|
"docs/features/copy-on-select.md",
|
|
40
|
-
"docs/releases/1.4.5-rc.9.md"
|
|
42
|
+
"docs/releases/1.4.5-rc.9.md",
|
|
43
|
+
"docs/releases/1.4.5-rc.10.md",
|
|
44
|
+
"docs/context-checkpoints-and-daemon.md",
|
|
45
|
+
"docs/releases/1.4.6-beta.0.md",
|
|
46
|
+
"examples/assistant",
|
|
47
|
+
"docs/releases/1.5.0-alpha.0.md"
|
|
41
48
|
],
|
|
42
49
|
"engines": {
|
|
43
|
-
"node": ">=
|
|
50
|
+
"node": ">=22.19.0"
|
|
44
51
|
},
|
|
45
52
|
"scripts": {
|
|
46
53
|
"typecheck": "tsc --noEmit",
|
|
@@ -128,7 +135,7 @@
|
|
|
128
135
|
"tree-kill": "^1.2.2",
|
|
129
136
|
"turndown": "^7.2.4",
|
|
130
137
|
"type-fest": "^5.5.0",
|
|
131
|
-
"undici": "
|
|
138
|
+
"undici": "8.0.2",
|
|
132
139
|
"usehooks-ts": "^3.1.1",
|
|
133
140
|
"vscode-jsonrpc": "^8.2.1",
|
|
134
141
|
"vscode-languageserver-protocol": "^3.17.5",
|
package/sdk/API.md
CHANGED
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
} from '@margay/ccl-core/sdk'
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
- **运行时**:Node ≥
|
|
20
|
+
- **运行时**:Node ≥ 22.19.0(ESM),依赖随 `@margay/ccl-core` 安装。
|
|
21
21
|
- **CLI 定位**:SDK 以受管子进程方式运行 CCL CLI(全管道 stdio,无 TTY)。CLI 路径解析顺序:
|
|
22
22
|
1. `options.cliPath`
|
|
23
23
|
2. 环境变量 `CCL_SDK_CLI_PATH`
|
|
@@ -29,10 +29,16 @@ import {
|
|
|
29
29
|
|
|
30
30
|
## 2. 核心入口
|
|
31
31
|
|
|
32
|
-
### 2.1 `query(prompt, options?) →
|
|
32
|
+
### 2.1 `query({ prompt, options? }) → Query`
|
|
33
33
|
|
|
34
34
|
发起一轮对话(一次 agentic 任务),异步迭代产出事件,直到终态事件(`result` / `suspended` / fatal `error`)后结束。
|
|
35
35
|
|
|
36
|
+
上面的单轮语义适用于字符串输入;旧的双参数形式仍兼容。`prompt` 也可传
|
|
37
|
+
`AsyncIterable<SDKUserMessage>`:每条输入完成后再消费下一条,`result.turnId`
|
|
38
|
+
关联输入的 `uuid`(未提供时自动生成)。输入结束、生成器错误、abort 或 close
|
|
39
|
+
都会回收流式 query 的 CLI 进程。必须持续消费事件;流式输出积压超过 1024 项
|
|
40
|
+
会明确报错并关闭,避免无限占用内存。
|
|
41
|
+
|
|
36
42
|
```ts
|
|
37
43
|
for await (const event of query('检查收件箱并起草回复', {
|
|
38
44
|
cwd: '/srv/cos-workspace',
|
|
@@ -73,6 +79,36 @@ for await (const event of resumeWithDecision(sessionId, { behavior: 'allow' }))
|
|
|
73
79
|
|
|
74
80
|
`query(newPrompt, { resume: sessionId })` —— 在已有会话上继续新一轮对话(非审批场景的普通 resume)。
|
|
75
81
|
|
|
82
|
+
### 2.5 `createSession(options?) → Session`
|
|
83
|
+
|
|
84
|
+
在同一个 CLI 进程中连续交互。`prompt(content)`、`followUp(content)` 返回
|
|
85
|
+
`Turn { id, result, cancel() }`,按顺序执行;`steer(content)` 中断当前轮,将纠偏
|
|
86
|
+
放到等待队列前面。已产生的工具副作用不会回滚。
|
|
87
|
+
|
|
88
|
+
`maxPendingTurns` 默认 32,包含正在执行的轮次,队满时同步拒绝新输入。
|
|
89
|
+
`onEvent(event)` 接收即时事件,Session 不保存事件历史。`controls` 提供原有
|
|
90
|
+
Query 控制方法,`pid` 用于宿主监控。
|
|
91
|
+
|
|
92
|
+
`onLifecycle(event: SessionLifecycleEvent)` 单独观察宿主状态,不增加模型调用。
|
|
93
|
+
所有通知含 `at`(毫秒)、`pendingTurns`,可用时附 `pid`/`sessionId`:
|
|
94
|
+
|
|
95
|
+
- `turn_queued`:接受入队;`turn_started`:输入已写入 CLI,尚不代表模型已响应。
|
|
96
|
+
- `turn_cancel_requested`:已请求中断;已执行操作仍可能有副作用。
|
|
97
|
+
- `turn_finished`:`succeeded`/`failed`/`cancelled`/`interrupted`/`suspended`;其中 `cancelled` 为尚未发送的队列项。
|
|
98
|
+
- `permission_waiting`/`permission_resolved`:请求 ID、工具 ID/名称或决定。
|
|
99
|
+
- `session_closed`:执行通道关闭及原因。
|
|
100
|
+
|
|
101
|
+
回调异常不终止执行;耗时工作应由宿主另行排队。执行成功不等于产物验证通过。
|
|
102
|
+
通过 `Session.interrupt()` 或 `Turn.cancel()` 请求取消,才能观察宿主取消通知;
|
|
103
|
+
`controls` 是底层控制通道,直接调用其 interrupt 不经过宿主队列状态。
|
|
104
|
+
`SDKEvent.origin` 的 `user`/`background` 与 `turnId` 用于区分用户输入和自主工作;
|
|
105
|
+
旧 CLI 没有显式来源时保持兼容归属,不能据此证明后台来源。
|
|
106
|
+
参考终端、状态恢复与权限操作见 [assistant 示例](../examples/assistant/README.md)。
|
|
107
|
+
|
|
108
|
+
`endInput()` 停止接受新输入并等待已接受轮次,但保留进程和控制通道;`close()`
|
|
109
|
+
终止进程并拒绝未完成轮次。权限回调可等待宿主决定,interrupt/close 会取消
|
|
110
|
+
等待;返回 `pending` 仍表示持久挂起,后续用 `resumeWithDecision` 恢复。
|
|
111
|
+
|
|
76
112
|
---
|
|
77
113
|
|
|
78
114
|
## 3. 事件参考(SDKEvent)
|
|
@@ -92,7 +128,9 @@ for await (const event of resumeWithDecision(sessionId, { behavior: 'allow' }))
|
|
|
92
128
|
| `error_diagnostic` | `diagnostic: RecoveryDiagnostic`, `sessionId?` | 可选过程事件,仅 `includeErrorDiagnostics:true` 时输出;不是终态 |
|
|
93
129
|
| `raw` | `message` | 透传子进程原始消息(仅 `includeRaw:true` 时产出,调试用) |
|
|
94
130
|
|
|
95
|
-
|
|
131
|
+
**终态约定**:字符串 query 的 `result` 结束该次输入。流式 query / Session 的
|
|
132
|
+
`result` 只结束对应 `turnId`,后续仍可能有输入。`suspended`、fatal `error`、
|
|
133
|
+
关闭或输入 EOF 结束流式 query;不要把首个 result 当作常驻 Session 已关闭。
|
|
96
134
|
|
|
97
135
|
### 恢复结果与用户介入
|
|
98
136
|
|
|
@@ -390,3 +428,13 @@ query('用 filesystem server 列目录', {
|
|
|
390
428
|
|
|
391
429
|
新增事件:`{ type:'stream_event', event, sessionId? }`;result 事件新增 `structuredOutput?`。
|
|
392
430
|
新增 hooks:`onSessionStart` / `onUserPromptSubmit`;`onTurnEnd` 事件新增 `usage?`。
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
### Optional gateway request progress (source candidate)
|
|
434
|
+
|
|
435
|
+
`QueryOptions.includeRequestProgress?: boolean` defaults to false. When enabled,
|
|
436
|
+
`SDKEvent` additionally includes `{ type: 'request_progress', progress }`, where
|
|
437
|
+
`progress` is the exported `GatewayRequestProgress` type. See the
|
|
438
|
+
[request status guide](./README.md#gateway-request-status-source-candidate) for
|
|
439
|
+
phases, bounded delivery and host lifecycle rules. This nonterminal event must
|
|
440
|
+
not be used as an assistant message, a successful result, or a retry instruction.
|
package/sdk/README.md
CHANGED
|
@@ -66,6 +66,43 @@ process events. Those events do not replace or add a terminal result.
|
|
|
66
66
|
See the [API contract](./API.md) and [recovery guide](../docs/error-recovery-usage.md)
|
|
67
67
|
for consent, external blockers, and continuation rules.
|
|
68
68
|
|
|
69
|
+
## Gateway request status (source candidate)
|
|
70
|
+
|
|
71
|
+
Set `includeRequestProgress: true` to receive nonterminal `request_progress`
|
|
72
|
+
events. This is off by default, independent of raw/partial/error diagnostic
|
|
73
|
+
options. It does not change `result`, cancellation, or `onSessionEnd`.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
for await (const event of query('Summarize this project.', {
|
|
77
|
+
includeRequestProgress: true,
|
|
78
|
+
})) {
|
|
79
|
+
if (event.type === 'request_progress') {
|
|
80
|
+
const { requestId, phase, attempt, elapsedMs, idleMs, slow } = event.progress
|
|
81
|
+
// Update a transient status indicator, keyed by requestId.
|
|
82
|
+
}
|
|
83
|
+
if (event.type === 'result') {
|
|
84
|
+
// End the turn using the existing success/error contract.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Phases are `preparing`, `connecting`, `sending`, `waiting_response`,
|
|
90
|
+
`response_headers`, `retry_wait`, `streaming`, and `finished`. `connecting`
|
|
91
|
+
includes pool waiting, DNS, TCP, TLS and proxy negotiation; it does not identify
|
|
92
|
+
which of those substeps is slow. `idleMs` measures time without effective model
|
|
93
|
+
progress. Events contain no prompt, headers, credentials or endpoint URL.
|
|
94
|
+
|
|
95
|
+
Events are finite (at most 32 per model request), not periodic heartbeats.
|
|
96
|
+
A slow notice never counts as model progress or grants permission to retry.
|
|
97
|
+
`finished` ends a request, not necessarily the turn; clear status on the existing
|
|
98
|
+
result/abort/disconnect path as well. Hosts should keep their own deadlines
|
|
99
|
+
and avoid replaying tools when the remote completion state is unknown.
|
|
100
|
+
|
|
101
|
+
CLI users can opt in with `--print --output-format stream-json
|
|
102
|
+
--include-request-progress`; each wire event is `system/request_progress`.
|
|
103
|
+
The flag is invalid with text or JSON output. See
|
|
104
|
+
[request deadlines](../docs/gateway-quickstart.md#request-deadlines-and-slow-waits).
|
|
105
|
+
|
|
69
106
|
## Architecture
|
|
70
107
|
|
|
71
108
|
The SDK is the **host side** of the stream-json control protocol that
|
|
@@ -146,3 +183,67 @@ provider mapping, skills-dir wrapping, hooks, usage accumulation, file-store
|
|
|
146
183
|
round-trip. The issue's live end-to-end DoD slice (real model + mock outbound
|
|
147
184
|
endpoint + real process exit) is CoS-side per the issue's division of labor —
|
|
148
185
|
the SDK-side seams it needs are all covered above.
|
|
186
|
+
# Minimal mode and explicit tools
|
|
187
|
+
|
|
188
|
+
When passing `--bare` through `extraArgs`, the built-in tool set is Bash, Read,
|
|
189
|
+
and Edit (REPL/coordinator modes have their own subset). `--tools default` uses
|
|
190
|
+
that mode's available set; an explicit incompatible selection such as
|
|
191
|
+
`--bare --tools Write` fails with a diagnostic before a model request. Remove
|
|
192
|
+
`--bare` and unset `CLAUDE_CODE_SIMPLE` to request Write. Allow/deny permissions
|
|
193
|
+
still apply; `--allowedTools` does not add tools to the available set.
|
|
194
|
+
|
|
195
|
+
Use `extraArgs: ['--thinking', 'disabled']` to explicitly disable thinking on
|
|
196
|
+
Anthropic Messages compatible routes. Omitting the option retains the route's
|
|
197
|
+
default. The selection persists across requests; a query handle's
|
|
198
|
+
`setMaxThinkingTokens(0)` explicitly disables it, and `null` restores the default.
|
|
199
|
+
# Persistent sessions and streamed input
|
|
200
|
+
|
|
201
|
+
`query({ prompt: asyncIterable, options })` accepts `SDKUserMessage` values. It
|
|
202
|
+
submits one message at a time, waits for its result before pulling the next,
|
|
203
|
+
and adds the input UUID as `result.turnId` (generating a UUID if omitted).
|
|
204
|
+
Input EOF closes a streamed query. Invalid messages or producer failures reject
|
|
205
|
+
the event iterator. Consume events as they arrive: an unread streamed query fails
|
|
206
|
+
explicitly at 1024 buffered events instead of retaining unlimited output.
|
|
207
|
+
|
|
208
|
+
Use `createSession` when the host owns a longer conversation:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
import { createSession } from '@margay/ccl-core/sdk'
|
|
212
|
+
|
|
213
|
+
const session = createSession({ cwd: projectDir, maxPendingTurns: 32 })
|
|
214
|
+
try {
|
|
215
|
+
const first = session.prompt('Inspect the project and describe the failing test.')
|
|
216
|
+
console.log(await first.result)
|
|
217
|
+
const next = session.followUp('Fix that failure and run the relevant test.')
|
|
218
|
+
console.log(await next.result)
|
|
219
|
+
await session.endInput()
|
|
220
|
+
// The same child and controls remain alive until close().
|
|
221
|
+
console.log(session.pid, await session.controls.initializationResult())
|
|
222
|
+
} finally {
|
|
223
|
+
await session.close()
|
|
224
|
+
}
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`prompt` and `followUp` enqueue FIFO; exceeding `maxPendingTurns` throws before
|
|
228
|
+
acceptance. Each `Turn` has a stable `id`, a `result` promise and `cancel()`.
|
|
229
|
+
Cancelling a queued turn rejects its promise. Cancelling an active turn interrupts
|
|
230
|
+
the CLI and returns its interrupted result; already executed tool effects remain.
|
|
231
|
+
`steer(text)` interrupts the active turn and puts the correction ahead of waiting
|
|
232
|
+
follow-ups. It does not undo earlier effects. `endInput()` rejects new inputs and
|
|
233
|
+
waits for accepted turns while preserving controls; `close()` terminates the child
|
|
234
|
+
and rejects unfinished turns. `onEvent` observes events without retaining history.
|
|
235
|
+
|
|
236
|
+
Permission callbacks can wait for a host decision while the process stays alive.
|
|
237
|
+
Interrupt and close cancel that wait. Returning `{ behavior: 'pending' }` retains
|
|
238
|
+
the existing durable suspend contract: it ends this process and requires
|
|
239
|
+
`resumeWithDecision(sessionId, decision)`; it does not imply automatic approval.
|
|
240
|
+
Uncooperative application iterators/callbacks cannot be forcibly stopped, but do
|
|
241
|
+
not prevent CLI cleanup. One-shot string queries and post-result `rewindFiles`
|
|
242
|
+
retain their existing behavior.
|
|
243
|
+
|
|
244
|
+
`onLifecycle` observes queue acceptance, actual input dispatch, cancellation requests,
|
|
245
|
+
permission waits, terminal turn outcomes and session closure without another model
|
|
246
|
+
call. `SessionLifecycleEvent` is exported alongside `SDKEvent`. Use `turnId` and
|
|
247
|
+
`origin` to keep background work separate from a waiting user input. Execution
|
|
248
|
+
outcomes do not certify artifacts. See the [runnable terminal host](../examples/assistant/README.md)
|
|
249
|
+
for persistence, explicit permission resumption and no-replay recovery.
|