@nanhara/hara 0.115.0 → 0.116.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/CHANGELOG.md +16 -0
- package/dist/cron/runner.js +3 -1
- package/dist/index.js +19 -2
- package/dist/serve/protocol.js +8 -1
- package/dist/serve/server.js +62 -4
- package/dist/serve/sessions.js +31 -0
- package/dist/session/store.js +15 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.116.0 — the desktop-grade serve protocol (models · automation · rename/archive · capabilities)
|
|
9
|
+
|
|
10
|
+
- **Session source stamping.** Sessions now record WHO created them (`interactive` / `gateway` / `cron`,
|
|
11
|
+
env-derived; the cron runner passes the job name along) — and automated sessions get a
|
|
12
|
+
**"name · MM-DD HH:mm" title at creation**, so a cron prompt never becomes a session title again.
|
|
13
|
+
- **`hara serve` protocol, batch 2** (everything the Hara desktop app drives):
|
|
14
|
+
- `models.list` (endpoint model catalog + thinking-effort levels) and `session.set-model`
|
|
15
|
+
(per-session model/effort switch, applies next turn)
|
|
16
|
+
- `automation.list` — cron jobs with last outcome + automated sessions, for the desktop's
|
|
17
|
+
automation timeline
|
|
18
|
+
- `session.rename` + `session.archive` (archived sessions hidden from lists, kept on disk)
|
|
19
|
+
- `initialize` now advertises `capabilities.methods` — clients feature-detect instead of
|
|
20
|
+
probing for method-not-found
|
|
21
|
+
- `session.send` expands `@file` mentions (CLI parity)
|
|
22
|
+
- `session.list` carries `source` / `sourceName` / `archived`
|
|
23
|
+
|
|
8
24
|
## 0.115.0 — serve exposes plugins & skills (desktop plugin panel)
|
|
9
25
|
|
|
10
26
|
- **`hara serve` protocol grows a plugin surface**: `plugins.list` (installed plugins with enabled state +
|
package/dist/cron/runner.js
CHANGED
|
@@ -55,7 +55,9 @@ export function runJobOnce(job) {
|
|
|
55
55
|
const [cmd, argv] = job.mode === "command"
|
|
56
56
|
? ["bash", ["-lc", job.task]]
|
|
57
57
|
: [self[0], [...self.slice(1), ...(job.mode === "org" ? ["org", job.task] : ["-p", job.task, "--approval", "full-auto"])]];
|
|
58
|
-
|
|
58
|
+
// HARA_CRON_NAME rides along so the child session's meta gets a human title ("job name · time")
|
|
59
|
+
// instead of the raw prompt (session store's automated-title strategy).
|
|
60
|
+
const child = spawn(cmd, argv, { cwd: job.cwd, env: { ...process.env, HARA_CRON: "1", HARA_CRON_NAME: job.name } });
|
|
59
61
|
let tail = ""; // last few KB, for chat delivery (the full stream goes to the log file)
|
|
60
62
|
const append = (d) => {
|
|
61
63
|
tail = (tail + d.toString()).slice(-4_000);
|
package/dist/index.js
CHANGED
|
@@ -45,6 +45,7 @@ import { EXPLORE_SYSTEM } from "./tools/agent.js";
|
|
|
45
45
|
import { createAnthropicProvider } from "./providers/anthropic.js";
|
|
46
46
|
import { createOpenAIProvider } from "./providers/openai.js";
|
|
47
47
|
import { resolvePlatform } from "./providers/registry.js";
|
|
48
|
+
import { levelsFor } from "./tui/model-picker.js";
|
|
48
49
|
import { listModels } from "./providers/models.js";
|
|
49
50
|
import { listJobs, tailJob, killJob } from "./exec/jobs.js";
|
|
50
51
|
/** Render the background-job list for /jobs (user-facing view of what the agent has running in the
|
|
@@ -67,7 +68,7 @@ import { getEmbedder } from "./search/embed.js";
|
|
|
67
68
|
import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
|
|
68
69
|
import { searchHybrid } from "./search/hybrid.js";
|
|
69
70
|
import { expandMentions, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
|
|
70
|
-
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
|
|
71
|
+
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
|
|
71
72
|
import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
|
|
72
73
|
import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
73
74
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
@@ -1579,6 +1580,9 @@ program
|
|
|
1579
1580
|
providerId: cfg.provider,
|
|
1580
1581
|
model: cfg.model,
|
|
1581
1582
|
buildSessionProvider: async () => withRouting(await buildProvider(cfg), cfg),
|
|
1583
|
+
buildProviderFor: async (model, effort) => withRouting(await buildProvider({ ...cfg, model, reasoningEffort: effort ?? cfg.reasoningEffort }), cfg),
|
|
1584
|
+
listModels: () => listModels(cfg.baseURL ?? providerDefaultBaseURL(cfg.provider), cfg.apiKey ?? ""),
|
|
1585
|
+
effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning).filter((e) => !!e),
|
|
1582
1586
|
spawnSubagent: (provider, scwd, projectContext, stats, task, role) => runSubagent(cfg, provider, scwd, sandbox, projectContext, stats, task, role),
|
|
1583
1587
|
guardian: guardianOpt,
|
|
1584
1588
|
sandbox,
|
|
@@ -2251,7 +2255,19 @@ program.action(async (opts) => {
|
|
|
2251
2255
|
const prior = rid ? loadSession(rid) : null;
|
|
2252
2256
|
if (prior?.history)
|
|
2253
2257
|
history.push(...prior.history);
|
|
2254
|
-
|
|
2258
|
+
// Stamp who created this session (cron runner sets HARA_CRON, gateway sets HARA_GATEWAY) and give
|
|
2259
|
+
// automated sessions a "name · time" title UP FRONT — the raw prompt must never become the title.
|
|
2260
|
+
const src = sessionSourceFromEnv();
|
|
2261
|
+
meta = prior?.meta ?? {
|
|
2262
|
+
id: rid ?? newSessionId(),
|
|
2263
|
+
cwd,
|
|
2264
|
+
provider: cfg.provider,
|
|
2265
|
+
model: cfg.model,
|
|
2266
|
+
title: src.source === "interactive" ? "" : automatedTitle(src.source, src.sourceName),
|
|
2267
|
+
createdAt: new Date().toISOString(),
|
|
2268
|
+
updatedAt: "",
|
|
2269
|
+
...(src.source !== "interactive" ? { source: src.source, sourceName: src.sourceName } : { source: "interactive" }),
|
|
2270
|
+
};
|
|
2255
2271
|
// Apply per-session pinned model on headless resume (mirrors the interactive path).
|
|
2256
2272
|
// --model flag wins (already on cfg.model) and is written back; otherwise restore meta.model.
|
|
2257
2273
|
if (prior) {
|
|
@@ -2431,6 +2447,7 @@ program.action(async (opts) => {
|
|
|
2431
2447
|
title: "",
|
|
2432
2448
|
createdAt: new Date().toISOString(),
|
|
2433
2449
|
updatedAt: "",
|
|
2450
|
+
source: "interactive",
|
|
2434
2451
|
};
|
|
2435
2452
|
// Single-writer guard: two hara processes on the SAME session race writes to its append-only history and
|
|
2436
2453
|
// corrupt it. Lock it here (a brand-new session's id is unique, so this only ever blocks a DOUBLE-resume
|
package/dist/serve/protocol.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// Everything here is PURE (parse + frame builders + error codes) and unit-tested.
|
|
4
4
|
//
|
|
5
5
|
// Client → server requests:
|
|
6
|
-
// initialize {token}
|
|
6
|
+
// initialize {token,capabilities?} → {name,version,protocol,cwd,provider,model,
|
|
7
|
+
// capabilities:{methods:[…]}} (feature detection)
|
|
7
8
|
// session.list {cwd?} → {sessions:[{id,title,cwd,model,updatedAt}]}
|
|
8
9
|
// session.create {cwd?,approval?} → {sessionId,model}
|
|
9
10
|
// session.resume {sessionId} → {sessionId,model,history:[{role,text}]}
|
|
@@ -13,6 +14,12 @@
|
|
|
13
14
|
// plugins.list {} → {plugins:[{name,version,description,enabled,skills,agents,mcpServers}]}
|
|
14
15
|
// plugins.set {name,enabled} → {name,enabled} (applies to future sessions/turns)
|
|
15
16
|
// skills.list {cwd?} → {skills:[{id,description,source}]}
|
|
17
|
+
// automation.list {} → {jobs:[{id,name,mode,enabled,lastRunAt,lastStatus,…}],
|
|
18
|
+
// sessions:[{id,title,source,sourceName,updatedAt,…}]}
|
|
19
|
+
// models.list {} → {models:[…], current, effortLevels:[…]}
|
|
20
|
+
// session.rename {sessionId,title} → {sessionId,title}
|
|
21
|
+
// session.archive {sessionId,archived} → {sessionId,archived} (list hides archived unless {archived:true})
|
|
22
|
+
// session.set-model {sessionId,model?,effort?} → {sessionId,model,effort} (next turn; refused mid-turn)
|
|
16
23
|
// Server → client notifications (all carry sessionId):
|
|
17
24
|
// event.text / event.reasoning {delta} · event.tool {name,preview} · event.diff {text}
|
|
18
25
|
// event.notice {text} · event.turn_end {reply,usage,error?} · approval.request {approvalId,question}
|
package/dist/serve/server.js
CHANGED
|
@@ -11,9 +11,11 @@ import { join } from "node:path";
|
|
|
11
11
|
import "../tools/all.js"; // register the full built-in toolset — serve must work as a standalone entry
|
|
12
12
|
import { runAgent } from "../agent/loop.js";
|
|
13
13
|
import { loadAgentsMd } from "../context/agents-md.js";
|
|
14
|
+
import { expandMentions } from "../context/mentions.js";
|
|
14
15
|
import { memoryDigest } from "../memory/store.js";
|
|
15
16
|
import { listInstalled, enabledPlugins, setPluginEnabled } from "../plugins/plugins.js";
|
|
16
17
|
import { loadSkillIndex } from "../skills/skills.js";
|
|
18
|
+
import { loadJobs } from "../cron/store.js";
|
|
17
19
|
import { SessionHub, realStore } from "./sessions.js";
|
|
18
20
|
import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } from "./protocol.js";
|
|
19
21
|
const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
|
|
@@ -94,7 +96,8 @@ export async function startServe(opts, deps) {
|
|
|
94
96
|
broadcast("approval.request", { sessionId, approvalId, question: q });
|
|
95
97
|
});
|
|
96
98
|
try {
|
|
97
|
-
|
|
99
|
+
// @file mentions expand to file contents, same as the CLI (`@src/foo.ts` in the composer works)
|
|
100
|
+
s.history.push({ role: "user", content: expandMentions(text, s.meta.cwd) });
|
|
98
101
|
await runAgent(s.history, {
|
|
99
102
|
provider: s.provider,
|
|
100
103
|
ctx: {
|
|
@@ -137,13 +140,20 @@ export async function startServe(opts, deps) {
|
|
|
137
140
|
if (typeof p.token !== "string" || !sameToken(p.token, token))
|
|
138
141
|
return reply(rpcError(id, ERR.UNAUTHORIZED, "bad token"));
|
|
139
142
|
authed.add(ws);
|
|
140
|
-
|
|
143
|
+
// capability negotiation (codex app-server pattern): the server ADVERTISES its method set so
|
|
144
|
+
// clients feature-detect up front instead of probing for -32601 per call. `p.capabilities`
|
|
145
|
+
// (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
|
|
146
|
+
const methods = [
|
|
147
|
+
"session.list", "session.create", "session.resume", "session.send", "session.interrupt", "session.set-model",
|
|
148
|
+
"approval.reply", "plugins.list", "plugins.set", "skills.list", "automation.list", "models.list",
|
|
149
|
+
];
|
|
150
|
+
return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: deps.providerId, model: deps.model, capabilities: { methods } }));
|
|
141
151
|
}
|
|
142
152
|
if (!authed.has(ws))
|
|
143
153
|
return reply(rpcError(id, ERR.UNAUTHORIZED, "initialize first"));
|
|
144
154
|
switch (req.method) {
|
|
145
155
|
case "session.list":
|
|
146
|
-
return reply(rpcResult(id, { sessions: hub.list(typeof p.cwd === "string" ? p.cwd : undefined).map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, model: m.model, updatedAt: m.updatedAt })) }));
|
|
156
|
+
return reply(rpcResult(id, { sessions: hub.list(typeof p.cwd === "string" ? p.cwd : undefined).filter((m) => !m.archived || p.archived === true).map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, model: m.model, updatedAt: m.updatedAt, source: m.source ?? "interactive", sourceName: m.sourceName, archived: m.archived ?? false })) }));
|
|
147
157
|
case "session.create": {
|
|
148
158
|
const provider = await deps.buildSessionProvider();
|
|
149
159
|
if (!provider)
|
|
@@ -195,7 +205,7 @@ export async function startServe(opts, deps) {
|
|
|
195
205
|
}
|
|
196
206
|
case "plugins.list": {
|
|
197
207
|
const on = new Set(enabledPlugins().map((pl) => pl.name));
|
|
198
|
-
return reply(rpcResult(id, { plugins: listInstalled().map((pl) => ({ name: pl.name, version: pl.version, description: pl.manifest.description ?? "", enabled: on.has(pl.name), skills: (pl.manifest.skills ?? []).length, agents: (pl.manifest.agents ?? []).length, mcpServers: Object.keys(pl.manifest.mcpServers ?? {}).length })) }));
|
|
208
|
+
return reply(rpcResult(id, { plugins: listInstalled().map((pl) => ({ name: pl.name, version: pl.version, description: pl.manifest.description ?? "", enabled: on.has(pl.name), skills: (pl.manifest.skills ?? []).length, agents: (pl.manifest.agents ?? []).length, mcpServers: Object.keys(pl.manifest.mcpServers ?? {}).length, panels: pl.manifest.panels ?? [] })) }));
|
|
199
209
|
}
|
|
200
210
|
case "plugins.set": {
|
|
201
211
|
if (typeof p.name !== "string" || typeof p.enabled !== "boolean")
|
|
@@ -205,6 +215,54 @@ export async function startServe(opts, deps) {
|
|
|
205
215
|
setPluginEnabled(p.name, p.enabled);
|
|
206
216
|
return reply(rpcResult(id, { name: p.name, enabled: p.enabled })); // takes effect on the next session/turn (loaders re-read)
|
|
207
217
|
}
|
|
218
|
+
case "session.rename": {
|
|
219
|
+
if (typeof p.sessionId !== "string" || typeof p.title !== "string")
|
|
220
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId + title required"));
|
|
221
|
+
if (!hub.rename(p.sessionId, p.title.slice(0, 120)))
|
|
222
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
223
|
+
return reply(rpcResult(id, { sessionId: p.sessionId, title: p.title.slice(0, 120) }));
|
|
224
|
+
}
|
|
225
|
+
case "session.archive": {
|
|
226
|
+
if (typeof p.sessionId !== "string" || typeof p.archived !== "boolean")
|
|
227
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId + archived required"));
|
|
228
|
+
if (!hub.setArchived(p.sessionId, p.archived))
|
|
229
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
|
|
230
|
+
return reply(rpcResult(id, { sessionId: p.sessionId, archived: p.archived }));
|
|
231
|
+
}
|
|
232
|
+
case "models.list": {
|
|
233
|
+
const models = deps.listModels ? await deps.listModels().catch(() => []) : [];
|
|
234
|
+
return reply(rpcResult(id, { models, current: deps.model, effortLevels: deps.effortLevels ?? [] }));
|
|
235
|
+
}
|
|
236
|
+
case "session.set-model": {
|
|
237
|
+
// per-session model / thinking-effort switch (the composer picker). Rebuilds the session's
|
|
238
|
+
// provider; takes effect on the NEXT turn. Refused mid-turn.
|
|
239
|
+
if (typeof p.sessionId !== "string")
|
|
240
|
+
return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
|
|
241
|
+
const s = hub.get(p.sessionId);
|
|
242
|
+
if (!s)
|
|
243
|
+
return reply(rpcError(id, ERR.NO_SESSION, `no live session ${p.sessionId}`));
|
|
244
|
+
if (s.busy)
|
|
245
|
+
return reply(rpcError(id, ERR.BUSY, "a turn is running — switch after it finishes"));
|
|
246
|
+
const model = typeof p.model === "string" && p.model ? p.model : s.meta.model;
|
|
247
|
+
const effort = typeof p.effort === "string" && p.effort ? p.effort : undefined;
|
|
248
|
+
if (!deps.buildProviderFor)
|
|
249
|
+
return reply(rpcError(id, ERR.METHOD, "model switching not supported by this server"));
|
|
250
|
+
const provider = await deps.buildProviderFor(model, effort);
|
|
251
|
+
if (!provider)
|
|
252
|
+
return reply(rpcError(id, ERR.INTERNAL, `could not build provider for ${model}`));
|
|
253
|
+
s.provider = provider;
|
|
254
|
+
s.meta.model = model;
|
|
255
|
+
s.effort = effort;
|
|
256
|
+
return reply(rpcResult(id, { sessionId: s.meta.id, model, effort: effort ?? null }));
|
|
257
|
+
}
|
|
258
|
+
case "automation.list": {
|
|
259
|
+
// The automation timeline's data: cron jobs with their last outcome, plus this machine's
|
|
260
|
+
// automated sessions (source=cron/gateway) so the desktop can render results and "continue
|
|
261
|
+
// as conversation". Read-only.
|
|
262
|
+
const jobs = loadJobs().map((j) => ({ id: j.id, name: j.name, mode: j.mode, cwd: j.cwd, enabled: j.enabled, deliver: j.deliver, lastRunAt: j.lastRunAt, lastStatus: j.lastStatus, lastError: j.lastError }));
|
|
263
|
+
const automated = hub.list().filter((m) => m.source === "cron" || m.source === "gateway").map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, source: m.source, sourceName: m.sourceName, updatedAt: m.updatedAt }));
|
|
264
|
+
return reply(rpcResult(id, { jobs, sessions: automated }));
|
|
265
|
+
}
|
|
208
266
|
case "skills.list": {
|
|
209
267
|
const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
|
|
210
268
|
return reply(rpcResult(id, { skills: loadSkillIndex(cwd).map((s) => ({ id: s.id, description: s.description, source: s.source })) }));
|
package/dist/serve/sessions.js
CHANGED
|
@@ -22,6 +22,7 @@ export class SessionHub {
|
|
|
22
22
|
title: "",
|
|
23
23
|
createdAt: new Date().toISOString(),
|
|
24
24
|
updatedAt: "",
|
|
25
|
+
source: "interactive", // serve sessions are user-driven (desktop/IDE clients)
|
|
25
26
|
};
|
|
26
27
|
this.store.acquire(meta.id); // fresh id — always ours; registers the single-writer claim
|
|
27
28
|
const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
|
|
@@ -58,6 +59,36 @@ export class SessionHub {
|
|
|
58
59
|
}
|
|
59
60
|
this.store.save(s.meta, s.history);
|
|
60
61
|
}
|
|
62
|
+
/** Rename a session (live or on-disk). Returns false when the id is unknown. */
|
|
63
|
+
rename(id, title) {
|
|
64
|
+
const live = this.sessions.get(id);
|
|
65
|
+
if (live) {
|
|
66
|
+
live.meta.title = title;
|
|
67
|
+
this.store.save(live.meta, live.history);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
const prior = this.store.load(id);
|
|
71
|
+
if (!prior)
|
|
72
|
+
return false;
|
|
73
|
+
prior.meta.title = title;
|
|
74
|
+
this.store.save(prior.meta, prior.history);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
/** Archive/unarchive (hidden from lists, kept on disk). Returns false when unknown. */
|
|
78
|
+
setArchived(id, on) {
|
|
79
|
+
const live = this.sessions.get(id);
|
|
80
|
+
if (live) {
|
|
81
|
+
live.meta.archived = on;
|
|
82
|
+
this.store.save(live.meta, live.history);
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
const prior = this.store.load(id);
|
|
86
|
+
if (!prior)
|
|
87
|
+
return false;
|
|
88
|
+
prior.meta.archived = on;
|
|
89
|
+
this.store.save(prior.meta, prior.history);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
61
92
|
/** Release all locks (server shutdown). In-flight turns are aborted by the caller first. */
|
|
62
93
|
releaseAll() {
|
|
63
94
|
for (const id of this.sessions.keys())
|
package/dist/session/store.js
CHANGED
|
@@ -3,6 +3,21 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
|
+
/** Derive the session source from the spawn environment — the gateway subprocess runs with
|
|
7
|
+
* HARA_GATEWAY=<platform>, the cron runner with HARA_CRON=1 (+ HARA_CRON_NAME=<job name>). */
|
|
8
|
+
export function sessionSourceFromEnv() {
|
|
9
|
+
if (process.env.HARA_CRON)
|
|
10
|
+
return { source: "cron", sourceName: process.env.HARA_CRON_NAME || undefined };
|
|
11
|
+
if (process.env.HARA_GATEWAY)
|
|
12
|
+
return { source: "gateway", sourceName: process.env.HARA_GATEWAY };
|
|
13
|
+
return { source: "interactive" };
|
|
14
|
+
}
|
|
15
|
+
/** Title for a NON-interactive session: "name · MM-DD HH:mm" — the raw prompt never becomes a title. */
|
|
16
|
+
export function automatedTitle(source, sourceName, at = new Date()) {
|
|
17
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
18
|
+
const stamp = `${pad(at.getMonth() + 1)}-${pad(at.getDate())} ${pad(at.getHours())}:${pad(at.getMinutes())}`;
|
|
19
|
+
return `${sourceName || source} · ${stamp}`;
|
|
20
|
+
}
|
|
6
21
|
function sessionsDir() {
|
|
7
22
|
const d = join(homedir(), ".hara", "sessions");
|
|
8
23
|
mkdirSync(d, { recursive: true });
|