@neosh/titles 0.1.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/main.ts +131 -0
- package/package.json +21 -0
- package/plugin.toml +4 -0
package/main.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation titles, written by a model.
|
|
3
|
+
*
|
|
4
|
+
* A thread list is only navigable if the rows say what they are. Falling back to the first message
|
|
5
|
+
* gets you a long way — it is what the label does when there is no title — but the first message is
|
|
6
|
+
* often "have a look at this" and the conversation turns out to be about something else.
|
|
7
|
+
*
|
|
8
|
+
* So: after the first exchange settles, ask for a title. Once, per conversation, never again unless
|
|
9
|
+
* asked, and never while a turn is running. The prompt is a setting, like every other prompt here.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Neosh, PluginContext, SessionId } from "@neosh/api";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* How much of a title a list can actually show.
|
|
16
|
+
*
|
|
17
|
+
* A sidebar is a narrow column with an indent, a state glyph and an age on it, so a title of forty
|
|
18
|
+
* characters is a title that ends in an ellipsis in the one place it is read. Asked for *and*
|
|
19
|
+
* enforced: a model told "under 40" writes 46 often enough that the clamp is what the panel
|
|
20
|
+
* actually gets, and a clamp is a truncation — the sentence it cuts is one the model would have
|
|
21
|
+
* written shorter if it had been asked to.
|
|
22
|
+
*/
|
|
23
|
+
const LIMIT = 32;
|
|
24
|
+
|
|
25
|
+
const DEFAULT_PROMPT =
|
|
26
|
+
`Generate a title that will help someone recognise this conversation weeks later.
|
|
27
|
+
Return a JSON object with exactly one key: title.
|
|
28
|
+
|
|
29
|
+
Rules:
|
|
30
|
+
- 2 to 5 words, at most ${LIMIT} characters. Shorter is better.
|
|
31
|
+
- A compact noun phrase, or a clear action phrase.
|
|
32
|
+
- Name the subject and what the user wants, not the process used to get there.
|
|
33
|
+
- Leave out articles, and anything a sidebar already shows: the project, the language, the word
|
|
34
|
+
"conversation".
|
|
35
|
+
- Do not claim the work is finished.
|
|
36
|
+
- Do not copy and truncate the user's message.
|
|
37
|
+
- No quotes, no trailing punctuation.`;
|
|
38
|
+
|
|
39
|
+
export async function activate({ neosh, subscriptions }: PluginContext) {
|
|
40
|
+
await neosh.opt.declare({
|
|
41
|
+
name: "session.autotitle",
|
|
42
|
+
type: { type: "bool" },
|
|
43
|
+
default: true,
|
|
44
|
+
description:
|
|
45
|
+
"Name a conversation after its first exchange. Uses `gen.model`, so point that at something cheap.",
|
|
46
|
+
});
|
|
47
|
+
await neosh.opt.declare({
|
|
48
|
+
name: "session.title.prompt",
|
|
49
|
+
type: { type: "str" },
|
|
50
|
+
default: "",
|
|
51
|
+
description:
|
|
52
|
+
"Replaces the titling prompt entirely. Empty uses the built-in one. It must ask for JSON with a `title` key.",
|
|
53
|
+
});
|
|
54
|
+
await neosh.opt.declare({
|
|
55
|
+
name: "session.title.instructions",
|
|
56
|
+
type: { type: "str" },
|
|
57
|
+
default: "",
|
|
58
|
+
description: "Appended to the titling prompt. The usual way to add a house rule.",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Conversations already asked about. A failed attempt counts: retrying every turn would turn one
|
|
62
|
+
// bad answer into a request per message, which is the expensive kind of bug.
|
|
63
|
+
const attempted = new Set<SessionId>();
|
|
64
|
+
|
|
65
|
+
subscriptions.push(
|
|
66
|
+
await neosh.cmd.register("session.retitle", () => retitle(neosh, attempted, true), {
|
|
67
|
+
desc: "Rename this conversation from what it is about",
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
subscriptions.push(
|
|
72
|
+
neosh.agent.onTurnEnd(() => {
|
|
73
|
+
void retitle(neosh, attempted, false);
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* A title that is too long for the column it lives in, cut at a word.
|
|
80
|
+
*
|
|
81
|
+
* Mid-word is worse than short: `Fix the authenticati` reads as a bug in the panel, and the two
|
|
82
|
+
* characters saved by cutting there buy nothing.
|
|
83
|
+
*/
|
|
84
|
+
function clamp(title: string): string {
|
|
85
|
+
if (Array.from(title).length <= LIMIT) return title;
|
|
86
|
+
const cut = Array.from(title).slice(0, LIMIT).join("");
|
|
87
|
+
const space = cut.lastIndexOf(" ");
|
|
88
|
+
return (space > LIMIT / 2 ? cut.slice(0, space) : cut).trimEnd();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function retitle(neosh: Neosh, attempted: Set<SessionId>, forced: boolean): Promise<void> {
|
|
92
|
+
if (!forced && !((await neosh.opt.get<boolean>("session.autotitle")) ?? true)) return;
|
|
93
|
+
|
|
94
|
+
const current = await neosh.session.current().catch(() => null);
|
|
95
|
+
if (!current) return;
|
|
96
|
+
if (!forced) {
|
|
97
|
+
// A title someone set by hand is theirs. And one exchange is the earliest point at which there
|
|
98
|
+
// is anything to name.
|
|
99
|
+
if (current.title || attempted.has(current.id)) return;
|
|
100
|
+
if (current.message_count < 2) return;
|
|
101
|
+
}
|
|
102
|
+
attempted.add(current.id);
|
|
103
|
+
|
|
104
|
+
const messages = await neosh.session.messages().catch(() => []);
|
|
105
|
+
const transcript = messages
|
|
106
|
+
.slice(0, 6)
|
|
107
|
+
.flatMap((m) =>
|
|
108
|
+
m.content.flatMap((b) => (b.type === "text" ? [`${m.role}: ${b.text}`] : [])),
|
|
109
|
+
)
|
|
110
|
+
.join("\n\n")
|
|
111
|
+
.slice(0, 6000);
|
|
112
|
+
if (transcript.trim() === "") return;
|
|
113
|
+
|
|
114
|
+
const override = ((await neosh.opt.get<string>("session.title.prompt")) ?? "").trim();
|
|
115
|
+
const extra = ((await neosh.opt.get<string>("session.title.instructions")) ?? "").trim();
|
|
116
|
+
const base = override || DEFAULT_PROMPT;
|
|
117
|
+
const prompt = `${extra ? `${base}\n\nAdditional instructions:\n${extra}` : base}\n\nConversation:\n${transcript}`;
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const answer = await neosh.gen.json<{ title?: string }>(prompt);
|
|
121
|
+
const title = (answer.title ?? "").trim().replace(/^["']|["']$/g, "");
|
|
122
|
+
if (title === "") return;
|
|
123
|
+
// The conversation may have been switched away from while the model was thinking; renaming by
|
|
124
|
+
// id rather than "the current one" is what stops the answer landing on the wrong thread.
|
|
125
|
+
await neosh.session.rename(current.id, clamp(title));
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// Not worth a message: failing to name a conversation costs the user nothing, and a popup
|
|
128
|
+
// every time a cheap model hiccups would cost them plenty.
|
|
129
|
+
neosh.log.info(`could not title the conversation: ${e}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@neosh/titles",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Names a conversation after what it turned out to be about.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"neosh",
|
|
9
|
+
"neosh-plugin"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/neoswarm/neosh.git",
|
|
14
|
+
"directory": "plugins/builtin/titles"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"*.ts",
|
|
18
|
+
"plugin.toml",
|
|
19
|
+
"!._*"
|
|
20
|
+
]
|
|
21
|
+
}
|
package/plugin.toml
ADDED