portable-agent-layer 0.85.21 → 0.85.23
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.
|
@@ -425,6 +425,17 @@ LoadContext.ts
|
|
|
425
425
|
8. Other recent learnings (other projects, titles only)
|
|
426
426
|
```
|
|
427
427
|
|
|
428
|
+
### Injection Budget
|
|
429
|
+
|
|
430
|
+
Claude Code caps each string a hook injects at **10,000 characters**: plain stdout as a whole, or each JSON field (`additionalContext`, `systemMessage`) on its own. Over the cap, the text is saved to a file and replaced with the file path plus a preview of the first 2,000 characters, and the model is never told to read the file. No setting raises the cap.
|
|
431
|
+
|
|
432
|
+
Design rules for anything injected by a hook (Tier 3 and per-prompt reminders):
|
|
433
|
+
- Stay under 10,000 characters per hook output.
|
|
434
|
+
- Put what matters most in the first 2,000 characters, in case a future change crosses the cap.
|
|
435
|
+
- Measure before adding a section: pipe a SessionStart payload into `LoadContext.ts` and count the bytes.
|
|
436
|
+
|
|
437
|
+
Tier 1 and Tier 2 load as memory files, not hook output, so this cap does not apply to them. Other agents' limits are not documented here; check them before relying on the same budget.
|
|
438
|
+
|
|
428
439
|
### On-Demand Context
|
|
429
440
|
|
|
430
441
|
Everything else loads via the routing table in CLAUDE.md. The AI reads files only when the current task requires that context — no upfront loading of the full system.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.85.
|
|
3
|
+
"version": "0.85.23",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -64,10 +64,10 @@
|
|
|
64
64
|
"tool:thread": "bun run src/tools/agent/thread.ts",
|
|
65
65
|
"tool:wisdom-frame": "bun run src/tools/agent/wisdom-frame.ts",
|
|
66
66
|
"tool:reflect": "bun run src/tools/agent/relationship-reflect.ts",
|
|
67
|
-
"eval:sentiment": "bun eval/run.ts sentiment",
|
|
68
67
|
"eval:failure-principle": "bun eval/run.ts failure-principle",
|
|
69
68
|
"eval:goal-links": "bun eval/run.ts goal-links",
|
|
70
|
-
"eval:waiting-on": "bun eval/run.ts waiting-on"
|
|
69
|
+
"eval:waiting-on": "bun eval/run.ts waiting-on",
|
|
70
|
+
"eval:reaction": "bun eval/run.ts reaction"
|
|
71
71
|
},
|
|
72
72
|
"lint-staged": {
|
|
73
73
|
"*.ts": [
|
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* UserPromptSubmit handler: detects explicit and implicit ratings.
|
|
3
|
-
* Ported from original PAI's RatingCapture.hook.ts with rich sentiment analysis.
|
|
4
3
|
*
|
|
5
4
|
* - Explicit: "7", "8 - great work", "rating: 8"
|
|
6
|
-
* - Implicit:
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
5
|
+
* - Implicit: a model labels the reaction to the previous reply; only a confirmed
|
|
6
|
+
* correction or plain praise becomes a rating
|
|
7
|
+
* - Low ratings (<=4) write pending-failure.json; the Stop handler writes the
|
|
8
|
+
* lesson from the transcript
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
13
|
import { spawnDetachedInference } from "../lib/detached-inference";
|
|
14
14
|
import { canInfer, inference } from "../lib/inference";
|
|
15
|
+
import { replyEnd } from "../lib/interaction-samples";
|
|
15
16
|
import { paths } from "../lib/paths";
|
|
16
17
|
import { isSystemText, stripInjectedTags } from "../lib/prompt-text";
|
|
18
|
+
import {
|
|
19
|
+
isCorrectionLabel,
|
|
20
|
+
parseReactionLabel,
|
|
21
|
+
ratingContext,
|
|
22
|
+
ratingFromLabels,
|
|
23
|
+
reactionRequest,
|
|
24
|
+
} from "../lib/reaction-rating";
|
|
17
25
|
import { emitRating } from "../lib/signals";
|
|
18
26
|
import { now } from "../lib/time";
|
|
19
27
|
import { logTokenUsage } from "../lib/token-usage";
|
|
@@ -129,80 +137,18 @@ function isPraise(prompt: string): boolean {
|
|
|
129
137
|
);
|
|
130
138
|
}
|
|
131
139
|
|
|
132
|
-
// ── Sentiment Analysis ──
|
|
133
|
-
|
|
134
|
-
const SENTIMENT_SCHEMA = {
|
|
135
|
-
type: "object",
|
|
136
|
-
properties: {
|
|
137
|
-
rating: { type: ["number", "null"] },
|
|
138
|
-
sentiment: { enum: ["positive", "negative", "neutral"] },
|
|
139
|
-
confidence: { type: "number" },
|
|
140
|
-
summary: { type: "string" },
|
|
141
|
-
detailed_context: { type: "string" },
|
|
142
|
-
principle: { type: "string" },
|
|
143
|
-
},
|
|
144
|
-
required: [
|
|
145
|
-
"rating",
|
|
146
|
-
"sentiment",
|
|
147
|
-
"confidence",
|
|
148
|
-
"summary",
|
|
149
|
-
"detailed_context",
|
|
150
|
-
"principle",
|
|
151
|
-
],
|
|
152
|
-
additionalProperties: false,
|
|
153
|
-
} as const;
|
|
154
|
-
|
|
155
|
-
interface SentimentResult {
|
|
156
|
-
rating: number | null;
|
|
157
|
-
sentiment: "positive" | "negative" | "neutral";
|
|
158
|
-
confidence: number;
|
|
159
|
-
summary: string;
|
|
160
|
-
detailed_context: string;
|
|
161
|
-
principle: string;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const SENTIMENT_SYSTEM_PROMPT = `Analyze the user's message for emotional sentiment toward the AI assistant.
|
|
165
|
-
|
|
166
|
-
OUTPUT FORMAT (JSON only):
|
|
167
|
-
{
|
|
168
|
-
"rating": <1-10 or null>,
|
|
169
|
-
"sentiment": "positive" | "negative" | "neutral",
|
|
170
|
-
"confidence": <0.0-1.0>,
|
|
171
|
-
"summary": "<10 words max>",
|
|
172
|
-
"detailed_context": "<what the user wanted, what the AI did or failed to do, root cause, and what to do differently — 50-150 words>",
|
|
173
|
-
"principle": "<one actionable rule, 10-20 words, start with a verb: Verify / Always / Never / Ask>"
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
RATING SCALE: 1-2 strong frustration · 3-4 mild frustration · 5 neutral · 6-7 approval · 8-9 strong approval · 10 exceptional · null = no emotional signal
|
|
177
|
-
Short praise ("great job", "nice") = STRONG APPROVAL (8-9), not mild.
|
|
178
|
-
|
|
179
|
-
TASK DIRECTIVES — short imperatives with "pls" are approval or sequencing signals (null or 6-7, NOT negative):
|
|
180
|
-
"write it pls" / "do it pls" / "step 3 pls" / "option 2 pls" = user directing or approving the AI's own proposal.
|
|
181
|
-
EXCEPTION: "fix X pls" / "fix that pls" is always negative — "fix" implies a problem regardless of "pls".
|
|
182
|
-
|
|
183
|
-
IMPLIED NEGATIVE (rate 2-4): corrections ("that's wrong", "I meant..."), repeats ("again pls", "still broken", "doesn't work again"), behavioral corrections ("don't do that"), exasperated questions ("why is this still broken?"), failure confirmations ("nothing is fixed").
|
|
184
|
-
IMPLIED POSITIVE (rate 6-8): trust signals ("go ahead", "fix all of it"), continuation ("now also add X", "next do..."), moving forward with the next task.
|
|
185
|
-
NULL rating: neutral questions, simple continuations ("yes", "continue"), task directives without failure context.`;
|
|
186
|
-
|
|
187
|
-
const MIN_CONFIDENCE = 0.5;
|
|
188
|
-
|
|
189
140
|
// ── Rating Handling ──
|
|
190
141
|
|
|
191
142
|
function handleRating(
|
|
192
143
|
rating: number,
|
|
193
144
|
context: string,
|
|
194
145
|
source: string,
|
|
195
|
-
|
|
196
|
-
principle?: string,
|
|
197
|
-
sessionId?: string,
|
|
146
|
+
responsePreview: string,
|
|
198
147
|
userMessage?: string
|
|
199
148
|
): void {
|
|
200
|
-
const responsePreview = getLastResponse(sessionId).slice(0, 500);
|
|
201
149
|
emitRating(rating, context, source, responsePreview);
|
|
202
150
|
|
|
203
151
|
if (rating <= 4) {
|
|
204
|
-
// Low rating — write pending file for Stop handler with full transcript
|
|
205
|
-
const userPreview = userMessage?.slice(0, 400);
|
|
206
152
|
writeFileSync(
|
|
207
153
|
resolve(paths.state(), "pending-failure.json"),
|
|
208
154
|
JSON.stringify(
|
|
@@ -210,10 +156,8 @@ function handleRating(
|
|
|
210
156
|
rating,
|
|
211
157
|
context,
|
|
212
158
|
source,
|
|
213
|
-
detailedContext,
|
|
214
|
-
principle,
|
|
215
159
|
responsePreview,
|
|
216
|
-
userPreview,
|
|
160
|
+
userPreview: userMessage?.slice(0, 400),
|
|
217
161
|
cwd: process.cwd(),
|
|
218
162
|
ts: now(),
|
|
219
163
|
},
|
|
@@ -225,92 +169,63 @@ function handleRating(
|
|
|
225
169
|
}
|
|
226
170
|
}
|
|
227
171
|
|
|
228
|
-
// ── Implicit
|
|
172
|
+
// ── Implicit Rating ──
|
|
229
173
|
|
|
230
|
-
function
|
|
174
|
+
function handleImplicitReaction(message: string, sessionId?: string): void {
|
|
231
175
|
const trimmed = message.trim();
|
|
176
|
+
const reply = replyEnd(getLastResponse(sessionId));
|
|
232
177
|
|
|
233
|
-
// Fast-path: short praise -> rating 8 (synchronous, no inference)
|
|
234
178
|
if (isPraise(trimmed)) {
|
|
235
|
-
handleRating(
|
|
236
|
-
8,
|
|
237
|
-
`Direct praise: "${trimmed}"`,
|
|
238
|
-
"implicit",
|
|
239
|
-
undefined,
|
|
240
|
-
undefined,
|
|
241
|
-
sessionId,
|
|
242
|
-
trimmed
|
|
243
|
-
);
|
|
179
|
+
handleRating(8, `Direct praise: "${trimmed}"`, "implicit", reply, trimmed);
|
|
244
180
|
return;
|
|
245
181
|
}
|
|
246
182
|
|
|
247
|
-
// Skip system-injected text
|
|
248
183
|
if (isSystemText(trimmed)) return;
|
|
184
|
+
if (!reply) return;
|
|
249
185
|
|
|
250
186
|
// Skip very short, very long, or code-like messages
|
|
251
187
|
if (trimmed.length < 5 || trimmed.length > 500) return;
|
|
252
188
|
if (/^[/$`{]/.test(trimmed) || trimmed.includes("\n\n")) return;
|
|
253
189
|
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
// any reasonable in-line budget. Uses the shared detach helper.
|
|
190
|
+
// claude --print has 3-5s of cold start, too slow for UserPromptSubmit.
|
|
191
|
+
// The reply is read here, before the turn's own reply can replace it in the cache.
|
|
257
192
|
if (!canInfer()) return;
|
|
258
|
-
const msgB64 = Buffer.from(trimmed.slice(0, 800)).toString("base64");
|
|
259
193
|
spawnDetachedInference(
|
|
260
194
|
import.meta.filename,
|
|
261
|
-
[
|
|
195
|
+
[
|
|
196
|
+
"--sentiment",
|
|
197
|
+
sessionId ?? "",
|
|
198
|
+
Buffer.from(trimmed).toString("base64"),
|
|
199
|
+
Buffer.from(reply).toString("base64"),
|
|
200
|
+
],
|
|
262
201
|
"rating"
|
|
263
202
|
);
|
|
264
203
|
}
|
|
265
204
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
205
|
+
async function labelReaction(reply: string, message: string, sessionId?: string) {
|
|
206
|
+
const result = await inference(reactionRequest(reply, message, sessionId));
|
|
207
|
+
if (result.usage) logTokenUsage("rating", result.usage);
|
|
208
|
+
return result.success ? parseReactionLabel(result.output) : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Background mode: label the reaction, confirm a correction, store the rating. */
|
|
212
|
+
async function runReactionRatingAndStore(
|
|
271
213
|
message: string,
|
|
214
|
+
reply: string,
|
|
272
215
|
sessionId?: string
|
|
273
216
|
): Promise<void> {
|
|
274
217
|
try {
|
|
275
|
-
const
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
system: SENTIMENT_SYSTEM_PROMPT,
|
|
283
|
-
user: contextBlock,
|
|
284
|
-
maxTokens: 500,
|
|
285
|
-
timeout: 90000,
|
|
286
|
-
jsonSchema: SENTIMENT_SCHEMA,
|
|
287
|
-
caller: "rating",
|
|
288
|
-
sessionId,
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
if (result.usage) logTokenUsage("rating", result.usage);
|
|
292
|
-
if (!result.success || !result.output) return;
|
|
293
|
-
|
|
294
|
-
const parsed = JSON.parse(result.output) as SentimentResult;
|
|
295
|
-
|
|
296
|
-
if (parsed.rating === null) return;
|
|
297
|
-
if (parsed.confidence < MIN_CONFIDENCE) return;
|
|
298
|
-
|
|
299
|
-
const rating = parsed.rating;
|
|
300
|
-
if (typeof rating === "number" && rating >= 1 && rating <= 10 && rating !== 5) {
|
|
301
|
-
handleRating(
|
|
302
|
-
rating,
|
|
303
|
-
`${parsed.summary}: ${trimmed.slice(0, 200)}`,
|
|
304
|
-
"implicit",
|
|
305
|
-
parsed.detailed_context,
|
|
306
|
-
parsed.principle,
|
|
307
|
-
sessionId,
|
|
308
|
-
trimmed
|
|
309
|
-
);
|
|
218
|
+
const first = await labelReaction(reply, message, sessionId);
|
|
219
|
+
const confirmation = isCorrectionLabel(first)
|
|
220
|
+
? await labelReaction(reply, message, sessionId)
|
|
221
|
+
: null;
|
|
222
|
+
const rating = ratingFromLabels(first, confirmation);
|
|
223
|
+
if (first && rating !== null) {
|
|
224
|
+
handleRating(rating, ratingContext(first, message), "implicit", reply, message);
|
|
310
225
|
}
|
|
311
226
|
} catch (err) {
|
|
312
227
|
const { logError } = await import("../lib/log");
|
|
313
|
-
logError("rating:
|
|
228
|
+
logError("rating:reaction-child", err);
|
|
314
229
|
}
|
|
315
230
|
}
|
|
316
231
|
|
|
@@ -327,26 +242,29 @@ export function captureRating(message: string, sessionId?: string): void {
|
|
|
327
242
|
explicit.rating,
|
|
328
243
|
explicit.comment || cleaned.slice(0, 200),
|
|
329
244
|
"explicit",
|
|
330
|
-
|
|
331
|
-
undefined,
|
|
332
|
-
sessionId,
|
|
245
|
+
getLastResponse(sessionId).slice(0, 500),
|
|
333
246
|
cleaned
|
|
334
247
|
);
|
|
335
248
|
return;
|
|
336
249
|
}
|
|
337
250
|
|
|
338
|
-
// Path 2: Implicit
|
|
339
|
-
// path detaches to a background bun subprocess (mirrors session-name).
|
|
340
|
-
|
|
251
|
+
// Path 2: Implicit reaction — the praise fast-path runs synchronously, the
|
|
252
|
+
// model path detaches to a background bun subprocess (mirrors session-name).
|
|
253
|
+
handleImplicitReaction(cleaned, sessionId);
|
|
341
254
|
}
|
|
342
255
|
|
|
343
|
-
// Background
|
|
256
|
+
// Background reaction entry point
|
|
344
257
|
if (process.argv[2] === "--sentiment") {
|
|
345
258
|
const sid = process.argv[3];
|
|
346
259
|
const msgB64 = process.argv[4];
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
260
|
+
const replyB64 = process.argv[5];
|
|
261
|
+
if (msgB64 && replyB64) {
|
|
262
|
+
const decode = (b64: string) => Buffer.from(b64, "base64").toString("utf-8");
|
|
263
|
+
await runReactionRatingAndStore(
|
|
264
|
+
decode(msgB64),
|
|
265
|
+
decode(replyB64),
|
|
266
|
+
sid === "" ? undefined : sid
|
|
267
|
+
);
|
|
350
268
|
}
|
|
351
269
|
process.exit(0);
|
|
352
270
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Implicit rating from the user's reaction to the previous reply.
|
|
3
|
+
*
|
|
4
|
+
* A model labels the reaction from the end of the reply and the message. Only a
|
|
5
|
+
* correction, confirmed by a second call, or plain praise becomes a rating; a
|
|
6
|
+
* go-ahead, a question or news carries no rating at all.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const MODEL_REACTIONS = [
|
|
10
|
+
"corrected",
|
|
11
|
+
"repeated",
|
|
12
|
+
"praised",
|
|
13
|
+
"approved",
|
|
14
|
+
"new-topic",
|
|
15
|
+
"follow-up",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
type ModelReaction = (typeof MODEL_REACTIONS)[number];
|
|
19
|
+
|
|
20
|
+
export interface ReactionLabel {
|
|
21
|
+
reaction: ModelReaction;
|
|
22
|
+
issue: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const REACTION_SYSTEM_PROMPT = `You classify how a user reacted to an AI assistant's reply.
|
|
26
|
+
|
|
27
|
+
Pick exactly one label:
|
|
28
|
+
- corrected: the message shows the reply itself was wrong. Something the reply claimed is false, something it said was done is missing, a command or step it gave does not work, a promise it made was not kept, it forgot or missed what the user asked, or the user still does not understand after its explanation.
|
|
29
|
+
- repeated: the user restates their previous request because it was not done.
|
|
30
|
+
- praised: the user explicitly praises the reply or the work, such as "well done", "perfect", "exactly", "this works great".
|
|
31
|
+
- approved: the user accepts the reply or tells the assistant to go ahead, with nothing else to fix.
|
|
32
|
+
- new-topic: the user moves to something unrelated to the reply.
|
|
33
|
+
- follow-up: anything else on the same work.
|
|
34
|
+
|
|
35
|
+
These are follow-up, not corrected:
|
|
36
|
+
- news about something that failed after the reply, when the reply never claimed it would work, such as a failed CI run or pasted errors;
|
|
37
|
+
- asking "are you sure?" or asking the assistant to double-check, without saying the reply is wrong;
|
|
38
|
+
- suggesting more, or a new requirement on top of the work;
|
|
39
|
+
- asking where or how to do a step the reply described;
|
|
40
|
+
- a complaint about something other than the reply.
|
|
41
|
+
|
|
42
|
+
A go-ahead, "yes", "ok", or "thanks" with a next instruction is approved, not praised.
|
|
43
|
+
|
|
44
|
+
Judge the message against what the reply said. Answer corrected only when the message clearly says or shows the reply was wrong; otherwise answer follow-up.
|
|
45
|
+
|
|
46
|
+
For corrected or repeated, set issue to what the reply got wrong, in at most twelve words. Otherwise set issue to an empty string.`;
|
|
47
|
+
|
|
48
|
+
const REACTION_SCHEMA = {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
reaction: { enum: [...MODEL_REACTIONS] },
|
|
52
|
+
issue: { type: "string" },
|
|
53
|
+
},
|
|
54
|
+
required: ["reaction", "issue"],
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
const MESSAGE_MAX = 800;
|
|
59
|
+
|
|
60
|
+
export function reactionRequest(replyEnd: string, message: string, sessionId?: string) {
|
|
61
|
+
return {
|
|
62
|
+
system: REACTION_SYSTEM_PROMPT,
|
|
63
|
+
user: `The end of the assistant's reply:\n<reply_end>\n${replyEnd}\n</reply_end>\n\nThe user's next message:\n<message>\n${message.slice(0, MESSAGE_MAX)}\n</message>`,
|
|
64
|
+
maxTokens: 200,
|
|
65
|
+
timeout: 90_000,
|
|
66
|
+
jsonSchema: REACTION_SCHEMA,
|
|
67
|
+
caller: "rating",
|
|
68
|
+
sessionId,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isModelReaction(value: unknown): value is ModelReaction {
|
|
73
|
+
return MODEL_REACTIONS.includes(value as ModelReaction);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function parseReactionLabel(
|
|
77
|
+
output: string | null | undefined
|
|
78
|
+
): ReactionLabel | null {
|
|
79
|
+
if (!output) return null;
|
|
80
|
+
try {
|
|
81
|
+
const parsed = JSON.parse(output) as { reaction?: unknown; issue?: unknown };
|
|
82
|
+
if (!isModelReaction(parsed.reaction)) return null;
|
|
83
|
+
return {
|
|
84
|
+
reaction: parsed.reaction,
|
|
85
|
+
issue: typeof parsed.issue === "string" ? parsed.issue.trim() : "",
|
|
86
|
+
};
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isCorrectionLabel(label: ReactionLabel | null): boolean {
|
|
93
|
+
return label?.reaction === "corrected" || label?.reaction === "repeated";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const RATING_BY_REACTION: Partial<Record<ModelReaction, number>> = {
|
|
97
|
+
corrected: 3,
|
|
98
|
+
repeated: 2,
|
|
99
|
+
praised: 8,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** A correction counts only when a second, independent label agrees. */
|
|
103
|
+
export function ratingFromLabels(
|
|
104
|
+
first: ReactionLabel | null,
|
|
105
|
+
confirmation: ReactionLabel | null
|
|
106
|
+
): number | null {
|
|
107
|
+
if (!first) return null;
|
|
108
|
+
if (isCorrectionLabel(first) && !isCorrectionLabel(confirmation)) return null;
|
|
109
|
+
return RATING_BY_REACTION[first.reaction] ?? null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function ratingContext(label: ReactionLabel, message: string): string {
|
|
113
|
+
const what = label.reaction === "praised" ? "Praised the reply" : label.issue;
|
|
114
|
+
return `${what || label.reaction}: ${message.slice(0, 200)}`;
|
|
115
|
+
}
|