@nanhara/hara 0.89.0 → 0.98.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 +74 -0
- package/README.md +4 -1
- package/dist/agent/loop.js +113 -8
- package/dist/config.js +51 -9
- package/dist/gateway/serve.js +58 -1
- package/dist/gateway/signal.js +220 -0
- package/dist/gateway/tmux-routes.js +135 -0
- package/dist/gateway/wecom.js +383 -0
- package/dist/index.js +1021 -82
- package/dist/org-fleet/enroll.js +28 -5
- package/dist/plugins/plugins.js +49 -1
- package/dist/profile/profile.js +436 -0
- package/dist/providers/anthropic.js +28 -1
- package/dist/providers/openai.js +16 -1
- package/dist/security/guardian.js +261 -0
- package/dist/session/session-model.js +36 -0
- package/dist/statusbar.js +12 -3
- package/dist/tools/ask_user.js +64 -0
- package/dist/tools/external_agent.js +118 -0
- package/dist/tools/skill.js +6 -2
- package/dist/tools/todo.js +65 -8
- package/dist/tui/App.js +280 -33
- package/dist/tui/run.js +36 -1
- package/package.json +1 -1
package/dist/org-fleet/enroll.js
CHANGED
|
@@ -13,6 +13,7 @@ import { homedir, hostname, platform } from "node:os";
|
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
|
|
15
15
|
import { orgRolesDir } from "../org/roles.js";
|
|
16
|
+
import { loadActiveProfile } from "../profile/profile.js";
|
|
16
17
|
const orgPath = () => join(homedir(), ".hara", "org.json");
|
|
17
18
|
const deviceInfo = () => ({ name: hostname(), os: platform(), hara_version: process.env.HARA_BUILD_VERSION ?? "dev" });
|
|
18
19
|
/** The effective OpenAI-compatible base URL for an enrollment (explicit, else <gatewayUrl>/v1). */
|
|
@@ -20,16 +21,38 @@ export function gatewayBaseURL(e) {
|
|
|
20
21
|
return e.baseURL || `${e.gatewayUrl.replace(/\/$/, "")}/v1`;
|
|
21
22
|
}
|
|
22
23
|
export function loadEnrollment() {
|
|
24
|
+
// 1) Legacy storage (~/.hara/org.json) for back-compat with pre-profile builds. After the
|
|
25
|
+
// profile migration runs (lazily on any profile.ts read), org.json is renamed to .legacy
|
|
26
|
+
// so this branch only fires for users who never touched the new profile layer yet.
|
|
23
27
|
const p = orgPath();
|
|
24
|
-
if (
|
|
25
|
-
|
|
28
|
+
if (existsSync(p)) {
|
|
29
|
+
try {
|
|
30
|
+
const e = JSON.parse(readFileSync(p, "utf8"));
|
|
31
|
+
if (e && typeof e === "object" && e.gatewayUrl && e.deviceToken)
|
|
32
|
+
return e;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* fall through to profile-derived */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// 2) Active-profile path. profile.ts doesn't import enroll.ts so this static import is safe.
|
|
26
39
|
try {
|
|
27
|
-
const
|
|
28
|
-
|
|
40
|
+
const ap = loadActiveProfile();
|
|
41
|
+
if (ap.kind === "gateway" && ap.gatewayUrl && ap.deviceToken) {
|
|
42
|
+
return {
|
|
43
|
+
gatewayUrl: ap.gatewayUrl,
|
|
44
|
+
deviceToken: ap.deviceToken,
|
|
45
|
+
deviceId: ap.deviceId || "",
|
|
46
|
+
model: ap.defaultModel || "",
|
|
47
|
+
baseURL: ap.baseURL,
|
|
48
|
+
enrolledAt: ap.enrolledAt || new Date().toISOString(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
29
51
|
}
|
|
30
52
|
catch {
|
|
31
|
-
|
|
53
|
+
/* not yet migrated */
|
|
32
54
|
}
|
|
55
|
+
return null;
|
|
33
56
|
}
|
|
34
57
|
function saveEnrollment(e) {
|
|
35
58
|
mkdirSync(join(homedir(), ".hara"), { recursive: true });
|
package/dist/plugins/plugins.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// runtime. The existing loaders pick the contents up (skillsDirs/loadRoles append the resolvers below;
|
|
3
3
|
// index.ts merges pluginMcpServers into the MCP set). Manifest is Claude-Code-compatible: we read
|
|
4
4
|
// .claude-plugin/plugin.json, .hara-plugin/plugin.json, or a bare plugin.json at the plugin root.
|
|
5
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync } from "node:fs";
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync, symlinkSync, chmodSync } from "node:fs";
|
|
6
6
|
import { join, resolve, isAbsolute } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { execFileSync } from "node:child_process";
|
|
@@ -10,6 +10,52 @@ import { readRawConfig } from "../config.js";
|
|
|
10
10
|
export function pluginsDir() {
|
|
11
11
|
return join(homedir(), ".hara", "plugins");
|
|
12
12
|
}
|
|
13
|
+
/** Where plugin-contributed CLI commands are symlinked. Add to PATH to use them (e.g. `export PATH="$HOME/.hara/bin:$PATH"`). */
|
|
14
|
+
export function haraBinDir() {
|
|
15
|
+
return join(homedir(), ".hara", "bin");
|
|
16
|
+
}
|
|
17
|
+
/** Symlink a plugin's `bin` entries into ~/.hara/bin (chmod +x the targets). Returns the command names linked. */
|
|
18
|
+
function linkPluginBins(root, manifest) {
|
|
19
|
+
if (!manifest.bin)
|
|
20
|
+
return [];
|
|
21
|
+
const dir = haraBinDir();
|
|
22
|
+
mkdirSync(dir, { recursive: true });
|
|
23
|
+
const linked = [];
|
|
24
|
+
for (const [name, rel] of Object.entries(manifest.bin)) {
|
|
25
|
+
const target = join(root, rel);
|
|
26
|
+
if (!existsSync(target))
|
|
27
|
+
continue;
|
|
28
|
+
try {
|
|
29
|
+
chmodSync(target, 0o755);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* best-effort */
|
|
33
|
+
}
|
|
34
|
+
const link = join(dir, name);
|
|
35
|
+
try {
|
|
36
|
+
rmSync(link, { force: true });
|
|
37
|
+
symlinkSync(target, link);
|
|
38
|
+
linked.push(name);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* skip a bin we can't link */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return linked;
|
|
45
|
+
}
|
|
46
|
+
/** Remove a plugin's linked bins (on uninstall). */
|
|
47
|
+
function unlinkPluginBins(manifest) {
|
|
48
|
+
if (!manifest?.bin)
|
|
49
|
+
return;
|
|
50
|
+
for (const name of Object.keys(manifest.bin)) {
|
|
51
|
+
try {
|
|
52
|
+
rmSync(join(haraBinDir(), name), { force: true });
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* ignore */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
13
59
|
const MANIFEST_PATHS = [".claude-plugin/plugin.json", ".hara-plugin/plugin.json", "plugin.json"];
|
|
14
60
|
function readManifest(root) {
|
|
15
61
|
for (const rel of MANIFEST_PATHS) {
|
|
@@ -112,6 +158,7 @@ export function installPlugin(source) {
|
|
|
112
158
|
// move tmp → dest (rename within the same dir)
|
|
113
159
|
cpSync(tmp, dest, { recursive: true });
|
|
114
160
|
rmSync(tmp, { recursive: true, force: true });
|
|
161
|
+
linkPluginBins(dest, manifest); // expose any plugin CLI commands in ~/.hara/bin
|
|
115
162
|
return { name: manifest.name, version: manifest.version || "0.0.0", root: dest, manifest };
|
|
116
163
|
}
|
|
117
164
|
catch (e) {
|
|
@@ -123,6 +170,7 @@ export function uninstallPlugin(name) {
|
|
|
123
170
|
const dest = join(pluginsDir(), name);
|
|
124
171
|
if (!existsSync(dest))
|
|
125
172
|
return false;
|
|
173
|
+
unlinkPluginBins(readManifest(dest)); // remove any linked CLI commands first
|
|
126
174
|
rmSync(dest, { recursive: true, force: true });
|
|
127
175
|
return true;
|
|
128
176
|
}
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// Profile = identity layer for hara (Personal ↔ Org A ↔ Org B). Switching a profile
|
|
3
|
+
// connects through to *every* downstream decision: provider (BYOK direct vs gateway),
|
|
4
|
+
// API key / device token, base URL, **default model** the gateway / setup chose, and
|
|
5
|
+
// the user's model override within that profile. Plus presentation: a `kind` badge
|
|
6
|
+
// ("ORG" vs "PERSONAL"), a label, a routing display.
|
|
7
|
+
//
|
|
8
|
+
// Single source of truth at runtime. `~/.hara/profiles.json` (0600) stores the *list*
|
|
9
|
+
// of profiles + which one is `active`. The legacy `~/.hara/config.json` keeps acting
|
|
10
|
+
// as the storage for the "personal" profile so existing users with only a config.json
|
|
11
|
+
// don't have to migrate anything (their config.json IS their personal profile).
|
|
12
|
+
//
|
|
13
|
+
// Migration (run lazily on first read):
|
|
14
|
+
// • config.json exists, no profiles.json → personal profile is config.json itself,
|
|
15
|
+
// profiles.json is created with active=personal.
|
|
16
|
+
// • org.json exists (legacy enrolled) → injected as a `default-org` gateway profile,
|
|
17
|
+
// active is set to it (the user IS using a gateway
|
|
18
|
+
// right now — preserve that), org.json renamed
|
|
19
|
+
// `.legacy` so we never re-migrate.
|
|
20
|
+
// • Both exist → both become profiles; active = default-org (the gateway, since that's the
|
|
21
|
+
// live routing today).
|
|
22
|
+
//
|
|
23
|
+
// Idempotent: re-running the migration after it's done is a no-op.
|
|
24
|
+
//
|
|
25
|
+
// Provider resolution (in src/index.ts buildProvider):
|
|
26
|
+
// profile.kind === 'gateway' → OpenAI-compatible w/ deviceToken + (baseURL || gatewayUrl+'/v1')
|
|
27
|
+
// profile.kind === 'byok' → existing anthropic / qwen / openai / qwen-oauth dispatch
|
|
28
|
+
//
|
|
29
|
+
// The `hara-gateway` ProviderId enum value is retired from new writes — buildProvider still
|
|
30
|
+
// tolerates reading it from a legacy config.json (it just maps to the migrated gateway profile).
|
|
31
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { join, dirname, parse as parsePath, resolve as resolvePath } from "node:path";
|
|
34
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
|
|
35
|
+
const PERSONAL_ID = "personal";
|
|
36
|
+
const DEFAULT_ORG_ID = "default-org";
|
|
37
|
+
function haraDir() {
|
|
38
|
+
return join(homedir(), ".hara");
|
|
39
|
+
}
|
|
40
|
+
function profilesPath() {
|
|
41
|
+
return join(haraDir(), "profiles.json");
|
|
42
|
+
}
|
|
43
|
+
function configPath() {
|
|
44
|
+
return join(haraDir(), "config.json");
|
|
45
|
+
}
|
|
46
|
+
function orgPath() {
|
|
47
|
+
return join(haraDir(), "org.json");
|
|
48
|
+
}
|
|
49
|
+
function readJSON(p) {
|
|
50
|
+
if (!existsSync(p))
|
|
51
|
+
return null;
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Write the profiles file 0600 (it can hold device tokens / api keys). */
|
|
60
|
+
function persistProfilesFile(f) {
|
|
61
|
+
const p = profilesPath();
|
|
62
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
63
|
+
writeFileSync(p, JSON.stringify(f, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
64
|
+
try {
|
|
65
|
+
chmodSync(p, 0o600);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* best-effort */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Synthesize the "personal" profile view from the legacy config.json. The config.json itself
|
|
72
|
+
* stays the *storage* — this just presents it as a Profile object. */
|
|
73
|
+
function readPersonalFromConfig() {
|
|
74
|
+
const cfg = readJSON(configPath()) ?? {};
|
|
75
|
+
// A legacy user that ran `hara enroll` had their provider written as "hara-gateway" in config.json.
|
|
76
|
+
// After migration that case is handled separately (default-org profile), so when synthesizing the
|
|
77
|
+
// personal profile we coerce a stray "hara-gateway" provider to anthropic (the BYOK default) — the
|
|
78
|
+
// user can always fix it with `hara setup`.
|
|
79
|
+
const rawProvider = cfg.provider;
|
|
80
|
+
const provider = rawProvider && rawProvider !== "hara-gateway" ? rawProvider : "anthropic";
|
|
81
|
+
return {
|
|
82
|
+
id: PERSONAL_ID,
|
|
83
|
+
kind: "byok",
|
|
84
|
+
label: "Personal",
|
|
85
|
+
provider,
|
|
86
|
+
apiKey: cfg.apiKey,
|
|
87
|
+
baseURL: cfg.baseURL,
|
|
88
|
+
defaultModel: cfg.model,
|
|
89
|
+
// No per-profile override yet for the personal slot — `model` (override) and `defaultModel`
|
|
90
|
+
// come from the same field in config.json. `hara model use X` writes `model` to config.json,
|
|
91
|
+
// `hara model reset` clears it. Conceptually one slot, but the rest of the codebase only ever
|
|
92
|
+
// reads "effective model" so this is fine.
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
/** Synthesize a `default-org` profile from the legacy org.json (Enrollment). */
|
|
96
|
+
function readDefaultOrgFromOrgJson() {
|
|
97
|
+
const e = readJSON(orgPath());
|
|
98
|
+
if (!e || !e.gatewayUrl || !e.deviceToken)
|
|
99
|
+
return null;
|
|
100
|
+
const defaultModel = e.model || "";
|
|
101
|
+
return {
|
|
102
|
+
id: DEFAULT_ORG_ID,
|
|
103
|
+
kind: "gateway",
|
|
104
|
+
label: "Default Org",
|
|
105
|
+
gatewayUrl: e.gatewayUrl,
|
|
106
|
+
deviceId: e.deviceId || "",
|
|
107
|
+
deviceToken: e.deviceToken,
|
|
108
|
+
baseURL: e.baseURL,
|
|
109
|
+
defaultModel,
|
|
110
|
+
availableModels: defaultModel ? [defaultModel] : [],
|
|
111
|
+
enrolledAt: e.enrolledAt || new Date().toISOString(),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** First-time migration. Idempotent — running again is a no-op (profiles.json already present). */
|
|
115
|
+
function maybeMigrate() {
|
|
116
|
+
const existing = readJSON(profilesPath());
|
|
117
|
+
if (existing && Array.isArray(existing.profiles) && existing.profiles.length > 0)
|
|
118
|
+
return existing;
|
|
119
|
+
const personal = readPersonalFromConfig();
|
|
120
|
+
const org = readDefaultOrgFromOrgJson();
|
|
121
|
+
const profiles = [personal];
|
|
122
|
+
let active = PERSONAL_ID;
|
|
123
|
+
if (org) {
|
|
124
|
+
profiles.push(org);
|
|
125
|
+
active = DEFAULT_ORG_ID; // legacy enrolled user IS using the gateway right now — preserve
|
|
126
|
+
}
|
|
127
|
+
const f = { active, profiles };
|
|
128
|
+
persistProfilesFile(f);
|
|
129
|
+
// Park org.json so we never re-migrate. We keep the file (don't delete data), just rename.
|
|
130
|
+
if (org && existsSync(orgPath())) {
|
|
131
|
+
try {
|
|
132
|
+
renameSync(orgPath(), orgPath() + ".legacy");
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* best-effort */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return f;
|
|
139
|
+
}
|
|
140
|
+
export function listProfiles() {
|
|
141
|
+
return maybeMigrate().profiles;
|
|
142
|
+
}
|
|
143
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
144
|
+
// `.hara-profile` project pin — like .nvmrc, but personal identity rather than
|
|
145
|
+
// runtime version, so we keep it out of repos by default (the printed hint nudges
|
|
146
|
+
// the user to add it to their *global* gitignore — see `profile pin`). Lookup is
|
|
147
|
+
// "walk up from startDir until we hit a `.hara-profile`, fs root, or home". The
|
|
148
|
+
// walk stops at $HOME to prevent a stray ~/.hara-profile from silently overriding
|
|
149
|
+
// the global default (~/.hara/profiles.json `active`).
|
|
150
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
151
|
+
const PIN_FILE = ".hara-profile";
|
|
152
|
+
export function pinFilePath(dir) {
|
|
153
|
+
return join(dir, PIN_FILE);
|
|
154
|
+
}
|
|
155
|
+
/** Walk up from `startDir` looking for `.hara-profile`; return the first hit whose
|
|
156
|
+
* contents name a real profile. Returns `{ id, file }` (absolute file path) or null.
|
|
157
|
+
* If a pin file exists but names an unknown profile, we emit a one-line stderr warn
|
|
158
|
+
* and return null (non-fatal — the active resolution falls through to the next layer). */
|
|
159
|
+
export function findPinnedProfile(startDir) {
|
|
160
|
+
const home = homedir();
|
|
161
|
+
const { root } = parsePath(startDir);
|
|
162
|
+
let dir = resolvePath(startDir);
|
|
163
|
+
// Track visited to defend against pathological symlink loops (best-effort).
|
|
164
|
+
const seen = new Set();
|
|
165
|
+
while (!seen.has(dir)) {
|
|
166
|
+
seen.add(dir);
|
|
167
|
+
const file = pinFilePath(dir);
|
|
168
|
+
if (existsSync(file)) {
|
|
169
|
+
try {
|
|
170
|
+
const id = readFileSync(file, "utf8").split(/\r?\n/)[0].trim();
|
|
171
|
+
if (id && getProfile(id))
|
|
172
|
+
return { id, file };
|
|
173
|
+
if (id) {
|
|
174
|
+
// pin points to a profile that no longer exists — warn once, fall through.
|
|
175
|
+
try {
|
|
176
|
+
process.stderr.write(`hara: ${file} pins profile '${id}', but it doesn't exist — falling back. Run \`hara profile pin <id>\` or \`hara profile unpin\` to fix.\n`);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
/* ignore */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* unreadable pin — skip */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Stop walking once we're at $HOME or the fs root (don't escape into shared parent dirs).
|
|
188
|
+
if (dir === home || dir === root)
|
|
189
|
+
return null;
|
|
190
|
+
const parent = dirname(dir);
|
|
191
|
+
if (!parent || parent === dir)
|
|
192
|
+
return null;
|
|
193
|
+
dir = parent;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
/** Write `.hara-profile` in the given dir with `id` as the only line. */
|
|
198
|
+
export function writePin(dir, id) {
|
|
199
|
+
if (!getProfile(id))
|
|
200
|
+
throw new Error(`no profile '${id}' — list with \`hara profile list\``);
|
|
201
|
+
const file = pinFilePath(dir);
|
|
202
|
+
writeFileSync(file, id + "\n", "utf8");
|
|
203
|
+
return { file };
|
|
204
|
+
}
|
|
205
|
+
/** Remove `.hara-profile` from the given dir. Returns true if it was there. */
|
|
206
|
+
export function removePin(dir) {
|
|
207
|
+
const file = pinFilePath(dir);
|
|
208
|
+
if (!existsSync(file))
|
|
209
|
+
return false;
|
|
210
|
+
try {
|
|
211
|
+
unlinkSync(file);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
let _flagProfile = null;
|
|
219
|
+
/** Set by the top-level `--profile <id>` flag handler. Cleared between processes
|
|
220
|
+
* (we never persist this — it's a one-shot override). Pass null to clear. */
|
|
221
|
+
export function setFlagOverride(id) {
|
|
222
|
+
_flagProfile = id && id.trim() ? id.trim() : null;
|
|
223
|
+
}
|
|
224
|
+
export function getFlagOverride() {
|
|
225
|
+
return _flagProfile;
|
|
226
|
+
}
|
|
227
|
+
export function resolveActive(cwd = process.cwd()) {
|
|
228
|
+
// 1. CLI flag (one-shot).
|
|
229
|
+
if (_flagProfile && getProfile(_flagProfile))
|
|
230
|
+
return { id: _flagProfile, source: "flag" };
|
|
231
|
+
// 2. env (also one-shot — scripts / cron).
|
|
232
|
+
const env = process.env.HARA_PROFILE;
|
|
233
|
+
if (env && getProfile(env))
|
|
234
|
+
return { id: env, source: "env" };
|
|
235
|
+
// 3. project pin — walk up from cwd to home/root.
|
|
236
|
+
const pin = findPinnedProfile(cwd);
|
|
237
|
+
if (pin)
|
|
238
|
+
return { id: pin.id, source: "pin", pinFile: pin.file };
|
|
239
|
+
// 4. global default.
|
|
240
|
+
const f = maybeMigrate();
|
|
241
|
+
if (f.active && getProfile(f.active))
|
|
242
|
+
return { id: f.active, source: "default" };
|
|
243
|
+
// 5. ultimate fallback — personal always exists (migration guarantees it).
|
|
244
|
+
return { id: PERSONAL_ID, source: "fallback" };
|
|
245
|
+
}
|
|
246
|
+
/** Thin wrapper for the (many) call sites that just need "which profile am I as". */
|
|
247
|
+
export function activeId() {
|
|
248
|
+
return resolveActive().id;
|
|
249
|
+
}
|
|
250
|
+
export function getProfile(id) {
|
|
251
|
+
return maybeMigrate().profiles.find((p) => p.id === id);
|
|
252
|
+
}
|
|
253
|
+
/** The effective, runtime view of the active profile. For id==='personal' we re-read from
|
|
254
|
+
* config.json on each call so external edits (config set / setup) are picked up live. */
|
|
255
|
+
export function loadActiveProfile() {
|
|
256
|
+
const id = activeId();
|
|
257
|
+
const f = maybeMigrate();
|
|
258
|
+
if (id === PERSONAL_ID) {
|
|
259
|
+
// Always re-sync personal from config.json (the storage of record). Other profile fields in
|
|
260
|
+
// profiles.json for "personal" are presentation only (label).
|
|
261
|
+
const p = readPersonalFromConfig();
|
|
262
|
+
const stored = f.profiles.find((x) => x.id === PERSONAL_ID);
|
|
263
|
+
if (stored?.label)
|
|
264
|
+
p.label = stored.label;
|
|
265
|
+
return p;
|
|
266
|
+
}
|
|
267
|
+
const p = f.profiles.find((x) => x.id === id);
|
|
268
|
+
if (p)
|
|
269
|
+
return p;
|
|
270
|
+
// Active points to nothing → degrade to personal silently and persist.
|
|
271
|
+
const personal = readPersonalFromConfig();
|
|
272
|
+
return personal;
|
|
273
|
+
}
|
|
274
|
+
export function useProfile(id) {
|
|
275
|
+
const f = maybeMigrate();
|
|
276
|
+
const p = f.profiles.find((x) => x.id === id);
|
|
277
|
+
if (!p)
|
|
278
|
+
return { ok: false, reason: `no profile '${id}' — try \`hara profile list\`` };
|
|
279
|
+
f.active = id;
|
|
280
|
+
persistProfilesFile(f);
|
|
281
|
+
return { ok: true, profile: p };
|
|
282
|
+
}
|
|
283
|
+
export function addProfile(p) {
|
|
284
|
+
if (!p.id || /[\s/]/.test(p.id))
|
|
285
|
+
return { ok: false, reason: "profile id must be non-empty and contain no whitespace or '/'" };
|
|
286
|
+
const f = maybeMigrate();
|
|
287
|
+
if (f.profiles.some((x) => x.id === p.id))
|
|
288
|
+
return { ok: false, reason: `profile '${p.id}' already exists` };
|
|
289
|
+
if (p.kind === "gateway" && (!p.gatewayUrl || !p.deviceToken))
|
|
290
|
+
return { ok: false, reason: "gateway profile needs gatewayUrl + deviceToken" };
|
|
291
|
+
if (p.kind === "byok" && !p.provider)
|
|
292
|
+
return { ok: false, reason: "byok profile needs a provider" };
|
|
293
|
+
f.profiles.push(p);
|
|
294
|
+
persistProfilesFile(f);
|
|
295
|
+
return { ok: true };
|
|
296
|
+
}
|
|
297
|
+
/** Replace an existing profile (same id) — used by `hara enroll <url> --code` when the
|
|
298
|
+
* default-org profile already exists (re-enrollment / token rotation). */
|
|
299
|
+
export function upsertProfile(p) {
|
|
300
|
+
const f = maybeMigrate();
|
|
301
|
+
const i = f.profiles.findIndex((x) => x.id === p.id);
|
|
302
|
+
if (i >= 0)
|
|
303
|
+
f.profiles[i] = p;
|
|
304
|
+
else
|
|
305
|
+
f.profiles.push(p);
|
|
306
|
+
persistProfilesFile(f);
|
|
307
|
+
}
|
|
308
|
+
export function removeProfile(id) {
|
|
309
|
+
if (id === PERSONAL_ID)
|
|
310
|
+
return { ok: false, reason: "personal is your base profile — switch away with `hara profile use <other>`; it stays." };
|
|
311
|
+
const f = maybeMigrate();
|
|
312
|
+
const i = f.profiles.findIndex((x) => x.id === id);
|
|
313
|
+
if (i < 0)
|
|
314
|
+
return { ok: false, reason: `no profile '${id}' — list with \`hara profile list\`` };
|
|
315
|
+
const removed = f.profiles[i];
|
|
316
|
+
f.profiles.splice(i, 1);
|
|
317
|
+
let activeChanged = false;
|
|
318
|
+
if (f.active === id) {
|
|
319
|
+
f.active = PERSONAL_ID;
|
|
320
|
+
activeChanged = true;
|
|
321
|
+
}
|
|
322
|
+
persistProfilesFile(f);
|
|
323
|
+
return { ok: true, activeChanged, removedKind: removed.kind, removed };
|
|
324
|
+
}
|
|
325
|
+
/** Override the effective model within a profile. For "personal" this writes to config.json
|
|
326
|
+
* (the storage of record). For others it writes to profiles.json. P0: when availableModels
|
|
327
|
+
* is non-empty on a gateway profile we validate the choice is in the set. */
|
|
328
|
+
export function setModel(id, model) {
|
|
329
|
+
if (id === PERSONAL_ID) {
|
|
330
|
+
// delegate to config.ts so this stays single-storage for personal
|
|
331
|
+
return setModelOnPersonal(model);
|
|
332
|
+
}
|
|
333
|
+
const f = maybeMigrate();
|
|
334
|
+
const i = f.profiles.findIndex((x) => x.id === id);
|
|
335
|
+
if (i < 0)
|
|
336
|
+
return { ok: false, reason: `no profile '${id}'` };
|
|
337
|
+
const p = f.profiles[i];
|
|
338
|
+
if (p.kind === "gateway" && p.availableModels && p.availableModels.length > 0 && !p.availableModels.includes(model)) {
|
|
339
|
+
return { ok: false, reason: `'${model}' not in this profile's availableModels (${p.availableModels.join(", ")})` };
|
|
340
|
+
}
|
|
341
|
+
f.profiles[i] = { ...p, model };
|
|
342
|
+
persistProfilesFile(f);
|
|
343
|
+
return { ok: true };
|
|
344
|
+
}
|
|
345
|
+
/** Clear a per-profile model override (revert to defaultModel). */
|
|
346
|
+
export function resetModel(id) {
|
|
347
|
+
if (id === PERSONAL_ID) {
|
|
348
|
+
// For personal we don't have a distinct override slot — `model` IS the value. Reset means
|
|
349
|
+
// remove the model line from config.json so the provider default kicks in.
|
|
350
|
+
return clearModelOnPersonal();
|
|
351
|
+
}
|
|
352
|
+
const f = maybeMigrate();
|
|
353
|
+
const i = f.profiles.findIndex((x) => x.id === id);
|
|
354
|
+
if (i < 0)
|
|
355
|
+
return { ok: false, reason: `no profile '${id}'` };
|
|
356
|
+
const { model: _drop, ...rest } = f.profiles[i];
|
|
357
|
+
f.profiles[i] = rest;
|
|
358
|
+
persistProfilesFile(f);
|
|
359
|
+
return { ok: true };
|
|
360
|
+
}
|
|
361
|
+
/** The effective model for a profile = override (model) || defaultModel || "" (caller decides default). */
|
|
362
|
+
export function effectiveModel(p) {
|
|
363
|
+
return process.env.HARA_MODEL || p.model || p.defaultModel || "";
|
|
364
|
+
}
|
|
365
|
+
/** Routing display string — the user-visible "where this profile sends requests".
|
|
366
|
+
* Used by `whoami` / `profile list`; the TUI header uses `routeHost` for a tighter,
|
|
367
|
+
* host-only render. */
|
|
368
|
+
export function routingLabel(p) {
|
|
369
|
+
if (p.kind === "gateway") {
|
|
370
|
+
try {
|
|
371
|
+
const host = new URL(p.gatewayUrl || "").host;
|
|
372
|
+
return `${host}${p.deviceId ? " · device " + p.deviceId.slice(-8) : ""}`;
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return p.gatewayUrl || "gateway";
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return `${p.provider}${p.baseURL ? " · " + p.baseURL : ""}`;
|
|
379
|
+
}
|
|
380
|
+
/** Host-only routing for the TUI header. Returns the URL host (no scheme, no path) plus
|
|
381
|
+
* `isCustom`: true when the profile carries a non-default baseURL (BYOK) or always-true
|
|
382
|
+
* for gateway profiles. View layer decides whether to show `→ host` (always for org;
|
|
383
|
+
* only when `isCustom` for personal). Returns `null` if there's nothing to display
|
|
384
|
+
* (BYOK on the provider's official endpoint). */
|
|
385
|
+
export function routeHost(p) {
|
|
386
|
+
if (p.kind === "gateway") {
|
|
387
|
+
try {
|
|
388
|
+
return { host: new URL(p.gatewayUrl || "").host, isCustom: true };
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return p.gatewayUrl ? { host: p.gatewayUrl, isCustom: true } : null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
// BYOK: only surface a host when the user pointed at a non-default endpoint.
|
|
395
|
+
if (!p.baseURL)
|
|
396
|
+
return null;
|
|
397
|
+
try {
|
|
398
|
+
return { host: new URL(p.baseURL).host, isCustom: true };
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
return { host: p.baseURL, isCustom: true };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
405
|
+
// Personal-profile model storage helpers — split out so they can be re-exported via
|
|
406
|
+
// config.ts without circular imports. Implemented inline to avoid pulling config.ts.
|
|
407
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
408
|
+
function setModelOnPersonal(model) {
|
|
409
|
+
const p = configPath();
|
|
410
|
+
const cfg = readJSON(p) ?? {};
|
|
411
|
+
cfg.model = model;
|
|
412
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
413
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
414
|
+
try {
|
|
415
|
+
chmodSync(p, 0o600);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
/* best-effort */
|
|
419
|
+
}
|
|
420
|
+
return { ok: true };
|
|
421
|
+
}
|
|
422
|
+
function clearModelOnPersonal() {
|
|
423
|
+
const p = configPath();
|
|
424
|
+
const cfg = readJSON(p) ?? {};
|
|
425
|
+
delete cfg.model;
|
|
426
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
427
|
+
writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
|
|
428
|
+
try {
|
|
429
|
+
chmodSync(p, 0o600);
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
/* best-effort */
|
|
433
|
+
}
|
|
434
|
+
return { ok: true };
|
|
435
|
+
}
|
|
436
|
+
export { PERSONAL_ID, DEFAULT_ORG_ID };
|
|
@@ -51,16 +51,43 @@ export function toAnthropic(history) {
|
|
|
51
51
|
}
|
|
52
52
|
return msgs;
|
|
53
53
|
}
|
|
54
|
+
/** Anthropic models whose only valid `thinking` setting is `{type: "adaptive"}` — they reject any
|
|
55
|
+
* explicit `budget_tokens`. We detect them by id family so "off"/low/high still degrade gracefully
|
|
56
|
+
* (we just omit the field or stay on adaptive instead of sending a 400-triggering body). */
|
|
57
|
+
function isAdaptiveOnly(model) {
|
|
58
|
+
// Known adaptive-only families per Anthropic docs: opus-4-7 / opus-4-8 / claude-fable.
|
|
59
|
+
// Conservative: regex on the family slug so future micro-versions (opus-4-8-20260101) match.
|
|
60
|
+
return /^(claude-)?(opus-4-7|opus-4-8|fable)/i.test(model) || /(opus-4-7|opus-4-8|fable)/i.test(model);
|
|
61
|
+
}
|
|
62
|
+
/** Map hara's reasoningEffort dial to Anthropic's `thinking` parameter (or omit it).
|
|
63
|
+
* Exported for unit testing. */
|
|
64
|
+
export function buildThinkingParam(model, effort) {
|
|
65
|
+
// Unset = preserve hara's prior behavior (adaptive thinking on by default).
|
|
66
|
+
if (effort === undefined)
|
|
67
|
+
return { type: "adaptive" };
|
|
68
|
+
if (effort === "off")
|
|
69
|
+
return undefined; // omit thinking entirely (works on every model, incl. adaptive-only)
|
|
70
|
+
const adaptiveOnly = isAdaptiveOnly(model);
|
|
71
|
+
if (adaptiveOnly)
|
|
72
|
+
return { type: "adaptive" }; // can't honor budget on these — fall back to adaptive instead of 400'ing
|
|
73
|
+
if (effort === "low")
|
|
74
|
+
return { type: "enabled", budget_tokens: 4096 };
|
|
75
|
+
if (effort === "medium")
|
|
76
|
+
return { type: "adaptive" };
|
|
77
|
+
// high
|
|
78
|
+
return { type: "enabled", budget_tokens: 24000 };
|
|
79
|
+
}
|
|
54
80
|
export function createAnthropicProvider(opts) {
|
|
55
81
|
const client = new Anthropic({ apiKey: opts.apiKey, maxRetries: 4, ...(opts.baseURL ? { baseURL: opts.baseURL } : {}) });
|
|
56
82
|
return {
|
|
57
83
|
id: "anthropic",
|
|
58
84
|
model: opts.model,
|
|
59
85
|
async turn({ system, history, tools, onText, onReasoning, signal }) {
|
|
86
|
+
const thinking = buildThinkingParam(opts.model, opts.reasoningEffort);
|
|
60
87
|
const stream = client.messages.stream({
|
|
61
88
|
model: opts.model,
|
|
62
89
|
max_tokens: 32000,
|
|
63
|
-
thinking
|
|
90
|
+
...(thinking ? { thinking } : {}),
|
|
64
91
|
system,
|
|
65
92
|
tools: tools,
|
|
66
93
|
messages: toAnthropic(history),
|
package/dist/providers/openai.js
CHANGED
|
@@ -45,6 +45,12 @@ export function toOpenAI(system, history) {
|
|
|
45
45
|
}
|
|
46
46
|
return msgs;
|
|
47
47
|
}
|
|
48
|
+
/** Reasoning models on OpenAI (o-series + gpt-5) accept `reasoning_effort` on chat-completions.
|
|
49
|
+
* Non-reasoning models reject it. We only attach the param when the model id matches a known
|
|
50
|
+
* reasoning family — keeps DeepSeek/GLM/Qwen requests clean. Exported for tests. */
|
|
51
|
+
export function isReasoningModel(model) {
|
|
52
|
+
return /^(o1|o3|o4|gpt-5)/i.test(model);
|
|
53
|
+
}
|
|
48
54
|
/** OpenAI-compatible provider (works with OpenAI, Qwen/DashScope, GLM, Kimi, …). */
|
|
49
55
|
export function createOpenAIProvider(opts) {
|
|
50
56
|
const client = new OpenAI({ apiKey: opts.apiKey, maxRetries: 4, ...(opts.baseURL ? { baseURL: opts.baseURL } : {}) });
|
|
@@ -65,6 +71,13 @@ export function createOpenAIProvider(opts) {
|
|
|
65
71
|
};
|
|
66
72
|
if (oaiTools.length)
|
|
67
73
|
params.tools = oaiTools;
|
|
74
|
+
// reasoning_effort: only attach for OpenAI reasoning models, and only when the user picked a
|
|
75
|
+
// non-default level. "off" means "don't ask the model to reason" — for reasoning models we
|
|
76
|
+
// pass "minimal" (gpt-5 / o-series accept it); on chat-style models that put reasoning in
|
|
77
|
+
// the stream (DeepSeek/GLM) we can't silence it server-side, so the UI just won't render.
|
|
78
|
+
if (opts.reasoningEffort && opts.reasoningEffort !== undefined && isReasoningModel(opts.model)) {
|
|
79
|
+
params.reasoning_effort = opts.reasoningEffort === "off" ? "minimal" : opts.reasoningEffort;
|
|
80
|
+
}
|
|
68
81
|
// Stream: emit text deltas live; accumulate tool-call args by index; grab usage from the tail chunk.
|
|
69
82
|
let text = "";
|
|
70
83
|
const acc = new Map();
|
|
@@ -80,7 +93,9 @@ export function createOpenAIProvider(opts) {
|
|
|
80
93
|
onText(delta.content);
|
|
81
94
|
}
|
|
82
95
|
const rc = delta?.reasoning_content ?? delta?.reasoning; // GLM-5 / DeepSeek
|
|
83
|
-
|
|
96
|
+
// reasoningEffort="off" + a stream-reasoning model: server can't be silenced, so we just
|
|
97
|
+
// don't surface it. Anything else (incl. undefined) shows the reasoning live.
|
|
98
|
+
if (rc && opts.reasoningEffort !== "off")
|
|
84
99
|
onReasoning?.(rc);
|
|
85
100
|
if (delta?.tool_calls) {
|
|
86
101
|
for (const tc of delta.tool_calls) {
|