工作日志:一行摆首句,展开就把话说完

从前一条只有一行标题,而人真想回看的东西全躺在留痕里没露过面 ——
凭什么算这一步办完了、那一笔落下哪几行、被拒到底拒在哪儿、发的是哪种卡送到哪儿。
现在每条可展开,细节由 detailOf 摘成**一句句人话**(不摆 execution 这类内部名);
摘不出就是空数组,前端据此不给展开钮 —— 点开是空抽屉比不给还糟。

不摆二级条目(用户定):细节不是「键:值」两栏 —— 那是把留痕的字段名搬到人面前,
人还得自己在脑子里连成句子。每句自带主语与语境,单拎出来也读得通。

连带修一处文档与实现不符:「一轮一句」的口径说埋头办事那几拍的交代「留进工作日志」,
而实现把 internal 整类滤掉了,那几句哪儿都看不到。现在带交代的放行,
章写「交代进展」、记在 AI 名下;排障用的内部痕照旧不摆。
两种交代统一「首句上行、全文进抽屉」,一句话说完的不重复摆;截了才加省略号。

契约:任务详情的日志行新增可选 detail(字符串数组),api-report 同批更新。
门禁:typecheck / lint / depcruise / test(1378)全绿;浏览器实测折叠行高 34-36px 齐平、
展开无键值两栏残留。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 13:10:46 +08:00
co-authored by Claude Opus 5
parent ade0fa5a94
commit 70e83d0b09
8 changed files with 352 additions and 16 deletions
@@ -41,6 +41,20 @@ export type LogLine = {
/** 内容本身,**不含主语** —— 主语由上层拼在前头(名字要查库才知道) */
readonly title: string;
readonly summary: string | undefined;
/**
* **展开才看的细节 —— 一段话说完,不摆二级条目**(2026-08-03 用户两次定调:
* 先是「细节展开不够好」,再是「不要写二级条目,直接正文描述完」)。
*
* 从前这一屏一条只有一行标题:凭什么算这一步办完了、那一笔写了哪几行、
* 行动被拒到底拒在哪儿 —— 全躺在留痕的 `detail` 里,一个字都没露面。
* 中间试过摘成「键:值」两栏,那是**把留痕的字段名搬到人面前**,
* 人还得自己在脑子里把它们连成句子。
*
* 所以这里出的是**一句句人话**:每条自带主语与语境,单独拎出来也读得通
* (与 `title` 同一条纪律:不摆系统内的英文名、认不出的宁可不摆)。
* 说得完就一句,真有两三样要说的就两三句 —— 摘不出就是空数组。
*/
readonly detail: readonly string[];
readonly eventRef: string | undefined;
readonly position: string | undefined;
};
@@ -88,7 +102,9 @@ const SKELETON: ReadonlySet<EntryKind> = new Set<EntryKind>([
* 主语也不在这儿拼:名字要查库,域里查不了(纯函数零 IO)。
*/
const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
observation: (e) => e.title,
// 带交代的内部痕:标题摆交代的**首句**(原标题是「这一拍在往下办事…」那句机器话;
// 全文归抽屉 —— 一行摆全文会把这一屏撑成散文集,而这一屏的用法是扫)
observation: (e) => (typeof e.detail?.['say'] === 'string' ? firstSentence(e.detail['say']) : e.title),
thought: (e) => e.title,
action: (e) => actionEntryOf(e).body,
result: (e) => e.title,
@@ -101,7 +117,8 @@ const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
'release-verdict': (e) => plain(e.title),
terminal: (e) => plain(e.title),
'human-input': (e) => e.title,
narration: (e) => e.title,
// 交代一律「首句上行、全文进抽屉」—— 进没进对话区都一样,这一屏的用法是扫
narration: (e) => firstSentence(e.title),
control: (e) => e.title,
trigger: (e) => e.title,
};
@@ -210,7 +227,8 @@ function actionOf(e: MemoryEntry): string {
case 'trigger':
return '触发事件';
case 'observation':
return '系统事件';
// 带交代的内部痕 = AI 这一拍的交代(没进对话区),章与对话区同词
return typeof d['say'] === 'string' ? '交代进展' : '系统事件';
case 'engine-transition':
return transitionOf(e.title).action;
case 'action':
@@ -254,8 +272,12 @@ function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined;
const by = e.detail?.['by'];
const id = typeof by === 'string' && by !== '' ? by : undefined;
switch (e.kind) {
case 'trigger':
case 'observation':
// 带交代的那种是 **AI 说的话**,记在它名下;其余系统事件照旧署系统
return typeof e.detail?.['say'] === 'string'
? { actor: '任务助手', actorId: undefined, who: 'agent' as const }
: { actor: '系统', actorId: undefined, who: 'system' as const };
case 'trigger':
case 'terminal':
return { actor: '系统', actorId: undefined, who: 'system' };
/*
@@ -317,7 +339,17 @@ function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined;
const RESTATED: ReadonlyArray<RegExp> = [/^落地收敛[::]\s*合并/, /^落地收敛,发射输出口/];
function machinery(e: MemoryEntry): boolean {
if (e.detail?.['internal'] === true) return true;
/*
* **被压下的每拍交代要留在工作日志里**(2026-08-03 用户:「细节展开不够好」)。
*
* 对话区一轮一句:只有交出控制权那一拍的交代摆到人面前,埋头办事那几拍的
* 「留进工作日志」—— 这是 2026-08-01 立的口径,快照里也是这么写的。
* 可实现是 `internal === true` 一律滤掉:那几句**哪儿都看不到**,
* 白烧的 token 不说,人想回看「这一拍它在干什么」时一片空白。
* 现在带 `say` 的放行(它是人话,正是要留的那样东西);
* 其余内部痕(重问原文这类给排障用的)照旧不摆。
*/
if (e.detail?.['internal'] === true && typeof e.detail?.['say'] !== 'string') return true;
if (RESTATED.some((re) => re.test(e.title))) return true;
if (e.kind === 'thought') return true;
if (e.kind === 'engine-transition' && e.title.startsWith('装载案例模型')) return true;
@@ -331,6 +363,130 @@ function machinery(e: MemoryEntry): boolean {
return false;
}
/**
* 首句 —— 交代常有三四句,一行只摆得下第一句(其余进抽屉)。
* 断在句号/分号/换行,断不出就按字数截;**截了才加省略号**,没截不加
* (加了等于告诉人「后面还有」,而后面没有)。
*/
export function firstSentence(text: string, cap = 42): string {
const clean = text.trim().replace(/\s+/g, ' ');
const cut = clean.search(/[。;;\n!!??]/);
const head = cut > 0 && cut + 1 <= cap ? clean.slice(0, cut + 1) : clean.slice(0, cap);
return head.length < clean.length ? `${head}…` : head;
}
/** 卡的种类:发的是哪一种人类工具 —— 摆人读名,不摆 `execution` 这类内部词 */
const CARD_KIND_LABEL: Readonly<Record<string, string>> = {
execution: '执行工单',
acceptance: '验收工单',
decision: '决策卡',
entry: '数据录入卡',
rating: '评价卡',
};
/** 送到哪儿了 —— 同上,内部路由名换人话 */
const ARRIVAL_LABEL: Readonly<Record<string, string>> = {
'task-chat': '这一单的对话区',
inbox: '收件箱',
'cross-task': '跨任务征答',
};
/** 值摆到日志上的样子:太长截断(细节是给人扫一眼的,不是把留痕抄一遍) */
function short(value: unknown, cap = 300): string {
const text =
typeof value === 'string'
? value
: typeof value === 'number' || typeof value === 'boolean'
? String(value)
: JSON.stringify(value);
const clean = (text ?? '').trim();
return clean.length > cap ? `${clean.slice(0, cap)}…` : clean;
}
/** 主键名单摘成一句:`PN=A、PN=B` —— 「写了哪几行」是人回看时最想知道的一件事 */
function keyList(keys: unknown): string | undefined {
if (!Array.isArray(keys) || keys.length === 0) return undefined;
const rows = keys.slice(0, 8).map((one) =>
typeof one === 'object' && one !== null
? Object.values(one as Record<string, unknown>)
.map((v) => String(v))
.join(' / ')
: String(one),
);
return keys.length > 8 ? `${rows.join('、')}…(共 ${keys.length} 行)` : rows.join('、');
}
/**
* **展开看的那几句** —— 按 kind 各摘各的,认不出的一概不摆。
*
* 出的是**人话**不是字段:每句自带主语与语境(「发的是执行工单,送到这一单的对话区」),
* 单拎出来也读得通。纪律三条:①不摆系统内的英文名(卡种/送达位置都换人话)
* ②不把留痕整段抄上去(长值截断)③摘不出就返回空数组 ——
* 前端据此不给展开钮,**没有细节就别摆一个空抽屉**。
*/
export function detailOf(e: MemoryEntry): readonly string[] {
const d = e.detail ?? {};
const out: string[] = [];
const say = (text: string | undefined): void => {
if (text !== undefined && text.trim() !== '') out.push(text.trim());
};
const has = (v: unknown): v is string | number | boolean =>
v !== undefined && v !== null && v !== '' && (typeof v === 'string' ? v.trim() !== '' : true);
switch (e.kind) {
case 'engine-transition':
// 「凭什么算办完了」—— 收步那一下的理由,人回看时最想核的就是它
if (has(d['rationale'])) say(`算办完的依据:${short(d['rationale'], 400)}`);
break;
case 'result':
// 被拒/失败:理由与下一步(拒回的话本来就是写给下一拍的)
if (has(d['reason'] ?? d['error'])) say(`没通过的缘由:${short(d['reason'] ?? d['error'], 500)}`);
if (has(d['hint'])) say(`接下来该:${short(d['hint'], 300)}`);
break;
case 'card-sent': {
const kind = CARD_KIND_LABEL[String(d['mode'] ?? d['k'] ?? '')];
const arrival = ARRIVAL_LABEL[String(d['arrival'] ?? '')];
if (kind !== undefined) say(arrival === undefined ? `发的是${kind}。` : `发的是${kind},送到${arrival}。`);
break;
}
case 'card-reply':
if (has(d['summary'])) say(`他填的是:${short(d['summary'], 400)}`);
if (has(d['value'] ?? d['chosen'])) say(`他选的是:${short(d['value'] ?? d['chosen'], 200)}`);
break;
case 'ledger-write': {
if (has(d['from'])) say(`把${short(d['label'] ?? '这一格', 40)}从「${short(d['from'], 80)}」改成「${short(d['to'], 80)}」。`);
const rows = keyList(d['keys']);
if (rows !== undefined) say(`落下这几行:${rows}`);
else if (has(d['rows'])) say(`落下 ${short(d['rows'])} 行。`);
break;
}
case 'release-verdict': {
const verdict = d['verdict'];
if (verdict === 'pass') say('裁决:通过放行。');
else if (verdict === 'redo') say('裁决:打回重做。');
if (has(d['comment'])) say(`他的意见:${short(d['comment'], 300)}`);
break;
}
case 'narration': {
// 首句已经上行了,这儿摆全文;一句话说完的不重复摆(点开发现一模一样等于白点)
const full = e.title.trim().replace(/\s+/g, ' ');
if (firstSentence(e.title) !== full) say(short(e.title, 600));
say(d['internal'] === true ? '这句只留在工作日志,没进对话区 —— 那一拍没有交出控制权。' : '这句已进对话区。');
break;
}
case 'observation': {
// 被压下的每拍交代:全文在这儿(标题只摆得下第一句)
const text = d['say'];
if (typeof text === 'string' && firstSentence(text) !== text.trim().replace(/\s+/g, ' ')) say(short(text, 600));
if (has(d['raw'])) say(`留档原文:${short(d['raw'], 400)}`);
break;
}
default:
break;
}
return out;
}
export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] {
return entries
.filter((e) => SKELETON.has(e.kind) && !machinery(e))
@@ -343,6 +499,7 @@ export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] {
...actorOf(e),
title: TEMPLATE[e.kind](e),
summary: typeof e.detail?.['summary'] === 'string' ? e.detail['summary'] : undefined,
detail: detailOf(e),
eventRef: e.eventRef,
position: e.stamp.position,
}));