@nanhara/hara 0.121.0 → 0.122.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 +85 -0
- package/README.md +40 -6
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +158 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +62 -26
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +5 -9
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +640 -219
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +150 -0
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +112 -28
- package/dist/session/store.js +337 -44
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +61 -23
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +364 -64
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +83 -7
- package/package.json +1 -1
package/dist/agent/reminders.js
CHANGED
|
@@ -6,19 +6,34 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Claude Code's disclaimer is preserved: the model is told the context may be irrelevant, so an
|
|
8
8
|
// injected nudge never derails an unrelated task.
|
|
9
|
-
const
|
|
9
|
+
const DEFAULT_SCOPE = "default";
|
|
10
|
+
const queues = new Map();
|
|
11
|
+
function scopeKey(scope) {
|
|
12
|
+
return scope?.trim() || DEFAULT_SCOPE;
|
|
13
|
+
}
|
|
10
14
|
/** Queue a reminder for injection before the next model call (main loop only — quiet/sub-agent runs
|
|
11
15
|
* neither push nor drain, so a parallel fan-out can't steal the main conversation's reminders). */
|
|
12
|
-
export function pushReminder(text) {
|
|
16
|
+
export function pushReminder(text, scope) {
|
|
13
17
|
const t = text.trim();
|
|
14
|
-
if (t)
|
|
15
|
-
|
|
18
|
+
if (!t)
|
|
19
|
+
return;
|
|
20
|
+
const key = scopeKey(scope);
|
|
21
|
+
const queue = queues.get(key) ?? [];
|
|
22
|
+
queue.push(t);
|
|
23
|
+
queues.set(key, queue);
|
|
16
24
|
}
|
|
17
25
|
/** Take everything queued (FIFO), clearing the queue. */
|
|
18
|
-
export function drainReminders() {
|
|
19
|
-
|
|
26
|
+
export function drainReminders(scope) {
|
|
27
|
+
const key = scopeKey(scope);
|
|
28
|
+
const queue = queues.get(key);
|
|
29
|
+
if (!queue?.length)
|
|
20
30
|
return [];
|
|
21
|
-
|
|
31
|
+
queues.delete(key);
|
|
32
|
+
return queue;
|
|
33
|
+
}
|
|
34
|
+
/** Drop an ephemeral session's pending reminders without exposing them to another run. */
|
|
35
|
+
export function disposeReminderScope(scope) {
|
|
36
|
+
queues.delete(scopeKey(scope));
|
|
22
37
|
}
|
|
23
38
|
/** Merge queued reminders into the single injected message. */
|
|
24
39
|
export function wrapReminders(items) {
|
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
// covers FAILED ones. Deterministic and session-scoped (module state, same pattern as net-reachability):
|
|
5
5
|
// when an identical (tool, args) call fails twice in a row, the tool result gets an explicit "stop
|
|
6
6
|
// repeating this" note the model can't miss. Successful repeats are NOT flagged — a re-read after an edit
|
|
7
|
-
// or a re-run after a fix is legitimate, and a success resets the failure streak.
|
|
8
|
-
|
|
7
|
+
// or a re-run after a fix is legitimate, and a success resets the failure streak. Serve can run several
|
|
8
|
+
// sessions in one process, so streaks are keyed by the same run scope as todo/reminder state.
|
|
9
|
+
const DEFAULT_SCOPE = "default";
|
|
10
|
+
const seenByScope = new Map();
|
|
11
|
+
function scopedSeen(scope) {
|
|
12
|
+
const key = scope?.trim() || DEFAULT_SCOPE;
|
|
13
|
+
const seen = seenByScope.get(key) ?? new Map();
|
|
14
|
+
seenByScope.set(key, seen);
|
|
15
|
+
return seen;
|
|
16
|
+
}
|
|
9
17
|
/** Identity of a call = tool name + exact JSON of its arguments (tool names contain no spaces,
|
|
10
18
|
* so a space separator is unambiguous). */
|
|
11
19
|
export function keyOf(name, input) {
|
|
@@ -24,12 +32,20 @@ export function looksFailed(content) {
|
|
|
24
32
|
}
|
|
25
33
|
/** Record a completed call; returns a warning to APPEND to the tool result when the same call has now
|
|
26
34
|
* failed >=2x in a row (empty string otherwise). Pure aside from the session-scoped map. */
|
|
27
|
-
export function recordCall(name, input, content, isError = false) {
|
|
35
|
+
export function recordCall(name, input, content, isError = false, scope) {
|
|
28
36
|
const k = keyOf(name, input);
|
|
29
37
|
const failed = isError || looksFailed(content);
|
|
38
|
+
const seen = scopedSeen(scope);
|
|
30
39
|
const s = seen.get(k) ?? { fails: 0 };
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
if (!failed) {
|
|
41
|
+
seen.delete(k); // successes have no useful state; don't leak every unique tool call in a long-lived server
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
s.fails++;
|
|
45
|
+
seen.delete(k);
|
|
46
|
+
seen.set(k, s); // refresh insertion order for the bounded per-scope LRU
|
|
47
|
+
if (seen.size > 500)
|
|
48
|
+
seen.delete(seen.keys().next().value);
|
|
33
49
|
if (s.fails < 2)
|
|
34
50
|
return "";
|
|
35
51
|
return (`\n\n⟳ hara: this exact ${name} call has now FAILED ${s.fails}× with identical arguments — ` +
|
|
@@ -37,6 +53,9 @@ export function recordCall(name, input, content, isError = false) {
|
|
|
37
53
|
`or step back and re-plan; if you're out of ideas, ask the user and say what you tried.`);
|
|
38
54
|
}
|
|
39
55
|
/** Clear the streaks — /reset (fresh start) and tests. */
|
|
40
|
-
export function resetRepeatGuard() {
|
|
41
|
-
|
|
56
|
+
export function resetRepeatGuard(scope) {
|
|
57
|
+
if (scope)
|
|
58
|
+
seenByScope.delete(scope.trim() || DEFAULT_SCOPE);
|
|
59
|
+
else
|
|
60
|
+
seenByScope.clear();
|
|
42
61
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported validation keywords are deliberately small and explicit:
|
|
3
|
+
* - `type`: object, array, string, number, integer, boolean, null (or a union array)
|
|
4
|
+
* - `required`, `properties`, and boolean `additionalProperties` for objects
|
|
5
|
+
* - one schema in `items` for arrays (tuple schemas are not supported)
|
|
6
|
+
* - `enum` containing JSON primitive values
|
|
7
|
+
* - annotation-only `title` and `description`
|
|
8
|
+
*
|
|
9
|
+
* Every other JSON-Schema keyword is rejected. In particular, combinators and references such as
|
|
10
|
+
* `$ref`, `allOf`, `anyOf`, `oneOf`, and `not` must never be silently accepted: doing so would make
|
|
11
|
+
* the CLI promise a constraint that this dependency-free validator did not enforce.
|
|
12
|
+
*/
|
|
13
|
+
const SUPPORTED_SCHEMA_KEYWORDS = new Set([
|
|
14
|
+
"type",
|
|
15
|
+
"required",
|
|
16
|
+
"properties",
|
|
17
|
+
"additionalProperties",
|
|
18
|
+
"items",
|
|
19
|
+
"enum",
|
|
20
|
+
"title",
|
|
21
|
+
"description",
|
|
22
|
+
]);
|
|
23
|
+
const SUPPORTED_SCHEMA_TYPES = new Set(["object", "array", "string", "number", "integer", "boolean", "null"]);
|
|
24
|
+
function isSchemaObject(value) {
|
|
25
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
26
|
+
return false;
|
|
27
|
+
const prototype = Object.getPrototypeOf(value);
|
|
28
|
+
return prototype === Object.prototype || prototype === null;
|
|
29
|
+
}
|
|
30
|
+
function own(schema, key) {
|
|
31
|
+
return Object.hasOwn(schema, key) ? schema[key] : undefined;
|
|
32
|
+
}
|
|
33
|
+
function schemaTypes(schema) {
|
|
34
|
+
const type = own(schema, "type");
|
|
35
|
+
return type === undefined ? [] : Array.isArray(type) ? type : [type];
|
|
36
|
+
}
|
|
37
|
+
function describeSchemaValue(value) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.stringify(value) ?? String(value);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return String(value);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function validateSchemaDefinition(schema, path = "$schema") {
|
|
46
|
+
if (!isSchemaObject(schema))
|
|
47
|
+
return `${path}: schema must be a plain JSON object`;
|
|
48
|
+
for (const keyword of Object.keys(schema)) {
|
|
49
|
+
if (!SUPPORTED_SCHEMA_KEYWORDS.has(keyword))
|
|
50
|
+
return `${path}.${keyword}: unsupported JSON-Schema keyword`;
|
|
51
|
+
}
|
|
52
|
+
const type = own(schema, "type");
|
|
53
|
+
if (type !== undefined) {
|
|
54
|
+
const types = Array.isArray(type) ? type : [type];
|
|
55
|
+
if (types.length === 0)
|
|
56
|
+
return `${path}.type: type array must not be empty`;
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
for (const candidate of types) {
|
|
59
|
+
if (typeof candidate !== "string" || !SUPPORTED_SCHEMA_TYPES.has(candidate)) {
|
|
60
|
+
return `${path}.type: unsupported JSON-Schema type ${describeSchemaValue(candidate)}`;
|
|
61
|
+
}
|
|
62
|
+
if (seen.has(candidate))
|
|
63
|
+
return `${path}.type: duplicate type ${describeSchemaValue(candidate)}`;
|
|
64
|
+
seen.add(candidate);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
for (const annotation of ["title", "description"]) {
|
|
68
|
+
const value = own(schema, annotation);
|
|
69
|
+
if (value !== undefined && typeof value !== "string")
|
|
70
|
+
return `${path}.${annotation}: expected string`;
|
|
71
|
+
}
|
|
72
|
+
const enumValues = own(schema, "enum");
|
|
73
|
+
if (enumValues !== undefined) {
|
|
74
|
+
if (!Array.isArray(enumValues) || enumValues.length === 0)
|
|
75
|
+
return `${path}.enum: expected a non-empty array`;
|
|
76
|
+
for (const value of enumValues) {
|
|
77
|
+
if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
|
|
78
|
+
return `${path}.enum: only JSON primitive values are supported`;
|
|
79
|
+
}
|
|
80
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
81
|
+
return `${path}.enum: numbers must be finite`;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const required = own(schema, "required");
|
|
85
|
+
if (required !== undefined) {
|
|
86
|
+
if (!Array.isArray(required) || required.some((key) => typeof key !== "string")) {
|
|
87
|
+
return `${path}.required: expected an array of property names`;
|
|
88
|
+
}
|
|
89
|
+
if (new Set(required).size !== required.length)
|
|
90
|
+
return `${path}.required: property names must be unique`;
|
|
91
|
+
}
|
|
92
|
+
const properties = own(schema, "properties");
|
|
93
|
+
if (properties !== undefined) {
|
|
94
|
+
if (!isSchemaObject(properties))
|
|
95
|
+
return `${path}.properties: expected an object of property schemas`;
|
|
96
|
+
for (const [key, child] of Object.entries(properties)) {
|
|
97
|
+
const error = validateSchemaDefinition(child, `${path}.properties.${key}`);
|
|
98
|
+
if (error)
|
|
99
|
+
return error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const additionalProperties = own(schema, "additionalProperties");
|
|
103
|
+
if (additionalProperties !== undefined && typeof additionalProperties !== "boolean") {
|
|
104
|
+
return `${path}.additionalProperties: only boolean values are supported`;
|
|
105
|
+
}
|
|
106
|
+
const items = own(schema, "items");
|
|
107
|
+
if (items !== undefined) {
|
|
108
|
+
const error = validateSchemaDefinition(items, `${path}.items`);
|
|
109
|
+
if (error)
|
|
110
|
+
return error;
|
|
111
|
+
}
|
|
112
|
+
const types = schemaTypes(schema);
|
|
113
|
+
const hasObjectKeyword = required !== undefined || properties !== undefined || additionalProperties !== undefined;
|
|
114
|
+
if (hasObjectKeyword && !types.includes("object")) {
|
|
115
|
+
return `${path}: object keywords require type "object"`;
|
|
116
|
+
}
|
|
117
|
+
if (items !== undefined && !types.includes("array"))
|
|
118
|
+
return `${path}: items requires type "array"`;
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
function actualType(value) {
|
|
122
|
+
return Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
|
|
123
|
+
}
|
|
124
|
+
function matchesType(value, type) {
|
|
125
|
+
const actual = actualType(value);
|
|
126
|
+
if (type === "integer")
|
|
127
|
+
return actual === "number" && Number.isFinite(value) && Number.isInteger(value);
|
|
128
|
+
if (type === "number")
|
|
129
|
+
return actual === "number" && Number.isFinite(value);
|
|
130
|
+
return type === actual;
|
|
131
|
+
}
|
|
132
|
+
function validateValue(value, schema, path) {
|
|
133
|
+
const types = schemaTypes(schema);
|
|
134
|
+
if (types.length && !types.some((type) => matchesType(value, type))) {
|
|
135
|
+
return `${path}: expected ${types.join("|")}, got ${actualType(value)}`;
|
|
136
|
+
}
|
|
137
|
+
const enumValues = own(schema, "enum");
|
|
138
|
+
if (enumValues && !enumValues.some((candidate) => candidate === value)) {
|
|
139
|
+
return `${path}: value not in enum ${JSON.stringify(enumValues)}`;
|
|
140
|
+
}
|
|
141
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && types.includes("object")) {
|
|
142
|
+
const object = value;
|
|
143
|
+
const required = own(schema, "required");
|
|
144
|
+
for (const key of required ?? []) {
|
|
145
|
+
if (!Object.hasOwn(object, key))
|
|
146
|
+
return `${path}.${key}: required property missing`;
|
|
147
|
+
}
|
|
148
|
+
const properties = own(schema, "properties");
|
|
149
|
+
for (const [key, child] of Object.entries(properties ?? {})) {
|
|
150
|
+
if (Object.hasOwn(object, key)) {
|
|
151
|
+
const error = validateValue(object[key], child, `${path}.${key}`);
|
|
152
|
+
if (error)
|
|
153
|
+
return error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (own(schema, "additionalProperties") === false) {
|
|
157
|
+
for (const key of Object.keys(object)) {
|
|
158
|
+
if (!properties || !Object.hasOwn(properties, key))
|
|
159
|
+
return `${path}.${key}: unexpected property`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (Array.isArray(value) && types.includes("array")) {
|
|
164
|
+
const items = own(schema, "items");
|
|
165
|
+
if (items) {
|
|
166
|
+
for (let index = 0; index < value.length; index++) {
|
|
167
|
+
const error = validateValue(value[index], items, `${path}[${index}]`);
|
|
168
|
+
if (error)
|
|
169
|
+
return error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
/** Validate `value` against the supported JSON-Schema subset. Returns null when valid, otherwise a path-rich
|
|
176
|
+
* error. Invalid or unsupported schemas are errors too; they are never treated as permissive schemas. */
|
|
177
|
+
export function validateAgainstSchema(value, schema, path = "$") {
|
|
178
|
+
const schemaError = validateSchemaDefinition(schema);
|
|
179
|
+
if (schemaError)
|
|
180
|
+
return `invalid schema — ${schemaError}`;
|
|
181
|
+
return validateValue(value, schema, path);
|
|
182
|
+
}
|
|
183
|
+
/** Parse a `--schema` CLI value: inline JSON, or (by the caller) file contents. Returns the schema object or
|
|
184
|
+
* an error string. Top level must be an object schema — tool arguments are always a JSON object. */
|
|
185
|
+
export function parseSchemaArg(raw) {
|
|
186
|
+
let schema;
|
|
187
|
+
try {
|
|
188
|
+
schema = JSON.parse(raw);
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
return { error: `--schema is not valid JSON: ${e instanceof Error ? e.message : String(e)}` };
|
|
192
|
+
}
|
|
193
|
+
if (!isSchemaObject(schema))
|
|
194
|
+
return { error: "--schema must be a JSON object (a JSON-Schema)" };
|
|
195
|
+
let normalized = schema;
|
|
196
|
+
if (!Object.hasOwn(normalized, "type"))
|
|
197
|
+
normalized = { ...normalized, type: "object" };
|
|
198
|
+
if (own(normalized, "type") !== "object") {
|
|
199
|
+
return { error: "--schema top level must have type:'object' (tool args are an object)" };
|
|
200
|
+
}
|
|
201
|
+
if (!Object.hasOwn(normalized, "properties"))
|
|
202
|
+
normalized = { ...normalized, properties: {} };
|
|
203
|
+
const schemaError = validateSchemaDefinition(normalized);
|
|
204
|
+
if (schemaError)
|
|
205
|
+
return { error: `--schema is unsupported or invalid: ${schemaError}` };
|
|
206
|
+
return normalized;
|
|
207
|
+
}
|
|
208
|
+
/** The instruction appended to the prompt so the model knows the contract. */
|
|
209
|
+
export const STRUCTURED_INSTRUCTION = "\n\nWhen you have the final answer, you MUST call the `structured_output` tool exactly once with the result " +
|
|
210
|
+
"matching its schema. Do not print the result as text — the tool call IS the answer.";
|
|
211
|
+
/** The retry nudge when a turn ends without the tool having been called. */
|
|
212
|
+
export const STRUCTURED_NUDGE = "You finished without calling `structured_output`. Call it NOW with your final result matching the schema — the tool call is the only accepted answer.";
|
|
213
|
+
/** Build the run-scoped structured_output tool. `sink` receives the validated payload (last call wins). */
|
|
214
|
+
export function structuredOutputTool(schema, sink) {
|
|
215
|
+
const schemaError = validateSchemaDefinition(schema);
|
|
216
|
+
if (schemaError)
|
|
217
|
+
throw new Error(`Invalid structured-output schema: ${schemaError}`);
|
|
218
|
+
return {
|
|
219
|
+
name: "structured_output",
|
|
220
|
+
description: "Record the final structured result of this task. Call exactly once, when done, with the complete answer.",
|
|
221
|
+
input_schema: schema,
|
|
222
|
+
kind: "read", // never prompts; the payload is data, not an action
|
|
223
|
+
async run(input) {
|
|
224
|
+
const err = validateAgainstSchema(input, schema);
|
|
225
|
+
if (err)
|
|
226
|
+
return `schema validation failed — ${err}. Fix the payload and call structured_output again.`;
|
|
227
|
+
sink(input);
|
|
228
|
+
return "structured result recorded.";
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
package/dist/agent/touched.js
CHANGED
|
@@ -2,17 +2,31 @@
|
|
|
2
2
|
// The loop records every file the MAIN conversation reads/edits; when the history is compacted to a
|
|
3
3
|
// summary, the top-N most-recent files get their CURRENT on-disk content re-attached — so the model
|
|
4
4
|
// doesn't lose the very files it was working on and re-read them all next turn.
|
|
5
|
-
const
|
|
6
|
-
|
|
5
|
+
const DEFAULT_SCOPE = "default";
|
|
6
|
+
const touchedByScope = new Map(); // scope → absolute path → last-touch timestamp
|
|
7
|
+
function scopedTouched(scope) {
|
|
8
|
+
const key = scope?.trim() || DEFAULT_SCOPE;
|
|
9
|
+
const touched = touchedByScope.get(key) ?? new Map();
|
|
10
|
+
touchedByScope.set(key, touched);
|
|
11
|
+
return touched;
|
|
12
|
+
}
|
|
13
|
+
export function recordTouch(path, scope) {
|
|
14
|
+
const touched = scopedTouched(scope);
|
|
15
|
+
touched.delete(path);
|
|
7
16
|
touched.set(path, Date.now());
|
|
17
|
+
if (touched.size > 200)
|
|
18
|
+
touched.delete(touched.keys().next().value);
|
|
8
19
|
}
|
|
9
20
|
/** Most-recently-touched first. */
|
|
10
|
-
export function recentTouched(n = 5) {
|
|
11
|
-
return [...
|
|
21
|
+
export function recentTouched(n = 5, scope) {
|
|
22
|
+
return [...scopedTouched(scope).entries()]
|
|
12
23
|
.sort((a, b) => b[1] - a[1])
|
|
13
24
|
.slice(0, n)
|
|
14
25
|
.map(([p]) => p);
|
|
15
26
|
}
|
|
16
|
-
export function clearTouched() {
|
|
17
|
-
|
|
27
|
+
export function clearTouched(scope) {
|
|
28
|
+
if (scope)
|
|
29
|
+
touchedByScope.delete(scope.trim() || DEFAULT_SCOPE);
|
|
30
|
+
else
|
|
31
|
+
touchedByScope.clear();
|
|
18
32
|
}
|
package/dist/config.js
CHANGED
|
@@ -38,17 +38,48 @@ const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "p
|
|
|
38
38
|
export function configPath() {
|
|
39
39
|
return join(homedir(), ".hara", "config.json");
|
|
40
40
|
}
|
|
41
|
+
function configRecord(value) {
|
|
42
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
43
|
+
}
|
|
41
44
|
export function readRawConfig() {
|
|
42
45
|
const p = configPath();
|
|
43
46
|
if (!existsSync(p))
|
|
44
47
|
return {};
|
|
45
48
|
try {
|
|
46
|
-
return JSON.parse(readFileSync(p, "utf8"));
|
|
49
|
+
return configRecord(JSON.parse(readFileSync(p, "utf8")));
|
|
47
50
|
}
|
|
48
51
|
catch {
|
|
49
52
|
return {};
|
|
50
53
|
}
|
|
51
54
|
}
|
|
55
|
+
const ROUTING_CONFIG_KEYS = new Set([
|
|
56
|
+
"provider", "apiKey", "model", "baseURL",
|
|
57
|
+
"fallbackProvider", "fallbackApiKey", "fallbackModel", "fallbackBaseURL",
|
|
58
|
+
"visionApiKey", "visionModel", "visionBaseURL",
|
|
59
|
+
"embedProvider", "embedApiKey", "embedModel", "embedBaseURL",
|
|
60
|
+
"routeApiKey", "routeModel", "routeBaseURL",
|
|
61
|
+
]);
|
|
62
|
+
/** Empty routing values are not meaningful credentials/endpoints. Ignore them at each precedence layer so
|
|
63
|
+
* an empty project override (or launcher-exported empty env var) cannot hide a valid global config value. */
|
|
64
|
+
function withoutBlankRoutingValues(input) {
|
|
65
|
+
const out = {};
|
|
66
|
+
for (const [key, value] of Object.entries(input)) {
|
|
67
|
+
if (ROUTING_CONFIG_KEYS.has(key) && typeof value === "string") {
|
|
68
|
+
const trimmed = value.trim();
|
|
69
|
+
if (!trimmed)
|
|
70
|
+
continue;
|
|
71
|
+
out[key] = trimmed;
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
out[key] = value;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
function nonBlankEnv(value) {
|
|
80
|
+
const trimmed = value?.trim();
|
|
81
|
+
return trimmed || undefined;
|
|
82
|
+
}
|
|
52
83
|
/** Nearest project override `.hara/config.json`, searching cwd up to the repo root. */
|
|
53
84
|
function readProjectConfig(cwd) {
|
|
54
85
|
let dir = resolve(cwd);
|
|
@@ -56,7 +87,7 @@ function readProjectConfig(cwd) {
|
|
|
56
87
|
const p = join(dir, ".hara", "config.json");
|
|
57
88
|
if (existsSync(p)) {
|
|
58
89
|
try {
|
|
59
|
-
return JSON.parse(readFileSync(p, "utf8"));
|
|
90
|
+
return configRecord(JSON.parse(readFileSync(p, "utf8")));
|
|
60
91
|
}
|
|
61
92
|
catch {
|
|
62
93
|
return {};
|
|
@@ -117,16 +148,21 @@ export function loadConfig(opts = {}) {
|
|
|
117
148
|
// Strip both the new (`overlays`) and legacy (`profiles`) overlay containers from the base merge.
|
|
118
149
|
// The legacy `profiles` key is kept readable for back-compat with users who already have it.
|
|
119
150
|
const { overlays, profiles, ...globalBase } = global;
|
|
120
|
-
const
|
|
121
|
-
const
|
|
151
|
+
const effectiveCwd = resolve(opts.cwd ?? process.cwd());
|
|
152
|
+
const project = readProjectConfig(effectiveCwd);
|
|
153
|
+
const overlayName = nonBlankEnv(process.env.HARA_OVERLAY) ?? nonBlankEnv(opts.overlay);
|
|
122
154
|
const overlayMap = overlays && typeof overlays === "object" ? overlays : profiles && typeof profiles === "object" ? profiles : null;
|
|
123
|
-
const overlay = overlayName && overlayMap
|
|
124
|
-
const merged = {
|
|
125
|
-
|
|
155
|
+
const overlay = configRecord(overlayName && overlayMap ? overlayMap[overlayName] : undefined);
|
|
156
|
+
const merged = {
|
|
157
|
+
...withoutBlankRoutingValues(globalBase),
|
|
158
|
+
...withoutBlankRoutingValues(overlay),
|
|
159
|
+
...withoutBlankRoutingValues(project),
|
|
160
|
+
};
|
|
161
|
+
const provider = (nonBlankEnv(process.env.HARA_PROVIDER) ?? merged.provider ?? "anthropic");
|
|
126
162
|
const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
|
|
127
|
-
const model = process.env.HARA_MODEL ?? merged.model ?? d.model;
|
|
128
|
-
const baseURL = process.env.HARA_BASE_URL ?? merged.baseURL ?? d.baseURL;
|
|
129
|
-
const apiKey = process.env.HARA_API_KEY ?? process.env[d.envKey] ?? merged.apiKey;
|
|
163
|
+
const model = nonBlankEnv(process.env.HARA_MODEL) ?? merged.model ?? d.model;
|
|
164
|
+
const baseURL = nonBlankEnv(process.env.HARA_BASE_URL) ?? merged.baseURL ?? d.baseURL;
|
|
165
|
+
const apiKey = nonBlankEnv(process.env.HARA_API_KEY) ?? nonBlankEnv(process.env[d.envKey]) ?? merged.apiKey;
|
|
130
166
|
const approval = (process.env.HARA_APPROVAL ?? merged.approval ?? "suggest");
|
|
131
167
|
const sandbox = (process.env.HARA_SANDBOX ?? merged.sandbox ?? "off");
|
|
132
168
|
const theme = (process.env.HARA_THEME ?? merged.theme ?? "dark");
|
|
@@ -134,21 +170,21 @@ export function loadConfig(opts = {}) {
|
|
|
134
170
|
const assetCapture = (process.env.HARA_ASSET_CAPTURE ?? merged.assetCapture ?? "ask");
|
|
135
171
|
const computerUse = (process.env.HARA_COMPUTER_USE ?? merged.computerUse ?? "off");
|
|
136
172
|
const computerApps = String(process.env.HARA_COMPUTER_APPS ?? merged.computerApps ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
137
|
-
const visionModel = process.env.HARA_VISION_MODEL ?? merged.visionModel;
|
|
138
|
-
const visionBaseURL = process.env.HARA_VISION_BASE_URL ?? merged.visionBaseURL;
|
|
139
|
-
const visionApiKey = process.env.HARA_VISION_API_KEY ?? merged.visionApiKey;
|
|
173
|
+
const visionModel = nonBlankEnv(process.env.HARA_VISION_MODEL) ?? merged.visionModel;
|
|
174
|
+
const visionBaseURL = nonBlankEnv(process.env.HARA_VISION_BASE_URL) ?? merged.visionBaseURL;
|
|
175
|
+
const visionApiKey = nonBlankEnv(process.env.HARA_VISION_API_KEY) ?? merged.visionApiKey;
|
|
140
176
|
const modelVision = merged.modelVision && typeof merged.modelVision === "object" ? merged.modelVision : {};
|
|
141
|
-
const embedProvider = (process.env.HARA_EMBED_PROVIDER ?? merged.embedProvider ?? "off");
|
|
142
|
-
const embedModel = process.env.HARA_EMBED_MODEL ?? merged.embedModel;
|
|
143
|
-
const embedBaseURL = process.env.HARA_EMBED_BASE_URL ?? merged.embedBaseURL;
|
|
144
|
-
const embedApiKey = process.env.HARA_EMBED_API_KEY ?? merged.embedApiKey;
|
|
145
|
-
const routeModel = process.env.HARA_ROUTE_MODEL ?? merged.routeModel;
|
|
146
|
-
const routeBaseURL = process.env.HARA_ROUTE_BASE_URL ?? merged.routeBaseURL;
|
|
147
|
-
const routeApiKey = process.env.HARA_ROUTE_API_KEY ?? merged.routeApiKey;
|
|
177
|
+
const embedProvider = (nonBlankEnv(process.env.HARA_EMBED_PROVIDER) ?? merged.embedProvider ?? "off");
|
|
178
|
+
const embedModel = nonBlankEnv(process.env.HARA_EMBED_MODEL) ?? merged.embedModel;
|
|
179
|
+
const embedBaseURL = nonBlankEnv(process.env.HARA_EMBED_BASE_URL) ?? merged.embedBaseURL;
|
|
180
|
+
const embedApiKey = nonBlankEnv(process.env.HARA_EMBED_API_KEY) ?? merged.embedApiKey;
|
|
181
|
+
const routeModel = nonBlankEnv(process.env.HARA_ROUTE_MODEL) ?? merged.routeModel;
|
|
182
|
+
const routeBaseURL = nonBlankEnv(process.env.HARA_ROUTE_BASE_URL) ?? merged.routeBaseURL;
|
|
183
|
+
const routeApiKey = nonBlankEnv(process.env.HARA_ROUTE_API_KEY) ?? merged.routeApiKey;
|
|
148
184
|
const mcpServers = {
|
|
149
185
|
...(globalBase.mcpServers ?? {}),
|
|
150
|
-
...(project.mcpServers ?? {}),
|
|
151
186
|
...(overlay.mcpServers ?? {}),
|
|
187
|
+
...(project.mcpServers ?? {}),
|
|
152
188
|
};
|
|
153
189
|
const hooks = (merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {});
|
|
154
190
|
// Guardian: default ON; env HARA_GUARDIAN=0/off/false or config guardian:"off" disables it.
|
|
@@ -159,15 +195,15 @@ export function loadConfig(opts = {}) {
|
|
|
159
195
|
const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
|
|
160
196
|
const fileCheckpoints = !(process.env.HARA_CHECKPOINTS === "0" || merged.fileCheckpoints === false || merged.fileCheckpoints === "false"); // default ON
|
|
161
197
|
const updateCheck = !(process.env.HARA_UPDATE_CHECK === "0" || merged.updateCheck === false || merged.updateCheck === "false"); // default ON
|
|
162
|
-
const fallbackModel = process.env.HARA_FALLBACK_MODEL ?? merged.fallbackModel;
|
|
163
|
-
const fallbackProvider = (process.env.HARA_FALLBACK_PROVIDER ?? merged.fallbackProvider);
|
|
164
|
-
const fallbackBaseURL = process.env.HARA_FALLBACK_BASE_URL ?? merged.fallbackBaseURL;
|
|
165
|
-
const fallbackApiKey = process.env.HARA_FALLBACK_API_KEY ?? merged.fallbackApiKey;
|
|
198
|
+
const fallbackModel = nonBlankEnv(process.env.HARA_FALLBACK_MODEL) ?? merged.fallbackModel;
|
|
199
|
+
const fallbackProvider = (nonBlankEnv(process.env.HARA_FALLBACK_PROVIDER) ?? merged.fallbackProvider);
|
|
200
|
+
const fallbackBaseURL = nonBlankEnv(process.env.HARA_FALLBACK_BASE_URL) ?? merged.fallbackBaseURL;
|
|
201
|
+
const fallbackApiKey = nonBlankEnv(process.env.HARA_FALLBACK_API_KEY) ?? merged.fallbackApiKey;
|
|
166
202
|
const reasoningRaw = process.env.HARA_REASONING_EFFORT ?? merged.reasoningEffort;
|
|
167
203
|
const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high", "max"].includes(reasoningRaw)
|
|
168
204
|
? reasoningRaw
|
|
169
205
|
: undefined;
|
|
170
|
-
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd:
|
|
206
|
+
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, vimMode, autoCompact, fileCheckpoints, updateCheck, fallbackModel, fallbackProvider, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: effectiveCwd };
|
|
171
207
|
}
|
|
172
208
|
export function providerEnvKey(provider) {
|
|
173
209
|
return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
|
package/dist/cron/deliver.js
CHANGED
|
@@ -1,15 +1,39 @@
|
|
|
1
|
+
// Cron result delivery — push a finished job's output to a chat channel (openclaw/hermes parity),
|
|
2
|
+
// WITHOUT needing the gateway process: adapters are constructed one-shot from the same env vars the
|
|
3
|
+
// gateway uses, send once, and are dropped. Spec format: "<target>:<id>" —
|
|
4
|
+
// telegram:<chatId> (HARA_TELEGRAM_TOKEN)
|
|
5
|
+
// feishu:<chatId> (HARA_FEISHU_APP_ID + HARA_FEISHU_APP_SECRET)
|
|
6
|
+
// webhook:<url> (plain POST {name,status,text} JSON — for anything else)
|
|
7
|
+
// weixin:<peerId> (sends over stored ~/.hara/weixin creds — explicit peer required; guessing the
|
|
8
|
+
// "owner" from a multi-DM context-token cache can deliver private results to the wrong person)
|
|
9
|
+
// Adapters are imported LAZILY so the (heavy) SDKs never load unless a job actually delivers.
|
|
1
10
|
/** Parse a `--deliver` spec; error string on anything unsupported (listing what IS supported). */
|
|
2
11
|
export function parseDeliver(spec) {
|
|
3
12
|
const i = spec.indexOf(":");
|
|
4
13
|
if (i <= 0)
|
|
5
|
-
return { error: `bad deliver spec "${spec}" — use telegram:<chatId>, feishu:<chatId>, or webhook:<url>` };
|
|
14
|
+
return { error: `bad deliver spec "${spec}" — use telegram:<chatId>, feishu:<chatId>, weixin:<peerId>, or webhook:<url>` };
|
|
6
15
|
const platform = spec.slice(0, i).toLowerCase();
|
|
7
16
|
const to = spec.slice(i + 1).trim();
|
|
8
17
|
if (!to)
|
|
9
18
|
return { error: `deliver spec "${spec}" is missing a target after ":"` };
|
|
10
|
-
if (platform === "
|
|
19
|
+
if (platform === "weixin" && to.toLowerCase() === "owner") {
|
|
20
|
+
return { error: "weixin:owner is ambiguous when several people have messaged the bot — use an explicit weixin:<peerId>" };
|
|
21
|
+
}
|
|
22
|
+
if (platform === "telegram" || platform === "feishu" || platform === "webhook" || platform === "weixin")
|
|
11
23
|
return { platform, to };
|
|
12
|
-
return { error: `unsupported deliver platform "${platform}" — supported: telegram, feishu, webhook
|
|
24
|
+
return { error: `unsupported deliver platform "${platform}" — supported: telegram, feishu, weixin, webhook` };
|
|
25
|
+
}
|
|
26
|
+
/** Flatten markdown for plain-text chat surfaces (Feishu/WeChat/Telegram text messages render syntax
|
|
27
|
+
* literally): drop code fences/inline backticks/bold/headers, turn [text](url) into "text (url)". */
|
|
28
|
+
export function plainChat(text) {
|
|
29
|
+
return text
|
|
30
|
+
.replace(/```[a-zA-Z0-9_-]*\n?/g, "")
|
|
31
|
+
.replace(/```/g, "")
|
|
32
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
33
|
+
.replace(/__([^_]+)__/g, "$1")
|
|
34
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
35
|
+
.replace(/^#{1,6}\s+/gm, "")
|
|
36
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)");
|
|
13
37
|
}
|
|
14
38
|
/** Send `text` to the target. Returns null on success, or an error string (never throws — cron
|
|
15
39
|
* delivery is best-effort and must not kill the tick). */
|
|
@@ -17,6 +41,8 @@ export async function deliverResult(spec, text) {
|
|
|
17
41
|
const t = parseDeliver(spec);
|
|
18
42
|
if ("error" in t)
|
|
19
43
|
return t.error;
|
|
44
|
+
if (t.platform !== "webhook")
|
|
45
|
+
text = plainChat(text); // chat surfaces are plain text; webhooks get raw payload
|
|
20
46
|
try {
|
|
21
47
|
if (t.platform === "webhook") {
|
|
22
48
|
const r = await fetch(t.to, {
|
|
@@ -35,6 +61,14 @@ export async function deliverResult(spec, text) {
|
|
|
35
61
|
await telegramAdapter(token).send(t.to, text);
|
|
36
62
|
return null;
|
|
37
63
|
}
|
|
64
|
+
if (t.platform === "weixin") {
|
|
65
|
+
const { loadWeixinCreds, weixinAdapter } = await import("../gateway/weixin.js");
|
|
66
|
+
const creds = loadWeixinCreds();
|
|
67
|
+
if (!creds)
|
|
68
|
+
return "hara weixin not logged in (~/.hara/weixin/creds.json missing)";
|
|
69
|
+
await weixinAdapter(creds).send(t.to, text);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
38
72
|
// feishu
|
|
39
73
|
const appId = process.env.HARA_FEISHU_APP_ID;
|
|
40
74
|
const appSecret = process.env.HARA_FEISHU_APP_SECRET;
|
package/dist/feedback.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// humans and agents file through the same door (gh CLI when present, copy-paste text otherwise).
|
|
4
4
|
// The session tail is OFF by default and explicitly opt-in (--session) because issues are public.
|
|
5
5
|
import { platform, release, arch } from "node:os";
|
|
6
|
+
import { redactSensitiveText } from "./security/secrets.js";
|
|
6
7
|
export function collectEnv(version, model) {
|
|
7
8
|
return {
|
|
8
9
|
version,
|
|
@@ -14,13 +15,7 @@ export function collectEnv(version, model) {
|
|
|
14
15
|
/** Strip credential-looking material from text that is about to become a PUBLIC issue.
|
|
15
16
|
* Deliberately aggressive: false positives cost a little readability, false negatives leak keys. */
|
|
16
17
|
export function redact(text) {
|
|
17
|
-
return text
|
|
18
|
-
.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, "sk-***")
|
|
19
|
-
.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "gh*_***")
|
|
20
|
-
.replace(/\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, "AWS-KEY-***")
|
|
21
|
-
.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer ***")
|
|
22
|
-
.replace(/\b(api[_-]?key|apikey|token|secret|password|passwd|authorization)(["']?\s*[:=]\s*["']?)[^\s"',;]{6,}/gi, "$1$2***")
|
|
23
|
-
.replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "JWT-***");
|
|
18
|
+
return redactSensitiveText(text).text;
|
|
24
19
|
}
|
|
25
20
|
/** The structured issue body — same shape as .github/ISSUE_TEMPLATE/bug_report.yml so hand-filed
|
|
26
21
|
* and command-filed issues read identically to the triage side. */
|
|
@@ -46,9 +41,10 @@ export function buildIssueBody(description, env, sessionTail) {
|
|
|
46
41
|
parts.push("---", "_filed via `hara feedback`_");
|
|
47
42
|
return parts.join("\n");
|
|
48
43
|
}
|
|
49
|
-
/** Issue title from the description: first line,
|
|
44
|
+
/** Issue title from the description: first line, redacted BEFORE it crosses the public issue boundary,
|
|
45
|
+
* trimmed and capped. Body-only redaction is insufficient because GitHub titles are public too. */
|
|
50
46
|
export function issueTitle(description) {
|
|
51
|
-
const first = (description.trim().split("\n")[0] || "feedback").trim();
|
|
47
|
+
const first = redact((description.trim().split("\n")[0] || "feedback").trim());
|
|
52
48
|
return first.length > 70 ? first.slice(0, 67) + "…" : first;
|
|
53
49
|
}
|
|
54
50
|
export const FEEDBACK_REPO = "hara-cli/hara";
|