pi-honcho-memory 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/LICENSE +21 -0
- package/README.md +133 -0
- package/extensions/client.ts +64 -0
- package/extensions/commands.ts +198 -0
- package/extensions/config.ts +307 -0
- package/extensions/context.ts +107 -0
- package/extensions/git.ts +28 -0
- package/extensions/index.ts +190 -0
- package/extensions/session.ts +53 -0
- package/extensions/tools.ts +162 -0
- package/extensions/upload.ts +216 -0
- package/package.json +43 -0
- package/types/shims.d.ts +3 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir, userInfo } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type SessionStrategy = "per-directory" | "git-branch" | "pi-session" | "per-repo" | "global";
|
|
6
|
+
export type RecallMode = "hybrid" | "context" | "tools";
|
|
7
|
+
export type ReasoningLevel = "minimal" | "low" | "medium" | "high" | "max";
|
|
8
|
+
export type WriteFrequency = "async" | "turn" | "session";
|
|
9
|
+
export type InjectionFrequency = "every-turn" | "first-turn";
|
|
10
|
+
export type ObservationMode = "directional" | "unified";
|
|
11
|
+
|
|
12
|
+
export interface PiHostConfig {
|
|
13
|
+
workspace: string;
|
|
14
|
+
aiPeer: string;
|
|
15
|
+
linkedHosts?: string[];
|
|
16
|
+
endpoint?: string;
|
|
17
|
+
sessionStrategy?: SessionStrategy;
|
|
18
|
+
recallMode?: RecallMode;
|
|
19
|
+
contextTokens?: number;
|
|
20
|
+
contextRefreshTtlSeconds?: number;
|
|
21
|
+
maxMessageLength?: number;
|
|
22
|
+
searchLimit?: number;
|
|
23
|
+
toolPreviewLength?: number;
|
|
24
|
+
observeMe?: boolean;
|
|
25
|
+
observeOthers?: boolean;
|
|
26
|
+
aiObserveMe?: boolean;
|
|
27
|
+
aiObserveOthers?: boolean;
|
|
28
|
+
reasoningLevel?: ReasoningLevel;
|
|
29
|
+
contextInjectionInterval?: number;
|
|
30
|
+
// Phase 1 additions
|
|
31
|
+
saveMessages?: boolean;
|
|
32
|
+
writeFrequency?: WriteFrequency | number;
|
|
33
|
+
dialecticDynamic?: boolean;
|
|
34
|
+
dialecticMaxChars?: number;
|
|
35
|
+
dialecticMaxInputChars?: number;
|
|
36
|
+
sessionPeerPrefix?: boolean;
|
|
37
|
+
observationMode?: ObservationMode;
|
|
38
|
+
injectionFrequency?: InjectionFrequency;
|
|
39
|
+
contextCadence?: number;
|
|
40
|
+
dialecticCadence?: number;
|
|
41
|
+
reasoningLevelCap?: ReasoningLevel;
|
|
42
|
+
environment?: "local" | "production";
|
|
43
|
+
logging?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface HonchoConfigFile {
|
|
47
|
+
apiKey?: string;
|
|
48
|
+
peerName?: string;
|
|
49
|
+
baseUrl?: string;
|
|
50
|
+
sessions?: Record<string, string>;
|
|
51
|
+
globalOverride?: boolean;
|
|
52
|
+
contextRefresh?: { messageThreshold?: number };
|
|
53
|
+
logging?: boolean;
|
|
54
|
+
hosts?: {
|
|
55
|
+
pi?: Partial<PiHostConfig>;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface HonchoConfig {
|
|
60
|
+
enabled: boolean;
|
|
61
|
+
apiKey?: string;
|
|
62
|
+
peerName: string;
|
|
63
|
+
baseURL?: string;
|
|
64
|
+
workspace: string;
|
|
65
|
+
aiPeer: string;
|
|
66
|
+
linkedHosts: string[];
|
|
67
|
+
sessionStrategy: SessionStrategy;
|
|
68
|
+
recallMode: RecallMode;
|
|
69
|
+
contextTokens: number;
|
|
70
|
+
contextRefreshTtlSeconds: number;
|
|
71
|
+
maxMessageLength: number;
|
|
72
|
+
searchLimit: number;
|
|
73
|
+
toolPreviewLength: number;
|
|
74
|
+
observeMe: boolean;
|
|
75
|
+
observeOthers: boolean;
|
|
76
|
+
aiObserveMe: boolean;
|
|
77
|
+
aiObserveOthers: boolean;
|
|
78
|
+
reasoningLevel: ReasoningLevel;
|
|
79
|
+
contextInjectionInterval: number;
|
|
80
|
+
// Phase 1 additions
|
|
81
|
+
saveMessages: boolean;
|
|
82
|
+
writeFrequency: WriteFrequency | number;
|
|
83
|
+
dialecticDynamic: boolean;
|
|
84
|
+
dialecticMaxChars: number;
|
|
85
|
+
dialecticMaxInputChars: number;
|
|
86
|
+
sessionPeerPrefix: boolean;
|
|
87
|
+
observationMode: ObservationMode;
|
|
88
|
+
injectionFrequency: InjectionFrequency;
|
|
89
|
+
contextCadence: number;
|
|
90
|
+
dialecticCadence: number;
|
|
91
|
+
reasoningLevelCap: ReasoningLevel | null;
|
|
92
|
+
environment: "local" | "production";
|
|
93
|
+
logging: boolean;
|
|
94
|
+
sessions: Record<string, string>;
|
|
95
|
+
contextRefreshMessageThreshold: number | null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const CONFIG_PATH = join(homedir(), ".honcho", "config.json");
|
|
99
|
+
const DEFAULT_CONTEXT_TOKENS = 1200;
|
|
100
|
+
const DEFAULT_CONTEXT_REFRESH_TTL_SECONDS = 300;
|
|
101
|
+
const DEFAULT_MAX_MESSAGE_LENGTH = 25000;
|
|
102
|
+
const DEFAULT_SEARCH_LIMIT = 8;
|
|
103
|
+
const DEFAULT_TOOL_PREVIEW_LENGTH = 500;
|
|
104
|
+
const DEFAULT_DIALECTIC_MAX_CHARS = 600;
|
|
105
|
+
const DEFAULT_DIALECTIC_MAX_INPUT_CHARS = 10000;
|
|
106
|
+
|
|
107
|
+
const toPositiveInt = (value: string | number | undefined, fallback: number): number => {
|
|
108
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
|
|
109
|
+
if (typeof value === "string" && value.trim()) {
|
|
110
|
+
const parsed = Number(value);
|
|
111
|
+
if (Number.isInteger(parsed) && parsed > 0) return parsed;
|
|
112
|
+
}
|
|
113
|
+
return fallback;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const toBool = (value: string | boolean | undefined, fallback: boolean): boolean => {
|
|
117
|
+
if (typeof value === "boolean") return value;
|
|
118
|
+
if (typeof value === "string") return value === "true";
|
|
119
|
+
return fallback;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const normalizeSessionStrategy = (value: string | undefined): SessionStrategy => {
|
|
123
|
+
if (value === "per-directory" || value === "git-branch" || value === "pi-session" || value === "per-repo" || value === "global") return value;
|
|
124
|
+
return "per-directory";
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export const normalizeRecallMode = (value: string | undefined): RecallMode => {
|
|
128
|
+
if (value === "hybrid" || value === "context" || value === "tools") return value;
|
|
129
|
+
if (value === "auto") return "hybrid";
|
|
130
|
+
return "hybrid";
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const normalizeReasoningLevel = (value: string | undefined): ReasoningLevel => {
|
|
134
|
+
if (value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "max") return value;
|
|
135
|
+
if (value === "mid") return "medium";
|
|
136
|
+
return "low";
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const normalizeWriteFrequency = (value: string | number | undefined): WriteFrequency | number => {
|
|
140
|
+
if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
|
|
141
|
+
if (typeof value === "string") {
|
|
142
|
+
const n = Number(value);
|
|
143
|
+
if (Number.isInteger(n) && n > 0) return n;
|
|
144
|
+
if (value === "async" || value === "turn" || value === "session") return value;
|
|
145
|
+
}
|
|
146
|
+
return "async";
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const normalizeInjectionFrequency = (value: string | undefined): InjectionFrequency => {
|
|
150
|
+
if (value === "every-turn" || value === "first-turn") return value;
|
|
151
|
+
return "every-turn";
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const normalizeObservationMode = (value: string | undefined): ObservationMode => {
|
|
155
|
+
if (value === "directional" || value === "unified") return value;
|
|
156
|
+
if (value === "shared") return "unified";
|
|
157
|
+
if (value === "separate" || value === "cross") return "directional";
|
|
158
|
+
return "directional";
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export const resolveObservation = (
|
|
162
|
+
mode: ObservationMode,
|
|
163
|
+
explicit: { observeMe?: boolean; observeOthers?: boolean; aiObserveMe?: boolean; aiObserveOthers?: boolean },
|
|
164
|
+
): { observeMe: boolean; observeOthers: boolean; aiObserveMe: boolean; aiObserveOthers: boolean } => {
|
|
165
|
+
const presets: Record<ObservationMode, { observeMe: boolean; observeOthers: boolean; aiObserveMe: boolean; aiObserveOthers: boolean }> = {
|
|
166
|
+
directional: { observeMe: true, observeOthers: true, aiObserveMe: true, aiObserveOthers: true },
|
|
167
|
+
unified: { observeMe: true, observeOthers: false, aiObserveMe: false, aiObserveOthers: true },
|
|
168
|
+
};
|
|
169
|
+
const base = presets[mode];
|
|
170
|
+
return {
|
|
171
|
+
observeMe: explicit.observeMe ?? base.observeMe,
|
|
172
|
+
observeOthers: explicit.observeOthers ?? base.observeOthers,
|
|
173
|
+
aiObserveMe: explicit.aiObserveMe ?? base.aiObserveMe,
|
|
174
|
+
aiObserveOthers: explicit.aiObserveOthers ?? base.aiObserveOthers,
|
|
175
|
+
};
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
let activeRecallMode: RecallMode = "hybrid";
|
|
179
|
+
export const getRecallMode = (): RecallMode => activeRecallMode;
|
|
180
|
+
export const setRecallMode = (mode: RecallMode): void => { activeRecallMode = mode; };
|
|
181
|
+
|
|
182
|
+
export const readConfigFile = async (): Promise<HonchoConfigFile | null> => {
|
|
183
|
+
try {
|
|
184
|
+
const raw = await readFile(CONFIG_PATH, "utf8");
|
|
185
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
186
|
+
return typeof parsed === "object" && parsed !== null ? (parsed as HonchoConfigFile) : null;
|
|
187
|
+
} catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export const resolveConfig = async (): Promise<HonchoConfig> => {
|
|
193
|
+
const file = await readConfigFile();
|
|
194
|
+
const host = file?.hosts?.pi ?? {};
|
|
195
|
+
const apiKey = process.env.HONCHO_API_KEY ?? file?.apiKey;
|
|
196
|
+
const peerName = process.env.HONCHO_PEER_NAME ?? file?.peerName ?? userInfo().username ?? "user";
|
|
197
|
+
const baseURL = process.env.HONCHO_URL ?? host.endpoint ?? file?.baseUrl;
|
|
198
|
+
const workspace = process.env.HONCHO_WORKSPACE_ID ?? host.workspace ?? "pi";
|
|
199
|
+
const aiPeer = process.env.HONCHO_AI_PEER ?? host.aiPeer ?? "pi";
|
|
200
|
+
const linkedHosts = host.linkedHosts ?? [];
|
|
201
|
+
const sessionStrategy = normalizeSessionStrategy(process.env.HONCHO_SESSION_STRATEGY ?? host.sessionStrategy);
|
|
202
|
+
const recallMode = normalizeRecallMode(process.env.HONCHO_RECALL_MODE ?? host.recallMode);
|
|
203
|
+
const contextTokens = toPositiveInt(process.env.HONCHO_CONTEXT_TOKENS ?? host.contextTokens, DEFAULT_CONTEXT_TOKENS);
|
|
204
|
+
const contextRefreshTtlSeconds = toPositiveInt(
|
|
205
|
+
process.env.HONCHO_CONTEXT_REFRESH_TTL_SECONDS ?? host.contextRefreshTtlSeconds,
|
|
206
|
+
DEFAULT_CONTEXT_REFRESH_TTL_SECONDS,
|
|
207
|
+
);
|
|
208
|
+
const maxMessageLength = toPositiveInt(
|
|
209
|
+
process.env.HONCHO_MAX_MESSAGE_LENGTH ?? host.maxMessageLength,
|
|
210
|
+
DEFAULT_MAX_MESSAGE_LENGTH,
|
|
211
|
+
);
|
|
212
|
+
const searchLimit = toPositiveInt(process.env.HONCHO_SEARCH_LIMIT ?? host.searchLimit, DEFAULT_SEARCH_LIMIT);
|
|
213
|
+
const toolPreviewLength = toPositiveInt(process.env.HONCHO_TOOL_PREVIEW_LENGTH ?? host.toolPreviewLength, DEFAULT_TOOL_PREVIEW_LENGTH);
|
|
214
|
+
const reasoningLevel = normalizeReasoningLevel(process.env.HONCHO_REASONING_LEVEL ?? host.reasoningLevel);
|
|
215
|
+
const contextInjectionInterval = toPositiveInt(process.env.HONCHO_CONTEXT_INJECTION_INTERVAL ?? host.contextInjectionInterval, 1);
|
|
216
|
+
|
|
217
|
+
// Observation: preset first, then explicit overrides
|
|
218
|
+
const obsMode = normalizeObservationMode(process.env.HONCHO_OBSERVATION_MODE ?? host.observationMode);
|
|
219
|
+
const explicitObs = {
|
|
220
|
+
observeMe: host.observeMe !== undefined ? toBool(process.env.HONCHO_OBSERVE_ME ?? host.observeMe, true) : undefined,
|
|
221
|
+
observeOthers: host.observeOthers !== undefined ? toBool(process.env.HONCHO_OBSERVE_OTHERS ?? host.observeOthers, true) : undefined,
|
|
222
|
+
aiObserveMe: host.aiObserveMe !== undefined ? toBool(process.env.HONCHO_AI_OBSERVE_ME ?? host.aiObserveMe, true) : undefined,
|
|
223
|
+
aiObserveOthers: host.aiObserveOthers !== undefined ? toBool(process.env.HONCHO_AI_OBSERVE_OTHERS ?? host.aiObserveOthers, true) : undefined,
|
|
224
|
+
};
|
|
225
|
+
const obs = resolveObservation(obsMode, explicitObs);
|
|
226
|
+
|
|
227
|
+
// Phase 1 additions
|
|
228
|
+
const saveMessages = toBool(process.env.HONCHO_SAVE_MESSAGES ?? host.saveMessages, true);
|
|
229
|
+
const writeFrequency = normalizeWriteFrequency(process.env.HONCHO_WRITE_FREQUENCY ?? host.writeFrequency);
|
|
230
|
+
const dialecticDynamic = toBool(process.env.HONCHO_DIALECTIC_DYNAMIC ?? host.dialecticDynamic, true);
|
|
231
|
+
const dialecticMaxChars = toPositiveInt(process.env.HONCHO_DIALECTIC_MAX_CHARS ?? host.dialecticMaxChars, DEFAULT_DIALECTIC_MAX_CHARS);
|
|
232
|
+
const dialecticMaxInputChars = toPositiveInt(process.env.HONCHO_DIALECTIC_MAX_INPUT_CHARS ?? host.dialecticMaxInputChars, DEFAULT_DIALECTIC_MAX_INPUT_CHARS);
|
|
233
|
+
const sessionPeerPrefix = toBool(process.env.HONCHO_SESSION_PEER_PREFIX ?? host.sessionPeerPrefix, false);
|
|
234
|
+
const injectionFrequency = normalizeInjectionFrequency(process.env.HONCHO_INJECTION_FREQUENCY ?? host.injectionFrequency);
|
|
235
|
+
const contextCadence = toPositiveInt(process.env.HONCHO_CONTEXT_CADENCE ?? host.contextCadence, 1);
|
|
236
|
+
const dialecticCadence = toPositiveInt(process.env.HONCHO_DIALECTIC_CADENCE ?? host.dialecticCadence, 1);
|
|
237
|
+
const reasoningLevelCapRaw = process.env.HONCHO_REASONING_LEVEL_CAP ?? host.reasoningLevelCap;
|
|
238
|
+
const reasoningLevelCap = reasoningLevelCapRaw ? normalizeReasoningLevel(reasoningLevelCapRaw) : null;
|
|
239
|
+
const envRaw = process.env.HONCHO_ENVIRONMENT ?? host.environment ?? "production";
|
|
240
|
+
const environment: "local" | "production" = envRaw === "local" ? "local" : "production";
|
|
241
|
+
const logging = toBool(process.env.HONCHO_LOGGING ?? file?.logging, true);
|
|
242
|
+
const sessions = file?.sessions ?? {};
|
|
243
|
+
const contextRefreshMessageThreshold = file?.contextRefresh?.messageThreshold ?? null;
|
|
244
|
+
|
|
245
|
+
const enabled = (process.env.HONCHO_ENABLED ? process.env.HONCHO_ENABLED === "true" : Boolean(apiKey || baseURL));
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
enabled,
|
|
249
|
+
apiKey,
|
|
250
|
+
peerName,
|
|
251
|
+
baseURL,
|
|
252
|
+
workspace,
|
|
253
|
+
aiPeer,
|
|
254
|
+
linkedHosts,
|
|
255
|
+
sessionStrategy,
|
|
256
|
+
recallMode,
|
|
257
|
+
contextTokens,
|
|
258
|
+
contextRefreshTtlSeconds,
|
|
259
|
+
maxMessageLength,
|
|
260
|
+
searchLimit,
|
|
261
|
+
toolPreviewLength,
|
|
262
|
+
...obs,
|
|
263
|
+
observationMode: obsMode,
|
|
264
|
+
reasoningLevel,
|
|
265
|
+
contextInjectionInterval,
|
|
266
|
+
saveMessages,
|
|
267
|
+
writeFrequency,
|
|
268
|
+
dialecticDynamic,
|
|
269
|
+
dialecticMaxChars,
|
|
270
|
+
dialecticMaxInputChars,
|
|
271
|
+
sessionPeerPrefix,
|
|
272
|
+
injectionFrequency,
|
|
273
|
+
contextCadence,
|
|
274
|
+
dialecticCadence,
|
|
275
|
+
reasoningLevelCap,
|
|
276
|
+
environment,
|
|
277
|
+
logging,
|
|
278
|
+
sessions,
|
|
279
|
+
contextRefreshMessageThreshold,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
export const saveConfig = async (input: {
|
|
284
|
+
apiKey?: string;
|
|
285
|
+
peerName?: string;
|
|
286
|
+
workspace?: string;
|
|
287
|
+
aiPeer?: string;
|
|
288
|
+
endpoint?: string;
|
|
289
|
+
linkedHosts?: string[];
|
|
290
|
+
sessionStrategy?: SessionStrategy;
|
|
291
|
+
}): Promise<void> => {
|
|
292
|
+
const current = (await readConfigFile()) ?? {};
|
|
293
|
+
const next: HonchoConfigFile = { ...current };
|
|
294
|
+
if (input.apiKey) next.apiKey = input.apiKey;
|
|
295
|
+
if (input.peerName) next.peerName = input.peerName;
|
|
296
|
+
|
|
297
|
+
const pi: Partial<PiHostConfig> = { ...(current.hosts?.pi ?? {}) };
|
|
298
|
+
if (input.workspace) pi.workspace = input.workspace;
|
|
299
|
+
if (input.aiPeer) pi.aiPeer = input.aiPeer;
|
|
300
|
+
if (input.endpoint) pi.endpoint = input.endpoint;
|
|
301
|
+
if (input.linkedHosts) pi.linkedHosts = input.linkedHosts;
|
|
302
|
+
if (input.sessionStrategy) pi.sessionStrategy = input.sessionStrategy;
|
|
303
|
+
next.hosts = { ...(current.hosts ?? {}), pi };
|
|
304
|
+
|
|
305
|
+
await mkdir(dirname(CONFIG_PATH), { recursive: true, mode: 0o700 });
|
|
306
|
+
await writeFile(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
307
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { HonchoHandles } from "./client.js";
|
|
2
|
+
|
|
3
|
+
interface CachedContext {
|
|
4
|
+
profile: string | null;
|
|
5
|
+
aiProfile: string | null;
|
|
6
|
+
peerCard: string[] | null;
|
|
7
|
+
aiCard: string[] | null;
|
|
8
|
+
summary: string | null;
|
|
9
|
+
refreshedAt: number | null;
|
|
10
|
+
pinned: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const EMPTY: CachedContext = { profile: null, aiProfile: null, peerCard: null, aiCard: null, summary: null, refreshedAt: null, pinned: false };
|
|
14
|
+
let cachedContext: CachedContext = EMPTY;
|
|
15
|
+
let messagesSinceRefresh = 0;
|
|
16
|
+
|
|
17
|
+
const normalize = (value?: string | null): string | null => {
|
|
18
|
+
if (!value) return null;
|
|
19
|
+
const trimmed = value.trim();
|
|
20
|
+
return trimmed ? trimmed : null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const normalizeCard = (card: unknown): string[] | null => {
|
|
24
|
+
if (!Array.isArray(card)) return null;
|
|
25
|
+
const items = card.filter((c): c is string => typeof c === "string" && c.trim().length > 0);
|
|
26
|
+
return items.length > 0 ? items : null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const clearCachedContext = (): void => {
|
|
30
|
+
cachedContext = EMPTY;
|
|
31
|
+
messagesSinceRefresh = 0;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const incrementMessageCount = (count: number): void => {
|
|
35
|
+
messagesSinceRefresh += count;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const refreshCachedContext = async (handles: HonchoHandles): Promise<void> => {
|
|
39
|
+
if (!handles.session) return;
|
|
40
|
+
try {
|
|
41
|
+
const [userCtx, aiCtx] = await Promise.all([
|
|
42
|
+
handles.session.context({
|
|
43
|
+
summary: true,
|
|
44
|
+
peerPerspective: handles.aiPeer,
|
|
45
|
+
peerTarget: handles.userPeer,
|
|
46
|
+
tokens: handles.config.contextTokens,
|
|
47
|
+
}),
|
|
48
|
+
handles.session.context({
|
|
49
|
+
summary: false,
|
|
50
|
+
peerPerspective: handles.userPeer,
|
|
51
|
+
peerTarget: handles.aiPeer,
|
|
52
|
+
tokens: Math.floor(handles.config.contextTokens / 3),
|
|
53
|
+
}),
|
|
54
|
+
]);
|
|
55
|
+
cachedContext = {
|
|
56
|
+
profile: normalize(userCtx.peerRepresentation),
|
|
57
|
+
aiProfile: normalize(aiCtx.peerRepresentation),
|
|
58
|
+
peerCard: normalizeCard(userCtx.peerCard),
|
|
59
|
+
aiCard: normalizeCard(aiCtx.peerCard),
|
|
60
|
+
summary: normalize(userCtx.summary?.content),
|
|
61
|
+
refreshedAt: Date.now(),
|
|
62
|
+
pinned: false,
|
|
63
|
+
};
|
|
64
|
+
messagesSinceRefresh = 0;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error("[honcho-memory] context refresh failed:", error instanceof Error ? error.message : error);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const pinCachedContext = (): void => {
|
|
71
|
+
if (cachedContext.refreshedAt !== null) cachedContext.pinned = true;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export let pendingRefresh: Promise<void> | null = null;
|
|
75
|
+
|
|
76
|
+
export const backgroundRefresh = (handles: HonchoHandles): void => {
|
|
77
|
+
pendingRefresh = refreshCachedContext(handles).finally(() => { pendingRefresh = null; });
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const shouldRefreshCachedContext = (handles: HonchoHandles): boolean => {
|
|
81
|
+
if (cachedContext.pinned) return false;
|
|
82
|
+
if (cachedContext.refreshedAt === null) return true;
|
|
83
|
+
const ttlExpired = (Date.now() - cachedContext.refreshedAt) / 1000 >= handles.config.contextRefreshTtlSeconds;
|
|
84
|
+
const thresholdExceeded = handles.config.contextRefreshMessageThreshold !== null
|
|
85
|
+
&& messagesSinceRefresh >= handles.config.contextRefreshMessageThreshold;
|
|
86
|
+
return ttlExpired || thresholdExceeded;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const truncateToBudget = (text: string, tokens: number): string => {
|
|
90
|
+
const budgetChars = tokens * 4;
|
|
91
|
+
return text.length > budgetChars ? text.slice(0, budgetChars) : text;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const renderCachedContext = (contextTokens: number): string | null => {
|
|
95
|
+
const sections = [
|
|
96
|
+
cachedContext.profile ? `User profile:\n${cachedContext.profile}` : null,
|
|
97
|
+
cachedContext.peerCard?.length ? `User peer card:\n${cachedContext.peerCard.join("\n")}` : null,
|
|
98
|
+
cachedContext.aiProfile ? `AI peer profile:\n${cachedContext.aiProfile}` : null,
|
|
99
|
+
cachedContext.aiCard?.length ? `AI peer card:\n${cachedContext.aiCard.join("\n")}` : null,
|
|
100
|
+
cachedContext.summary ? `Project summary:\n${cachedContext.summary}` : null,
|
|
101
|
+
cachedContext.refreshedAt ? `Context refreshed: ${new Date(cachedContext.refreshedAt).toISOString()}` : null,
|
|
102
|
+
].filter((value): value is string => Boolean(value));
|
|
103
|
+
|
|
104
|
+
if (!sections.length) return null;
|
|
105
|
+
const raw = `[Persistent memory]\n${sections.join("\n\n")}`;
|
|
106
|
+
return truncateToBudget(raw, contextTokens);
|
|
107
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface GitExecResult {
|
|
4
|
+
code: number;
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const execGit = async (cwd: string, args: string[]): Promise<GitExecResult | null> => {
|
|
10
|
+
try {
|
|
11
|
+
return await new Promise<GitExecResult>((resolve) => {
|
|
12
|
+
execFile("git", args, { cwd, encoding: "utf8" }, (error, stdout, stderr) => {
|
|
13
|
+
if (error) {
|
|
14
|
+
const err = error as { code?: number };
|
|
15
|
+
resolve({
|
|
16
|
+
code: typeof err.code === "number" ? err.code : 1,
|
|
17
|
+
stdout: stdout ?? "",
|
|
18
|
+
stderr: stderr ?? "",
|
|
19
|
+
});
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
resolve({ code: 0, stdout, stderr });
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { bootstrap, clearHandles, ensureSession, getHandles, type HonchoHandles } from "./client.js";
|
|
7
|
+
import { registerCommands } from "./commands.js";
|
|
8
|
+
import { getRecallMode, resolveConfig, setRecallMode } from "./config.js";
|
|
9
|
+
import {
|
|
10
|
+
backgroundRefresh,
|
|
11
|
+
clearCachedContext,
|
|
12
|
+
incrementMessageCount,
|
|
13
|
+
pendingRefresh,
|
|
14
|
+
pinCachedContext,
|
|
15
|
+
refreshCachedContext,
|
|
16
|
+
renderCachedContext,
|
|
17
|
+
shouldRefreshCachedContext,
|
|
18
|
+
} from "./context.js";
|
|
19
|
+
import { registerTools } from "./tools.js";
|
|
20
|
+
import { WriteScheduler } from "./upload.js";
|
|
21
|
+
|
|
22
|
+
const setStatus = (ctx: { ui: { setStatus(id: string, text: string): void } }, state: "off" | "connected" | "syncing" | "offline") => {
|
|
23
|
+
const labels: Record<typeof state, string> = {
|
|
24
|
+
off: "🧠 Honcho off",
|
|
25
|
+
connected: "🧠 Honcho connected",
|
|
26
|
+
syncing: "🧠 Honcho syncing",
|
|
27
|
+
offline: "🧠 Honcho offline",
|
|
28
|
+
};
|
|
29
|
+
ctx.ui.setStatus("honcho", labels[state]);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const MIGRATION_DIR = join(homedir(), ".honcho", "migrations");
|
|
33
|
+
const MAX_MIGRATE_CHARS = 4000;
|
|
34
|
+
|
|
35
|
+
const migrateMemoryFiles = async (handles: HonchoHandles, cwd: string): Promise<void> => {
|
|
36
|
+
if (!handles.session) return;
|
|
37
|
+
const markerPath = join(MIGRATION_DIR, createHash("sha256").update(handles.sessionKey).digest("hex").slice(0, 16));
|
|
38
|
+
try {
|
|
39
|
+
await access(markerPath);
|
|
40
|
+
return;
|
|
41
|
+
} catch { /* marker doesn't exist, proceed */ }
|
|
42
|
+
|
|
43
|
+
const filesToMigrate = ["MEMORY.md", "USER.md", "SOUL.md"];
|
|
44
|
+
const conclusions: Array<{ content: string; sessionId: typeof handles.session }> = [];
|
|
45
|
+
|
|
46
|
+
for (const filename of filesToMigrate) {
|
|
47
|
+
try {
|
|
48
|
+
const content = await readFile(join(cwd, filename), "utf8");
|
|
49
|
+
const trimmed = content.trim();
|
|
50
|
+
if (trimmed) {
|
|
51
|
+
conclusions.push({
|
|
52
|
+
content: `[Migrated from ${filename}]\n${trimmed.slice(0, MAX_MIGRATE_CHARS)}`,
|
|
53
|
+
sessionId: handles.session,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
} catch { /* file doesn't exist, skip */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (conclusions.length > 0) {
|
|
60
|
+
await handles.aiPeer.conclusionsOf(handles.userPeer).create(conclusions);
|
|
61
|
+
await mkdir(MIGRATION_DIR, { recursive: true });
|
|
62
|
+
await writeFile(markerPath, `migrated=${new Date().toISOString()}\n`, "utf8");
|
|
63
|
+
if (handles.config.logging) {
|
|
64
|
+
console.log(`[honcho-memory] Migrated ${conclusions.length} memory file(s) to Honcho.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export default function honchoMemory(pi: ExtensionAPI): void {
|
|
70
|
+
let initializing: Promise<void> | null = null;
|
|
71
|
+
let turnCount = 0;
|
|
72
|
+
let lastContextTurn = 0;
|
|
73
|
+
let scheduler: WriteScheduler | null = null;
|
|
74
|
+
|
|
75
|
+
registerTools(pi);
|
|
76
|
+
registerCommands(pi);
|
|
77
|
+
|
|
78
|
+
const initialize = (ctx: { cwd: string; ui: { setStatus(id: string, text: string): void } }) => {
|
|
79
|
+
initializing = (async () => {
|
|
80
|
+
try {
|
|
81
|
+
clearHandles();
|
|
82
|
+
clearCachedContext();
|
|
83
|
+
turnCount = 0;
|
|
84
|
+
lastContextTurn = 0;
|
|
85
|
+
scheduler?.reset();
|
|
86
|
+
scheduler = null;
|
|
87
|
+
|
|
88
|
+
const config = await resolveConfig();
|
|
89
|
+
if (!config.enabled || !config.apiKey) {
|
|
90
|
+
setStatus(ctx, "off");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
setRecallMode(config.recallMode);
|
|
94
|
+
scheduler = new WriteScheduler(config.writeFrequency);
|
|
95
|
+
|
|
96
|
+
const handles = await bootstrap(config, ctx.cwd);
|
|
97
|
+
pi.setSessionName(handles.sessionKey);
|
|
98
|
+
|
|
99
|
+
if (handles.session) {
|
|
100
|
+
await refreshCachedContext(handles);
|
|
101
|
+
if (config.injectionFrequency === "first-turn") {
|
|
102
|
+
pinCachedContext();
|
|
103
|
+
}
|
|
104
|
+
migrateMemoryFiles(handles, ctx.cwd).catch((error) => {
|
|
105
|
+
if (config.logging) {
|
|
106
|
+
console.error("[honcho-memory] migration failed:", error instanceof Error ? error.message : error);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
setStatus(ctx, "connected");
|
|
112
|
+
} catch (error) {
|
|
113
|
+
console.error("[honcho-memory] initialization failed:", error instanceof Error ? error.message : error);
|
|
114
|
+
setStatus(ctx, "offline");
|
|
115
|
+
} finally {
|
|
116
|
+
initializing = null;
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
122
|
+
initialize(ctx);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
pi.on("before_agent_start", async (event) => {
|
|
126
|
+
if (initializing) await initializing;
|
|
127
|
+
const recallMode = getRecallMode();
|
|
128
|
+
if (recallMode === "tools") return;
|
|
129
|
+
const handles = getHandles();
|
|
130
|
+
if (!handles || !handles.session) return;
|
|
131
|
+
|
|
132
|
+
turnCount++;
|
|
133
|
+
|
|
134
|
+
// Injection frequency: "first-turn" only injects on turn 1
|
|
135
|
+
if (handles.config.injectionFrequency === "first-turn" && turnCount > 1) return;
|
|
136
|
+
|
|
137
|
+
// Context cadence: respect minimum turns between context API calls
|
|
138
|
+
const contextCadence = handles.config.contextCadence;
|
|
139
|
+
const shouldRefresh = (turnCount - lastContextTurn) >= contextCadence
|
|
140
|
+
&& shouldRefreshCachedContext(handles);
|
|
141
|
+
|
|
142
|
+
if (pendingRefresh) await pendingRefresh;
|
|
143
|
+
|
|
144
|
+
if (shouldRefresh) {
|
|
145
|
+
backgroundRefresh(handles);
|
|
146
|
+
lastContextTurn = turnCount;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const memory = renderCachedContext(handles.config.contextTokens);
|
|
150
|
+
if (!memory) return;
|
|
151
|
+
const toolHint = recallMode === "hybrid"
|
|
152
|
+
? "\n\nUse honcho_search / honcho_context when durable context may matter. Use honcho_conclude for stable preferences, decisions, and long-lived project facts."
|
|
153
|
+
: "";
|
|
154
|
+
return {
|
|
155
|
+
systemPrompt: `${event.systemPrompt}\n\n${memory}${toolHint}`,
|
|
156
|
+
};
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
160
|
+
const handles = getHandles();
|
|
161
|
+
if (!handles) return;
|
|
162
|
+
if (!handles.config.saveMessages) return;
|
|
163
|
+
|
|
164
|
+
// Ensure session exists for upload (lazy init case)
|
|
165
|
+
try {
|
|
166
|
+
await ensureSession(handles);
|
|
167
|
+
} catch {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
incrementMessageCount(event.messages.length);
|
|
172
|
+
setStatus(ctx, "syncing");
|
|
173
|
+
try {
|
|
174
|
+
await scheduler?.onTurnEnd(handles, event.messages);
|
|
175
|
+
setStatus(ctx, "connected");
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (handles.config.logging) {
|
|
178
|
+
console.error("[honcho-memory] upload failed:", error instanceof Error ? error.message : error);
|
|
179
|
+
}
|
|
180
|
+
setStatus(ctx, "offline");
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const flush = async () => { await scheduler?.flush(); };
|
|
185
|
+
|
|
186
|
+
pi.on("session_shutdown", flush);
|
|
187
|
+
pi.on("session_before_switch", flush);
|
|
188
|
+
pi.on("session_before_fork", flush);
|
|
189
|
+
pi.on("session_before_compact", flush);
|
|
190
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { HonchoConfig, SessionStrategy } from "./config.js";
|
|
3
|
+
import { execGit } from "./git.js";
|
|
4
|
+
|
|
5
|
+
const hash = (value: string): string => createHash("sha256").update(value).digest("hex").slice(0, 8);
|
|
6
|
+
const sanitize = (value: string): string => value.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
7
|
+
|
|
8
|
+
const repoBase = async (cwd: string): Promise<string | null> => {
|
|
9
|
+
const remote = await execGit(cwd, ["remote", "get-url", "origin"]);
|
|
10
|
+
if (remote?.code === 0 && remote.stdout.trim()) {
|
|
11
|
+
const url = remote.stdout.trim().replace(/\.git$/, "").replace(/^(https?:\/\/)[^@]+@/, "$1");
|
|
12
|
+
return sanitize(url);
|
|
13
|
+
}
|
|
14
|
+
const root = await execGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
15
|
+
if (root?.code === 0 && root.stdout.trim()) {
|
|
16
|
+
const normalized = root.stdout.trim();
|
|
17
|
+
return sanitize(`${normalized.split("/").pop() ?? "repo"}_${hash(normalized)}`);
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const branchName = async (cwd: string): Promise<string | null> => {
|
|
23
|
+
const result = await execGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
24
|
+
const name = result?.code === 0 ? result.stdout.trim() : null;
|
|
25
|
+
if (name && name !== "HEAD") return sanitize(name);
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const directoryKey = (cwd: string): string =>
|
|
30
|
+
sanitize(`dir_${cwd.split("/").pop() ?? "project"}_${hash(cwd)}`);
|
|
31
|
+
|
|
32
|
+
export const deriveSessionKey = async (cwd: string, strategy: SessionStrategy, config: HonchoConfig): Promise<string> => {
|
|
33
|
+
// Priority 1: explicit mapping from config.sessions
|
|
34
|
+
const manual = config.sessions[cwd];
|
|
35
|
+
if (manual) {
|
|
36
|
+
const key = sanitize(manual);
|
|
37
|
+
return config.sessionPeerPrefix ? sanitize(`${config.peerName}_${key}`) : key;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Priority 2: algorithmic derivation
|
|
41
|
+
let key: string;
|
|
42
|
+
if (strategy === "global") key = "global";
|
|
43
|
+
else if (strategy === "pi-session") key = sanitize(`pi_${hash(cwd)}_${Date.now().toString(36)}`);
|
|
44
|
+
else if (strategy === "per-directory") key = directoryKey(cwd);
|
|
45
|
+
else if (strategy === "per-repo") key = (await repoBase(cwd)) ?? directoryKey(cwd);
|
|
46
|
+
else {
|
|
47
|
+
const repo = (await repoBase(cwd)) ?? directoryKey(cwd);
|
|
48
|
+
const branch = await branchName(cwd);
|
|
49
|
+
key = branch ? `${repo}__branch_${branch}` : repo;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return config.sessionPeerPrefix ? sanitize(`${config.peerName}_${key}`) : key;
|
|
53
|
+
};
|