portable-agent-layer 0.81.0 → 0.82.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/assets/templates/PAL/MEMORY_SYSTEM.md +1 -1
- package/package.json +1 -1
- package/src/cli/builtin-tools.ts +1 -0
- package/src/hooks/lib/interaction-reaction.ts +26 -27
- package/src/hooks/lib/interaction.ts +24 -13
- package/src/tools/agent/interaction.ts +45 -0
- package/src/tools/lib/interaction-report.ts +144 -0
|
@@ -55,7 +55,7 @@ Almost everything in `state/`, `signals/`, `relationship/opinions.json`, `learni
|
|
|
55
55
|
| A reusable session insight | Session-intelligence handler (automatic) | `learning/session/YYYY-MM/*.md` |
|
|
56
56
|
| A failure worth learning from | Failure-principle handler (automatic, low ratings) | `learning/failures/YYYY-MM/*.md` |
|
|
57
57
|
| A rating signal | UserPromptOrchestrator rating capture | `signals/ratings.jsonl` |
|
|
58
|
-
| How a user turn went (pace, length, skimming, how it received the last reply) | Prompt-time interaction measurement (automatic) | `signals/interaction/YYYY-MM.jsonl` |
|
|
58
|
+
| How a user turn went (pace, length, skimming, how it received the last reply) | Prompt-time interaction measurement (automatic); read it with `pal cli interaction report` | `signals/interaction/YYYY-MM.jsonl` |
|
|
59
59
|
| A rolling sample of labelled messages, for auditing the reaction rules | Prompt-time interaction measurement (automatic, `reactionSampling`) | `state/reaction-samples.json` |
|
|
60
60
|
| A project handoff | Project skill / detached handlers | `projects/<slug>/` |
|
|
61
61
|
|
package/package.json
CHANGED
package/src/cli/builtin-tools.ts
CHANGED
|
@@ -11,6 +11,7 @@ const BUILTIN_TOOLS = {
|
|
|
11
11
|
"algorithm-synthesize": () => import("../tools/agent/algorithm-synthesize"),
|
|
12
12
|
analyze: () => import("../tools/agent/analyze"),
|
|
13
13
|
"handoff-note": () => import("../tools/agent/handoff-note"),
|
|
14
|
+
interaction: () => import("../tools/agent/interaction"),
|
|
14
15
|
project: () => import("../tools/agent/project"),
|
|
15
16
|
"relationship-note": () => import("../tools/agent/relationship-note"),
|
|
16
17
|
"relationship-reflect": () => import("../tools/agent/relationship-reflect"),
|
|
@@ -8,11 +8,6 @@ import { extractKeywords, similarity } from "./text-similarity";
|
|
|
8
8
|
|
|
9
9
|
export type Reaction = "corrected" | "repeated" | "approved" | "new-topic" | "follow-up";
|
|
10
10
|
|
|
11
|
-
export interface ReplyContext {
|
|
12
|
-
previousPrompt?: string;
|
|
13
|
-
replyKeywords: string[];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
11
|
const CORRECTION_RE =
|
|
17
12
|
/^(?:(?:no|nope|wrong)(?:[,.!]|$)|(?:that'?s|this is|it'?s) (?:wrong|not)\b|not what i\b|i said\b|i told you\b|you forgot\b|you missed\b|still (?:broken|failing|wrong)\b)/i;
|
|
18
13
|
|
|
@@ -20,15 +15,16 @@ const APPROVAL_RE =
|
|
|
20
15
|
/^(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|it works|thanks|thank you|thx|y+e+s+|yep|yeah|yup|sure|exactly|correct|agreed|lgtm|well done|love it|sounds good|looks good|that'?s it|(?:it|this|that) (?:is|'s) (?:fine|good|great|right))\b/i;
|
|
21
16
|
|
|
22
17
|
const GO_AHEAD_RE =
|
|
23
|
-
/^(?:go ahead|go|do it|proceed|continue|merge|push|ship|commit|build|approved|let'?s (?:see|do|go))\b/i;
|
|
18
|
+
/^(?:go ahead|go|do it|proceed|continue|merge|push|ship|commit|build|start|fix (?:all|them|both)|approved|let'?s (?:see|do|go))\b/i;
|
|
19
|
+
|
|
20
|
+
const NEW_TOPIC_RE =
|
|
21
|
+
/^(?:(?:ok(?:ay)?|btw|also),?\s+)?(?:new (?:\w+ ){0,2}(?:task|topic|question|thing)|(?:different|unrelated|separate|another) (?:task|topic|question|thing)|unrelated\b|switching (?:gears|topics?)|change of (?:topic|subject))/i;
|
|
24
22
|
|
|
25
23
|
const GO_AHEAD_MAX_WORDS = 6;
|
|
26
24
|
const BARE_OK_RE = /^(?:ok(?:ay)?|k)[,.!]?(?:\s+\S+){0,2}$/i;
|
|
27
25
|
const HEDGE_RE = /\b(?:but|however|though|except|instead)\b/i;
|
|
28
26
|
const REPEAT_SIMILARITY = 0.6;
|
|
29
27
|
const REPEAT_MIN_KEYWORDS = 5;
|
|
30
|
-
const NEW_TOPIC_MIN_KEYWORDS = 4;
|
|
31
|
-
const NEW_TOPIC_MAX_OVERLAP = 0.15;
|
|
32
28
|
|
|
33
29
|
export function isCorrection(text: string): boolean {
|
|
34
30
|
return CORRECTION_RE.test(text.trim());
|
|
@@ -39,33 +35,36 @@ export function isRepeat(text: string, previous: string | undefined): boolean {
|
|
|
39
35
|
return similarity(text, previous) >= REPEAT_SIMILARITY;
|
|
40
36
|
}
|
|
41
37
|
|
|
38
|
+
function isShortGoAhead(sentence: string): boolean {
|
|
39
|
+
return GO_AHEAD_RE.test(sentence) && sentence.split(/\s+/).length <= GO_AHEAD_MAX_WORDS;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function lastSentence(text: string): string {
|
|
43
|
+
return (
|
|
44
|
+
text
|
|
45
|
+
.split(/[.!\n]\s*/)
|
|
46
|
+
.filter(Boolean)
|
|
47
|
+
.at(-1)
|
|
48
|
+
?.trim() ?? ""
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
42
52
|
function isApproval(text: string): boolean {
|
|
43
53
|
const trimmed = text.trim();
|
|
44
|
-
if (HEDGE_RE.test(trimmed.split(/[.!?\n]/)[0])) return false;
|
|
54
|
+
if (trimmed.includes("?") || HEDGE_RE.test(trimmed.split(/[.!?\n]/)[0])) return false;
|
|
45
55
|
if (APPROVAL_RE.test(trimmed) || BARE_OK_RE.test(trimmed)) return true;
|
|
46
|
-
return
|
|
56
|
+
return isShortGoAhead(trimmed) || isShortGoAhead(lastSentence(trimmed));
|
|
47
57
|
}
|
|
48
58
|
|
|
49
|
-
/**
|
|
50
|
-
function isNewTopic(text: string
|
|
51
|
-
|
|
52
|
-
if (words.size < NEW_TOPIC_MIN_KEYWORDS) return false;
|
|
53
|
-
const exchange = new Set([
|
|
54
|
-
...context.replyKeywords,
|
|
55
|
-
...extractKeywords(context.previousPrompt ?? ""),
|
|
56
|
-
]);
|
|
57
|
-
const shared = [...words].filter((w) => exchange.has(w)).length;
|
|
58
|
-
return shared / words.size <= NEW_TOPIC_MAX_OVERLAP;
|
|
59
|
+
/** Word overlap was measured against real messages and does not separate topics; only saying so does. */
|
|
60
|
+
function isNewTopic(text: string): boolean {
|
|
61
|
+
return NEW_TOPIC_RE.test(text.trim());
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
export function reactionTo(text: string,
|
|
64
|
+
export function reactionTo(text: string, previousPrompt: string | undefined): Reaction {
|
|
62
65
|
if (isCorrection(text)) return "corrected";
|
|
63
|
-
if (isRepeat(text,
|
|
66
|
+
if (isRepeat(text, previousPrompt)) return "repeated";
|
|
64
67
|
if (isApproval(text)) return "approved";
|
|
65
|
-
if (isNewTopic(text
|
|
68
|
+
if (isNewTopic(text)) return "new-topic";
|
|
66
69
|
return "follow-up";
|
|
67
70
|
}
|
|
68
|
-
|
|
69
|
-
export function replyKeywords(reply: string): string[] {
|
|
70
|
-
return [...extractKeywords(reply)];
|
|
71
|
-
}
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
* Per-session tracking lives in state and is discarded with the session.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
appendFileSync,
|
|
8
|
+
existsSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
7
13
|
import { resolve } from "node:path";
|
|
8
14
|
import { currentAttribution } from "./actor";
|
|
9
15
|
import {
|
|
@@ -19,7 +25,6 @@ import {
|
|
|
19
25
|
isRepeat,
|
|
20
26
|
type Reaction,
|
|
21
27
|
reactionTo,
|
|
22
|
-
replyKeywords,
|
|
23
28
|
} from "./interaction-reaction";
|
|
24
29
|
import { keepSample, replyEnd } from "./interaction-samples";
|
|
25
30
|
import { ensureDir, paths } from "./paths";
|
|
@@ -43,6 +48,8 @@ export interface TurnEvent extends MoodTurn {
|
|
|
43
48
|
weekday: string;
|
|
44
49
|
reply: Omit<ReplyShape, "at"> | null;
|
|
45
50
|
reaction: Reaction | null;
|
|
51
|
+
mood?: string;
|
|
52
|
+
hinted?: boolean;
|
|
46
53
|
}
|
|
47
54
|
|
|
48
55
|
interface SessionTrack {
|
|
@@ -50,7 +57,6 @@ interface SessionTrack {
|
|
|
50
57
|
lastPrompt?: string;
|
|
51
58
|
lastPromptAt?: string;
|
|
52
59
|
reply?: ReplyShape;
|
|
53
|
-
replyKeywords?: string[];
|
|
54
60
|
replyEnd?: string;
|
|
55
61
|
recent: TurnEvent[];
|
|
56
62
|
mood?: string;
|
|
@@ -117,12 +123,7 @@ function measureTurn(
|
|
|
117
123
|
repeated: isRepeat(text, track.lastPrompt),
|
|
118
124
|
corrected: isCorrection(text),
|
|
119
125
|
reply: reply ? replyFeatures(reply) : null,
|
|
120
|
-
reaction: reply
|
|
121
|
-
? reactionTo(text, {
|
|
122
|
-
previousPrompt: track.lastPrompt,
|
|
123
|
-
replyKeywords: track.replyKeywords ?? [],
|
|
124
|
-
})
|
|
125
|
-
: null,
|
|
126
|
+
reaction: reply ? reactionTo(text, track.lastPrompt) : null,
|
|
126
127
|
};
|
|
127
128
|
}
|
|
128
129
|
|
|
@@ -153,6 +154,16 @@ function readEvents(file: string): TurnEvent[] {
|
|
|
153
154
|
});
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
export function turnsSince(since: Date): TurnEvent[] {
|
|
158
|
+
const fromMonth = since.toISOString().slice(0, 7);
|
|
159
|
+
const sinceTs = since.toISOString();
|
|
160
|
+
return readdirSync(eventsDir())
|
|
161
|
+
.filter((f) => f.endsWith(".jsonl") && f.slice(0, 7) >= fromMonth)
|
|
162
|
+
.sort()
|
|
163
|
+
.flatMap((f) => readEvents(resolve(eventsDir(), f)))
|
|
164
|
+
.filter((e) => e.ts >= sinceTs);
|
|
165
|
+
}
|
|
166
|
+
|
|
156
167
|
/** The user's own normal, from this month and last, outside the session being judged. */
|
|
157
168
|
function baseline(session: string, now: Date = new Date()): Baseline {
|
|
158
169
|
const lastMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
|
|
@@ -211,7 +222,6 @@ export function recordReply(
|
|
|
211
222
|
...track,
|
|
212
223
|
updated: now.toISOString(),
|
|
213
224
|
reply: replyShape(reply, now),
|
|
214
|
-
replyKeywords: replyKeywords(reply),
|
|
215
225
|
replyEnd: replyEnd(reply),
|
|
216
226
|
});
|
|
217
227
|
}
|
|
@@ -239,10 +249,11 @@ export function observeTurn(
|
|
|
239
249
|
if (!text) return null;
|
|
240
250
|
const track = trackOf(session);
|
|
241
251
|
const event = measureTurn(text, session, track, now, channel);
|
|
242
|
-
appendEvent(event);
|
|
243
|
-
sampleReaction(event, text, track);
|
|
244
252
|
const recent = [...track.recent, event].slice(-RECENT_KEPT);
|
|
245
253
|
const mood = readMood(recent, baseline(session, now));
|
|
254
|
+
const reminder = moodReminder(mood, track.mood ?? "");
|
|
255
|
+
appendEvent({ ...event, mood: mood.key, hinted: reminder !== null });
|
|
256
|
+
sampleReaction(event, text, track);
|
|
246
257
|
writeTrack(session, {
|
|
247
258
|
...track,
|
|
248
259
|
updated: now.toISOString(),
|
|
@@ -251,5 +262,5 @@ export function observeTurn(
|
|
|
251
262
|
recent,
|
|
252
263
|
mood: mood.key,
|
|
253
264
|
});
|
|
254
|
-
return
|
|
265
|
+
return reminder;
|
|
255
266
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Interaction report — how the user's turns went, and whether replies changed
|
|
4
|
+
* while a measured label was in the agent's context.
|
|
5
|
+
*
|
|
6
|
+
* Usage: pal cli interaction report [--days N]
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { parseArgs } from "node:util";
|
|
10
|
+
import { turnsSince } from "../../hooks/lib/interaction";
|
|
11
|
+
import { reportLines, summarize } from "../lib/interaction-report";
|
|
12
|
+
import { scriptArgs } from "../lib/script-args";
|
|
13
|
+
|
|
14
|
+
const HELP = `
|
|
15
|
+
PAL Interaction report
|
|
16
|
+
|
|
17
|
+
Reads the measured turns in memory/signals/interaction/ (features only, no
|
|
18
|
+
text) and prints turns, channels, reactions, the hints sent, and for each
|
|
19
|
+
label (short, long, fast, skimming, friction) how the replies written while
|
|
20
|
+
it was active compare with the replies written while none was.
|
|
21
|
+
|
|
22
|
+
Usage: pal cli interaction report [--days N] (default 7)
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
const DAY_MS = 86_400_000;
|
|
26
|
+
|
|
27
|
+
export function run(argv: string[] = scriptArgs()) {
|
|
28
|
+
const { values, positionals } = parseArgs({
|
|
29
|
+
args: argv,
|
|
30
|
+
allowPositionals: true,
|
|
31
|
+
options: {
|
|
32
|
+
help: { type: "boolean", short: "h" },
|
|
33
|
+
days: { type: "string", default: "7" },
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
const days = Number(values.days);
|
|
37
|
+
if (values.help || positionals[0] !== "report" || !(days > 0)) {
|
|
38
|
+
console.log(HELP);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const events = turnsSince(new Date(Date.now() - days * DAY_MS));
|
|
42
|
+
for (const line of reportLines(summarize(events), days)) console.log(line);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (import.meta.main) run();
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `pal cli interaction report` says: how the user's turns went, and whether
|
|
3
|
+
* the agent's replies changed while a measured label was in its context.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { TurnEvent } from "../../hooks/lib/interaction";
|
|
7
|
+
import { median } from "../../hooks/lib/interaction-mood";
|
|
8
|
+
|
|
9
|
+
const LABELS = ["short", "long", "fast", "skimming", "friction"] as const;
|
|
10
|
+
|
|
11
|
+
interface ReplyUnderMood {
|
|
12
|
+
labels: string[];
|
|
13
|
+
words: number;
|
|
14
|
+
approved: boolean;
|
|
15
|
+
corrected: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface ReplyStats {
|
|
19
|
+
replies: number;
|
|
20
|
+
medianWords: number | null;
|
|
21
|
+
approvedShare: number;
|
|
22
|
+
correctedShare: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface InteractionSummary {
|
|
26
|
+
turns: number;
|
|
27
|
+
sessions: number;
|
|
28
|
+
channels: Record<string, number>;
|
|
29
|
+
reactions: Record<string, number>;
|
|
30
|
+
hints: Record<string, number>;
|
|
31
|
+
unlogged: number;
|
|
32
|
+
usual: ReplyStats;
|
|
33
|
+
byLabel: Record<string, ReplyStats>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function tally(values: string[]): Record<string, number> {
|
|
37
|
+
const counts: Record<string, number> = {};
|
|
38
|
+
for (const v of values) counts[v] = (counts[v] ?? 0) + 1;
|
|
39
|
+
return counts;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function share(replies: ReplyUnderMood[], test: (r: ReplyUnderMood) => boolean): number {
|
|
43
|
+
return replies.length ? replies.filter(test).length / replies.length : 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stats(replies: ReplyUnderMood[]): ReplyStats {
|
|
47
|
+
return {
|
|
48
|
+
replies: replies.length,
|
|
49
|
+
medianWords: median(replies.map((r) => r.words)),
|
|
50
|
+
approvedShare: share(replies, (r) => r.approved),
|
|
51
|
+
correctedShare: share(replies, (r) => r.corrected),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The reply to a turn arrives with the session's next turn, written under that turn's mood. */
|
|
56
|
+
function repliesUnderMood(events: TurnEvent[]): ReplyUnderMood[] {
|
|
57
|
+
const lastMood = new Map<string, string | undefined>();
|
|
58
|
+
const replies: ReplyUnderMood[] = [];
|
|
59
|
+
for (const e of events) {
|
|
60
|
+
const mood = lastMood.get(e.session);
|
|
61
|
+
if (e.reply && mood !== undefined)
|
|
62
|
+
replies.push({
|
|
63
|
+
labels: mood ? mood.split(",") : [],
|
|
64
|
+
words: e.reply.words,
|
|
65
|
+
approved: e.reaction === "approved",
|
|
66
|
+
corrected: e.reaction === "corrected" || e.reaction === "repeated",
|
|
67
|
+
});
|
|
68
|
+
lastMood.set(e.session, e.mood);
|
|
69
|
+
}
|
|
70
|
+
return replies;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function summarize(events: TurnEvent[]): InteractionSummary {
|
|
74
|
+
const replies = repliesUnderMood(events);
|
|
75
|
+
const hinted = events.filter((e) => e.hinted);
|
|
76
|
+
return {
|
|
77
|
+
turns: events.length,
|
|
78
|
+
sessions: new Set(events.map((e) => e.session)).size,
|
|
79
|
+
channels: tally(events.map((e) => e.channel ?? "unknown")),
|
|
80
|
+
reactions: tally(events.flatMap((e) => (e.reaction ? [e.reaction] : []))),
|
|
81
|
+
hints: tally(hinted.map((e) => e.mood || "back to usual")),
|
|
82
|
+
unlogged: events.filter((e) => e.mood === undefined).length,
|
|
83
|
+
usual: stats(replies.filter((r) => r.labels.length === 0)),
|
|
84
|
+
byLabel: Object.fromEntries(
|
|
85
|
+
LABELS.map((label) => [
|
|
86
|
+
label,
|
|
87
|
+
stats(replies.filter((r) => r.labels.includes(label))),
|
|
88
|
+
])
|
|
89
|
+
),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function pct(n: number): string {
|
|
94
|
+
return `${Math.round(n * 100)}%`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function counts(record: Record<string, number>): string {
|
|
98
|
+
const entries = Object.entries(record).sort(([, a], [, b]) => b - a);
|
|
99
|
+
return entries.length ? entries.map(([k, n]) => `${k} ${n}`).join(" · ") : "none";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function shares(record: Record<string, number>): string {
|
|
103
|
+
const total = Object.values(record).reduce((a, b) => a + b, 0);
|
|
104
|
+
const entries = Object.entries(record).sort(([, a], [, b]) => b - a);
|
|
105
|
+
return total ? entries.map(([k, n]) => `${k} ${pct(n / total)}`).join(" · ") : "none";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function words(s: ReplyStats): string {
|
|
109
|
+
return s.medianWords === null ? "-" : `${Math.round(s.medianWords)}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function labelLine(label: string, s: ReplyStats, usual: ReplyStats): string {
|
|
113
|
+
if (s.replies === 0) return ` ${label.padEnd(9)} no replies yet`;
|
|
114
|
+
return [
|
|
115
|
+
` ${label.padEnd(9)} ${s.replies} replies`,
|
|
116
|
+
`median ${words(s)} words (usual ${words(usual)})`,
|
|
117
|
+
`approved ${pct(s.approvedShare)} (usual ${pct(usual.approvedShare)})`,
|
|
118
|
+
`corrected ${pct(s.correctedShare)} (usual ${pct(usual.correctedShare)})`,
|
|
119
|
+
].join(" · ");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function plural(n: number, noun: string): string {
|
|
123
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function reportLines(summary: InteractionSummary, days: number): string[] {
|
|
127
|
+
const period = days === 1 ? "day" : plural(days, "day");
|
|
128
|
+
if (summary.turns === 0) return [`No measured turns in the last ${period}.`];
|
|
129
|
+
const lines = [
|
|
130
|
+
`Interaction report, last ${period}`,
|
|
131
|
+
`Turns: ${summary.turns} in ${plural(summary.sessions, "session")} · ${counts(summary.channels)}`,
|
|
132
|
+
`Reactions: ${shares(summary.reactions)}`,
|
|
133
|
+
`Hints sent: ${counts(summary.hints)}`,
|
|
134
|
+
"",
|
|
135
|
+
"Replies written while a label was active, against replies while none was:",
|
|
136
|
+
...LABELS.map((label) => labelLine(label, summary.byLabel[label], summary.usual)),
|
|
137
|
+
];
|
|
138
|
+
if (summary.unlogged)
|
|
139
|
+
lines.push(
|
|
140
|
+
"",
|
|
141
|
+
`${summary.unlogged} older turns were logged before moods were, and are left out of the comparison.`
|
|
142
|
+
);
|
|
143
|
+
return lines;
|
|
144
|
+
}
|