niuse 0.1.12

A personal engineering companion built on niucode
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
//! Turn a finished thread into a few journal bullets.
//!
//! The digest is deliberately narrow: user and assistant prose only, one
//! tool-free model call, at most five bullets, no write on a parse or secret
//! failure. The in-band journal remains the default; this is the opt-in path
//! for a long thread.

use std::path::Path;

use anyhow::{bail, Result};
use niucode_core::{load, Event};
use niucode_provider::Provider;
use niuse_core::model::one_line;
use niuse_core::policy::secret_reason;
use niuse_core::{Date, Store};

use crate::bootstrap::{complete, truncate};

const MAX_MESSAGE_CHARS: usize = 2_000;
const MAX_TRANSCRIPT_CHARS: usize = 12_000;
const MAX_BULLETS: usize = 5;

const DIGEST_SYSTEM: &str =
    "你是会话日记压缩器。根据下面这段 coding agent 与用户的会话,写 1-5 条 journal 条目。\n\
先判断:这段会话是否出现了一个决定、转折点、新出现的未解决问题、被说服、\n\
或判断/情绪的变化。如果只是完成例行任务、没有改变任何方向,只输出 NONE。\n\
不要写任务完成情况(“创建了/修复了/确认了 X”),也不要写工具调用过程;\n\
活动不是 journal。\n\
每条都要脱离这段会话也能看懂,一句话,具体;不要复制代码或文件内容,\n\
不要重复偏好,不要写密钥或别人的信息。\n\
没有值得记录的,就只输出 NONE。\n\
如果验证先失败后通过,记录失败的原因和修复,而不是只写最终通过。\n\
只输出以 \"- \" 开头的条目,不要解释,不要代码围栏。";

const DIGEST_GATE: &str =
    "判断下面这段 coding agent 与用户的会话,是否包含一个**新出现**的决定、\n\
转折点、新出现的未解决问题、被说服、或判断/情绪的变化。\n\
只算本会话中新出现的、或在本会话中被推进/改变的内容。\n\
如果只是任务完成、工具调用、确认结果,或只是重复/提醒会话开始前就存在的旧事项\n\
(例如一个早就记下的到期 loop),判断为 NO。\n\
如果只是复述/披露已经存在的记忆、偏好或事实,不是新决定或新问题,也判断为 NO。\n\
复述记忆不算新信息:记忆在会话开始前就存在;只有它被修改、被质疑、\n\
或被用于做出新决定,才算变化。仅复述,回答 NO。\n\
如果出现一次验证失败,随后被修复并通过,这也是本会话中的转折点,判断为 YES。\n\
只输出一行:`YES: 一句话理由` 或 `NO: 一句话理由`。不要输出其他内容。";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Gate {
    Yes,
    No,
}

fn parse_gate(raw: &str) -> Option<(Gate, String)> {
    let line = raw.trim().lines().next()?.trim();
    let (verdict, reason) = match line.split_once(':') {
        Some((verdict, reason)) => (verdict.trim(), reason.trim()),
        None => (line, ""),
    };
    let gate = match verdict.to_ascii_uppercase().as_str() {
        "YES" => Gate::Yes,
        "NO" => Gate::No,
        _ => return None,
    };
    Some((
        gate,
        if reason.is_empty() {
            "模型未给出理由".to_string()
        } else {
            reason.to_string()
        },
    ))
}

#[derive(Debug, Clone, Copy)]
pub struct DigestOptions {
    pub dry_run: bool,
    pub limit: usize,
}

pub async fn run(
    store: &Store,
    provider: &dyn Provider,
    model: &str,
    thread: &Path,
    project: &str,
    options: DigestOptions,
) -> Result<()> {
    let Some(transcript) = thread_transcript(thread, options.limit)? else {
        bail!("会话没有可压缩的用户或助手文本:{}", thread.display());
    };
    let input = format!("# 项目:{project}\n\n{transcript}");
    let gate = complete(provider, model, DIGEST_GATE, &input, 80).await?;
    let (verdict, reason) = parse_gate(&gate).ok_or_else(|| {
        anyhow::anyhow!("digest 预检没有返回 YES/NO({});没有写入", gate.trim())
    })?;
    match verdict {
        Gate::No => {
            if options.dry_run {
                println!("digest dry run:没有可提炼的变化({reason}),不写入");
            } else {
                println!("digest:没有可提炼的变化({reason}),journal 未变");
            }
            return Ok(());
        }
        Gate::Yes => {
            println!("digest gate:YES · {reason}");
        }
    }
    let raw = complete(provider, model, DIGEST_SYSTEM, &input, 800).await?;
    let bullets = parse_bullets(&raw);
    if bullets.is_empty() {
        if raw.trim().eq_ignore_ascii_case("NONE") {
            if options.dry_run {
                println!("digest dry run:没有可提炼的变化,不写入");
            } else {
                println!("digest:没有可提炼的变化,journal 未变");
            }
            return Ok(());
        }
        bail!("模型没有返回 journal 条目;没有写入");
    }
    for bullet in &bullets {
        if let Some(reason) = secret_reason(bullet) {
            bail!("拒绝写入 digest:条目疑似包含 {reason}");
        }
    }

    let source = thread
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("thread");
    if options.dry_run {
        println!(
            "digest dry run:{} 条(来源 {source}),不写入",
            bullets.len()
        );
    } else {
        store.append_journal(Date::today(), &bullets)?;
        store.append_metric("digest", &format!("{source}:{}", bullets.len()))?;
        println!("journal +{} 条(来源 {source})", bullets.len());
    }
    for bullet in &bullets {
        println!("  - {bullet}");
    }
    Ok(())
}

/// Use only the most recent messages, so a long thread does not digest its
/// opening instead of the work that just finished.
fn thread_transcript(path: &Path, limit: usize) -> Result<Option<String>> {
    let loaded = load(path)?;
    let messages: Vec<(&str, String)> = loaded
        .events
        .iter()
        .filter_map(|event| match event {
            Event::UserMessage { text, .. } if !text.trim().is_empty() => {
                Some(("user", text.clone()))
            }
            Event::AssistantMessage { text, .. } if !text.trim().is_empty() => {
                Some(("assistant", text.clone()))
            }
            Event::Verification {
                command, ok, step, ..
            } => Some((
                "verification",
                format!(
                    "{} {} {}",
                    step.as_deref().unwrap_or("-"),
                    if *ok { "ok" } else { "FAILED" },
                    command
                ),
            )),
            _ => None,
        })
        .collect();
    let start = messages.len().saturating_sub(limit.max(1));
    let mut out = String::new();
    let mut budget = MAX_TRANSCRIPT_CHARS;
    for (role, text) in &messages[start..] {
        let text = truncate(text, MAX_MESSAGE_CHARS);
        let line = format!("[{role}] {text}\n\n");
        if line.len() > budget {
            break;
        }
        budget -= line.len();
        out.push_str(&line);
    }
    Ok((!out.trim().is_empty()).then_some(out))
}

fn parse_bullets(raw: &str) -> Vec<String> {
    let mut bullets: Vec<String> = Vec::new();
    for line in raw.lines() {
        let line = line.trim();
        let Some(text) = line.strip_prefix("- ").or_else(|| line.strip_prefix("* ")) else {
            continue;
        };
        let text = one_line(text.to_string());
        if text.is_empty() || text.eq_ignore_ascii_case("none") || bullets.contains(&text) {
            continue;
        }
        bullets.push(text);
        if bullets.len() == MAX_BULLETS {
            break;
        }
    }
    bullets
}

#[cfg(test)]
mod tests {
    use super::*;
    use niucode_core::{now_ms, SessionLog};
    use niucode_provider::{ScriptedProvider, StreamEvent};
    use std::path::PathBuf;

    fn turn(text: &str) -> Vec<StreamEvent> {
        vec![
            StreamEvent::TextDelta(text.into()),
            StreamEvent::Done {
                finish_reason: Some("stop".into()),
            },
        ]
    }

    fn scripted(text: &str) -> ScriptedProvider {
        ScriptedProvider::from_turns(vec![turn("YES: decision"), turn(text)])
    }

    fn thread_with(messages: &[&str]) -> (tempfile::TempDir, PathBuf) {
        let directory = tempfile::tempdir().unwrap();
        let log = SessionLog::create(directory.path()).unwrap();
        for message in messages {
            log.append(&Event::UserMessage {
                text: (*message).into(),
                ts: now_ms(),
            })
            .unwrap();
            log.append(&Event::AssistantMessage {
                text: "ok".into(),
                reasoning: None,
                tool_calls: Vec::new(),
                ts: now_ms(),
            })
            .unwrap();
        }
        let path = log.path().to_path_buf();
        drop(log);
        (directory, path)
    }

    fn store(directory: &Path) -> Store {
        let store = Store::at(directory.join(".niuse"));
        store.init().unwrap();
        store
    }

    #[test]
    fn parses_gate_verdict_and_reason() {
        assert_eq!(
            parse_gate("YES: a decision"),
            Some((Gate::Yes, "a decision".into()))
        );
        assert_eq!(
            parse_gate("no: old reminder"),
            Some((Gate::No, "old reminder".into()))
        );
        assert_eq!(
            parse_gate("YES"),
            Some((Gate::Yes, "模型未给出理由".into()))
        );
        assert_eq!(parse_gate("MAYBE: x"), None);
        assert_eq!(parse_gate(""), None);
    }

    #[test]
    fn parses_bullets_dedupes_and_caps() {
        let raw =
            "Here:\n```\n- first\n* second\n- first\n- third\n- fourth\n- fifth\n- sixth\n```\n";
        assert_eq!(
            parse_bullets(raw),
            vec!["first", "second", "third", "fourth", "fifth"]
        );
        assert!(parse_bullets("NONE").is_empty());
        assert!(parse_bullets("just chatter").is_empty());
    }

    #[test]
    fn transcript_includes_verification_evidence() {
        let (directory, thread) = thread_with(&["run the check"]);
        let log = SessionLog::open(&thread).unwrap();
        log.append(&Event::Verification {
            command: "cargo test".into(),
            ok: false,
            output: "one failure".into(),
            step: Some("p-1".into()),
            ts: now_ms(),
        })
        .unwrap();
        let transcript = thread_transcript(&thread, 20).unwrap().unwrap();
        assert!(transcript.contains("[verification] p-1 FAILED cargo test"));
        drop(log);
        drop(directory);
    }

    #[test]
    fn transcript_prefers_recent_messages() {
        let (directory, thread) = thread_with(&[
            "message 0",
            "message 1",
            "message 2",
            "message 3",
            "message 4",
        ]);
        let transcript = thread_transcript(&thread, 4).unwrap().unwrap();
        assert!(!transcript.contains("message 0"));
        assert!(!transcript.contains("message 2"));
        assert!(transcript.contains("message 3"));
        assert!(transcript.contains("message 4"));
        drop(directory);
    }

    #[tokio::test]
    async fn writes_at_most_five_bullets() {
        let (directory, thread) = thread_with(&["finish the digest"]);
        let store = store(directory.path());
        let provider = scripted(
            "- decision one\n- decision two\n- decision three\n- decision four\n- decision five\n- decision six",
        );
        run(
            &store,
            &provider,
            "scripted",
            &thread,
            "niuse",
            DigestOptions {
                dry_run: false,
                limit: 20,
            },
        )
        .await
        .unwrap();

        let days = store.recent_journal(1).unwrap();
        assert_eq!(days.len(), 1);
        assert_eq!(days[0].bullets.len(), 5);
        assert_eq!(days[0].bullets[0], "decision one");
    }

    #[tokio::test]
    async fn dry_run_does_not_write() {
        let (directory, thread) = thread_with(&["finish the digest"]);
        let store = store(directory.path());
        let provider = scripted("- a decision");
        run(
            &store,
            &provider,
            "scripted",
            &thread,
            "niuse",
            DigestOptions {
                dry_run: true,
                limit: 20,
            },
        )
        .await
        .unwrap();
        assert!(store.recent_journal(10).unwrap().is_empty());
    }

    #[tokio::test]
    async fn refuses_a_secret_without_writing() {
        let (directory, thread) = thread_with(&["use the key"]);
        let store = store(directory.path());
        let provider = scripted("- leaked key sk-abcdefghijklmnopqrstuvwx");
        let error = run(
            &store,
            &provider,
            "scripted",
            &thread,
            "niuse",
            DigestOptions {
                dry_run: false,
                limit: 20,
            },
        )
        .await
        .unwrap_err();
        assert!(error.to_string().contains("拒绝"));
        assert!(store.recent_journal(10).unwrap().is_empty());
    }

    #[tokio::test]
    async fn none_is_a_no_change_and_writes_nothing() {
        let (directory, thread) = thread_with(&["nothing durable"]);
        let store = store(directory.path());
        let provider = scripted("NONE");
        run(
            &store,
            &provider,
            "scripted",
            &thread,
            "niuse",
            DigestOptions {
                dry_run: false,
                limit: 20,
            },
        )
        .await
        .unwrap();
        assert!(store.recent_journal(10).unwrap().is_empty());
        assert!(!store.metrics().unwrap().contains("digest"));
    }

    #[tokio::test]
    async fn gate_no_skips_the_digest_call() {
        let (directory, thread) = thread_with(&["routine task"]);
        let store = store(directory.path());
        let provider = ScriptedProvider::from_turns(vec![turn("NO: no durable change")]);
        run(
            &store,
            &provider,
            "scripted",
            &thread,
            "niuse",
            DigestOptions {
                dry_run: false,
                limit: 20,
            },
        )
        .await
        .unwrap();
        assert!(store.recent_journal(10).unwrap().is_empty());
        assert!(!store.metrics().unwrap().contains("digest"));
    }

    #[tokio::test]
    async fn malformed_answer_is_still_an_error() {
        let (directory, thread) = thread_with(&["nothing durable"]);
        let store = store(directory.path());
        let provider = scripted("just some prose, no bullets");
        let error = run(
            &store,
            &provider,
            "scripted",
            &thread,
            "niuse",
            DigestOptions {
                dry_run: false,
                limit: 20,
            },
        )
        .await
        .unwrap_err();
        assert!(error.to_string().contains("没有返回"));
        assert!(store.recent_journal(10).unwrap().is_empty());
    }
}